@expo/code-review-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/build/cli.js +54 -0
- package/build/commands/ci.js +130 -0
- package/build/commands/dismiss.js +97 -0
- package/build/commands/doctor.js +81 -0
- package/build/commands/init.js +82 -0
- package/build/commands/review.js +191 -0
- package/build/config/load.js +205 -0
- package/build/config/schema.js +65 -0
- package/build/core/auth.js +102 -0
- package/build/core/coordinator.js +24 -0
- package/build/core/diff.js +86 -0
- package/build/core/exec.js +61 -0
- package/build/core/log.js +10 -0
- package/build/core/noise.js +186 -0
- package/build/core/opencode.js +412 -0
- package/build/core/prompts.js +288 -0
- package/build/core/render.js +153 -0
- package/build/core/review.js +550 -0
- package/build/core/router.js +33 -0
- package/build/core/schema.js +107 -0
- package/build/core/suppress.js +60 -0
- package/build/core/tools.js +16 -0
- package/build/core/util.js +11 -0
- package/build/core/verify.js +93 -0
- package/build/reporters/github.js +166 -0
- package/build/reporters/reporter.js +1 -0
- package/build/reporters/terminal.js +93 -0
- package/build/sources/github-pr.js +36 -0
- package/build/sources/local-git.js +107 -0
- package/build/sources/source.js +1 -0
- package/package.json +43 -0
- package/templates/agents/consistency.md +53 -0
- package/templates/agents/correctness.md +32 -0
- package/templates/agents/security.md +51 -0
- package/templates/config.jsonc +44 -0
- package/templates/coordinator.md +62 -0
- package/templates/shared.md +79 -0
- package/templates/workflow.yml +43 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { createOpencode } from '@opencode-ai/sdk';
|
|
2
|
+
import { toolMap } from './tools.js';
|
|
3
|
+
import { sleep } from './util.js';
|
|
4
|
+
/** Sum token usage across attempts (for per-task/run totals). */
|
|
5
|
+
export function addTokenUsage(into, from) {
|
|
6
|
+
if (!from) {
|
|
7
|
+
return into;
|
|
8
|
+
}
|
|
9
|
+
into.input = (into.input ?? 0) + (from.input ?? 0);
|
|
10
|
+
into.output = (into.output ?? 0) + (from.output ?? 0);
|
|
11
|
+
into.reasoning = (into.reasoning ?? 0) + (from.reasoning ?? 0);
|
|
12
|
+
into.cache = {
|
|
13
|
+
read: (into.cache?.read ?? 0) + (from.cache?.read ?? 0),
|
|
14
|
+
write: (into.cache?.write ?? 0) + (from.cache?.write ?? 0),
|
|
15
|
+
};
|
|
16
|
+
return into;
|
|
17
|
+
}
|
|
18
|
+
// The coordinator consolidates findings; it needs no repo tools.
|
|
19
|
+
const COORDINATOR_TOOLS = toolMap([]);
|
|
20
|
+
// Agent id for the single combined cross-cutting pass (see review.ts). It MUST be
|
|
21
|
+
// defined here so OpenCode uses this restricted tool set — otherwise the model
|
|
22
|
+
// falls back to a default agent with full tools and crawls the whole repo, which
|
|
23
|
+
// is why the cross-file pass used to wander for its entire time budget.
|
|
24
|
+
export const CROSS_CUTTING_AGENT = 'cross-cutting';
|
|
25
|
+
// Deliberately NO `glob`/`list`: the cross-file pass is given the changed files'
|
|
26
|
+
// patch paths already, and directory crawling is exactly what made it wander into
|
|
27
|
+
// unrelated packages. `read` (open a known file) + `grep` (find a cross-reference
|
|
28
|
+
// among the changed files) are enough to trace interactions.
|
|
29
|
+
const CROSS_CUTTING_TOOLS = toolMap(['read', 'grep']);
|
|
30
|
+
// Verifies a finding by re-reading the actual file (adversarial refute pass). Same
|
|
31
|
+
// restricted tool set — it opens the cited file and checks the claim.
|
|
32
|
+
export const VERIFIER_AGENT = 'verifier';
|
|
33
|
+
const VERIFIER_TOOLS = toolMap(['read', 'grep']);
|
|
34
|
+
/** Build the inline OpenCode config (agents + coordinator) from a repo config. */
|
|
35
|
+
export function buildOpencodeConfig(config) {
|
|
36
|
+
const agent = {};
|
|
37
|
+
for (const reviewer of config.agents) {
|
|
38
|
+
agent[reviewer.id] = {
|
|
39
|
+
description: `${reviewer.id} reviewer`,
|
|
40
|
+
mode: 'all',
|
|
41
|
+
model: reviewer.model,
|
|
42
|
+
temperature: reviewer.temperature,
|
|
43
|
+
prompt: `You are the ${reviewer.id} code reviewer. Follow the user message exactly and return only the requested JSON.`,
|
|
44
|
+
tools: reviewer.tools,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
agent[CROSS_CUTTING_AGENT] = {
|
|
48
|
+
description: 'Cross-file reviewer: issues spanning multiple changed files.',
|
|
49
|
+
mode: 'all',
|
|
50
|
+
// Use the default reviewing model (agents share it unless overridden).
|
|
51
|
+
model: config.agents[0]?.model ?? config.coordinator.model,
|
|
52
|
+
temperature: config.agents[0]?.temperature ?? 0.1,
|
|
53
|
+
prompt: 'You are the cross-file code reviewer. Follow the user message exactly and return only the requested JSON.',
|
|
54
|
+
tools: CROSS_CUTTING_TOOLS,
|
|
55
|
+
};
|
|
56
|
+
agent[VERIFIER_AGENT] = {
|
|
57
|
+
description: 'Verifies a finding against the real file (adversarial refute pass).',
|
|
58
|
+
mode: 'all',
|
|
59
|
+
model: config.agents[0]?.model ?? config.coordinator.model,
|
|
60
|
+
temperature: config.agents[0]?.temperature ?? 0.1,
|
|
61
|
+
prompt: 'You verify code-review findings against the actual source. Follow the user message exactly and return only the requested JSON.',
|
|
62
|
+
tools: VERIFIER_TOOLS,
|
|
63
|
+
};
|
|
64
|
+
agent['coordinator'] = {
|
|
65
|
+
description: 'Consolidates specialist findings into one decision.',
|
|
66
|
+
mode: 'all',
|
|
67
|
+
model: config.coordinator.model,
|
|
68
|
+
temperature: config.coordinator.temperature,
|
|
69
|
+
prompt: 'You are the review coordinator. Follow the user message exactly and return only the requested JSON.',
|
|
70
|
+
tools: COORDINATOR_TOOLS,
|
|
71
|
+
};
|
|
72
|
+
return { $schema: 'https://opencode.ai/config.json', agent };
|
|
73
|
+
}
|
|
74
|
+
/** hey-api style responses come back as { data, error }; unwrap or throw. */
|
|
75
|
+
function unwrap(res) {
|
|
76
|
+
if (res && typeof res === 'object' && ('data' in res || 'error' in res)) {
|
|
77
|
+
if (res.error) {
|
|
78
|
+
throw new Error(typeof res.error === 'string' ? res.error : JSON.stringify(res.error));
|
|
79
|
+
}
|
|
80
|
+
return res.data;
|
|
81
|
+
}
|
|
82
|
+
return res;
|
|
83
|
+
}
|
|
84
|
+
/** Start an in-process OpenCode server with the given inline config. */
|
|
85
|
+
export async function startOpencode(config) {
|
|
86
|
+
const { client, server } = await createOpencode({
|
|
87
|
+
hostname: '127.0.0.1',
|
|
88
|
+
config: config,
|
|
89
|
+
});
|
|
90
|
+
return { client, url: server.url, close: () => server.close() };
|
|
91
|
+
}
|
|
92
|
+
const POLL_INTERVAL_MS = 1000;
|
|
93
|
+
// Emit a "still working" heartbeat if this long passes with no tool activity, so
|
|
94
|
+
// a long model-thinking stretch doesn't look hung in the logs.
|
|
95
|
+
const HEARTBEAT_MS = 45_000;
|
|
96
|
+
// Default per-attempt ceiling. Focused chunk passes finish well under this; the
|
|
97
|
+
// cross-cutting pass is given more (see review.ts). Hitting the cap does NOT mean
|
|
98
|
+
// "retry" — we first interrupt the run and ask the agent to return whatever
|
|
99
|
+
// findings it already has (finalizeOnTimeout), and only fail if that also runs
|
|
100
|
+
// over. Callers must treat AgentTimeoutError as "abandon", never "retry".
|
|
101
|
+
const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
|
|
102
|
+
// Extra budget for the "stop and summarize what you have" finalization prompt.
|
|
103
|
+
const FINALIZE_WAIT_MS = 90 * 1000;
|
|
104
|
+
const FINALIZE_PROMPT = 'You have reached your time budget. STOP investigating now — do NOT read, grep, ' +
|
|
105
|
+
'glob, list, or open any more files, and do not call any tools. Based ONLY on ' +
|
|
106
|
+
'what you have already examined, reply with the single JSON object exactly as ' +
|
|
107
|
+
'specified in your instructions, containing whatever findings you are already ' +
|
|
108
|
+
'confident about. If you have nothing solid, return an empty findings array.';
|
|
109
|
+
/**
|
|
110
|
+
* Internal signal that a poll loop passed its deadline. Carries the best-effort
|
|
111
|
+
* cost/tokens of the in-progress (never-completed) assistant message so a
|
|
112
|
+
* timed-out task's spend isn't dropped from the run's metrics.
|
|
113
|
+
*/
|
|
114
|
+
class DeadlineReached extends Error {
|
|
115
|
+
cost;
|
|
116
|
+
tokens;
|
|
117
|
+
constructor(cost = 0, tokens) {
|
|
118
|
+
super('deadline reached');
|
|
119
|
+
this.cost = cost;
|
|
120
|
+
this.tokens = tokens;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const DEADLINE_SENTINEL = Symbol('deadline');
|
|
124
|
+
/**
|
|
125
|
+
* Race a promise against the poll deadline. Without this, a stalled message fetch
|
|
126
|
+
* (a wedged/overloaded OpenCode server) blocks the poll loop past its deadline,
|
|
127
|
+
* because the deadline is only re-checked at the top of the loop — so a single
|
|
128
|
+
* hung fetch could let a task run minutes past its time cap. Returns the sentinel
|
|
129
|
+
* the instant the deadline passes, so the loop enforces the cap even mid-fetch.
|
|
130
|
+
*/
|
|
131
|
+
async function raceDeadline(work, deadline) {
|
|
132
|
+
const remaining = deadline - Date.now();
|
|
133
|
+
if (remaining <= 0) {
|
|
134
|
+
return DEADLINE_SENTINEL;
|
|
135
|
+
}
|
|
136
|
+
let timer;
|
|
137
|
+
const timeout = new Promise(resolve => {
|
|
138
|
+
timer = setTimeout(() => resolve(DEADLINE_SENTINEL), remaining);
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
return await Promise.race([work, timeout]);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Thrown when an agent exceeds its time budget even after being asked to wrap up.
|
|
149
|
+
* Callers MUST treat this as "abandon this task" — retrying just repeats the same
|
|
150
|
+
* non-convergent run. Carries the cost/tokens burned so the caller can still
|
|
151
|
+
* account for the (abandoned) work.
|
|
152
|
+
*/
|
|
153
|
+
export class AgentTimeoutError extends Error {
|
|
154
|
+
cost;
|
|
155
|
+
tokens;
|
|
156
|
+
constructor(agent, minutes, cost = 0, tokens) {
|
|
157
|
+
super(`Agent "${agent}" timed out after ${minutes} minutes (including finalize)`);
|
|
158
|
+
this.name = 'AgentTimeoutError';
|
|
159
|
+
this.cost = cost;
|
|
160
|
+
this.tokens = tokens;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run a single prompt against the named agent in a fresh session and return the
|
|
165
|
+
* concatenated assistant text.
|
|
166
|
+
*
|
|
167
|
+
* Uses the async prompt + polling rather than the synchronous `session.prompt`:
|
|
168
|
+
* a large diff can keep an agent busy well past undici's 300s headers timeout,
|
|
169
|
+
* which would kill a long-held synchronous request. promptAsync returns
|
|
170
|
+
* immediately and we poll the message list (quick GETs) until the assistant
|
|
171
|
+
* message completes.
|
|
172
|
+
*/
|
|
173
|
+
export async function promptAgent(handle, args) {
|
|
174
|
+
const session = unwrap(await handle.client.session.create({ body: { title: args.title } }));
|
|
175
|
+
const reportedTools = new Set();
|
|
176
|
+
await sendSessionPrompt(handle, session.id, {
|
|
177
|
+
agent: args.agent,
|
|
178
|
+
system: args.system,
|
|
179
|
+
text: args.text,
|
|
180
|
+
});
|
|
181
|
+
const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
182
|
+
try {
|
|
183
|
+
return await pollForCompletion(handle, session.id, {
|
|
184
|
+
agent: args.agent,
|
|
185
|
+
fromIndex: 0,
|
|
186
|
+
deadline: Date.now() + maxWaitMs,
|
|
187
|
+
onActivity: args.onActivity,
|
|
188
|
+
reportedTools,
|
|
189
|
+
maxToolCalls: args.maxToolCalls,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (!(error instanceof DeadlineReached)) {
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
// Cost/tokens burned during the (never-completed) investigation, so the
|
|
197
|
+
// finalize reply or the abandon path still accounts for them.
|
|
198
|
+
const spentCost = error.cost;
|
|
199
|
+
const spentTokens = error.tokens;
|
|
200
|
+
// Time budget hit. Interrupt the wandering run first.
|
|
201
|
+
await abortQuietly(handle, session.id);
|
|
202
|
+
if (!args.finalizeOnTimeout) {
|
|
203
|
+
throw new AgentTimeoutError(args.agent, Math.round(maxWaitMs / 60000), spentCost, spentTokens);
|
|
204
|
+
}
|
|
205
|
+
// Soft landing: ask the (same, context-carrying) session to return whatever
|
|
206
|
+
// it has now. Only messages after this point count as the answer.
|
|
207
|
+
const baseline = (await fetchMessages(handle, session.id)).length;
|
|
208
|
+
args.onActivity?.('time budget reached — asking for findings so far');
|
|
209
|
+
await sendSessionPrompt(handle, session.id, {
|
|
210
|
+
agent: args.agent,
|
|
211
|
+
system: args.system,
|
|
212
|
+
text: FINALIZE_PROMPT,
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
const result = await pollForCompletion(handle, session.id, {
|
|
216
|
+
agent: args.agent,
|
|
217
|
+
fromIndex: baseline,
|
|
218
|
+
deadline: Date.now() + FINALIZE_WAIT_MS,
|
|
219
|
+
onActivity: args.onActivity,
|
|
220
|
+
reportedTools,
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
...result,
|
|
224
|
+
cost: result.cost + spentCost,
|
|
225
|
+
tokens: addTokenUsage(addTokenUsage({}, spentTokens), result.tokens),
|
|
226
|
+
truncated: true,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
catch (finalizeError) {
|
|
230
|
+
if (finalizeError instanceof DeadlineReached) {
|
|
231
|
+
await abortQuietly(handle, session.id);
|
|
232
|
+
throw new AgentTimeoutError(args.agent, Math.round((maxWaitMs + FINALIZE_WAIT_MS) / 60000), spentCost + finalizeError.cost, addTokenUsage(addTokenUsage({}, spentTokens), finalizeError.tokens));
|
|
233
|
+
}
|
|
234
|
+
throw finalizeError;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const CORRECTIVE = '\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single ' +
|
|
239
|
+
'JSON object described above — no prose, no code fences, no partial output.';
|
|
240
|
+
// Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
|
|
241
|
+
// it should return almost immediately.
|
|
242
|
+
const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
|
|
243
|
+
/**
|
|
244
|
+
* Prompt an agent and parse its reply. On a JSON-parse failure, first retry in
|
|
245
|
+
* the SAME session: the model still holds all the file context it read, so the
|
|
246
|
+
* corrective is a cache read and a cheap re-emit — and better for recall than
|
|
247
|
+
* re-investigating from scratch (the usual failure is a truncated/malformed reply
|
|
248
|
+
* after a sound investigation). Only if that also fails do we fall back to a fresh
|
|
249
|
+
* session as a clean-slate last resort. A timeout is NOT a parse failure:
|
|
250
|
+
* promptAgent throws AgentTimeoutError, which propagates so the caller abandons
|
|
251
|
+
* the task instead of retrying a non-convergent run.
|
|
252
|
+
*/
|
|
253
|
+
export async function promptAndParse(handle, args, parse) {
|
|
254
|
+
let cost = 0;
|
|
255
|
+
let truncated = false;
|
|
256
|
+
const tokens = {};
|
|
257
|
+
const record = (result) => {
|
|
258
|
+
cost += result.cost;
|
|
259
|
+
truncated = truncated || (result.truncated ?? false);
|
|
260
|
+
addTokenUsage(tokens, result.tokens);
|
|
261
|
+
};
|
|
262
|
+
const first = await promptAgent(handle, args);
|
|
263
|
+
record(first);
|
|
264
|
+
try {
|
|
265
|
+
return { value: parse(first.text), cost, truncated, tokens };
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Same-session corrective retry: send the nudge as a follow-up and wait for
|
|
269
|
+
// the NEW assistant message (past the current message count).
|
|
270
|
+
try {
|
|
271
|
+
const baseline = (await fetchMessages(handle, first.sessionID)).length;
|
|
272
|
+
await sendSessionPrompt(handle, first.sessionID, {
|
|
273
|
+
agent: args.agent,
|
|
274
|
+
system: args.system,
|
|
275
|
+
text: CORRECTIVE,
|
|
276
|
+
});
|
|
277
|
+
const retry = await pollForCompletion(handle, first.sessionID, {
|
|
278
|
+
agent: args.agent,
|
|
279
|
+
fromIndex: baseline,
|
|
280
|
+
deadline: Date.now() + CORRECTIVE_WAIT_MS,
|
|
281
|
+
onActivity: args.onActivity,
|
|
282
|
+
reportedTools: new Set(),
|
|
283
|
+
});
|
|
284
|
+
record(retry);
|
|
285
|
+
return { value: parse(retry.text), cost, truncated, tokens };
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
// Fresh-session last resort: a clean slate for a genuinely confused run.
|
|
289
|
+
const fresh = await promptAgent(handle, {
|
|
290
|
+
...args,
|
|
291
|
+
text: args.text + CORRECTIVE,
|
|
292
|
+
finalizeOnTimeout: false,
|
|
293
|
+
});
|
|
294
|
+
record(fresh);
|
|
295
|
+
try {
|
|
296
|
+
return { value: parse(fresh.text), cost, truncated, tokens };
|
|
297
|
+
}
|
|
298
|
+
catch (finalError) {
|
|
299
|
+
throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async function fetchMessages(handle, sessionID) {
|
|
305
|
+
return unwrap(await handle.client.session.messages({ path: { id: sessionID } }));
|
|
306
|
+
}
|
|
307
|
+
async function sendSessionPrompt(handle, sessionID, args) {
|
|
308
|
+
unwrap(await handle.client.session.promptAsync({
|
|
309
|
+
path: { id: sessionID },
|
|
310
|
+
body: {
|
|
311
|
+
agent: args.agent,
|
|
312
|
+
system: args.system,
|
|
313
|
+
parts: [{ type: 'text', text: args.text }],
|
|
314
|
+
},
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
async function abortQuietly(handle, sessionID) {
|
|
318
|
+
try {
|
|
319
|
+
await handle.client.session.abort({ path: { id: sessionID } });
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// best effort — the session may already be gone
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Poll a session for the first assistant message at or after `fromIndex` to
|
|
327
|
+
* complete. `fromIndex` lets a follow-up prompt (finalize, corrective retry)
|
|
328
|
+
* skip the earlier completed message and wait for the NEW reply instead. Throws
|
|
329
|
+
* DeadlineReached once `deadline` passes.
|
|
330
|
+
*/
|
|
331
|
+
async function pollForCompletion(handle, sessionID, opts) {
|
|
332
|
+
// Best-effort usage of the in-progress assistant message, so a task that times
|
|
333
|
+
// out before completing still contributes its spend to the run's metrics.
|
|
334
|
+
let lastCost = 0;
|
|
335
|
+
let lastTokens;
|
|
336
|
+
const startedAt = Date.now();
|
|
337
|
+
let lastEmitAt = startedAt;
|
|
338
|
+
const emit = (line) => {
|
|
339
|
+
lastEmitAt = Date.now();
|
|
340
|
+
opts.onActivity?.(line);
|
|
341
|
+
};
|
|
342
|
+
for (;;) {
|
|
343
|
+
if (Date.now() > opts.deadline) {
|
|
344
|
+
throw new DeadlineReached(lastCost, lastTokens);
|
|
345
|
+
}
|
|
346
|
+
await sleep(POLL_INTERVAL_MS);
|
|
347
|
+
// Heartbeat if nothing has been reported for a while (e.g. the model is
|
|
348
|
+
// reasoning without calling tools), so a long pass doesn't look hung.
|
|
349
|
+
if (opts.onActivity && Date.now() - lastEmitAt >= HEARTBEAT_MS) {
|
|
350
|
+
emit(`still working… ${Math.round((Date.now() - startedAt) / 1000)}s elapsed`);
|
|
351
|
+
}
|
|
352
|
+
// Bound the fetch by the deadline: a stalled server can't push the task past
|
|
353
|
+
// its time cap (the overshoot we saw when the server was overloaded).
|
|
354
|
+
const messages = await raceDeadline(fetchMessages(handle, sessionID), opts.deadline);
|
|
355
|
+
if (messages === DEADLINE_SENTINEL) {
|
|
356
|
+
throw new DeadlineReached(lastCost, lastTokens);
|
|
357
|
+
}
|
|
358
|
+
const recent = messages.slice(opts.fromIndex);
|
|
359
|
+
const assistant = [...recent].reverse().find(message => message.info?.role === 'assistant');
|
|
360
|
+
if (!assistant) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (typeof assistant.info?.cost === 'number') {
|
|
364
|
+
lastCost = assistant.info.cost;
|
|
365
|
+
}
|
|
366
|
+
if (assistant.info?.tokens) {
|
|
367
|
+
lastTokens = assistant.info.tokens;
|
|
368
|
+
}
|
|
369
|
+
// Track each distinct tool call once (for the tool-call cap) and, the first
|
|
370
|
+
// time it starts, emit a live line so a long run shows what the agent is doing.
|
|
371
|
+
for (const part of assistant.parts ?? []) {
|
|
372
|
+
if (part?.type !== 'tool') {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const key = part.callID ?? part.id;
|
|
376
|
+
const status = part.state?.status;
|
|
377
|
+
if (key && status && status !== 'pending' && !opts.reportedTools.has(key)) {
|
|
378
|
+
opts.reportedTools.add(key);
|
|
379
|
+
if (opts.onActivity) {
|
|
380
|
+
const tool = part.tool ?? 'tool';
|
|
381
|
+
const title = part.state?.title;
|
|
382
|
+
emit(title ? `${tool}: ${title}` : tool);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (assistant.info?.error) {
|
|
387
|
+
throw new Error(`Agent "${opts.agent}" returned an error: ${JSON.stringify(assistant.info.error)}`);
|
|
388
|
+
}
|
|
389
|
+
// A completed message ALWAYS wins — return it regardless of tool count; the
|
|
390
|
+
// work is done, so there's nothing to finalize.
|
|
391
|
+
if (assistant.info?.time?.completed != null) {
|
|
392
|
+
const text = (assistant.parts ?? [])
|
|
393
|
+
.filter(part => part?.type === 'text' && typeof part.text === 'string')
|
|
394
|
+
.map(part => part.text)
|
|
395
|
+
.join('\n')
|
|
396
|
+
.trim();
|
|
397
|
+
return {
|
|
398
|
+
text,
|
|
399
|
+
cost: assistant.info?.cost ?? 0,
|
|
400
|
+
sessionID,
|
|
401
|
+
tokens: assistant.info?.tokens,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
// Still in progress: enforce the tool-call cap. An agent that has made this
|
|
405
|
+
// many tool calls without finishing is wandering, not converging — trip the
|
|
406
|
+
// same soft-landing as the wall-clock deadline so it returns what it has.
|
|
407
|
+
if (opts.maxToolCalls != null && opts.reportedTools.size > opts.maxToolCalls) {
|
|
408
|
+
emit(`made ${opts.reportedTools.size} tool calls — wrapping up to stay on budget`);
|
|
409
|
+
throw new DeadlineReached(lastCost, lastTokens);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|