From 8b7dd10a8a61ef5e65093b338325816566fcac20 Mon Sep 17 00:00:00 2001 From: can1357 Date: Mon, 15 Jun 2026 08:12:58 +0200 Subject: [PATCH] feat: added native tool inventory rendering with TypeScript signatures - Added `jsonSchemaToTypeScript` and `renderToolInventory` to generate tool blocks with TypeScript signatures. - Added `examples` and `TSchema` fields to dump-tool metadata and passed them through prompt rendering. - Changed Harmony invocation rendering to omit `<|constrain|>json` markers in tool call payloads. - Added compact native tool list-mode inventory rendering with full `# Tool:` output elsewhere. --- docs/toolconv/anthropic.md | 6 +- docs/toolconv/harmony.md | 18 +- packages/ai/CHANGELOG.md | 7 +- packages/ai/src/grammar/harmony.md | 2 +- packages/ai/src/grammar/index.ts | 1 + packages/ai/src/grammar/inventory.ts | 28 +++ packages/ai/src/grammar/rendering.ts | 2 +- packages/ai/src/utils/schema/index.ts | 1 + packages/ai/src/utils/schema/typescript.ts | 198 ++++++++++++++++++ packages/ai/test/inband-tools.test.ts | 7 +- .../ai/test/json-schema-typescript.test.ts | 94 +++++++++ packages/ai/test/tool-inventory.test.ts | 43 ++++ packages/coding-agent/CHANGELOG.md | 6 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 1 + .../coding-agent/src/modes/rpc/rpc-types.ts | 4 +- .../src/prompts/system/system-prompt.md | 38 ++-- packages/coding-agent/src/sdk.ts | 5 + .../src/session/session-dump-format.ts | 41 +--- packages/coding-agent/src/system-prompt.ts | 31 +++ .../test/session/session-dump-format.test.ts | 59 ++++-- .../test/system-prompt-inventory.test.ts | 100 +++++++++ .../src/in-process-client.ts | 5 +- .../typescript-edit-benchmark/src/runner.ts | 6 +- 23 files changed, 603 insertions(+), 100 deletions(-) create mode 100644 packages/ai/src/grammar/inventory.ts create mode 100644 packages/ai/src/utils/schema/typescript.ts create mode 100644 packages/ai/test/json-schema-typescript.test.ts create mode 100644 packages/ai/test/tool-inventory.test.ts create mode 100644 packages/coding-agent/test/system-prompt-inventory.test.ts diff --git a/docs/toolconv/anthropic.md b/docs/toolconv/anthropic.md index fc3f05ada..19ae67790 100644 --- a/docs/toolconv/anthropic.md +++ b/docs/toolconv/anthropic.md @@ -138,7 +138,7 @@ Here are the functions available in JSONSchema format: {{ TOOL CONFIGURATION }} ``` -`{{ TOOL DEFINITIONS IN JSON SCHEMA }}` is your `tools` array serialized to JSON Schema. `{{ FORMATTING INSTRUCTIONS }}` is the (unpublished) block teaching the model the ``/``/`` syntax shown under [Tool-call format → underlying XML](#underlying-xml-modern-attribute-form). The note "parsed with regular expressions" is why output need not be well-formed XML. +`{{ TOOL DEFINITIONS IN JSON SCHEMA }}` is your `tools` array serialized to JSON Schema. `{{ FORMATTING INSTRUCTIONS }}` is the (unpublished) block teaching the model the XML syntax with `antml:` namespace prefixes (shown under [Tool-call format → underlying XML](#underlying-xml-modern-attribute-form-with-antml-namespace)). The note "parsed with regular expressions" is why output need not be well-formed XML. --- @@ -175,7 +175,7 @@ Key facts for a parser: - A leading `text` block is optional and informational; do not rely on its wording. - Match calls to results by `id` → `tool_use_id`. -### Underlying XML (modern attribute form) +### Underlying XML (modern attribute form with antml: namespace) Before the API converts it, the model literally emits an XML block. The current (Claude 3+) form is attribute-based: @@ -188,7 +188,7 @@ Before the API converts it, the model literally emits an XML block. The current ``` -`[Partially verified]` Anthropic does not publish the literal `{{ FORMATTING INSTRUCTIONS }}`, so the exact tag spelling for current models is reconstructed from the trained format (and matches the task's reference anchor) rather than an official verbatim doc. In production, current Claude models prefix these tags with an `antml:` XML namespace (e.g. ``, ``, ``); the namespace is widely observed but **not** documented officially — treat it as `[unverified]`. The API strips all of this and exposes only the JSON `tool_use` block; integrators should target the JSON, not the XML. +Current Claude models prefix these tags with an `antml:` XML namespace prefix (e.g. `antml:function_calls`, `antml:invoke name="…"`, `antml:parameter name="…"`). The API strips all of this and exposes only the JSON `tool_use` block; integrators should target the JSON, not the XML. --- diff --git a/docs/toolconv/harmony.md b/docs/toolconv/harmony.md index a6c1ed803..5051a64ee 100644 --- a/docs/toolconv/harmony.md +++ b/docs/toolconv/harmony.md @@ -110,21 +110,21 @@ format?: "celsius" | "fahrenheit", // default: celsius ## Tool-call format -A function call is an **assistant** message on the **commentary** channel, addressed to the tool via recipient `to=functions.`, with content-type `<|constrain|>json` and the JSON arguments as the body, terminated by the `<|call|>` stop token. +A function call is an **assistant** message on the **commentary** channel, addressed to the tool via recipient `to=functions.`, with the JSON arguments as the body, terminated by the `<|call|>` stop token. -The recipient may appear in the *role section* or the *channel section* of the header — both are valid Harmony and the parser accepts either. The model commonly emits it in the channel section: +The recipient may appear in the *role section* or the *channel section* of the header — both are valid Harmony and the parser accepts either. The model commonly emits it in the channel section. The pi renderer omits the optional content-type marker: ```text -<|start|>assistant<|channel|>commentary to=functions.get_current_weather <|constrain|>json<|message|>{"location":"San Francisco, CA"}<|call|> +<|start|>assistant<|channel|>commentary to=functions.get_current_weather<|message|>{"location":"San Francisco, CA"}<|call|> ``` -The `openai-harmony` renderer, when re-serializing a stored call, places the recipient in the role section instead (note the `<|constrain|>` is preceded by a space in both forms): +Some Harmony serializers include an explicit JSON content type and place the recipient in the role section instead: ```text <|start|>assistant to=functions.get_current_weather<|channel|>commentary <|constrain|>json<|message|>{"location":"San Francisco, CA"}<|call|> ``` -The arguments body is a raw JSON object. The `<|constrain|>json` content-type signals JSON (and is the hook for constrained/grammar-based decoding); the `<|constrain|>` token is optional, and the content-type may also be a bare word such as `code` (seen with built-in tools). Built-in tools differ only in channel and recipient: they typically render on `analysis`, with recipient `browser.search` / `browser.open` / `browser.find` or always `python`. +The arguments body is a raw JSON object. The optional `<|constrain|>json` content-type signals JSON (and is the hook for constrained/grammar-based decoding); the content-type may also be a bare word such as `code` (seen with built-in tools). Built-in tools differ only in channel and recipient: they typically render on `analysis`, with recipient `browser.search` / `browser.open` / `browser.find` or always `python`. ## Multiple / parallel tool calls @@ -136,7 +136,7 @@ Harmony has no special "parallel" wrapper. Multiple calls are just multiple cons 2. Generate a JavaScript for the Node.js server 3. Start the server --- -Will start executing the plan step by step<|end|><|start|>assistant<|channel|>commentary to=functions.generate_file<|constrain|>json<|message|>{"template": "basic_html", "path": "index.html"}<|call|> +Will start executing the plan step by step<|end|><|start|>assistant<|channel|>commentary to=functions.generate_file<|message|>{"template": "basic_html", "path": "index.html"}<|call|> ``` ## Tool-result format @@ -178,7 +178,7 @@ location: string, format?: "celsius" | "fahrenheit", // default: celsius }) => any; -} // namespace functions<|end|><|start|>user<|message|>What is the weather like in SF?<|end|><|start|>assistant<|channel|>analysis<|message|>User wants the weather in San Francisco. Use get_current_weather.<|end|><|start|>assistant<|channel|>commentary to=functions.get_current_weather <|constrain|>json<|message|>{"location":"San Francisco, CA"}<|call|><|start|>functions.get_current_weather to=assistant<|channel|>commentary<|message|>{"sunny": true, "temperature": 20}<|end|><|start|>assistant<|channel|>final<|message|>It's sunny and about 20°C in San Francisco right now.<|return|> +} // namespace functions<|end|><|start|>user<|message|>What is the weather like in SF?<|end|><|start|>assistant<|channel|>analysis<|message|>User wants the weather in San Francisco. Use get_current_weather.<|end|><|start|>assistant<|channel|>commentary to=functions.get_current_weather<|message|>{"location":"San Francisco, CA"}<|call|><|start|>functions.get_current_weather to=assistant<|channel|>commentary<|message|>{"sunny": true, "temperature": 20}<|end|><|start|>assistant<|channel|>final<|message|>It's sunny and about 20°C in San Francisco right now.<|return|> ``` Turn boundaries: @@ -203,12 +203,12 @@ When a server (vLLM/SGLang/Ollama) bridges Harmony to Chat Completions JSON: ## Parsing notes & gotchas - **Two stop tokens.** Always stop on both `<|return|>` and `<|call|>`. Stopping only on `<|return|>` will run past tool calls; stopping only on `<|end|>` is wrong for assistant generation. -- **Recipient position varies.** `to=functions.` may be in the role section (`<|start|>assistant to=...<|channel|>commentary`) or the channel section (`<|channel|>commentary to=... `). A parser must accept both. A space precedes `<|constrain|>` in both renderings. +- **Recipient position varies.** `to=functions.` may be in the role section (`<|start|>assistant to=...<|channel|>commentary`) or the channel section (`<|channel|>commentary to=...`). A parser must accept both. - **Channel is mandatory** on assistant messages; the system message even reminds the model ("Channel must be included for every message."). Missing-channel output is malformed. - **Tool author, not `tool`.** The tool-result message's role is the tool's *name* (`functions.get_current_weather`), not the literal string `tool`. Splitting `functions.x` into namespace + function is the parser's job. - **CoT dropping is conditional.** Drop `analysis` only when the previous assistant turn ended on `final`. Dropping the `analysis` that immediately precedes a `<|call|>` breaks multi-step tool reasoning. - **`arguments` is a string.** Do not double-encode. The body after `<|message|>` is already serialized JSON; pass it through as the `arguments` string. -- **Content-type variants.** `<|constrain|>json` is typical, but the content-type can be a bare token (`json`, `code`); treat `<|constrain|>` as optional metadata, not a guarantee of valid JSON. Enforce JSON validity with constrained decoding / your own grammar — the prompt format alone does not guarantee schema adherence (same caveat applies to structured-output `# Response Formats`). +- **Content-type variants.** `<|constrain|>json` is optional. If present, it is metadata, not a guarantee of valid JSON. Enforce JSON validity with constrained decoding / your own grammar — the prompt format alone does not guarantee schema adherence (same caveat applies to structured-output `# Response Formats`). - **Streaming.** Use a stateful parser (the library ships `StreamableParser`) so partial UTF-8 and the header/channel/recipient/content-type fields are reconstructed incrementally; a naive substring scan mishandles multi-byte splits and the optional header fields. `parse_messages_from_completion_tokens` takes `strict=True|False` — `strict=False` tolerates some malformed headers. Do not pass the trailing stop token into the parser. - **Encoding.** Use `o200k_harmony` (the `o200k_base` ranks plus the Harmony specials above). Treat the `<|...|>` tokens as atomic special tokens during both encode and decode; encoding them as ordinary text yields different ranks and corrupts the stream. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index ab94f5087..36351e631 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Added +- Added `jsonSchemaToTypeScript` to `@oh-my-pi/pi-ai/utils/schema` to render JSON Schema argument shapes as compact, human-readable TypeScript-style signatures - Added the generic `ToolExample` type (`ToolCallExample`/`ToolCompareExample`/`ToolNoteExample`, parameterized over a tool's argument shape) and an `examples` property on the `Tool` interface for defining tool-call examples once as data. - Added `renderToolExamples` (via `@oh-my-pi/pi-ai/grammar`) to render a tool's examples into an `` block in the model's native tool-call syntax, with an optional `_i` intent-field placeholder injected when intent tracing is active. - Added per-grammar `renderToolCall` rendering of a single tool-call invocation (the inner element only, without the parallel-call block envelope), distinct from `renderAssistantToolCalls` which renders a complete block of one or more parallel calls. @@ -14,9 +14,12 @@ ### Changed +- Changed Harmony in-band tool-call rendering to omit the `<|constrain|>json` marker before the payload in `commentary` channel calls +- Changed tool inventory rendering to present each tool’s `Parameters` section as a simplified TypeScript-style signature derived from its wire schema - 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. +- Changed `renderToolInventory` (the verbose system-prompt inventory and `/dump`) to render each tool as a `# Tool: ` markdown section instead of a `…` wrapper. ### Fixed @@ -3678,4 +3681,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. +Initial release with multi-provider LLM support. \ No newline at end of file diff --git a/packages/ai/src/grammar/harmony.md b/packages/ai/src/grammar/harmony.md index 4e7057aa8..221ad731a 100644 --- a/packages/ai/src/grammar/harmony.md +++ b/packages/ai/src/grammar/harmony.md @@ -3,7 +3,7 @@ Each function call is one assistant message on the `commentary` channel addressed to the function, emitted as text: ```text -<|start|>assistant<|channel|>commentary to=functions.function_name <|constrain|>json<|message|>{"arg":"value"}<|call|> +<|start|>assistant<|channel|>commentary to=functions.function_name<|message|>{"arg":"value"}<|call|> ``` Put private reasoning in an `analysis` message: diff --git a/packages/ai/src/grammar/index.ts b/packages/ai/src/grammar/index.ts index 66eb97281..7c13b5800 100644 --- a/packages/ai/src/grammar/index.ts +++ b/packages/ai/src/grammar/index.ts @@ -3,5 +3,6 @@ export * from "./coercion"; export * from "./examples"; export * from "./factory"; export * from "./history"; +export * from "./inventory"; export * from "./owned-stream"; export * from "./types"; diff --git a/packages/ai/src/grammar/inventory.ts b/packages/ai/src/grammar/inventory.ts new file mode 100644 index 000000000..f8100ecce --- /dev/null +++ b/packages/ai/src/grammar/inventory.ts @@ -0,0 +1,28 @@ +import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity"; +import { jsonSchemaToTypeScript, toolWireSchema } from "../utils/schema"; +import { renderToolExamples } from "./examples"; +import type { InbandTool } from "./types"; + +/** + * Human-readable per-tool inventory: each tool renders as a `# Tool: ` + * section with its description, a simplified TypeScript-style parameter + * signature (derived from the wire JSON Schema), and examples in the model's + * native tool-call syntax. Shared by the verbose system-prompt inventory and + * `/dump` so both render the catalog the same way. + * + * `model` is a model id; the native example syntax is resolved from it + * (`preferredToolSyntax`, which falls back to XML for empty/unknown ids). + */ +export function renderToolInventory(tools: readonly InbandTool[], model: string): string { + if (tools.length === 0) return ""; + const syntax = preferredToolSyntax(model); + return tools + .map(tool => { + const params = jsonSchemaToTypeScript(toolWireSchema(tool)); + const examples = renderToolExamples(tool, syntax); + const parts = [`# Tool: ${tool.name}`, tool.description ?? "", "", `Parameters: ${params}`]; + if (examples) parts.push("", examples); + return parts.join("\n"); + }) + .join("\n\n"); +} diff --git a/packages/ai/src/grammar/rendering.ts b/packages/ai/src/grammar/rendering.ts index 88ceee45a..093bc9f42 100644 --- a/packages/ai/src/grammar/rendering.ts +++ b/packages/ai/src/grammar/rendering.ts @@ -85,7 +85,7 @@ export function renderDeepSeekToolResults(results: readonly GrammarToolResult[]) } export function renderHarmonyInvocation(call: ToolCall): string { - return `<|start|>assistant<|channel|>commentary to=${harmonyRecipient(call.name)} <|constrain|>json<|message|>${stringifyJson(call.arguments)}<|call|>`; + return `<|start|>assistant<|channel|>commentary to=${harmonyRecipient(call.name)}<|message|>${stringifyJson(call.arguments)}<|call|>`; } export function renderHarmonyToolCalls(calls: readonly ToolCall[]): string { diff --git a/packages/ai/src/utils/schema/index.ts b/packages/ai/src/utils/schema/index.ts index 971e88208..de9e9f0dc 100644 --- a/packages/ai/src/utils/schema/index.ts +++ b/packages/ai/src/utils/schema/index.ts @@ -9,5 +9,6 @@ export * from "./meta-validator"; export * from "./normalize"; export * from "./spill"; export * from "./types"; +export * from "./typescript"; export * from "./wire"; export * from "./zod-decontaminate"; diff --git a/packages/ai/src/utils/schema/typescript.ts b/packages/ai/src/utils/schema/typescript.ts new file mode 100644 index 000000000..085ee8a4b --- /dev/null +++ b/packages/ai/src/utils/schema/typescript.ts @@ -0,0 +1,198 @@ +/** + * Render a JSON Schema as a simplified, human-readable TypeScript type. + * + * This is a *display* conversion, not a faithful TS codegen: it surfaces the + * shape (objects, arrays, unions, enums, records) and property descriptions so + * a model — or a human reading `/dump` — can grasp a tool's parameters at a + * glance, far more legibly than raw JSON Schema. Refinement keywords + * (min/max/pattern/format) are intentionally dropped; only type structure, + * literal enums/consts, and descriptions survive. + */ + +import { isJsonObject } from "./types"; + +export interface JsonSchemaToTsOptions { + /** Indentation unit for nested object bodies. Default two spaces. */ + readonly indent?: string; + /** Emit `description` keywords as JSDoc comments on object properties. Default true. */ + readonly comments?: boolean; +} + +interface Ctx { + readonly indent: string; + readonly comments: boolean; + readonly defs: Record | undefined; + readonly seen: Set; +} + +const SAFE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; +const LOCAL_REF = /^#\/(?:\$defs|definitions)\/(.+)$/; +/** Inline an array item as `T[]` only while it stays a short single token. */ +const INLINE_ARRAY_LIMIT = 40; + +function literal(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value); + return JSON.stringify(value) ?? "unknown"; +} + +/** Join member types into a TS union, deduping structurally identical renders. */ +function joinUnion(parts: readonly string[]): string { + const seen = new Set(); + const unique: string[] = []; + for (const part of parts) { + if (seen.has(part)) continue; + seen.add(part); + unique.push(part); + } + return unique.length > 0 ? unique.join(" | ") : "never"; +} + +function emitJsDoc(lines: string[], description: string, pad: string): void { + // `* /` keeps a stray closing token inside the description from ending the comment. + const safe = description.replace(/\*\//g, "* /"); + if (!safe.includes("\n")) { + lines.push(`${pad}/** ${safe} */`); + return; + } + lines.push(`${pad}/**`); + for (const line of safe.split("\n")) lines.push(`${pad} * ${line}`); + lines.push(`${pad} */`); +} + +function convertArray(node: Record, ctx: Ctx, pad: string): string { + const prefixItems = node.prefixItems; + if (Array.isArray(prefixItems)) { + return `[${prefixItems.map(item => convert(item, ctx, pad)).join(", ")}]`; + } + const items = node.items; + if (items === undefined || items === true) return "unknown[]"; + if (items === false) return "never[]"; + const inner = convert(items, ctx, pad); + if (inner.includes("\n") || inner.includes(" | ") || inner.length > INLINE_ARRAY_LIMIT) { + return `Array<${inner}>`; + } + return `${inner}[]`; +} + +function convertObject(node: Record, ctx: Ctx, pad: string): string { + const properties = isJsonObject(node.properties) ? node.properties : undefined; + const additional = node.additionalProperties; + const childPad = pad + ctx.indent; + + const body: string[] = []; + if (properties) { + const required = new Set( + Array.isArray(node.required) ? node.required.filter((key): key is string => typeof key === "string") : [], + ); + for (const key in properties) { + const value = properties[key]; + if ( + ctx.comments && + isJsonObject(value) && + typeof value.description === "string" && + value.description.length > 0 + ) { + emitJsDoc(body, value.description, childPad); + } + const optional = required.has(key) ? "" : "?"; + const name = SAFE_KEY.test(key) ? key : JSON.stringify(key); + body.push(`${childPad}${name}${optional}: ${convert(value, ctx, childPad)};`); + } + } + + // No named properties: pure record / open / empty object. + if (body.length === 0) { + if (isJsonObject(additional)) return `Record`; + if (additional === true) return "Record"; + return "{}"; + } + + // Named properties alongside a free-form value schema → index signature. + if (isJsonObject(additional)) { + body.push(`${childPad}[key: string]: ${convert(additional, ctx, childPad)};`); + } + return `{\n${body.join("\n")}\n${pad}}`; +} + +function convertType(type: string, node: Record, ctx: Ctx, pad: string): string { + switch (type) { + case "string": + return "string"; + case "integer": + case "number": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + case "array": + return convertArray(node, ctx, pad); + case "object": + return convertObject(node, ctx, pad); + default: + return "unknown"; + } +} + +function convert(node: unknown, ctx: Ctx, pad: string): string { + if (node === true) return "unknown"; + if (node === false) return "never"; + if (!isJsonObject(node)) return "unknown"; + + const ref = node.$ref; + if (typeof ref === "string") { + const match = LOCAL_REF.exec(ref); + const resolved = match && ctx.defs ? ctx.defs[match[1]] : undefined; + if (isJsonObject(resolved) && !ctx.seen.has(resolved)) { + ctx.seen.add(resolved); + const out = convert(resolved, ctx, pad); + ctx.seen.delete(resolved); + return out; + } + return ref.slice(ref.lastIndexOf("/") + 1); + } + + if ("const" in node) return literal(node.const); + + if (Array.isArray(node.enum)) { + return node.enum.length > 0 ? joinUnion(node.enum.map(literal)) : "never"; + } + + const union = Array.isArray(node.anyOf) ? node.anyOf : Array.isArray(node.oneOf) ? node.oneOf : undefined; + if (union) return joinUnion(union.map(variant => convert(variant, ctx, pad))); + + if (Array.isArray(node.allOf)) { + return node.allOf.map(variant => convert(variant, ctx, pad)).join(" & "); + } + + const type = node.type; + if (Array.isArray(type)) { + return joinUnion(type.map(entry => convertType(String(entry), node, ctx, pad))); + } + if (typeof type === "string") return convertType(type, node, ctx, pad); + + return "unknown"; +} + +/** Convert a JSON Schema object into a simplified TypeScript type string. */ +export function jsonSchemaToTypeScript(schema: unknown, options?: JsonSchemaToTsOptions): string { + const root = isJsonObject(schema) ? schema : undefined; + let defs: Record | undefined; + if (root) { + for (const key of ["definitions", "$defs"] as const) { + const value = root[key]; + if (isJsonObject(value)) { + defs ??= {}; + Object.assign(defs, value); + } + } + } + const ctx: Ctx = { + indent: options?.indent ?? " ", + comments: options?.comments ?? true, + defs, + seen: new Set(), + }; + return convert(schema, ctx, ""); +} diff --git a/packages/ai/test/inband-tools.test.ts b/packages/ai/test/inband-tools.test.ts index 6819f5e47..c5c024eae 100644 --- a/packages/ai/test/inband-tools.test.ts +++ b/packages/ai/test/inband-tools.test.ts @@ -143,8 +143,8 @@ describe("in-band tool grammars", () => { ); expectRawBlock( "harmony", - '<|start|>assistant<|channel|>commentary to=functions.read <|constrain|>json<|message|>{"path":"src/a.ts"}<|call|>', - '<|start|>assistant<|channel|>commentary to=functions.read <|constrain|>json<|message|>{"path":"src/a.ts"}<|call|>', + '<|start|>assistant<|channel|>commentary to=functions.read<|message|>{"path":"src/a.ts"}<|call|>', + '<|start|>assistant<|channel|>commentary to=functions.read<|message|>{"path":"src/a.ts"}<|call|>', ); expectRawBlock( "pi", @@ -154,8 +154,7 @@ describe("in-band tool grammars", () => { }); it("projects raw tool blocks onto parsed ToolCall content", () => { - const raw = - '<|start|>assistant<|channel|>commentary to=functions.read <|constrain|>json<|message|>{"path":"src/a.ts"}<|call|>'; + const raw = '<|start|>assistant<|channel|>commentary to=functions.read<|message|>{"path":"src/a.ts"}<|call|>'; const parsed = parseInbandToolMessage(assistant([{ type: "text", text: raw }]), "harmony", TOOLS); const call = parsed.content.find((block): block is ToolCall => block.type === "toolCall"); diff --git a/packages/ai/test/json-schema-typescript.test.ts b/packages/ai/test/json-schema-typescript.test.ts new file mode 100644 index 000000000..1962091c5 --- /dev/null +++ b/packages/ai/test/json-schema-typescript.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import { jsonSchemaToTypeScript, toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import { z } from "zod/v4"; + +describe("jsonSchemaToTypeScript", () => { + it("renders objects with optional markers and JSDoc descriptions", () => { + const ts = jsonSchemaToTypeScript({ + type: "object", + properties: { + query: { type: "string", description: "search query" }, + limit: { type: "number", description: "max results" }, + }, + required: ["query"], + }); + expect(ts).toContain("/** search query */"); + expect(ts).toContain("query: string;"); + expect(ts).toContain("/** max results */"); + expect(ts).toContain("limit?: number;"); + }); + + it("renders enums and consts as literal unions", () => { + const ts = jsonSchemaToTypeScript({ + type: "object", + properties: { + recency: { type: "string", enum: ["day", "week", "month"] }, + kind: { const: "fixed" }, + }, + required: ["recency", "kind"], + }); + expect(ts).toContain('recency: "day" | "week" | "month";'); + expect(ts).toContain('kind: "fixed";'); + }); + + it("renders arrays, tuples, and records", () => { + const ts = jsonSchemaToTypeScript({ + type: "object", + properties: { + tags: { type: "array", items: { type: "string" } }, + pair: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }, + meta: { type: "object", additionalProperties: { type: "number" } }, + rows: { + type: "array", + items: { type: "object", properties: { id: { type: "string" } }, required: ["id"] }, + }, + }, + required: ["tags", "pair", "meta", "rows"], + }); + expect(ts).toContain("tags: string[];"); + expect(ts).toContain("pair: [string, number];"); + expect(ts).toContain("meta: Record;"); + // Object-valued array elements expand to Array<{ … }> rather than inline `[]`. + expect(ts).toContain("rows: Array<{"); + expect(ts).toContain("id: string;"); + }); + + it("renders nullable unions from both type-arrays and anyOf", () => { + const ts = jsonSchemaToTypeScript({ + type: "object", + properties: { + a: { type: ["string", "null"] }, + b: { anyOf: [{ type: "number" }, { type: "null" }] }, + }, + required: ["a", "b"], + }); + expect(ts).toContain("a: string | null;"); + expect(ts).toContain("b: number | null;"); + }); + + it("resolves a local $ref against $defs", () => { + const ts = jsonSchemaToTypeScript({ + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + required: ["node"], + $defs: { Node: { type: "object", properties: { value: { type: "number" } }, required: ["value"] } }, + }); + expect(ts).toContain("node: {"); + expect(ts).toContain("value: number;"); + }); + + it("renders an empty object schema as {}", () => { + expect(jsonSchemaToTypeScript({ type: "object", properties: {}, additionalProperties: false })).toBe("{}"); + }); + + it("converts a Zod schema through the wire pipeline", () => { + const parameters = z.object({ + name: z.string().describe("the name"), + count: z.number().int().optional(), + }); + const ts = jsonSchemaToTypeScript(toolWireSchema({ name: "t", description: "", parameters })); + expect(ts).toContain("/** the name */"); + expect(ts).toContain("name: string;"); + expect(ts).toContain("count?: number;"); + }); +}); diff --git a/packages/ai/test/tool-inventory.test.ts b/packages/ai/test/tool-inventory.test.ts new file mode 100644 index 000000000..6500d459a --- /dev/null +++ b/packages/ai/test/tool-inventory.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "bun:test"; +import { z } from "zod/v4"; +import { renderToolInventory } from "../src/grammar/inventory"; +import type { InbandTool } from "../src/grammar/types"; + +const searchTool: InbandTool = { + name: "web_search", + description: "Searches the web.", + parameters: z.object({ + query: z.string().describe("search query"), + recency: z.enum(["day", "week"]).optional(), + }), + examples: [{ caption: "Basic", call: { query: "rust" } }], +}; + +describe("renderToolInventory", () => { + it("renders a tool block with a TypeScript signature and native-syntax examples", () => { + const out = renderToolInventory([searchTool], "claude-3-5-sonnet-20241022"); + expect(out).toContain("# Tool: web_search"); + expect(out).toContain("Searches the web."); + expect(out).toContain("Parameters: {"); + expect(out).toContain("query: string;"); + expect(out).toContain('recency?: "day" | "week";'); + expect(out).toContain(""); + // Examples render in the model's native (anthropic) tool-call syntax. + expect(out).toContain(''); + }); + + it("omits the examples block when a tool has none", () => { + const tool: InbandTool = { + name: "noop", + description: "No examples.", + parameters: z.object({ x: z.string() }), + }; + const out = renderToolInventory([tool], "claude-3-5-sonnet-20241022"); + expect(out).toContain("Parameters: {"); + expect(out).not.toContain(""); + }); + + it("returns an empty string when there are no tools", () => { + expect(renderToolInventory([], "claude-3-5-sonnet-20241022")).toBe(""); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 855cc09e7..932948f3f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,9 @@ # Changelog ## [Unreleased] - ### Added +- Added tool examples data to exported RPC session tool metadata, so tool dumps and other clients can receive model-call examples - Added `supportsTools` to model definitions and overrides so custom model configs can declare whether a model supports native tool calls - Added `tools.format` for choosing native tool calling or a specific owned in-band format (`glm`, `hermes`, `kimi`, `xml`), with `auto` falling back to GLM only for models marked as not supporting native tools. - Added a conditional easter-egg tip recommending nerd fonts when using the unicode symbol preset. @@ -11,11 +11,13 @@ ### Changed +- Changed `/dump` tool catalog output to render tools through the shared inventory renderer with readable TypeScript-style signatures and native-syntax `` blocks - Changed todo tool result rendering so that collapsed phases truncate from the beginning, showing the latest/active tasks and displaying the "more todos" summary at the top. - Expanded `tools.format` to support additional in-band tool-call syntaxes, including `anthropic`, `deepseek`, `harmony`, `pi`, and `qwen3` - Changed the 13 tools that documented hand-written `` blocks (`eval`, `browser`, `todo`, `irc`, `ssh`, `ast_edit`, `ast_grep`, `debug`, `find`, `inspect_image`, `ask`, plus the `patch`/`apply_patch` edit modes) to define examples as typed `examples` data on the tool (`ToolExample>`); the AI layer now renders them in the model's native tool-call syntax and the markdown `` blocks were removed. - Changed the experimental owned tool-calling prompt from a GLM-only toggle to syntax-specific grammar prompts and result formats. `PI_OWNED_TOOLS=1` still forces GLM; `PI_OWNED_TOOLS=` forces that syntax. - Changed the large-paste menu to offer attachment XML blocks (``), local-file attachments, or inline paste as explicit actions. +- Changed the system-prompt tool inventory: moved the `# Inventory` block to the bottom of the TOOLS section, and it now renders a compact tool-name list only when native tool calling is active and tool descriptions are not repeated; otherwise it emits full `# Tool: ` sections. ### Fixed @@ -11714,4 +11716,4 @@ Initial public release. ## [0.7.6] - 2025-11-13 -Previous releases did not maintain a changelog. +Previous releases did not maintain a changelog. \ No newline at end of file diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index ca1885c3a..20aae6d0e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -796,6 +796,7 @@ export async function runRpcMode( name: tool.name, description: tool.description, parameters: isZodSchema(tool.parameters) ? zodToWireSchema(tool.parameters) : tool.parameters, + examples: tool.examples, })), contextUsage: session.getContextUsage(), }; diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 51ea03252..e5906d787 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage, AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction"; -import type { Effort, ImageContent, Model } from "@oh-my-pi/pi-ai"; +import type { Effort, ImageContent, Model, ToolExample } from "@oh-my-pi/pi-ai"; import type { BashResult } from "../../exec/bash-executor"; import type { ContextUsage } from "../../extensibility/extensions/types"; import type { AgentSessionEvent, SessionStats } from "../../session/agent-session"; @@ -107,7 +107,7 @@ export interface RpcSessionState { todoPhases: TodoPhase[]; /** For session dump / export (plain-text parity with /dump). */ systemPrompt?: string[]; - dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; + dumpTools?: Array<{ name: string; description: string; parameters: unknown; examples?: readonly ToolExample[] }>; /** Current context window usage. Null tokens/percent when unknown (e.g. right after compaction). */ contextUsage?: ContextUsage; } diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 6415d647c..76a1f7942 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -24,27 +24,6 @@ Use tools whenever they materially improve correctness, completeness, or groundi - SHOULD parallelize calls when possible. {{#has tools "task"}}- User says `parallel`/`parallelize` → MUST use `{{toolRefs.task}}` subagents; parallel tool calls alone do not satisfy.{{/has}} -{{#if toolInfo.length}} -# Inventory -{{#if mcpDiscoveryMode}} - -{{#if hasMCPDiscoveryServers}}Discoverable MCP servers in this session: {{#list mcpDiscoveryServerSummaries join=", "}}{{this}}{{/list}}.{{/if}} -If the task may involve external systems, SaaS APIs, chat, tickets, databases, deployments, or other non-local integrations, you SHOULD call `{{toolRefs.search_tool_bm25}}` before concluding no such tool exists. - -{{/if}} -{{#if repeatToolDescriptions}} -{{#each toolInfo}} - -{{description}} - -{{/each}} -{{else}} -{{#each toolInfo}} -- {{#if label}}{{label}}: `{{name}}`{{else}}`{{name}}`{{/if}} -{{/each}} -{{/if}} -{{/if}} - # I/O - For tools taking `path` or path-like fields, prefer relative paths. {{#if intentTracing}}- Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period, capitalized.{{/if}} @@ -115,6 +94,23 @@ Delegation is preferred here. Once the design is settled, you SHOULD fan substan {{/has}} {{/if}} +{{#if toolInfo.length}} +# Inventory +{{#if mcpDiscoveryMode}} + +{{#if hasMCPDiscoveryServers}}Discoverable MCP servers in this session: {{#list mcpDiscoveryServerSummaries join=", "}}{{this}}{{/list}}.{{/if}} +If the task may involve external systems, SaaS APIs, chat, tickets, databases, deployments, or other non-local integrations, you SHOULD call `{{toolRefs.search_tool_bm25}}` before concluding no such tool exists. + +{{/if}} +{{#if toolListMode}} +{{#each toolInfo}} +- {{#if label}}{{label}}: `{{name}}`{{else}}`{{name}}`{{/if}} +{{/each}} +{{else}} +{{toolInventory}} +{{/if}} +{{/if}} + ENV =================================== diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index d2577f1f0..cc08d02d8 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2161,6 +2161,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } appendPrompt = parts.join("\n\n"); } + // Owned/in-band tool syntax (non-native) repeats the catalog as `# Tool:` + // sections; native tool calling lets the compact name list suffice. + const nativeTools = + resolveToolCallSyntax(settings.get("tools.format"), agent?.state.model ?? model) === undefined; const defaultPrompt = await buildSystemPromptInternal({ cwd, skills, @@ -2172,6 +2176,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} skillsSettings: settings.getGroup("skills"), appendSystemPrompt: appendPrompt, repeatToolDescriptions, + nativeTools, intentField, mcpDiscoveryMode: hasDiscoverableTools, mcpDiscoveryServerSummaries: discoverableToolSummary.servers.map(formatDiscoverableToolServerSummary), diff --git a/packages/coding-agent/src/session/session-dump-format.ts b/packages/coding-agent/src/session/session-dump-format.ts index bd018fdb7..a44f70cd7 100644 --- a/packages/coding-agent/src/session/session-dump-format.ts +++ b/packages/coding-agent/src/session/session-dump-format.ts @@ -3,8 +3,8 @@ */ import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai"; -import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import type { AssistantMessage, Model, ToolExample, TSchema } from "@oh-my-pi/pi-ai"; +import { renderToolInventory } from "@oh-my-pi/pi-ai/grammar"; import { getVisibleThinkingText } from "../utils/thinking-display"; import { type BashExecutionMessage, @@ -23,6 +23,7 @@ export interface SessionDumpToolInfo { name: string; description: string; parameters: unknown; + examples?: readonly ToolExample[]; } export interface FormatSessionDumpTextOptions { @@ -33,28 +34,6 @@ export interface FormatSessionDumpTextOptions { tools?: readonly SessionDumpToolInfo[]; } -function stripTypeBoxFields(obj: unknown): unknown { - if (Array.isArray(obj)) { - return obj.map(stripTypeBoxFields); - } - if (obj && typeof obj === "object") { - const result: Record = {}; - for (const [k, v] of Object.entries(obj)) { - if (!k.startsWith("TypeBox.")) { - result[k] = stripTypeBoxFields(v); - } - } - return result; - } - return obj; -} - -/** Resolve tool parameters to a plain JSON Schema object for dump output. */ -function toolParametersToJsonSchema(parameters: unknown): unknown { - if (isZodSchema(parameters)) return zodToWireSchema(parameters); - return stripTypeBoxFields(parameters); -} - /** Serialize an object as XML parameter elements, one per key. */ function formatArgsAsXml(args: Record, indent = "\t"): string { const parts: string[] = []; @@ -94,13 +73,13 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st const tools = options.tools ?? []; if (tools.length > 0) { lines.push("## Available Tools\n"); - for (const tool of tools) { - lines.push(``); - lines.push(tool.description); - const parametersClean = toolParametersToJsonSchema(tool.parameters); - lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record)}`); - lines.push("<" + "/tool>\n"); - } + const inventoryTools = tools.map(tool => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters as TSchema, + examples: tool.examples, + })); + lines.push(renderToolInventory(inventoryTools, options.model?.id ?? "")); lines.push("\n"); } diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 4efb8d66a..7161d48b5 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -4,6 +4,8 @@ import * as os from "node:os"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai"; +import { renderToolInventory } from "@oh-my-pi/pi-ai/grammar"; import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { contextFileCapability } from "./capability/context-file"; @@ -330,6 +332,10 @@ export interface SystemPromptToolMetadata { description: string; /** Tool name the model sees on the provider wire. Defaults to the internal tool name. */ wireName?: string; + /** Tool parameters schema (Zod or JSON Schema), fed to the verbose inventory renderer. */ + parameters?: TSchema; + /** Illustrative examples rendered into the verbose inventory. */ + examples?: readonly ToolExample[]; } export function buildSystemPromptToolMetadata( @@ -349,6 +355,8 @@ export function buildSystemPromptToolMetadata( label: override?.label ?? (typeof toolRecord.label === "string" ? toolRecord.label : ""), description: override?.description ?? (typeof toolRecord.description === "string" ? toolRecord.description : ""), + parameters: toolRecord.parameters, + examples: toolRecord.examples, wireName, }, ] as const; @@ -367,6 +375,12 @@ export interface BuildSystemPromptOptions { appendSystemPrompt?: string; /** Repeat full tool descriptions in system prompt. Default: false */ repeatToolDescriptions?: boolean; + /** + * Whether provider-native tool calling is active (no owned/in-band syntax). + * When true and `repeatToolDescriptions` is false, the inventory renders as a + * compact tool-name list; otherwise it renders full `# Tool:` sections. Default: true + */ + nativeTools?: boolean; /** Skills settings for discovery. */ skillsSettings?: SkillsSettings; /** Working directory. Default: getProjectDir() */ @@ -420,6 +434,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): tools, appendSystemPrompt, repeatToolDescriptions = false, + nativeTools = true, skillsSettings, toolNames: providedToolNames, cwd, @@ -575,6 +590,20 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): label: tools?.get(name)?.label ?? "", description: tools?.get(name)?.description ?? "", })); + const inventoryTools = toolNames.map(name => { + const meta = tools?.get(name); + return { + name: toolPromptNames.get(name) ?? name, + description: meta?.description ?? "", + parameters: meta?.parameters ?? ({ type: "object" } as TSchema), + examples: meta?.examples, + }; + }); + // List mode shows a compact tool-name list; it only applies when descriptions + // are not repeated AND native tool calling is active (the model already has the + // schemas). Otherwise render full `# Tool:` sections. + const toolListMode = !repeatToolDescriptions && nativeTools; + const toolInventory = toolListMode ? "" : renderToolInventory(inventoryTools, model ?? ""); // Filter skills for the rendered system prompt: // - require the `read` tool so the model can actually fetch skill content; @@ -596,7 +625,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): appendPrompt: resolvedAppendPrompt ?? "", tools: toolNames, toolInfo, + toolInventory, repeatToolDescriptions, + toolListMode, toolRefs, environment, contextFiles, diff --git a/packages/coding-agent/test/session/session-dump-format.test.ts b/packages/coding-agent/test/session/session-dump-format.test.ts index 96e2e6dfc..c2a269d17 100644 --- a/packages/coding-agent/test/session/session-dump-format.test.ts +++ b/packages/coding-agent/test/session/session-dump-format.test.ts @@ -1,18 +1,18 @@ /** - * Contract: /dump tool catalog renders parameters as JSON Schema. + * Contract: /dump renders the tool catalog through the shared AI inventory + * renderer — a simplified TypeScript signature (derived from the wire JSON + * Schema) plus each tool's examples in the model's native tool-call syntax. * - * Tools carry live Zod v4 schemas; the dump formatter must convert them to - * the wire JSON Schema (same shape providers receive) instead of enumerating - * the schema instance's internals (`def`, `shape`, stringified methods). - * Legacy plain JSON-Schema parameters still pass through with TypeBox - * bookkeeping fields stripped. + * Tools carry live Zod v4 schemas; the dump must surface a readable signature + * (not the schema instance's internals) and must include examples, which the + * previous ``-per-key JSON Schema dump dropped entirely. */ import { describe, expect, it } from "bun:test"; import { formatSessionDumpText } from "@oh-my-pi/pi-coding-agent/session/session-dump-format"; import { z } from "zod/v4"; describe("formatSessionDumpText tool parameters", () => { - it("renders Zod schemas as wire JSON Schema, not schema internals", () => { + it("renders Zod schemas as a TypeScript signature, not schema internals", () => { const out = formatSessionDumpText({ messages: [], tools: [ @@ -27,16 +27,19 @@ describe("formatSessionDumpText tool parameters", () => { ], }); - expect(out).toContain('object'); - expect(out).toContain('"query":{"type":"string","description":"search query"}'); - expect(out).toContain('["query"]'); - // Zod instance internals must never leak into the dump. - expect(out).not.toContain('name="def"'); - expect(out).not.toContain('name="shape"'); - expect(out).not.toContain(">undefined"); + expect(out).toContain("# Tool: web_search"); + expect(out).toContain("Parameters: {"); + expect(out).toContain("/** search query */"); + expect(out).toContain("query: string;"); + expect(out).toContain('recency?: "day" | "week";'); + // Live Zod instance internals must never leak into the dump. + expect(out).not.toContain("_zod"); + expect(out).not.toContain("ZodObject"); + // Tool params are no longer emitted as XML elements. + expect(out).not.toContain(''); }); - it("passes plain JSON-Schema parameters through, stripping TypeBox fields", () => { + it("passes plain JSON-Schema parameters through to a TypeScript signature", () => { const out = formatSessionDumpText({ messages: [], tools: [ @@ -45,15 +48,33 @@ describe("formatSessionDumpText tool parameters", () => { description: "Legacy tool.", parameters: { type: "object", - properties: { path: { type: "string", "TypeBox.Kind": "String" } }, + properties: { path: { type: "string", description: "a path" } }, required: ["path"], }, }, ], }); - expect(out).toContain('object'); - expect(out).toContain('"path":{"type":"string"}'); - expect(out).not.toContain("TypeBox."); + expect(out).toContain("# Tool: legacy"); + expect(out).toContain("/** a path */"); + expect(out).toContain("path: string;"); + }); + + it("includes tool examples in the model's native syntax", () => { + const out = formatSessionDumpText({ + messages: [], + tools: [ + { + name: "find", + description: "Finds files.", + parameters: z.object({ paths: z.array(z.string()) }), + examples: [{ call: { paths: ["src/**/*.ts"] } }], + }, + ], + }); + + expect(out).toContain("## Available Tools"); + expect(out).toContain(""); + expect(out).toContain(''); }); }); diff --git a/packages/coding-agent/test/system-prompt-inventory.test.ts b/packages/coding-agent/test/system-prompt-inventory.test.ts new file mode 100644 index 000000000..d82e95f53 --- /dev/null +++ b/packages/coding-agent/test/system-prompt-inventory.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { buildSystemPrompt, type SystemPromptToolMetadata } from "@oh-my-pi/pi-coding-agent/system-prompt"; +import { cleanupTempHome } from "./helpers/temp-home-cleanup"; + +const EMPTY_TREE = { + rootPath: "", + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], +}; + +const TOOLS = new Map([ + [ + "read", + { + label: "Read", + description: "Reads files from disk.", + parameters: { type: "object", properties: { path: { type: "string" } } }, + }, + ], + [ + "bash", + { + label: "Bash", + description: "Executes a shell command.", + parameters: { type: "object", properties: { command: { type: "string" } } }, + }, + ], +]); + +describe("system prompt tool inventory", () => { + let tempDir = ""; + let tempHomeDir = ""; + let originalHome: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-inv-")); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-inv-home-")); + originalHome = process.env.HOME; + process.env.HOME = tempHomeDir; + }); + + afterEach(cleanupTempHome(() => ({ tempDir, tempHomeDir, originalHome }))); + + async function render(opts: { nativeTools: boolean; repeatToolDescriptions: boolean }): Promise { + const { systemPrompt } = await buildSystemPrompt({ + cwd: tempDir, + contextFiles: [], + skills: [], + rules: [], + toolNames: ["read", "bash"], + tools: TOOLS, + workspaceTree: { ...EMPTY_TREE, rootPath: tempDir }, + ...opts, + }); + return systemPrompt.join("\n\n"); + } + + it("renders a compact name list only when native tools are active and descriptions are not repeated", async () => { + const text = await render({ nativeTools: true, repeatToolDescriptions: false }); + expect(text).toContain("- Read: `read`"); + expect(text).toContain("- Bash: `bash`"); + // No full per-tool sections in list mode. + expect(text).not.toContain("# Tool: read"); + expect(text).not.toContain("Reads files from disk."); + }); + + it("renders `# Tool:` sections (not a name list) when tools are not native", async () => { + const text = await render({ nativeTools: false, repeatToolDescriptions: false }); + expect(text).toContain("# Tool: read"); + expect(text).toContain("# Tool: bash"); + expect(text).toContain("Reads files from disk."); + expect(text).not.toContain("- Read: `read`"); + // The legacy `` wrapper is gone. + expect(text).not.toContain(" { + const text = await render({ nativeTools: true, repeatToolDescriptions: true }); + expect(text).toContain("# Tool: read"); + expect(text).toContain("Executes a shell command."); + expect(text).not.toContain("- Read: `read`"); + }); + + it("places the inventory at the bottom of the TOOLS section (after I/O and Exploration)", async () => { + const text = await render({ nativeTools: true, repeatToolDescriptions: false }); + const inventoryIdx = text.indexOf("# Inventory"); + const ioIdx = text.indexOf("# I/O"); + const explorationIdx = text.indexOf("# Exploration"); + expect(inventoryIdx).toBeGreaterThan(-1); + expect(ioIdx).toBeGreaterThan(-1); + expect(explorationIdx).toBeGreaterThan(-1); + expect(inventoryIdx).toBeGreaterThan(ioIdx); + expect(inventoryIdx).toBeGreaterThan(explorationIdx); + }); +}); diff --git a/packages/typescript-edit-benchmark/src/in-process-client.ts b/packages/typescript-edit-benchmark/src/in-process-client.ts index 9ae3024e7..39995efbf 100644 --- a/packages/typescript-edit-benchmark/src/in-process-client.ts +++ b/packages/typescript-edit-benchmark/src/in-process-client.ts @@ -6,7 +6,7 @@ * in-process and sharing auth/model infrastructure across tasks. */ import type { AgentEvent, AgentMessage, ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { Model } from "@oh-my-pi/pi-ai"; +import type { Model, ToolExample } from "@oh-my-pi/pi-ai"; import type { AgentSession, AgentSessionEvent, AuthStorage, SessionStats } from "@oh-my-pi/pi-coding-agent"; import { type CreateAgentSessionResult, @@ -169,7 +169,7 @@ export class InProcessClient { systemPrompt?: string[]; model?: Model; thinkingLevel?: ThinkingLevel | undefined; - dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; + dumpTools?: Array<{ name: string; description: string; parameters: unknown; examples?: readonly ToolExample[] }>; }> { const session = this.#session!; return { @@ -181,6 +181,7 @@ export class InProcessClient { name: tool.name, description: tool.description, parameters: tool.parameters, + examples: tool.examples, })), }; } diff --git a/packages/typescript-edit-benchmark/src/runner.ts b/packages/typescript-edit-benchmark/src/runner.ts index 9b20d7372..c11e5431a 100644 --- a/packages/typescript-edit-benchmark/src/runner.ts +++ b/packages/typescript-edit-benchmark/src/runner.ts @@ -9,7 +9,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { formatHashlineHeader, InMemorySnapshotStore } from "@oh-my-pi/hashline"; import type { AgentMessage, ResolvedThinkingLevel, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { Model } from "@oh-my-pi/pi-ai"; +import type { Model, ToolExample } from "@oh-my-pi/pi-ai"; import { formatSessionDumpText, RpcClient } from "@oh-my-pi/pi-coding-agent"; import { prompt } from "@oh-my-pi/pi-utils"; import { diffLines } from "diff"; @@ -37,7 +37,7 @@ type ConversationDumpSessionState = { systemPrompt?: string[]; model?: Model; thinkingLevel?: ThinkingLevel | undefined; - dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; + dumpTools?: Array<{ name: string; description: string; parameters: unknown; examples?: readonly ToolExample[] }>; }; /** Common interface for both RPC and in-process clients */ @@ -103,7 +103,7 @@ type ConversationDumpSnapshot = { systemPrompt?: string[]; model?: Model; thinkingLevel?: ThinkingLevel | undefined; - dumpTools?: Array<{ name: string; description: string; parameters: unknown }>; + dumpTools?: Array<{ name: string; description: string; parameters: unknown; examples?: readonly ToolExample[] }>; }; function sanitizeDumpPathSegment(value: string): string {