diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 46f3fdf64..e59b05437 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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). \ No newline at end of file diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index e8b9eb3a0..d7ab1abe5 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -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=` 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=` 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, ); diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 13e63e9e1..00cdc3961 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -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, toolName: string) => Record; #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, diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts index d5ac88512..50e45b1d2 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -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++) { diff --git a/packages/agent/src/compaction/branch-summarization.ts b/packages/agent/src/compaction/branch-summarization.ts index 8ff13c0db..11b04ebd4 100644 --- a/packages/agent/src/compaction/branch-summarization.ts +++ b/packages/agent/src/compaction/branch-summarization.ts @@ -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; diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 059fe3416..2ac488785 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -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 = `\n${conversationText}\n\n\n`; @@ -791,7 +791,7 @@ async function generateShortSummary( ): Promise { 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 = `\n${conversationText}\n\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 = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; const summarizationMessages = [ { diff --git a/packages/agent/src/compaction/utils.ts b/packages/agent/src/compaction/utils.ts index be61895d9..82ef3cab8 100644 --- a/packages/agent/src/compaction/utils.ts +++ b/packages/agent/src/compaction/utils.ts @@ -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) @@ -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 // ============================================================================ diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 04168adca..12fc63016 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -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; /** diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index d173ccaaa..8d838e59b 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -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(""); expect(desc).toContain("# Find files"); expect(desc).toContain(''); }); - 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(' { + 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); diff --git a/packages/agent/test/prompt-tools-loop.test.ts b/packages/agent/test/prompt-tools-loop.test.ts index 1cede30bd..4af28191c 100644 --- a/packages/agent/test/prompt-tools-loop.test.ts +++ b/packages/agent/test/prompt-tools-loop.test.ts @@ -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 = { @@ -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(); diff --git a/packages/agent/test/serialize-conversation.test.ts b/packages/agent/test/serialize-conversation.test.ts index 87ad21cfd..d91c059a0 100644 --- a/packages/agent/test/serialize-conversation.test.ts +++ b/packages/agent/test/serialize-conversation.test.ts @@ -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(""); + expect(out).toContain(""); + 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(""); + }); }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index eb9c50d19..3815130be 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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. \ No newline at end of file diff --git a/packages/ai/package.json b/packages/ai/package.json index 3b37a6d19..677f3ad97 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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", diff --git a/packages/ai/src/grammar/anthropic.md b/packages/ai/src/dialect/anthropic.md similarity index 100% rename from packages/ai/src/grammar/anthropic.md rename to packages/ai/src/dialect/anthropic.md diff --git a/packages/ai/src/grammar/anthropic.ts b/packages/ai/src/dialect/anthropic.ts similarity index 84% rename from packages/ai/src/grammar/anthropic.ts rename to packages/ai/src/dialect/anthropic.ts index a646931c0..5f42ccc9f 100644 --- a/packages/ai/src/grammar/anthropic.ts +++ b/packages/ai/src/dialect/anthropic.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 `\n${renderInvokes(calls, options.tools ?? [])}\n`; +} + +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${escapeXmlText(result.name)}\n<${streamTag}>${result.text}\n`; + }) + .join("\n"); + return `\n${body}\n`; +} + +function renderThinking(text: string): string { + return renderDelimitedThinking("", "", 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 = ``; + 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 += `${rendered}`; + } + return `${body}`; +} + +function renderInvokes(calls: readonly ToolCall[], tools: NonNullable): 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; diff --git a/packages/ai/src/grammar/catalog.ts b/packages/ai/src/dialect/catalog.ts similarity index 58% rename from packages/ai/src/grammar/catalog.ts rename to packages/ai/src/dialect/catalog.ts index e72113ba3..0c438a954 100644 --- a/packages/ai/src/grammar/catalog.ts +++ b/packages/ai/src/dialect/catalog.ts @@ -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); } diff --git a/packages/ai/src/grammar/coercion.ts b/packages/ai/src/dialect/coercion.ts similarity index 100% rename from packages/ai/src/grammar/coercion.ts rename to packages/ai/src/dialect/coercion.ts diff --git a/packages/ai/src/grammar/deepseek.md b/packages/ai/src/dialect/deepseek.md similarity index 100% rename from packages/ai/src/grammar/deepseek.md rename to packages/ai/src/dialect/deepseek.md diff --git a/packages/ai/src/grammar/deepseek.ts b/packages/ai/src/dialect/deepseek.ts similarity index 85% rename from packages/ai/src/grammar/deepseek.ts rename to packages/ai/src/dialect/deepseek.ts index 725197680..fd7646811 100644 --- a/packages/ai/src/grammar/deepseek.ts +++ b/packages/ai/src/dialect/deepseek.ts @@ -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 = ""; const THINK_CLOSE = ""; @@ -22,11 +36,11 @@ const DSML_TOOL_CALLS_OPEN_ASCII = "<|DSML|tool_calls>"; const DSML_TOOL_CALLS_CLOSE_ASCII = ""; 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; diff --git a/packages/ai/src/grammar/examples.ts b/packages/ai/src/dialect/examples.ts similarity index 72% rename from packages/ai/src/grammar/examples.ts rename to packages/ai/src/dialect/examples.ts index 0220774ec..350e987e8 100644 --- a/packages/ai/src/grammar/examples.ts +++ b/packages/ai/src/dialect/examples.ts @@ -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 => { // 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 `\n${grammar.renderToolCall(call, { tools: [tool], example: true }).trim()}\n`; + return `\n${definition.renderToolCall(call, { tools: [tool], example: true }).trim()}\n`; }; const parts = examples.map(ex => { const head = ex.caption ? `# ${ex.caption}\n` : ""; diff --git a/packages/ai/src/dialect/factory.ts b/packages/ai/src/dialect/factory.ts new file mode 100644 index 000000000..953bdefba --- /dev/null +++ b/packages/ai/src/dialect/factory.ts @@ -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 = { + 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); +} diff --git a/packages/ai/src/grammar/gemini.md b/packages/ai/src/dialect/gemini.md similarity index 100% rename from packages/ai/src/grammar/gemini.md rename to packages/ai/src/dialect/gemini.md diff --git a/packages/ai/src/grammar/gemini.ts b/packages/ai/src/dialect/gemini.ts similarity index 74% rename from packages/ai/src/grammar/gemini.ts rename to packages/ai/src/dialect/gemini.ts index 5131a9615..d5c196c4c 100644 --- a/packages/ai/src/grammar/gemini.ts +++ b/packages/ai/src/dialect/gemini.ts @@ -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 = ""; + 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 `${role}\n${body}\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); + 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; diff --git a/packages/ai/src/grammar/gemma.md b/packages/ai/src/dialect/gemma.md similarity index 100% rename from packages/ai/src/grammar/gemma.md rename to packages/ai/src/dialect/gemma.md diff --git a/packages/ai/src/grammar/gemma.ts b/packages/ai/src/dialect/gemma.ts similarity index 66% rename from packages/ai/src/grammar/gemma.ts rename to packages/ai/src/dialect/gemma.ts index fd34600ec..f1076135b 100644 --- a/packages/ai/src/grammar/gemma.ts +++ b/packages/ai/src/dialect/gemma.ts @@ -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 = ""; const STRING = '<|"|>'; +const RESPONSE_OPEN = "<|tool_response>"; +const RESPONSE_CLOSE = ""; 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 = ""; + 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); + 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}`; +} + +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; diff --git a/packages/ai/src/grammar/glm.md b/packages/ai/src/dialect/glm.md similarity index 100% rename from packages/ai/src/grammar/glm.md rename to packages/ai/src/dialect/glm.md diff --git a/packages/ai/src/grammar/glm.ts b/packages/ai/src/dialect/glm.ts similarity index 79% rename from packages/ai/src/grammar/glm.ts rename to packages/ai/src/dialect/glm.ts index b17d7d060..b72fe1b52 100644 --- a/packages/ai/src/grammar/glm.ts +++ b/packages/ai/src/dialect/glm.ts @@ -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 = ""; const TOOL_CLOSE = ""; @@ -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 `\n${renderToolResponseResults(results)}\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 = "[gMASK]"; + 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; diff --git a/packages/ai/src/grammar/harmony.md b/packages/ai/src/dialect/harmony.md similarity index 100% rename from packages/ai/src/grammar/harmony.md rename to packages/ai/src/dialect/harmony.md diff --git a/packages/ai/src/grammar/harmony.ts b/packages/ai/src/dialect/harmony.ts similarity index 74% rename from packages/ai/src/grammar/harmony.ts rename to packages/ai/src/dialect/harmony.ts index 5d85d4036..f79fa800a 100644 --- a/packages/ai/src/grammar/harmony.ts +++ b/packages/ai/src/dialect/harmony.ts @@ -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; diff --git a/packages/ai/src/grammar/hermes.md b/packages/ai/src/dialect/hermes.md similarity index 100% rename from packages/ai/src/grammar/hermes.md rename to packages/ai/src/dialect/hermes.md diff --git a/packages/ai/src/grammar/hermes.ts b/packages/ai/src/dialect/hermes.ts similarity index 76% rename from packages/ai/src/grammar/hermes.ts rename to packages/ai/src/dialect/hermes.ts index e2edf41d6..741dd3cfc 100644 --- a/packages/ai/src/grammar/hermes.ts +++ b/packages/ai/src/dialect/hermes.ts @@ -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 = ""; const TOOL_CLOSE = ""; @@ -159,13 +167,40 @@ export class HermesInbandScanner implements InbandScanner { } } -const grammar: Grammar = { - syntax: "hermes", - prompt: grammarPrompt, +function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string { + return `\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n`; +} + +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; diff --git a/packages/ai/src/grammar/history.ts b/packages/ai/src/dialect/history.ts similarity index 75% rename from packages/ai/src/grammar/history.ts rename to packages/ai/src/dialect/history.ts index 41762406d..0b74a505c 100644 --- a/packages/ai/src/grammar/history.ts +++ b/packages/ai/src/dialect/history.ts @@ -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() }; diff --git a/packages/ai/src/grammar/index.ts b/packages/ai/src/dialect/index.ts similarity index 100% rename from packages/ai/src/grammar/index.ts rename to packages/ai/src/dialect/index.ts diff --git a/packages/ai/src/grammar/inventory.ts b/packages/ai/src/dialect/inventory.ts similarity index 68% rename from packages/ai/src/grammar/inventory.ts rename to packages/ai/src/dialect/inventory.ts index f8100ecce..342a82276 100644 --- a/packages/ai/src/grammar/inventory.ts +++ b/packages/ai/src/dialect/inventory.ts @@ -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: ` * 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"); diff --git a/packages/ai/src/grammar/kimi.md b/packages/ai/src/dialect/kimi.md similarity index 100% rename from packages/ai/src/grammar/kimi.md rename to packages/ai/src/dialect/kimi.md diff --git a/packages/ai/src/grammar/kimi.ts b/packages/ai/src/dialect/kimi.ts similarity index 66% rename from packages/ai/src/grammar/kimi.ts rename to packages/ai/src/dialect/kimi.ts index c7e62094b..ea24c7b4b 100644 --- a/packages/ai/src/grammar/kimi.ts +++ b/packages/ai/src/dialect/kimi.ts @@ -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; diff --git a/packages/ai/src/grammar/owned-stream.ts b/packages/ai/src/dialect/owned-stream.ts similarity index 96% rename from packages/ai/src/grammar/owned-stream.ts rename to packages/ai/src/dialect/owned-stream.ts index 3c2fc4850..7edb8a453 100644 --- a/packages/ai/src/grammar/owned-stream.ts +++ b/packages/ai/src/dialect/owned-stream.ts @@ -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 = { +const RESPONSE_OPEN_TOKENS: Record = { glm: [""], hermes: [""], 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 }); diff --git a/packages/ai/src/grammar/pi.md b/packages/ai/src/dialect/pi.md similarity index 100% rename from packages/ai/src/grammar/pi.md rename to packages/ai/src/dialect/pi.md diff --git a/packages/ai/src/grammar/pi.ts b/packages/ai/src/dialect/pi.ts similarity index 86% rename from packages/ai/src/grammar/pi.ts rename to packages/ai/src/dialect/pi.ts index bd63541e6..a026d02e6 100644 --- a/packages/ai/src/grammar/pi.ts +++ b/packages/ai/src/dialect/pi.ts @@ -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 = "`; + for (const key in call.arguments) { + body += `\n${renderElement(key, call.arguments[key], shape?.properties[key])}`; + } + return `${body}\n`; +} + +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 `\n${text}\n`; +} + +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; + const properties = getObjectProperties(schema); + let body = `<${key}>`; + for (const childKey in record) { + body += `\n${renderElement(childKey, record[childKey], properties[childKey])}`; + } + return `${body}\n`; + } + return `<${key}>${renderScalar(value, schema)}`; +} + +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; diff --git a/packages/ai/src/grammar/prompt-template.md b/packages/ai/src/dialect/prompt-template.md similarity index 96% rename from packages/ai/src/grammar/prompt-template.md rename to packages/ai/src/dialect/prompt-template.md index f4f40622a..29e911c6e 100644 --- a/packages/ai/src/grammar/prompt-template.md +++ b/packages/ai/src/dialect/prompt-template.md @@ -9,4 +9,4 @@ Available functions are listed inside `` as one JSON object per l {{TOOLS}} -{{GRAMMAR}} +{{DIALECT}} diff --git a/packages/ai/src/grammar/qwen3.md b/packages/ai/src/dialect/qwen3.md similarity index 100% rename from packages/ai/src/grammar/qwen3.md rename to packages/ai/src/dialect/qwen3.md diff --git a/packages/ai/src/grammar/qwen3.ts b/packages/ai/src/dialect/qwen3.ts similarity index 78% rename from packages/ai/src/grammar/qwen3.ts rename to packages/ai/src/dialect/qwen3.ts index 698c71171..d6063451c 100644 --- a/packages/ai/src/grammar/qwen3.ts +++ b/packages/ai/src/dialect/qwen3.ts @@ -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 = ""; const TOOL_CLOSE = ""; @@ -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; diff --git a/packages/ai/src/dialect/rendering.ts b/packages/ai/src/dialect/rendering.ts new file mode 100644 index 000000000..6bbfda10a --- /dev/null +++ b/packages/ai/src/dialect/rendering.ts @@ -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 => `\n${result.text}\n`).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}`; +} + +export function geminiTurn(role: "model" | "user", body: string): string { + return `${role}\n${body}\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}`; +} diff --git a/packages/ai/src/grammar/thinking.ts b/packages/ai/src/dialect/thinking.ts similarity index 100% rename from packages/ai/src/grammar/thinking.ts rename to packages/ai/src/dialect/thinking.ts diff --git a/packages/ai/src/grammar/types.ts b/packages/ai/src/dialect/types.ts similarity index 65% rename from packages/ai/src/grammar/types.ts rename to packages/ai/src/dialect/types.ts index 78ac21eb7..778895b06 100644 --- a/packages/ai/src/grammar/types.ts +++ b/packages/ai/src/dialect/types.ts @@ -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 `` / 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; - /** 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; } diff --git a/packages/ai/src/grammar/xml.md b/packages/ai/src/dialect/xml.md similarity index 100% rename from packages/ai/src/grammar/xml.md rename to packages/ai/src/dialect/xml.md diff --git a/packages/ai/src/dialect/xml.ts b/packages/ai/src/dialect/xml.ts new file mode 100644 index 000000000..67d25f21a --- /dev/null +++ b/packages/ai/src/dialect/xml.ts @@ -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("", "", 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 = ``; + 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 += `${rendered}`; + } + return `${body}`; +} + +function renderInvokes(calls: readonly ToolCall[], tools: NonNullable): 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; diff --git a/packages/ai/src/grammar/factory.ts b/packages/ai/src/grammar/factory.ts deleted file mode 100644 index af6efb96f..000000000 --- a/packages/ai/src/grammar/factory.ts +++ /dev/null @@ -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 = { - 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); -} diff --git a/packages/ai/src/grammar/rendering.ts b/packages/ai/src/grammar/rendering.ts deleted file mode 100644 index 8e91410fc..000000000 --- a/packages/ai/src/grammar/rendering.ts +++ /dev/null @@ -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 = `${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${key}\n${rendered}`; - } - return `${body}\n`; -} - -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 `\n${renderToolResponseResults(results)}\n`; -} - -export function renderHermesInvocation(call: ToolCall, _options: GrammarRenderOptions = {}): string { - return `\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n`; -} - -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 `\n${renderXmlInvokes(calls, options.tools ?? [])}\n`; -} - -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${escapeXmlText(result.name)}\n<${streamTag}>${result.text}\n`; - }) - .join("\n"); - return `\n${body}\n`; -} - -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 = ``; - for (const key in call.arguments) { - body += `\n${renderPiNativeElement(key, call.arguments[key], shape?.properties[key])}`; - } - return `${body}\n`; -} - -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 => `\n${result.text}\n`).join("\n"); -} - -function renderXmlInvoke(call: ToolCall, shape: ToolArgShape | undefined): string { - let body = ``; - 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 += `${rendered}`; - } - return `${body}`; -} - -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; - const properties = getObjectProperties(schema); - let body = `<${key}>`; - for (const childKey in record) { - body += `\n${renderPiNativeElement(childKey, record[childKey], properties[childKey])}`; - } - return `${body}\n`; - } - return `<${key}>${renderPiNativeScalar(value, schema)}`; -} - -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); - 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 = ""; -const GEMMA_RESPONSE_OPEN = "<|tool_response>"; -const GEMMA_RESPONSE_CLOSE = ""; -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); - 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; - } -} diff --git a/packages/ai/src/grammar/xml.ts b/packages/ai/src/grammar/xml.ts deleted file mode 100644 index 509cd0799..000000000 --- a/packages/ai/src/grammar/xml.ts +++ /dev/null @@ -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; diff --git a/packages/ai/src/utils/stream-markup-healing.ts b/packages/ai/src/utils/stream-markup-healing.ts index b0fc00b14..5414753c2 100644 --- a/packages/ai/src/utils/stream-markup-healing.ts +++ b/packages/ai/src/utils/stream-markup-healing.ts @@ -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 = ""; diff --git a/packages/ai/test/gemini-gemma-grammar.test.ts b/packages/ai/test/gemini-gemma-dialect.test.ts similarity index 86% rename from packages/ai/test/gemini-gemma-grammar.test.ts rename to packages/ai/test/gemini-gemma-dialect.test.ts index b649362e0..9bcdd7aa2 100644 --- a/packages/ai/test/gemini-gemma-grammar.test.ts +++ b/packages/ai/test/gemini-gemma-dialect.test.ts @@ -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 }[] { - return scan(syntax, text, charByChar) + return scan(dialect, text, charByChar) .filter((event): event is Extract => event.type === "toolEnd") .map(event => ({ name: event.name, arguments: event.arguments })); } @@ -40,7 +35,7 @@ const call = (name: string, args: Record): 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}'); 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" }), ]); diff --git a/packages/ai/test/inband-tools.test.ts b/packages/ai/test/inband-tools.test.ts index 588792482..3e965eef4 100644 --- a/packages/ai/test/inband-tools.test.ts +++ b/packages/ai/test/inband-tools.test.ts @@ -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; -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 => 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(""); expect(prompt).toContain(""); 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( "\n\nFILE\n\n", ); - 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( "\n\nread\nFILE\n\n", ); - expect(getInbandGrammar("qwen3").renderToolResults([resultBlock])).toBe( + expect(getDialectDefinition("qwen3").renderToolResults([resultBlock])).toBe( "\nFILE\n", ); - expect(getInbandGrammar("pi").renderToolResults([resultBlock])).toBe("\nFILE\n"); - expect(getInbandGrammar("gemini").renderToolResults([resultBlock])).toBe("```tool_outputs\nFILE\n```"); - expect(getInbandGrammar("gemma").renderToolResults([resultBlock])).toBe( + expect(getDialectDefinition("pi").renderToolResults([resultBlock])).toBe( + "\nFILE\n", + ); + expect(getDialectDefinition("gemini").renderToolResults([resultBlock])).toBe("```tool_outputs\nFILE\n```"); + expect(getDialectDefinition("gemma").renderToolResults([resultBlock])).toBe( '<|tool_response>response:read{output:<|"|>FILE<|"|>}', ); }); - 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", diff --git a/packages/ai/test/owned-stream-fabrication.test.ts b/packages/ai/test/owned-stream-fabrication.test.ts index 5be398be2..f652d724f 100644 --- a/packages/ai/test/owned-stream-fabrication.test.ts +++ b/packages/ai/test/owned-stream-fabrication.test.ts @@ -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"; diff --git a/packages/ai/test/owned-stream-native-toolcall.test.ts b/packages/ai/test/owned-stream-native-toolcall.test.ts index 43fcedd4a..25ff62ab4 100644 --- a/packages/ai/test/owned-stream-native-toolcall.test.ts +++ b/packages/ai/test/owned-stream-native-toolcall.test.ts @@ -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"; diff --git a/packages/ai/test/tool-examples.test.ts b/packages/ai/test/tool-examples.test.ts index 1b2873377..d1ae732e6 100644 --- a/packages/ai/test/tool-examples.test.ts +++ b/packages/ai/test/tool-examples.test.ts @@ -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", () => { diff --git a/packages/ai/test/tool-inventory.test.ts b/packages/ai/test/tool-inventory.test.ts index 6500d459a..0b349799a 100644 --- a/packages/ai/test/tool-inventory.test.ts +++ b/packages/ai/test/tool-inventory.test.ts @@ -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", diff --git a/packages/ai/test/transcript-render.test.ts b/packages/ai/test/transcript-render.test.ts new file mode 100644 index 000000000..eff7dd4bc --- /dev/null +++ b/packages/ai/test/transcript-render.test.ts @@ -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\nI should search.\n"); + expect(out).toContain("<|im_start|>user\n\nresult\n<|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]"); + expect(out).toContain("<|assistant|>\n\n\nI should search.\n"); + expect(out).toContain("<|observation|>\n\nresult\n"); + }); + + 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: \nI should search.\n"); + expect(out).toContain(""); + expect(out).toContain(""); + 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); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index f97a96c69..6fc6f30cf 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -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 diff --git a/packages/catalog/src/identity/tool-syntax.ts b/packages/catalog/src/identity/dialect.ts similarity index 73% rename from packages/catalog/src/identity/tool-syntax.ts rename to packages/catalog/src/identity/dialect.ts index 74933ef67..26cd15265 100644 --- a/packages/catalog/src/identity/tool-syntax.ts +++ b/packages/catalog/src/identity/dialect.ts @@ -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; } } diff --git a/packages/catalog/src/identity/index.ts b/packages/catalog/src/identity/index.ts index bd6d34b9e..e7ff6948f 100644 --- a/packages/catalog/src/identity/index.ts +++ b/packages/catalog/src/identity/index.ts @@ -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"; diff --git a/packages/catalog/test/preferred-dialect.test.ts b/packages/catalog/test/preferred-dialect.test.ts new file mode 100644 index 000000000..f01efb566 --- /dev/null +++ b/packages/catalog/test/preferred-dialect.test.ts @@ -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); + }); +}); diff --git a/packages/catalog/test/preferred-tool-syntax.test.ts b/packages/catalog/test/preferred-tool-syntax.test.ts deleted file mode 100644 index 2085a2d65..000000000 --- a/packages/catalog/test/preferred-tool-syntax.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 89249db01..9ad4f71a5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. \ No newline at end of file diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index ba2e2f4d2..3bfc1e5ee 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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." }, ], }, }, diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index cc08d02d8..cee11d7e0 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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 | 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, diff --git a/packages/coding-agent/src/session/session-dump-format.ts b/packages/coding-agent/src/session/session-dump-format.ts index b51484329..e6c368499 100644 --- a/packages/coding-agent/src/session/session-dump-format.ts +++ b/packages/coding-agent/src/session/session-dump-format.ts @@ -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(""); - lines.push(thinking); - lines.push("\n"); - } else if (c.type === "toolCall") { - const args = { ...(c.arguments as Record) }; - 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(``); - if (file.content) { - lines.push(file.content); - } - if (file.image) { - lines.push("[Image attached]"); - } - lines.push("\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(); } diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 7161d48b5..08eed1ab7 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -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"; diff --git a/packages/coding-agent/test/sdk-dialect.test.ts b/packages/coding-agent/test/sdk-dialect.test.ts new file mode 100644 index 000000000..1fc86a30e --- /dev/null +++ b/packages/coding-agent/test/sdk-dialect.test.ts @@ -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"); + }); +}); diff --git a/packages/coding-agent/test/sdk-tool-call-syntax.test.ts b/packages/coding-agent/test/sdk-tool-call-syntax.test.ts deleted file mode 100644 index a305bddf0..000000000 --- a/packages/coding-agent/test/sdk-tool-call-syntax.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/coding-agent/test/session/session-dump-format.test.ts b/packages/coding-agent/test/session/session-dump-format.test.ts index c2a269d17..f49d13319 100644 --- a/packages/coding-agent/test/session/session-dump-format.test.ts +++ b/packages/coding-agent/test/session/session-dump-format.test.ts @@ -8,9 +8,21 @@ * previous ``-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(""); expect(out).toContain(''); }); + + 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"); + }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index ff83e05b7..6a0d6f2aa 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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", ]); }); });