chore(vendor): import pi 1.3.0

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
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -0,0 +1,172 @@
/**
* Adapters that give a pi-agent-core `Agent` MCP tools and a codemode tool, using
* `@earendil-works/pi-mcp` and `@earendil-works/pi-codemode`. See main.ts for how they fit together.
*/
import {
type Agent,
type AgentTool,
type AgentToolCallOutcome,
type AgentToolResult,
runToolCall,
} from "@earendil-works/pi-agent-core";
import type { ImageContent, JsonObject, JsonValue, TextContent } from "@earendil-works/pi-ai";
import {
type CodemodeJsonSchema,
CodemodeSandbox,
type CodemodeTool,
renderDeclarations,
} from "@earendil-works/pi-codemode";
import { type McpClient, toLlmContent } from "@earendil-works/pi-mcp";
import { Type } from "typebox";
/** Provider tool names are limited to 64 characters of `[A-Za-z0-9_-]`. */
function toToolName(prefix: string, name: string): string {
return `${prefix}_${name}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
}
function textOf(result: AgentToolResult<unknown>): string {
return result.content
.filter((block): block is TextContent => block.type === "text")
.map((block) => block.text)
.join("\n");
}
/**
* One `AgentTool` per tool of a connected MCP server, named `<prefix>_<tool>`. Tools with an MCP
* output schema declare it as `outputSchema` and return `structuredContent`, which codemode scripts
* receive instead of the text.
*/
export async function createMcpTools(client: McpClient, prefix: string): Promise<AgentTool[]> {
const tools = await client.listTools();
return tools.map(
(tool): AgentTool => ({
name: toToolName(prefix, tool.name),
label: tool.title ?? tool.name,
description: tool.description ?? tool.title ?? `MCP tool ${tool.name}`,
// Providers require an object schema, and some reject one without `properties`.
parameters: Type.Unsafe({
...tool.inputSchema,
type: "object",
properties: tool.inputSchema.properties ?? {},
}),
outputSchema: tool.outputSchema ? Type.Unsafe<Record<string, unknown>>(tool.outputSchema) : undefined,
execute: async (_toolCallId, params, signal, onUpdate) => {
const result = await client.callTool(tool.name, params as Record<string, unknown>, {
signal,
onProgress: (progress) => {
const text = progress.message ?? `Progress ${progress.progress}`;
onUpdate?.({ content: [{ type: "text", text }], details: undefined });
},
});
const content = toLlmContent(result);
if (result.isError && content.length === 0) {
content.push({ type: "text", text: `MCP tool ${tool.name} failed` });
}
return {
content,
details: undefined,
structuredContent: result.structuredContent as JsonValue | undefined,
// MCP reports tool failures in the result instead of as a protocol error.
isError: result.isError === true,
};
},
}),
);
}
/**
* Run a tool call on behalf of another tool through the agent's pipeline: argument validation and
* the agent's `beforeToolCall`/`afterToolCall` hooks apply as for calls the model makes. Calling
* `tool.execute()` directly also works when the agent has no hooks.
*/
export function createNestedToolRunner(
agent: Agent,
): (name: string, args: unknown, signal: AbortSignal) => Promise<AgentToolCallOutcome> {
let nextId = 1;
return (name, args, signal) => {
const assistantMessage = agent.state.messages.findLast((message) => message.role === "assistant");
if (assistantMessage?.role !== "assistant") throw new Error("Nested tool calls need an assistant message");
const tools = agent.state.tools;
return runToolCall(
{ type: "toolCall", id: `nested-${nextId++}`, name, arguments: (args ?? {}) as JsonObject },
{
tools,
assistantMessage,
context: { messages: agent.state.messages, tools },
beforeToolCall: agent.beforeToolCall,
afterToolCall: agent.afterToolCall,
signal,
},
);
};
}
export const CODEMODE_TOOL_NAME = "codemode";
const codemodeParameters = Type.Object({
code: Type.String({ description: "JavaScript source. Top-level await and return work." }),
});
/**
* A tool that runs model-written JavaScript in the codemode sandbox, where `tools.<name>(args)`
* calls `tools`. Nested results stay out of the model context; only the script's output and
* return value come back.
*/
export function createCodemodeTool(
tools: readonly AgentTool[],
runTool: (name: string, args: unknown, signal: AbortSignal) => Promise<AgentToolCallOutcome>,
): AgentTool<typeof codemodeParameters> {
const callable = tools.filter((tool) => tool.name !== CODEMODE_TOOL_NAME);
const sandboxTools: CodemodeTool[] = callable.map((tool) => ({
name: tool.name,
description: tool.description,
// TypeBox schemas are JSON Schema.
inputSchema: tool.parameters as CodemodeJsonSchema,
// What the script receives: the structured result if the tool declares one, else its text.
outputSchema: (tool.outputSchema as CodemodeJsonSchema | undefined) ?? { type: "string" },
execute: async (args, { signal }) => {
const outcome = await runTool(tool.name, args, signal);
if (tool.outputSchema && outcome.result.structuredContent !== undefined) {
return outcome.result.structuredContent;
}
const text = textOf(outcome.result);
if (outcome.isError) throw new Error(text || `Tool ${tool.name} failed`);
return text;
},
}));
return {
name: CODEMODE_TOOL_NAME,
label: "Codemode",
description: [
"Run JavaScript that calls other tools, to chain calls, run them in parallel, or filter large results.",
"The code is the body of an async function. There is no Node, file system, network, or timers.",
"Call tools as `await tools.<name>(args)`. Output with `text(value)` or `console.log()`, or `return` a value.",
"Only the output and the return value reach you, not the results of the tool calls.",
"",
"```ts",
renderDeclarations({ tools: sandboxTools }),
"```",
].join("\n"),
parameters: codemodeParameters,
execute: async (_toolCallId, params, signal) => {
const sandbox = new CodemodeSandbox({ tools: sandboxTools, timeoutMs: 120_000 });
try {
const result = await sandbox.execute(params.code, { signal });
// Items from text(), console.*, and image(), in order. They have pi-ai's content shapes.
const content: (TextContent | ImageContent)[] = [...result.output];
if (result.ok && result.value !== undefined) {
const value = typeof result.value === "string" ? result.value : JSON.stringify(result.value);
content.push({ type: "text", text: value });
}
if (!result.ok) {
content.push({ type: "text", text: `Script failed: ${result.error.stack ?? result.error.message}` });
}
return { content, details: { calls: result.calls }, isError: !result.ok };
} finally {
await sandbox.close();
}
},
};
}