Skip to content

Commit bc2f3a3

Browse files
authored
fix(pi-rpc): align host RPC protocol dispatch (#1662)
1 parent 1d882db commit bc2f3a3

4 files changed

Lines changed: 357 additions & 52 deletions

File tree

packages/core/src/evaluation/providers/pi-process.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface PiProcessRunOptions {
1515
readonly env: NodeJS.ProcessEnv;
1616
readonly signal?: AbortSignal;
1717
readonly stdin?: string;
18+
readonly stdinEnd?: 'after_write' | 'manual';
19+
readonly completeOnStdout?: (stdout: string) => boolean;
1820
readonly onStdoutChunk?: (chunk: string) => void;
1921
readonly onStderrChunk?: (chunk: string) => void;
2022
}
@@ -211,9 +213,21 @@ export async function defaultPiProcessRunner(
211213
}
212214

213215
child.stdout.setEncoding('utf8');
216+
let stdinEnded = false;
217+
const endStdin = (): void => {
218+
if (stdinEnded || child.stdin.destroyed) {
219+
return;
220+
}
221+
stdinEnded = true;
222+
child.stdin.end();
223+
};
224+
214225
child.stdout.on('data', (chunk) => {
215226
stdout += chunk;
216227
options.onStdoutChunk?.(chunk);
228+
if (options.completeOnStdout?.(stdout)) {
229+
endStdin();
230+
}
217231
});
218232

219233
child.stderr.setEncoding('utf8');
@@ -261,7 +275,12 @@ export async function defaultPiProcessRunner(
261275
});
262276
});
263277

264-
child.stdin.end(options.stdin ?? '');
278+
if (options.stdin !== undefined) {
279+
child.stdin.write(options.stdin);
280+
}
281+
if (options.stdinEnd !== 'manual') {
282+
endStdin();
283+
}
265284
});
266285
}
267286

packages/core/src/evaluation/providers/pi-rpc.ts

Lines changed: 223 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import {
99
classifyPiProcessFailure,
1010
defaultPiProcessRunner,
1111
piProcessFailureMessage,
12+
splitPiCommand,
1213
} from './pi-process.js';
14+
import {
15+
extractAzureResourceName,
16+
resolveCliProvider,
17+
resolveEnvKeyName,
18+
} from './pi-provider-aliases.js';
1319
import { extractPiTextContent, toFiniteNumber } from './pi-utils.js';
1420
import { normalizeInputFiles } from './preread.js';
1521
import type { PiRpcResolvedConfig } from './targets.js';
@@ -49,7 +55,7 @@ export class PiRpcProvider implements Provider {
4955

5056
const startedAt = Date.now();
5157
const cwd = this.resolveCwd(request.cwd);
52-
const command = ensureRpcMode(this.config.command);
58+
const command = this.buildCommand();
5359
const inputFiles = normalizeInputFiles(request.inputFiles);
5460
const rpcRequest = buildRpcRequest({
5561
request,
@@ -61,9 +67,11 @@ export class PiRpcProvider implements Provider {
6167
command,
6268
cwd,
6369
timeoutMs: this.config.timeoutMs,
64-
env: buildPiRuntimeEnv({ runtime: this.config.runtime, targetName: this.targetName }),
70+
env: this.buildEnv(),
6571
signal: request.signal,
6672
stdin: `${JSON.stringify(rpcRequest)}\n`,
73+
stdinEnd: 'manual',
74+
completeOnStdout: (stdout) => hasRpcAgentEnd(stdout, rpcRequest.id),
6775
});
6876

6977
if (result.timedOut || result.exitCode !== 0 || result.signal || result.spawnErrorCode) {
@@ -100,6 +108,18 @@ export class PiRpcProvider implements Provider {
100108
});
101109
}
102110

111+
const resultError = rpcResultError(parsed.result);
112+
if (resultError) {
113+
return this.buildRpcTaskFailureResponse({
114+
result,
115+
command,
116+
cwd,
117+
startedAt,
118+
message: resultError,
119+
events: parsed.events,
120+
});
121+
}
122+
103123
const output = messagesFromRpcResult(parsed.result);
104124
const tokenUsage = tokenUsageFromRpcResult(parsed.result);
105125
const endedAt = Date.now();
@@ -150,6 +170,90 @@ export class PiRpcProvider implements Provider {
150170
return process.cwd();
151171
}
152172

173+
private buildCommand(): readonly string[] {
174+
const args: string[] = [];
175+
if (this.config.subprovider) {
176+
args.push('--provider', resolveCliProvider(this.config.subprovider));
177+
}
178+
if (this.config.model) {
179+
args.push('--model', this.config.model);
180+
}
181+
if (this.config.apiKey && this.config.subprovider?.toLowerCase() !== 'azure') {
182+
args.push('--api-key', this.config.apiKey);
183+
}
184+
if (this.config.systemPrompt) {
185+
args.push('--system-prompt', this.config.systemPrompt);
186+
}
187+
188+
if (!hasModeFlag(this.config.command)) {
189+
args.push('--mode', 'rpc');
190+
}
191+
args.push('--no-session');
192+
193+
if (this.config.tools) {
194+
args.push('--tools', this.config.tools);
195+
}
196+
if (this.config.thinking) {
197+
args.push('--thinking', this.config.thinking);
198+
}
199+
200+
return splitPiCommand(this.config.command, args);
201+
}
202+
203+
private buildEnv(): NodeJS.ProcessEnv {
204+
const env = buildPiRuntimeEnv({
205+
runtime: this.config.runtime,
206+
targetName: this.targetName,
207+
});
208+
209+
const provider = this.config.subprovider?.toLowerCase() ?? 'google';
210+
if (provider === 'azure') {
211+
if (this.config.apiKey) {
212+
env.AZURE_OPENAI_API_KEY = this.config.apiKey;
213+
}
214+
if (this.config.baseUrl) {
215+
if (/^https?:\/\//.test(this.config.baseUrl)) {
216+
env.AZURE_OPENAI_BASE_URL = this.config.baseUrl;
217+
} else {
218+
env.AZURE_OPENAI_RESOURCE_NAME = extractAzureResourceName(this.config.baseUrl);
219+
}
220+
}
221+
} else if (this.config.apiKey) {
222+
const envKey = resolveEnvKeyName(provider);
223+
if (envKey) {
224+
env[envKey] = this.config.apiKey;
225+
}
226+
}
227+
228+
if (this.config.subprovider) {
229+
const resolvedProvider = resolveCliProvider(this.config.subprovider);
230+
const providerOwnPrefixes: Record<string, readonly string[]> = {
231+
openrouter: ['OPENROUTER_'],
232+
anthropic: ['ANTHROPIC_'],
233+
openai: ['OPENAI_'],
234+
'azure-openai-responses': ['AZURE_OPENAI_'],
235+
google: ['GEMINI_', 'GOOGLE_GENERATIVE_AI_'],
236+
gemini: ['GEMINI_', 'GOOGLE_GENERATIVE_AI_'],
237+
groq: ['GROQ_'],
238+
xai: ['XAI_'],
239+
};
240+
const ownPrefixes = providerOwnPrefixes[resolvedProvider] ?? [];
241+
const allOtherPrefixes = Object.entries(providerOwnPrefixes)
242+
.filter(([key]) => key !== resolvedProvider)
243+
.flatMap(([, prefixes]) => prefixes);
244+
for (const key of Object.keys(env)) {
245+
if (
246+
allOtherPrefixes.some((prefix) => key.startsWith(prefix)) &&
247+
!ownPrefixes.some((prefix) => key.startsWith(prefix))
248+
) {
249+
delete env[key];
250+
}
251+
}
252+
}
253+
254+
return env;
255+
}
256+
153257
private buildProcessErrorResponse(params: {
154258
readonly result: PiProcessRunResult;
155259
readonly command: readonly string[];
@@ -253,10 +357,10 @@ export class PiRpcProvider implements Provider {
253357
}
254358

255359
type RpcRequest = {
256-
readonly jsonrpc: '2.0';
257360
readonly id: string;
258-
readonly method: 'run';
259-
readonly params: Record<string, unknown>;
361+
readonly type: 'prompt';
362+
readonly message: string;
363+
readonly streamingBehavior?: 'followUp';
260364
};
261365

262366
type ParsedRpcOutput = {
@@ -270,37 +374,18 @@ function buildRpcRequest(params: {
270374
readonly config: PiRpcResolvedConfig;
271375
readonly inputFiles?: readonly string[];
272376
}): RpcRequest {
273-
const rpcParams: Record<string, unknown> = {
274-
prompt: params.request.question,
275-
system_prompt: params.config.systemPrompt ?? params.request.systemPrompt,
276-
model: params.config.model,
277-
subprovider: params.config.subprovider,
278-
tools: params.config.tools,
279-
thinking: params.config.thinking,
280-
input_files: params.inputFiles,
281-
metadata: params.request.metadata,
282-
};
283-
for (const key of Object.keys(rpcParams)) {
284-
if (rpcParams[key] === undefined) {
285-
delete rpcParams[key];
286-
}
287-
}
377+
const prefix = params.request.systemPrompt ? `${params.request.systemPrompt}\n\n` : '';
378+
const suffix =
379+
params.inputFiles && params.inputFiles.length > 0
380+
? `\n\nInput files:\n${params.inputFiles.map((file) => `@${file}`).join('\n')}`
381+
: '';
288382
return {
289-
jsonrpc: '2.0',
290383
id: randomUUID(),
291-
method: 'run',
292-
params: rpcParams,
384+
type: 'prompt',
385+
message: `${prefix}${params.request.question}${suffix}`,
293386
};
294387
}
295388

296-
function ensureRpcMode(command: readonly string[]): readonly string[] {
297-
const hasMode = command.some(
298-
(arg, index) =>
299-
arg === '--mode' || arg.startsWith('--mode=') || command[index - 1] === '--mode',
300-
);
301-
return hasMode ? command : [...command, '--mode', 'rpc'];
302-
}
303-
304389
function parseRpcOutput(stdout: string, requestId: string): ParsedRpcOutput {
305390
const lines = stdout
306391
.split(/\r?\n/)
@@ -326,6 +411,17 @@ function parseRpcOutput(stdout: string, requestId: string): ParsedRpcOutput {
326411
throw new Error(`invalid JSON protocol message: ${formatError(parseError)}`);
327412
}
328413

414+
if (message.id === requestId && message.type === 'response') {
415+
if (message.success === false) {
416+
error = rpcErrorMessage(message.error ?? message.message);
417+
}
418+
continue;
419+
}
420+
if (message.type === 'agent_end') {
421+
result = message;
422+
events.push(message);
423+
continue;
424+
}
329425
if (message.id === requestId && Object.prototype.hasOwnProperty.call(message, 'result')) {
330426
result = message.result;
331427
continue;
@@ -354,6 +450,32 @@ function parseRpcOutput(stdout: string, requestId: string): ParsedRpcOutput {
354450
return { result, events };
355451
}
356452

453+
function hasRpcAgentEnd(stdout: string, requestId: string): boolean {
454+
for (const line of stdout.split('\n')) {
455+
const trimmed = line.trim();
456+
if (!trimmed) continue;
457+
try {
458+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
459+
if (parsed.type === 'agent_end') {
460+
return true;
461+
}
462+
if (parsed.id === requestId && parsed.type === 'response' && parsed.success === false) {
463+
return true;
464+
}
465+
} catch {
466+
// Keep waiting; parseRpcOutput will report malformed lines after exit.
467+
}
468+
}
469+
return false;
470+
}
471+
472+
function hasModeFlag(command: readonly string[]): boolean {
473+
return command.some(
474+
(arg, index) =>
475+
arg === '--mode' || arg.startsWith('--mode=') || command[index - 1] === '--mode',
476+
);
477+
}
478+
357479
function messagesFromRpcResult(result: unknown): readonly Message[] {
358480
if (result && typeof result === 'object') {
359481
const record = result as Record<string, unknown>;
@@ -374,6 +496,30 @@ function messagesFromRpcResult(result: unknown): readonly Message[] {
374496
return [{ role: 'assistant', content: JSON.stringify(result) }];
375497
}
376498

499+
function rpcResultError(result: unknown): string | undefined {
500+
if (!result || typeof result !== 'object') {
501+
return undefined;
502+
}
503+
const record = result as Record<string, unknown>;
504+
if (!Array.isArray(record.messages)) {
505+
return undefined;
506+
}
507+
const errored = [...record.messages].reverse().find((message) => {
508+
if (!message || typeof message !== 'object') return false;
509+
const msg = message as Record<string, unknown>;
510+
return msg.role === 'assistant' && (msg.stopReason === 'error' || msg.stop_reason === 'error');
511+
});
512+
if (!errored || typeof errored !== 'object') {
513+
return undefined;
514+
}
515+
const msg = errored as Record<string, unknown>;
516+
return (
517+
stringField(msg, 'errorMessage') ??
518+
stringField(msg, 'error_message') ??
519+
'Pi RPC assistant message ended with stopReason=error'
520+
);
521+
}
522+
377523
function convertRpcMessage(value: unknown): Message | undefined {
378524
if (!value || typeof value !== 'object' || Array.isArray(value)) {
379525
return undefined;
@@ -403,7 +549,7 @@ function tokenUsageFromRpcResult(result: unknown): ProviderTokenUsage | undefine
403549
| Record<string, unknown>
404550
| undefined;
405551
if (!usage || typeof usage !== 'object') {
406-
return undefined;
552+
return tokenUsageFromMessages(record.messages);
407553
}
408554
const input = toFiniteNumber(usage.input ?? usage.input_tokens ?? usage.inputTokens);
409555
const output = toFiniteNumber(usage.output ?? usage.output_tokens ?? usage.outputTokens);
@@ -420,6 +566,49 @@ function tokenUsageFromRpcResult(result: unknown): ProviderTokenUsage | undefine
420566
};
421567
}
422568

569+
function tokenUsageFromMessages(messages: unknown): ProviderTokenUsage | undefined {
570+
if (!Array.isArray(messages)) {
571+
return undefined;
572+
}
573+
574+
let totalInput = 0;
575+
let totalOutput = 0;
576+
let totalCached = 0;
577+
let totalReasoning = 0;
578+
let found = false;
579+
580+
for (const message of messages) {
581+
if (!message || typeof message !== 'object') {
582+
continue;
583+
}
584+
const usage = (message as Record<string, unknown>).usage;
585+
if (!usage || typeof usage !== 'object') {
586+
continue;
587+
}
588+
found = true;
589+
const record = usage as Record<string, unknown>;
590+
totalInput += toFiniteNumber(record.input) ?? 0;
591+
totalOutput += toFiniteNumber(record.output) ?? 0;
592+
totalCached +=
593+
toFiniteNumber(record.cacheRead) ??
594+
toFiniteNumber(record.cache_read) ??
595+
toFiniteNumber(record.cached) ??
596+
0;
597+
totalReasoning +=
598+
toFiniteNumber(record.reasoning) ?? toFiniteNumber(record.reasoning_tokens) ?? 0;
599+
}
600+
601+
if (!found) {
602+
return undefined;
603+
}
604+
return {
605+
input: totalInput,
606+
output: totalOutput,
607+
...(totalCached > 0 ? { cached: totalCached } : {}),
608+
...(totalReasoning > 0 ? { reasoning: totalReasoning } : {}),
609+
};
610+
}
611+
423612
function stringField(record: Record<string, unknown>, key: string): string | undefined {
424613
const value = record[key];
425614
return typeof value === 'string' ? value : undefined;
@@ -443,6 +632,7 @@ function formatError(error: unknown): string {
443632
}
444633

445634
export const _internal = {
446-
ensureRpcMode,
635+
hasModeFlag,
636+
hasRpcAgentEnd,
447637
parseRpcOutput,
448638
};

0 commit comments

Comments
 (0)