diff --git a/docs/integrations/pi-integration.mdx b/docs/integrations/pi-integration.mdx index 4b584577..e8a30063 100644 --- a/docs/integrations/pi-integration.mdx +++ b/docs/integrations/pi-integration.mdx @@ -72,6 +72,8 @@ pi --version bun run test ``` +For a minimal end-to-end example, run `bun run cli run examples/pi-hello-world.tsx`. To choose a model, check your PI `models.json` or run `pi --list-models`. + ## Design Guidance Use `PiAgent` task nodes when: diff --git a/examples/pi-hello-world.tsx b/examples/pi-hello-world.tsx new file mode 100644 index 00000000..19938c2c --- /dev/null +++ b/examples/pi-hello-world.tsx @@ -0,0 +1,31 @@ +/** @jsxImportSource smithers-orchestrator */ +import { createSmithers, Task, Workflow, PiAgent } from "smithers-orchestrator"; +import { z } from "zod"; + +const HelloSchema = z.object({ + message: z.string(), +}); + +const { smithers, outputs } = createSmithers( + { + output: HelloSchema, + }, + { + dbPath: "./examples/pi-hello-world.db", + }, +); + +const pi = new PiAgent({ + provider: "openai-codex", + model: "gpt-5.4", + mode: "json", +}); + +export default smithers(() => ( + + + {`Return exactly this JSON and nothing else: +{"message":"hello world"}`} + + +)); diff --git a/examples/pi-tools-input.txt b/examples/pi-tools-input.txt new file mode 100644 index 00000000..4f8a719e --- /dev/null +++ b/examples/pi-tools-input.txt @@ -0,0 +1,3 @@ +Smithers PI tools sample +Unique phrase: saffron-orbit-lantern +This file is here so the PI agent has to read something real from disk. diff --git a/examples/pi-tools-workflow.tsx b/examples/pi-tools-workflow.tsx new file mode 100644 index 00000000..ad225b82 --- /dev/null +++ b/examples/pi-tools-workflow.tsx @@ -0,0 +1,42 @@ +/** @jsxImportSource smithers-orchestrator */ +import { createSmithers, Task, Workflow, PiAgent } from "smithers-orchestrator"; +import { z } from "zod"; + +const OutputSchema = z.object({ + phrase: z.string().regex(/^saffron-orbit-lantern$/), + lineCount: z.number().int().min(3).max(3), + cwdBasename: z.string().regex(/^examples$/), + summary: z.string(), +}); + +const { smithers, outputs } = createSmithers( + { + output: OutputSchema, + }, + { + dbPath: "./examples/pi-tools-workflow.db", + }, +); + +const pi = new PiAgent({ + provider: "openai-codex", + model: "gpt-5.4", + mode: "rpc", + tools: ["read", "bash"], +}); + +export default smithers(() => ( + + + {`Use the read tool to inspect ./pi-tools-input.txt and use the bash tool to determine the basename of the current working directory. + +Then return exactly this JSON and nothing else: +{ + "phrase": "the unique phrase from the file", + "lineCount": 3, + "cwdBasename": "the basename of the current working directory", + "summary": "one short sentence confirming what you found" +}`} + + +)); diff --git a/src/agents/BaseCliAgent.ts b/src/agents/BaseCliAgent.ts index 4c94842a..3d4f9605 100644 --- a/src/agents/BaseCliAgent.ts +++ b/src/agents/BaseCliAgent.ts @@ -62,6 +62,7 @@ type RunRpcCommandOptions = { | Promise | PiExtensionUiResponse | null; + onEvent?: (event: unknown) => void; }; type PromptParts = { @@ -711,16 +712,21 @@ export function runRpcCommandEffect(command: string, args: string[], options: Ru child.stdin.write(`${JSON.stringify(normalized)}\n`); }; - const handleLine = async (line: string) => { - inactivity.reset(); - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - return; - } - if (!parsed || typeof parsed !== "object") return; - const event = parsed as Record; + const handleLine = async (line: string) => { + inactivity.reset(); + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + if (!parsed || typeof parsed !== "object") return; + try { + options.onEvent?.(parsed); + } catch { + // ignore observer errors + } + const event = parsed as Record; const type = event.type; if (type === "response" && event.command === "prompt" && event.success === false) { const errorMessage = typeof event.error === "string" ? event.error : "PI RPC prompt failed"; @@ -754,20 +760,56 @@ export function runRpcCommandEffect(command: string, args: string[], options: Ru if (message.usage) extractedUsage = message.usage; if (message.stopReason === "error" || message.stopReason === "aborted") { promptResponseError = message.errorMessage || `Request ${message.stopReason}`; - } - const extracted = finalMessage ? extractTextFromJsonValue(finalMessage) : undefined; - const text = extracted ?? textDeltas; - inactivity.clear(); - totalTimeout.clear(); - if (promptResponseError) { + inactivity.clear(); + totalTimeout.clear(); handleError(new Error(promptResponseError)); return; } - finalize(text, finalMessage ?? text); - child.stdin?.end(); - terminateChild(); + // Do not finalize on tool-use turns. Pi continues with additional + // turns after tool execution and only reaches the real final answer + // on a later turn/agent_end. + if (message.stopReason !== "toolUse") { + const extracted = finalMessage ? extractTextFromJsonValue(finalMessage) : undefined; + const text = extracted ?? textDeltas; + inactivity.clear(); + totalTimeout.clear(); + finalize(text, finalMessage ?? text); + child.stdin?.end(); + terminateChild(); + return; + } } } + if (type === "agent_end") { + const messages = (event as any).messages as Array | undefined; + if (Array.isArray(messages)) { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.role === "assistant") { + finalMessage = message; + if (message.usage) extractedUsage = message.usage; + if (message.stopReason === "error" || message.stopReason === "aborted") { + promptResponseError = message.errorMessage || `Request ${message.stopReason}`; + } + break; + } + } + } + if (promptResponseError) { + inactivity.clear(); + totalTimeout.clear(); + handleError(new Error(promptResponseError)); + return; + } + const extracted = finalMessage ? extractTextFromJsonValue(finalMessage) : undefined; + const text = extracted ?? textDeltas; + inactivity.clear(); + totalTimeout.clear(); + finalize(text, finalMessage ?? text); + child.stdin?.end(); + terminateChild(); + return; + } if (type === "extension_ui_request") { await maybeWriteExtensionResponse(event as PiExtensionUiRequest); } diff --git a/tests/pi-support.test.ts b/tests/pi-support.test.ts index ca7c46bc..a7a89d81 100644 --- a/tests/pi-support.test.ts +++ b/tests/pi-support.test.ts @@ -183,6 +183,48 @@ import { afterEach, describe, expect, test } from "bun:test"; } }); + test("PiAgent RPC mode waits past tool-use turns for the final assistant answer", async () => { + const fake = await makeFakePi(` + let buffer = ""; + process.stdin.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + const lines = buffer.split(/\\r?\\n/); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.type === "prompt") { + process.stdout.write(JSON.stringify({ type: "response", command: "prompt", success: true, id: msg.id }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Thinking" } }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: [{ type: "text", text: "Tool turn" }], stopReason: "toolUse" } }) + "\\n"); + setTimeout(() => { + process.stdout.write(JSON.stringify({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: " final answer" } }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: [{ type: "text", text: "Final answer" }], stopReason: "stop" } }) + "\\n"); + }, 20); + } + } + }); + `); + + try { + process.env.PATH = `${fake.dir}:${originalPath}`; + + const agent = new PiAgent({ + mode: "rpc", + model: "gpt-4o-mini", + env: { PATH: process.env.PATH! }, + }); + + const result = await agent.generate({ + messages: [{ role: "user", content: "Use a tool and then answer" }], + }); + + expect(result.text).toBe("Final answer"); + } finally { + await rm(fake.dir, { recursive: true, force: true }); + } + }); + test("PiAgent RPC mode handles extension UI requests", async () => { const argsFileDir = await mkdtemp(join(tmpdir(), "smithers-pi-rpc-ui-")); const argsFile = join(argsFileDir, "prompt.json");