refactor: renamed intent field from _i to i

- Renamed the global `INTENT_FIELD` constant from `_i` to `i`.
- Updated documentation strings, type annotations, and test expectations across packages to reflect the new field name.
- Ensured consistent usage of the constant in tool schema construction and intent serialization.
This commit is contained in:
can1357
2026-06-19 16:30:48 +02:00
parent 42bd5e65b1
commit 7d28c60c86
19 changed files with 46 additions and 40 deletions
+1 -1
View File
@@ -591,7 +591,7 @@ export function normalizeTools(
// specs without their descriptions (top-level + nested schema annotations)
// so they are not duplicated on the wire. Strip the STABLE wire schema (the
// memoized `stripSchemaDescriptions` result is reused across requests), then
// re-inject `_i` (without its hint, which `describeIntent: false` omits) so
// re-inject `i` (without its hint, which `describeIntent: false` omits) so
// intent tracing keeps the field while no descriptions ride the wire.
if (pruneDescriptions) {
let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
+1 -1
View File
@@ -32,7 +32,7 @@ export interface StablePrefixSnapshot {
/** Options threaded through `build()` so the snapshot reflects loop-time settings. */
export interface BuildOptions {
/** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
/** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
intentTracing: boolean;
exampleDialect?: Dialect;
/** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
+6 -6
View File
@@ -273,7 +273,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
*
* When set, the loop reads messages from the append-only log (stable
* byte prefix) and caches system prompt + tools. Tools exclude per-turn
* `_i` intent fields.
* `i` intent fields.
*/
appendOnlyContext?: AppendOnlyContextManager;
@@ -584,11 +584,11 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
*/
interruptible?: boolean;
/**
* Controls how the INTENT_FIELD (`_i`) is handled for this tool.
* - `"require"` (default): `_i` is injected and required in the parameter schema.
* - `"optional"`: `_i` is injected as an optional/nullable field.
* - `"omit"`: `_i` is NOT injected. Use for tools where intent is obvious (yield, resolve, todo, …).
* - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
* Controls how the INTENT_FIELD (`i`) is handled for this tool.
* - `"require"` (default): `i` is injected and required in the parameter schema.
* - `"optional"`: `i` is injected as an optional/nullable field.
* - `"omit"`: `i` is NOT injected. Use for tools where intent is obvious (yield, resolve, todo, …).
* - function: `i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
*/
intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
@@ -592,7 +592,7 @@ describe("message sync", () => {
// ---------------------------------------------------------------------------
describe("intent injection through build()", () => {
it("injects required `_i` into tool schemas when intentTracing is true", () => {
it("injects required `i` into tool schemas when intentTracing is true", () => {
const mgr = new AppendOnlyContextManager();
const tool = makeTool("read", "Read", {
type: "object",
@@ -608,20 +608,20 @@ describe("intent injection through build()", () => {
expect(params!.required).toContain(INTENT_FIELD);
});
it("materializes ArkType params and keeps `_i` first in authored order", () => {
it("materializes ArkType params and keeps `i` first in authored order", () => {
const mgr = new AppendOnlyContextManager();
const tool = makeTool("write", "Write", type({ path: "string", content: "string" }));
const ctx = makeContext({ tools: [tool] });
const result = mgr.build(ctx, { intentTracing: true });
const params = result.tools?.[0]?.parameters as { properties?: Record<string, unknown>; required?: string[] };
// `_i` must lead; authored order (path before content) is preserved rather
// `i` must lead; authored order (path before content) is preserved rather
// than ArkType's alphabetized-by-hash order (content, path).
expect(Object.keys(params.properties ?? {})).toEqual([INTENT_FIELD, "path", "content"]);
expect(params.required).toContain(INTENT_FIELD);
});
it("omits `_i` when intentTracing is false", () => {
it("omits `i` when intentTracing is false", () => {
const mgr = new AppendOnlyContextManager();
const tool = makeTool("read", "Read", {
type: "object",
@@ -679,7 +679,7 @@ describe("tool examples injection through build()", () => {
expect(desc).toBe("Find files.");
});
it("injects the `_i` placeholder into examples when intentTracing is on", () => {
it("injects the `i` placeholder into examples when intentTracing is on", () => {
const mgr = new AppendOnlyContextManager();
const tool = makeTool("find", "Find files.", findParams, findExamples);
const ctx = makeContext({ tools: [tool] });
+1 -1
View File
@@ -9,7 +9,7 @@ export function renderToolExamples(tool: InbandTool, dialect: Dialect, intentFie
if (!examples?.length) return "";
const definition = getDialectDefinition(dialect);
const renderCall = (args: Record<string, unknown>): string => {
// When intent tracing injects `_i` into the schema, examples must show a
// When intent tracing injects `i` into the schema, examples must show a
// placeholder so the model learns to emit it. Keep it first, matching the
// schema injection order.
const finalArgs = intentField ? { [intentField]: INTENT_PLACEHOLDER, ...args } : args;
+1 -1
View File
@@ -23,7 +23,7 @@ Call with a verbatim body — everything between `«` and `»` is taken literall
Argument values:
- Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`_i="run the tests"`).
- Strings are written bare and verbatim (`path=src/a.ts`). Quote with `"…"` only when the value contains spaces or starts with `"`, `[`, or `{` (`i="run the tests"`).
- Numbers, booleans, and `null` are JSON literals (`offset=50`, `force=true`).
- Arrays and objects are inline JSON (`paths=["src","test"]`).
- The body fence holds the call's first long/multi-line string parameter; its key is implied, never written.
+2 -2
View File
@@ -656,8 +656,8 @@ export interface Tool<TParameters extends TSchema = TSchema> {
* Illustrative calls/notes; the AI layer renders them into an `<examples>`
* block in the model's native tool-call syntax and appends to the wire
* description. Author `call`/`bad`/`good` as plain argument objects WITHOUT
* `_i` — when intent tracing injects `_i` into the schema, the renderer adds
* a placeholder `_i` automatically. Type each tool's `examples` against its
* `i` — when intent tracing injects `i` into the schema, the renderer adds
* a placeholder `i` automatically. Type each tool's `examples` against its
* own schema (e.g. `readonly ToolExample<typeof schema["type"]>[]`).
*/
examples?: readonly ToolExample[];
@@ -65,8 +65,8 @@ describe("processResponsesStream: parallel function_call items", () => {
const emitted: EmittedEvent[] = [];
const stream = { push: (e: unknown) => emitted.push(e as EmittedEvent), end: () => {} } as never;
const argsA = JSON.stringify({ _i: "Reading test", path: "test.txt" });
const argsB = JSON.stringify({ _i: "Reading test", path: "test.md" });
const argsA = JSON.stringify({ i: "Reading test", path: "test.txt" });
const argsB = JSON.stringify({ i: "Reading test", path: "test.md" });
await processResponsesStream(
makeStream([
@@ -125,8 +125,8 @@ describe("processResponsesStream: parallel function_call items", () => {
expect(blockA?.type).toBe("toolCall");
expect(blockB?.type).toBe("toolCall");
if (blockA?.type !== "toolCall" || blockB?.type !== "toolCall") throw new Error("expected toolCalls");
expect(blockA.arguments).toEqual({ _i: "Reading test", path: "test.txt" });
expect(blockB.arguments).toEqual({ _i: "Reading test", path: "test.md" });
expect(blockA.arguments).toEqual({ i: "Reading test", path: "test.txt" });
expect(blockB.arguments).toEqual({ i: "Reading test", path: "test.md" });
const ends = emitted.filter(e => e.type === "toolcall_end") as Array<{
toolCall: { id: string; arguments: Record<string, unknown> };
@@ -134,8 +134,8 @@ describe("processResponsesStream: parallel function_call items", () => {
}>;
expect(ends).toHaveLength(2);
const byCallId = new Map(ends.map(e => [e.toolCall.id.split("|")[0], e]));
expect(byCallId.get("call_a")?.toolCall.arguments).toEqual({ _i: "Reading test", path: "test.txt" });
expect(byCallId.get("call_b")?.toolCall.arguments).toEqual({ _i: "Reading test", path: "test.md" });
expect(byCallId.get("call_a")?.toolCall.arguments).toEqual({ i: "Reading test", path: "test.txt" });
expect(byCallId.get("call_b")?.toolCall.arguments).toEqual({ i: "Reading test", path: "test.md" });
expect(byCallId.get("call_a")?.contentIndex).toBe(0);
expect(byCallId.get("call_b")?.contentIndex).toBe(1);
@@ -72,7 +72,7 @@ function chunk(model: string, delta: SseChoiceDelta, finish: SseChunk["choices"]
const REPORTED_DSML_LEAK =
"<|DSML|tool_calls>\n" +
' <|DSML|invoke name="bash">\n' +
' <|DSML|parameter name="_i" string="true">Check Fedora 42 available packages</|DSML|parameter>\n' +
' <|DSML|parameter name="i" string="true">Check Fedora 42 available packages</|DSML|parameter>\n' +
' <|DSML|parameter name="command" string="true">docker run --rm --platform linux/arm64 fedora:42 bash -c \'type python3; type git; type sed; type cp; ls /usr/bin/python3 2>/dev/null; rpm -qa | grep -E "^python3|^git-|^sed-|^bash-" | sort\'</|DSML|parameter>\n' +
' <|DSML|parameter name="timeout" string="false">15</|DSML|parameter>\n' +
" </|DSML|invoke>\n" +
@@ -616,7 +616,7 @@ describe("Ollama provider DSML envelope healing", () => {
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0].name).toBe("bash");
expect(toolCalls[0].arguments).toMatchObject({
_i: "Check Fedora 42 available packages",
[INTENT_FIELD]: "Check Fedora 42 available packages",
timeout: 15,
});
expect(String(toolCalls[0].arguments.command)).toContain("docker run");
+1 -1
View File
@@ -186,6 +186,6 @@ describe("renderToolExamples", () => {
examples: [{ caption: "Find files", call: { paths: ["src/**/*.ts"] } }],
};
expect(renderToolExamples(tool, "anthropic")).not.toContain(INTENT_FIELD);
expect(renderToolExamples(tool, "anthropic")).not.toContain(`<parameter name="${INTENT_FIELD}"`);
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ if "__omp_prelude_loaded__" not in globals():
from pathlib import Path
import os, json, math, re
from urllib.parse import unquote
INTENT_FIELD = "_i"
INTENT_FIELD = "i"
# __omp_display is injected by runner.py before the prelude executes; it
# mirrors IPython's display() semantics with the same MIME bundle output.
@@ -186,7 +186,7 @@ export class EventController {
}
#updateWorkingMessageFromIntent(intent: unknown): void {
if (this.ctx.session.isAborting) return;
// Streamed JSON can deliver non-string `_i` (object, number, boolean) before
// Streamed JSON can deliver non-string `i` (object, number, boolean) before
// schema validation; `?.` only guards null/undefined, so guard the type too.
if (typeof intent !== "string") return;
const trimmed = intent.trim();
@@ -71,7 +71,7 @@ describe("Python tool bridge HTTP server", () => {
expect(res.status).toBe(200);
expect(body).toEqual({ ok: true, value: "file body" });
expect(calls).toHaveLength(1);
// `_i` survives the bridge round trip so transcript renderers have a label.
// `i` survives the bridge round trip so transcript renderers have a label.
expect((calls[0]!.args as Record<string, unknown>)[INTENT_FIELD]).toBe("py prelude");
} finally {
unregister();
@@ -170,7 +170,7 @@ describe("formatSessionDumpText markdown-headings transcript", () => {
expect(out).toContain("### Tool Result: read");
expect(out).toContain("### Tool Call: read");
expect(out).toContain("path: src/foo.ts");
// The `_i` intent renders as a `//` comment under the heading, never inside the YAML args.
// The `i` intent renders as a `//` comment under the heading, never inside the YAML args.
expect(out).toContain("// Reading the file");
expect(out).not.toContain(`${INTENT_FIELD}:`);
// Tool calls render as a readable heading + YAML, never the <invoke>/<parameter> XML.
@@ -16,7 +16,7 @@ export interface ToolViewProps {
result?: ToolResultLike;
/** Tool is still executing (live collab view). */
running?: boolean;
/** Model-provided intent (`_i`), shown atop the body. */
/** Model-provided intent (`i`), shown atop the body. */
intent?: string;
/** Streaming partial output tail while running. */
partial?: string;
+1 -1
View File
@@ -49,7 +49,7 @@ export interface ToolRenderHost {
export interface ToolRenderProps {
/** Wire tool name (may be an alias of the registry key, e.g. `grep` → search). */
name: string;
/** Parsed tool-call arguments with the internal `_i` intent already stripped. */
/** Parsed tool-call arguments with the internal `i` intent already stripped. */
args: Record<string, unknown>;
result?: ToolResultLike;
/** Tool is still executing (live collab view). */
+2 -2
View File
@@ -772,8 +772,8 @@ export function serializeConversation(messages: Message[], options?: SerializeOp
if (uselessCallIds.has(block.id)) continue;
flushAssistant();
const args = block.arguments as Record<string, unknown>;
// Prefer the harness-derived intent, else the raw `_i` arg; render it as
// a one-line `//comment` and drop `_i` from the args below.
// Prefer the harness-derived intent, else the raw intent arg; render it as
// a one-line `//comment` and drop it from the args below.
const rawIntent =
typeof block.intent === "string"
? block.intent
+11 -5
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import type { AssistantMessage, Message, Usage } from "@oh-my-pi/pi-ai";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import * as snapcompact from "../src";
// Small frames keep render time negligible. Legacy 5x8 shape: 320px → 64 cols
@@ -567,11 +568,16 @@ describe("serializeConversation", () => {
expect(out).toBe("# User ¶\ndo the thing\n\n# Assistant ¶\ndone");
});
it("merges a tool call with its paired result into one block, _i as a // comment", () => {
it("merges a tool call with its paired result into one block, intent as a // comment", () => {
const out = snapcompact.serializeConversation(
[
createAssistantMessage([
{ type: "toolCall", id: "c1", name: "bash", arguments: { _i: "Running tests", command: "bun test" } },
{
type: "toolCall",
id: "c1",
name: "bash",
arguments: { [INTENT_FIELD]: "Running tests", command: "bun test" },
},
]),
{ ...createToolResultMessage("3 pass"), toolCallId: "c1" } as Message,
],
@@ -580,21 +586,21 @@ describe("serializeConversation", () => {
expect(out).toBe('# Tool call ¶\n//Running tests\nbash(command="bun test")\n<out>\n3 pass\n</out>');
});
it("prefers the harness-derived intent over the raw _i arg and squashes newlines", () => {
it("prefers the harness-derived intent over the raw intent arg and squashes newlines", () => {
const out = snapcompact.serializeConversation([
createAssistantMessage([
{
type: "toolCall",
id: "c1",
name: "bash",
arguments: { _i: "raw arg", command: "ls" },
arguments: { [INTENT_FIELD]: "raw arg", command: "ls" },
intent: "Derived\nintent line",
},
]),
]);
expect(out).toContain("//Derived intent line");
expect(out).not.toContain("raw arg");
expect(out).not.toContain("_i=");
expect(out).not.toContain(`${INTENT_FIELD}=`);
});
it("folds thinking into the assistant block as italics above the text", () => {
+1 -1
View File
@@ -346,7 +346,7 @@ export type WireFrame = GuestFrame | HostFrame;
export const COLLAB_PROTO = 1;
/** Parameter key used for intent tracing (e.g. prompt explanation/reasoning) */
export const INTENT_FIELD = "_i";
export const INTENT_FIELD = "i";
// ═══════════════════════════════════════════════════════════════════════════
// Envelope & link constants