feat: added syntax-aware tool example rendering across catalog, AI, and agent modules

- Added model-to-syntax mapping in catalog with preferred tool-call syntax API.
- Added `ToolExample` typing and `ToolCallSyntax` exports across tool/grammar interfaces.
- Added syntax-aware tool example rendering through provider-specific grammar invocations.
- Added `exampleSyntax` context flow and example metadata so rendered prompts include examples.
This commit is contained in:
can1357
2026-06-15 07:32:11 +02:00
parent 52f2ade900
commit 7687810c5d
52 changed files with 963 additions and 239 deletions
+2
View File
@@ -10,6 +10,7 @@
- Added support for selecting owned in-band tool-call syntax via `PI_OWNED_TOOLS=<syntax>` (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 `<examples>` 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.
+18 -4
View File
@@ -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) {
+4 -1
View File
@@ -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++) {
@@ -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>): AgentContext {
};
}
function makeTool(name: string, description?: string, parameters?: Record<string, unknown>): AgentTool {
function makeTool(
name: string,
description?: string,
parameters?: Record<string, unknown>,
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("<examples>");
expect(desc).toContain("# Find files");
expect(desc).toContain('<invoke name="find">');
});
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('<parameter name="_i"');
expect(desc).toContain("…");
});
it("exampleSyntax flip invalidates the fingerprint cache", () => {
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
// ---------------------------------------------------------------------------
+5 -1
View File
@@ -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 `<examples>` 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.
Initial release with multi-provider LLM support.
+2 -1
View File
@@ -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,
};
+2 -1
View File
@@ -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,
};
+33
View File
@@ -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, 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
// schema injection order.
const finalArgs = intentField ? { [intentField]: INTENT_PLACEHOLDER, ...args } : args;
const call: ToolCall = {
type: "toolCall",
id: "example",
name: tool.name,
arguments: finalArgs,
};
return `<example>\n${grammar.renderToolCall(call, { tools: [tool] }).trim()}\n</example>`;
};
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 `<examples>\n${parts.join("\n")}\n</examples>`;
}
+2 -1
View File
@@ -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 = "<tool_call>";
@@ -376,6 +376,7 @@ const grammar: Grammar = {
syntax: "glm",
prompt: grammarPrompt,
createScanner: options => new GLMInbandScanner(options),
renderToolCall: renderGlmInvocation,
renderAssistantToolCalls: renderGlmToolCalls,
renderToolResults: renderGlmToolResults,
};
+2 -1
View File
@@ -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,
};
+2 -1
View File
@@ -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 = "<tool_call>";
@@ -163,6 +163,7 @@ const grammar: Grammar = {
syntax: "hermes",
prompt: grammarPrompt,
createScanner: options => new HermesInbandScanner(options),
renderToolCall: renderHermesInvocation,
renderAssistantToolCalls: renderHermesToolCalls,
renderToolResults: renderToolResponseResults,
};
+1
View File
@@ -1,5 +1,6 @@
export * from "./catalog";
export * from "./coercion";
export * from "./examples";
export * from "./factory";
export * from "./history";
export * from "./owned-stream";
+2 -1
View File
@@ -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,
};
+2 -1
View File
@@ -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 = "<call:";
@@ -577,6 +577,7 @@ const grammar: Grammar = {
syntax: "pi",
prompt: grammarPrompt,
createScanner: options => new PiNativeInbandScanner(options),
renderToolCall: renderPiNativeInvocation,
renderAssistantToolCalls: renderPiNativeToolCalls,
renderToolResults: renderToolResponseResults,
};
+2 -1
View File
@@ -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 = "<tool_call>";
@@ -195,6 +195,7 @@ const grammar: Grammar = {
syntax: "qwen3",
prompt: grammarPrompt,
createScanner: options => new Qwen3InbandScanner(options),
renderToolCall: renderHermesInvocation,
renderAssistantToolCalls: renderHermesToolCalls,
renderToolResults: renderToolResponseResults,
};
+80 -58
View File
@@ -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 = `<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 => {
const shape = shapes.get(call.name);
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>`;
})
.join("\n");
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): string {
return `<tool_call>\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n</tool_call>`;
}
export function renderHermesToolCalls(calls: readonly ToolCall[]): string {
return calls
.map(call => `<tool_call>\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n</tool_call>`)
.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 `<function_calls>\n${renderXmlInvokes(calls, options.tools ?? [])}\n</function_calls>`;
@@ -105,44 +121,50 @@ export function renderAnthropicToolResults(results: readonly GrammarToolResult[]
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 => {
const shape = shapes.get(call.name);
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}>`;
})
.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 => `<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 stringAttr = isString ? ' string="true"' : ' string="false"';
const rendered = isString && typeof value === "string" ? value : stringifyJson(value);
body += `<parameter name="${escapeXmlAttr(key)}"${stringAttr}>${rendered}</parameter>`;
}
return `${body}</invoke>`;
}
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 = `<invoke name="${escapeXmlAttr(call.name)}">`;
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 += `<parameter name="${escapeXmlAttr(key)}"${stringAttr}>${rendered}</parameter>`;
}
return `${body}</invoke>`;
})
.join("\n");
return calls.map(call => renderXmlInvoke(call, shapes.get(call.name))).join("\n");
}
function renderPiNativeElement(key: string, value: unknown, schema: unknown): string {
+5 -1
View File
@@ -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 `<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;
}
+2 -1
View File
@@ -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,
};
+27
View File
@@ -587,6 +587,24 @@ export type TSchema = ZodType | TJsonSchema;
/** Resolve parameter types for tool execution / handlers. */
export type Static<S> = S extends ZodType ? z.infer<S> : S extends { static: infer T } ? T : unknown;
export interface ToolCallExample<TArgs = Record<string, unknown>> {
caption?: string;
call: TArgs;
}
export interface ToolCompareExample<TArgs = Record<string, unknown>> {
caption?: string;
bad: TArgs;
good: TArgs;
}
export interface ToolNoteExample {
caption: string;
note?: string;
}
export type ToolExample<TArgs = Record<string, unknown>> =
| ToolCallExample<TArgs>
| ToolCompareExample<TArgs>
| ToolNoteExample;
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
@@ -610,6 +628,15 @@ export interface Tool<TParameters extends TSchema = TSchema> {
* calls route correctly. Absent for regular JSON function tools.
*/
customWireName?: string;
/**
* Illustrative calls/notes; the AI layer renders them into an `<examples>`
* 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<z.input<typeof schema>>[]`).
*/
examples?: readonly ToolExample[];
}
export interface Context {
+162
View File
@@ -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("<examples>");
expect(rendered).toContain("# Find files");
expect(rendered).toContain('<invoke name="find">');
expect(rendered).toContain('<parameter name="paths"');
expect(rendered).toContain("</examples>");
});
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("<call:find>");
expect(rendered).toContain("<paths>");
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("<tool_call>");
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('<parameter name="paths"');
expect(rendered).toContain('["**/*.ts"]');
});
it("injects the intent-field placeholder when intentField is provided", () => {
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('<parameter name="_i"');
expect(rendered).toContain("…");
// Placeholder leads the args, matching schema-injection order.
expect(rendered.indexOf('name="_i"')).toBeLessThan(rendered.indexOf('name="paths"'));
});
it("omits the intent-field placeholder when intentField is undefined", () => {
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");
});
});
+5 -1
View File
@@ -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.
- 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.
+7
View File
@@ -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";
+1
View File
@@ -7,3 +7,4 @@ export * from "./markers";
export * from "./priority";
export * from "./reference";
export * from "./selection";
export * from "./tool-syntax";
@@ -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;
}
}
@@ -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");
});
});
+4 -2
View File
@@ -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 `<examples>` 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<z.input<typeof schema>>`); the AI layer now renders them in the model's native tool-call syntax and the markdown `<examples>` 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=<syntax>` forces that syntax.
- Changed the large-paste menu to offer attachment XML blocks (`<attachment>`), 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.
Previous releases did not maintain a changelog.
+48
View File
@@ -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<TInput> {
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<TInput> {
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<z.input<typeof patchEditSchema>>[],
execute: (
tool: EditTool,
params: EditParams,
@@ -432,6 +472,14 @@ export class EditTool implements AgentTool<TInput> {
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<z.input<typeof applyPatchSchema>>[],
execute: (
tool: EditTool,
params: EditParams,
@@ -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.
</critical>
<examples>
# 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"}]}]
</examples>
@@ -18,21 +18,6 @@ Performs structural AST-aware rewrites via native ast-grep.
- Parse issues when files cannot be processed
</output>
<examples>
# 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"]}`
</examples>
<critical>
- 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
@@ -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
</output>
<examples>
# 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"]}`
</examples>
<critical>
- 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"
@@ -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.
</critical>
<examples>
# 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}`
</examples>
<output>
Per call: `display(value)` outputs (text/images), then the JSON-stringified return value of `code`. `run` always produces at least a status line.
</output>
@@ -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.
</caution>
<examples>
# 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")`
</examples>
@@ -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.
```
</prelude>
<example>
{
"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)" }
]
}
</example>
@@ -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 `# <dir>/` followed by basenames (one per line); directory entries get a trailing `/`. Root-level entries have no header. Truncated at 200 entries or 50KB.
</output>
<examples>
# 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}`
</examples>
<avoid>
For open-ended searches requiring multiple rounds of globbing and searching, you MUST use Task tool instead.
</avoid>
@@ -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
</instruction>
<examples>
# 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."}`
</examples>
<output>
- Returns text-only analysis from the vision model
- No image content blocks are returned in tool output
@@ -40,18 +40,3 @@ Applies to sending and replying.
- `inbox`: pending messages, oldest first.
- `list`: peers with status, unread count, parent, last activity.
</output>
<examples>
# 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?"}`
</examples>
@@ -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.
</critical>
<examples>
# 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.
</examples>
<avoid>
- Generic anchors: `import`, `export`, `describe`, `function`, `const`
- Repeating same addition in multiple hunks (duplicate blocks)
@@ -20,12 +20,3 @@ Runs commands on remote hosts.
<critical>
You MUST verify the shell type from "Available hosts" and use matching commands.
</critical>
<examples>
# 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`
</examples>
@@ -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
<examples>
# 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"]}]}`
</examples>
<critical>
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.
+41
View File
@@ -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<typeof askSchema, AskToolDetails> {
readonly description: string;
readonly parameters = askSchema;
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof askSchema>>[] = [
{
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
@@ -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<typeof astEditSchema, AstEditToolD
readonly description: string;
readonly parameters = astEditSchema;
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof astEditSchema>>[] = [
{
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) {
@@ -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<typeof astGrepSchema, AstGrepToolD
readonly description: string;
readonly parameters = astGrepSchema;
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof astGrepSchema>>[] = [
{
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) {
@@ -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<typeof browserSchema, BrowserToolD
readonly parameters = browserSchema;
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof browserSchema>>[] = [
{
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 {
+17
View File
@@ -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<typeof debugSchema, DebugToolDetails
readonly description: string;
readonly parameters = debugSchema;
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof debugSchema>>[] = [
{
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";
+20 -1
View File
@@ -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<typeof evalSchema> {
const spawnsAllowed = sessionSpawns !== "" && sessionSpawns !== null;
return getEvalToolDescription({ py: backends.python, js: backends.js, spawns: spawnsAllowed });
}
readonly examples: readonly ToolExample<z.input<typeof evalSchema>>[] = [
{
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;
+24
View File
@@ -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<typeof findSchema, FindToolDetails> {
readonly label = "Find";
readonly description: string;
readonly parameters = findSchema;
readonly examples: readonly ToolExample<z.input<typeof findSchema>>[] = [
{
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;
@@ -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<typeof inspectImageSchema, In
readonly parameters = inspectImageSchema;
readonly strict = false;
readonly examples: readonly ToolExample<z.input<typeof inspectImageSchema>>[] = [
{
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,
+41
View File
@@ -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<typeof ircSchema, IrcDetails> {
readonly description: string;
readonly parameters = ircSchema;
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof ircSchema>>[] = [
{
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);
+16
View File
@@ -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<typeof sshSchema, SSHToolDetails> {
readonly concurrency = "exclusive";
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof sshSchema>>[] = [
{
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<string>;
constructor(
+66
View File
@@ -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<typeof todoSchema, TodoToolDetails> {
readonly parameters = todoSchema;
readonly concurrency = "exclusive";
readonly strict = true;
readonly examples: readonly ToolExample<z.input<typeof todoSchema>>[] = [
{
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);
@@ -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",
@@ -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");
});
});