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.
This commit is contained in:
@@ -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 `<function_calls>`/`<invoke name>`/`<parameter name>` 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
|
||||
</function_calls>
|
||||
```
|
||||
|
||||
`[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. `<function_calls>`, `<invoke name="…">`, `<parameter name="…">`); 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.<name>`, 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.<name>`, 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.<name>` 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.<name>` 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.
|
||||
|
||||
|
||||
@@ -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 `<examples>` 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: <name>` markdown section instead of a `<tool name="…">…</tool>` 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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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: <name>`
|
||||
* section with its description, a simplified TypeScript-style parameter
|
||||
* signature (derived from the wire JSON Schema), and examples in the model's
|
||||
* native tool-call syntax. Shared by the verbose system-prompt inventory and
|
||||
* `/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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown> | undefined;
|
||||
readonly seen: Set<unknown>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<string, unknown>, 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<string, unknown>, 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<string, ${convert(additional, ctx, pad)}>`;
|
||||
if (additional === true) return "Record<string, unknown>";
|
||||
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<string, unknown>, 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<string, unknown> | 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, "");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<string, number>;");
|
||||
// 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;");
|
||||
});
|
||||
});
|
||||
@@ -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>");
|
||||
// Examples render in the model's native (anthropic) tool-call syntax.
|
||||
expect(out).toContain('<invoke name="web_search">');
|
||||
});
|
||||
|
||||
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("<examples>");
|
||||
});
|
||||
|
||||
it("returns an empty string when there are no tools", () => {
|
||||
expect(renderToolInventory([], "claude-3-5-sonnet-20241022")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -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 `<examples>` 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 `<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.
|
||||
- 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: <name>` 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.
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
<discovery-notice>
|
||||
{{#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.
|
||||
</discovery-notice>
|
||||
{{/if}}
|
||||
{{#if repeatToolDescriptions}}
|
||||
{{#each toolInfo}}
|
||||
<tool name={{name}}>
|
||||
{{description}}
|
||||
</tool>
|
||||
{{/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}}
|
||||
<discovery-notice>
|
||||
{{#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.
|
||||
</discovery-notice>
|
||||
{{/if}}
|
||||
{{#if toolListMode}}
|
||||
{{#each toolInfo}}
|
||||
- {{#if label}}{{label}}: `{{name}}`{{else}}`{{name}}`{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{toolInventory}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
ENV
|
||||
===================================
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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<string, unknown>, 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(`<tool name="${tool.name}">`);
|
||||
lines.push(tool.description);
|
||||
const parametersClean = toolParametersToJsonSchema(tool.parameters);
|
||||
lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<parameter>`-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('<parameter name="type">object</parameter>');
|
||||
expect(out).toContain('"query":{"type":"string","description":"search query"}');
|
||||
expect(out).toContain('<parameter name="required">["query"]</parameter>');
|
||||
// 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</parameter>");
|
||||
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 <parameter> elements.
|
||||
expect(out).not.toContain('<parameter name="type">');
|
||||
});
|
||||
|
||||
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('<parameter name="type">object</parameter>');
|
||||
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("<examples>");
|
||||
expect(out).toContain('<invoke name="find">');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, SystemPromptToolMetadata>([
|
||||
[
|
||||
"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<string> {
|
||||
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 `<tool>` wrapper is gone.
|
||||
expect(text).not.toContain("<tool name=");
|
||||
});
|
||||
|
||||
it("renders `# Tool:` sections when descriptions are repeated even with native tools", async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user