mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
The agent loop is back to main's tool execution: it only exports runToolCall(), honors isError results, and passes structuredContent through afterToolCall. Nested calls (ctx.executeTool, ids, events with parentToolCallId, the nestedCalls record) live in coding-agent and start on the first call. AgentSession no longer knows exec or tool_search. Tools adjust the declared loadout through a generic prepareLoadout hook, and codemode and tool_search use it like any other tool. exec and tool_search move to built-in extensions under src/extensions/ and register inactive (defaultActive: false). The CLI loads them with MCP; SDK sessions add createCodemodeExtension(), createToolSearchExtension(), and createMcpExtension(). Extensions gain pi.getSettings() and model catalog and classifier access on ModelRegistry.
185 lines
6.5 KiB
TypeScript
185 lines
6.5 KiB
TypeScript
import type { AgentTool, AgentToolCall } from "@earendil-works/pi-agent-core";
|
|
import { Type } from "typebox";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
NESTED_CALL_LIMITS,
|
|
NestedCallRecorder,
|
|
type NestedToolCallHost,
|
|
NestedToolCallRunner,
|
|
type NestedToolExecutionEvent,
|
|
} from "../src/core/nested-tool-calls.ts";
|
|
|
|
function createRunner(tools: AgentTool[], options: { sequential?: boolean } = {}) {
|
|
const events: NestedToolExecutionEvent[] = [];
|
|
const host: NestedToolCallHost = {
|
|
getTools: () => tools,
|
|
isSequential: () => options.sequential ?? false,
|
|
runToolCall: async (toolCall, _parentId, signal, onUpdate) => {
|
|
const tool = tools.find((candidate) => candidate.name === toolCall.name);
|
|
if (!tool) {
|
|
return {
|
|
toolCall,
|
|
result: { content: [{ type: "text", text: `Tool ${toolCall.name} not found` }], details: {} },
|
|
isError: true,
|
|
};
|
|
}
|
|
const result = await tool.execute(
|
|
toolCall.id,
|
|
toolCall.arguments,
|
|
signal,
|
|
(partial) => void onUpdate(partial),
|
|
);
|
|
return { toolCall, result, isError: result.isError === true };
|
|
},
|
|
emit: async (event) => {
|
|
events.push(event);
|
|
},
|
|
};
|
|
return { runner: new NestedToolCallRunner(host), events };
|
|
}
|
|
|
|
describe("NestedToolCallRunner", () => {
|
|
it("assigns ids below the caller, emits events with the parent id, and records the calls", async () => {
|
|
const echo: AgentTool = {
|
|
name: "echo",
|
|
label: "Echo",
|
|
description: "Echo",
|
|
parameters: Type.Object({}),
|
|
async execute(_id, _params, _signal, onUpdate) {
|
|
onUpdate?.({ content: [{ type: "text", text: "partial" }], details: {} });
|
|
return { content: [{ type: "text", text: "ok" }], details: {} };
|
|
},
|
|
};
|
|
const { runner, events } = createRunner([echo]);
|
|
const updates: unknown[] = [];
|
|
|
|
const first = await runner.execute("call", "echo", { a: 1 }, { onUpdate: (partial) => updates.push(partial) });
|
|
const missing = await runner.execute("call", "missing", {});
|
|
|
|
expect(first.toolCall.id).toBe("call/1");
|
|
expect(missing).toMatchObject({ toolCall: { id: "call/2" }, isError: true });
|
|
expect(updates).toHaveLength(1);
|
|
expect(events.map((event) => [event.type, event.toolCallId, event.parentToolCallId])).toEqual([
|
|
["tool_execution_start", "call/1", "call"],
|
|
["tool_execution_update", "call/1", "call"],
|
|
["tool_execution_end", "call/1", "call"],
|
|
["tool_execution_start", "call/2", "call"],
|
|
["tool_execution_end", "call/2", "call"],
|
|
]);
|
|
expect(runner.takeRecord("call")).toEqual({
|
|
calls: [
|
|
{ id: "call/1", name: "echo", arguments: { a: 1 }, status: "ok", durationMs: expect.any(Number) },
|
|
{
|
|
id: "call/2",
|
|
name: "missing",
|
|
arguments: {},
|
|
status: "error",
|
|
durationMs: expect.any(Number),
|
|
error: "Tool missing not found",
|
|
},
|
|
],
|
|
complete: true,
|
|
});
|
|
// The record is taken once.
|
|
expect(runner.takeRecord("call")).toBeUndefined();
|
|
expect(runner.takeRecord("other")).toBeUndefined();
|
|
});
|
|
|
|
it("records calls of nested tools on the model-issued call", async () => {
|
|
const leaf: AgentTool = {
|
|
name: "leaf",
|
|
label: "Leaf",
|
|
description: "Leaf",
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
return { content: [], details: {} };
|
|
},
|
|
};
|
|
const tools: AgentTool[] = [leaf];
|
|
const { runner } = createRunner(tools);
|
|
tools.push({
|
|
name: "middle",
|
|
label: "Middle",
|
|
description: "Calls leaf",
|
|
parameters: Type.Object({}),
|
|
async execute(toolCallId) {
|
|
await runner.execute(toolCallId, "leaf", {});
|
|
return { content: [], details: {} };
|
|
},
|
|
});
|
|
|
|
await runner.execute("call", "middle", {});
|
|
|
|
expect(runner.takeRecord("call")?.calls.map((call) => call.id)).toEqual(["call/1", "call/1/1"]);
|
|
});
|
|
|
|
it("serializes concurrent calls to sequential tools", async () => {
|
|
let active = 0;
|
|
let maxActive = { sequential: 0, parallel: 0 };
|
|
const makeTool = (name: "sequential" | "parallel"): AgentTool => ({
|
|
name,
|
|
label: name,
|
|
description: name,
|
|
parameters: Type.Object({}),
|
|
executionMode: name === "sequential" ? "sequential" : undefined,
|
|
async execute() {
|
|
active++;
|
|
maxActive = { ...maxActive, [name]: Math.max(maxActive[name], active) };
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
active--;
|
|
return { content: [], details: {} };
|
|
},
|
|
});
|
|
const { runner } = createRunner([makeTool("sequential"), makeTool("parallel")]);
|
|
|
|
await Promise.all([1, 2, 3].map(() => runner.execute("call", "sequential", {})));
|
|
await Promise.all([1, 2, 3].map(() => runner.execute("call", "parallel", {})));
|
|
|
|
expect(maxActive).toEqual({ sequential: 1, parallel: 3 });
|
|
});
|
|
});
|
|
|
|
describe("NestedCallRecorder", () => {
|
|
const call = (id: string, args: AgentToolCall["arguments"]): AgentToolCall => ({
|
|
type: "toolCall",
|
|
id,
|
|
name: "t",
|
|
arguments: args,
|
|
});
|
|
|
|
it("omits oversized arguments and drops calls beyond the limit", () => {
|
|
const recorder = new NestedCallRecorder();
|
|
expect(recorder.snapshot()).toBeUndefined();
|
|
const small = recorder.start(call("a", { x: 1 }));
|
|
recorder.finish(small, false, "");
|
|
expect(recorder.snapshot()).toEqual({
|
|
calls: [{ id: "a", name: "t", arguments: { x: 1 }, status: "ok", durationMs: expect.any(Number) }],
|
|
complete: true,
|
|
});
|
|
|
|
const big = recorder.start(call("b", { text: "x".repeat(NESTED_CALL_LIMITS.maxArgumentBytesPerCall) }));
|
|
recorder.finish(big, true, "e".repeat(1000));
|
|
const snapshot = recorder.snapshot();
|
|
expect(snapshot?.complete).toBe(false);
|
|
expect(snapshot?.calls[1]).toMatchObject({ id: "b", status: "error" });
|
|
expect(snapshot?.calls[1].arguments).toBeUndefined();
|
|
expect(snapshot?.calls[1].argumentsBytes).toBeGreaterThan(NESTED_CALL_LIMITS.maxArgumentBytesPerCall);
|
|
expect(snapshot?.calls[1].error).toHaveLength(NESTED_CALL_LIMITS.maxErrorChars);
|
|
|
|
for (let i = 0; i < NESTED_CALL_LIMITS.maxCalls; i++)
|
|
recorder.finish(recorder.start(call(`c${i}`, {})), false, "");
|
|
expect(recorder.snapshot()?.calls).toHaveLength(NESTED_CALL_LIMITS.maxCalls);
|
|
});
|
|
|
|
it("caps the total argument size and marks unfinished calls incomplete", () => {
|
|
const recorder = new NestedCallRecorder();
|
|
const chunk = { text: "x".repeat(7000) };
|
|
const records = Array.from({ length: 6 }, (_, i) => recorder.start(call(`c${i}`, chunk)));
|
|
const snapshot = recorder.snapshot();
|
|
// 32 KiB fits four 7000-byte argument objects.
|
|
expect(snapshot?.calls.filter((entry) => entry.arguments !== undefined)).toHaveLength(4);
|
|
expect(snapshot?.calls.every((entry) => entry.status === "unfinished")).toBe(true);
|
|
expect(snapshot?.complete).toBe(false);
|
|
expect(records).toHaveLength(6);
|
|
});
|
|
});
|