refactor: renamed grammar module to dialect with unified transcript rendering

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