Three small wins from boocode_code_review_v2 §1 #11/#7/#8. #11 sampling knobs: top_n_sigma + dry_* family as first-class Agent fields, threaded into the request body via providerOptions.openaiCompatible. Fixes a latent bug — top_k (rejected by the AI-SDK provider) and min_p (never passed to streamText) were dead on the wire; both now route through the same channel. --reasoning-budget documented in data/AGENTS.md. #7 live PTY stream-json: new stream-json-parser.ts line-buffers qwen/claude NDJSON and emits text/reasoning/tool frames live + persists, with a fallback to the old opaque slice. claude gets --output-format stream-json --verbose. #8 token UI: agent_sessions input/output_tokens/cost now flow through the route + type and render beside the AgentComposerBar session chip. Built by 3 parallel agents. Server 523 + coder 245 tests passing; builds + web tsc clean. Builds on v2.7.2. openspec sampling-streamjson-tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
5.9 KiB
TypeScript
196 lines
5.9 KiB
TypeScript
/**
|
|
* PTY dispatch — runs external agents directly on the host.
|
|
*
|
|
* claude + qwen run with `--output-format stream-json` and emit Claude-Code's
|
|
* stream-json NDJSON on stdout. When an `onEvent` callback is supplied we
|
|
* line-buffer that stdout (split on `\n`, hold the partial tail) and feed complete
|
|
* lines to `makeStreamJsonParser` so deltas surface live as AgentEvents. The raw
|
|
* stdout is still accumulated + returned for back-compat (and the dispatcher's
|
|
* fallback when nothing parsed). See `stream-json-parser.ts`.
|
|
*/
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
import { spawn } from 'node:child_process';
|
|
import type { AgentEvent } from './agent-backend.js';
|
|
import { makeStreamJsonParser, type StreamJsonUsage } from './stream-json-parser.js';
|
|
|
|
export interface DispatchResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
/** True iff at least one NDJSON AgentEvent was parsed from stdout (v#7). When
|
|
* false the dispatcher falls back to slicing stdout as the assistant content. */
|
|
streamed: boolean;
|
|
/** Final usage parsed from the stream-json `result` / `message_delta`, if any. */
|
|
usage?: StreamJsonUsage;
|
|
/** Provider session id from the stream-json `system` init line, if any. */
|
|
agentSessionId?: string | null;
|
|
}
|
|
|
|
export interface PtyDispatchOpts {
|
|
agent: string;
|
|
task: string;
|
|
worktreePath: string;
|
|
model?: string;
|
|
modeId?: string;
|
|
thinkingOptionId?: string;
|
|
installPath?: string;
|
|
signal?: AbortSignal;
|
|
log: FastifyBaseLogger;
|
|
/** Optional live event sink. When set, stdout is line-buffered + NDJSON-parsed
|
|
* and each AgentEvent is forwarded here as it arrives. Absent → opaque (old)
|
|
* behavior: stdout is accumulated and returned, no parsing. */
|
|
onEvent?: (e: AgentEvent) => void;
|
|
}
|
|
|
|
interface PtySpawnSpec {
|
|
binary: string;
|
|
args: string[];
|
|
stdin?: string;
|
|
}
|
|
|
|
function buildPtySpawnSpec(
|
|
agent: string,
|
|
task: string,
|
|
model?: string,
|
|
modeId?: string,
|
|
thinkingOptionId?: string,
|
|
installPath?: string,
|
|
): PtySpawnSpec | null {
|
|
const binary = installPath ?? agent;
|
|
|
|
switch (agent) {
|
|
case 'claude': {
|
|
// stream-json on -p requires --verbose (Claude Code rejects stream-json
|
|
// print mode without it). qwen needs no such flag.
|
|
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
|
if (model) args.push('--model', model);
|
|
if (modeId) args.push('--permission-mode', modeId);
|
|
if (thinkingOptionId) args.push('--effort', thinkingOptionId);
|
|
return { binary, args, stdin: task };
|
|
}
|
|
|
|
case 'qwen': {
|
|
const args = ['-p', task, '--output-format', 'stream-json'];
|
|
if (model) args.push('--model', model);
|
|
if (modeId) args.push('--approval-mode', modeId);
|
|
return { binary, args };
|
|
}
|
|
|
|
case 'opencode':
|
|
return {
|
|
binary,
|
|
args: model ? ['--model', model] : [],
|
|
stdin: task,
|
|
};
|
|
|
|
case 'goose':
|
|
return {
|
|
binary,
|
|
args: model ? ['run', '--text', task, '--model', model] : ['run', '--text', task],
|
|
};
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
|
|
const { agent, task, worktreePath, model, modeId, thinkingOptionId, installPath, signal, log, onEvent } = opts;
|
|
|
|
const cmd = buildPtySpawnSpec(agent, task, model, modeId, thinkingOptionId, installPath);
|
|
if (!cmd) {
|
|
return {
|
|
exitCode: 1,
|
|
stdout: '',
|
|
stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`,
|
|
streamed: false,
|
|
};
|
|
}
|
|
|
|
log.info({ agent, binary: cmd.binary, worktreePath, modeId }, 'pty-dispatch: starting');
|
|
|
|
return new Promise<DispatchResult>((resolve, reject) => {
|
|
const child = spawn(cmd.binary, cmd.args, {
|
|
cwd: worktreePath,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env },
|
|
});
|
|
|
|
if (cmd.stdin) {
|
|
child.stdin!.write(cmd.stdin);
|
|
}
|
|
child.stdin!.end();
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let killed = false;
|
|
|
|
// Live NDJSON parsing (only when a sink is supplied). Line-buffer: split on
|
|
// '\n', dispatch complete lines, hold the partial tail until the next chunk.
|
|
const parser = onEvent ? makeStreamJsonParser() : null;
|
|
let lineBuf = '';
|
|
let streamed = false;
|
|
const feedLine = (line: string): void => {
|
|
if (!parser || !onEvent) return;
|
|
for (const e of parser.push(line)) {
|
|
streamed = true;
|
|
onEvent(e);
|
|
}
|
|
};
|
|
|
|
child.stdout!.on('data', (chunk: Buffer) => {
|
|
const text = chunk.toString();
|
|
stdout += text;
|
|
if (!parser) return;
|
|
lineBuf += text;
|
|
let nl = lineBuf.indexOf('\n');
|
|
while (nl !== -1) {
|
|
const line = lineBuf.slice(0, nl);
|
|
lineBuf = lineBuf.slice(nl + 1);
|
|
feedLine(line);
|
|
nl = lineBuf.indexOf('\n');
|
|
}
|
|
});
|
|
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
|
|
|
const cleanup = () => {
|
|
if (!killed) {
|
|
killed = true;
|
|
child.kill('SIGTERM');
|
|
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
|
}
|
|
};
|
|
|
|
if (signal) {
|
|
if (signal.aborted) {
|
|
cleanup();
|
|
resolve({ exitCode: 130, stdout: '', stderr: 'Aborted before start', streamed: false });
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', cleanup, { once: true });
|
|
}
|
|
|
|
child.on('close', (code) => {
|
|
if (signal) signal.removeEventListener('abort', cleanup);
|
|
// Flush any final line with no trailing newline.
|
|
if (lineBuf.trim()) feedLine(lineBuf);
|
|
lineBuf = '';
|
|
log.info({ agent, exitCode: code, streamed }, 'pty-dispatch: completed');
|
|
resolve({
|
|
exitCode: code ?? 1,
|
|
stdout,
|
|
stderr,
|
|
streamed,
|
|
usage: parser?.usage(),
|
|
agentSessionId: parser?.sessionId() ?? null,
|
|
});
|
|
});
|
|
|
|
child.on('error', (err) => {
|
|
if (signal) signal.removeEventListener('abort', cleanup);
|
|
log.error({ agent, err: err.message }, 'pty-dispatch: spawn error');
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|