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:
@@ -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,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) {
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from "./catalog";
|
||||
export * from "./coercion";
|
||||
export * from "./examples";
|
||||
export * from "./factory";
|
||||
export * from "./history";
|
||||
export * from "./owned-stream";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user