jeo-code 0.8.22 → 0.8.24

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.
@@ -9,12 +9,12 @@
9
9
  * repo). Subagents are spawned with `subagentToolset(role)`, which never includes
10
10
  * `task` itself, so delegation cannot recurse infinitely.
11
11
  */
12
- import { runAgentLoop, type ToolHandler } from "./engine";
12
+ import { runAgentLoop, isSafetyFallbackBail, stripSafetyFallbackTag, type ToolHandler } from "./engine";
13
13
  import { toolTarget } from "./step-budget";
14
14
 
15
15
  import type { ToolResult } from "./tools";
16
16
  import type { Message } from "./loop";
17
- import { loadProjectContext, withProjectContext } from "./context-files";
17
+ import { loadProjectContext, withProjectContext, type ProjectContextFile } from "./context-files";
18
18
  import { memoryPromptSection } from "./memory";
19
19
 
20
20
  import type { Config } from "./state";
@@ -28,10 +28,25 @@ import {
28
28
  resolveSubagentThinking,
29
29
  subagentRoleIds,
30
30
  validateSubagentDoneReason,
31
+ type SubagentRole,
31
32
  } from "./subagents";
32
- import { resolveMaxOutputTokens } from "../ai/model-manager";
33
+ import { resolveMaxOutputTokens, resolveProvider } from "../ai/model-manager";
33
34
  import type { SubagentRegistry } from "./subagent-registry";
34
35
  import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notify/session-endpoint";
36
+ import { inferTierForModel, tierModelPool, credentialScopeFor, selectFromPool, strongestMidTierCredentialed, type RoutingConfig } from "./prompt-router";
37
+ import { isRateLimitError } from "../util/retry";
38
+
39
+ /** `runSubagentOnce`/`createTaskTool`'s config requirement: everything
40
+ * `resolveSubagentModel`/`resolveSubagentMaxSteps`/`resolveSubagentThinking` need
41
+ * (`defaultModel`/`subagents`/`thinkingLevel`) PLUS `RoutingConfig` (`roles`/
42
+ * `routing`, optionally `providers`/`oauth`/`openaiBaseUrl`) — the rate-limit
43
+ * fast-fallback reroute below (`tierModelPool`/`credentialScopeFor`) needs the
44
+ * SAME credential-aware routing config `launch.ts`'s `rateLimitFallbackAvailable`
45
+ * uses, not just the narrower subagent-resolution slice this type used to be.
46
+ * Both real call sites (`launch.ts`'s `createTaskTool({ config: turnConfig, … })`
47
+ * and `team.ts`'s two `runSubagentOnce` call sites) already pass a full `Config`
48
+ * at runtime, so this widening is type-only — zero runtime behavior change. */
49
+ export type SubagentTaskConfig = RoutingConfig & Pick<Config, "subagents" | "thinkingLevel">;
35
50
 
36
51
 
37
52
  /** Lifecycle event emitted while a delegated subagent runs. */
@@ -61,8 +76,11 @@ export interface TaskSubEvent {
61
76
  }
62
77
 
63
78
  export interface TaskToolOptions {
64
- /** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback. */
65
- config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">;
79
+ /** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback.
80
+ * Widened to `SubagentTaskConfig` (RoutingConfig-compatible) — see its doc comment
81
+ * — so the rate-limit fast-fallback reroute in `runSubagentOnce` can classify
82
+ * credential scopes without a second, narrower config type at this boundary. */
83
+ config: SubagentTaskConfig;
66
84
  /** Forwarded to the subagent loop so Ctrl-C cancels nested work too. */
67
85
  signal?: AbortSignal;
68
86
  /** Optional live sink (e.g. plain-stream rendering of nested progress). */
@@ -89,7 +107,17 @@ export interface TaskToolOptions {
89
107
  * disjoint files (documented in `taskToolProtocolLine`); overlapping scopes
90
108
  * should either run sequentially (separate `task` calls) or coordinate — jeo has
91
109
  * no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
92
- const MAX_FANOUT = 4;
110
+ export const MAX_FANOUT = 4;
111
+
112
+ /** Small bounded reroute budget for a SUBAGENT's own 429 (distinct from
113
+ * launch.ts's `ROUTE_FALLBACK_MAX_ATTEMPTS`, default 3): a subagent is already
114
+ * bounded by its own step/token budget and the parent turn's patience, so this
115
+ * stays intentionally tight — 2 extra attempts (3 total dispatches) is plenty to
116
+ * escape a same-credential-scope 429 without turning one subagent slot into an
117
+ * unbounded model-hopping loop. Exported so `ralplan.ts`'s `runConsensusCriticGate`
118
+ * (a single, non-fan-out subagent call following the same reroute pattern) shares
119
+ * ONE budget definition instead of a second magic number drifting out of sync. */
120
+ export const MAX_SUBAGENT_REROUTES = 2;
93
121
 
94
122
  /** Hard cap on a fan-out BATCH SIZE (queue length, not concurrency): an unbounded
95
123
  * queue behind MAX_FANOUT workers would still monopolize the parent turn for a
@@ -182,8 +210,9 @@ export function fenceSubagentReport(detail: string): string {
182
210
  * handler accepts `{ role?, task | prompt | assignment, context? }`.
183
211
  */
184
212
  export interface RunSubagentOptions {
185
- /** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback. */
186
- config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">;
213
+ /** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback.
214
+ * Widened to `SubagentTaskConfig` — see its doc comment for why. */
215
+ config: SubagentTaskConfig;
187
216
  /** Forwarded to the subagent's own runAgentLoop so Ctrl-C cancels nested work too. */
188
217
  signal?: AbortSignal;
189
218
  /** Optional live sink (e.g. plain-stream rendering of nested progress). */
@@ -193,7 +222,25 @@ export interface RunSubagentOptions {
193
222
  /** 1-based position within a fan-out/parallel batch, for event tagging. */
194
223
  slot?: { index: number; total: number };
195
224
  /** Pre-loaded project context to avoid re-scanning AGENTS.md per batch item. */
196
- projectContext?: Awaited<ReturnType<typeof loadProjectContext>>;
225
+ projectContext?: ProjectContextFile[];
226
+ /** BATCH-scoped (not per-call) credential scopes already known exhausted this
227
+ * turn, shared across every CONCURRENT fan-out worker (see `createTaskTool`'s
228
+ * fan-out path). Up to MAX_FANOUT subagents can be dispatched against the SAME
229
+ * OAuth-scoped model simultaneously (e.g. bare `task` calls all defaulting to
230
+ * `executor`); without a shared set, each worker independently 429s, waits,
231
+ * and rediscovers the SAME exhausted scope instead of the first worker to hit
232
+ * it saving its siblings the wasted round. Mutated in place (`.add`) — every
233
+ * worker sharing this Set sees a scope the instant ANY of them excludes it.
234
+ * Omitted (single-task/detached calls with no batch) = a fresh local Set,
235
+ * scoped to just this one run. */
236
+ excludedCredentialScopes?: Set<string>;
237
+ /** Overrides the role's own `resolveSubagentModel` pick for THIS run only
238
+ * (never persisted, never a per-role config change). Used by `createTaskTool`'s
239
+ * fan-out path to default an unpinned batch to a mid-tier model instead of the
240
+ * role's normal strongest-tier resolution — see MAX_FANOUT's fan-out call site
241
+ * for the cost rationale (bulk/high-volume work does not need the same tier a
242
+ * single deep task gets). Absent = unchanged `resolveSubagentModel` behavior. */
243
+ modelOverride?: string;
197
244
  }
198
245
 
199
246
  /** `runSubagentOnce`'s result — a superset of `ToolResult` exposing the raw
@@ -216,6 +263,14 @@ export interface SubagentRunResult extends ToolResult {
216
263
  fileMutations: number;
217
264
  /** Successful bash calls observed (tracked apart — bash CAN mutate but isn't proof). */
218
265
  bashRuns: number;
266
+ /** Successful read/search/find/ast_grep/lsp calls observed — evidence a
267
+ * READ-ONLY role (architect/critic) actually inspected real files before
268
+ * rendering a verdict, mirroring goal-verifier.ts's `applyEvidenceGate`
269
+ * philosophy (a claim is only trustworthy when backed by an OBSERVED
270
+ * signal in THIS run, never by the model's own unverified assertion).
271
+ * Zero means the verdict's `Inspected:`/prose claims are unevidenced,
272
+ * regardless of what the text says. */
273
+ readOnlyEvidenceCalls: number;
219
274
  }
220
275
 
221
276
  /**
@@ -227,7 +282,7 @@ export interface SubagentRunResult extends ToolResult {
227
282
  * plan step.
228
283
  */
229
284
  export async function runSubagentOnce(
230
- role: ReturnType<typeof getSubagentRole> & {},
285
+ role: SubagentRole,
231
286
  taskText: string,
232
287
  context: string,
233
288
  cwd: string,
@@ -238,7 +293,7 @@ export async function runSubagentOnce(
238
293
  // task 1 from task 3 when several same-role subagents stream concurrently.
239
294
  const emit = (ev: TaskSubEvent) =>
240
295
  opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
241
- const model = resolveSubagentModel(role.id, opts.config);
296
+ const initialModel = opts.modelOverride || resolveSubagentModel(role.id, opts.config);
242
297
  const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
243
298
  // gjc parity: a role may pin its own reasoning budget; absent = inherit the
244
299
  // session/global thinking level (the "(inherit)" row in the picker).
@@ -258,71 +313,179 @@ export async function runSubagentOnce(
258
313
  // calls so the parent can audit a "Changed Files:" claim against observed
259
314
  // reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
260
315
  // bash: read-only bash (e.g. `bun test`) MUST NOT count as edit evidence, but
261
- // bash CAN mutate, so the audit message distinguishes the two cases.
316
+ // bash CAN mutate, so the audit message distinguishes the two cases. These
317
+ // accumulate ACROSS a rate-limit reroute below — a step that already ran (and
318
+ // its file writes/bash calls) stays real evidence even if a LATER step 429s
319
+ // and switches models; only `history` state matters for what actually happened,
320
+ // never which model dispatched a given step.
262
321
  let fileMutations = 0;
263
322
  let bashRuns = 0;
264
- emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model });
265
- const result = await runAgentLoop(history, {
266
- cwd,
267
- model,
268
- maxSteps,
269
- maxTokens: resolveMaxOutputTokens(model, thinking),
270
- // Per-run prompt-cache key: the subagent replays its own growing history each
271
- // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
272
- sessionKey: crypto.randomUUID(),
273
- // Bounded delegation: a subagent's step contract stays exact — the parent
274
- // owns any retry/extension decision, so the gjc retry flow is disabled here.
275
- budget: { maxExtensions: 0 },
276
- signal,
277
- steer,
278
- tools: subagentToolset(role),
279
- events: {
280
- onStep: n => { currentStep = n; },
281
- // Live reasoning preview (native extended-thinking models only — the JSON-protocol
282
- // "reasoning" field the main turn also extracts from onModelStream is intentionally
283
- // NOT wired here: a subagent's forming tool-call JSON is rarely useful mid-stream and
284
- // doubling the stream sinks would double emit() calls for the same underlying delta).
285
- // Tail-sliced + whitespace-collapsed to a compact one-line preview — the ledger never
286
- // records this (see the `kind` doc comment); it only drives the live per-slot status.
287
- onReasoningStream: textSoFar => {
288
- const tail = textSoFar.length > 200 ? textSoFar.slice(textSoFar.length - 200) : textSoFar;
289
- const preview = tail.replace(/\s+/g, " ").trim();
290
- if (preview) emit({ role: role.id, kind: "thinking", detail: preview, step: currentStep, maxSteps, model });
291
- },
292
- onAssistant: (_raw, invocation) => {
293
- if (invocation && invocation.tool && invocation.tool !== "done") {
294
- lastTarget = toolTarget(invocation.tool, invocation.arguments);
295
- trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
296
- emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model });
297
- }
298
- },
299
- onToolResult: (tool, success, output) => {
300
- if (success) {
301
- if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
302
- else if (tool === "bash") bashRuns++;
303
- }
304
- const label = lastTarget || tool;
305
- const summary = firstUsefulLine(output);
306
- const suffix = summary ? ` — ${summary}` : "";
307
- trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
308
- emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model });
309
- lastTarget = "";
323
+ let readOnlyEvidenceCalls = 0;
324
+ // Per-run prompt-cache key: the subagent replays its own growing history each
325
+ // step, so a stable key (even across a model switch below — a stale cache key
326
+ // on a NEW provider is simply unused, never wrong) gets provider cache hits
327
+ // (gjc sub-session parity).
328
+ const sessionKey = crypto.randomUUID();
329
+
330
+ // --- Rate-limit fast fallback (mirrors launch.ts's `rateLimitFallbackAvailable`/
331
+ // `equivalentRouteFallback` pattern — see df3475d/cca5fe2/02b7e59's commit docs for
332
+ // the full design history) adapted to a SUBAGENT's narrower, bounded context: a
333
+ // subagent is already bounded by its own step/token budget, so this stays a SMALL
334
+ // reroute loop (MAX_SUBAGENT_REROUTES extra attempts) instead of launch.ts's fuller
335
+ // multi-round loop with live provider-reachability probing. Pairing the bail
336
+ // predicate with an ACTUAL reroute-and-retry loop is not optional — per 02b7e59's
337
+ // doc comment, bailing the retry ladder early WITHOUT a real switch-and-retry
338
+ // around it is a net regression (fails faster with the identical outcome, trading
339
+ // away the one real win the full backoff still offers: a transient <~90s rate-limit
340
+ // blip clearing mid-wait and the ORIGINAL call succeeding).
341
+ const tier = inferTierForModel(initialModel);
342
+ const attemptedModels = new Set<string>([initialModel]);
343
+ // Batch-shared when a fan-out worker passes one (see RunSubagentOptions'
344
+ // doc comment); a fresh local Set for a single-task/detached run.
345
+ const excludedCredentialScopes = opts.excludedCredentialScopes ?? new Set<string>();
346
+ // Synchronous, config-only candidate search — same tierModelPool/credentialScopeFor
347
+ // filtering launch.ts's fast-bail predicate uses, deliberately WITHOUT the async
348
+ // describeModel/describeProvider/local-reachability validation `equivalentRouteFallback`
349
+ // does: a bounded 1-2-attempt subagent reroute does not warrant that extra latency/
350
+ // complexity — tierModelPool already filters to config-servable models
351
+ // (modelServableWithConfig), so a candidate here is at minimum "configured", even if
352
+ // (rarely) it turns out unreachable mid-call, in which case the reroute loop below
353
+ // just reports that real failure instead of masking it.
354
+ const fallbackCandidates = (): string[] => {
355
+ const currentScope = credentialScopeFor(activeModel, opts.config);
356
+ return tierModelPool(tier, opts.config)
357
+ .filter(m => !attemptedModels.has(m))
358
+ .filter(m => {
359
+ const scope = credentialScopeFor(m, opts.config);
360
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
361
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
362
+ return !excludedCredentialScopes.has(scope.key);
363
+ });
364
+ };
365
+ // Fed to AgentLoopOptions.rateLimitFallbackAvailable — lets the engine bail a 429
366
+ // retry ladder on the FIRST failed attempt instead of riding ~90s of backoff when
367
+ // a genuinely different-credential-scope candidate is available RIGHT NOW.
368
+ const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
369
+ // Providers that have ALREADY produced an uncategorized-refusal bail THIS run —
370
+ // accumulates across reroutes (mirrors excludedCredentialScopes' own pattern)
371
+ // so a 2nd safety bail (on the FIRST fallback model) can never cycle back to a
372
+ // provider already ruled out, even though `attemptedModels` only tracks exact
373
+ // model ids, not whole providers.
374
+ const excludedSafetyProviders = new Set<string>();
375
+ // Stricter than fallbackCandidates(): a same-provider sibling shares the SAME
376
+ // constitutional/moderation classifier (unlike a same-credential-scope sibling,
377
+ // which is a BILLING boundary, not a classifier boundary) — trying it after an
378
+ // uncategorized refusal is not a genuinely different verdict, it's very likely
379
+ // the identical false positive again. Always excludes the CURRENT provider (not
380
+ // just accumulated ones), so this is correct from the very first check too.
381
+ const safetyFallbackCandidates = (): string[] => {
382
+ const currentProvider = resolveProvider(activeModel);
383
+ return fallbackCandidates().filter(m => {
384
+ const p = resolveProvider(m);
385
+ return p !== currentProvider && !excludedSafetyProviders.has(p);
386
+ });
387
+ };
388
+ // Fed to AgentLoopOptions.safetyFallbackAvailable — see its doc comment for the
389
+ // "false positive -> try a different model" design this implements.
390
+ const safetyFallbackAvailable = (): boolean => safetyFallbackCandidates().length > 0;
391
+
392
+ let activeModel = initialModel;
393
+ emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
394
+ const runOnce = () => {
395
+ return runAgentLoop(history, {
396
+ cwd,
397
+ model: activeModel,
398
+ maxSteps,
399
+ maxTokens: resolveMaxOutputTokens(activeModel, thinking),
400
+ sessionKey,
401
+ // Bounded delegation: a subagent's step contract stays exact — the parent
402
+ // owns any retry/extension decision, so the gjc retry flow is disabled here.
403
+ budget: { maxExtensions: 0 },
404
+ signal,
405
+ steer,
406
+ tools: subagentToolset(role),
407
+ rateLimitFallbackAvailable,
408
+ safetyFallbackAvailable,
409
+ events: {
410
+ onStep: n => { currentStep = n; },
411
+ // Live reasoning preview (native extended-thinking models only — the JSON-protocol
412
+ // "reasoning" field the main turn also extracts from onModelStream is intentionally
413
+ // NOT wired here: a subagent's forming tool-call JSON is rarely useful mid-stream and
414
+ // doubling the stream sinks would double emit() calls for the same underlying delta).
415
+ // Tail-sliced + whitespace-collapsed to a compact one-line preview — the ledger never
416
+ // records this (see the `kind` doc comment); it only drives the live per-slot status.
417
+ onReasoningStream: textSoFar => {
418
+ const tail = textSoFar.length > 200 ? textSoFar.slice(textSoFar.length - 200) : textSoFar;
419
+ const preview = tail.replace(/\s+/g, " ").trim();
420
+ if (preview) emit({ role: role.id, kind: "thinking", detail: preview, step: currentStep, maxSteps, model: activeModel });
421
+ },
422
+ onAssistant: (_raw, invocation) => {
423
+ if (invocation && invocation.tool && invocation.tool !== "done") {
424
+ lastTarget = toolTarget(invocation.tool, invocation.arguments);
425
+ trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
426
+ emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model: activeModel });
427
+ }
428
+ },
429
+ onToolResult: (tool, success, output) => {
430
+ if (success) {
431
+ if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
432
+ else if (tool === "bash") bashRuns++;
433
+ else if (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp") readOnlyEvidenceCalls++;
434
+ }
435
+ const label = lastTarget || tool;
436
+ const summary = firstUsefulLine(output);
437
+ const suffix = summary ? ` — ${summary}` : "";
438
+ trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
439
+ emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model: activeModel });
440
+ lastTarget = "";
441
+ },
442
+ // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
443
+ // parent's monitor shows WHY a subagent is pausing instead of going silent.
444
+ onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model: activeModel }),
445
+ // Mid-turn steering reached this subagent: surface it as a live beat so the
446
+ // parent's monitor shows the redirect instead of an unexplained behavior change.
447
+ onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model: activeModel }),
310
448
  },
311
- // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
312
- // parent's monitor shows WHY a subagent is pausing instead of going silent.
313
- onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
314
- // Mid-turn steering reached this subagent: surface it as a live beat so the
315
- // parent's monitor shows the redirect instead of an unexplained behavior change.
316
- onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
317
- },
318
- });
319
- const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
449
+ });
450
+ };
451
+
452
+ let result = await runOnce();
453
+ for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
454
+ const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
455
+ const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
456
+ if (!rateLimited && !safetyBail) break;
457
+ const previousModel = activeModel;
458
+ if (rateLimited) {
459
+ // The model that just 429'd: exclude its WHOLE credential scope (not just its
460
+ // own id) so a sibling model riding the SAME exhausted OAuth subscription is
461
+ // never proposed as a "fallback" (cca5fe2's fix, mirrored here).
462
+ const failedScope = credentialScopeFor(activeModel, opts.config);
463
+ if (failedScope) excludedCredentialScopes.add(failedScope.key);
464
+ const candidates = fallbackCandidates();
465
+ if (candidates.length === 0) break;
466
+ activeModel = selectFromPool(candidates, undefined);
467
+ emit({ role: role.id, kind: "step", detail: `↳ rate limited on '${previousModel}' — switching to equivalent '${activeModel}'`, step: currentStep, maxSteps, model: activeModel });
468
+ } else {
469
+ // Uncategorized refusal with a fallback available (engine already verified
470
+ // this via safetyFallbackAvailable before bailing) — exclude the WHOLE
471
+ // provider (see safetyFallbackCandidates' doc comment), not just this model.
472
+ excludedSafetyProviders.add(resolveProvider(activeModel));
473
+ const candidates = safetyFallbackCandidates();
474
+ if (candidates.length === 0) break;
475
+ activeModel = selectFromPool(candidates, undefined);
476
+ emit({ role: role.id, kind: "step", detail: `↳ refused on '${previousModel}' — switching to '${activeModel}' in case of a classifier false positive`, step: currentStep, maxSteps, model: activeModel });
477
+ }
478
+ attemptedModels.add(activeModel);
479
+ result = await runOnce();
480
+ }
481
+
482
+ const reason = stripSafetyFallbackTag(result.doneReason?.trim() || "") || `(subagent reached the ${result.steps}-step limit without signaling done)`;
320
483
  const validation = validateSubagentDoneReason(role, reason);
321
484
  const complete = result.done && validation.ok;
322
485
  const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
323
- emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
486
+ emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model: activeModel, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
324
487
  const tokNote = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tok` : "";
325
- const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${model}${tokNote}.`;
488
+ const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${activeModel}${tokNote}.`;
326
489
  const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
327
490
  // Parent-side audit: a mutating role that "completed" without a successful file
328
491
  // mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
@@ -342,6 +505,7 @@ export async function runSubagentOnce(
342
505
  missingMarkers: validation.ok ? undefined : validation.missing,
343
506
  fileMutations,
344
507
  bashRuns,
508
+ readOnlyEvidenceCalls,
345
509
  };
346
510
  }
347
511
 
@@ -353,17 +517,22 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
353
517
  /** Run ONE subagent via the shared execution core, binding this tool instance's
354
518
  * config/signal/onEvent (the original single-task path). */
355
519
  const runOne = (
356
- role: ReturnType<typeof getSubagentRole> & {},
520
+ role: SubagentRole,
357
521
  taskText: string,
358
522
  context: string,
359
523
  cwd: string,
360
524
  extra: {
361
525
  steer?: () => string[];
362
526
  slot?: { index: number; total: number };
363
- projectContext?: Awaited<ReturnType<typeof loadProjectContext>>;
527
+ projectContext?: ProjectContextFile[];
364
528
  /** Overrides opts.signal — a detached run uses its own registry signal so it
365
529
  * is cancellable independently of the parent turn. */
366
530
  signal?: AbortSignal;
531
+ /** Batch-shared exhausted-scope set — see RunSubagentOptions' doc comment.
532
+ * Omitted for the single-task/detached paths (each gets its own local Set). */
533
+ excludedCredentialScopes?: Set<string>;
534
+ /** See RunSubagentOptions.modelOverride. */
535
+ modelOverride?: string;
367
536
  } = {},
368
537
  ): Promise<ToolResult> =>
369
538
  runSubagentOnce(role, taskText, context, cwd, {
@@ -373,6 +542,8 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
373
542
  steer: extra.steer,
374
543
  slot: extra.slot,
375
544
  projectContext: extra.projectContext,
545
+ excludedCredentialScopes: extra.excludedCredentialScopes,
546
+ modelOverride: extra.modelOverride,
376
547
  });
377
548
 
378
549
  return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
@@ -435,6 +606,24 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
435
606
  // Load project context ONCE per batch instead of re-scanning AGENTS.md for
436
607
  // every fan-out task (redundant IO + duplicated tokens).
437
608
  const batchContext = await loadProjectContext(cwd);
609
+ // Cost tier: an UNPINNED fan-out batch (no explicit 'role' arg — the common
610
+ // case, MAX_FANOUT concurrent items all defaulting to executor) is bulk/
611
+ // high-volume work by construction, not a single deep task — default it to
612
+ // a mid-tier model instead of executor's normal strongest-tier resolution.
613
+ // An EXPLICIT role arg (a caller deliberately fanning out e.g. architect
614
+ // reviews) is left untouched: that is a real per-role choice, not the
615
+ // "whatever's unpinned" case this discount targets. roles.high (explicit
616
+ // pin) wins first, mirroring 'planner''s own resolution order.
617
+ const fanoutModelOverride = roleArg
618
+ ? undefined
619
+ : (opts.config.roles?.high || strongestMidTierCredentialed(opts.config) || undefined);
620
+ // Batch-scoped, shared across every CONCURRENT worker in THIS fan-out (not
621
+ // across separate `task` calls) — see RunSubagentOptions.excludedCredentialScopes'
622
+ // doc comment: several workers can 429 the SAME OAuth-scoped model near-
623
+ // simultaneously (the common case, since bare `task` calls all default to
624
+ // `executor`'s single resolved model), so the first worker to exclude a
625
+ // scope saves every sibling worker the same wasted round.
626
+ const batchExcludedScopes = new Set<string>();
438
627
  const results: ToolResult[] = new Array(items.length);
439
628
  let next = 0;
440
629
  // #7: broadcast steering hub — each concurrent worker sees every parent
@@ -452,7 +641,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
452
641
  // dead/misleading once the executor stopped being forced-serial). Each
453
642
  // task runs isolated on its own slice of context; a task that genuinely
454
643
  // depends on another's output belongs in a sequential follow-up call.
455
- results[i] = await runOne(role, items[i]!.task, items[i]!.context, cwd, { slot: { index: i + 1, total: items.length }, projectContext: batchContext, steer: workerSteer });
644
+ results[i] = await runOne(role, items[i]!.task, items[i]!.context, cwd, { slot: { index: i + 1, total: items.length }, projectContext: batchContext, steer: workerSteer, excludedCredentialScopes: batchExcludedScopes, modelOverride: fanoutModelOverride });
456
645
  }
457
646
 
458
647
  };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Vision self-check loop (maker -> renderer -> vision-verifier -> caller), the
3
+ * missing half of the browser tool's screenshot capability: `tab.screenshot()`
4
+ * already captures pixels, but nothing previously fed those bytes to a vision-
5
+ * capable model for judgment — the tool's own protocol line told the calling
6
+ * agent to "prefer observe over screenshot", so a screenshot was a human-only
7
+ * debug artifact (a temp file path), never an automated pass/fail signal.
8
+ *
9
+ * `visionVerify` closes that loop: render -> screenshot -> attach as an
10
+ * ImageAttachment -> ask an INDEPENDENT model call (never the maker's own turn)
11
+ * to compare the image against a plain-language goal, returning a structured
12
+ * PASS/MISMATCH verdict plus a concrete diff on mismatch. Mirrors
13
+ * `goal-verifier.ts`'s shape (independent judge, JSON verdict, graceful parse
14
+ * failure -> conservative MISMATCH) so the two verifier call sites stay
15
+ * consistent for anyone reading either file.
16
+ */
17
+ import { callLlm } from "./loop";
18
+ import { tryExtractJsonObject } from "./json";
19
+ import type { ImageAttachment } from "../ai/types";
20
+
21
+ export interface VisionVerdict {
22
+ verdict: "PASS" | "MISMATCH";
23
+ detail: string;
24
+ }
25
+
26
+ /**
27
+ * Compare a screenshot against a plain-language goal (optionally scoped by
28
+ * design tokens/constraints) using a vision-capable model. `priorImage`, when
29
+ * given, is attached alongside `image` so the verifier can also judge "what
30
+ * changed" (e.g. confirming a specific element updated, not just that SOME
31
+ * state now matches the goal).
32
+ */
33
+ export async function visionVerify(
34
+ image: ImageAttachment,
35
+ goal: string,
36
+ opts: { model?: string; designTokens?: string; priorImage?: ImageAttachment } = {},
37
+ ): Promise<VisionVerdict> {
38
+ const goalTrimmed = goal.trim();
39
+ if (!goalTrimmed) {
40
+ return { verdict: "MISMATCH", detail: "vision-verify requires a non-empty 'goal' to compare the screenshot against." };
41
+ }
42
+
43
+ const systemPrompt = `You are an independent Vision Verifier. You are shown a screenshot and a goal it should satisfy. Judge ONLY what is visible in the image(s) against the goal — you have no access to the code or reasoning that produced it.
44
+
45
+ Goal:
46
+ "${goalTrimmed}"
47
+ ${opts.designTokens ? `\nDesign constraints/tokens to check against:\n${opts.designTokens}\n` : ""}
48
+ ${opts.priorImage ? "\nTwo images are attached: the FIRST is the prior state, the SECOND is the current state. Judge whether the current state now satisfies the goal (the prior image is context for what changed, not itself the thing being judged).\n" : "\nOne image is attached: the current state to judge against the goal.\n"}
49
+ Verdict discipline:
50
+ - PASS only when the visible content clearly satisfies the goal as written. Do not reframe or narrow the goal to fit what you see.
51
+ - On MISMATCH, describe the concrete, visible gap (what is missing, wrong, or different from the goal) precisely enough that another agent could act on it without re-looking at the image.
52
+ - Judge only what is visibly present. Do not assume off-screen or unrendered content satisfies the goal.
53
+
54
+ Respond with ONLY a JSON object: {"verdict": "PASS" | "MISMATCH", "detail": "<one paragraph, concrete>"}. No markdown, no code fences, no other text.`;
55
+
56
+ const images = opts.priorImage ? [opts.priorImage, image] : [image];
57
+
58
+ try {
59
+ const response = await callLlm(
60
+ [
61
+ { role: "system", content: systemPrompt },
62
+ { role: "user", content: "Judge the attached screenshot(s) against the goal above.", images },
63
+ ],
64
+ { model: opts.model, jsonMode: true, maxTokens: 600, reasoningEffort: "none" },
65
+ );
66
+
67
+ const parsed = tryExtractJsonObject<{ verdict?: unknown; detail?: unknown }>(response);
68
+ if (parsed && (parsed.verdict === "PASS" || parsed.verdict === "MISMATCH") && typeof parsed.detail === "string") {
69
+ return { verdict: parsed.verdict, detail: parsed.detail };
70
+ }
71
+ return { verdict: "MISMATCH", detail: `Vision verifier returned an unparseable response: ${response.slice(0, 300)}` };
72
+ } catch (err) {
73
+ return { verdict: "MISMATCH", detail: `Vision verification failed to execute: ${(err as Error).message}` };
74
+ }
75
+ }
@@ -353,6 +353,22 @@ export function liveProviderCatalogModels(config?: { openaiBaseUrl?: string }):
353
353
  .map(record => record.row);
354
354
  }
355
355
 
356
+ /** Live-discovered catalog row for `canonical`, regardless of config/base-URL scope —
357
+ * used by `resolveProvider` to recover a live-discovered model's ALREADY-CORRECT
358
+ * `.provider` tag when the static catalog and substring heuristics both miss it (e.g.
359
+ * a brand-neutral id from an OpenAI-compatible aggregator, or any future provider
360
+ * rename). Deliberately NOT config-scoped like `liveProviderCatalogModels`/
361
+ * `isLiveProviderModel` — a model discovered under one base URL is still THE model
362
+ * `resolveProvider` needs to route correctly, independent of THIS call's routing-
363
+ * eligibility (config). Live model count is bounded by session discovery (never
364
+ * large), so a linear scan is cheap — same access pattern as `liveProviderCatalogModels`. */
365
+ export function findLiveCatalogModel(canonical: string): CatalogModel | undefined {
366
+ for (const record of liveProviderModels.values()) {
367
+ if (record.row.canonical === canonical) return record.row;
368
+ }
369
+ return undefined;
370
+ }
371
+
356
372
  /** True when a model came from the current provider/base URL's live model list.
357
373
  * O(1) average case via `liveModelIdIndex` — see that Map's doc comment for why
358
374
  * this must not be an O(n) scan over every live-discovered model (it runs once
@@ -6,7 +6,7 @@ import "./register-providers"; // side-effect: registers built-in adapters into
6
6
  import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
7
7
  import { expandAlias, resolveModelId, effectiveAliasesFor, BUILTIN_ALIASES } from "./model-registry";
8
8
  import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
9
- import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel, isCodexModel } from "./model-catalog";
9
+ import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel, isCodexModel, findLiveCatalogModel } from "./model-catalog";
10
10
  import { xaiCredential } from "./providers/xai";
11
11
  import { OPENAI_COMPAT_NAMES, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
12
12
  import { withRetry, defaultRetryable, withConnectionContext, isConnectionError, ConnectionContextError, type RetryOptions } from "../util/retry";
@@ -21,6 +21,15 @@ export function resolveProvider(model: string): ProviderName {
21
21
  // misroute a future/edge id); heuristics handle everything uncatalogued.
22
22
  const entry = findCatalogEntry(model);
23
23
  if (entry) return entry.provider;
24
+ // Live-discovered rows carry an ALREADY-CORRECT `.provider` tag (set at discovery
25
+ // time by `recordLiveProviderModels`, itself passed the real provider by the
26
+ // caller) — this must win over the substring heuristics below, which only
27
+ // recognize brand names present in the id string (grok/kimi/gpt/gemini) and
28
+ // silently misroute anything else (a future provider rename, or a brand-neutral
29
+ // id from an OpenAI-compatible aggregator like Groq/OpenRouter/Together) to the
30
+ // final `anthropic` fallthrough.
31
+ const liveEntry = findLiveCatalogModel(model);
32
+ if (liveEntry) return liveEntry.provider;
24
33
  const m = (model ?? "").toLowerCase();
25
34
  // Explicit `<provider>/` prefixes ALWAYS win over substring heuristics — a model id
26
35
  // can legitimately contain another provider's name (e.g. `synthetic/hf:moonshotai/Kimi-K2.5`
@@ -305,7 +314,7 @@ export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "s
305
314
  * OAuth. Everything else (Anthropic Messages, Antigravity Cloud Code Assist) is served
306
315
  * end-to-end.
307
316
  */
308
- function oauthServesModel(provider: AuthProvider, model: string): boolean {
317
+ export function oauthServesModel(provider: AuthProvider, model: string): boolean {
309
318
  if (provider === "openai") {
310
319
  // `isCodexModel` checks the maintained CODEX_MODELS list PLUS any id observed
311
320
  // live this session (model-catalog.ts) — closes the "picker shows a model the
package/src/ai/types.ts CHANGED
@@ -120,8 +120,10 @@ export interface CallOptions {
120
120
  * hits instead of full-prompt re-reads. Optional — absent for one-shot calls. */
121
121
  sessionKey?: string;
122
122
  /** Notified before each auto-retry backoff wait (rate limits / transient errors).
123
- * NOT forwarded to provider adapters — consumed by the manager's retry layer. */
124
- onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
123
+ * NOT forwarded to provider adapters — consumed by the manager's retry layer.
124
+ * Returning `false` aborts the retry immediately instead of backing off and
125
+ * re-attempting (see util/retry.ts's RetryOptions.onRetry). */
126
+ onRetry?: (attempt: number, err: unknown, delayMs: number) => void | false;
125
127
  /** Streaming sink for native model reasoning/thinking text deltas (separate from the
126
128
  * answer text). Surfaced as a transient dimmed view; absent for models that emit no
127
129
  * thought text. */