@expo/code-review-cli 0.3.0 → 0.5.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/README.md +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
package/build/core/opencode.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createOpencode } from "@opencode-ai/sdk";
|
|
4
|
+
import { toolMap } from "./tools.js";
|
|
5
|
+
import { errorMessage, sleep } from "./util.js";
|
|
4
6
|
/** Sum token usage across attempts (for per-task/run totals). */
|
|
5
7
|
export function addTokenUsage(into, from) {
|
|
6
8
|
if (!from) {
|
|
@@ -17,27 +19,32 @@ export function addTokenUsage(into, from) {
|
|
|
17
19
|
}
|
|
18
20
|
// The coordinator consolidates findings; it needs no repo tools.
|
|
19
21
|
const COORDINATOR_TOOLS = toolMap([]);
|
|
22
|
+
// Every tool disabled, passed per-REQUEST (not per-agent) to make a "reply now,
|
|
23
|
+
// don't investigate" prompt physically unable to call tools. A prompt-level plea is
|
|
24
|
+
// not enough: in eas-cli#4084 the finalize ("stop and return what you have") reply
|
|
25
|
+
// opened 7 more files and then blew its window, losing the whole pass.
|
|
26
|
+
const NO_TOOLS = toolMap([]);
|
|
20
27
|
// Agent id for the single combined cross-cutting pass (see review.ts). It MUST be
|
|
21
28
|
// defined here so OpenCode uses this restricted tool set — otherwise the model
|
|
22
29
|
// falls back to a default agent with full tools and crawls the whole repo, which
|
|
23
30
|
// is why the cross-file pass used to wander for its entire time budget.
|
|
24
|
-
export const CROSS_CUTTING_AGENT =
|
|
31
|
+
export const CROSS_CUTTING_AGENT = "cross-cutting";
|
|
25
32
|
// Deliberately NO `glob`/`list`: the cross-file pass is given the changed files'
|
|
26
33
|
// patch paths already, and directory crawling is exactly what made it wander into
|
|
27
34
|
// unrelated packages. `read` (open a known file) + `grep` (find a cross-reference
|
|
28
35
|
// among the changed files) are enough to trace interactions.
|
|
29
|
-
const CROSS_CUTTING_TOOLS = toolMap([
|
|
36
|
+
const CROSS_CUTTING_TOOLS = toolMap(["read", "grep"]);
|
|
30
37
|
// Verifies a finding by re-reading the actual file (adversarial refute pass). Same
|
|
31
38
|
// restricted tool set — it opens the cited file and checks the claim.
|
|
32
|
-
export const VERIFIER_AGENT =
|
|
33
|
-
const VERIFIER_TOOLS = toolMap([
|
|
39
|
+
export const VERIFIER_AGENT = "verifier";
|
|
40
|
+
const VERIFIER_TOOLS = toolMap(["read", "grep"]);
|
|
34
41
|
/** Build the inline OpenCode config (agents + coordinator) from a repo config. */
|
|
35
42
|
export function buildOpencodeConfig(config) {
|
|
36
43
|
const agent = {};
|
|
37
44
|
for (const reviewer of config.agents) {
|
|
38
45
|
agent[reviewer.id] = {
|
|
39
46
|
description: `${reviewer.id} reviewer`,
|
|
40
|
-
mode:
|
|
47
|
+
mode: "all",
|
|
41
48
|
model: reviewer.model,
|
|
42
49
|
temperature: reviewer.temperature,
|
|
43
50
|
prompt: `You are the ${reviewer.id} code reviewer. Follow the user message exactly and return only the requested JSON.`,
|
|
@@ -45,50 +52,261 @@ export function buildOpencodeConfig(config) {
|
|
|
45
52
|
};
|
|
46
53
|
}
|
|
47
54
|
agent[CROSS_CUTTING_AGENT] = {
|
|
48
|
-
description:
|
|
49
|
-
mode:
|
|
55
|
+
description: "Cross-file reviewer: issues spanning multiple changed files.",
|
|
56
|
+
mode: "all",
|
|
50
57
|
// Use the default reviewing model (agents share it unless overridden).
|
|
51
58
|
model: config.agents[0]?.model ?? config.coordinator.model,
|
|
52
59
|
temperature: config.agents[0]?.temperature ?? 0.1,
|
|
53
|
-
prompt:
|
|
60
|
+
prompt: "You are the cross-file code reviewer. Follow the user message exactly and return only the requested JSON.",
|
|
54
61
|
tools: CROSS_CUTTING_TOOLS,
|
|
55
62
|
};
|
|
56
63
|
agent[VERIFIER_AGENT] = {
|
|
57
|
-
description:
|
|
58
|
-
mode:
|
|
64
|
+
description: "Verifies a finding against the real file (adversarial refute pass).",
|
|
65
|
+
mode: "all",
|
|
59
66
|
model: config.agents[0]?.model ?? config.coordinator.model,
|
|
60
67
|
temperature: config.agents[0]?.temperature ?? 0.1,
|
|
61
|
-
prompt:
|
|
68
|
+
prompt: "You verify code-review findings against the actual source. Follow the user message exactly and return only the requested JSON.",
|
|
62
69
|
tools: VERIFIER_TOOLS,
|
|
63
70
|
};
|
|
64
|
-
agent[
|
|
65
|
-
description:
|
|
66
|
-
mode:
|
|
71
|
+
agent["coordinator"] = {
|
|
72
|
+
description: "Consolidates specialist findings into one decision.",
|
|
73
|
+
mode: "all",
|
|
67
74
|
model: config.coordinator.model,
|
|
68
75
|
temperature: config.coordinator.temperature,
|
|
69
|
-
prompt:
|
|
76
|
+
prompt: "You are the review coordinator. Follow the user message exactly and return only the requested JSON.",
|
|
70
77
|
tools: COORDINATOR_TOOLS,
|
|
71
78
|
};
|
|
72
|
-
|
|
79
|
+
// Synthesize a provider block for each upstream-alias auth entry, so one
|
|
80
|
+
// upstream can be reached with two credentials at once (e.g. "openai" on a
|
|
81
|
+
// ChatGPT/Codex subscription for the default models, plus an "openai-api"
|
|
82
|
+
// alias holding a metered API key for pro-tier models the subscription
|
|
83
|
+
// doesn't offer). The alias's model list is exactly the ids the roster
|
|
84
|
+
// references under that provider — OpenCode needs custom providers' models
|
|
85
|
+
// declared, and declaring only what's used keeps the preflight meaningful.
|
|
86
|
+
const provider = {};
|
|
87
|
+
const referencedModels = [
|
|
88
|
+
...config.agents.map((reviewer) => reviewer.model),
|
|
89
|
+
config.coordinator.model,
|
|
90
|
+
];
|
|
91
|
+
for (const entry of config.auth) {
|
|
92
|
+
if (!entry.upstream || !entry.tokenEnv) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const models = {};
|
|
96
|
+
for (const id of referencedModels) {
|
|
97
|
+
const slash = id.indexOf("/");
|
|
98
|
+
if (slash > 0 && id.slice(0, slash) === entry.provider) {
|
|
99
|
+
models[id.slice(slash + 1)] = {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
provider[entry.provider] = {
|
|
103
|
+
npm: entry.upstream === "openai"
|
|
104
|
+
? "@ai-sdk/openai"
|
|
105
|
+
: entry.upstream === "anthropic"
|
|
106
|
+
? "@ai-sdk/anthropic"
|
|
107
|
+
: "@ai-sdk/openai-compatible",
|
|
108
|
+
name: entry.provider,
|
|
109
|
+
options: { apiKey: `{env:${entry.tokenEnv}}` },
|
|
110
|
+
models,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
$schema: "https://opencode.ai/config.json",
|
|
115
|
+
agent,
|
|
116
|
+
...(Object.keys(provider).length > 0 ? { provider } : {}),
|
|
117
|
+
};
|
|
73
118
|
}
|
|
74
119
|
/** hey-api style responses come back as { data, error }; unwrap or throw. */
|
|
75
120
|
function unwrap(res) {
|
|
76
|
-
if (res && typeof res ===
|
|
121
|
+
if (res && typeof res === "object" && ("data" in res || "error" in res)) {
|
|
77
122
|
if (res.error) {
|
|
78
|
-
throw new Error(typeof res.error ===
|
|
123
|
+
throw new Error(typeof res.error === "string" ? res.error : JSON.stringify(res.error));
|
|
79
124
|
}
|
|
80
125
|
return res.data;
|
|
81
126
|
}
|
|
82
127
|
return res;
|
|
83
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Directory holding the `opencode` binary from OUR dependency tree, or null if it
|
|
131
|
+
* can't be resolved.
|
|
132
|
+
*
|
|
133
|
+
* The SDK spawns the server with a bare `launch("opencode", …)`, i.e. whatever comes
|
|
134
|
+
* first on PATH. That silently couples every run to the machine's global install:
|
|
135
|
+
* in CI, `npx -p @expo/code-review-cli` puts the temp prefix's `.bin` first and the
|
|
136
|
+
* pinned version wins, but on a developer machine an older global `opencode` shadows
|
|
137
|
+
* it and the pair drifts (a 1.18.1 CLI against a 1.18.4 SDK surfaced as
|
|
138
|
+
* `ProviderModelNotFoundError: Model not found: anthropic/claude-opus-4-8` — the CLI
|
|
139
|
+
* resolving a model id the SDK considered valid). Prepending this directory to PATH
|
|
140
|
+
* makes the version we declare in package.json the version we actually run.
|
|
141
|
+
*/
|
|
142
|
+
function bundledOpencodeBinDir() {
|
|
143
|
+
try {
|
|
144
|
+
const require = createRequire(import.meta.url);
|
|
145
|
+
// <…>/node_modules/opencode-ai/package.json → <…>/node_modules/.bin, which holds
|
|
146
|
+
// the correctly-named `opencode` shim (the package's own bin file is
|
|
147
|
+
// `opencode.exe`, so the package dir itself is NOT a usable PATH entry).
|
|
148
|
+
const pkg = require.resolve("opencode-ai/package.json");
|
|
149
|
+
return path.join(path.dirname(pkg), "..", ".bin");
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** The `opencode` the SDK will actually spawn: ours if resolvable, else PATH's. */
|
|
156
|
+
export function opencodeBinSource() {
|
|
157
|
+
const dir = bundledOpencodeBinDir();
|
|
158
|
+
return { dir, pinned: dir !== null };
|
|
159
|
+
}
|
|
84
160
|
/** Start an in-process OpenCode server with the given inline config. */
|
|
85
161
|
export async function startOpencode(config) {
|
|
162
|
+
// Make our pinned CLI win over any global install (see bundledOpencodeBinDir).
|
|
163
|
+
// The SDK takes no `env`, so PATH is the only lever; it spreads `process.env` at
|
|
164
|
+
// spawn time, so setting it here reaches the child.
|
|
165
|
+
const binDir = bundledOpencodeBinDir();
|
|
166
|
+
if (binDir) {
|
|
167
|
+
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
|
|
168
|
+
}
|
|
86
169
|
const { client, server } = await createOpencode({
|
|
87
|
-
hostname:
|
|
170
|
+
hostname: "127.0.0.1",
|
|
171
|
+
// Port 0 = let the OS pick a free one. The SDK defaults to a FIXED 4096 and reads
|
|
172
|
+
// the real URL back from the server's startup line, so the default meant any
|
|
173
|
+
// already-running opencode (a developer's own session is the common case) made
|
|
174
|
+
// every local run die with an opaque `ServeError`.
|
|
175
|
+
port: 0,
|
|
88
176
|
config: config,
|
|
89
177
|
});
|
|
90
178
|
return { client, url: server.url, close: () => server.close() };
|
|
91
179
|
}
|
|
180
|
+
/** `provider/model` as the server reported it, or undefined if it reported neither. */
|
|
181
|
+
export function formatModel(providerID, modelID) {
|
|
182
|
+
if (!providerID && !modelID) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
return `${providerID ?? "?"}/${modelID ?? "?"}`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Check configured model ids against what the server can resolve. Pure so the
|
|
189
|
+
* matching rules are testable without a live server.
|
|
190
|
+
*
|
|
191
|
+
* A wrong model id is a CONFIG error: it hits every pass identically, no retry or
|
|
192
|
+
* smaller scope can fix it, and the run should say so once instead of reporting N
|
|
193
|
+
* indistinguishable "pass failed" gaps (or, worse, burning the whole budget first).
|
|
194
|
+
*/
|
|
195
|
+
export function findUnknownModels(models, available,
|
|
196
|
+
/** Provider(s) we supplied a credential for, if any (see reason: "credential"). */
|
|
197
|
+
credentialedProvider) {
|
|
198
|
+
const credentialed = new Set(typeof credentialedProvider === "string"
|
|
199
|
+
? [credentialedProvider]
|
|
200
|
+
: (credentialedProvider ?? []));
|
|
201
|
+
const unknown = [];
|
|
202
|
+
for (const model of new Set(models)) {
|
|
203
|
+
// OpenCode model ids are `provider/model`; a model id may itself contain slashes
|
|
204
|
+
// (e.g. openrouter's `vendor/name`), so only the FIRST segment is the provider.
|
|
205
|
+
const slash = model.indexOf("/");
|
|
206
|
+
const providerID = slash === -1 ? model : model.slice(0, slash);
|
|
207
|
+
const modelID = slash === -1 ? "" : model.slice(slash + 1);
|
|
208
|
+
const providerModels = available[providerID];
|
|
209
|
+
if (!providerModels) {
|
|
210
|
+
unknown.push({
|
|
211
|
+
model,
|
|
212
|
+
// We configured this provider's credential and the server still doesn't
|
|
213
|
+
// offer it ⇒ the credential was refused, not the provider misnamed.
|
|
214
|
+
reason: credentialed.has(providerID) ? "credential" : "provider",
|
|
215
|
+
suggestions: Object.keys(available).sort(),
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!providerModels.includes(modelID)) {
|
|
220
|
+
// Suggest ids that share a prefix with what was asked for, else the whole list.
|
|
221
|
+
const stem = modelID.split(/[-/]/)[0] ?? "";
|
|
222
|
+
const near = stem ? providerModels.filter((id) => id.startsWith(stem)) : [];
|
|
223
|
+
unknown.push({
|
|
224
|
+
model,
|
|
225
|
+
reason: "model",
|
|
226
|
+
suggestions: (near.length > 0 ? near : providerModels).slice(0, 8).sort(),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return unknown;
|
|
231
|
+
}
|
|
232
|
+
/** Ask the running server which providers/models it can resolve. */
|
|
233
|
+
export async function fetchProviderModels(handle) {
|
|
234
|
+
const data = unwrap(await handle.client.config.providers());
|
|
235
|
+
const available = {};
|
|
236
|
+
for (const provider of data?.providers ?? []) {
|
|
237
|
+
if (provider?.id) {
|
|
238
|
+
available[provider.id] = Object.keys(provider.models ?? {});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return available;
|
|
242
|
+
}
|
|
243
|
+
/** Human-readable, copy-pasteable explanation of unresolvable model ids. */
|
|
244
|
+
export function formatUnknownModels(unknown, auths) {
|
|
245
|
+
// A refused credential is one fact about the run, not one per model: report it once
|
|
246
|
+
// and name the token to check, rather than repeating it for every configured model.
|
|
247
|
+
const refused = unknown.filter((entry) => entry.reason === "credential");
|
|
248
|
+
if (refused.length > 0) {
|
|
249
|
+
const refusedProvider = refused[0].model.split("/")[0];
|
|
250
|
+
const entries = auths ? (Array.isArray(auths) ? auths : [auths]) : [];
|
|
251
|
+
const auth = entries.find((entry) => entry.provider === refusedProvider) ?? entries[0];
|
|
252
|
+
const provider = auth?.provider ?? refusedProvider;
|
|
253
|
+
const tokenEnv = auth?.tokenEnv;
|
|
254
|
+
// Do NOT blame the token alone: the most common causes have nothing to do with
|
|
255
|
+
// the credential's validity (see below). An earlier version of this message sent
|
|
256
|
+
// us to re-issue two perfectly good tokens.
|
|
257
|
+
const oauth = auth?.mode === "oauth";
|
|
258
|
+
const deadOauth = oauth && provider === "anthropic";
|
|
259
|
+
return (`The OpenCode server does not offer the "${provider}" provider, even though this run ` +
|
|
260
|
+
`supplied a ${auth?.mode ?? "configured"} credential for it. OpenCode drops a provider whose ` +
|
|
261
|
+
`credential it could not use, which makes every ${provider} model look nonexistent: ` +
|
|
262
|
+
`${refused.map((entry) => entry.model).join(", ")}.\n` +
|
|
263
|
+
`The credential itself is often FINE. Check these in order:\n` +
|
|
264
|
+
(deadOauth
|
|
265
|
+
? ` 1. anthropic OAuth cannot work through OpenCode at all. Anthropic does not permit ` +
|
|
266
|
+
`Pro/Max subscription tokens in third-party tools, and OpenCode (since 1.3.0) ships no ` +
|
|
267
|
+
`anthropic OAuth support — an oauth credential never registers the provider, no matter ` +
|
|
268
|
+
`how valid the token is. Switch auth in .expo-code-review/config.jsonc to ` +
|
|
269
|
+
`{ "mode": "api-key", "provider": "anthropic", "tokenEnv": "ANTHROPIC_API_KEY" } with a ` +
|
|
270
|
+
`Console API key, or run with REVIEWER_MODEL set to a model you are logged into ` +
|
|
271
|
+
`(e.g. REVIEWER_MODEL=openai/gpt-5.5).\n`
|
|
272
|
+
: "") +
|
|
273
|
+
(tokenEnv
|
|
274
|
+
? ` ${deadOauth ? "2" : "1"}. The credential is wrong for the mode. ` +
|
|
275
|
+
`auth.mode "api-key" expects a plain API key for ${provider}; an OAuth/subscription ` +
|
|
276
|
+
`token is not an API key. A truncated or half-pasted ${tokenEnv} fails the same way.\n`
|
|
277
|
+
: ` ${deadOauth ? "2" : "1"}. The credential is wrong for the configured auth.mode.\n`) +
|
|
278
|
+
`Providers the server does offer: ${refused[0].suggestions.join(", ") || "(none)"}.`);
|
|
279
|
+
}
|
|
280
|
+
const lines = unknown.map((entry) => entry.reason === "provider"
|
|
281
|
+
? ` ${entry.model} — unknown provider "${entry.model.split("/")[0]}". Configured providers: ${entry.suggestions.join(", ") || "(none — is the credential set?)"}`
|
|
282
|
+
: ` ${entry.model} — that provider has no such model. Close matches: ${entry.suggestions.join(", ") || "(none)"}`);
|
|
283
|
+
return (`The configured model id(s) do not exist on the running OpenCode server:\n${lines.join("\n")}\n` +
|
|
284
|
+
`Fix the model in .expo-code-review/config.jsonc (agents' \`model\`, \`coordinator.model\`) ` +
|
|
285
|
+
`or REVIEWER_MODEL. Note that a model id must be "provider/model" (e.g. anthropic/claude-sonnet-5), ` +
|
|
286
|
+
`and that an out-of-date \`opencode\` can reject an id a newer one accepts — run \`ecr doctor\`.`);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Fail fast when a configured model can't be resolved, BEFORE any pass runs. Never
|
|
290
|
+
* blocks the run on its own failure: if the providers endpoint can't be read (an
|
|
291
|
+
* older server, a transport blip), the review proceeds and any real problem surfaces
|
|
292
|
+
* per-pass as before.
|
|
293
|
+
*/
|
|
294
|
+
export async function assertModelsResolvable(handle, models, auths) {
|
|
295
|
+
let available;
|
|
296
|
+
try {
|
|
297
|
+
available = await fetchProviderModels(handle);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (Object.keys(available).length === 0) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const unknown = findUnknownModels(models, available, auths?.map((entry) => entry.provider));
|
|
306
|
+
if (unknown.length > 0) {
|
|
307
|
+
throw new Error(formatUnknownModels(unknown, auths));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
92
310
|
const POLL_INTERVAL_MS = 1000;
|
|
93
311
|
// Emit a "still working" heartbeat if this long passes with no tool activity, so
|
|
94
312
|
// a long model-thinking stretch doesn't look hung in the logs.
|
|
@@ -100,12 +318,62 @@ const HEARTBEAT_MS = 45_000;
|
|
|
100
318
|
// over. Callers must treat AgentTimeoutError as "abandon", never "retry".
|
|
101
319
|
const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
|
|
102
320
|
// Extra budget for the "stop and summarize what you have" finalization prompt.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
321
|
+
// Deliberately generous: this is the ONLY chance to salvage a pass that ran out of
|
|
322
|
+
// time, and at 90s it was losing that race. Tools are disabled for the request
|
|
323
|
+
// (NO_TOOLS), so the reply is a single emit and normally lands in seconds.
|
|
324
|
+
const FINALIZE_WAIT_MS = 3 * 60 * 1000;
|
|
325
|
+
// ---- stall detection ----
|
|
326
|
+
//
|
|
327
|
+
// A pass is STALLED when its in-progress assistant message stops changing at all:
|
|
328
|
+
// no new tool call, no streamed text or reasoning, no token growth. A model that is
|
|
329
|
+
// genuinely thinking still grows that message every few seconds, so a gap this long
|
|
330
|
+
// means the provider request is wedged (or stuck in an internal retry we cannot
|
|
331
|
+
// see) — not that the work is hard.
|
|
332
|
+
//
|
|
333
|
+
// Motivating incident (eas-cli#4084, 2026-07-26): the cross-file pass ran 7 `read`
|
|
334
|
+
// calls in its first 6 seconds and then sat completely silent for 25 minutes,
|
|
335
|
+
// recording ZERO tokens (input, output, reasoning and cache all 0 in the run log),
|
|
336
|
+
// until its wall-clock cap fired. The finalize salvage then also went silent, so the
|
|
337
|
+
// pass's entire work product was lost and the PR got a coverage gap. Nothing in the
|
|
338
|
+
// run distinguished "wedged" from "thinking" — the heartbeat just printed elapsed
|
|
339
|
+
// seconds. A wall-clock cap alone cannot fix this: raising it only buys a longer
|
|
340
|
+
// silence, which is why the cap is NOT the lever here.
|
|
341
|
+
//
|
|
342
|
+
// Unlike the wall-clock cap (non-convergence → abandon, never retry), a stall is
|
|
343
|
+
// transient, so it earns ONE clean-slate retry inside the pass's existing budget.
|
|
344
|
+
const STALL_MS = 4 * 60 * 1000;
|
|
345
|
+
// Never let the watchdog outlast the pass it guards: a short pass (the 3m verifier,
|
|
346
|
+
// a 4m no-tools fallback) would otherwise hit its deadline before the watchdog could
|
|
347
|
+
// fire, and get none of this protection. Half the cap, with a floor that leaves room
|
|
348
|
+
// for a slow first token.
|
|
349
|
+
const MIN_STALL_MS = 30 * 1000;
|
|
350
|
+
/** Exported for tests. */
|
|
351
|
+
export function stallWindowMs(maxWaitMs) {
|
|
352
|
+
return Math.min(STALL_MS, Math.max(MIN_STALL_MS, Math.floor(maxWaitMs / 2)));
|
|
353
|
+
}
|
|
354
|
+
// The finalize reply does no investigation and cannot call tools, so it should
|
|
355
|
+
// stream within seconds; a much shorter silence already means wedged.
|
|
356
|
+
const FINALIZE_STALL_MS = 60 * 1000;
|
|
357
|
+
// Breathing room before the retry: if the silence came from provider-side throttling
|
|
358
|
+
// or backoff, reconnecting instantly is the worst move.
|
|
359
|
+
const STALL_RETRY_BACKOFF_MS = 20 * 1000;
|
|
360
|
+
// Only retry when enough of the pass's budget remains for the fresh attempt to
|
|
361
|
+
// plausibly finish; otherwise go straight to the soft landing.
|
|
362
|
+
const STALL_RETRY_MIN_REMAINING_MS = STALL_MS + 60 * 1000;
|
|
363
|
+
/**
|
|
364
|
+
* What to do about a stalled attempt: start over from a clean session, or stop and
|
|
365
|
+
* try to salvage findings. Exactly ONE retry, and only with enough budget left for it
|
|
366
|
+
* to land — a second wedged attempt would just spend the rest of the pass's window,
|
|
367
|
+
* which is the failure this whole mechanism exists to end. Exported for tests.
|
|
368
|
+
*/
|
|
369
|
+
export function stallAction(attempt, remainingMs) {
|
|
370
|
+
return attempt === 0 && remainingMs > STALL_RETRY_MIN_REMAINING_MS ? "retry" : "soft-land";
|
|
371
|
+
}
|
|
372
|
+
const FINALIZE_PROMPT = "You have reached your time budget. STOP investigating now — do NOT read, grep, " +
|
|
373
|
+
"glob, list, or open any more files, and do not call any tools. Based ONLY on " +
|
|
374
|
+
"what you have already examined, reply with the single JSON object exactly as " +
|
|
375
|
+
"specified in your instructions, containing whatever findings you are already " +
|
|
376
|
+
"confident about. If you have nothing solid, return an empty findings array.";
|
|
109
377
|
/**
|
|
110
378
|
* Internal signal that a poll loop passed its deadline. Carries the best-effort
|
|
111
379
|
* cost/tokens of the in-progress (never-completed) assistant message so a
|
|
@@ -115,12 +383,52 @@ class DeadlineReached extends Error {
|
|
|
115
383
|
cost;
|
|
116
384
|
tokens;
|
|
117
385
|
constructor(cost = 0, tokens) {
|
|
118
|
-
super(
|
|
386
|
+
super("deadline reached");
|
|
387
|
+
this.cost = cost;
|
|
388
|
+
this.tokens = tokens;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Internal signal that an in-progress reply went silent (see STALL_MS). Distinct
|
|
393
|
+
* from DeadlineReached: the pass's time budget is NOT spent, so the caller can spend
|
|
394
|
+
* what's left on a fresh attempt instead of abandoning the work.
|
|
395
|
+
*/
|
|
396
|
+
class NoProgress extends Error {
|
|
397
|
+
cost;
|
|
398
|
+
tokens;
|
|
399
|
+
idleMs;
|
|
400
|
+
constructor(cost = 0, tokens,
|
|
401
|
+
/** How long the reply had been unchanged when we gave up on it. */
|
|
402
|
+
idleMs = 0) {
|
|
403
|
+
super("no progress");
|
|
119
404
|
this.cost = cost;
|
|
120
405
|
this.tokens = tokens;
|
|
406
|
+
this.idleMs = idleMs;
|
|
121
407
|
}
|
|
122
408
|
}
|
|
123
|
-
|
|
409
|
+
/**
|
|
410
|
+
* A cheap signature of how far along an in-progress reply is: its parts (count,
|
|
411
|
+
* type, streamed length, tool status) plus the message's token/cost counters. ANY
|
|
412
|
+
* real progress — a new tool call, another chunk of text or reasoning, a tool
|
|
413
|
+
* advancing pending → running → completed — changes it; a wedged request leaves it
|
|
414
|
+
* byte-identical poll after poll. Exported for tests.
|
|
415
|
+
*/
|
|
416
|
+
export function progressFingerprint(message) {
|
|
417
|
+
const shape = (message.parts ?? [])
|
|
418
|
+
.map((part) => `${part.type ?? ""}:${(part.text ?? "").length}:${part.state?.status ?? ""}`)
|
|
419
|
+
.join("|");
|
|
420
|
+
const tokens = message.info?.tokens;
|
|
421
|
+
return [
|
|
422
|
+
shape,
|
|
423
|
+
tokens?.input ?? 0,
|
|
424
|
+
tokens?.output ?? 0,
|
|
425
|
+
tokens?.reasoning ?? 0,
|
|
426
|
+
tokens?.cache?.read ?? 0,
|
|
427
|
+
tokens?.cache?.write ?? 0,
|
|
428
|
+
message.info?.cost ?? 0,
|
|
429
|
+
].join("~");
|
|
430
|
+
}
|
|
431
|
+
const DEADLINE_SENTINEL = Symbol("deadline");
|
|
124
432
|
/**
|
|
125
433
|
* Race a promise against the poll deadline. Without this, a stalled message fetch
|
|
126
434
|
* (a wedged/overloaded OpenCode server) blocks the poll loop past its deadline,
|
|
@@ -134,7 +442,7 @@ async function raceDeadline(work, deadline) {
|
|
|
134
442
|
return DEADLINE_SENTINEL;
|
|
135
443
|
}
|
|
136
444
|
let timer;
|
|
137
|
-
const timeout = new Promise(resolve => {
|
|
445
|
+
const timeout = new Promise((resolve) => {
|
|
138
446
|
timer = setTimeout(() => resolve(DEADLINE_SENTINEL), remaining);
|
|
139
447
|
});
|
|
140
448
|
try {
|
|
@@ -153,11 +461,22 @@ async function raceDeadline(work, deadline) {
|
|
|
153
461
|
export class AgentTimeoutError extends Error {
|
|
154
462
|
cost;
|
|
155
463
|
tokens;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Why the pass was abandoned: it investigated without converging ("time"), or its
|
|
466
|
+
* model request went silent and did not recover after a fresh attempt ("stall").
|
|
467
|
+
* The caller's handling is the same (abandon), but the two have different causes —
|
|
468
|
+
* a wander is ours to bound, a stall is the provider's — so logs and coverage
|
|
469
|
+
* notes must not conflate them.
|
|
470
|
+
*/
|
|
471
|
+
reason;
|
|
472
|
+
constructor(agent, minutes, cost = 0, tokens, reason = "time") {
|
|
473
|
+
super(reason === "stall"
|
|
474
|
+
? `Agent "${agent}" stalled: its model request went silent and produced nothing after a retry (${minutes} minutes)`
|
|
475
|
+
: `Agent "${agent}" timed out after ${minutes} minutes (including finalize)`);
|
|
476
|
+
this.name = "AgentTimeoutError";
|
|
159
477
|
this.cost = cost;
|
|
160
478
|
this.tokens = tokens;
|
|
479
|
+
this.reason = reason;
|
|
161
480
|
}
|
|
162
481
|
}
|
|
163
482
|
/**
|
|
@@ -171,72 +490,117 @@ export class AgentTimeoutError extends Error {
|
|
|
171
490
|
* message completes.
|
|
172
491
|
*/
|
|
173
492
|
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
493
|
const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
494
|
+
// ONE deadline for the whole pass, shared by the first attempt and any stall
|
|
495
|
+
// retry, so retrying a wedged request can never push the pass past its declared
|
|
496
|
+
// cap (the budget math in review.ts depends on that being true).
|
|
497
|
+
const deadline = Date.now() + maxWaitMs;
|
|
498
|
+
// Spend from abandoned attempts, carried forward so a wedged attempt's cost and
|
|
499
|
+
// tokens still land in the run's metrics instead of vanishing.
|
|
500
|
+
let carriedCost = 0;
|
|
501
|
+
let carriedTokens;
|
|
502
|
+
const carry = (result) => ({
|
|
503
|
+
...result,
|
|
504
|
+
cost: result.cost + carriedCost,
|
|
505
|
+
tokens: addTokenUsage(addTokenUsage({}, carriedTokens), result.tokens),
|
|
506
|
+
});
|
|
507
|
+
const absorb = (spent) => {
|
|
508
|
+
carriedCost += spent.cost;
|
|
509
|
+
carriedTokens = addTokenUsage(addTokenUsage({}, carriedTokens), spent.tokens);
|
|
510
|
+
};
|
|
511
|
+
/**
|
|
512
|
+
* Last chance to get something out of a pass that hit its ceiling: interrupt the
|
|
513
|
+
* run and ask the SAME (context-carrying) session for whatever it already has.
|
|
514
|
+
* Tools are disabled for this request, so it cannot resume investigating — the
|
|
515
|
+
* only thing it can do is emit.
|
|
516
|
+
*/
|
|
517
|
+
const softLand = async (sessionID, reason, reportedTools) => {
|
|
518
|
+
await abortQuietly(handle, sessionID);
|
|
519
|
+
const minutes = Math.round(maxWaitMs / 60000);
|
|
202
520
|
if (!args.finalizeOnTimeout) {
|
|
203
|
-
throw new AgentTimeoutError(args.agent,
|
|
521
|
+
throw new AgentTimeoutError(args.agent, minutes, carriedCost, carriedTokens, reason);
|
|
204
522
|
}
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
523
|
+
// Only messages after this point count as the answer.
|
|
524
|
+
const baseline = (await fetchMessages(handle, sessionID)).length;
|
|
525
|
+
args.onActivity?.(reason === "stall"
|
|
526
|
+
? "no output after a retry — asking for findings so far"
|
|
527
|
+
: "time budget reached — asking for findings so far");
|
|
528
|
+
await sendSessionPrompt(handle, sessionID, {
|
|
210
529
|
agent: args.agent,
|
|
211
530
|
system: args.system,
|
|
212
531
|
text: FINALIZE_PROMPT,
|
|
532
|
+
tools: NO_TOOLS,
|
|
213
533
|
});
|
|
214
534
|
try {
|
|
215
|
-
const result = await pollForCompletion(handle,
|
|
535
|
+
const result = await pollForCompletion(handle, sessionID, {
|
|
216
536
|
agent: args.agent,
|
|
217
537
|
fromIndex: baseline,
|
|
218
538
|
deadline: Date.now() + FINALIZE_WAIT_MS,
|
|
219
539
|
onActivity: args.onActivity,
|
|
220
540
|
reportedTools,
|
|
541
|
+
stallMs: FINALIZE_STALL_MS,
|
|
221
542
|
});
|
|
222
|
-
return {
|
|
223
|
-
...result,
|
|
224
|
-
cost: result.cost + spentCost,
|
|
225
|
-
tokens: addTokenUsage(addTokenUsage({}, spentTokens), result.tokens),
|
|
226
|
-
truncated: true,
|
|
227
|
-
};
|
|
543
|
+
return { ...carry(result), truncated: true };
|
|
228
544
|
}
|
|
229
545
|
catch (finalizeError) {
|
|
230
|
-
if (finalizeError instanceof DeadlineReached) {
|
|
231
|
-
await abortQuietly(handle,
|
|
232
|
-
|
|
546
|
+
if (finalizeError instanceof DeadlineReached || finalizeError instanceof NoProgress) {
|
|
547
|
+
await abortQuietly(handle, sessionID);
|
|
548
|
+
absorb(finalizeError);
|
|
549
|
+
throw new AgentTimeoutError(args.agent, Math.round((maxWaitMs + FINALIZE_WAIT_MS) / 60000), carriedCost, carriedTokens, reason);
|
|
233
550
|
}
|
|
234
551
|
throw finalizeError;
|
|
235
552
|
}
|
|
553
|
+
};
|
|
554
|
+
for (let attempt = 0;; attempt++) {
|
|
555
|
+
const session = unwrap(await handle.client.session.create({
|
|
556
|
+
body: { title: attempt === 0 ? args.title : `${args.title}-retry${attempt}` },
|
|
557
|
+
}));
|
|
558
|
+
const reportedTools = new Set();
|
|
559
|
+
await sendSessionPrompt(handle, session.id, {
|
|
560
|
+
agent: args.agent,
|
|
561
|
+
system: args.system,
|
|
562
|
+
text: args.text,
|
|
563
|
+
});
|
|
564
|
+
try {
|
|
565
|
+
return carry(await pollForCompletion(handle, session.id, {
|
|
566
|
+
agent: args.agent,
|
|
567
|
+
fromIndex: 0,
|
|
568
|
+
deadline,
|
|
569
|
+
onActivity: args.onActivity,
|
|
570
|
+
reportedTools,
|
|
571
|
+
maxToolCalls: args.maxToolCalls,
|
|
572
|
+
stallMs: stallWindowMs(maxWaitMs),
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
// Went silent. The request is wedged, not slow, so the first move is a clean
|
|
577
|
+
// slate — a fresh session, not the finalize prompt, which would be asking the
|
|
578
|
+
// wedged request to answer. Exactly one retry, and only when enough budget
|
|
579
|
+
// remains for it to land; after that the finalize is still worth a try as the
|
|
580
|
+
// only remaining salvage (in eas-cli#4084 the session did respond once aborted).
|
|
581
|
+
if (error instanceof NoProgress) {
|
|
582
|
+
await abortQuietly(handle, session.id);
|
|
583
|
+
absorb(error);
|
|
584
|
+
const remaining = deadline - Date.now();
|
|
585
|
+
if (stallAction(attempt, remaining) === "retry") {
|
|
586
|
+
args.onActivity?.(`stalled — no output for ${Math.round(error.idleMs / 1000)}s; ` +
|
|
587
|
+
`retrying once from a clean session (${Math.round(remaining / 60000)}m of budget left)`);
|
|
588
|
+
await sleep(STALL_RETRY_BACKOFF_MS);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
return await softLand(session.id, "stall", reportedTools);
|
|
592
|
+
}
|
|
593
|
+
// Ran the clock down while investigating: converge on what it has.
|
|
594
|
+
if (error instanceof DeadlineReached) {
|
|
595
|
+
absorb(error);
|
|
596
|
+
return await softLand(session.id, "time", reportedTools);
|
|
597
|
+
}
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
236
600
|
}
|
|
237
601
|
}
|
|
238
|
-
const CORRECTIVE =
|
|
239
|
-
|
|
602
|
+
const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single " +
|
|
603
|
+
"JSON object described above — no prose, no code fences, no partial output.";
|
|
240
604
|
// Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
|
|
241
605
|
// it should return almost immediately.
|
|
242
606
|
const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
|
|
@@ -270,7 +634,7 @@ export function isTransientApiError(error) {
|
|
|
270
634
|
return false;
|
|
271
635
|
}
|
|
272
636
|
const message = errorMessage(error);
|
|
273
|
-
return TRANSIENT_PATTERNS.some(pattern => pattern.test(message));
|
|
637
|
+
return TRANSIENT_PATTERNS.some((pattern) => pattern.test(message));
|
|
274
638
|
}
|
|
275
639
|
/**
|
|
276
640
|
* Run a model call, retrying with bounded backoff on a transient API error. This
|
|
@@ -307,16 +671,19 @@ async function withTransientRetry(label, onActivity, fn) {
|
|
|
307
671
|
export async function promptAndParse(handle, args, parse) {
|
|
308
672
|
let cost = 0;
|
|
309
673
|
let truncated = false;
|
|
674
|
+
let model;
|
|
310
675
|
const tokens = {};
|
|
311
676
|
const record = (result) => {
|
|
312
677
|
cost += result.cost;
|
|
313
678
|
truncated = truncated || (result.truncated ?? false);
|
|
314
679
|
addTokenUsage(tokens, result.tokens);
|
|
680
|
+
// Keep the model from whichever attempt actually answered.
|
|
681
|
+
model = result.model ?? model;
|
|
315
682
|
};
|
|
316
683
|
const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => promptAgent(handle, args));
|
|
317
684
|
record(first);
|
|
318
685
|
try {
|
|
319
|
-
return { value: parse(first.text), cost, truncated, tokens };
|
|
686
|
+
return { value: parse(first.text), cost, truncated, tokens, model };
|
|
320
687
|
}
|
|
321
688
|
catch {
|
|
322
689
|
// Same-session corrective retry: send the nudge as a follow-up and wait for
|
|
@@ -327,6 +694,9 @@ export async function promptAndParse(handle, args, parse) {
|
|
|
327
694
|
agent: args.agent,
|
|
328
695
|
system: args.system,
|
|
329
696
|
text: CORRECTIVE,
|
|
697
|
+
// Re-emit only: the investigation is done, so no tools are needed and
|
|
698
|
+
// disabling them keeps the corrective from turning into a second wander.
|
|
699
|
+
tools: NO_TOOLS,
|
|
330
700
|
});
|
|
331
701
|
const retry = await pollForCompletion(handle, first.sessionID, {
|
|
332
702
|
agent: args.agent,
|
|
@@ -334,9 +704,10 @@ export async function promptAndParse(handle, args, parse) {
|
|
|
334
704
|
deadline: Date.now() + CORRECTIVE_WAIT_MS,
|
|
335
705
|
onActivity: args.onActivity,
|
|
336
706
|
reportedTools: new Set(),
|
|
707
|
+
stallMs: FINALIZE_STALL_MS,
|
|
337
708
|
});
|
|
338
709
|
record(retry);
|
|
339
|
-
return { value: parse(retry.text), cost, truncated, tokens };
|
|
710
|
+
return { value: parse(retry.text), cost, truncated, tokens, model };
|
|
340
711
|
}
|
|
341
712
|
catch {
|
|
342
713
|
// Fresh-session last resort: a clean slate for a genuinely confused run.
|
|
@@ -347,7 +718,7 @@ export async function promptAndParse(handle, args, parse) {
|
|
|
347
718
|
});
|
|
348
719
|
record(fresh);
|
|
349
720
|
try {
|
|
350
|
-
return { value: parse(fresh.text), cost, truncated, tokens };
|
|
721
|
+
return { value: parse(fresh.text), cost, truncated, tokens, model };
|
|
351
722
|
}
|
|
352
723
|
catch (finalError) {
|
|
353
724
|
throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
|
|
@@ -364,7 +735,8 @@ async function sendSessionPrompt(handle, sessionID, args) {
|
|
|
364
735
|
body: {
|
|
365
736
|
agent: args.agent,
|
|
366
737
|
system: args.system,
|
|
367
|
-
parts: [{ type:
|
|
738
|
+
parts: [{ type: "text", text: args.text }],
|
|
739
|
+
...(args.tools ? { tools: args.tools } : {}),
|
|
368
740
|
},
|
|
369
741
|
}));
|
|
370
742
|
}
|
|
@@ -380,7 +752,8 @@ async function abortQuietly(handle, sessionID) {
|
|
|
380
752
|
* Poll a session for the first assistant message at or after `fromIndex` to
|
|
381
753
|
* complete. `fromIndex` lets a follow-up prompt (finalize, corrective retry)
|
|
382
754
|
* skip the earlier completed message and wait for the NEW reply instead. Throws
|
|
383
|
-
* DeadlineReached once `deadline` passes
|
|
755
|
+
* DeadlineReached once `deadline` passes, or NoProgress once the reply has been
|
|
756
|
+
* unchanged for `stallMs` (see STALL_MS).
|
|
384
757
|
*/
|
|
385
758
|
async function pollForCompletion(handle, sessionID, opts) {
|
|
386
759
|
// Best-effort usage of the in-progress assistant message, so a task that times
|
|
@@ -389,6 +762,11 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
389
762
|
let lastTokens;
|
|
390
763
|
const startedAt = Date.now();
|
|
391
764
|
let lastEmitAt = startedAt;
|
|
765
|
+
// Stall watchdog state: when the reply last changed in any way, and what it looked
|
|
766
|
+
// like then. Starts at "now" so a prompt that never produces an assistant message
|
|
767
|
+
// at all (a wedged submission) also trips the watchdog.
|
|
768
|
+
let lastProgressAt = startedAt;
|
|
769
|
+
let lastFingerprint = "";
|
|
392
770
|
const emit = (line) => {
|
|
393
771
|
lastEmitAt = Date.now();
|
|
394
772
|
opts.onActivity?.(line);
|
|
@@ -399,9 +777,14 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
399
777
|
}
|
|
400
778
|
await sleep(POLL_INTERVAL_MS);
|
|
401
779
|
// Heartbeat if nothing has been reported for a while (e.g. the model is
|
|
402
|
-
// reasoning without calling tools), so a long pass doesn't look hung.
|
|
780
|
+
// reasoning without calling tools), so a long pass doesn't look hung. Say how
|
|
781
|
+
// long the reply has been unchanged, not just how long the pass has run — that
|
|
782
|
+
// distinction is what tells a slow investigation apart from a wedged request,
|
|
783
|
+
// and its absence is why eas-cli#4084 took a forensic dig to explain.
|
|
403
784
|
if (opts.onActivity && Date.now() - lastEmitAt >= HEARTBEAT_MS) {
|
|
404
|
-
|
|
785
|
+
const idleMs = Date.now() - lastProgressAt;
|
|
786
|
+
emit(`still working… ${Math.round((Date.now() - startedAt) / 1000)}s elapsed` +
|
|
787
|
+
(idleMs >= HEARTBEAT_MS ? ` (no new output for ${Math.round(idleMs / 1000)}s)` : ""));
|
|
405
788
|
}
|
|
406
789
|
// Bound the fetch by the deadline: a stalled server can't push the task past
|
|
407
790
|
// its time cap (the overshoot we saw when the server was overloaded).
|
|
@@ -410,28 +793,34 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
410
793
|
throw new DeadlineReached(lastCost, lastTokens);
|
|
411
794
|
}
|
|
412
795
|
const recent = messages.slice(opts.fromIndex);
|
|
413
|
-
const assistant = [...recent].reverse().find(message => message.info?.role ===
|
|
796
|
+
const assistant = [...recent].reverse().find((message) => message.info?.role === "assistant");
|
|
414
797
|
if (!assistant) {
|
|
415
798
|
continue;
|
|
416
799
|
}
|
|
417
|
-
if (typeof assistant.info?.cost ===
|
|
800
|
+
if (typeof assistant.info?.cost === "number") {
|
|
418
801
|
lastCost = assistant.info.cost;
|
|
419
802
|
}
|
|
420
803
|
if (assistant.info?.tokens) {
|
|
421
804
|
lastTokens = assistant.info.tokens;
|
|
422
805
|
}
|
|
806
|
+
// Stall watchdog: did the reply change AT ALL since the last poll?
|
|
807
|
+
const fingerprint = progressFingerprint(assistant);
|
|
808
|
+
if (fingerprint !== lastFingerprint) {
|
|
809
|
+
lastFingerprint = fingerprint;
|
|
810
|
+
lastProgressAt = Date.now();
|
|
811
|
+
}
|
|
423
812
|
// Track each distinct tool call once (for the tool-call cap) and, the first
|
|
424
813
|
// time it starts, emit a live line so a long run shows what the agent is doing.
|
|
425
814
|
for (const part of assistant.parts ?? []) {
|
|
426
|
-
if (part?.type !==
|
|
815
|
+
if (part?.type !== "tool") {
|
|
427
816
|
continue;
|
|
428
817
|
}
|
|
429
818
|
const key = part.callID ?? part.id;
|
|
430
819
|
const status = part.state?.status;
|
|
431
|
-
if (key && status && status !==
|
|
820
|
+
if (key && status && status !== "pending" && !opts.reportedTools.has(key)) {
|
|
432
821
|
opts.reportedTools.add(key);
|
|
433
822
|
if (opts.onActivity) {
|
|
434
|
-
const tool = part.tool ??
|
|
823
|
+
const tool = part.tool ?? "tool";
|
|
435
824
|
const title = part.state?.title;
|
|
436
825
|
emit(title ? `${tool}: ${title}` : tool);
|
|
437
826
|
}
|
|
@@ -444,15 +833,16 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
444
833
|
// work is done, so there's nothing to finalize.
|
|
445
834
|
if (assistant.info?.time?.completed != null) {
|
|
446
835
|
const text = (assistant.parts ?? [])
|
|
447
|
-
.filter(part => part?.type ===
|
|
448
|
-
.map(part => part.text)
|
|
449
|
-
.join(
|
|
836
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
837
|
+
.map((part) => part.text)
|
|
838
|
+
.join("\n")
|
|
450
839
|
.trim();
|
|
451
840
|
return {
|
|
452
841
|
text,
|
|
453
842
|
cost: assistant.info?.cost ?? 0,
|
|
454
843
|
sessionID,
|
|
455
844
|
tokens: assistant.info?.tokens,
|
|
845
|
+
model: formatModel(assistant.info?.providerID, assistant.info?.modelID),
|
|
456
846
|
};
|
|
457
847
|
}
|
|
458
848
|
// Still in progress: enforce the tool-call cap. An agent that has made this
|
|
@@ -462,5 +852,15 @@ async function pollForCompletion(handle, sessionID, opts) {
|
|
|
462
852
|
emit(`made ${opts.reportedTools.size} tool calls — wrapping up to stay on budget`);
|
|
463
853
|
throw new DeadlineReached(lastCost, lastTokens);
|
|
464
854
|
}
|
|
855
|
+
// Still in progress and completely silent: the request is wedged, not slow.
|
|
856
|
+
// Bail out NOW rather than spending the rest of the cap on a dead request —
|
|
857
|
+
// the caller retries a stall from a clean session (see promptAgent).
|
|
858
|
+
if (opts.stallMs != null) {
|
|
859
|
+
const idleMs = Date.now() - lastProgressAt;
|
|
860
|
+
if (idleMs >= opts.stallMs) {
|
|
861
|
+
emit(`no new output for ${Math.round(idleMs / 1000)}s — treating the model request as stalled`);
|
|
862
|
+
throw new NoProgress(lastCost, lastTokens, idleMs);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
465
865
|
}
|
|
466
866
|
}
|