diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 4465fadf7..327fcdd84 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Added support for selecting owned in-band tool-call syntax via `PI_OWNED_TOOLS=` (for example `hermes` or `qwen3`) while preserving legacy `PI_OWNED_TOOLS=1/true` as GLM mode - Added owned in-band tool calling for multiple syntaxes (`glm`, `hermes`, `kimi`, `xml`, `anthropic`, `deepseek`, `harmony`, `pi-native`, `qwen3`). Owned mode sends no native provider tools, appends a syntax-specific prompt/catalog, re-encodes prior tool calls/results as grammar-owned text, and parses streamed model output back into canonical tool calls. +- Added tool-example folding to `normalizeTools`: when given a model's affinity syntax (resolved via `preferredToolSyntax`), it renders each tool's `examples` into an `` block in that native syntax and appends it to the wire description. Wired through both context paths (fresh build and append-only `takeSnapshot`/`build` via a new `exampleSyntax` build option), with the `_i` intent-field placeholder added to examples when intent tracing injects it. ### Changed @@ -17,6 +18,7 @@ ### Fixed +- Fixed append-only context cache fingerprinting to account for `exampleSyntax`, so switching tool-call syntax rebuilds cached prompts with the correct injected tool examples - Fixed owned in-band tool-calling requests to omit `toolChoice` after stripping native tools, preventing invalid tool-choice requests - Fixed owned tool calling letting the model fabricate tool results by treating grammar-owned tool-result markers in assistant text as a hard turn boundary: calls before the fabrication are kept, fabricated results and dependent calls are dropped, and the real result is fed back on the next turn. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index b392c4180..805b17b93 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -18,6 +18,7 @@ import { import { encodeInbandToolHistory, renderInbandToolPrompt, + renderToolExamples, type ToolCallSyntax, wrapInbandToolStream, } from "@oh-my-pi/pi-ai/grammar"; @@ -31,6 +32,7 @@ import { recoverHarmonyToolCall, signalListLabel, } from "@oh-my-pi/pi-ai/utils/harmony-leak"; +import { preferredToolSyntax } 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 { @@ -516,7 +518,11 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = }; } -export function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"] { +export function normalizeTools( + tools: AgentContext["tools"], + injectIntent: boolean, + exampleSyntax?: ToolCallSyntax, +): Context["tools"] { injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1"; return tools?.map(t => { const intentMode = resolveIntentMode(t.intent); @@ -530,7 +536,12 @@ export function normalizeTools(tools: AgentContext["tools"], injectIntent: boole } } const description = t.description ?? ""; - return { ...t, parameters, description }; + const injectExampleIntent = injectIntent && intentMode !== "omit"; + const examplesBlock = exampleSyntax + ? renderToolExamples({ ...t, parameters }, exampleSyntax, injectExampleIntent ? INTENT_FIELD : undefined) + : ""; + const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description; + return { ...t, parameters, description: finalDescription }; }); } @@ -909,12 +920,15 @@ async function streamAssistantResponse( let llmContext: Context; if (config.appendOnlyContext) { config.appendOnlyContext.syncMessages(normalizedMessages); - llmContext = config.appendOnlyContext.build(context, { intentTracing: !!config.intentTracing }); + llmContext = config.appendOnlyContext.build(context, { + intentTracing: !!config.intentTracing, + exampleSyntax: preferredToolSyntax(config.model.id), + }); } else { llmContext = { systemPrompt: context.systemPrompt, messages: normalizedMessages, - tools: normalizeTools(context.tools, !!config.intentTracing), + tools: normalizeTools(context.tools, !!config.intentTracing, preferredToolSyntax(config.model.id)), }; } if (config.transformProviderContext) { diff --git a/packages/agent/src/append-only-context.ts b/packages/agent/src/append-only-context.ts index e0fce2484..d5ac88512 100644 --- a/packages/agent/src/append-only-context.ts +++ b/packages/agent/src/append-only-context.ts @@ -15,6 +15,7 @@ */ import type { Context, Message, Tool } from "@oh-my-pi/pi-ai"; +import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar"; import { normalizeTools } from "./agent-loop"; import type { AgentContext } from "./types"; @@ -33,6 +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; } /** @@ -268,7 +270,7 @@ export class AppendOnlyContextManager { function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot { const systemPrompt = [...context.systemPrompt]; - const tools = normalizeTools(context.tools, options.intentTracing) ?? []; + const tools = normalizeTools(context.tools, options.intentTracing, options.exampleSyntax) ?? []; return { systemPrompt, tools, @@ -288,6 +290,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil cw: t.customWireName, })), i: options.intentTracing, + ex: options.exampleSyntax, }); let hash = 0; for (let i = 0; i < payload.length; i++) { diff --git a/packages/agent/test/append-only-context.test.ts b/packages/agent/test/append-only-context.test.ts index b46988663..d173ccaaa 100644 --- a/packages/agent/test/append-only-context.test.ts +++ b/packages/agent/test/append-only-context.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { AppendOnlyContextManager, AppendOnlyLog, StablePrefix } from "@oh-my-pi/pi-agent-core/append-only-context"; import type { AgentContext, AgentTool } from "@oh-my-pi/pi-agent-core/types"; -import type { Message, Tool } from "@oh-my-pi/pi-ai"; +import type { Message, Tool, ToolExample } from "@oh-my-pi/pi-ai"; // --------------------------------------------------------------------------- // Helpers @@ -16,12 +16,18 @@ function makeContext(overrides?: Partial): AgentContext { }; } -function makeTool(name: string, description?: string, parameters?: Record): AgentTool { +function makeTool( + name: string, + description?: string, + parameters?: Record, + examples?: readonly ToolExample[], +): AgentTool { return { name, description: description ?? `Tool ${name}`, parameters: parameters ?? { type: "object", properties: {} }, label: name, + examples, execute: async () => ({ content: [{ type: "text", text: "done" }] }), } as AgentTool; } @@ -629,6 +635,61 @@ describe("intent injection through build()", () => { }); }); +describe("tool examples injection through build()", () => { + const findExamples: readonly ToolExample[] = [{ caption: "Find files", call: { paths: ["src/**/*.ts"] } }]; + const findParams = { + type: "object", + properties: { paths: { type: "array", items: { type: "string" } } }, + }; + + it("injects examples when exampleSyntax 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 desc = result.tools?.[0]?.description ?? ""; + expect(desc).toContain(""); + expect(desc).toContain("# Find files"); + expect(desc).toContain(''); + }); + + it("omits examples when exampleSyntax is undefined", () => { + const mgr = new AppendOnlyContextManager(); + const tool = makeTool("find", "Find files.", findParams, findExamples); + const ctx = makeContext({ tools: [tool] }); + + const result = mgr.build(ctx, { intentTracing: false }); + const desc = result.tools?.[0]?.description ?? ""; + expect(desc).toBe("Find files."); + }); + + it("injects the `_i` placeholder into examples when intentTracing is on", () => { + const mgr = new AppendOnlyContextManager(); + const tool = makeTool("find", "Find files.", findParams, findExamples); + const ctx = makeContext({ tools: [tool] }); + + const result = mgr.build(ctx, { intentTracing: true, exampleSyntax: "anthropic" }); + const desc = result.tools?.[0]?.description ?? ""; + expect(desc).toContain(' { + const mgr = new AppendOnlyContextManager(); + const tool = makeTool("find", "Find files.", undefined, findExamples); + const ctx = makeContext({ tools: [tool] }); + + mgr.build(ctx, { intentTracing: false }); + const fpNoExamples = mgr.prefix.fingerprint; + + mgr.build(ctx, { intentTracing: false, exampleSyntax: "anthropic" }); + const fpWithExamples = mgr.prefix.fingerprint; + + expect(fpNoExamples).not.toBe(fpWithExamples); + }); +}); + // --------------------------------------------------------------------------- // Tool-call mutation detection // --------------------------------------------------------------------------- diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7cfcdc055..45b1b0653 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog ## [Unreleased] + ### Added +- Added the generic `ToolExample` type (`ToolCallExample`/`ToolCompareExample`/`ToolNoteExample`, parameterized over a tool's argument shape) and an `examples` property on the `Tool` interface for defining tool-call examples once as data. +- Added `renderToolExamples` (via `@oh-my-pi/pi-ai/grammar`) to render a tool's examples into an `` block in the model's native tool-call syntax, with an optional `_i` intent-field placeholder injected when intent tracing is active. - Added `@oh-my-pi/pi-ai/utils/harmony-leak` export with helpers to detect, audit, and recover GPT-5 Harmony tool-call header leaks - Added the `@oh-my-pi/pi-ai/grammar` public entrypoint for grammar factories, prompt/call rendering, in-band scanning, history encoding, and related typed utilities - Added a unified in-band tool-call grammar engine with syntax-owned scanners, prompts, history rendering, tool-result rendering, and stream adaptation for GLM, Hermes/Qwen, Kimi, XML/Anthropic, DeepSeek, Harmony, and pi-native formats. @@ -10,6 +13,7 @@ ### Changed - Added raw in-band tool-call block capture to parsed owned tool calls so debugging can inspect the exact model-emitted call syntax. +- Moved the canonical `ToolCallSyntax` union to `@oh-my-pi/pi-catalog/identity` and re-exported it from `@oh-my-pi/pi-ai/grammar` so the catalog can own the syntax vocabulary without an `@oh-my-pi/pi-ai` runtime import; all existing import paths are unchanged. - Made tool-call argument validation more lenient for schema-directed scalar coercions, including object/array stringification and 0/1 boolean coercion. ### Fixed @@ -3672,4 +3676,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. \ No newline at end of file +Initial release with multi-provider LLM support. diff --git a/packages/ai/src/grammar/anthropic.ts b/packages/ai/src/grammar/anthropic.ts index b2903c0aa..a646931c0 100644 --- a/packages/ai/src/grammar/anthropic.ts +++ b/packages/ai/src/grammar/anthropic.ts @@ -1,7 +1,7 @@ import { parseJsonWithRepair } from "../utils/json-parse"; import grammarPrompt from "./anthropic.md" with { type: "text" }; import { buildStringArgsResolver, mintToolCallId } from "./coercion"; -import { renderAnthropicToolCalls, renderAnthropicToolResults } from "./rendering"; +import { renderAnthropicInvocation, renderAnthropicToolCalls, renderAnthropicToolResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types"; const MAX_PARTIAL_TAG_LENGTH = 256; @@ -513,6 +513,7 @@ const grammar: Grammar = { syntax: "anthropic", prompt: grammarPrompt, createScanner: options => new AnthropicInbandScanner(options), + renderToolCall: renderAnthropicInvocation, renderAssistantToolCalls: renderAnthropicToolCalls, renderToolResults: renderAnthropicToolResults, }; diff --git a/packages/ai/src/grammar/deepseek.ts b/packages/ai/src/grammar/deepseek.ts index 8ac14b729..725197680 100644 --- a/packages/ai/src/grammar/deepseek.ts +++ b/packages/ai/src/grammar/deepseek.ts @@ -1,7 +1,7 @@ import { parseJsonWithRepair } from "../utils/json-parse"; import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import grammarPrompt from "./deepseek.md" with { type: "text" }; -import { renderDeepSeekToolCalls, renderDeepSeekToolResults } from "./rendering"; +import { renderDeepSeekInvocation, renderDeepSeekToolCalls, renderDeepSeekToolResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types"; export const DEEPSEEK_TOOL_CALLS_BEGIN = "<|tool▁calls▁begin|>"; @@ -527,6 +527,7 @@ const grammar: Grammar = { syntax: "deepseek", prompt: grammarPrompt, createScanner: options => new DeepSeekInbandScanner(options), + renderToolCall: renderDeepSeekInvocation, renderAssistantToolCalls: renderDeepSeekToolCalls, renderToolResults: renderDeepSeekToolResults, }; diff --git a/packages/ai/src/grammar/examples.ts b/packages/ai/src/grammar/examples.ts new file mode 100644 index 000000000..06d6a6257 --- /dev/null +++ b/packages/ai/src/grammar/examples.ts @@ -0,0 +1,33 @@ +import type { ToolCall } from "../types"; +import { getInbandGrammar } from "./factory"; +import type { InbandTool, ToolCallSyntax } from "./types"; + +const INTENT_PLACEHOLDER = "…"; + +export function renderToolExamples(tool: InbandTool, syntax: ToolCallSyntax, intentField?: string): string { + const examples = tool.examples; + if (!examples?.length) return ""; + const grammar = getInbandGrammar(syntax); + const renderCall = (args: Record): string => { + // When intent tracing injects `_i` into the schema, examples must show a + // placeholder so the model learns to emit it. Keep it first, matching the + // schema injection order. + const finalArgs = intentField ? { [intentField]: INTENT_PLACEHOLDER, ...args } : args; + const call: ToolCall = { + type: "toolCall", + id: "example", + name: tool.name, + arguments: finalArgs, + }; + return `\n${grammar.renderToolCall(call, { tools: [tool] }).trim()}\n`; + }; + const parts = examples.map(ex => { + const head = ex.caption ? `# ${ex.caption}\n` : ""; + if ("call" in ex) return head + renderCall(ex.call); + if ("good" in ex) { + return `${head}WRONG:\n${renderCall(ex.bad)}\nRIGHT:\n${renderCall(ex.good)}`; + } + return head.trimEnd() + (ex.note ? `\n${ex.note}` : ""); + }); + return `\n${parts.join("\n")}\n`; +} diff --git a/packages/ai/src/grammar/glm.ts b/packages/ai/src/grammar/glm.ts index d6112e0e3..b17d7d060 100644 --- a/packages/ai/src/grammar/glm.ts +++ b/packages/ai/src/grammar/glm.ts @@ -6,7 +6,7 @@ import { partialSuffixOverlapAny, } from "./coercion"; import grammarPrompt from "./glm.md" with { type: "text" }; -import { renderGlmToolCalls, renderGlmToolResults } from "./rendering"; +import { renderGlmInvocation, renderGlmToolCalls, renderGlmToolResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types"; const TOOL_OPEN = ""; @@ -376,6 +376,7 @@ const grammar: Grammar = { syntax: "glm", prompt: grammarPrompt, createScanner: options => new GLMInbandScanner(options), + renderToolCall: renderGlmInvocation, renderAssistantToolCalls: renderGlmToolCalls, renderToolResults: renderGlmToolResults, }; diff --git a/packages/ai/src/grammar/harmony.ts b/packages/ai/src/grammar/harmony.ts index f2e78d049..5d85d4036 100644 --- a/packages/ai/src/grammar/harmony.ts +++ b/packages/ai/src/grammar/harmony.ts @@ -1,7 +1,7 @@ import { parseJsonWithRepair } from "../utils/json-parse"; import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import grammarPrompt from "./harmony.md" with { type: "text" }; -import { renderHarmonyToolCalls, renderHarmonyToolResults } from "./rendering"; +import { renderHarmonyInvocation, renderHarmonyToolCalls, renderHarmonyToolResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner } from "./types"; const START = "<|start|>"; @@ -264,6 +264,7 @@ const grammar: Grammar = { syntax: "harmony", prompt: grammarPrompt, createScanner: () => new HarmonyInbandScanner(), + renderToolCall: renderHarmonyInvocation, renderAssistantToolCalls: renderHarmonyToolCalls, renderToolResults: renderHarmonyToolResults, }; diff --git a/packages/ai/src/grammar/hermes.ts b/packages/ai/src/grammar/hermes.ts index d387bb013..e2edf41d6 100644 --- a/packages/ai/src/grammar/hermes.ts +++ b/packages/ai/src/grammar/hermes.ts @@ -1,7 +1,7 @@ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse"; import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import grammarPrompt from "./hermes.md" with { type: "text" }; -import { renderHermesToolCalls, renderToolResponseResults } from "./rendering"; +import { renderHermesInvocation, renderHermesToolCalls, renderToolResponseResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types"; const TOOL_OPEN = ""; @@ -163,6 +163,7 @@ const grammar: Grammar = { syntax: "hermes", prompt: grammarPrompt, createScanner: options => new HermesInbandScanner(options), + renderToolCall: renderHermesInvocation, renderAssistantToolCalls: renderHermesToolCalls, renderToolResults: renderToolResponseResults, }; diff --git a/packages/ai/src/grammar/index.ts b/packages/ai/src/grammar/index.ts index 75c992d04..66eb97281 100644 --- a/packages/ai/src/grammar/index.ts +++ b/packages/ai/src/grammar/index.ts @@ -1,5 +1,6 @@ export * from "./catalog"; export * from "./coercion"; +export * from "./examples"; export * from "./factory"; export * from "./history"; export * from "./owned-stream"; diff --git a/packages/ai/src/grammar/kimi.ts b/packages/ai/src/grammar/kimi.ts index 634971598..c7e62094b 100644 --- a/packages/ai/src/grammar/kimi.ts +++ b/packages/ai/src/grammar/kimi.ts @@ -1,7 +1,7 @@ import { parseJsonWithRepair } from "../utils/json-parse"; import { asRecord, normalizeKimiFunctionName, partialSuffixOverlapAny } from "./coercion"; import grammarPrompt from "./kimi.md" with { type: "text" }; -import { renderKimiToolCalls, renderKimiToolResults } from "./rendering"; +import { renderKimiInvocation, renderKimiToolCalls, renderKimiToolResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner } from "./types"; export const KIMI_SECTION_BEGIN = "<|tool_calls_section_begin|>"; @@ -190,6 +190,7 @@ const grammar: Grammar = { syntax: "kimi", prompt: grammarPrompt, createScanner: () => new KimiInbandScanner(), + renderToolCall: renderKimiInvocation, renderAssistantToolCalls: renderKimiToolCalls, renderToolResults: renderKimiToolResults, }; diff --git a/packages/ai/src/grammar/pi.ts b/packages/ai/src/grammar/pi.ts index 40b7a6850..bd63541e6 100644 --- a/packages/ai/src/grammar/pi.ts +++ b/packages/ai/src/grammar/pi.ts @@ -12,7 +12,7 @@ import { partialSuffixOverlapAny, } from "./coercion"; import grammarPrompt from "./pi.md" with { type: "text" }; -import { renderPiNativeToolCalls, renderToolResponseResults } from "./rendering"; +import { renderPiNativeInvocation, renderPiNativeToolCalls, renderToolResponseResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types"; const CALL_PREFIX = " new PiNativeInbandScanner(options), + renderToolCall: renderPiNativeInvocation, renderAssistantToolCalls: renderPiNativeToolCalls, renderToolResults: renderToolResponseResults, }; diff --git a/packages/ai/src/grammar/qwen3.ts b/packages/ai/src/grammar/qwen3.ts index eaa04ef52..698c71171 100644 --- a/packages/ai/src/grammar/qwen3.ts +++ b/packages/ai/src/grammar/qwen3.ts @@ -1,7 +1,7 @@ import { parseJsonWithRepair } from "../utils/json-parse"; import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import grammarPrompt from "./qwen3.md" with { type: "text" }; -import { renderHermesToolCalls, renderToolResponseResults } from "./rendering"; +import { renderHermesInvocation, renderHermesToolCalls, renderToolResponseResults } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types"; const TOOL_OPEN = ""; @@ -195,6 +195,7 @@ const grammar: Grammar = { syntax: "qwen3", prompt: grammarPrompt, createScanner: options => new Qwen3InbandScanner(options), + renderToolCall: renderHermesInvocation, renderAssistantToolCalls: renderHermesToolCalls, renderToolResults: renderToolResponseResults, }; diff --git a/packages/ai/src/grammar/rendering.ts b/packages/ai/src/grammar/rendering.ts index a7358440d..88ceee45a 100644 --- a/packages/ai/src/grammar/rendering.ts +++ b/packages/ai/src/grammar/rendering.ts @@ -1,5 +1,11 @@ import type { ToolCall } from "../types"; -import { buildArgShapes, getArrayItemSchema, getObjectProperties, isStringOnlySchema } from "./coercion"; +import { + buildArgShapes, + getArrayItemSchema, + getObjectProperties, + isStringOnlySchema, + type ToolArgShape, +} from "./coercion"; import type { GrammarRenderOptions, GrammarToolResult, InbandTool } from "./types"; const DEEPSEEK_TOOL_CALLS_BEGIN = "<|tool▁calls▁begin|>"; @@ -10,40 +16,48 @@ const DEEPSEEK_TOOL_SEPARATOR = "<|tool▁sep|>"; const DEEPSEEK_TOOL_OUTPUT_BEGIN = "<|tool▁output▁begin|>"; const DEEPSEEK_TOOL_OUTPUT_END = "<|tool▁output▁end|>"; +export function renderGlmInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string { + return glmInvocation(call, buildArgShapes(options.tools).get(call.name)); +} + +function glmInvocation(call: ToolCall, shape: ToolArgShape | undefined): string { + let body = `${call.name}`; + for (const key in call.arguments) { + const value = call.arguments[key]; + const rendered = shape?.stringArgs.has(key) && typeof value === "string" ? value : stringifyJson(value); + body += `\n${key}\n${rendered}`; + } + return `${body}\n`; +} + export function renderGlmToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string { const shapes = buildArgShapes(options.tools); - return calls - .map(call => { - const shape = shapes.get(call.name); - let body = `${call.name}`; - for (const key in call.arguments) { - const value = call.arguments[key]; - const rendered = shape?.stringArgs.has(key) && typeof value === "string" ? value : stringifyJson(value); - body += `\n${key}\n${rendered}`; - } - return `${body}\n`; - }) - .join("\n"); + return calls.map(call => glmInvocation(call, shapes.get(call.name))).join("\n"); } export function renderGlmToolResults(results: readonly GrammarToolResult[]): string { return `\n${renderToolResponseResults(results)}\n`; } +export function renderHermesInvocation(call: ToolCall): string { + return `\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n`; +} + export function renderHermesToolCalls(calls: readonly ToolCall[]): string { - return calls - .map(call => `\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n`) - .join("\n"); + return calls.map(renderHermesInvocation).join("\n"); +} + +export function renderKimiInvocation(call: ToolCall): 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) => - `<|tool_call_begin|>${kimiCallId(call.name, call.id, index)}<|tool_call_argument_begin|>${stringifyJson(call.arguments)}<|tool_call_end|>`, - ) - .join(""); + const body = calls.map((call, index) => kimiInvocation(call, index)).join(""); return `<|tool_calls_section_begin|>${body}<|tool_calls_section_end|>`; } @@ -56,14 +70,13 @@ export function renderKimiToolResults(results: readonly GrammarToolResult[]): st .join(""); } +export function renderDeepSeekInvocation(call: ToolCall): string { + return `${DEEPSEEK_TOOL_CALL_BEGIN}${call.name}${DEEPSEEK_TOOL_SEPARATOR}${stringifyJson(call.arguments)}${DEEPSEEK_TOOL_CALL_END}`; +} + export function renderDeepSeekToolCalls(calls: readonly ToolCall[]): string { if (calls.length === 0) return ""; - const body = calls - .map( - call => - `${DEEPSEEK_TOOL_CALL_BEGIN}${call.name}${DEEPSEEK_TOOL_SEPARATOR}${stringifyJson(call.arguments)}${DEEPSEEK_TOOL_CALL_END}`, - ) - .join(""); + const body = calls.map(renderDeepSeekInvocation).join(""); return `${DEEPSEEK_TOOL_CALLS_BEGIN}${body}${DEEPSEEK_TOOL_CALLS_END}`; } @@ -71,13 +84,12 @@ export function renderDeepSeekToolResults(results: readonly GrammarToolResult[]) return results.map(result => `${DEEPSEEK_TOOL_OUTPUT_BEGIN}${result.text}${DEEPSEEK_TOOL_OUTPUT_END}`).join("\n"); } +export function renderHarmonyInvocation(call: ToolCall): string { + return `<|start|>assistant<|channel|>commentary to=${harmonyRecipient(call.name)} <|constrain|>json<|message|>${stringifyJson(call.arguments)}<|call|>`; +} + export function renderHarmonyToolCalls(calls: readonly ToolCall[]): string { - return calls - .map( - call => - `<|start|>assistant<|channel|>commentary to=${harmonyRecipient(call.name)} <|constrain|>json<|message|>${stringifyJson(call.arguments)}<|call|>`, - ) - .join(""); + return calls.map(renderHarmonyInvocation).join(""); } export function renderHarmonyToolResults(results: readonly GrammarToolResult[]): string { @@ -89,6 +101,10 @@ export function renderHarmonyToolResults(results: readonly GrammarToolResult[]): .join(""); } +export function renderAnthropicInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string { + return renderXmlInvoke(call, buildArgShapes(options.tools).get(call.name)); +} + export function renderAnthropicToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string { if (calls.length === 0) return ""; return `\n${renderXmlInvokes(calls, options.tools ?? [])}\n`; @@ -105,44 +121,50 @@ export function renderAnthropicToolResults(results: readonly GrammarToolResult[] return `\n${body}\n`; } +export function renderXmlInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string { + return renderXmlInvoke(call, buildArgShapes(options.tools).get(call.name)); +} + export function renderXmlToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string { return renderXmlInvokes(calls, options.tools ?? []); } +export function renderPiNativeInvocation(call: ToolCall, options: GrammarRenderOptions = {}): string { + return piInvocation(call, buildArgShapes(options.tools).get(call.name)); +} + +function piInvocation(call: ToolCall, shape: ToolArgShape | undefined): string { + let body = ``; + for (const key in call.arguments) { + body += `\n${renderPiNativeElement(key, call.arguments[key], shape?.properties[key])}`; + } + return `${body}\n`; +} + export function renderPiNativeToolCalls(calls: readonly ToolCall[], options: GrammarRenderOptions = {}): string { const shapes = buildArgShapes(options.tools); - return calls - .map(call => { - const shape = shapes.get(call.name); - let body = ``; - for (const key in call.arguments) { - body += `\n${renderPiNativeElement(key, call.arguments[key], shape?.properties[key])}`; - } - return `${body}\n`; - }) - .join("\n"); + return calls.map(call => piInvocation(call, shapes.get(call.name))).join("\n"); } export function renderToolResponseResults(results: readonly GrammarToolResult[]): string { return results.map(result => `\n${result.text}\n`).join("\n"); } +function renderXmlInvoke(call: ToolCall, shape: ToolArgShape | undefined): string { + let body = ``; + for (const key in call.arguments) { + const value = call.arguments[key]; + const isString = shape?.stringArgs.has(key) === true; + const stringAttr = isString ? ' string="true"' : ' string="false"'; + const rendered = isString && typeof value === "string" ? value : stringifyJson(value); + body += `${rendered}`; + } + return `${body}`; +} + function renderXmlInvokes(calls: readonly ToolCall[], tools: readonly InbandTool[]): string { const shapes = buildArgShapes(tools); - return calls - .map(call => { - const shape = shapes.get(call.name); - let body = ``; - for (const key in call.arguments) { - const value = call.arguments[key]; - const isString = shape?.stringArgs.has(key) === true; - const stringAttr = isString ? ' string="true"' : ' string="false"'; - const rendered = isString && typeof value === "string" ? value : stringifyJson(value); - body += `${rendered}`; - } - return `${body}`; - }) - .join("\n"); + return calls.map(call => renderXmlInvoke(call, shapes.get(call.name))).join("\n"); } function renderPiNativeElement(key: string, value: unknown, schema: unknown): string { diff --git a/packages/ai/src/grammar/types.ts b/packages/ai/src/grammar/types.ts index 35ab040a1..9b7d56d53 100644 --- a/packages/ai/src/grammar/types.ts +++ b/packages/ai/src/grammar/types.ts @@ -1,6 +1,7 @@ +import type { ToolCallSyntax } from "@oh-my-pi/pi-catalog/identity"; import type { Context, ToolCall } from "../types"; -export type ToolCallSyntax = "glm" | "hermes" | "kimi" | "xml" | "anthropic" | "deepseek" | "harmony" | "pi" | "qwen3"; +export type { ToolCallSyntax }; export type InbandScanEvent = | { type: "text"; text: string } @@ -32,6 +33,9 @@ export interface Grammar { readonly syntax: ToolCallSyntax; readonly prompt: string; createScanner(options?: InbandScannerOptions): InbandScanner; + /** Render a single tool-call invocation — the inner element only, WITHOUT any parallel-call block envelope (e.g. anthropic's `` / kimi's section wrapper). */ + renderToolCall(call: ToolCall, options?: GrammarRenderOptions): string; + /** Render a batch of (parallel) tool calls as one complete block, including whatever envelope the syntax wraps multiple calls in. */ renderAssistantToolCalls(calls: readonly ToolCall[], options?: GrammarRenderOptions): string; renderToolResults(results: readonly GrammarToolResult[], options?: GrammarRenderOptions): string; } diff --git a/packages/ai/src/grammar/xml.ts b/packages/ai/src/grammar/xml.ts index 84cb4acf6..509cd0799 100644 --- a/packages/ai/src/grammar/xml.ts +++ b/packages/ai/src/grammar/xml.ts @@ -1,6 +1,6 @@ import { AnthropicInbandScanner } from "./anthropic"; import { DeepSeekInbandScanner } from "./deepseek"; -import { renderToolResponseResults, renderXmlToolCalls } from "./rendering"; +import { renderToolResponseResults, renderXmlInvocation, renderXmlToolCalls } from "./rendering"; import type { Grammar, InbandScanEvent, InbandScanner, InbandScannerOptions } from "./types"; import grammarPrompt from "./xml.md" with { type: "text" }; @@ -25,6 +25,7 @@ const grammar: Grammar = { syntax: "xml", prompt: grammarPrompt, createScanner: options => new XmlInbandScanner(options), + renderToolCall: renderXmlInvocation, renderAssistantToolCalls: renderXmlToolCalls, renderToolResults: renderToolResponseResults, }; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c7aa9716c..9bd48a5ef 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -587,6 +587,24 @@ export type TSchema = ZodType | TJsonSchema; /** Resolve parameter types for tool execution / handlers. */ export type Static = S extends ZodType ? z.infer : S extends { static: infer T } ? T : unknown; +export interface ToolCallExample> { + caption?: string; + call: TArgs; +} +export interface ToolCompareExample> { + caption?: string; + bad: TArgs; + good: TArgs; +} +export interface ToolNoteExample { + caption: string; + note?: string; +} +export type ToolExample> = + | ToolCallExample + | ToolCompareExample + | ToolNoteExample; + export interface Tool { name: string; description: string; @@ -610,6 +628,15 @@ export interface Tool { * calls route correctly. Absent for regular JSON function tools. */ customWireName?: string; + /** + * Illustrative calls/notes; the AI layer renders them into an `` + * block in the model's native tool-call syntax and appends to the wire + * description. Author `call`/`bad`/`good` as plain argument objects WITHOUT + * `_i` — when intent tracing injects `_i` into the schema, the renderer adds + * a placeholder `_i` automatically. Type each tool's `examples` against its + * own schema (e.g. `readonly ToolExample>[]`). + */ + examples?: readonly ToolExample[]; } export interface Context { diff --git a/packages/ai/test/tool-examples.test.ts b/packages/ai/test/tool-examples.test.ts new file mode 100644 index 000000000..adf0f1f52 --- /dev/null +++ b/packages/ai/test/tool-examples.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; +import { renderToolExamples } from "../src/grammar/examples"; +import type { InbandTool } from "../src/grammar/types"; + +describe("renderToolExamples", () => { + it("renders call example in anthropic format", () => { + const tool: InbandTool = { + name: "find", + description: "Find files.", + parameters: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + }, + required: ["paths"], + }, + examples: [ + { + caption: "Find files", + call: { paths: ["src/**/*.ts"] }, + }, + ], + }; + + const rendered = renderToolExamples(tool, "anthropic"); + expect(rendered).toContain(""); + expect(rendered).toContain("# Find files"); + expect(rendered).toContain(''); + expect(rendered).toContain('"); + }); + + it("renders call example in pi format", () => { + const tool: InbandTool = { + name: "find", + description: "Find files.", + parameters: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + }, + required: ["paths"], + }, + examples: [ + { + caption: "Find files", + call: { paths: ["src/**/*.ts"] }, + }, + ], + }; + + const rendered = renderToolExamples(tool, "pi"); + expect(rendered).toContain(""); + expect(rendered).toContain(""); + expect(rendered).toContain("src/**/*.ts"); + }); + + it("renders call example in hermes format", () => { + const tool: InbandTool = { + name: "find", + description: "Find files.", + parameters: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + }, + required: ["paths"], + }, + examples: [ + { + caption: "Find files", + call: { paths: ["src/**/*.ts"] }, + }, + ], + }; + + const rendered = renderToolExamples(tool, "hermes"); + expect(rendered).toContain(""); + expect(rendered).toContain('"name":"find"'); + expect(rendered).toContain('"paths"'); + }); + + it("returns empty string for empty examples", () => { + const tool: InbandTool = { + name: "find", + description: "Find files.", + parameters: { type: "object", properties: {} }, + examples: [], + }; + + expect(renderToolExamples(tool, "anthropic")).toBe(""); + }); + + it("renders compare examples with WRONG and RIGHT", () => { + const tool: InbandTool = { + name: "find", + description: "Find files.", + parameters: { + type: "object", + properties: { + paths: { type: "array", items: { type: "string" } }, + }, + required: ["paths"], + }, + examples: [ + { + caption: "Avoid broad scans", + bad: { paths: ["**/*.ts"] }, + good: { paths: ["src/**/*.ts"] }, + }, + ], + }; + + const rendered = renderToolExamples(tool, "anthropic"); + expect(rendered).toContain("WRONG:"); + expect(rendered).toContain("RIGHT:"); + expect(rendered).toContain(' { + const tool: InbandTool = { + name: "find", + description: "Find files.", + parameters: { + type: "object", + properties: { + _i: { type: "string" }, + paths: { type: "array", items: { type: "string" } }, + }, + required: ["_i", "paths"], + }, + examples: [ + { + caption: "Find files", + call: { paths: ["src/**/*.ts"] }, + }, + ], + }; + + const rendered = renderToolExamples(tool, "anthropic", "_i"); + expect(rendered).toContain(' { + const tool: InbandTool = { + name: "find", + description: "Find files.", + parameters: { + type: "object", + properties: { paths: { type: "array", items: { type: "string" } } }, + required: ["paths"], + }, + examples: [{ caption: "Find files", call: { paths: ["src/**/*.ts"] } }], + }; + + expect(renderToolExamples(tool, "anthropic")).not.toContain("_i"); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 90d5b8653..958ce9d5b 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog ## [Unreleased] + ### Added +- Added the `ToolCallSyntax` union and `FALLBACK_TOOL_SYNTAX` constant to `@oh-my-pi/pi-catalog/identity` (re-exported from `@oh-my-pi/pi-ai/grammar`). +- Added `preferredToolSyntax(modelId)` to `@oh-my-pi/pi-catalog/identity`, resolving a model's native tool-call syntax affinity from its family token (Claude→`anthropic`, GLM→`glm`, Kimi→`kimi`, Qwen→`qwen3`, DeepSeek→`deepseek`, OpenAI/gpt-oss→`harmony`, else the `xml` fallback). - Added `flux-1-schnell-fp8` to the Fireworks serverless model catalog - Added `gpt-oss-20b` to the Fireworks model catalog - Added `qwen3-embedding-8b` to the Fireworks model catalog @@ -14,6 +17,7 @@ ### Changed - Kept non-tool-capable Fireworks serverless models in discovery results and marked them with `supportsTools: false` for fallback-aware handling +- Extended `modelFamilyToken(modelId)` to classify Claude/OpenAI ids the structured parser misses (older dated forms such as `claude-3-5-sonnet-20241022` and `gpt-4o`), returning `anthropic`/`openai` instead of an empty token. ## [15.13.1] - 2026-06-15 @@ -180,4 +184,4 @@ ### Removed -- Removed the runtime enrichment layer: `enrichModelThinking` (and its non-enumerable memo-slot cache), `refreshModelThinking`, `modelOmitsReasoningEffort`, and the `model-thinking` re-exports of generator-only policies. Thinking metadata is resolved exactly once inside `buildModel`; runtime helpers (`getSupportedEfforts`, `clampThinkingLevelForModel`, `requireSupportedEffort`, the effort mappers) are pure field reads. \ No newline at end of file +- Removed the runtime enrichment layer: `enrichModelThinking` (and its non-enumerable memo-slot cache), `refreshModelThinking`, `modelOmitsReasoningEffort`, and the `model-thinking` re-exports of generator-only policies. Thinking metadata is resolved exactly once inside `buildModel`; runtime helpers (`getSupportedEfforts`, `clampThinkingLevelForModel`, `requireSupportedEffort`, the effort mappers) are pure field reads. diff --git a/packages/catalog/src/identity/family.ts b/packages/catalog/src/identity/family.ts index b40db9bca..1381344eb 100644 --- a/packages/catalog/src/identity/family.ts +++ b/packages/catalog/src/identity/family.ts @@ -78,6 +78,11 @@ export function isOpenAIGptOssModelId(modelId: string): boolean { return /(^|\/)gpt-oss[-:]/i.test(modelId); } +/** OpenAI model ids (gpt-*, o1-*, o3-*, o4-*, or prefixed with openai/). */ +export function isOpenAIModelId(modelId: string): boolean { + return /(^|\/)(gpt|o1|o3|o4)[-.]/i.test(modelId) || modelId.toLowerCase().includes("openai/"); +} + /** * Reasoning-capable GLM coding SKUs: glm-4.5 and up on the base / `-air` / * `-turbo` lines. Excludes the vision (`…v`) shape, the non-reasoning @@ -114,6 +119,8 @@ export function isGlmVisionModelId(modelId: string): boolean { export function modelFamilyToken(modelId: string): string { const parsed = parseKnownModel(modelId); if (parsed.family !== "unknown") return parsed.family; + if (isClaudeModelId(modelId) || isAnthropicNamespacedModelId(modelId)) return "anthropic"; + if (isOpenAIModelId(modelId)) return "openai"; if (isKimiModelId(modelId)) return "kimi"; if (isQwenModelId(modelId)) return "qwen"; if (isMinimaxM2FamilyModelId(modelId)) return "minimax"; diff --git a/packages/catalog/src/identity/index.ts b/packages/catalog/src/identity/index.ts index 69c28db81..bd6d34b9e 100644 --- a/packages/catalog/src/identity/index.ts +++ b/packages/catalog/src/identity/index.ts @@ -7,3 +7,4 @@ export * from "./markers"; export * from "./priority"; export * from "./reference"; export * from "./selection"; +export * from "./tool-syntax"; diff --git a/packages/catalog/src/identity/tool-syntax.ts b/packages/catalog/src/identity/tool-syntax.ts new file mode 100644 index 000000000..0149ec0d2 --- /dev/null +++ b/packages/catalog/src/identity/tool-syntax.ts @@ -0,0 +1,25 @@ +import { modelFamilyToken } from "./family"; + +export type ToolCallSyntax = "glm" | "hermes" | "kimi" | "xml" | "anthropic" | "deepseek" | "harmony" | "pi" | "qwen3"; + +export const FALLBACK_TOOL_SYNTAX: ToolCallSyntax = "xml"; + +export function preferredToolSyntax(modelId: string): ToolCallSyntax { + switch (modelFamilyToken(modelId)) { + case "anthropic": + return "anthropic"; + case "glm": + return "glm"; + case "kimi": + return "kimi"; + case "qwen": + return "qwen3"; + case "deepseek": + return "deepseek"; + case "openai": + case "gpt-oss": + return "harmony"; + default: + return FALLBACK_TOOL_SYNTAX; + } +} diff --git a/packages/catalog/test/preferred-tool-syntax.test.ts b/packages/catalog/test/preferred-tool-syntax.test.ts new file mode 100644 index 000000000..5b372e16c --- /dev/null +++ b/packages/catalog/test/preferred-tool-syntax.test.ts @@ -0,0 +1,16 @@ +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("xml"); + expect(preferredToolSyntax("unclassified-model-id")).toBe("xml"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 71e24b0cc..2cda49596 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `supportsTools` to model definitions and overrides so custom model configs can declare whether a model supports native tool calls @@ -8,9 +9,10 @@ - Added a conditional easter-egg tip recommending nerd fonts when using the unicode symbol preset. ### Changed -- Changed todo tool result rendering so that collapsed phases truncate from the beginning, showing the latest/active tasks and displaying the "more todos" summary at the top. +- Changed todo tool result rendering so that collapsed phases truncate from the beginning, showing the latest/active tasks and displaying the "more todos" summary at the top. - Expanded `tools.format` to support additional in-band tool-call syntaxes, including `anthropic`, `deepseek`, `harmony`, `pi`, and `qwen3` +- Changed the 13 tools that documented hand-written `` blocks (`eval`, `browser`, `todo`, `irc`, `ssh`, `ast_edit`, `ast_grep`, `debug`, `find`, `inspect_image`, `ask`, plus the `patch`/`apply_patch` edit modes) to define examples as typed `examples` data on the tool (`ToolExample>`); the AI layer now renders them in the model's native tool-call syntax and the markdown `` blocks were removed. - Changed the experimental owned tool-calling prompt from a GLM-only toggle to syntax-specific grammar prompts and result formats. `PI_OWNED_TOOLS=1` still forces GLM; `PI_OWNED_TOOLS=` forces that syntax. - Changed the large-paste menu to offer attachment XML blocks (``), local-file attachments, or inline paste as explicit actions. @@ -11711,4 +11713,4 @@ Initial public release. ## [0.7.6] - 2025-11-13 -Previous releases did not maintain a changelog. \ No newline at end of file +Previous releases did not maintain a changelog. diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 08f1ad49d..502775b44 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -2,7 +2,9 @@ import { MismatchError as HashlineMismatchError } from "@oh-my-pi/hashline"; import hashlineGrammar from "@oh-my-pi/hashline/grammar.lark" with { type: "text" }; import hashlineDescription from "@oh-my-pi/hashline/prompt.md" with { type: "text" }; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; +import type { z } from "zod/v4"; import { createLspWritethrough, type FileDiagnosticsResult, @@ -50,6 +52,7 @@ type EditParams = ReplaceParams | PatchParams | HashlineParams | ApplyPatchParam type EditModeDefinition = { description: (session: ToolSession) => string; parameters: TInput; + examples?: readonly ToolExample[]; execute: ( tool: EditTool, params: EditParams, @@ -356,6 +359,10 @@ export class EditTool implements AgentTool { return this.#getModeDefinition().parameters; } + get examples(): readonly ToolExample[] | undefined { + return this.#getModeDefinition().examples; + } + /** * When in `apply_patch` mode, expose the Codex Lark grammar so providers * that support OpenAI-style custom tools can emit a grammar-constrained @@ -404,6 +411,39 @@ export class EditTool implements AgentTool { patch: { description: () => prompt.render(patchDescription), parameters: patchEditSchema, + examples: [ + { + caption: "Create", + call: { path: "hello.txt", edits: [{ op: "create", diff: "Hello\n" }] }, + }, + { + caption: "Update", + call: { + path: "src/app.py", + edits: [ + { + op: "update", + diff: "@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n", + }, + ], + }, + }, + { + caption: "Rename", + call: { + path: "src/app.py", + edits: [{ op: "update", rename: "src/main.py", diff: "@@\n …\n" }], + }, + }, + { + caption: "Delete", + call: { path: "obsolete.txt", edits: [{ op: "delete" }] }, + }, + { + caption: "Multiple entries", + note: "All entries in one call apply to the top-level `path`; use separate calls for different files.", + }, + ] satisfies readonly ToolExample>[], execute: ( tool: EditTool, params: EditParams, @@ -432,6 +472,14 @@ export class EditTool implements AgentTool { apply_patch: { description: () => prompt.render(applyPatchDescription), parameters: applyPatchSchema, + examples: [ + { + caption: "Apply a combined patch file", + call: { + input: '*** Begin Patch\n*** Add File: hello.txt\n+Hello world\n*** Update File: src/app.py\n*** Move to: src/main.py\n@@ def greet():\n-print("Hi")\n+print("Hello, world!")\n*** Delete File: obsolete.txt\n*** End Patch\n', + }, + }, + ] satisfies readonly ToolExample>[], execute: ( tool: EditTool, params: EditParams, diff --git a/packages/coding-agent/src/prompts/tools/ask.md b/packages/coding-agent/src/prompts/tools/ask.md index 0fc7ada1a..f5492aa96 100644 --- a/packages/coding-agent/src/prompts/tools/ask.md +++ b/packages/coding-agent/src/prompts/tools/ask.md @@ -20,11 +20,3 @@ Asks user when you need clarification or input during task execution. - **If multiple choices are acceptable**, pick the most conservative/standard option and proceed; state the choice. - **Do NOT include "Other" option** — UI automatically adds "Other (type your own)" to every question. - - -# Single question -questions: [{"id": "auth_method", "question": "Which authentication method should this API use?", "options": [{"label": "JWT", "description": "Bearer tokens for stateless API clients."}, {"label": "OAuth2", "description": "Delegated authorization with external identity providers."}, {"label": "Session cookies", "description": "Browser-first authentication backed by server-side sessions."}], "recommended": 0}] - -# Multiple questions -questions: [{"id": "storage_type", "question": "Which storage backend?", "options": [{"label": "SQLite"}, {"label": "PostgreSQL"}]}, {"id": "auth_method", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "Session cookies"}]}] - diff --git a/packages/coding-agent/src/prompts/tools/ast-edit.md b/packages/coding-agent/src/prompts/tools/ast-edit.md index bf9b34c2a..66bc3b0cd 100644 --- a/packages/coding-agent/src/prompts/tools/ast-edit.md +++ b/packages/coding-agent/src/prompts/tools/ast-edit.md @@ -18,21 +18,6 @@ Performs structural AST-aware rewrites via native ast-grep. - Parse issues when files cannot be processed - -# Rename a call site across TypeScript files -`{"ops":[{"pat":"oldApi($$$ARGS)","out":"newApi($$$ARGS)"}],"paths":["src/**/*.ts"]}` -# Delete matching calls -`{"ops":[{"pat":"console.log($$$ARGS)","out":""}],"paths":["src/**/*.ts"]}` -# Rewrite import source path -`{"ops":[{"pat":"import { $$$IMPORTS } from \"old-package\"","out":"import { $$$IMPORTS } from \"new-package\""}],"paths":["src/**/*.ts"]}` -# Modernize to optional chaining (same metavariable enforces identity) -`{"ops":[{"pat":"$A && $A()","out":"$A?.()"}],"paths":["src/**/*.ts"]}` -# Swap two arguments using captures -`{"ops":[{"pat":"assertEqual($A, $B)","out":"assertEqual($B, $A)"}],"paths":["tests/**/*.ts"]}` -# Python — convert print calls to logging -`{"ops":[{"pat":"print($$$ARGS)","out":"logger.info($$$ARGS)"}],"paths":["src/**/*.py"]}` - - - Parse issues mean the rewrite is malformed or mis-scoped — fix the pattern before assuming a clean no-op - For one-off local text edits, you SHOULD prefer the Edit tool diff --git a/packages/coding-agent/src/prompts/tools/ast-grep.md b/packages/coding-agent/src/prompts/tools/ast-grep.md index d435be7fb..506c52992 100644 --- a/packages/coding-agent/src/prompts/tools/ast-grep.md +++ b/packages/coding-agent/src/prompts/tools/ast-grep.md @@ -22,19 +22,6 @@ Performs structural code search using AST matching via native ast-grep. - Summary counts (`totalMatches`, `filesWithMatches`, `filesSearched`) and parse issues when present - -# Search TypeScript files under src -`{"pat":"console.log($$$)","paths":["src/**/*.ts"]}` -# Named imports from a specific package -`{"pat":"import { $$$IMPORTS } from \"react\"","paths":["src/**/*.ts"]}` -# Arrow functions assigned to a const -`{"pat":"const $NAME = ($$$ARGS) => $BODY","paths":["src/utils/**/*.ts"]}` -# Method call on any object, ignoring method name with `$_` -`{"pat":"logger.$_($$$ARGS)","paths":["src/**/*.ts"]}` -# Loosest existence check for a symbol in one file -`{"pat":"processItems","paths":["src/worker.ts"]}` - - - AVOID repo-root scans — narrow `paths` first - Parse issues are query failure, not evidence of absence: repair the pattern or tighten `paths` before concluding "no matches" diff --git a/packages/coding-agent/src/prompts/tools/browser.md b/packages/coding-agent/src/prompts/tools/browser.md index a2c936eaa..95f1a5ed9 100644 --- a/packages/coding-agent/src/prompts/tools/browser.md +++ b/packages/coding-agent/src/prompts/tools/browser.md @@ -37,27 +37,6 @@ Drives real Chromium tab; full puppeteer access via JS execution. - `code` runs with full Node access. Treat as your code, not sandboxed code. - -# Open a tab and read structured page data -`{"action":"open","name":"docs","url":"https://example.com"}` -`{"action":"run","name":"docs","code":"const obs = await tab.observe(); display(obs); return obs.elements.length;"}` - -# Click an observed element by id -`{"action":"run","name":"docs","code":"const obs = await tab.observe(); const link = obs.elements.find(e => e.role === 'link' && e.name === 'Sign in'); assert(link, 'Sign in link missing'); await (await tab.id(link.id)).click();"}` - -# Fill and submit a form via selectors -`{"action":"run","name":"docs","code":"await tab.fill('input[name=email]', 'me@example.com'); await tab.click('text/Continue');"}` - -# Screenshot to look at the page — no save path -`{"action":"run","name":"docs","code":"await tab.screenshot();"}` - -# Attach to an existing Electron app -`{"action":"open","name":"cursor","app":{"path":"/Applications/Cursor.app/Contents/MacOS/Cursor"}}` - -# Close every tab and kill spawned-app processes -`{"action":"close","all":true,"kill":true}` - - Per call: `display(value)` outputs (text/images), then the JSON-stringified return value of `code`. `run` always produces at least a status line. diff --git a/packages/coding-agent/src/prompts/tools/debug.md b/packages/coding-agent/src/prompts/tools/debug.md index 8ae1ba844..74b63da44 100644 --- a/packages/coding-agent/src/prompts/tools/debug.md +++ b/packages/coding-agent/src/prompts/tools/debug.md @@ -19,16 +19,3 @@ Use for launching or attaching debuggers, setting breakpoints, stepping through - `program` must be an executable file or debug target, not a directory or interpreter name that resolves to a workspace directory. - Python debugging requires `debugpy`; install with `pip install debugpy` if the adapter is unavailable. - - -# Launch and inspect hang -1. `debug(action: "launch", program: "./my_app")` -2. `debug(action: "set_breakpoint", file: "src/main.c", line: 42)` -3. `debug(action: "continue")` -4. If the program appears hung: `debug(action: "pause")` -5. Inspect state with `threads`, `stack_trace`, `scopes`, and `variables` -# Launch a Python script with debugpy -`debug(action: "launch", adapter: "debugpy", program: "scripts/job.py", args: ["--flag"])` -# Raw debugger command through repl -`debug(action: "evaluate", expression: "info registers", context: "repl")` - diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index e4b959576..666006e0f 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -58,12 +58,3 @@ budget → per-turn token budget {{#if py}}`budget.total` (ceiling or None), `budget.spent()`, `budget.remaining()` (math.inf when no ceiling), `budget.hard` (bool).{{/if}}{{#if js}}`await budget.total()` (ceiling or null), `await budget.spent()`, `await budget.remaining()` (Infinity when no ceiling), `await budget.hard()`.{{/if}} Ceiling comes from a `+Nk` directive (advisory) or `+Nk!`/Goal Mode (hard — `agent()` refuses to spawn past it); otherwise None/null, spend still tracked across the turn. ``` - - -{ - "cells": [ - { "language": "py", "title": "imports", "timeout": 10, "code": "import json\nfrom pathlib import Path" }, - { "language": "py", "title": "load config", "code": "data = json.loads(read('package.json'))\ndisplay(data)" } - ] -} - diff --git a/packages/coding-agent/src/prompts/tools/find.md b/packages/coding-agent/src/prompts/tools/find.md index 9245df79b..76919a7ce 100644 --- a/packages/coding-agent/src/prompts/tools/find.md +++ b/packages/coding-agent/src/prompts/tools/find.md @@ -14,19 +14,6 @@ Finds files and directories using fast pattern matching that works with any code Matching file and directory paths sorted by modification time (most recent first), grouped by directory to reduce token usage. Each group starts with `# /` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB. - -# Find files -`{"paths": ["src/**/*.ts"]}` -# Multiple targets — separate array elements -`{"paths": ["src/**/*.ts", "test/**/*.ts"]}` -# Find gitignored files like .env -`{"paths": [".env*"], "gitignore": false}` -# Find directories matching a name (returns both files and dirs; directories are suffixed with `/`) -`{"paths": ["**/tests"]}` -# Long-running search on a slow volume -`{"paths": ["/Volumes/Storage/**/*.py"], "timeout": 30}` - - For open-ended searches requiring multiple rounds of globbing and searching, you MUST use Task tool instead. diff --git a/packages/coding-agent/src/prompts/tools/inspect-image.md b/packages/coding-agent/src/prompts/tools/inspect-image.md index 03ff2b150..0badcff33 100644 --- a/packages/coding-agent/src/prompts/tools/inspect-image.md +++ b/packages/coding-agent/src/prompts/tools/inspect-image.md @@ -11,15 +11,6 @@ Inspects an image file with a vision-capable model and returns compact text anal - Use this tool over `read` when the goal is image analysis - -# OCR with strict formatting -`{"path":"screenshots/error.png","question":"Extract all visible text verbatim. Return as bullet list in reading order."}` -# Screenshot debugging -`{"path":"screenshots/settings.png","question":"Identify the likely cause of the disabled Save button. Return: (1) observations, (2) likely cause, (3) confidence."}` -# Scene/object question -`{"path":"photos/shelf.jpg","question":"List all clearly visible product labels and their shelf positions (top/middle/bottom). If unreadable, say unreadable."}` - - - Returns text-only analysis from the vision model - No image content blocks are returned in tool output diff --git a/packages/coding-agent/src/prompts/tools/irc.md b/packages/coding-agent/src/prompts/tools/irc.md index b202daed9..963bde014 100644 --- a/packages/coding-agent/src/prompts/tools/irc.md +++ b/packages/coding-agent/src/prompts/tools/irc.md @@ -40,18 +40,3 @@ Applies to sending and replying. - `inbox`: pending messages, oldest first. - `list`: peers with status, unread count, parent, last activity. - - -# List peers -`{"op": "list"}` -# Fire-and-forget DM — same send wakes idle/parked peers -`{"op": "send", "to": "AuthLoader", "message": "Still touching src/server/auth.ts? I need to add a 401 path."}` -# Round-trip when you cannot proceed without the answer -`{"op": "send", "to": "Main", "message": "JWT or session cookies for the auth flow?", "await": true}` -# Block until a specific peer answers -`{"op": "wait", "from": "AuthLoader", "timeoutMs": 60000}` -# Drain pending messages -`{"op": "inbox"}` -# Broadcast to live peers (no replies expected) -`{"op": "send", "to": "all", "message": "About to refactor src/server/middleware/*. Anyone already in there?"}` - diff --git a/packages/coding-agent/src/prompts/tools/patch.md b/packages/coding-agent/src/prompts/tools/patch.md index f55cab551..960ec9c52 100644 --- a/packages/coding-agent/src/prompts/tools/patch.md +++ b/packages/coding-agent/src/prompts/tools/patch.md @@ -50,19 +50,6 @@ Returns success/failure; on failure, error message indicates: - NEVER use edit to fix indentation, whitespace, or reformat code. Formatting is a single command run once at the end (`bun fmt`, `cargo fmt`, `prettier --write`, etc.) — not N individual edits. If you see inconsistent indentation after an edit, leave it; the formatter will fix all of it in one pass. - -# Create -`edit {"path":"hello.txt","edits":[{"op":"create","diff":"Hello\n"}]}` -# Update -`edit {"path":"src/app.py","edits":[{"op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"}]}` -# Rename -`edit {"path":"src/app.py","edits":[{"op":"update","rename":"src/main.py","diff":"@@\n …\n"}]}` -# Delete -`edit {"path":"obsolete.txt","edits":[{"op":"delete"}]}` -# Multiple entries -All entries in one call apply to the top-level `path`; use separate calls for different files. - - - Generic anchors: `import`, `export`, `describe`, `function`, `const` - Repeating same addition in multiple hunks (duplicate blocks) diff --git a/packages/coding-agent/src/prompts/tools/ssh.md b/packages/coding-agent/src/prompts/tools/ssh.md index f7c352897..150342864 100644 --- a/packages/coding-agent/src/prompts/tools/ssh.md +++ b/packages/coding-agent/src/prompts/tools/ssh.md @@ -20,12 +20,3 @@ Runs commands on remote hosts. You MUST verify the shell type from "Available hosts" and use matching commands. - - -# List files: Linux -Host: server1 (10.0.0.1) | linux/bash. Command: `ls -la /home/user` -# Show running processes: Windows cmd -Host: winbox (192.168.1.5) | windows/cmd. Command: `tasklist /v` -# Get system info: macOS -Host: macbook (10.0.0.20) | macos/zsh. Command: `uname -a && sw_vers` - diff --git a/packages/coding-agent/src/prompts/tools/todo.md b/packages/coding-agent/src/prompts/tools/todo.md index be67cf1cc..f5dc3acd3 100644 --- a/packages/coding-agent/src/prompts/tools/todo.md +++ b/packages/coding-agent/src/prompts/tools/todo.md @@ -33,25 +33,6 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an - User provides a set of tasks to complete - New instructions arrive mid-task — capture before proceeding - -# Initial setup (multi-phase) -`{"ops":[{"op":"init","list":[{"phase":"Foundation","items":["Scaffold crate","Wire workspace"]},{"phase":"Auth","items":["Port credential store","Wire OAuth providers"]},{"phase":"Verification","items":["Run cargo test"]}]}]}` -# View current state (read-only) -`{"ops":[{"op":"view"}]}` -# Initial setup (single phase) -`{"ops":[{"op":"init","list":[{"phase":"Implementation","items":["Apply fix","Run tests"]}]}]}` -# Complete one task -`{"ops":[{"op":"done","task":"Wire workspace"}]}` -# Complete a whole phase -`{"ops":[{"op":"done","phase":"Auth"}]}` -# Remove all tasks -`{"ops":[{"op":"rm"}]}` -# Drop one task -`{"ops":[{"op":"drop","task":"Run cargo test"}]}` -# Append tasks to a phase -`{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}` - - When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through: - You MUST `init` the list with EVERY item as its own task before doing the work. diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index d6609b04d..1bfee97b2 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -16,6 +16,7 @@ */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type Component, Markdown, type MarkdownTheme, renderInlineMarkdown, TERMINAL, Text } from "@oh-my-pi/pi-tui"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; @@ -422,6 +423,46 @@ export class AskTool implements AgentTool { readonly description: string; readonly parameters = askSchema; readonly strict = true; + + readonly examples: readonly ToolExample>[] = [ + { + caption: "Single question", + call: { + questions: [ + { + id: "auth_method", + question: "Which authentication method should this API use?", + options: [ + { label: "JWT", description: "Bearer tokens for stateless API clients." }, + { label: "OAuth2", description: "Delegated authorization with external identity providers." }, + { + label: "Session cookies", + description: "Browser-first authentication backed by server-side sessions.", + }, + ], + recommended: 0, + }, + ], + }, + }, + { + caption: "Multiple questions", + call: { + questions: [ + { + id: "storage_type", + question: "Which storage backend?", + options: [{ label: "SQLite" }, { label: "PostgreSQL" }], + }, + { + id: "auth_method", + question: "Which auth method?", + options: [{ label: "JWT" }, { label: "Session cookies" }], + }, + ], + }, + }, + ]; // Run alone in its tool batch. The interactive selector/editor is a single // shared UI surface (`ExtensionUiController.showHookSelector` has no queue and // overwrites `ctx.hookSelector` on each call), so two concurrent `ask` calls diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 6245099fb..768a8158c 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -1,6 +1,7 @@ import * as path from "node:path"; import { formatHashlineHeader } from "@oh-my-pi/hashline"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { replaceTabs, Text } from "@oh-my-pi/pi-tui"; @@ -194,6 +195,51 @@ export class AstEditTool implements AgentTool>[] = [ + { + caption: "Rename a call site across TypeScript files", + call: { + ops: [{ pat: "oldApi($$$ARGS)", out: "newApi($$$ARGS)" }], + paths: ["src/**/*.ts"], + }, + }, + { + caption: "Delete matching calls", + call: { + ops: [{ pat: "console.log($$$ARGS)", out: "" }], + paths: ["src/**/*.ts"], + }, + }, + { + caption: "Rewrite import source path", + call: { + ops: [{ pat: 'import { $$$IMPORTS } from "old-package"', out: 'import { $$$IMPORTS } from "new-package"' }], + paths: ["src/**/*.ts"], + }, + }, + { + caption: "Modernize to optional chaining (same metavariable enforces identity)", + call: { + ops: [{ pat: "$A && $A()", out: "$A?.()" }], + paths: ["src/**/*.ts"], + }, + }, + { + caption: "Swap two arguments using captures", + call: { + ops: [{ pat: "assertEqual($A, $B)", out: "assertEqual($B, $A)" }], + paths: ["tests/**/*.ts"], + }, + }, + { + caption: "Python — convert print calls to logging", + call: { + ops: [{ pat: "print($$$ARGS)", out: "logger.info($$$ARGS)" }], + paths: ["src/**/*.py"], + }, + }, + ]; readonly deferrable = true; readonly loadMode = "discoverable"; constructor(private readonly session: ToolSession) { diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 39011115d..6681b798c 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -1,6 +1,7 @@ import * as path from "node:path"; import { formatHashlineHeader } from "@oh-my-pi/hashline"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; @@ -130,6 +131,29 @@ export class AstGrepTool implements AgentTool>[] = [ + { + caption: "Search TypeScript files under src", + call: { pat: "console.log($$$)", paths: ["src/**/*.ts"] }, + }, + { + caption: "Named imports from a specific package", + call: { pat: 'import { $$$IMPORTS } from "react"', paths: ["src/**/*.ts"] }, + }, + { + caption: "Arrow functions assigned to a const", + call: { pat: "const $NAME = ($$$ARGS) => $BODY", paths: ["src/utils/**/*.ts"] }, + }, + { + caption: "Method call on any object, ignoring method name with `$_`", + call: { pat: "logger.$_($$$ARGS)", paths: ["src/**/*.ts"] }, + }, + { + caption: "Loosest existence check for a symbol in one file", + call: { pat: "processItems", paths: ["src/worker.ts"] }, + }, + ]; readonly loadMode = "discoverable"; constructor(private readonly session: ToolSession) { diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index b27fc96b4..87c7c17cf 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -1,4 +1,5 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import { prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; import browserDescription from "../prompts/tools/browser.md" with { type: "text" }; @@ -118,6 +119,57 @@ export class BrowserTool implements AgentTool>[] = [ + { + caption: "Open a tab", + call: { action: "open", name: "docs", url: "https://example.com" }, + }, + { + caption: "Read structured page data in the opened tab", + call: { + action: "run", + name: "docs", + code: "const obs = await tab.observe(); display(obs); return obs.elements.length;", + }, + }, + { + caption: "Click an observed element by id", + call: { + action: "run", + name: "docs", + code: "const obs = await tab.observe(); const link = obs.elements.find(e => e.role === 'link' && e.name === 'Sign in'); assert(link, 'Sign in link missing'); await (await tab.id(link.id)).click();", + }, + }, + { + caption: "Fill and submit a form via selectors", + call: { + action: "run", + name: "docs", + code: "await tab.fill('input[name=email]', 'me@example.com'); await tab.click('text/Continue');", + }, + }, + { + caption: "Screenshot to look at the page — no save path", + call: { + action: "run", + name: "docs", + code: "await tab.screenshot();", + }, + }, + { + caption: "Attach to an existing Electron app", + call: { + action: "open", + name: "cursor", + app: { path: "/Applications/Cursor.app/Contents/MacOS/Cursor" }, + }, + }, + { + caption: "Close every tab and kill spawned-app processes", + call: { action: "close", all: true, kill: true }, + }, + ]; + constructor(private readonly session: ToolSession) {} #description?: string; get description(): string { diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index c018f721c..25e84a29b 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -7,6 +7,7 @@ import type { RenderResultOptions, ToolApprovalDecision, } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { isEnoent, prompt } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; @@ -659,6 +660,22 @@ export class DebugTool implements AgentTool>[] = [ + { + caption: "Launch and inspect hang", + note: '1. debug(action: "launch", program: "./my_app")\n2. debug(action: "set_breakpoint", file: "src/main.c", line: 42)\n3. debug(action: "continue")\n4. If the program appears hung: debug(action: "pause")\n5. Inspect state with `threads`, `stack_trace`, `scopes`, and `variables`', + }, + { + caption: "Launch a Python script with debugpy", + call: { action: "launch", adapter: "debugpy", program: "scripts/job.py", args: ["--flag"] }, + }, + { + caption: "Raw debugger command through repl", + call: { action: "evaluate", expression: "info registers", context: "repl" }, + }, + ]; + readonly concurrency = "exclusive"; readonly loadMode = "discoverable"; diff --git a/packages/coding-agent/src/tools/eval.ts b/packages/coding-agent/src/tools/eval.ts index aa8cc043c..f4d04bb50 100644 --- a/packages/coding-agent/src/tools/eval.ts +++ b/packages/coding-agent/src/tools/eval.ts @@ -1,5 +1,5 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import type { ImageContent } from "@oh-my-pi/pi-ai"; +import type { ImageContent, ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; import { jsBackend, pythonBackend } from "../eval"; @@ -183,6 +183,25 @@ export class EvalTool implements AgentTool { const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null; return getEvalToolDescription({ py: backends.python, js: backends.js, spawns: spawnsAllowed }); } + readonly examples: readonly ToolExample>[] = [ + { + call: { + cells: [ + { + language: "py", + title: "imports", + timeout: 10, + code: "import json\nfrom pathlib import Path", + }, + { + language: "py", + title: "load config", + code: "data = json.loads(read('package.json'))\ndisplay(data)", + }, + ], + }, + }, + ]; readonly parameters = evalSchema; readonly concurrency = "exclusive"; readonly strict = true; diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index 321db0303..db6af4359 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import * as natives from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; @@ -106,6 +107,29 @@ export class FindTool implements AgentTool { readonly label = "Find"; readonly description: string; readonly parameters = findSchema; + + readonly examples: readonly ToolExample>[] = [ + { + caption: "Find files", + call: { paths: ["src/**/*.ts"] }, + }, + { + caption: "Multiple targets — separate array elements", + call: { paths: ["src/**/*.ts", "test/**/*.ts"] }, + }, + { + caption: "Find gitignored files like .env", + call: { paths: [".env*"], gitignore: false }, + }, + { + caption: "Find directories matching a name (returns both files and dirs; directories are suffixed with `/`)", + call: { paths: ["**/tests"] }, + }, + { + caption: "Long-running search on a slow volume", + call: { paths: ["/Volumes/Storage/**/*.py"], timeout: 30 }, + }, + ]; readonly strict = true; readonly #customOps?: FindOperations; diff --git a/packages/coding-agent/src/tools/inspect-image.ts b/packages/coding-agent/src/tools/inspect-image.ts index 52ebf1d30..10828c2e3 100644 --- a/packages/coding-agent/src/tools/inspect-image.ts +++ b/packages/coding-agent/src/tools/inspect-image.ts @@ -1,6 +1,6 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; -import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai"; +import { type Api, completeSimple, type Model, type ToolExample } from "@oh-my-pi/pi-ai"; import { prompt } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; import { extractTextContent } from "../commit/utils"; @@ -43,6 +43,32 @@ export class InspectImageTool implements AgentTool>[] = [ + { + caption: "OCR with strict formatting", + call: { + path: "screenshots/error.png", + question: "Extract all visible text verbatim. Return as bullet list in reading order.", + }, + }, + { + caption: "Screenshot debugging", + call: { + path: "screenshots/settings.png", + question: + "Identify the likely cause of the disabled Save button. Return: (1) observations, (2) likely cause, (3) confidence.", + }, + }, + { + caption: "Scene/object question", + call: { + path: "photos/shelf.jpg", + question: + "List all clearly visible product labels and their shelf positions (top/middle/bottom). If unreadable, say unreadable.", + }, + }, + ]; + constructor( private readonly session: ToolSession, private readonly completeImageRequest: typeof completeSimple = completeSimple, diff --git a/packages/coding-agent/src/tools/irc.ts b/packages/coding-agent/src/tools/irc.ts index 1d420e30a..0856917ad 100644 --- a/packages/coding-agent/src/tools/irc.ts +++ b/packages/coding-agent/src/tools/irc.ts @@ -10,6 +10,7 @@ */ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { formatAge, formatDuration, prompt } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; @@ -96,6 +97,46 @@ export class IrcTool implements AgentTool { readonly description: string; readonly parameters = ircSchema; readonly strict = true; + + readonly examples: readonly ToolExample>[] = [ + { + caption: "List peers", + call: { op: "list" }, + }, + { + caption: "Fire-and-forget DM — same send wakes idle/parked peers", + call: { + op: "send", + to: "AuthLoader", + message: "Still touching src/server/auth.ts? I need to add a 401 path.", + }, + }, + { + caption: "Round-trip when you cannot proceed without the answer", + call: { + op: "send", + to: "Main", + message: "JWT or session cookies for the auth flow?", + await: true, + }, + }, + { + caption: "Block until a specific peer answers", + call: { op: "wait", from: "AuthLoader", timeoutMs: 60000 }, + }, + { + caption: "Drain pending messages", + call: { op: "inbox" }, + }, + { + caption: "Broadcast to live peers (no replies expected)", + call: { + op: "send", + to: "all", + message: "About to refactor src/server/middleware/*. Anyone already in there?", + }, + }, + ]; readonly loadMode = "discoverable"; constructor(private readonly session: ToolSession) { this.description = prompt.render(ircDescription); diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 8f1a41419..5f2d1c84b 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -1,4 +1,5 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; @@ -135,6 +136,21 @@ export class SshTool implements AgentTool { readonly concurrency = "exclusive"; readonly strict = true; + readonly examples: readonly ToolExample>[] = [ + { + caption: "List files: Linux (on server1 (10.0.0.1) | linux/bash)", + call: { host: "server1", command: "ls -la /home/user" }, + }, + { + caption: "Show running processes: Windows cmd (on winbox (192.168.1.5) | windows/cmd)", + call: { host: "winbox", command: "tasklist /v" }, + }, + { + caption: "Get system info: macOS (on macbook (10.0.0.20) | macos/zsh)", + call: { host: "macbook", command: "uname -a && sw_vers" }, + }, + ]; + readonly #allowedHosts: Set; constructor( diff --git a/packages/coding-agent/src/tools/todo.ts b/packages/coding-agent/src/tools/todo.ts index d92a1ed7d..e4d224a0f 100644 --- a/packages/coding-agent/src/tools/todo.ts +++ b/packages/coding-agent/src/tools/todo.ts @@ -1,4 +1,5 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { prompt } from "@oh-my-pi/pi-utils"; @@ -551,6 +552,71 @@ export class TodoTool implements AgentTool { readonly parameters = todoSchema; readonly concurrency = "exclusive"; readonly strict = true; + + readonly examples: readonly ToolExample>[] = [ + { + caption: "Initial setup (multi-phase)", + call: { + ops: [ + { + op: "init", + list: [ + { phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] }, + { phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] }, + { phase: "Verification", items: ["Run cargo test"] }, + ], + }, + ], + }, + }, + { + caption: "View current state (read-only)", + call: { + ops: [{ op: "view" }], + }, + }, + { + caption: "Initial setup (single phase)", + call: { + ops: [ + { + op: "init", + list: [{ phase: "Implementation", items: ["Apply fix", "Run tests"] }], + }, + ], + }, + }, + { + caption: "Complete one task", + call: { + ops: [{ op: "done", task: "Wire workspace" }], + }, + }, + { + caption: "Complete a whole phase", + call: { + ops: [{ op: "done", phase: "Auth" }], + }, + }, + { + caption: "Remove all tasks", + call: { + ops: [{ op: "rm" }], + }, + }, + { + caption: "Drop one task", + call: { + ops: [{ op: "drop", task: "Run cargo test" }], + }, + }, + { + caption: "Append tasks to a phase", + call: { + ops: [{ op: "append", phase: "Auth", items: ["Handle retries", "Run tests"] }], + }, + }, + ]; readonly loadMode = "discoverable"; constructor(private readonly session: ToolSession) { this.description = prompt.render(todoDescription); diff --git a/packages/coding-agent/test/input-controller-large-paste.test.ts b/packages/coding-agent/test/input-controller-large-paste.test.ts index 2ab33710b..498983317 100644 --- a/packages/coding-agent/test/input-controller-large-paste.test.ts +++ b/packages/coding-agent/test/input-controller-large-paste.test.ts @@ -72,7 +72,7 @@ describe("InputController.presentLargePasteMenu actions", () => { await controller.presentLargePasteMenu("payload", 1); const options = spies.showHookSelector.mock.calls[0][1] as Array<{ label: string }>; - expect(options.map((option) => option.label)).toEqual([ + expect(options.map(option => option.label)).toEqual([ "Attach as a wrapped block", "Attach as local file", "Paste inline", diff --git a/packages/coding-agent/test/tui-tree-list-collapsed-lines.test.ts b/packages/coding-agent/test/tui-tree-list-collapsed-lines.test.ts index 3e1d4e9c4..dd519a2b7 100644 --- a/packages/coding-agent/test/tui-tree-list-collapsed-lines.test.ts +++ b/packages/coding-agent/test/tui-tree-list-collapsed-lines.test.ts @@ -224,4 +224,68 @@ describe("renderTreeList maxCollapsedLines", () => { expect(collapsed).toHaveLength(3); expect(collapsed[2]).toContain("2 more items"); }); + + it("truncates from the start when maxCollapsed limits items", () => { + const items = [["a"], ["b"], ["c"], ["d"], ["e"]]; + const collapsed = renderTreeList( + { + items, + expanded: false, + maxCollapsed: 3, + itemType: "todo", + truncateFrom: "start", + renderItem: group => group, + }, + stubTheme, + ); + + // With 5 items and maxCollapsed: 3, we show: + // 1. Summary line: ├ … 2 more todos + // 2. Item 'c' (index 2): ├ c + // 3. Item 'd' (index 3): ├ d + // 4. Item 'e' (index 4): └ e + expect(collapsed).toHaveLength(4); + expect(collapsed[0]).toContain("2 more todos"); + expect(collapsed[0]).toContain("├"); + expect(collapsed[1]).toBe("├ c"); + expect(collapsed[2]).toBe("├ d"); + expect(collapsed[3]).toBe("└ e"); + }); + + it("truncates from the start when maxCollapsedLines limits items", () => { + const items = [ + ["a", "a2"], + ["b", "b2"], + ["c", "c2"], + ["d", "d2"], + ]; + const collapsed = renderTreeList( + { + items, + expanded: false, + maxCollapsedLines: 5, + itemType: "todo", + truncateFrom: "start", + renderItem: group => group, + }, + stubTheme, + ); + + // items are each 2 lines. Total budget is 5. + // Moving backwards: + // - item 3 ('d', 'd2'): fits. lines used: 2. summary lines needed (remainingBefore > 0): 1. total = 3. + // - item 2 ('c', 'c2'): fits. lines used: 4. summary lines needed: 1. total = 5. + // - item 1 ('b', 'b2'): does not fit (would be 6 + 1 = 7 > 5). + // So we show: + // 1. Summary line: ├ … 2 more todos + // 2. Item 'c' (2 lines: ├ c, │ c2) + // 3. Item 'd' (2 lines: └ d, d2) + expectWithinBudget(collapsed, 5); + expect(collapsed).toHaveLength(5); + expect(collapsed[0]).toContain("2 more todos"); + expect(collapsed[1]).toBe("├ c"); + expect(collapsed[2]).toBe("│ c2"); + expect(collapsed[3]).toBe("└ d"); + expect(collapsed[4]).toBe(" d2"); + }); });