Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
146 lines
6.1 KiB
TypeScript
146 lines
6.1 KiB
TypeScript
import { fauxAssistantMessage, fauxToolCall, getCurrentTools, type TranscriptContext } from "@earendil-works/pi-ai";
|
|
import type { ToolResultMessage } from "@earendil-works/pi-ai/compat";
|
|
import { Type } from "typebox";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import type { ExtensionAPI } from "../../src/core/extensions/types.ts";
|
|
import { createCodemodeExtension } from "../../src/extensions/codemode/index.ts";
|
|
import { createToolSearchExtension } from "../../src/extensions/tool-search/index.ts";
|
|
import { createHarness, type Harness } from "./harness.ts";
|
|
|
|
/**
|
|
* A tool that calls other tools, built only on the extension API: its own name, exposure, loadout
|
|
* hook, and ctx.executeTool(). Codemode and tool search use the same mechanisms.
|
|
*/
|
|
function orchestratorExtension(pi: ExtensionAPI): void {
|
|
pi.registerTool({
|
|
name: "echo",
|
|
label: "echo",
|
|
description: "Echo text.",
|
|
parameters: Type.Object({ text: Type.String() }),
|
|
execute: async (_id, { text }) => ({ content: [{ type: "text", text: `echo: ${text}` }], details: {} }),
|
|
});
|
|
pi.registerTool({
|
|
name: "helper",
|
|
label: "helper",
|
|
description: "Only reachable from other tools.",
|
|
parameters: Type.Object({}),
|
|
exposure: "codemode",
|
|
execute: async () => ({ content: [{ type: "text", text: "helped" }], details: {} }),
|
|
});
|
|
pi.registerTool({
|
|
name: "run_tools",
|
|
label: "run_tools",
|
|
description: "Runs tools.",
|
|
parameters: Type.Object({}),
|
|
exposure: "model-only",
|
|
prepareLoadout: (loadout) => ({
|
|
descriptions: {
|
|
run_tools: `Runs tools: ${loadout.callable.map((tool) => tool.name).join(", ")}`,
|
|
echo: "Echo text (also callable from run_tools).",
|
|
},
|
|
hiddenDeclarations: ["echo"],
|
|
}),
|
|
execute: async (_id, _params, _signal, _onUpdate, ctx) => {
|
|
const helper = await ctx.executeTool("helper", {});
|
|
const echo = await ctx.executeTool("echo", { text: "hi" });
|
|
const self = await ctx.executeTool("run_tools", {});
|
|
const text = [helper, echo, self]
|
|
.map((outcome) => (outcome.result.content[0] as { text: string }).text)
|
|
.join(" | ");
|
|
return { content: [{ type: "text", text }], details: {} };
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("AgentSession tool orchestration", () => {
|
|
const harnesses: Harness[] = [];
|
|
|
|
afterEach(() => {
|
|
while (harnesses.length > 0) harnesses.pop()?.cleanup();
|
|
});
|
|
|
|
it("supports tools that call other tools under any name through the extension API", async () => {
|
|
const toolCalls: string[] = [];
|
|
const harness = await createHarness({
|
|
initialActiveToolNames: [],
|
|
extensionFactories: [
|
|
orchestratorExtension,
|
|
(pi) => {
|
|
pi.on("tool_call", (event) => {
|
|
toolCalls.push(`${event.toolName}:${event.parentToolCallId ?? "top"}`);
|
|
});
|
|
},
|
|
],
|
|
});
|
|
harnesses.push(harness);
|
|
await harness.session.bindExtensions({});
|
|
|
|
expect(harness.session.getActiveToolNames()).toEqual(["echo", "run_tools"]);
|
|
expect(harness.session.getCallableToolNames()).toEqual(["echo", "helper"]);
|
|
const find = (name: string) => harness.session.agent.state.tools.find((tool) => tool.name === name);
|
|
expect(find("run_tools")?.description).toBe("Runs tools: echo, helper");
|
|
expect(find("echo")?.description).toBe("Echo text (also callable from run_tools).");
|
|
|
|
const requestTools: string[][] = [];
|
|
harness.setResponses([
|
|
(context: TranscriptContext) => {
|
|
requestTools.push(getCurrentTools(context.messages).map((tool) => tool.name));
|
|
return fauxAssistantMessage([fauxToolCall("run_tools", {})], { stopReason: "toolUse" });
|
|
},
|
|
fauxAssistantMessage("done"),
|
|
]);
|
|
await harness.session.prompt("go");
|
|
|
|
// echo stays active, but its declaration is left out of requests.
|
|
expect(requestTools[0]).toEqual(["run_tools"]);
|
|
const result = harness.session.messages.find(
|
|
(message): message is ToolResultMessage => message.role === "toolResult",
|
|
);
|
|
if (!result) throw new Error("No tool result");
|
|
expect(result.content).toEqual([{ type: "text", text: "helped | echo: hi | Tool run_tools not found" }]);
|
|
const parent = result.toolCallId;
|
|
expect(toolCalls).toEqual(["run_tools:top", `helper:${parent}`, `echo:${parent}`]);
|
|
expect(result.nestedCalls?.calls.map((call) => [call.id, call.name, call.status])).toEqual([
|
|
[`${parent}/1`, "helper", "ok"],
|
|
[`${parent}/2`, "echo", "ok"],
|
|
[`${parent}/3`, "run_tools", "error"],
|
|
]);
|
|
// The record is persisted with the session.
|
|
const persisted = harness.sessionManager
|
|
.getBranch()
|
|
.find((entry) => entry.type === "message" && entry.message.role === "toolResult");
|
|
expect(persisted?.type === "message" && persisted.message).toMatchObject({ nestedCalls: result.nestedCalls });
|
|
});
|
|
|
|
it("registers codemode and tool_search inactive until they are named", async () => {
|
|
const extensionFactories = [createCodemodeExtension(), createToolSearchExtension()];
|
|
const plain = await createHarness({ extensionFactories });
|
|
harnesses.push(plain);
|
|
expect(plain.session.getAllTools().map((tool) => tool.name)).toEqual(
|
|
expect.arrayContaining(["codemode", "tool_search"]),
|
|
);
|
|
expect(plain.session.getActiveToolNames()).toEqual(["read", "bash", "edit", "write"]);
|
|
|
|
// --tools and the defaultTools setting name them explicitly.
|
|
const allowed = await createHarness({ allowedToolNames: ["read", "codemode"], extensionFactories });
|
|
harnesses.push(allowed);
|
|
expect(allowed.session.getActiveToolNames()).toEqual(["read", "codemode"]);
|
|
const initial = await createHarness({ initialActiveToolNames: ["tool_search"], extensionFactories });
|
|
harnesses.push(initial);
|
|
expect(initial.session.getActiveToolNames()).toEqual(["tool_search"]);
|
|
});
|
|
|
|
it("leaves results without nested calls unchanged", async () => {
|
|
const harness = await createHarness({ initialActiveToolNames: [], extensionFactories: [orchestratorExtension] });
|
|
harnesses.push(harness);
|
|
await harness.session.bindExtensions({});
|
|
harness.setResponses([
|
|
fauxAssistantMessage([fauxToolCall("echo", { text: "x" })], { stopReason: "toolUse" }),
|
|
fauxAssistantMessage("done"),
|
|
]);
|
|
await harness.session.prompt("go");
|
|
const result = harness.session.messages.find((message) => message.role === "toolResult");
|
|
expect(result && "nestedCalls" in result).toBe(false);
|
|
});
|
|
});
|