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");