refactor: renamed grammar module to dialect with unified transcript rendering
- Renamed ToolCallSyntax type to Dialect and Grammar interface to DialectDefinition across all packages. - Moved grammar directory to dialect and updated all import paths in agent, ai, catalog, and coding-agent packages. - Added renderTranscript and renderThinking methods to DialectDefinition, enabling native dialect-aware conversation serialization. - Consolidated rendering utilities into new dialect/rendering.ts with shared helpers for ChatML, legacy text, and dialect-specific formatting. - Updated conversation serialization in agent and coding-agent to use dialect.renderTranscript() for native turn envelope rendering.
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed owned tool-calling options from `toolCallSyntax`/`exampleSyntax` to `dialect`/`exampleDialect`.
|
||||
- Changed compaction conversation serialization to use the target model's native dialect turn, thinking, tool-call, and tool-result envelopes when a dialect is selected.
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed `toolCallSyntax` option to `dialect` in AgentOptions and AgentLoopConfig
|
||||
- Updated conversation serialization to use dialect's native transcript rendering when a dialect is selected
|
||||
- Changed internal references from `ToolCallSyntax` type to `Dialect` type across agent loop and compaction modules
|
||||
|
||||
## [15.13.3] - 2026-06-15
|
||||
|
||||
@@ -814,4 +824,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
|
||||
### Changed
|
||||
|
||||
- `Agent` constructor now has all options optional (empty options use defaults).
|
||||
- `queueMessage()` is now synchronous (no longer returns a Promise).
|
||||
- `queueMessage()` is now synchronous (no longer returns a Promise).
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
zodToWireSchema,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
type Dialect,
|
||||
encodeInbandToolHistory,
|
||||
renderInbandToolPrompt,
|
||||
renderToolExamples,
|
||||
type ToolCallSyntax,
|
||||
wrapInbandToolStream,
|
||||
} from "@oh-my-pi/pi-ai/grammar";
|
||||
} from "@oh-my-pi/pi-ai/dialect";
|
||||
import {
|
||||
createHarmonyAuditEvent,
|
||||
detectHarmonyLeakInAssistantMessage,
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
recoverHarmonyToolCall,
|
||||
signalListLabel,
|
||||
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
||||
import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
||||
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
||||
import {
|
||||
@@ -92,7 +92,7 @@ class HarmonyLeakInterruption extends Error {
|
||||
this.name = "HarmonyLeakInterruption";
|
||||
}
|
||||
}
|
||||
function resolveOwnedToolSyntaxFromEnv(value: string | undefined): ToolCallSyntax | undefined {
|
||||
function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
|
||||
switch (value) {
|
||||
case "1":
|
||||
case "true":
|
||||
@@ -531,7 +531,7 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
|
||||
export function normalizeTools(
|
||||
tools: AgentContext["tools"],
|
||||
injectIntent: boolean,
|
||||
exampleSyntax?: ToolCallSyntax,
|
||||
exampleDialect?: Dialect,
|
||||
): Context["tools"] {
|
||||
injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
|
||||
return tools?.map(t => {
|
||||
@@ -547,8 +547,8 @@ export function normalizeTools(
|
||||
}
|
||||
const description = t.description ?? "";
|
||||
const injectExampleIntent = injectIntent && intentMode !== "omit";
|
||||
const examplesBlock = exampleSyntax
|
||||
? renderToolExamples({ ...t, parameters }, exampleSyntax, injectExampleIntent ? INTENT_FIELD : undefined)
|
||||
const examplesBlock = exampleDialect
|
||||
? renderToolExamples({ ...t, parameters }, exampleDialect, injectExampleIntent ? INTENT_FIELD : undefined)
|
||||
: "";
|
||||
const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
|
||||
return { ...t, parameters, description: finalDescription };
|
||||
@@ -925,6 +925,8 @@ async function streamAssistantResponse(
|
||||
const llmMessages = await config.convertToLlm(messages);
|
||||
const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
|
||||
|
||||
const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_OWNED_TOOLS);
|
||||
const exampleDialect = ownedDialect ?? preferredDialect(config.model.id);
|
||||
// Build LLM context — append-only mode caches system prompt + tools
|
||||
// AND keeps an append-only message log so prior-turn bytes are stable.
|
||||
let llmContext: Context;
|
||||
@@ -932,13 +934,13 @@ async function streamAssistantResponse(
|
||||
config.appendOnlyContext.syncMessages(normalizedMessages);
|
||||
llmContext = config.appendOnlyContext.build(context, {
|
||||
intentTracing: !!config.intentTracing,
|
||||
exampleSyntax: preferredToolSyntax(config.model.id),
|
||||
exampleDialect,
|
||||
});
|
||||
} else {
|
||||
llmContext = {
|
||||
systemPrompt: context.systemPrompt,
|
||||
messages: normalizedMessages,
|
||||
tools: normalizeTools(context.tools, !!config.intentTracing, preferredToolSyntax(config.model.id)),
|
||||
tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect),
|
||||
};
|
||||
}
|
||||
if (config.transformProviderContext) {
|
||||
@@ -946,17 +948,15 @@ async function streamAssistantResponse(
|
||||
}
|
||||
|
||||
// Owned tool calling: take tool calls away from the provider and run them
|
||||
// through the selected in-band prompt syntax. `PI_OWNED_TOOLS=1` still
|
||||
// force-enables GLM; `PI_OWNED_TOOLS=<syntax>` force-enables that syntax.
|
||||
const ownedSyntax: ToolCallSyntax | undefined =
|
||||
config.toolCallSyntax ?? resolveOwnedToolSyntaxFromEnv(Bun.env.PI_OWNED_TOOLS);
|
||||
// through the selected in-band prompt dialect. `PI_OWNED_TOOLS=1` still
|
||||
// force-enables GLM; `PI_OWNED_TOOLS=<dialect>` force-enables that dialect.
|
||||
let promptToolWireTools: Context["tools"];
|
||||
if (ownedSyntax && llmContext.tools && llmContext.tools.length > 0) {
|
||||
if (ownedDialect && llmContext.tools && llmContext.tools.length > 0) {
|
||||
promptToolWireTools = llmContext.tools;
|
||||
llmContext = {
|
||||
...llmContext,
|
||||
systemPrompt: [...(llmContext.systemPrompt ?? []), renderInbandToolPrompt(promptToolWireTools, ownedSyntax)],
|
||||
messages: encodeInbandToolHistory(llmContext.messages, ownedSyntax, promptToolWireTools),
|
||||
systemPrompt: [...(llmContext.systemPrompt ?? []), renderInbandToolPrompt(promptToolWireTools, ownedDialect)],
|
||||
messages: encodeInbandToolHistory(llmContext.messages, ownedDialect, promptToolWireTools),
|
||||
tools: undefined,
|
||||
};
|
||||
}
|
||||
@@ -990,7 +990,7 @@ async function streamAssistantResponse(
|
||||
// the hallucinated turn. Merged into the provider signal ONLY (not
|
||||
// `requestSignal`), so it cancels the request without tripping the loop's
|
||||
// external-abort handling (`abortRacePromise` / `requestSignal.aborted`).
|
||||
const promptToolAbortController = ownedSyntax ? new AbortController() : undefined;
|
||||
const promptToolAbortController = ownedDialect ? new AbortController() : undefined;
|
||||
const providerAbortSignals: AbortSignal[] = [];
|
||||
if (requestSignal) providerAbortSignals.push(requestSignal);
|
||||
providerAbortSignals.push(repetitionAbortController.signal);
|
||||
@@ -1000,7 +1000,7 @@ async function streamAssistantResponse(
|
||||
const effectiveTemperature =
|
||||
harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
|
||||
// Owned tool calling sends no native tools, so any tool_choice would error.
|
||||
const effectiveToolChoice = ownedSyntax ? undefined : (dynamicToolChoice ?? config.toolChoice);
|
||||
const effectiveToolChoice = ownedDialect ? undefined : (dynamicToolChoice ?? config.toolChoice);
|
||||
const effectiveReasoning = dynamicReasoning ?? config.reasoning;
|
||||
const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
|
||||
|
||||
@@ -1068,7 +1068,7 @@ async function streamAssistantResponse(
|
||||
signal: finalRequestSignal,
|
||||
onResponse: captureOnResponse,
|
||||
});
|
||||
if (promptToolWireTools && ownedSyntax) {
|
||||
if (promptToolWireTools && ownedDialect) {
|
||||
// Re-materialize in-band tool-call text as native toolCall content blocks
|
||||
// so the rest of the loop executes them unchanged. When the model starts
|
||||
// fabricating tool results, the abort callback cancels the provider — unless
|
||||
@@ -1077,7 +1077,7 @@ async function streamAssistantResponse(
|
||||
response = wrapInbandToolStream(
|
||||
response,
|
||||
promptToolWireTools,
|
||||
ownedSyntax,
|
||||
ownedDialect,
|
||||
() => promptToolAbortController?.abort(),
|
||||
config.abortOnFabricatedToolResult ?? true,
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type ToolChoice,
|
||||
type ToolResultMessage,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
|
||||
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
||||
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
||||
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
@@ -221,8 +221,8 @@ export interface AgentOptions {
|
||||
|
||||
/** Enable intent tracing schema injection/stripping in the harness. */
|
||||
intentTracing?: boolean;
|
||||
/** Owned tool-calling syntax. Undefined keeps provider-native tool calling. */
|
||||
toolCallSyntax?: ToolCallSyntax;
|
||||
/** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
|
||||
dialect?: Dialect;
|
||||
/**
|
||||
* When owned tool calling is active and the model fabricates a tool result
|
||||
* mid-turn: `true` (default) aborts the provider request immediately; `false`
|
||||
@@ -326,7 +326,7 @@ export class Agent {
|
||||
#preferWebsockets?: boolean;
|
||||
#transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
|
||||
#intentTracing: boolean;
|
||||
#toolCallSyntax?: ToolCallSyntax;
|
||||
#dialect?: Dialect;
|
||||
#abortOnFabricatedToolResult?: boolean;
|
||||
#getToolChoice?: () => ToolChoice | undefined;
|
||||
#onPayload?: SimpleStreamOptions["onPayload"];
|
||||
@@ -390,7 +390,7 @@ export class Agent {
|
||||
this.#preferWebsockets = opts.preferWebsockets;
|
||||
this.#transformToolCallArguments = opts.transformToolCallArguments;
|
||||
this.#intentTracing = opts.intentTracing === true;
|
||||
this.#toolCallSyntax = opts.toolCallSyntax;
|
||||
this.#dialect = opts.dialect;
|
||||
this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
|
||||
this.#getToolChoice = opts.getToolChoice;
|
||||
this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
|
||||
@@ -1037,7 +1037,7 @@ export class Agent {
|
||||
cursorOnToolResult,
|
||||
transformToolCallArguments: this.#transformToolCallArguments,
|
||||
intentTracing: this.#intentTracing,
|
||||
toolCallSyntax: this.#toolCallSyntax,
|
||||
dialect: this.#dialect,
|
||||
abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
|
||||
appendOnlyContext: this.#appendOnlyContext,
|
||||
beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
|
||||
import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
|
||||
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
||||
import { normalizeTools } from "./agent-loop";
|
||||
import type { AgentContext } from "./types";
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface StablePrefixSnapshot {
|
||||
export interface BuildOptions {
|
||||
/** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
|
||||
intentTracing: boolean;
|
||||
exampleSyntax?: ToolCallSyntax;
|
||||
exampleDialect?: Dialect;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,7 +270,7 @@ export class AppendOnlyContextManager {
|
||||
|
||||
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
|
||||
const systemPrompt = [...context.systemPrompt];
|
||||
const tools = normalizeTools(context.tools, options.intentTracing, options.exampleSyntax) ?? [];
|
||||
const tools = normalizeTools(context.tools, options.intentTracing, options.exampleDialect) ?? [];
|
||||
return {
|
||||
systemPrompt,
|
||||
tools,
|
||||
@@ -290,7 +290,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
|
||||
cw: t.customWireName,
|
||||
})),
|
||||
i: options.intentTracing,
|
||||
ex: options.exampleSyntax,
|
||||
ex: options.exampleDialect,
|
||||
});
|
||||
let hash = 0;
|
||||
for (let i = 0; i < payload.length; i++) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { ApiKey, Model } from "@oh-my-pi/pi-ai";
|
||||
import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { prompt } from "@oh-my-pi/pi-utils";
|
||||
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
||||
import type { AgentMessage } from "../types";
|
||||
@@ -291,7 +291,7 @@ export async function generateBranchSummary(
|
||||
// Transform to LLM-compatible messages, then serialize to text
|
||||
// Serialization prevents the model from treating it as a conversation to continue
|
||||
const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
|
||||
const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
|
||||
const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
|
||||
|
||||
// Build prompt
|
||||
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type Usage,
|
||||
withAuth,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
||||
import { countTokens } from "@oh-my-pi/pi-natives";
|
||||
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
@@ -643,7 +643,7 @@ export async function generateSummary(
|
||||
// Serialize conversation to text so model doesn't try to continue it
|
||||
// Convert to LLM messages first (handles custom app messages when caller provides a transformer).
|
||||
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
|
||||
const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
|
||||
const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
|
||||
|
||||
// Build the prompt with conversation wrapped in tags
|
||||
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
||||
@@ -791,7 +791,7 @@ async function generateShortSummary(
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
|
||||
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
|
||||
const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
|
||||
const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
|
||||
|
||||
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
||||
if (historySummary) {
|
||||
@@ -1156,7 +1156,7 @@ async function generateTurnPrefixSummary(
|
||||
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
||||
|
||||
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
||||
const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
|
||||
const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
||||
const summarizationMessages = [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Message, ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { type Grammar, type GrammarToolResult, getInbandGrammar, type ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
|
||||
import { type Dialect, getDialectDefinition } from "@oh-my-pi/pi-ai/dialect";
|
||||
import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
|
||||
import type { AgentMessage } from "../types";
|
||||
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
||||
@@ -189,10 +189,7 @@ function truncateForSummary(text: string, maxChars: number): string {
|
||||
* This prevents the model from treating it as a conversation to continue.
|
||||
* Call convertToLlm() first to handle custom message types.
|
||||
*/
|
||||
export function serializeConversation(messages: Message[], syntax?: ToolCallSyntax): string {
|
||||
const grammar = syntax ? getInbandGrammar(syntax) : undefined;
|
||||
const parts: string[] = [];
|
||||
|
||||
export function serializeConversation(messages: Message[], dialect?: Dialect): string {
|
||||
// Tool results flagged contextually useless (and their paired calls) are
|
||||
// dropped from the serialized text: the source region is discarded after
|
||||
// summarization anyway, so excluding them costs nothing and keeps garbage
|
||||
@@ -203,7 +200,33 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
|
||||
uselessCallIds.add(msg.toolCallId);
|
||||
}
|
||||
}
|
||||
if (dialect) {
|
||||
const processed: Message[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "assistant") {
|
||||
const content = msg.content.filter(block => block.type !== "toolCall" || !uselessCallIds.has(block.id));
|
||||
if (content.length > 0) processed.push(content.length === msg.content.length ? msg : { ...msg, content });
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "toolResult") {
|
||||
if (uselessCallIds.has(msg.toolCallId)) continue;
|
||||
const text = msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map(c => c.text)
|
||||
.join("");
|
||||
if (!text) continue;
|
||||
processed.push({
|
||||
...msg,
|
||||
content: [{ type: "text", text: truncateForSummary(text, TOOL_RESULT_MAX_CHARS) }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
processed.push(msg);
|
||||
}
|
||||
return getDialectDefinition(dialect).renderTranscript(processed);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const content =
|
||||
@@ -237,7 +260,7 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
|
||||
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
parts.push(`[Tool Call]: ${renderToolCalls(toolCalls, grammar)}`);
|
||||
parts.push(`[Tool Call]: ${renderToolCalls(toolCalls)}`);
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
if (uselessCallIds.has(msg.toolCallId)) continue;
|
||||
@@ -247,9 +270,7 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
|
||||
.join("");
|
||||
if (content) {
|
||||
const text = truncateForSummary(content, TOOL_RESULT_MAX_CHARS);
|
||||
parts.push(
|
||||
`[Tool Result]: ${renderToolResult(msg.toolCallId, msg.toolName, msg.isError === true, text, grammar)}`,
|
||||
);
|
||||
parts.push(`[Tool Result]: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,11 +279,10 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an assistant turn's tool calls. With a grammar, emit the model's
|
||||
* native invocation block; otherwise fall back to a compact `name(args)` list.
|
||||
* Render an assistant turn's tool calls as a compact `name(args)` list for the
|
||||
* legacy serializer.
|
||||
*/
|
||||
function renderToolCalls(calls: ToolCall[], grammar: Grammar | undefined): string {
|
||||
if (grammar) return grammar.renderAssistantToolCalls(calls);
|
||||
function renderToolCalls(calls: ToolCall[]): string {
|
||||
return calls
|
||||
.map(call => {
|
||||
const argsStr = Object.entries(call.arguments as Record<string, unknown>)
|
||||
@@ -273,22 +293,6 @@ function renderToolCalls(calls: ToolCall[], grammar: Grammar | undefined): strin
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single tool result. With a grammar, emit the model's native
|
||||
* tool-result envelope; otherwise return the (already truncated) text verbatim.
|
||||
*/
|
||||
function renderToolResult(
|
||||
id: string,
|
||||
name: string,
|
||||
isError: boolean,
|
||||
text: string,
|
||||
grammar: Grammar | undefined,
|
||||
): string {
|
||||
if (!grammar) return text;
|
||||
const result: GrammarToolResult = { id, name, index: 0, text, isError };
|
||||
return grammar.renderToolResults([result]);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Summarization System Prompt
|
||||
// ============================================================================
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ToolResultMessage,
|
||||
TSchema,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
|
||||
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
||||
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
||||
import type { AppendOnlyContextManager } from "./append-only-context";
|
||||
import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
|
||||
@@ -201,14 +201,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
*/
|
||||
intentTracing?: boolean;
|
||||
/**
|
||||
* Owned tool calling syntax.
|
||||
* Owned tool calling dialect.
|
||||
*
|
||||
* Undefined keeps provider-native tool calling. A syntax value sends no
|
||||
* native `tools`, forces `toolChoice` off, appends that syntax's tool catalog
|
||||
* Undefined keeps provider-native tool calling. A dialect value sends no
|
||||
* native `tools`, forces `toolChoice` off, appends that dialect's tool catalog
|
||||
* instructions, re-encodes prior tool calls/results as text, and parses the
|
||||
* model's text output back into canonical `toolCall` blocks.
|
||||
*/
|
||||
toolCallSyntax?: ToolCallSyntax;
|
||||
dialect?: Dialect;
|
||||
/**
|
||||
* When owned (in-band) tool calling is active and the model starts
|
||||
* fabricating a tool result inside its own turn, control how the loop reacts:
|
||||
@@ -217,8 +217,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
* - `false`: let the request finish and silently discard everything past the
|
||||
* fabrication boundary (keeps the connection alive but pays for the tokens
|
||||
* the model spends on the discarded tail).
|
||||
* Only meaningful when {@link toolCallSyntax} (or `PI_OWNED_TOOLS`) selects an
|
||||
* owned syntax; native tool calling never fabricates results in text.
|
||||
* Only meaningful when {@link dialect} (or `PI_OWNED_TOOLS`) selects an
|
||||
* owned dialect; native tool calling never fabricates results in text.
|
||||
*/
|
||||
abortOnFabricatedToolResult?: boolean;
|
||||
/**
|
||||
|
||||
@@ -642,19 +642,19 @@ describe("tool examples injection through build()", () => {
|
||||
properties: { paths: { type: "array", items: { type: "string" } } },
|
||||
};
|
||||
|
||||
it("injects examples when exampleSyntax is provided", () => {
|
||||
it("injects examples when exampleDialect is provided", () => {
|
||||
const mgr = new AppendOnlyContextManager();
|
||||
const tool = makeTool("find", "Find files.", findParams, findExamples);
|
||||
const ctx = makeContext({ tools: [tool] });
|
||||
|
||||
const result = mgr.build(ctx, { intentTracing: false, exampleSyntax: "anthropic" });
|
||||
const result = mgr.build(ctx, { intentTracing: false, exampleDialect: "anthropic" });
|
||||
const desc = result.tools?.[0]?.description ?? "";
|
||||
expect(desc).toContain("<examples>");
|
||||
expect(desc).toContain("# Find files");
|
||||
expect(desc).toContain('<invoke name="find">');
|
||||
});
|
||||
|
||||
it("omits examples when exampleSyntax is undefined", () => {
|
||||
it("omits examples when exampleDialect is undefined", () => {
|
||||
const mgr = new AppendOnlyContextManager();
|
||||
const tool = makeTool("find", "Find files.", findParams, findExamples);
|
||||
const ctx = makeContext({ tools: [tool] });
|
||||
@@ -669,13 +669,13 @@ describe("tool examples injection through build()", () => {
|
||||
const tool = makeTool("find", "Find files.", findParams, findExamples);
|
||||
const ctx = makeContext({ tools: [tool] });
|
||||
|
||||
const result = mgr.build(ctx, { intentTracing: true, exampleSyntax: "anthropic" });
|
||||
const result = mgr.build(ctx, { intentTracing: true, exampleDialect: "anthropic" });
|
||||
const desc = result.tools?.[0]?.description ?? "";
|
||||
expect(desc).toContain('<parameter name="_i"');
|
||||
expect(desc).toContain("…");
|
||||
});
|
||||
|
||||
it("exampleSyntax flip invalidates the fingerprint cache", () => {
|
||||
it("exampleDialect flip invalidates the fingerprint cache", () => {
|
||||
const mgr = new AppendOnlyContextManager();
|
||||
const tool = makeTool("find", "Find files.", undefined, findExamples);
|
||||
const ctx = makeContext({ tools: [tool] });
|
||||
@@ -683,7 +683,7 @@ describe("tool examples injection through build()", () => {
|
||||
mgr.build(ctx, { intentTracing: false });
|
||||
const fpNoExamples = mgr.prefix.fingerprint;
|
||||
|
||||
mgr.build(ctx, { intentTracing: false, exampleSyntax: "anthropic" });
|
||||
mgr.build(ctx, { intentTracing: false, exampleDialect: "anthropic" });
|
||||
const fpWithExamples = mgr.prefix.fingerprint;
|
||||
|
||||
expect(fpNoExamples).not.toBe(fpWithExamples);
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("agentLoop with owned in-band tool calls", () => {
|
||||
});
|
||||
|
||||
const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, toolCallSyntax: "glm" };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, dialect: "glm" };
|
||||
|
||||
const messages = await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result();
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("agentLoop with owned in-band tool calls", () => {
|
||||
expect(wireText(internalResult!)).toBe("echoed:hello world");
|
||||
});
|
||||
|
||||
it("executes Hermes/Qwen JSON tool calls when that syntax is selected", async () => {
|
||||
it("executes Hermes/Qwen JSON tool calls when that dialect is selected", async () => {
|
||||
const echoArgs: Array<{ msg: string }> = [];
|
||||
const toolSchema = z.object({ msg: z.string().describe("message to echo") });
|
||||
const echoTool: AgentTool<typeof toolSchema, { msg: string }> = {
|
||||
@@ -131,7 +131,7 @@ describe("agentLoop with owned in-band tool calls", () => {
|
||||
});
|
||||
|
||||
const context: AgentContext = { systemPrompt: ["BASE PROMPT"], messages: [], tools: [echoTool] };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, toolCallSyntax: "hermes" };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter, dialect: "hermes" };
|
||||
|
||||
await agentLoop([createUserMessage("say hi")], context, config, undefined, mock.stream).result();
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("serializeConversation — useless pairs", () => {
|
||||
toolResultMessage("c-drop", "No matches found", { useless: true }),
|
||||
]);
|
||||
|
||||
expect(out).toContain('pattern="alpha"');
|
||||
expect(out).toContain('search(pattern="alpha")');
|
||||
expect(out).toContain("alpha match found in src/alpha.ts");
|
||||
expect(out).not.toContain("zzz_nothing");
|
||||
expect(out).not.toContain("No matches found");
|
||||
@@ -59,7 +59,40 @@ describe("serializeConversation — useless pairs", () => {
|
||||
toolResultMessage("c-err", "grep crashed", { useless: true, isError: true }),
|
||||
]);
|
||||
|
||||
expect(out).toContain('pattern="beta"');
|
||||
expect(out).toContain('search(pattern="beta")');
|
||||
expect(out).toContain("[Tool Result]: grep crashed");
|
||||
});
|
||||
|
||||
test("renders native dialect transcripts when a dialect is provided", () => {
|
||||
const out = serializeConversation(
|
||||
[
|
||||
assistantMessage([
|
||||
{ type: "text", text: "Searching." },
|
||||
{ type: "toolCall", id: "c-native", name: "search", arguments: { pattern: "gamma" } },
|
||||
]),
|
||||
toolResultMessage("c-native", "gamma match found"),
|
||||
],
|
||||
"anthropic",
|
||||
);
|
||||
|
||||
expect(out).toContain("\n\nAssistant:");
|
||||
expect(out).toContain("<function_calls>");
|
||||
expect(out).toContain("<function_results>");
|
||||
expect(out).not.toContain("[Tool Call]:");
|
||||
expect(out).not.toContain("[Assistant tool calls]:");
|
||||
});
|
||||
|
||||
test("native dialect serialization drops empty assistants left by useless calls", () => {
|
||||
const out = serializeConversation(
|
||||
[
|
||||
assistantMessage([
|
||||
{ type: "toolCall", id: "c-drop", name: "search", arguments: { pattern: "zzz_nothing" } },
|
||||
]),
|
||||
toolResultMessage("c-drop", "No matches found", { useless: true }),
|
||||
],
|
||||
"harmony",
|
||||
);
|
||||
|
||||
expect(out).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect`.
|
||||
- Renamed grammar dialect identifiers from `ToolCallSyntax` to `Dialect`, renamed the `Grammar` interface to `DialectDefinition`, and renamed `Grammar.syntax` to `DialectDefinition.dialect`.
|
||||
- Added `DialectDefinition.renderThinking` and `DialectDefinition.renderTranscript` so dialect implementations serialize complete native chat transcripts, not just tool call/result blocks.
|
||||
|
||||
### Added
|
||||
|
||||
- Added `renderTranscript` method to dialect definitions for serializing complete native chat transcripts
|
||||
- Added `renderThinking` method to dialect definitions for rendering thinking/reasoning blocks
|
||||
- Added support for 11 dialect implementations: Anthropic, DeepSeek, Gemini, Gemma, GLM, Harmony, Hermes, Kimi, Pi-native, Qwen3, and XML
|
||||
- Added `createInbandScanner` factory function to instantiate dialect-specific scanners
|
||||
- Added `getDialectDefinition` function to retrieve dialect implementations by name
|
||||
- Added `renderToolCatalog` and `renderInbandToolPrompt` functions for tool catalog rendering
|
||||
- Added `renderToolInventory` function to generate human-readable per-tool documentation with examples
|
||||
- Added `renderToolExamples` function to render tool usage examples in the model's native dialect
|
||||
- Added `encodeInbandToolHistory` function to encode tool call history in dialect-specific format
|
||||
- Added `wrapInbandToolStream` function to process streaming responses with in-band tool call parsing
|
||||
- Added `ThinkingInbandScanner` for parsing thinking/reasoning blocks across dialects
|
||||
- Added `OwnedStream` class for managing dialect-aware streaming with tool call events
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved public dialect entrypoint from `@oh-my-pi/pi-ai/grammar` to `@oh-my-pi/pi-ai/dialect` in package exports
|
||||
- Updated internal imports in `stream-markup-healing.ts` to use new dialect module path
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `src/grammar/factory.ts` (replaced by `src/dialect/factory.ts`)
|
||||
- Removed `src/grammar/rendering.ts` (functionality moved to `src/dialect/rendering.ts`)
|
||||
- Removed `src/grammar/xml.ts` (replaced by `src/dialect/xml.ts`)
|
||||
|
||||
## [15.13.3] - 2026-06-15
|
||||
|
||||
@@ -3708,4 +3739,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
|
||||
|
||||
## [0.9.4] - 2025-11-26
|
||||
|
||||
Initial release with multi-provider LLM support.
|
||||
Initial release with multi-provider LLM support.
|
||||
@@ -95,9 +95,9 @@
|
||||
"types": "./src/utils/harmony-leak.ts",
|
||||
"import": "./src/utils/harmony-leak.ts"
|
||||
},
|
||||
"./grammar": {
|
||||
"types": "./src/grammar/index.ts",
|
||||
"import": "./src/grammar/index.ts"
|
||||
"./dialect": {
|
||||
"types": "./src/dialect/index.ts",
|
||||
"import": "./src/dialect/index.ts"
|
||||
},
|
||||
"./utils/*": {
|
||||
"types": "./src/utils/*.ts",
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import grammarPrompt from "./anthropic.md" with { type: "text" };
|
||||
import { buildStringArgsResolver, mintToolCallId } from "./coercion";
|
||||
import { renderAnthropicInvocation, renderAnthropicToolCalls, renderAnthropicToolResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import dialectPrompt from "./anthropic.md" with { type: "text" };
|
||||
import { buildArgShapes, buildStringArgsResolver, mintToolCallId, type ToolArgShape } from "./coercion";
|
||||
import {
|
||||
escapeXmlAttr,
|
||||
escapeXmlText,
|
||||
renderDelimitedThinking,
|
||||
renderLegacyTextTranscript,
|
||||
stringifyJson,
|
||||
} from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
|
||||
const MAX_PARTIAL_TAG_LENGTH = 256;
|
||||
const MAX_PARAMETER_VALUE_LENGTH = 1_000_000;
|
||||
@@ -509,13 +523,63 @@ function couldBeTagPrefix(buffer: string, prefixes: readonly string[]): boolean
|
||||
return false;
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "anthropic",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
||||
return renderInvoke(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
if (calls.length === 0) return "";
|
||||
return `<function_calls>\n${renderInvokes(calls, options.tools ?? [])}\n</function_calls>`;
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[]): string {
|
||||
const body = results
|
||||
.map(result => {
|
||||
const tag = result.isError ? "error" : "result";
|
||||
const streamTag = result.isError ? "stderr" : "stdout";
|
||||
return `<${tag}>\n<tool_name>${escapeXmlText(result.name)}</tool_name>\n<${streamTag}>${result.text}</${streamTag}>\n</${tag}>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `<function_results>\n${body}\n</function_results>`;
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
return renderDelimitedThinking("<thinking>", "</thinking>", text);
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
return renderLegacyTextTranscript(messages, options, {
|
||||
renderThinking,
|
||||
renderCalls: renderAssistantToolCalls,
|
||||
renderResults: renderToolResults,
|
||||
});
|
||||
}
|
||||
|
||||
function renderInvoke(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
let body = `<invoke name="${escapeXmlAttr(call.name)}">`;
|
||||
for (const key in call.arguments) {
|
||||
const value = call.arguments[key];
|
||||
const isString = shape?.stringArgs.has(key) === true;
|
||||
const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
|
||||
body += `<parameter name="${escapeXmlAttr(key)}">${rendered}</parameter>`;
|
||||
}
|
||||
return `${body}</invoke>`;
|
||||
}
|
||||
|
||||
function renderInvokes(calls: readonly ToolCall[], tools: NonNullable<DialectRenderOptions["tools"]>): string {
|
||||
const shapes = buildArgShapes(tools);
|
||||
return calls.map(call => renderInvoke(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "anthropic",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new AnthropicInbandScanner(options),
|
||||
renderToolCall: renderAnthropicInvocation,
|
||||
renderAssistantToolCalls: renderAnthropicToolCalls,
|
||||
renderToolResults: renderAnthropicToolResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { toolWireSchema } from "../utils/schema";
|
||||
import { getInbandGrammar } from "./factory";
|
||||
import { getDialectDefinition } from "./factory";
|
||||
import promptTemplate from "./prompt-template.md" with { type: "text" };
|
||||
import type { InbandTool, ToolCallSyntax } from "./types";
|
||||
import type { Dialect, InbandTool } from "./types";
|
||||
|
||||
const TOOLS_TOKEN = "{{TOOLS}}";
|
||||
const GRAMMAR_TOKEN = "{{GRAMMAR}}";
|
||||
const DIALECT_PROMPT_TOKEN = "{{DIALECT}}";
|
||||
|
||||
export function renderToolCatalog(tools: readonly InbandTool[]): string {
|
||||
return tools
|
||||
@@ -21,7 +21,9 @@ export function renderToolCatalog(tools: readonly InbandTool[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function renderInbandToolPrompt(tools: readonly InbandTool[], syntax: ToolCallSyntax): string {
|
||||
const prompt = getInbandGrammar(syntax).prompt.trim();
|
||||
return promptTemplate.replace(TOOLS_TOKEN, () => renderToolCatalog(tools)).replace(GRAMMAR_TOKEN, () => prompt);
|
||||
export function renderInbandToolPrompt(tools: readonly InbandTool[], dialect: Dialect): string {
|
||||
const prompt = getDialectDefinition(dialect).prompt.trim();
|
||||
return promptTemplate
|
||||
.replace(TOOLS_TOKEN, () => renderToolCatalog(tools))
|
||||
.replace(DIALECT_PROMPT_TOKEN, () => prompt);
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import grammarPrompt from "./deepseek.md" with { type: "text" };
|
||||
import { renderDeepSeekInvocation, renderDeepSeekToolCalls, renderDeepSeekToolResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import dialectPrompt from "./deepseek.md" with { type: "text" };
|
||||
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
|
||||
export const DEEPSEEK_TOOL_CALLS_BEGIN = "<|tool▁calls▁begin|>";
|
||||
export const DEEPSEEK_TOOL_CALLS_END = "<|tool▁calls▁end|>";
|
||||
export const DEEPSEEK_TOOL_CALL_BEGIN = "<|tool▁call▁begin|>";
|
||||
export const DEEPSEEK_TOOL_CALL_END = "<|tool▁call▁end|>";
|
||||
export const DEEPSEEK_TOOL_SEPARATOR = "<|tool▁sep|>";
|
||||
const DEEPSEEK_TOOL_OUTPUT_BEGIN = "<|tool▁output▁begin|>";
|
||||
const DEEPSEEK_TOOL_OUTPUT_END = "<|tool▁output▁end|>";
|
||||
const DEEPSEEK_BOS = "<|begin▁of▁sentence|>";
|
||||
const DEEPSEEK_USER = "<|User|>";
|
||||
const DEEPSEEK_ASSISTANT = "<|Assistant|>";
|
||||
const DEEPSEEK_EOS = "<|end▁of▁sentence|>";
|
||||
|
||||
const THINK_OPEN = "<think>";
|
||||
const THINK_CLOSE = "</think>";
|
||||
@@ -22,11 +36,11 @@ const DSML_TOOL_CALLS_OPEN_ASCII = "<|DSML|tool_calls>";
|
||||
const DSML_TOOL_CALLS_CLOSE_ASCII = "</|DSML|tool_calls>";
|
||||
|
||||
const CONTROL_TOKENS = [
|
||||
"<|begin▁of▁sentence|>",
|
||||
"<|end▁of▁sentence|>",
|
||||
DEEPSEEK_BOS,
|
||||
DEEPSEEK_EOS,
|
||||
"<|▁pad▁|>",
|
||||
"<|User|>",
|
||||
"<|Assistant|>",
|
||||
DEEPSEEK_USER,
|
||||
DEEPSEEK_ASSISTANT,
|
||||
"<|EOT|>",
|
||||
"<|search▁begin|>",
|
||||
"<|search▁end|>",
|
||||
@@ -35,8 +49,8 @@ const CONTROL_TOKENS = [
|
||||
"<|fim▁end|>",
|
||||
"<|tool▁outputs▁begin|>",
|
||||
"<|tool▁outputs▁end|>",
|
||||
"<|tool▁output▁begin|>",
|
||||
"<|tool▁output▁end|>",
|
||||
DEEPSEEK_TOOL_OUTPUT_BEGIN,
|
||||
DEEPSEEK_TOOL_OUTPUT_END,
|
||||
] as const;
|
||||
|
||||
const OUTSIDE_TOKENS = [
|
||||
@@ -523,13 +537,58 @@ function coerceDsmlValue(raw: string, isString: boolean): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "deepseek",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
|
||||
return `${DEEPSEEK_TOOL_CALL_BEGIN}${call.name}${DEEPSEEK_TOOL_SEPARATOR}${stringifyJson(call.arguments)}${DEEPSEEK_TOOL_CALL_END}`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
if (calls.length === 0) return "";
|
||||
const body = calls.map(call => renderToolCall(call, options)).join("");
|
||||
return `${DEEPSEEK_TOOL_CALLS_BEGIN}${body}${DEEPSEEK_TOOL_CALLS_END}`;
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[], _options: DialectRenderOptions = {}): string {
|
||||
return results.map(result => `${DEEPSEEK_TOOL_OUTPUT_BEGIN}${result.text}${DEEPSEEK_TOOL_OUTPUT_END}`).join("\n");
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
if (!text) return "";
|
||||
return `${THINK_OPEN}\n${text}\n${THINK_CLOSE}`;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
if (messages.length === 0) return "";
|
||||
let out = DEEPSEEK_BOS;
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
out += `${DEEPSEEK_ASSISTANT}${renderThinking(parts.thinking)}${parts.text}${renderAssistantToolCalls(parts.toolCalls, options)}${DEEPSEEK_EOS}`;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out += renderToolResults(run.results, options);
|
||||
i = run.next;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "developer") out += messageContentText(message.content);
|
||||
else out += `${DEEPSEEK_USER}${messageContentText(message.content)}`;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "deepseek",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new DeepSeekInbandScanner(options),
|
||||
renderToolCall: renderDeepSeekInvocation,
|
||||
renderAssistantToolCalls: renderDeepSeekToolCalls,
|
||||
renderToolResults: renderDeepSeekToolResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ToolCall } from "../types";
|
||||
import { getInbandGrammar } from "./factory";
|
||||
import type { InbandTool, ToolCallSyntax } from "./types";
|
||||
import { getDialectDefinition } from "./factory";
|
||||
import type { Dialect, InbandTool } from "./types";
|
||||
|
||||
const INTENT_PLACEHOLDER = "…";
|
||||
|
||||
export function renderToolExamples(tool: InbandTool, syntax: ToolCallSyntax, intentField?: string): string {
|
||||
export function renderToolExamples(tool: InbandTool, dialect: Dialect, intentField?: string): string {
|
||||
const examples = tool.examples;
|
||||
if (!examples?.length) return "";
|
||||
const grammar = getInbandGrammar(syntax);
|
||||
const definition = getDialectDefinition(dialect);
|
||||
const renderCall = (args: Record<string, unknown>): string => {
|
||||
// When intent tracing injects `_i` into the schema, examples must show a
|
||||
// placeholder so the model learns to emit it. Keep it first, matching the
|
||||
@@ -19,7 +19,7 @@ export function renderToolExamples(tool: InbandTool, syntax: ToolCallSyntax, int
|
||||
name: tool.name,
|
||||
arguments: finalArgs,
|
||||
};
|
||||
return `<example>\n${grammar.renderToolCall(call, { tools: [tool], example: true }).trim()}\n</example>`;
|
||||
return `<example>\n${definition.renderToolCall(call, { tools: [tool], example: true }).trim()}\n</example>`;
|
||||
};
|
||||
const parts = examples.map(ex => {
|
||||
const head = ex.caption ? `# ${ex.caption}\n` : "";
|
||||
@@ -0,0 +1,34 @@
|
||||
import anthropicDefinition from "./anthropic";
|
||||
import deepseekDefinition from "./deepseek";
|
||||
import geminiDefinition from "./gemini";
|
||||
import gemmaDefinition from "./gemma";
|
||||
import glmDefinition from "./glm";
|
||||
import harmonyDefinition from "./harmony";
|
||||
import hermesDefinition from "./hermes";
|
||||
import kimiDefinition from "./kimi";
|
||||
import piDefinition from "./pi";
|
||||
import qwen3Definition from "./qwen3";
|
||||
import type { Dialect, DialectDefinition, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import xmlDefinition from "./xml";
|
||||
|
||||
const DIALECT_DEFINITIONS: Record<Dialect, DialectDefinition> = {
|
||||
glm: glmDefinition,
|
||||
hermes: hermesDefinition,
|
||||
kimi: kimiDefinition,
|
||||
xml: xmlDefinition,
|
||||
anthropic: anthropicDefinition,
|
||||
deepseek: deepseekDefinition,
|
||||
harmony: harmonyDefinition,
|
||||
pi: piDefinition,
|
||||
qwen3: qwen3Definition,
|
||||
gemini: geminiDefinition,
|
||||
gemma: gemmaDefinition,
|
||||
};
|
||||
|
||||
export function getDialectDefinition(dialect: Dialect): DialectDefinition {
|
||||
return DIALECT_DEFINITIONS[dialect];
|
||||
}
|
||||
|
||||
export function createInbandScanner(dialect: Dialect, options: InbandScannerOptions = {}): InbandScanner {
|
||||
return getDialectDefinition(dialect).createScanner(options);
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import grammarPrompt from "./gemini.md" with { type: "text" };
|
||||
import { renderGeminiInvocation, renderGeminiToolCalls, renderGeminiToolResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner } from "./types";
|
||||
import dialectPrompt from "./gemini.md" with { type: "text" };
|
||||
import { assistantTranscriptParts, collectToolResultRun, joinUserBodies, messageContentText } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
} from "./types";
|
||||
|
||||
const CODE_OPEN = "```tool_code";
|
||||
const OUTPUT_OPEN = "```tool_outputs";
|
||||
const FENCE = "```";
|
||||
const OPEN_TAGS = [CODE_OPEN] as const;
|
||||
|
||||
@@ -428,13 +436,105 @@ function topLevelIndexOf(text: string, ch: string): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "gemini",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
||||
const kwargs = Object.entries(call.arguments)
|
||||
.map(([key, value]) => `${key}=${pyValue(value)}`)
|
||||
.join(", ");
|
||||
return options.example ? `${call.name}(${kwargs})` : `default_api.${call.name}(${kwargs})`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
// One call renders bare; parallel calls render as a Python list `[a, b]`.
|
||||
const body =
|
||||
calls.length === 1
|
||||
? renderToolCall(calls[0]!, options)
|
||||
: `[${calls.map(call => renderToolCall(call, options)).join(", ")}]`;
|
||||
// Examples show the bare call; the live wire form fences it as `tool_code`.
|
||||
return options.example ? body : `${CODE_OPEN}\n${body}\n${FENCE}`;
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[]): string {
|
||||
return results.map(result => `${OUTPUT_OPEN}\n${result.text}\n${FENCE}`).join("\n");
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
if (messages.length === 0) return "";
|
||||
let out = "<bos>";
|
||||
let pendingUserPreamble = "";
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "developer") {
|
||||
pendingUserPreamble = joinUserBodies(pendingUserPreamble, messageContentText(message.content));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "user") {
|
||||
out += geminiTurn("user", joinUserBodies(pendingUserPreamble, messageContentText(message.content)));
|
||||
pendingUserPreamble = "";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (pendingUserPreamble) {
|
||||
out += geminiTurn("user", pendingUserPreamble);
|
||||
pendingUserPreamble = "";
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
out += geminiTurn(
|
||||
"model",
|
||||
`${parts.thinking}${parts.text}${renderAssistantToolCalls(parts.toolCalls, options)}`,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out += geminiTurn("user", renderToolResults(run.results));
|
||||
i = run.next;
|
||||
}
|
||||
if (pendingUserPreamble) out += geminiTurn("user", pendingUserPreamble);
|
||||
return out;
|
||||
}
|
||||
|
||||
function geminiTurn(role: "model" | "user", body: string): string {
|
||||
return `<start_of_turn>${role}\n${body}<end_of_turn>\n`;
|
||||
}
|
||||
|
||||
function pyValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "None";
|
||||
if (typeof value === "boolean") return value ? "True" : "False";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : pyString(String(value));
|
||||
if (typeof value === "string") return pyString(value);
|
||||
if (Array.isArray(value)) return `[${value.map(pyValue).join(", ")}]`;
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return `{${entries.map(([key, val]) => `${pyString(key)}: ${pyValue(val)}`).join(", ")}}`;
|
||||
}
|
||||
return pyString(String(value));
|
||||
}
|
||||
|
||||
function pyString(value: string): string {
|
||||
const escaped = value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll("\n", "\\n")
|
||||
.replaceAll("\r", "\\r")
|
||||
.replaceAll("\t", "\\t");
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "gemini",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: () => new GeminiInbandScanner(),
|
||||
renderToolCall: renderGeminiInvocation,
|
||||
renderAssistantToolCalls: renderGeminiToolCalls,
|
||||
renderToolResults: renderGeminiToolResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -1,11 +1,20 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import grammarPrompt from "./gemma.md" with { type: "text" };
|
||||
import { renderGemmaInvocation, renderGemmaToolCalls, renderGemmaToolResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner } from "./types";
|
||||
import dialectPrompt from "./gemma.md" with { type: "text" };
|
||||
import { assistantTranscriptParts, collectToolResultRun, messageContentText } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
} from "./types";
|
||||
|
||||
const CALL_OPEN = "<|tool_call>";
|
||||
const CALL_CLOSE = "<tool_call|>";
|
||||
const STRING = '<|"|>';
|
||||
const RESPONSE_OPEN = "<|tool_response>";
|
||||
const RESPONSE_CLOSE = "<tool_response|>";
|
||||
const OPEN_TAGS = [CALL_OPEN] as const;
|
||||
const CALL_HEAD = /^call:\s*([A-Za-z_]\w*)\s*\{/;
|
||||
|
||||
@@ -225,13 +234,95 @@ function topLevelIndexOf(text: string, ch: string): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "gemma",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
|
||||
const args = Object.entries(call.arguments)
|
||||
.map(([key, value]) => `${key}:${gemmaValue(value)}`)
|
||||
.join(",");
|
||||
return `${CALL_OPEN}call:${call.name}{${args}}${CALL_CLOSE}`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
return calls.map(call => renderToolCall(call, options)).join("");
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[], _options: DialectRenderOptions = {}): string {
|
||||
return results
|
||||
.map(
|
||||
result =>
|
||||
`${RESPONSE_OPEN}response:${result.name}{output:${gemmaValue(parseMaybeJson(result.text))}}${RESPONSE_CLOSE}`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
if (messages.length === 0) return "";
|
||||
let out = "<bos>";
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
let body = `${parts.thinking}${parts.text}${renderAssistantToolCalls(parts.toolCalls, options)}`;
|
||||
let next = i + 1;
|
||||
if (next < messages.length && messages[next]!.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, next);
|
||||
body += renderToolResults(run.results);
|
||||
next = run.next;
|
||||
}
|
||||
out += gemmaTurn("model", body);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out += gemmaTurn("model", renderToolResults(run.results));
|
||||
i = run.next;
|
||||
continue;
|
||||
}
|
||||
const role = message.role === "developer" ? "system" : message.role;
|
||||
out += gemmaTurn(role, messageContentText(message.content));
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function gemmaValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return `${STRING}${value}${STRING}`;
|
||||
if (Array.isArray(value)) return `[${value.map(gemmaValue).join(",")}]`;
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return `{${entries.map(([key, val]) => `${key}:${gemmaValue(val)}`).join(",")}}`;
|
||||
}
|
||||
return `${STRING}${String(value)}${STRING}`;
|
||||
}
|
||||
|
||||
function parseMaybeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function gemmaTurn(role: "model" | "system" | "user", body: string): string {
|
||||
return `<|turn>${role}\n${body}<turn|>`;
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "gemma",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: () => new GemmaInbandScanner(),
|
||||
renderToolCall: renderGemmaInvocation,
|
||||
renderAssistantToolCalls: renderGemmaToolCalls,
|
||||
renderToolResults: renderGemmaToolResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -1,13 +1,29 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import {
|
||||
buildArgShapes,
|
||||
buildStringArgsResolver,
|
||||
decodeValue,
|
||||
mintToolCallId,
|
||||
partialSuffixOverlap,
|
||||
partialSuffixOverlapAny,
|
||||
type ToolArgShape,
|
||||
} from "./coercion";
|
||||
import grammarPrompt from "./glm.md" with { type: "text" };
|
||||
import { renderGlmInvocation, renderGlmToolCalls, renderGlmToolResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import dialectPrompt from "./glm.md" with { type: "text" };
|
||||
import {
|
||||
assistantTranscriptParts,
|
||||
collectToolResultRun,
|
||||
messageContentText,
|
||||
renderToolResponseResults,
|
||||
stringifyJson,
|
||||
} from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
|
||||
const TOOL_OPEN = "<tool_call>";
|
||||
const TOOL_CLOSE = "</tool_call>";
|
||||
@@ -372,13 +388,68 @@ function minFound(...values: readonly number[]): number {
|
||||
return best;
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "glm",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
||||
return glmInvocation(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
function glmInvocation(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
let body = `${TOOL_OPEN}${call.name}`;
|
||||
for (const key in call.arguments) {
|
||||
const value = call.arguments[key];
|
||||
const rendered = shape?.stringArgs.has(key) && typeof value === "string" ? value : stringifyJson(value);
|
||||
body += `\n${ARG_KEY_OPEN}${key}${ARG_KEY_CLOSE}\n${ARG_VALUE_OPEN}${rendered}${ARG_VALUE_CLOSE}`;
|
||||
}
|
||||
return `${body}\n${TOOL_CLOSE}`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
const shapes = buildArgShapes(options.tools);
|
||||
return calls.map(call => glmInvocation(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[]): string {
|
||||
return `<observation>\n${renderToolResponseResults(results)}\n</observation>`;
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
if (!text) return "";
|
||||
return `${THINK_OPEN}\n${text}\n${THINK_CLOSE}`;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
if (messages.length === 0) return "";
|
||||
let out = "[gMASK]<sop>";
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
const thinking = parts.thinking ? `\n${renderThinking(parts.thinking)}` : "";
|
||||
out += `<|assistant|>\n${thinking}${parts.text}${renderAssistantToolCalls(parts.toolCalls, options)}`;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out += `<|observation|>\n${renderToolResponseResults(run.results)}`;
|
||||
i = run.next;
|
||||
continue;
|
||||
}
|
||||
const role = message.role === "developer" ? "system" : message.role;
|
||||
out += `<|${role}|>\n${messageContentText(message.content)}`;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "glm",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new GLMInbandScanner(options),
|
||||
renderToolCall: renderGlmInvocation,
|
||||
renderAssistantToolCalls: renderGlmToolCalls,
|
||||
renderToolResults: renderGlmToolResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import grammarPrompt from "./harmony.md" with { type: "text" };
|
||||
import { renderHarmonyInvocation, renderHarmonyToolCalls, renderHarmonyToolResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner } from "./types";
|
||||
import dialectPrompt from "./harmony.md" with { type: "text" };
|
||||
import {
|
||||
assistantTranscriptParts,
|
||||
collectToolResultRun,
|
||||
harmonyRecipient,
|
||||
messageContentText,
|
||||
stringifyJson,
|
||||
} from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
} from "./types";
|
||||
|
||||
const START = "<|start|>";
|
||||
const END = "<|end|>";
|
||||
@@ -260,13 +273,74 @@ function parseRecipient(header: string): string {
|
||||
return match?.[1] ?? "";
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "harmony",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
||||
if (options.example) return stringifyJson(call.arguments);
|
||||
return `${START}assistant${CHANNEL}commentary to=${harmonyRecipient(call.name)}${MESSAGE}${stringifyJson(call.arguments)}${CALL}`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
let out = "";
|
||||
for (const call of calls) out += renderToolCall(call, options);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[]): string {
|
||||
let out = "";
|
||||
for (const result of results) {
|
||||
out += `${START}${harmonyRecipient(result.name)} to=assistant${CHANNEL}commentary${MESSAGE}${result.text}${END}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
if (!text) return "";
|
||||
return `${START}assistant${CHANNEL}analysis${MESSAGE}${text}${END}`;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
let emitted = false;
|
||||
if (parts.thinking) {
|
||||
out += renderThinking(parts.thinking);
|
||||
emitted = true;
|
||||
}
|
||||
if (parts.text) {
|
||||
out += `${START}assistant${CHANNEL}final${MESSAGE}${parts.text}${END}`;
|
||||
emitted = true;
|
||||
}
|
||||
if (parts.toolCalls.length > 0) {
|
||||
out += renderAssistantToolCalls(parts.toolCalls, options);
|
||||
emitted = true;
|
||||
}
|
||||
if (!emitted) out += `${START}assistant${CHANNEL}final${MESSAGE}${END}`;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out += renderToolResults(run.results);
|
||||
i = run.next;
|
||||
continue;
|
||||
}
|
||||
out += `${START}${message.role}${MESSAGE}${messageContentText(message.content)}${END}`;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "harmony",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: () => new HarmonyInbandScanner(),
|
||||
renderToolCall: renderHarmonyInvocation,
|
||||
renderAssistantToolCalls: renderHarmonyToolCalls,
|
||||
renderToolResults: renderHarmonyToolResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import grammarPrompt from "./hermes.md" with { type: "text" };
|
||||
import { renderHermesInvocation, renderHermesToolCalls, renderToolResponseResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import dialectPrompt from "./hermes.md" with { type: "text" };
|
||||
import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
|
||||
const TOOL_OPEN = "<tool_call>";
|
||||
const TOOL_CLOSE = "</tool_call>";
|
||||
@@ -159,13 +167,40 @@ export class HermesInbandScanner implements InbandScanner {
|
||||
}
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "hermes",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
|
||||
return `<tool_call>\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n</tool_call>`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
return calls.map(call => renderToolCall(call, options)).join("\n");
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[], _options: DialectRenderOptions = {}): string {
|
||||
return renderToolResponseResults(results);
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
return renderDelimitedThinking(THINK_OPEN, THINK_CLOSE, text);
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
return renderChatMlTranscript(messages, options, {
|
||||
toolResultRole: "tool",
|
||||
renderThinking,
|
||||
renderCalls: renderAssistantToolCalls,
|
||||
renderResultsBody: renderToolResults,
|
||||
});
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "hermes",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new HermesInbandScanner(options),
|
||||
renderToolCall: renderHermesInvocation,
|
||||
renderAssistantToolCalls: renderHermesToolCalls,
|
||||
renderToolResults: renderToolResponseResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -7,20 +7,20 @@ import type {
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
} from "../types";
|
||||
import { getInbandGrammar } from "./factory";
|
||||
import type { Grammar, GrammarToolResult, InbandTool, ToolCallSyntax } from "./types";
|
||||
import { getDialectDefinition } from "./factory";
|
||||
import type { Dialect, DialectDefinition, DialectToolResult, InbandTool } from "./types";
|
||||
|
||||
export function encodeInbandToolHistory(
|
||||
messages: Context["messages"],
|
||||
syntax: ToolCallSyntax,
|
||||
dialect: Dialect,
|
||||
tools: readonly InbandTool[] = [],
|
||||
): Context["messages"] {
|
||||
const grammar = getInbandGrammar(syntax);
|
||||
const definition = getDialectDefinition(dialect);
|
||||
const out: Message[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
out.push(encodeAssistantMessage(message, grammar, tools));
|
||||
out.push(encodeAssistantMessage(message, definition, tools));
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
@@ -30,7 +30,7 @@ export function encodeInbandToolHistory(
|
||||
run.push(messages[j] as ToolResultMessage);
|
||||
j++;
|
||||
}
|
||||
out.push(encodeToolResults(run, grammar));
|
||||
out.push(encodeToolResults(run, definition));
|
||||
i = j - 1;
|
||||
continue;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function encodeInbandToolHistory(
|
||||
|
||||
function encodeAssistantMessage(
|
||||
message: AssistantMessage,
|
||||
grammar: Grammar,
|
||||
definition: DialectDefinition,
|
||||
tools: readonly InbandTool[],
|
||||
): AssistantMessage {
|
||||
const toolCalls = message.content.filter((block): block is ToolCall => block.type === "toolCall");
|
||||
@@ -50,13 +50,13 @@ function encodeAssistantMessage(
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map(block => block.text)
|
||||
.join("\n");
|
||||
const rendered = grammar.renderAssistantToolCalls(toolCalls, { tools });
|
||||
const rendered = definition.renderAssistantToolCalls(toolCalls, { tools });
|
||||
const text = prose.trim().length > 0 ? `${prose.trimEnd()}\n${rendered}` : rendered;
|
||||
return { ...message, content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function encodeToolResults(results: readonly ToolResultMessage[], grammar: Grammar): Message {
|
||||
const grammarResults: GrammarToolResult[] = [];
|
||||
function encodeToolResults(results: readonly ToolResultMessage[], definition: DialectDefinition): Message {
|
||||
const dialectResults: DialectToolResult[] = [];
|
||||
const images: ImageContent[] = [];
|
||||
for (let index = 0; index < results.length; index++) {
|
||||
const result = results[index]!;
|
||||
@@ -65,7 +65,7 @@ function encodeToolResults(results: readonly ToolResultMessage[], grammar: Gramm
|
||||
if (block.type === "text") text += block.text;
|
||||
else if (block.type === "image") images.push(block);
|
||||
}
|
||||
grammarResults.push({
|
||||
dialectResults.push({
|
||||
id: result.toolCallId,
|
||||
name: result.toolName,
|
||||
index,
|
||||
@@ -74,7 +74,7 @@ function encodeToolResults(results: readonly ToolResultMessage[], grammar: Gramm
|
||||
});
|
||||
}
|
||||
const content: (TextContent | ImageContent)[] = [
|
||||
{ type: "text", text: grammar.renderToolResults(grammarResults) },
|
||||
{ type: "text", text: definition.renderToolResults(dialectResults) },
|
||||
...images,
|
||||
];
|
||||
return { role: "user", content, timestamp: results[0]?.timestamp ?? Date.now() };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { jsonSchemaToTypeScript, toolWireSchema } from "../utils/schema";
|
||||
import { renderToolExamples } from "./examples";
|
||||
import type { InbandTool } from "./types";
|
||||
@@ -7,19 +7,19 @@ import type { InbandTool } from "./types";
|
||||
* Human-readable per-tool inventory: each tool renders as a `# Tool: <name>`
|
||||
* section with its description, a simplified TypeScript-style parameter
|
||||
* signature (derived from the wire JSON Schema), and examples in the model's
|
||||
* native tool-call syntax. Shared by the verbose system-prompt inventory and
|
||||
* native dialect. Shared by the verbose system-prompt inventory and
|
||||
* `/dump` so both render the catalog the same way.
|
||||
*
|
||||
* `model` is a model id; the native example syntax is resolved from it
|
||||
* (`preferredToolSyntax`, which falls back to XML for empty/unknown ids).
|
||||
* `model` is a model id; the native example dialect is resolved from it
|
||||
* (`preferredDialect`, which falls back to XML for empty/unknown ids).
|
||||
*/
|
||||
export function renderToolInventory(tools: readonly InbandTool[], model: string): string {
|
||||
if (tools.length === 0) return "";
|
||||
const syntax = preferredToolSyntax(model);
|
||||
const dialect = preferredDialect(model);
|
||||
return tools
|
||||
.map(tool => {
|
||||
const params = jsonSchemaToTypeScript(toolWireSchema(tool));
|
||||
const examples = renderToolExamples(tool, syntax);
|
||||
const examples = renderToolExamples(tool, dialect);
|
||||
const parts = [`# Tool: ${tool.name}`, tool.description ?? "", "", `Parameters: ${params}`];
|
||||
if (examples) parts.push("", examples);
|
||||
return parts.join("\n");
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, normalizeKimiFunctionName, partialSuffixOverlapAny } from "./coercion";
|
||||
import grammarPrompt from "./kimi.md" with { type: "text" };
|
||||
import { renderKimiInvocation, renderKimiToolCalls, renderKimiToolResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner } from "./types";
|
||||
import dialectPrompt from "./kimi.md" with { type: "text" };
|
||||
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
} from "./types";
|
||||
|
||||
export const KIMI_SECTION_BEGIN = "<|tool_calls_section_begin|>";
|
||||
export const KIMI_SECTION_END = "<|tool_calls_section_end|>";
|
||||
@@ -186,13 +193,82 @@ function isWhitespace(cp: number): boolean {
|
||||
return cp === 0x20 || cp === 0x09 || cp === 0x0a || cp === 0x0d || cp === 0x0b || cp === 0x0c;
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "kimi",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, _options?: DialectRenderOptions): string {
|
||||
return kimiInvocation(call, 0);
|
||||
}
|
||||
|
||||
function kimiInvocation(call: ToolCall, index: number): string {
|
||||
return `${KIMI_CALL_BEGIN}${kimiCallId(call.name, call.id, index)}${KIMI_ARG_BEGIN}${stringifyJson(call.arguments)}${KIMI_CALL_END}`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], _options?: DialectRenderOptions): string {
|
||||
if (calls.length === 0) return "";
|
||||
const body = calls.map((call, index) => kimiInvocation(call, index)).join("");
|
||||
return `${KIMI_SECTION_BEGIN}${body}${KIMI_SECTION_END}`;
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[], _options?: DialectRenderOptions): string {
|
||||
return results
|
||||
.map(result =>
|
||||
kimiTurn(
|
||||
"system",
|
||||
result.name,
|
||||
`## Return of ${kimiCallId(result.name, result.id, result.index)}\n${result.text}`,
|
||||
),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], _options?: DialectRenderOptions): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
out += kimiTurn(
|
||||
"assistant",
|
||||
"assistant",
|
||||
`${parts.thinking}${parts.text}${renderAssistantToolCalls(parts.toolCalls)}`,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out += renderToolResults(run.results);
|
||||
i = run.next;
|
||||
continue;
|
||||
}
|
||||
const name = message.role === "developer" ? "system" : message.role;
|
||||
const role = message.role === "developer" ? "system" : message.role;
|
||||
out += kimiTurn(role, name, messageContentText(message.content));
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function kimiCallId(name: string, id: string, index: number): string {
|
||||
const trimmed = id.trim();
|
||||
return trimmed.startsWith("functions.") ? trimmed : `functions.${name}:${index}`;
|
||||
}
|
||||
|
||||
function kimiTurn(role: "assistant" | "system" | "user", name: string, body: string): string {
|
||||
return `<|im_${role}|>${name}<|im_middle|>${body}<|im_end|>`;
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "kimi",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: () => new KimiInbandScanner(),
|
||||
renderToolCall: renderKimiInvocation,
|
||||
renderAssistantToolCalls: renderKimiToolCalls,
|
||||
renderToolResults: renderKimiToolResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { buildStringArgsResolver } from "./coercion";
|
||||
import { createInbandScanner } from "./factory";
|
||||
import type { InbandScanEvent, InbandScanner, InbandTool, ToolCallSyntax } from "./types";
|
||||
import type { Dialect, InbandScanEvent, InbandScanner, InbandTool } from "./types";
|
||||
|
||||
const RESPONSE_OPEN_TOKENS: Record<ToolCallSyntax, readonly string[]> = {
|
||||
const RESPONSE_OPEN_TOKENS: Record<Dialect, readonly string[]> = {
|
||||
glm: ["<tool_response>"],
|
||||
hermes: ["<tool_response>"],
|
||||
kimi: ["<|im_system|>"],
|
||||
@@ -38,10 +38,10 @@ type OpenThinking = { index: number; text: string } | undefined;
|
||||
|
||||
export function parseInbandToolMessage(
|
||||
message: AssistantMessage,
|
||||
syntax: ToolCallSyntax,
|
||||
dialect: Dialect,
|
||||
tools: readonly InbandTool[],
|
||||
): AssistantMessage {
|
||||
const projector = new InbandStreamProjector(new AssistantMessageEventStream(), tools, syntax, message, false);
|
||||
const projector = new InbandStreamProjector(new AssistantMessageEventStream(), tools, dialect, message, false);
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") projector.text(block.text);
|
||||
else projector.keep(block);
|
||||
@@ -52,7 +52,7 @@ export function parseInbandToolMessage(
|
||||
export function wrapInbandToolStream(
|
||||
inner: AssistantMessageEventStreamType,
|
||||
tools: readonly InbandTool[],
|
||||
syntax: ToolCallSyntax,
|
||||
dialect: Dialect,
|
||||
onAbort?: () => void,
|
||||
abortOnFabrication = true,
|
||||
): AssistantMessageEventStreamType {
|
||||
@@ -63,7 +63,7 @@ export function wrapInbandToolStream(
|
||||
for await (const event of inner) {
|
||||
switch (event.type) {
|
||||
case "start":
|
||||
projector = new InbandStreamProjector(out, tools, syntax, event.partial, true);
|
||||
projector = new InbandStreamProjector(out, tools, dialect, event.partial, true);
|
||||
break;
|
||||
case "thinking_start":
|
||||
projector?.thinkingStart();
|
||||
@@ -105,7 +105,7 @@ export function wrapInbandToolStream(
|
||||
projector?.nativeToolEnd(event.contentIndex, event.toolCall);
|
||||
break;
|
||||
case "done":
|
||||
projector ??= new InbandStreamProjector(out, tools, syntax, event.message, true);
|
||||
projector ??= new InbandStreamProjector(out, tools, dialect, event.message, true);
|
||||
projector.finish(event.message, true);
|
||||
return;
|
||||
case "error":
|
||||
@@ -144,18 +144,18 @@ class InbandStreamProjector {
|
||||
constructor(
|
||||
out: AssistantMessageEventStream,
|
||||
tools: readonly InbandTool[],
|
||||
syntax: ToolCallSyntax,
|
||||
dialect: Dialect,
|
||||
seed: AssistantMessage,
|
||||
emitEvents: boolean,
|
||||
) {
|
||||
this.#out = out;
|
||||
this.#emitEvents = emitEvents;
|
||||
this.#scanner = createInbandScanner(syntax, {
|
||||
this.#scanner = createInbandScanner(dialect, {
|
||||
tools,
|
||||
stringArgs: buildStringArgsResolver(tools),
|
||||
parseThinking: true,
|
||||
});
|
||||
this.#responseOpenTokens = RESPONSE_OPEN_TOKENS[syntax];
|
||||
this.#responseOpenTokens = RESPONSE_OPEN_TOKENS[dialect];
|
||||
this.#responseOverlapLength = Math.max(0, ...this.#responseOpenTokens.map(token => token.length - 1));
|
||||
this.#partial = { ...seed, content: [] };
|
||||
if (emitEvents) this.#out.push({ type: "start", partial: this.#partial });
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import type { ToolArgShape } from "./coercion";
|
||||
import {
|
||||
buildArgShapes,
|
||||
@@ -11,9 +12,16 @@ import {
|
||||
mintToolCallId,
|
||||
partialSuffixOverlapAny,
|
||||
} from "./coercion";
|
||||
import grammarPrompt from "./pi.md" with { type: "text" };
|
||||
import { renderPiNativeInvocation, renderPiNativeToolCalls, renderToolResponseResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import dialectPrompt from "./pi.md" with { type: "text" };
|
||||
import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
|
||||
const CALL_PREFIX = "<call:";
|
||||
const NAME_START = /[A-Za-z_]/;
|
||||
@@ -573,13 +581,73 @@ function isNameChar(ch: string | undefined): boolean {
|
||||
return ch !== undefined && NAME_CHAR.test(ch);
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "pi",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
||||
return renderInvocation(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
function renderInvocation(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
let body = `<call:${call.name}>`;
|
||||
for (const key in call.arguments) {
|
||||
body += `\n${renderElement(key, call.arguments[key], shape?.properties[key])}`;
|
||||
}
|
||||
return `${body}\n</call:${call.name}>`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
const shapes = buildArgShapes(options.tools);
|
||||
return calls.map(call => renderInvocation(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[], _options?: DialectRenderOptions): string {
|
||||
return renderToolResponseResults(results);
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
if (!text) return "";
|
||||
return `<thinking>\n${text}\n</thinking>`;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
return renderChatMlTranscript(messages, options, {
|
||||
toolResultRole: "tool",
|
||||
renderThinking,
|
||||
renderCalls: renderAssistantToolCalls,
|
||||
renderResultsBody: renderToolResults,
|
||||
});
|
||||
}
|
||||
|
||||
function renderElement(key: string, value: unknown, schema: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
const itemSchema = getArrayItemSchema(schema);
|
||||
return value.map(item => renderElement(key, item, itemSchema)).join("\n");
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
const properties = getObjectProperties(schema);
|
||||
let body = `<${key}>`;
|
||||
for (const childKey in record) {
|
||||
body += `\n${renderElement(childKey, record[childKey], properties[childKey])}`;
|
||||
}
|
||||
return `${body}\n</${key}>`;
|
||||
}
|
||||
return `<${key}>${renderScalar(value, schema)}</${key}>`;
|
||||
}
|
||||
|
||||
function renderScalar(value: unknown, schema: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (isStringOnlySchema(schema) && value === null) return "";
|
||||
return stringifyJson(value);
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "pi",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new PiNativeInbandScanner(options),
|
||||
renderToolCall: renderPiNativeInvocation,
|
||||
renderAssistantToolCalls: renderPiNativeToolCalls,
|
||||
renderToolResults: renderToolResponseResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
+1
-1
@@ -9,4 +9,4 @@ Available functions are listed inside `<tools></tools>` as one JSON object per l
|
||||
{{TOOLS}}
|
||||
</tools>
|
||||
|
||||
{{GRAMMAR}}
|
||||
{{DIALECT}}
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { parseJsonWithRepair } from "../utils/json-parse";
|
||||
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
||||
import grammarPrompt from "./qwen3.md" with { type: "text" };
|
||||
import { renderHermesInvocation, renderHermesToolCalls, renderToolResponseResults } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import dialectPrompt from "./qwen3.md" with { type: "text" };
|
||||
import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
|
||||
const TOOL_OPEN = "<tool_call>";
|
||||
const TOOL_CLOSE = "</tool_call>";
|
||||
@@ -191,13 +199,41 @@ export class Qwen3InbandScanner implements InbandScanner {
|
||||
}
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "qwen3",
|
||||
prompt: grammarPrompt,
|
||||
function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string {
|
||||
return `${TOOL_OPEN}\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n${TOOL_CLOSE}`;
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
return calls.map(call => renderToolCall(call, options)).join("\n");
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[], _options: DialectRenderOptions = {}): string {
|
||||
return renderToolResponseResults(results);
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
if (!text) return "";
|
||||
return `${THINK_OPEN}\n${text}\n${THINK_CLOSE}`;
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
return renderChatMlTranscript(messages, options, {
|
||||
toolResultRole: "user",
|
||||
renderThinking,
|
||||
renderCalls: renderAssistantToolCalls,
|
||||
renderResultsBody: renderToolResults,
|
||||
});
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "qwen3",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new Qwen3InbandScanner(options),
|
||||
renderToolCall: renderHermesInvocation,
|
||||
renderAssistantToolCalls: renderHermesToolCalls,
|
||||
renderToolResults: renderToolResponseResults,
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
export default definition;
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { AssistantMessage, Message, ToolCall, ToolResultMessage } from "../types";
|
||||
import type { DialectRenderOptions, DialectToolResult } from "./types";
|
||||
|
||||
export function renderToolResponseResults(results: readonly DialectToolResult[]): string {
|
||||
return results.map(result => `<tool_response>\n${result.text}\n</tool_response>`).join("\n");
|
||||
}
|
||||
|
||||
export function kimiCallId(name: string, id: string, index: number): string {
|
||||
const trimmed = id.trim();
|
||||
return trimmed.startsWith("functions.") ? trimmed : `functions.${name}:${index}`;
|
||||
}
|
||||
|
||||
export function harmonyRecipient(name: string): string {
|
||||
return name.startsWith("functions.") ? name : `functions.${name}`;
|
||||
}
|
||||
|
||||
export function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
export function escapeXmlAttr(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export function escapeXmlText(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export type AssistantTranscriptParts = {
|
||||
readonly text: string;
|
||||
readonly thinking: string;
|
||||
readonly toolCalls: readonly ToolCall[];
|
||||
};
|
||||
|
||||
export type ToolCallRenderer = (calls: readonly ToolCall[], options?: DialectRenderOptions) => string;
|
||||
export type ToolResultRenderer = (results: readonly DialectToolResult[], options?: DialectRenderOptions) => string;
|
||||
|
||||
export type ChatMlTranscriptConfig = {
|
||||
readonly bos?: string;
|
||||
readonly toolResultRole: "tool" | "user";
|
||||
readonly renderThinking: (text: string) => string;
|
||||
readonly renderCalls: ToolCallRenderer;
|
||||
readonly renderResultsBody: ToolResultRenderer;
|
||||
};
|
||||
|
||||
export type LegacyTextTranscriptConfig = {
|
||||
readonly renderThinking: (text: string) => string;
|
||||
readonly renderCalls: ToolCallRenderer;
|
||||
readonly renderResults: ToolResultRenderer;
|
||||
};
|
||||
|
||||
export function renderChatMlTranscript(
|
||||
messages: readonly Message[],
|
||||
options: DialectRenderOptions,
|
||||
config: ChatMlTranscriptConfig,
|
||||
): string {
|
||||
if (messages.length === 0) return "";
|
||||
let out = config.bos ?? "";
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
out += chatMlTurn(
|
||||
"assistant",
|
||||
`${config.renderThinking(parts.thinking)}${parts.text}${config.renderCalls(parts.toolCalls, options)}`,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out += chatMlTurn(config.toolResultRole, config.renderResultsBody(run.results, options));
|
||||
i = run.next;
|
||||
continue;
|
||||
}
|
||||
const role = message.role === "developer" ? "system" : message.role;
|
||||
out += chatMlTurn(role, messageContentText(message.content));
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderLegacyTextTranscript(
|
||||
messages: readonly Message[],
|
||||
options: DialectRenderOptions,
|
||||
config: LegacyTextTranscriptConfig,
|
||||
): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < messages.length; ) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "assistant") {
|
||||
const parts = assistantTranscriptParts(message);
|
||||
out = appendLegacySegment(
|
||||
out,
|
||||
`Assistant: ${config.renderThinking(parts.thinking)}${parts.text}${config.renderCalls(parts.toolCalls, options)}`,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const run = collectToolResultRun(messages, i);
|
||||
out = appendLegacySegment(out, `Human: ${config.renderResults(run.results, options)}`);
|
||||
i = run.next;
|
||||
continue;
|
||||
}
|
||||
const text = messageContentText(message.content);
|
||||
out = message.role === "developer" ? appendLegacyPlain(out, text) : appendLegacySegment(out, `Human: ${text}`);
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function assistantTranscriptParts(message: AssistantMessage): AssistantTranscriptParts {
|
||||
let text = "";
|
||||
const thinking: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") text += block.text;
|
||||
else if (block.type === "thinking") thinking.push(block.thinking);
|
||||
else if (block.type === "toolCall") toolCalls.push(block);
|
||||
}
|
||||
return { text, thinking: thinking.join("\n"), toolCalls };
|
||||
}
|
||||
|
||||
export function collectToolResultRun(
|
||||
messages: readonly Message[],
|
||||
start: number,
|
||||
): { readonly results: readonly DialectToolResult[]; readonly next: number } {
|
||||
const results: DialectToolResult[] = [];
|
||||
let next = start;
|
||||
while (next < messages.length && messages[next]!.role === "toolResult") {
|
||||
results.push(toolResultToDialectResult(messages[next] as ToolResultMessage, results.length));
|
||||
next++;
|
||||
}
|
||||
return { results, next };
|
||||
}
|
||||
|
||||
function toolResultToDialectResult(message: ToolResultMessage, index: number): DialectToolResult {
|
||||
return {
|
||||
id: message.toolCallId,
|
||||
name: message.toolName,
|
||||
index,
|
||||
text: messageContentText(message.content),
|
||||
isError: message.isError,
|
||||
};
|
||||
}
|
||||
|
||||
export function messageContentText(
|
||||
content: string | readonly { readonly type: string; readonly text?: string; readonly mimeType?: string }[],
|
||||
): string {
|
||||
if (typeof content === "string") return content;
|
||||
let text = "";
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text !== undefined) text += block.text;
|
||||
else if (block.type === "image") text += block.mimeType ? `[Image: ${block.mimeType}]` : "[Image]";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function renderDelimitedThinking(open: string, close: string, text: string): string {
|
||||
if (!text) return "";
|
||||
return `${open}\n${text}\n${close}`;
|
||||
}
|
||||
|
||||
export function chatMlTurn(role: "assistant" | "system" | "tool" | "user", body: string): string {
|
||||
return `<|im_start|>${role}\n${body}<|im_end|>\n`;
|
||||
}
|
||||
|
||||
export function kimiTurn(role: "assistant" | "system" | "user", name: string, body: string): string {
|
||||
return `<|im_${role}|>${name}<|im_middle|>${body}<|im_end|>`;
|
||||
}
|
||||
|
||||
export function gemmaTurn(role: "model" | "system" | "user", body: string): string {
|
||||
return `<|turn>${role}\n${body}<turn|>`;
|
||||
}
|
||||
|
||||
export function geminiTurn(role: "model" | "user", body: string): string {
|
||||
return `<start_of_turn>${role}\n${body}<end_of_turn>\n`;
|
||||
}
|
||||
|
||||
export function joinUserBodies(left: string, right: string): string {
|
||||
if (!left) return right;
|
||||
if (!right) return left;
|
||||
return `${left}\n${right}`;
|
||||
}
|
||||
|
||||
function appendLegacyPlain(out: string, text: string): string {
|
||||
if (!text) return out;
|
||||
return out ? `${out}\n\n${text}` : text;
|
||||
}
|
||||
|
||||
function appendLegacySegment(out: string, segment: string): string {
|
||||
return `${out}\n\n${segment}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolCallSyntax } from "@oh-my-pi/pi-catalog/identity";
|
||||
import type { Context, ToolCall } from "../types";
|
||||
import type { Dialect as CatalogDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import type { Context, Message, ToolCall } from "../types";
|
||||
|
||||
export type { ToolCallSyntax };
|
||||
export type { Dialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
|
||||
export type InbandScanEvent =
|
||||
| { type: "text"; text: string }
|
||||
@@ -17,7 +17,7 @@ export interface InbandScanner {
|
||||
flush(): InbandScanEvent[];
|
||||
}
|
||||
|
||||
export interface GrammarToolResult {
|
||||
export interface DialectToolResult {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly index: number;
|
||||
@@ -25,30 +25,32 @@ export interface GrammarToolResult {
|
||||
readonly isError: boolean;
|
||||
}
|
||||
|
||||
export interface GrammarRenderOptions {
|
||||
export interface DialectRenderOptions {
|
||||
readonly tools?: readonly InbandTool[];
|
||||
readonly example?: boolean;
|
||||
}
|
||||
|
||||
export interface Grammar {
|
||||
readonly syntax: ToolCallSyntax;
|
||||
export interface DialectDefinition {
|
||||
readonly dialect: CatalogDialect;
|
||||
readonly prompt: string;
|
||||
createScanner(options?: InbandScannerOptions): InbandScanner;
|
||||
/** Render a single tool-call invocation — the inner element only, WITHOUT any parallel-call block envelope (e.g. anthropic's `<function_calls>` / kimi's section wrapper). */
|
||||
renderToolCall(call: ToolCall, options?: GrammarRenderOptions): string;
|
||||
/** Render a batch of (parallel) tool calls as one complete block, including whatever envelope the syntax wraps multiple calls in. */
|
||||
renderAssistantToolCalls(calls: readonly ToolCall[], options?: GrammarRenderOptions): string;
|
||||
renderToolResults(results: readonly GrammarToolResult[], options?: GrammarRenderOptions): string;
|
||||
renderToolCall(call: ToolCall, options?: DialectRenderOptions): string;
|
||||
/** Render a batch of (parallel) tool calls as one complete block, including whatever envelope the dialect wraps multiple calls in. */
|
||||
renderAssistantToolCalls(calls: readonly ToolCall[], options?: DialectRenderOptions): string;
|
||||
renderToolResults(results: readonly DialectToolResult[], options?: DialectRenderOptions): string;
|
||||
renderThinking(text: string): string;
|
||||
renderTranscript(messages: readonly Message[], options?: DialectRenderOptions): string;
|
||||
}
|
||||
|
||||
export interface InbandScannerOptions {
|
||||
/** string-typed arg names for a tool → read verbatim. Ignored by JSON-carrying syntaxes. */
|
||||
/** string-typed arg names for a tool → read verbatim. Ignored by JSON-carrying dialects. */
|
||||
stringArgs?: (toolName: string) => ReadonlySet<string>;
|
||||
/** Full tool schemas for schema-driven syntaxes such as GLM XML and pi-native. */
|
||||
/** Full tool schemas for schema-driven dialects such as GLM XML and pi-native. */
|
||||
tools?: readonly InbandTool[];
|
||||
/** XML only: parse pipe-wrapped DeepSeek DSML tags vs plain Anthropic invoke/parameter tags. */
|
||||
xmlTagset?: "anthropic" | "dsml";
|
||||
/** Emit thinking markers as thinking events instead of visible text when the syntax defines them. */
|
||||
/** Emit thinking markers as thinking events instead of visible text when the dialect defines them. */
|
||||
parseThinking?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Message, ToolCall } from "../types";
|
||||
import { AnthropicInbandScanner } from "./anthropic";
|
||||
import { buildArgShapes, type ToolArgShape } from "./coercion";
|
||||
import { DeepSeekInbandScanner } from "./deepseek";
|
||||
import {
|
||||
escapeXmlAttr,
|
||||
renderDelimitedThinking,
|
||||
renderLegacyTextTranscript,
|
||||
renderToolResponseResults,
|
||||
stringifyJson,
|
||||
} from "./rendering";
|
||||
import type {
|
||||
DialectDefinition,
|
||||
DialectRenderOptions,
|
||||
DialectToolResult,
|
||||
InbandScanEvent,
|
||||
InbandScanner,
|
||||
InbandScannerOptions,
|
||||
} from "./types";
|
||||
import dialectPrompt from "./xml.md" with { type: "text" };
|
||||
|
||||
export class XmlInbandScanner implements InbandScanner {
|
||||
readonly #inner: InbandScanner;
|
||||
|
||||
constructor(options: InbandScannerOptions = {}) {
|
||||
this.#inner =
|
||||
options.xmlTagset === "dsml" ? new DeepSeekInbandScanner(options) : new AnthropicInbandScanner(options);
|
||||
}
|
||||
|
||||
feed(text: string): InbandScanEvent[] {
|
||||
return this.#inner.feed(text);
|
||||
}
|
||||
|
||||
flush(): InbandScanEvent[] {
|
||||
return this.#inner.flush();
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolCall(call: ToolCall, options: DialectRenderOptions = {}): string {
|
||||
return renderInvoke(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string {
|
||||
return renderInvokes(calls, options.tools ?? []);
|
||||
}
|
||||
|
||||
function renderToolResults(results: readonly DialectToolResult[]): string {
|
||||
return renderToolResponseResults(results);
|
||||
}
|
||||
|
||||
function renderThinking(text: string): string {
|
||||
return renderDelimitedThinking("<thinking>", "</thinking>", text);
|
||||
}
|
||||
|
||||
function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string {
|
||||
return renderLegacyTextTranscript(messages, options, {
|
||||
renderThinking,
|
||||
renderCalls: renderAssistantToolCalls,
|
||||
renderResults: renderToolResults,
|
||||
});
|
||||
}
|
||||
|
||||
function renderInvoke(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
let body = `<invoke name="${escapeXmlAttr(call.name)}">`;
|
||||
for (const key in call.arguments) {
|
||||
const value = call.arguments[key];
|
||||
const isString = shape?.stringArgs.has(key) === true;
|
||||
const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
|
||||
body += `<parameter name="${escapeXmlAttr(key)}">${rendered}</parameter>`;
|
||||
}
|
||||
return `${body}</invoke>`;
|
||||
}
|
||||
|
||||
function renderInvokes(calls: readonly ToolCall[], tools: NonNullable<DialectRenderOptions["tools"]>): string {
|
||||
const shapes = buildArgShapes(tools);
|
||||
return calls.map(call => renderInvoke(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
const definition: DialectDefinition = {
|
||||
dialect: "xml",
|
||||
prompt: dialectPrompt,
|
||||
createScanner: options => new XmlInbandScanner(options),
|
||||
renderToolCall,
|
||||
renderAssistantToolCalls,
|
||||
renderToolResults,
|
||||
renderThinking,
|
||||
renderTranscript,
|
||||
};
|
||||
|
||||
export default definition;
|
||||
@@ -1,34 +0,0 @@
|
||||
import anthropicGrammar from "./anthropic";
|
||||
import deepseekGrammar from "./deepseek";
|
||||
import geminiGrammar from "./gemini";
|
||||
import gemmaGrammar from "./gemma";
|
||||
import glmGrammar from "./glm";
|
||||
import harmonyGrammar from "./harmony";
|
||||
import hermesGrammar from "./hermes";
|
||||
import kimiGrammar from "./kimi";
|
||||
import piGrammar from "./pi";
|
||||
import qwen3Grammar from "./qwen3";
|
||||
import type { Grammar, InbandScanner, InbandScannerOptions, ToolCallSyntax } from "./types";
|
||||
import xmlGrammar from "./xml";
|
||||
|
||||
const GRAMMARS: Record<ToolCallSyntax, Grammar> = {
|
||||
glm: glmGrammar,
|
||||
hermes: hermesGrammar,
|
||||
kimi: kimiGrammar,
|
||||
xml: xmlGrammar,
|
||||
anthropic: anthropicGrammar,
|
||||
deepseek: deepseekGrammar,
|
||||
harmony: harmonyGrammar,
|
||||
pi: piGrammar,
|
||||
qwen3: qwen3Grammar,
|
||||
gemini: geminiGrammar,
|
||||
gemma: gemmaGrammar,
|
||||
};
|
||||
|
||||
export function getInbandGrammar(syntax: ToolCallSyntax): Grammar {
|
||||
return GRAMMARS[syntax];
|
||||
}
|
||||
|
||||
export function createInbandScanner(syntax: ToolCallSyntax, options: InbandScannerOptions = {}): InbandScanner {
|
||||
return getInbandGrammar(syntax).createScanner(options);
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import type { ToolCall } from "../types";
|
||||
import {
|
||||
buildArgShapes,
|
||||
getArrayItemSchema,
|
||||
getObjectProperties,
|
||||
isStringOnlySchema,
|
||||
type ToolArgShape,
|
||||
} from "./coercion";
|
||||
import type { GrammarRenderOptions, GrammarToolResult, InbandTool } from "./types";
|
||||
|
||||
const DEEPSEEK_TOOL_CALLS_BEGIN = "<|tool▁calls▁begin|>";
|
||||
const DEEPSEEK_TOOL_CALLS_END = "<|tool▁calls▁end|>";
|
||||
const DEEPSEEK_TOOL_CALL_BEGIN = "<|tool▁call▁begin|>";
|
||||
const DEEPSEEK_TOOL_CALL_END = "<|tool▁call▁end|>";
|
||||
const DEEPSEEK_TOOL_SEPARATOR = "<|tool▁sep|>";
|
||||
const DEEPSEEK_TOOL_OUTPUT_BEGIN = "<|tool▁output▁begin|>";
|
||||
const DEEPSEEK_TOOL_OUTPUT_END = "<|tool▁output▁end|>";
|
||||
|
||||
export function renderGlmInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string {
|
||||
return glmInvocation(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
function glmInvocation(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
let body = `<tool_call>${call.name}`;
|
||||
for (const key in call.arguments) {
|
||||
const value = call.arguments[key];
|
||||
const rendered = shape?.stringArgs.has(key) && typeof value === "string" ? value : stringifyJson(value);
|
||||
body += `\n<arg_key>${key}</arg_key>\n<arg_value>${rendered}</arg_value>`;
|
||||
}
|
||||
return `${body}\n</tool_call>`;
|
||||
}
|
||||
|
||||
export function renderGlmToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
const shapes = buildArgShapes(options.tools);
|
||||
return calls.map(call => glmInvocation(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
export function renderGlmToolResults(results: readonly GrammarToolResult[]): string {
|
||||
return `<observation>\n${renderToolResponseResults(results)}\n</observation>`;
|
||||
}
|
||||
|
||||
export function renderHermesInvocation(call: ToolCall, _options: GrammarRenderOptions = {}): string {
|
||||
return `<tool_call>\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n</tool_call>`;
|
||||
}
|
||||
|
||||
export function renderHermesToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
return calls.map(call => renderHermesInvocation(call, options)).join("\n");
|
||||
}
|
||||
|
||||
export function renderKimiInvocation(call: ToolCall, _options: GrammarRenderOptions = {}): string {
|
||||
return kimiInvocation(call, 0);
|
||||
}
|
||||
|
||||
function kimiInvocation(call: ToolCall, index: number): string {
|
||||
return `<|tool_call_begin|>${kimiCallId(call.name, call.id, index)}<|tool_call_argument_begin|>${stringifyJson(call.arguments)}<|tool_call_end|>`;
|
||||
}
|
||||
|
||||
export function renderKimiToolCalls(calls: readonly ToolCall[]): string {
|
||||
if (calls.length === 0) return "";
|
||||
const body = calls.map((call, index) => kimiInvocation(call, index)).join("");
|
||||
return `<|tool_calls_section_begin|>${body}<|tool_calls_section_end|>`;
|
||||
}
|
||||
|
||||
export function renderKimiToolResults(results: readonly GrammarToolResult[]): string {
|
||||
return results
|
||||
.map(
|
||||
result =>
|
||||
`<|im_system|>${result.name}<|im_middle|>## Return of ${kimiCallId(result.name, result.id, result.index)}\n${result.text}<|im_end|>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function renderDeepSeekInvocation(call: ToolCall, _options: GrammarRenderOptions = {}): string {
|
||||
return `${DEEPSEEK_TOOL_CALL_BEGIN}${call.name}${DEEPSEEK_TOOL_SEPARATOR}${stringifyJson(call.arguments)}${DEEPSEEK_TOOL_CALL_END}`;
|
||||
}
|
||||
|
||||
export function renderDeepSeekToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
if (calls.length === 0) return "";
|
||||
const body = calls.map(call => renderDeepSeekInvocation(call, options)).join("");
|
||||
return `${DEEPSEEK_TOOL_CALLS_BEGIN}${body}${DEEPSEEK_TOOL_CALLS_END}`;
|
||||
}
|
||||
|
||||
export function renderDeepSeekToolResults(results: readonly GrammarToolResult[]): string {
|
||||
return results.map(result => `${DEEPSEEK_TOOL_OUTPUT_BEGIN}${result.text}${DEEPSEEK_TOOL_OUTPUT_END}`).join("\n");
|
||||
}
|
||||
|
||||
export function renderHarmonyInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string {
|
||||
if (options.example) {
|
||||
return stringifyJson(call.arguments);
|
||||
}
|
||||
return `<|start|>assistant<|channel|>commentary to=${harmonyRecipient(call.name)}<|message|>${stringifyJson(call.arguments)}<|call|>`;
|
||||
}
|
||||
|
||||
export function renderHarmonyToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
return calls.map(call => renderHarmonyInvocation(call, options)).join("");
|
||||
}
|
||||
|
||||
export function renderHarmonyToolResults(results: readonly GrammarToolResult[]): string {
|
||||
return results
|
||||
.map(
|
||||
result =>
|
||||
`<|start|>${harmonyRecipient(result.name)} to=assistant<|channel|>commentary<|message|>${result.text}<|end|>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function renderAnthropicInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string {
|
||||
return renderXmlInvoke(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
export function renderAnthropicToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
if (calls.length === 0) return "";
|
||||
return `<function_calls>\n${renderXmlInvokes(calls, options.tools ?? [])}\n</function_calls>`;
|
||||
}
|
||||
|
||||
export function renderAnthropicToolResults(results: readonly GrammarToolResult[]): string {
|
||||
const body = results
|
||||
.map(result => {
|
||||
const tag = result.isError ? "error" : "result";
|
||||
const streamTag = result.isError ? "stderr" : "stdout";
|
||||
return `<${tag}>\n<tool_name>${escapeXmlText(result.name)}</tool_name>\n<${streamTag}>${result.text}</${streamTag}>\n</${tag}>`;
|
||||
})
|
||||
.join("\n");
|
||||
return `<function_results>\n${body}\n</function_results>`;
|
||||
}
|
||||
|
||||
export function renderXmlInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string {
|
||||
return renderXmlInvoke(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
export function renderXmlToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
return renderXmlInvokes(calls, options.tools ?? []);
|
||||
}
|
||||
|
||||
export function renderPiNativeInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string {
|
||||
return piInvocation(call, buildArgShapes(options.tools).get(call.name));
|
||||
}
|
||||
|
||||
function piInvocation(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
let body = `<call:${call.name}>`;
|
||||
for (const key in call.arguments) {
|
||||
body += `\n${renderPiNativeElement(key, call.arguments[key], shape?.properties[key])}`;
|
||||
}
|
||||
return `${body}\n</call:${call.name}>`;
|
||||
}
|
||||
|
||||
export function renderPiNativeToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
const shapes = buildArgShapes(options.tools);
|
||||
return calls.map(call => piInvocation(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
export function renderToolResponseResults(results: readonly GrammarToolResult[]): string {
|
||||
return results.map(result => `<tool_response>\n${result.text}\n</tool_response>`).join("\n");
|
||||
}
|
||||
|
||||
function renderXmlInvoke(call: ToolCall, shape: ToolArgShape | undefined): string {
|
||||
let body = `<invoke name="${escapeXmlAttr(call.name)}">`;
|
||||
for (const key in call.arguments) {
|
||||
const value = call.arguments[key];
|
||||
const isString = shape?.stringArgs.has(key) === true;
|
||||
const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
|
||||
body += `<parameter name="${escapeXmlAttr(key)}">${rendered}</parameter>`;
|
||||
}
|
||||
return `${body}</invoke>`;
|
||||
}
|
||||
|
||||
function renderXmlInvokes(calls: readonly ToolCall[], tools: readonly InbandTool[]): string {
|
||||
const shapes = buildArgShapes(tools);
|
||||
return calls.map(call => renderXmlInvoke(call, shapes.get(call.name))).join("\n");
|
||||
}
|
||||
|
||||
function renderPiNativeElement(key: string, value: unknown, schema: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
const itemSchema = getArrayItemSchema(schema);
|
||||
return value.map(item => renderPiNativeElement(key, item, itemSchema)).join("\n");
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
const properties = getObjectProperties(schema);
|
||||
let body = `<${key}>`;
|
||||
for (const childKey in record) {
|
||||
body += `\n${renderPiNativeElement(childKey, record[childKey], properties[childKey])}`;
|
||||
}
|
||||
return `${body}\n</${key}>`;
|
||||
}
|
||||
return `<${key}>${renderPiNativeScalar(value, schema)}</${key}>`;
|
||||
}
|
||||
|
||||
function renderPiNativeScalar(value: unknown, schema: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (isStringOnlySchema(schema) && value === null) return "";
|
||||
return stringifyJson(value);
|
||||
}
|
||||
|
||||
function kimiCallId(name: string, id: string, index: number): string {
|
||||
const trimmed = id.trim();
|
||||
return trimmed.startsWith("functions.") ? trimmed : `functions.${name}:${index}`;
|
||||
}
|
||||
|
||||
function harmonyRecipient(name: string): string {
|
||||
return name.startsWith("functions.") ? name : `functions.${name}`;
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(value) ?? "null";
|
||||
}
|
||||
|
||||
function escapeXmlAttr(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function escapeXmlText(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
// --- Gemini: Pythonic `tool_code` / `default_api` convention ---
|
||||
|
||||
const GEMINI_CODE_OPEN = "```tool_code";
|
||||
const GEMINI_OUTPUT_OPEN = "```tool_outputs";
|
||||
const GEMINI_FENCE = "```";
|
||||
|
||||
export function renderGeminiInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string {
|
||||
const kwargs = Object.entries(call.arguments)
|
||||
.map(([key, value]) => `${key}=${pyValue(value)}`)
|
||||
.join(", ");
|
||||
return options.example ? `${call.name}(${kwargs})` : `default_api.${call.name}(${kwargs})`;
|
||||
}
|
||||
|
||||
export function renderGeminiToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
// One call renders bare; parallel calls render as a Python list `[a, b]`.
|
||||
const body =
|
||||
calls.length === 1
|
||||
? renderGeminiInvocation(calls[0]!, options)
|
||||
: `[${calls.map(call => renderGeminiInvocation(call, options)).join(", ")}]`;
|
||||
// Examples show the bare call; the live wire form fences it as `tool_code`.
|
||||
return options.example ? body : `${GEMINI_CODE_OPEN}\n${body}\n${GEMINI_FENCE}`;
|
||||
}
|
||||
|
||||
export function renderGeminiToolResults(results: readonly GrammarToolResult[]): string {
|
||||
return results.map(result => `${GEMINI_OUTPUT_OPEN}\n${result.text}\n${GEMINI_FENCE}`).join("\n");
|
||||
}
|
||||
|
||||
function pyValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "None";
|
||||
if (typeof value === "boolean") return value ? "True" : "False";
|
||||
if (typeof value === "number") return Number.isFinite(value) ? String(value) : pyString(String(value));
|
||||
if (typeof value === "string") return pyString(value);
|
||||
if (Array.isArray(value)) return `[${value.map(pyValue).join(", ")}]`;
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return `{${entries.map(([key, val]) => `${pyString(key)}: ${pyValue(val)}`).join(", ")}}`;
|
||||
}
|
||||
return pyString(String(value));
|
||||
}
|
||||
|
||||
function pyString(value: string): string {
|
||||
const escaped = value
|
||||
.replaceAll("\\", "\\\\")
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll("\n", "\\n")
|
||||
.replaceAll("\r", "\\r")
|
||||
.replaceAll("\t", "\\t");
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
// --- Gemma 4: token-delimited `call:NAME{…}` convention ---
|
||||
|
||||
const GEMMA_CALL_OPEN = "<|tool_call>";
|
||||
const GEMMA_CALL_CLOSE = "<tool_call|>";
|
||||
const GEMMA_RESPONSE_OPEN = "<|tool_response>";
|
||||
const GEMMA_RESPONSE_CLOSE = "<tool_response|>";
|
||||
const GEMMA_STRING = '<|"|>';
|
||||
|
||||
export function renderGemmaInvocation(call: ToolCall, _options: GrammarRenderOptions = {}): string {
|
||||
const args = Object.entries(call.arguments)
|
||||
.map(([key, value]) => `${key}:${gemmaValue(value)}`)
|
||||
.join(",");
|
||||
return `${GEMMA_CALL_OPEN}call:${call.name}{${args}}${GEMMA_CALL_CLOSE}`;
|
||||
}
|
||||
|
||||
export function renderGemmaToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string {
|
||||
return calls.map(call => renderGemmaInvocation(call, options)).join("");
|
||||
}
|
||||
|
||||
export function renderGemmaToolResults(results: readonly GrammarToolResult[]): string {
|
||||
return results
|
||||
.map(
|
||||
result =>
|
||||
`${GEMMA_RESPONSE_OPEN}response:${result.name}{output:${gemmaValue(parseMaybeJson(result.text))}}${GEMMA_RESPONSE_CLOSE}`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function gemmaValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return `${GEMMA_STRING}${value}${GEMMA_STRING}`;
|
||||
if (Array.isArray(value)) return `[${value.map(gemmaValue).join(",")}]`;
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
return `{${entries.map(([key, val]) => `${key}:${gemmaValue(val)}`).join(",")}}`;
|
||||
}
|
||||
return `${GEMMA_STRING}${String(value)}${GEMMA_STRING}`;
|
||||
}
|
||||
|
||||
function parseMaybeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { AnthropicInbandScanner } from "./anthropic";
|
||||
import { DeepSeekInbandScanner } from "./deepseek";
|
||||
import { renderToolResponseResults, renderXmlInvocation, renderXmlToolCalls } from "./rendering";
|
||||
import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types";
|
||||
import grammarPrompt from "./xml.md" with { type: "text" };
|
||||
|
||||
export class XmlInbandScanner implements InbandScanner {
|
||||
readonly #inner: InbandScanner;
|
||||
|
||||
constructor(options: InbandScannerOptions = {}) {
|
||||
this.#inner =
|
||||
options.xmlTagset === "dsml" ? new DeepSeekInbandScanner(options) : new AnthropicInbandScanner(options);
|
||||
}
|
||||
|
||||
feed(text: string): InbandScanEvent[] {
|
||||
return this.#inner.feed(text);
|
||||
}
|
||||
|
||||
flush(): InbandScanEvent[] {
|
||||
return this.#inner.flush();
|
||||
}
|
||||
}
|
||||
|
||||
const grammar: Grammar = {
|
||||
syntax: "xml",
|
||||
prompt: grammarPrompt,
|
||||
createScanner: options => new XmlInbandScanner(options),
|
||||
renderToolCall: renderXmlInvocation,
|
||||
renderAssistantToolCalls: renderXmlToolCalls,
|
||||
renderToolResults: renderToolResponseResults,
|
||||
};
|
||||
|
||||
export default grammar;
|
||||
@@ -3,15 +3,15 @@
|
||||
*
|
||||
* Hosted models sometimes leak raw template markup into visible `content` instead
|
||||
* of returning structured events. Tool-call healing delegates to the same
|
||||
* grammar scanners used by owned in-band tool calling; this file keeps the
|
||||
* dialect scanners used by owned in-band tool calling; this file keeps the
|
||||
* provider-facing compatibility wrapper and model/provider gating.
|
||||
*/
|
||||
|
||||
import { isDeepseekModelIdOrName } from "@oh-my-pi/pi-catalog/identity";
|
||||
|
||||
import { createInbandScanner } from "../grammar/factory";
|
||||
import { ThinkingInbandScanner } from "../grammar/thinking";
|
||||
import type { InbandScanEvent, InbandScanner } from "../grammar/types";
|
||||
import { createInbandScanner } from "../dialect/factory";
|
||||
import { ThinkingInbandScanner } from "../dialect/thinking";
|
||||
import type { InbandScanEvent, InbandScanner } from "../dialect/types";
|
||||
|
||||
const KIMI_SECTION_END = "<|tool_calls_section_end|>";
|
||||
const DSML_TOOL_CALLS_CLOSE_FULLWIDTH = "</|DSML|tool_calls>";
|
||||
|
||||
+16
-21
@@ -1,14 +1,9 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
createInbandScanner,
|
||||
getInbandGrammar,
|
||||
type InbandScanEvent,
|
||||
type ToolCallSyntax,
|
||||
} from "@oh-my-pi/pi-ai/grammar";
|
||||
import { createInbandScanner, type Dialect, getDialectDefinition, type InbandScanEvent } from "@oh-my-pi/pi-ai/dialect";
|
||||
|
||||
function scan(syntax: ToolCallSyntax, text: string, charByChar = false): InbandScanEvent[] {
|
||||
const scanner = createInbandScanner(syntax);
|
||||
function scan(dialect: Dialect, text: string, charByChar = false): InbandScanEvent[] {
|
||||
const scanner = createInbandScanner(dialect);
|
||||
const events: InbandScanEvent[] = [];
|
||||
if (charByChar) for (const ch of text) events.push(...scanner.feed(ch));
|
||||
else events.push(...scanner.feed(text));
|
||||
@@ -17,11 +12,11 @@ function scan(syntax: ToolCallSyntax, text: string, charByChar = false): InbandS
|
||||
}
|
||||
|
||||
function parsedCalls(
|
||||
syntax: ToolCallSyntax,
|
||||
dialect: Dialect,
|
||||
text: string,
|
||||
charByChar = false,
|
||||
): { name: string; arguments: Record<string, unknown> }[] {
|
||||
return scan(syntax, text, charByChar)
|
||||
return scan(dialect, text, charByChar)
|
||||
.filter((event): event is Extract<InbandScanEvent, { type: "toolEnd" }> => event.type === "toolEnd")
|
||||
.map(event => ({ name: event.name, arguments: event.arguments }));
|
||||
}
|
||||
@@ -40,7 +35,7 @@ const call = (name: string, args: Record<string, unknown>): ToolCall => ({
|
||||
arguments: args,
|
||||
});
|
||||
|
||||
describe("gemini grammar (Pythonic tool_code)", () => {
|
||||
describe("gemini dialect (Pythonic tool_code)", () => {
|
||||
it("parses the print(default_api...) form", () => {
|
||||
const calls = parsedCalls("gemini", "```tool_code\nprint(default_api.read(path='a.ts', count=2))\n```");
|
||||
expect(calls).toEqual([{ name: "read", arguments: { path: "a.ts", count: 2 } }]);
|
||||
@@ -112,8 +107,8 @@ describe("gemini grammar (Pythonic tool_code)", () => {
|
||||
});
|
||||
|
||||
it("renders parallel calls as a list and round-trips through the scanner", () => {
|
||||
const grammar = getInbandGrammar("gemini");
|
||||
const rendered = grammar.renderAssistantToolCalls([
|
||||
const definition = getDialectDefinition("gemini");
|
||||
const rendered = definition.renderAssistantToolCalls([
|
||||
call("read", { path: "a" }),
|
||||
call("write", { path: "b", content: "c" }),
|
||||
]);
|
||||
@@ -127,21 +122,21 @@ describe("gemini grammar (Pythonic tool_code)", () => {
|
||||
});
|
||||
|
||||
it("renders examples without a fence or print wrapper", () => {
|
||||
const grammar = getInbandGrammar("gemini");
|
||||
expect(grammar.renderToolCall(call("read", { path: "a.ts" }), { example: true })).toBe('read(path="a.ts")');
|
||||
expect(grammar.renderAssistantToolCalls([call("read", { path: "a.ts" })], { example: true })).toBe(
|
||||
const definition = getDialectDefinition("gemini");
|
||||
expect(definition.renderToolCall(call("read", { path: "a.ts" }), { example: true })).toBe('read(path="a.ts")');
|
||||
expect(definition.renderAssistantToolCalls([call("read", { path: "a.ts" })], { example: true })).toBe(
|
||||
'read(path="a.ts")',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes special characters on render and decodes them on parse", () => {
|
||||
const grammar = getInbandGrammar("gemini");
|
||||
const rendered = grammar.renderAssistantToolCalls([call("write", { content: 'a "b"\n\tc\\d' })]);
|
||||
const definition = getDialectDefinition("gemini");
|
||||
const rendered = definition.renderAssistantToolCalls([call("write", { content: 'a "b"\n\tc\\d' })]);
|
||||
expect(parsedCalls("gemini", rendered)).toEqual([{ name: "write", arguments: { content: 'a "b"\n\tc\\d' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gemma grammar (token-delimited call:NAME{…})", () => {
|
||||
describe("gemma dialect (token-delimited call:NAME{…})", () => {
|
||||
it("parses a single call with string and scalar args", () => {
|
||||
const calls = parsedCalls("gemma", '<|tool_call>call:read{path:<|"|>a.ts<|"|>,count:2}<tool_call|>');
|
||||
expect(calls).toEqual([{ name: "read", arguments: { path: "a.ts", count: 2 } }]);
|
||||
@@ -187,8 +182,8 @@ describe("gemma grammar (token-delimited call:NAME{…})", () => {
|
||||
});
|
||||
|
||||
it("renders calls that round-trip through the scanner", () => {
|
||||
const grammar = getInbandGrammar("gemma");
|
||||
const rendered = grammar.renderAssistantToolCalls([
|
||||
const definition = getDialectDefinition("gemma");
|
||||
const rendered = definition.renderAssistantToolCalls([
|
||||
call("read", { path: "a" }),
|
||||
call("write", { path: "b", content: "c" }),
|
||||
]);
|
||||
@@ -2,14 +2,14 @@ import { describe, expect, it } from "bun:test";
|
||||
import type { AssistantMessage, Context, ToolCall, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
createInbandScanner,
|
||||
type Dialect,
|
||||
type DialectToolResult,
|
||||
encodeInbandToolHistory,
|
||||
type GrammarToolResult,
|
||||
getInbandGrammar,
|
||||
getDialectDefinition,
|
||||
type InbandScanEvent,
|
||||
parseInbandToolMessage,
|
||||
renderInbandToolPrompt,
|
||||
type ToolCallSyntax,
|
||||
} from "@oh-my-pi/pi-ai/grammar";
|
||||
} from "@oh-my-pi/pi-ai/dialect";
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
@@ -32,7 +32,7 @@ const TOOLS = [
|
||||
},
|
||||
] as unknown as NonNullable<Context["tools"]>;
|
||||
|
||||
const SYNTAXES: readonly ToolCallSyntax[] = [
|
||||
const DIALECTS: readonly Dialect[] = [
|
||||
"glm",
|
||||
"hermes",
|
||||
"kimi",
|
||||
@@ -74,8 +74,8 @@ function result(toolCallId: string, toolName: string, text: string, isError = fa
|
||||
return { role: "toolResult", toolCallId, toolName, content: [{ type: "text", text }], isError, timestamp: 0 };
|
||||
}
|
||||
|
||||
function feedText(syntax: ToolCallSyntax, text: string): InbandScanEvent[] {
|
||||
const scanner = createInbandScanner(syntax, { tools: TOOLS, parseThinking: true });
|
||||
function feedText(dialect: Dialect, text: string): InbandScanEvent[] {
|
||||
const scanner = createInbandScanner(dialect, { tools: TOOLS, parseThinking: true });
|
||||
const events: InbandScanEvent[] = [];
|
||||
for (const char of text) events.push(...scanner.feed(char));
|
||||
events.push(...scanner.flush());
|
||||
@@ -86,37 +86,37 @@ function toolEnds(events: readonly InbandScanEvent[]): Extract<InbandScanEvent,
|
||||
return events.filter((event): event is Extract<InbandScanEvent, { type: "toolEnd" }> => event.type === "toolEnd");
|
||||
}
|
||||
|
||||
function firstRawBlock(syntax: ToolCallSyntax, text: string): string | undefined {
|
||||
return toolEnds(feedText(syntax, text))[0]?.rawBlock;
|
||||
function firstRawBlock(dialect: Dialect, text: string): string | undefined {
|
||||
return toolEnds(feedText(dialect, text))[0]?.rawBlock;
|
||||
}
|
||||
|
||||
function expectRawBlock(syntax: ToolCallSyntax, text: string, expected: string): void {
|
||||
expect(firstRawBlock(syntax, text), syntax).toBe(expected);
|
||||
function expectRawBlock(dialect: Dialect, text: string, expected: string): void {
|
||||
expect(firstRawBlock(dialect, text), dialect).toBe(expected);
|
||||
}
|
||||
|
||||
describe("in-band tool grammars", () => {
|
||||
it("renders a tool prompt for every syntax", () => {
|
||||
for (const syntax of SYNTAXES) {
|
||||
const prompt = renderInbandToolPrompt(TOOLS, syntax);
|
||||
describe("in-band tool dialects", () => {
|
||||
it("renders a tool prompt for every dialect", () => {
|
||||
for (const dialect of DIALECTS) {
|
||||
const prompt = renderInbandToolPrompt(TOOLS, dialect);
|
||||
expect(prompt).toContain("<tools>");
|
||||
expect(prompt).toContain("</tools>");
|
||||
expect(prompt).toContain('"name":"read"');
|
||||
expect(prompt).toContain(getInbandGrammar(syntax).prompt.trim().split("\n", 1)[0]!);
|
||||
expect(prompt).toContain(getDialectDefinition(dialect).prompt.trim().split("\n", 1)[0]!);
|
||||
}
|
||||
});
|
||||
|
||||
it("each grammar renders calls that its scanner parses back", () => {
|
||||
it("each dialect renders calls that its scanner parses back", () => {
|
||||
const call: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: "functions.read:0",
|
||||
name: "read",
|
||||
arguments: { path: "src/a.ts", count: 2 },
|
||||
};
|
||||
for (const syntax of SYNTAXES) {
|
||||
const grammar = getInbandGrammar(syntax);
|
||||
const rendered = grammar.renderAssistantToolCalls([call], { tools: TOOLS });
|
||||
const calls = toolEnds(feedText(syntax, rendered));
|
||||
expect(calls, syntax).toHaveLength(1);
|
||||
for (const dialect of DIALECTS) {
|
||||
const definition = getDialectDefinition(dialect);
|
||||
const rendered = definition.renderAssistantToolCalls([call], { tools: TOOLS });
|
||||
const calls = toolEnds(feedText(dialect, rendered));
|
||||
expect(calls, dialect).toHaveLength(1);
|
||||
expect(calls[0]!.name).toBe("read");
|
||||
expect(calls[0]!.arguments).toEqual({ path: "src/a.ts", count: 2 });
|
||||
}
|
||||
@@ -180,40 +180,42 @@ describe("in-band tool grammars", () => {
|
||||
expect(calls[0]?.arguments).toEqual({ path: "rubygems.ts:85-93" });
|
||||
});
|
||||
|
||||
it("keeps result rendering in the owning grammar", () => {
|
||||
const resultBlock: GrammarToolResult = {
|
||||
it("keeps result rendering in the owning dialect", () => {
|
||||
const resultBlock: DialectToolResult = {
|
||||
id: "functions.read:0",
|
||||
name: "read",
|
||||
index: 0,
|
||||
text: "FILE",
|
||||
isError: false,
|
||||
};
|
||||
expect(getInbandGrammar("glm").renderToolResults([resultBlock])).toBe(
|
||||
expect(getDialectDefinition("glm").renderToolResults([resultBlock])).toBe(
|
||||
"<observation>\n<tool_response>\nFILE\n</tool_response>\n</observation>",
|
||||
);
|
||||
expect(getInbandGrammar("deepseek").renderToolResults([resultBlock])).toBe(
|
||||
expect(getDialectDefinition("deepseek").renderToolResults([resultBlock])).toBe(
|
||||
"<|tool▁output▁begin|>FILE<|tool▁output▁end|>",
|
||||
);
|
||||
expect(getInbandGrammar("kimi").renderToolResults([resultBlock])).toBe(
|
||||
expect(getDialectDefinition("kimi").renderToolResults([resultBlock])).toBe(
|
||||
"<|im_system|>read<|im_middle|>## Return of functions.read:0\nFILE<|im_end|>",
|
||||
);
|
||||
expect(getInbandGrammar("harmony").renderToolResults([resultBlock])).toBe(
|
||||
expect(getDialectDefinition("harmony").renderToolResults([resultBlock])).toBe(
|
||||
"<|start|>functions.read to=assistant<|channel|>commentary<|message|>FILE<|end|>",
|
||||
);
|
||||
expect(getInbandGrammar("anthropic").renderToolResults([resultBlock])).toBe(
|
||||
expect(getDialectDefinition("anthropic").renderToolResults([resultBlock])).toBe(
|
||||
"<function_results>\n<result>\n<tool_name>read</tool_name>\n<stdout>FILE</stdout>\n</result>\n</function_results>",
|
||||
);
|
||||
expect(getInbandGrammar("qwen3").renderToolResults([resultBlock])).toBe(
|
||||
expect(getDialectDefinition("qwen3").renderToolResults([resultBlock])).toBe(
|
||||
"<tool_response>\nFILE\n</tool_response>",
|
||||
);
|
||||
expect(getInbandGrammar("pi").renderToolResults([resultBlock])).toBe("<tool_response>\nFILE\n</tool_response>");
|
||||
expect(getInbandGrammar("gemini").renderToolResults([resultBlock])).toBe("```tool_outputs\nFILE\n```");
|
||||
expect(getInbandGrammar("gemma").renderToolResults([resultBlock])).toBe(
|
||||
expect(getDialectDefinition("pi").renderToolResults([resultBlock])).toBe(
|
||||
"<tool_response>\nFILE\n</tool_response>",
|
||||
);
|
||||
expect(getDialectDefinition("gemini").renderToolResults([resultBlock])).toBe("```tool_outputs\nFILE\n```");
|
||||
expect(getDialectDefinition("gemma").renderToolResults([resultBlock])).toBe(
|
||||
'<|tool_response>response:read{output:<|"|>FILE<|"|>}<tool_response|>',
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes assistant calls and tool results through the selected grammar", () => {
|
||||
it("encodes assistant calls and tool results through the selected dialect", () => {
|
||||
const history: Context["messages"] = [
|
||||
{ role: "user", content: "hi", timestamp: 0 },
|
||||
assistant([
|
||||
@@ -236,7 +238,7 @@ describe("in-band tool grammars", () => {
|
||||
});
|
||||
|
||||
it("streams string arguments incrementally for GLM", () => {
|
||||
const text = getInbandGrammar("glm").renderAssistantToolCalls(
|
||||
const text = getDialectDefinition("glm").renderAssistantToolCalls(
|
||||
[
|
||||
{
|
||||
type: "toolCall",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { wrapInbandToolStream } from "../src/grammar/owned-stream";
|
||||
import { wrapInbandToolStream } from "../src/dialect/owned-stream";
|
||||
import type { AssistantMessage, ToolCall, Usage } from "../src/types";
|
||||
import { AssistantMessageEventStream } from "../src/utils/event-stream";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { wrapInbandToolStream } from "../src/grammar/owned-stream";
|
||||
import { wrapInbandToolStream } from "../src/dialect/owned-stream";
|
||||
import type { AssistantMessage, AssistantMessageEvent, ThinkingContent, ToolCall, Usage } from "../src/types";
|
||||
import { AssistantMessageEventStream } from "../src/utils/event-stream";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { renderToolExamples } from "../src/grammar/examples";
|
||||
import type { InbandTool } from "../src/grammar/types";
|
||||
import { renderToolExamples } from "../src/dialect/examples";
|
||||
import type { InbandTool } from "../src/dialect/types";
|
||||
|
||||
describe("renderToolExamples", () => {
|
||||
it("renders call example in anthropic format", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { z } from "zod/v4";
|
||||
import { renderToolInventory } from "../src/grammar/inventory";
|
||||
import type { InbandTool } from "../src/grammar/types";
|
||||
import { renderToolInventory } from "../src/dialect/inventory";
|
||||
import type { InbandTool } from "../src/dialect/types";
|
||||
|
||||
const searchTool: InbandTool = {
|
||||
name: "web_search",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { Message, Usage } from "@oh-my-pi/pi-ai";
|
||||
import { type Dialect, getDialectDefinition } from "@oh-my-pi/pi-ai/dialect";
|
||||
|
||||
function usage(): Usage {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: "Find pi", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "I should search." },
|
||||
{ type: "text", text: "Searching." },
|
||||
{ type: "toolCall", id: "call-1", name: "search", arguments: { query: "pi" } },
|
||||
],
|
||||
api: "mock",
|
||||
provider: "mock",
|
||||
model: "mock",
|
||||
usage: usage(),
|
||||
stopReason: "toolUse",
|
||||
timestamp: 2,
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "search",
|
||||
content: [{ type: "text", text: "result" }],
|
||||
isError: false,
|
||||
timestamp: 3,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
api: "mock",
|
||||
provider: "mock",
|
||||
model: "mock",
|
||||
usage: usage(),
|
||||
stopReason: "stop",
|
||||
timestamp: 4,
|
||||
},
|
||||
];
|
||||
|
||||
describe("dialect transcript rendering", () => {
|
||||
it("renders harmony turns with analysis, final, calls, and tool results", () => {
|
||||
const out = getDialectDefinition("harmony").renderTranscript(messages);
|
||||
|
||||
expect(out).toContain("<|start|>user<|message|>Find pi<|end|>");
|
||||
expect(out).toContain("<|start|>assistant<|channel|>analysis<|message|>I should search.<|end|>");
|
||||
expect(out).toContain("<|start|>assistant<|channel|>final<|message|>Searching.<|end|>");
|
||||
expect(out).toContain("to=functions.search");
|
||||
expect(out).toContain("<|start|>functions.search to=assistant<|channel|>commentary<|message|>result<|end|>");
|
||||
});
|
||||
|
||||
it("renders qwen3 ChatML turns with thinking and user tool-result turns", () => {
|
||||
const out = getDialectDefinition("qwen3").renderTranscript(messages);
|
||||
|
||||
expect(out).toContain("<|im_start|>assistant\n<think>\nI should search.\n</think>");
|
||||
expect(out).toContain("<|im_start|>user\n<tool_response>\nresult\n</tool_response><|im_end|>\n");
|
||||
expect(out).not.toContain("[User]:");
|
||||
});
|
||||
|
||||
it("renders GLM turns with BOS and observation result turns", () => {
|
||||
const out = getDialectDefinition("glm").renderTranscript(messages);
|
||||
|
||||
expect(out).toStartWith("[gMASK]<sop>");
|
||||
expect(out).toContain("<|assistant|>\n\n<think>\nI should search.\n</think>");
|
||||
expect(out).toContain("<|observation|>\n<tool_response>\nresult\n</tool_response>");
|
||||
});
|
||||
|
||||
it("renders anthropic legacy Human and Assistant turns", () => {
|
||||
const out = getDialectDefinition("anthropic").renderTranscript(messages);
|
||||
|
||||
expect(out).toContain("\n\nHuman: Find pi");
|
||||
expect(out).toContain("\n\nAssistant: <thinking>\nI should search.\n</thinking>");
|
||||
expect(out).toContain("<function_calls>");
|
||||
expect(out).toContain("<function_results>");
|
||||
expect(out).not.toContain("[Assistant tool calls]:");
|
||||
});
|
||||
|
||||
it("renders distinct native text for each sampled dialect", () => {
|
||||
const outputs = (["harmony", "qwen3", "glm", "anthropic"] satisfies readonly Dialect[]).map(dialect =>
|
||||
getDialectDefinition(dialect).renderTranscript(messages),
|
||||
);
|
||||
|
||||
expect(new Set(outputs).size).toBe(outputs.length);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the catalog-owned tool syntax API from `ToolCallSyntax`/`FALLBACK_TOOL_SYNTAX`/`preferredToolSyntax` to `Dialect`/`FALLBACK_DIALECT`/`preferredDialect`.
|
||||
|
||||
|
||||
## [15.13.3] - 2026-06-15
|
||||
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { modelFamilyToken } from "./family";
|
||||
|
||||
export type ToolCallSyntax =
|
||||
export type Dialect =
|
||||
| "glm"
|
||||
| "hermes"
|
||||
| "kimi"
|
||||
@@ -13,9 +13,9 @@ export type ToolCallSyntax =
|
||||
| "gemini"
|
||||
| "gemma";
|
||||
|
||||
export const FALLBACK_TOOL_SYNTAX: ToolCallSyntax = "xml";
|
||||
export const FALLBACK_DIALECT: Dialect = "xml";
|
||||
|
||||
export function preferredToolSyntax(modelId: string): ToolCallSyntax {
|
||||
export function preferredDialect(modelId: string): Dialect {
|
||||
switch (modelFamilyToken(modelId)) {
|
||||
case "anthropic":
|
||||
return "anthropic";
|
||||
@@ -35,6 +35,6 @@ export function preferredToolSyntax(modelId: string): ToolCallSyntax {
|
||||
case "gpt-oss":
|
||||
return "harmony";
|
||||
default:
|
||||
return FALLBACK_TOOL_SYNTAX;
|
||||
return FALLBACK_DIALECT;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./bundled";
|
||||
export * from "./classify";
|
||||
export * from "./dialect";
|
||||
export * from "./equivalence";
|
||||
export * from "./family";
|
||||
export * from "./id";
|
||||
@@ -7,4 +8,3 @@ export * from "./markers";
|
||||
export * from "./priority";
|
||||
export * from "./reference";
|
||||
export * from "./selection";
|
||||
export * from "./tool-syntax";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { FALLBACK_DIALECT, preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
|
||||
describe("preferredDialect", () => {
|
||||
it("maps model IDs to dialects correctly", () => {
|
||||
expect(preferredDialect("claude-3-5-sonnet-20241022")).toBe("anthropic");
|
||||
expect(preferredDialect("glm-4-flash")).toBe("glm");
|
||||
expect(preferredDialect("moonshotai/kimi-k2")).toBe("kimi");
|
||||
expect(preferredDialect("deepseek-chat")).toBe("deepseek");
|
||||
expect(preferredDialect("qwen-coder-32b-instruct")).toBe("qwen3");
|
||||
expect(preferredDialect("gpt-4o-mini")).toBe("harmony");
|
||||
expect(preferredDialect("gpt-oss-120b")).toBe("harmony");
|
||||
expect(preferredDialect("gemini-1.5-pro")).toBe("gemini");
|
||||
expect(preferredDialect("gemini-3.5-flash")).toBe("gemini");
|
||||
expect(preferredDialect("gemma-3-27b-it")).toBe("gemma");
|
||||
expect(preferredDialect("google/gemma-4-E2B-it")).toBe("gemma");
|
||||
expect(preferredDialect("unclassified-model-id")).toBe(FALLBACK_DIALECT);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { preferredToolSyntax } from "../src/identity/tool-syntax";
|
||||
|
||||
describe("preferredToolSyntax", () => {
|
||||
it("maps model IDs to syntax correctly", () => {
|
||||
expect(preferredToolSyntax("claude-3-5-sonnet-20241022")).toBe("anthropic");
|
||||
expect(preferredToolSyntax("glm-4-flash")).toBe("glm");
|
||||
expect(preferredToolSyntax("moonshotai/kimi-k2")).toBe("kimi");
|
||||
expect(preferredToolSyntax("deepseek-chat")).toBe("deepseek");
|
||||
expect(preferredToolSyntax("qwen-coder-32b-instruct")).toBe("qwen3");
|
||||
expect(preferredToolSyntax("gpt-4o-mini")).toBe("harmony");
|
||||
expect(preferredToolSyntax("gpt-oss-120b")).toBe("harmony");
|
||||
expect(preferredToolSyntax("gemini-1.5-pro")).toBe("gemini");
|
||||
expect(preferredToolSyntax("gemini-3.5-flash")).toBe("gemini");
|
||||
expect(preferredToolSyntax("gemma-3-27b-it")).toBe("gemma");
|
||||
expect(preferredToolSyntax("google/gemma-4-E2B-it")).toBe("gemma");
|
||||
expect(preferredToolSyntax("unclassified-model-id")).toBe("xml");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed the SDK tool format type and resolver from `ToolCallFormat`/`resolveToolCallSyntax` to `DialectFormat`/`resolveDialect`, and the agent option from `toolCallSyntax` to `dialect`.
|
||||
- Changed `/dump` transcript output to render messages with the selected model's native dialect turn and thinking envelopes instead of markdown role headings.
|
||||
|
||||
### Changed
|
||||
|
||||
- Session dump output now renders message history using the model's native dialect turn envelope instead of markdown role headings
|
||||
|
||||
## [15.13.3] - 2026-06-15
|
||||
|
||||
@@ -11752,4 +11756,4 @@ Initial public release.
|
||||
|
||||
## [0.7.6] - 2025-11-13
|
||||
|
||||
Previous releases did not maintain a changelog.
|
||||
Previous releases did not maintain a changelog.
|
||||
@@ -1734,14 +1734,16 @@ export const SETTINGS_SCHEMA = {
|
||||
"harmony",
|
||||
"pi",
|
||||
"qwen3",
|
||||
"gemini",
|
||||
"gemma",
|
||||
] as const,
|
||||
default: "auto",
|
||||
ui: {
|
||||
tab: "context",
|
||||
group: "Experimental",
|
||||
label: "Tool Call Format",
|
||||
label: "Tool Calling Mode",
|
||||
description:
|
||||
"Controls how tools are exposed to the model. Auto uses native tool calls unless the selected model is marked as not supporting tools, then falls back to GLM-style in-band tool calls. Native forces provider-native tools; the other values force the named in-band syntax. Applies on session start.",
|
||||
"Controls how tools are exposed to the model. Auto uses provider-native tool calls unless the selected model is marked as not supporting them, then falls back to the GLM owned dialect. Native forces provider-native tools; the other values force the named owned dialect. Applies on session start.",
|
||||
options: [
|
||||
{
|
||||
value: "auto",
|
||||
@@ -1756,8 +1758,10 @@ export const SETTINGS_SCHEMA = {
|
||||
{ value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." },
|
||||
{ value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." },
|
||||
{ value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." },
|
||||
{ value: "pi", label: "Pi", description: "Use Pi-style in-band tool calls." },
|
||||
{ value: "qwen3", label: "Qwen3", description: "Use Qwen3-style in-band tool calls." },
|
||||
{ value: "pi", label: "Pi", description: "Use the Pi owned dialect." },
|
||||
{ value: "qwen3", label: "Qwen3", description: "Use the Qwen3 owned dialect." },
|
||||
{ value: "gemini", label: "Gemini", description: "Use the Gemini owned dialect." },
|
||||
{ value: "gemma", label: "Gemma", description: "Use the Gemma owned dialect." },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type SimpleStreamOptions,
|
||||
streamSimple,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
|
||||
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
||||
import {
|
||||
getOpenAICodexTransportDetails,
|
||||
prewarmOpenAICodexResponses,
|
||||
@@ -551,12 +551,12 @@ export interface CreateAgentSessionResult {
|
||||
eventBus: EventBus;
|
||||
}
|
||||
|
||||
export type ToolCallFormat = "auto" | "native" | ToolCallSyntax;
|
||||
export type DialectFormat = "auto" | "native" | Dialect;
|
||||
|
||||
export function resolveToolCallSyntax(
|
||||
format: ToolCallFormat,
|
||||
export function resolveDialect(
|
||||
format: DialectFormat,
|
||||
model: Pick<Model, "supportsTools"> | undefined,
|
||||
): ToolCallSyntax | undefined {
|
||||
): Dialect | undefined {
|
||||
if (format === "native") return undefined;
|
||||
if (format === "auto") return model?.supportsTools === false ? "glm" : undefined;
|
||||
return format;
|
||||
@@ -2161,10 +2161,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
appendPrompt = parts.join("\n\n");
|
||||
}
|
||||
// Owned/in-band tool syntax (non-native) repeats the catalog as `# Tool:`
|
||||
// Owned/in-band tool dialect (non-native) repeats the catalog as `# Tool:`
|
||||
// sections; native tool calling lets the compact name list suffice.
|
||||
const nativeTools =
|
||||
resolveToolCallSyntax(settings.get("tools.format"), agent?.state.model ?? model) === undefined;
|
||||
const nativeTools = resolveDialect(settings.get("tools.format"), agent?.state.model ?? model) === undefined;
|
||||
const defaultPrompt = await buildSystemPromptInternal({
|
||||
cwd,
|
||||
skills,
|
||||
@@ -2506,7 +2505,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
return result;
|
||||
},
|
||||
intentTracing: !!intentField,
|
||||
toolCallSyntax: resolveToolCallSyntax(settings.get("tools.format"), model),
|
||||
dialect: resolveDialect(settings.get("tools.format"), model),
|
||||
abortOnFabricatedToolResult: settings.get("tools.abortOnFabricatedResult"),
|
||||
getToolChoice: () => session?.nextToolChoice(),
|
||||
telemetry: options.telemetry,
|
||||
|
||||
@@ -2,22 +2,10 @@
|
||||
* Plain-text / markdown session formatting (same shape as /dump clipboard export).
|
||||
*/
|
||||
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
||||
import { getInbandGrammar, renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
|
||||
import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { canonicalizeMessage } from "../utils/thinking-display";
|
||||
import {
|
||||
type BashExecutionMessage,
|
||||
type BranchSummaryMessage,
|
||||
bashExecutionToText,
|
||||
type CompactionSummaryMessage,
|
||||
type CustomMessage,
|
||||
type FileMentionMessage,
|
||||
type HookMessage,
|
||||
type PythonExecutionMessage,
|
||||
pythonExecutionToText,
|
||||
} from "./messages";
|
||||
import type { Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
||||
import { getDialectDefinition, renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
|
||||
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { convertToLlm } from "./messages";
|
||||
|
||||
/** Minimal tool shape for dump output (matches AgentTool fields used by formatSessionDumpText). */
|
||||
export interface SessionDumpToolInfo {
|
||||
@@ -40,7 +28,7 @@ export interface FormatSessionDumpTextOptions {
|
||||
*/
|
||||
export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
|
||||
const lines: string[] = [];
|
||||
const grammar = getInbandGrammar(preferredToolSyntax(options.model?.id ?? ""));
|
||||
const definition = getDialectDefinition(preferredDialect(options.model?.id ?? ""));
|
||||
|
||||
const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
|
||||
if (systemPrompt.length > 0) {
|
||||
@@ -62,125 +50,21 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
|
||||
lines.push("\n");
|
||||
|
||||
const tools = options.tools ?? [];
|
||||
if (tools.length > 0) {
|
||||
const inventoryTools = tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as TSchema,
|
||||
examples: tool.examples,
|
||||
}));
|
||||
if (inventoryTools.length > 0) {
|
||||
lines.push("## Available Tools\n");
|
||||
const inventoryTools = tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as TSchema,
|
||||
examples: tool.examples,
|
||||
}));
|
||||
lines.push(renderToolInventory(inventoryTools, options.model?.id ?? ""));
|
||||
lines.push("\n");
|
||||
}
|
||||
|
||||
for (const msg of options.messages) {
|
||||
if (msg.role === "user" || msg.role === "developer") {
|
||||
lines.push(msg.role === "developer" ? "## Developer\n" : "## User\n");
|
||||
if (typeof msg.content === "string") {
|
||||
lines.push(msg.content);
|
||||
} else {
|
||||
for (const c of msg.content) {
|
||||
if (c.type === "text") {
|
||||
lines.push(c.text);
|
||||
} else if (c.type === "image") {
|
||||
lines.push("[Image]");
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("\n");
|
||||
} else if (msg.role === "assistant") {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
lines.push("## Assistant\n");
|
||||
|
||||
for (const c of assistantMsg.content) {
|
||||
if (c.type === "text") {
|
||||
lines.push(c.text);
|
||||
} else if (c.type === "thinking") {
|
||||
const thinking = canonicalizeMessage(c.thinking);
|
||||
if (thinking.length === 0) continue;
|
||||
lines.push("<thinking>");
|
||||
lines.push(thinking);
|
||||
lines.push("</thinking>\n");
|
||||
} else if (c.type === "toolCall") {
|
||||
const args = { ...(c.arguments as Record<string, unknown>) };
|
||||
delete args[INTENT_FIELD];
|
||||
lines.push(grammar.renderToolCall({ ...c, arguments: args }));
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
} else if (msg.role === "toolResult") {
|
||||
lines.push(`### Tool Result: ${msg.toolName}`);
|
||||
if (msg.isError) {
|
||||
lines.push("(error)");
|
||||
}
|
||||
for (const c of msg.content) {
|
||||
if (c.type === "text") {
|
||||
lines.push("```");
|
||||
lines.push(c.text);
|
||||
lines.push("```");
|
||||
} else if (c.type === "image") {
|
||||
lines.push("[Image output]");
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
} else if (msg.role === "bashExecution") {
|
||||
const bashMsg = msg as BashExecutionMessage;
|
||||
if (!bashMsg.excludeFromContext) {
|
||||
lines.push("## Bash Execution\n");
|
||||
lines.push(bashExecutionToText(bashMsg));
|
||||
lines.push("\n");
|
||||
}
|
||||
} else if (msg.role === "pythonExecution") {
|
||||
const pythonMsg = msg as PythonExecutionMessage;
|
||||
if (!pythonMsg.excludeFromContext) {
|
||||
lines.push("## Python Execution\n");
|
||||
lines.push(pythonExecutionToText(pythonMsg));
|
||||
lines.push("\n");
|
||||
}
|
||||
} else if (msg.role === "custom" || msg.role === "hookMessage") {
|
||||
const customMsg = msg as CustomMessage | HookMessage;
|
||||
lines.push(`## ${customMsg.customType}\n`);
|
||||
if (typeof customMsg.content === "string") {
|
||||
lines.push(customMsg.content);
|
||||
} else {
|
||||
for (const c of customMsg.content) {
|
||||
if (c.type === "text") {
|
||||
lines.push(c.text);
|
||||
} else if (c.type === "image") {
|
||||
lines.push("[Image]");
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("\n");
|
||||
} else if (msg.role === "branchSummary") {
|
||||
const branchMsg = msg as BranchSummaryMessage;
|
||||
lines.push("## Branch Summary\n");
|
||||
lines.push(`(from branch: ${branchMsg.fromId})\n`);
|
||||
lines.push(branchMsg.summary);
|
||||
lines.push("\n");
|
||||
} else if (msg.role === "compactionSummary") {
|
||||
const compactMsg = msg as CompactionSummaryMessage;
|
||||
lines.push("## Compaction Summary\n");
|
||||
lines.push(`(${compactMsg.tokensBefore} tokens before compaction)\n`);
|
||||
lines.push(compactMsg.summary);
|
||||
lines.push("\n");
|
||||
} else if (msg.role === "fileMention") {
|
||||
const fileMsg = msg as FileMentionMessage;
|
||||
lines.push("## File Mention\n");
|
||||
for (const file of fileMsg.files) {
|
||||
lines.push(`<file path="${file.path}">`);
|
||||
if (file.content) {
|
||||
lines.push(file.content);
|
||||
}
|
||||
if (file.image) {
|
||||
lines.push("[Image attached]");
|
||||
}
|
||||
lines.push("</file>\n");
|
||||
}
|
||||
lines.push("\n");
|
||||
}
|
||||
}
|
||||
lines.push("## Transcript\n");
|
||||
lines.push(definition.renderTranscript(convertToLlm([...options.messages]), { tools: inventoryTools }));
|
||||
lines.push("\n");
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as os from "node:os";
|
||||
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
||||
import { renderToolInventory } from "@oh-my-pi/pi-ai/grammar";
|
||||
import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
|
||||
import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
import { contextFileCapability } from "./capability/context-file";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { resolveDialect } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||
|
||||
describe("resolveDialect", () => {
|
||||
it("uses GLM in auto mode only for models known not to support native tools", () => {
|
||||
expect(resolveDialect("auto", { supportsTools: false })).toBe("glm");
|
||||
expect(resolveDialect("auto", { supportsTools: true })).toBeUndefined();
|
||||
expect(resolveDialect("auto", {})).toBeUndefined();
|
||||
expect(resolveDialect("auto", undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps native unset and passes explicit in-band dialects through", () => {
|
||||
expect(resolveDialect("native", { supportsTools: false })).toBeUndefined();
|
||||
expect(resolveDialect("qwen3", undefined)).toBe("qwen3");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { resolveToolCallSyntax } from "@oh-my-pi/pi-coding-agent/sdk";
|
||||
|
||||
describe("resolveToolCallSyntax", () => {
|
||||
it("uses GLM in auto mode only for models known not to support native tools", () => {
|
||||
expect(resolveToolCallSyntax("auto", { supportsTools: false })).toBe("glm");
|
||||
expect(resolveToolCallSyntax("auto", { supportsTools: true })).toBeUndefined();
|
||||
expect(resolveToolCallSyntax("auto", {})).toBeUndefined();
|
||||
expect(resolveToolCallSyntax("auto", undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps native unset and passes explicit in-band syntaxes through", () => {
|
||||
expect(resolveToolCallSyntax("native", { supportsTools: false })).toBeUndefined();
|
||||
expect(resolveToolCallSyntax("qwen3", undefined)).toBe("qwen3");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,21 @@
|
||||
* previous `<parameter>`-per-key JSON Schema dump dropped entirely.
|
||||
*/
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { Model, Usage } from "@oh-my-pi/pi-ai";
|
||||
import { formatSessionDumpText } from "@oh-my-pi/pi-coding-agent/session/session-dump-format";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const ZERO_USAGE: Usage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
const HARMONY_MODEL = { provider: "openai", id: "gpt-5", name: "GPT-5" } as Model;
|
||||
|
||||
describe("formatSessionDumpText tool parameters", () => {
|
||||
it("renders Zod schemas as a TypeScript signature, not schema internals", () => {
|
||||
const out = formatSessionDumpText({
|
||||
@@ -77,4 +89,28 @@ describe("formatSessionDumpText tool parameters", () => {
|
||||
expect(out).toContain("<examples>");
|
||||
expect(out).toContain('<invoke name="find">');
|
||||
});
|
||||
|
||||
it("renders message history with the model dialect turn envelope", () => {
|
||||
const out = formatSessionDumpText({
|
||||
model: HARMONY_MODEL,
|
||||
messages: [
|
||||
{ role: "user", content: "Hello", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hi." }],
|
||||
api: "mock",
|
||||
provider: "mock",
|
||||
model: "mock",
|
||||
usage: ZERO_USAGE,
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(out).toContain("## Transcript");
|
||||
expect(out).toContain("<|start|>user<|message|>Hello<|end|>");
|
||||
expect(out).toContain("<|start|>assistant<|channel|>final<|message|>Hi.<|end|>");
|
||||
expect(out).not.toContain("## Assistant");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("Settings", () => {
|
||||
expect(settings.get("tui.maxInlineImages")).toBe(8);
|
||||
});
|
||||
|
||||
it("exposes all tool call format options", () => {
|
||||
it("exposes all tool calling mode options", () => {
|
||||
const values = getEnumValues("tools.format");
|
||||
expect(values).toEqual([
|
||||
"auto",
|
||||
@@ -80,6 +80,8 @@ describe("Settings", () => {
|
||||
"harmony",
|
||||
"pi",
|
||||
"qwen3",
|
||||
"gemini",
|
||||
"gemma",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user