feat(pi-local): fix bugs, add RPC mode, improve cost tracking and output handling

Major improvements to the Pi local adapter:

Bug Fixes (Greptile-identified):
- Fix string interpolation in models.ts error message (was showing literal ${detail})
- Fix tool matching in parse.ts to use toolCallId instead of toolName for correct
  multi-call handling and result assignment
- Fix dead code in execute.ts by tracking instructionsReadFailed flag

Feature Improvements:
- Switch from print mode (-p) to RPC mode (--mode rpc) to prevent agent from
  exiting prematurely and ensure proper lifecycle completion
- Add stdin command sending via JSON-RPC format for prompt delivery
- Add line buffering in execute.ts to handle partial JSON chunks correctly
- Filter RPC protocol messages (response, extension_ui_request, etc.) from transcript

Cost Tracking:
- Extract cost and usage data from turn_end assistant messages
- Support both Pi format (input/output/cacheRead/cost.total) and generic format
- Add tests for cost extraction and accumulation across multiple turns

All tests pass (12/12), typecheck clean, server builds successfully.
This commit is contained in:
Richard Anaya
2026-03-07 07:23:44 -08:00
parent 6077ae6064
commit a6b5f12daf
5 changed files with 206 additions and 20 deletions

View File

@@ -246,6 +246,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
let systemPromptExtension = "";
let instructionsReadFailed = false;
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
@@ -259,6 +260,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\n`,
);
} catch (err) {
instructionsReadFailed = true;
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
@@ -294,19 +296,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const commandNotes = (() => {
if (!resolvedInstructionsFilePath) return [] as string[];
if (systemPromptExtension.length > 0) {
if (instructionsReadFailed) {
return [
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`,
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
}
return [
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`,
];
})();
const buildArgs = (sessionFile: string): string[] => {
const args: string[] = ["-p", userPrompt];
const args: string[] = [];
// Use RPC mode for proper lifecycle management (waits for agent completion)
args.push("--mode", "rpc");
// Use --append-system-prompt to extend Pi's default system prompt
args.push("--append-system-prompt", renderedSystemPromptExtension);
@@ -315,7 +320,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (modelId) args.push("--model", modelId);
if (thinking) args.push("--thinking", thinking);
args.push("--mode", "json");
args.push("--tools", "read,bash,edit,write,grep,find,ls");
args.push("--session", sessionFile);
@@ -324,6 +328,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return args;
};
const buildRpcStdin = (): string => {
// Send the prompt as an RPC command
const promptCommand = {
type: "prompt",
message: userPrompt,
};
return JSON.stringify(promptCommand) + "\n";
};
const runAttempt = async (sessionFile: string) => {
const args = buildArgs(sessionFile);
if (onMeta) {
@@ -339,13 +352,43 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
}
// Buffer stdout by lines to handle partial JSON chunks
let stdoutBuffer = "";
const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stderr") {
// Pass stderr through immediately (not JSONL)
await onLog(stream, chunk);
return;
}
// Buffer stdout and emit only complete lines
stdoutBuffer += chunk;
const lines = stdoutBuffer.split("\n");
// Keep the last (potentially incomplete) line in the buffer
stdoutBuffer = lines.pop() || "";
// Emit complete lines
for (const line of lines) {
if (line) {
await onLog(stream, line + "\n");
}
}
};
const proc = await runChildProcess(runId, command, args, {
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog,
onLog: bufferedOnLog,
stdin: buildRpcStdin(),
});
// Flush any remaining buffer content
if (stdoutBuffer) {
await onLog("stdout", stdoutBuffer);
}
return {
proc,
rawStderr: proc.stderr,