Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/integrations/pi-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions examples/pi-hello-world.tsx
Original file line number Diff line number Diff line change
@@ -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(() => (
<Workflow name="pi-hello-world">
<Task id="hello" output={outputs.output} agent={pi}>
{`Return exactly this JSON and nothing else:
{"message":"hello world"}`}
</Task>
</Workflow>
));
3 changes: 3 additions & 0 deletions examples/pi-tools-input.txt
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions examples/pi-tools-workflow.tsx
Original file line number Diff line number Diff line change
@@ -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(() => (
<Workflow name="pi-tools-workflow">
<Task id="inspect-file" output={outputs.output} agent={pi} retries={2}>
{`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"
}`}
</Task>
</Workflow>
));
80 changes: 61 additions & 19 deletions src/agents/BaseCliAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type RunRpcCommandOptions = {
| Promise<PiExtensionUiResponse | null>
| PiExtensionUiResponse
| null;
onEvent?: (event: unknown) => void;
};

type PromptParts = {
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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";
Expand Down Expand Up @@ -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<any> | 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);
}
Expand Down
42 changes: 42 additions & 0 deletions tests/pi-support.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading