jeo-code 0.8.22 → 0.8.23

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.
@@ -14,7 +14,7 @@ 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
33
  import { resolveMaxOutputTokens } 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). */
@@ -91,6 +109,16 @@ export interface TaskToolOptions {
91
109
  * no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
92
110
  const MAX_FANOUT = 4;
93
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;
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
96
124
  * long time. Split larger efforts into sequential task calls. */
@@ -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
@@ -227,7 +274,7 @@ export interface SubagentRunResult extends ToolResult {
227
274
  * plan step.
228
275
  */
229
276
  export async function runSubagentOnce(
230
- role: ReturnType<typeof getSubagentRole> & {},
277
+ role: SubagentRole,
231
278
  taskText: string,
232
279
  context: string,
233
280
  cwd: string,
@@ -238,7 +285,7 @@ export async function runSubagentOnce(
238
285
  // task 1 from task 3 when several same-role subagents stream concurrently.
239
286
  const emit = (ev: TaskSubEvent) =>
240
287
  opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
241
- const model = resolveSubagentModel(role.id, opts.config);
288
+ const initialModel = opts.modelOverride || resolveSubagentModel(role.id, opts.config);
242
289
  const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
243
290
  // gjc parity: a role may pin its own reasoning budget; absent = inherit the
244
291
  // session/global thinking level (the "(inherit)" row in the picker).
@@ -258,71 +305,141 @@ export async function runSubagentOnce(
258
305
  // calls so the parent can audit a "Changed Files:" claim against observed
259
306
  // reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
260
307
  // 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.
308
+ // bash CAN mutate, so the audit message distinguishes the two cases. These
309
+ // accumulate ACROSS a rate-limit reroute below — a step that already ran (and
310
+ // its file writes/bash calls) stays real evidence even if a LATER step 429s
311
+ // and switches models; only `history` state matters for what actually happened,
312
+ // never which model dispatched a given step.
262
313
  let fileMutations = 0;
263
314
  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 = "";
315
+ // Per-run prompt-cache key: the subagent replays its own growing history each
316
+ // step, so a stable key (even across a model switch below — a stale cache key
317
+ // on a NEW provider is simply unused, never wrong) gets provider cache hits
318
+ // (gjc sub-session parity).
319
+ const sessionKey = crypto.randomUUID();
320
+
321
+ // --- Rate-limit fast fallback (mirrors launch.ts's `rateLimitFallbackAvailable`/
322
+ // `equivalentRouteFallback` pattern — see df3475d/cca5fe2/02b7e59's commit docs for
323
+ // the full design history) adapted to a SUBAGENT's narrower, bounded context: a
324
+ // subagent is already bounded by its own step/token budget, so this stays a SMALL
325
+ // reroute loop (MAX_SUBAGENT_REROUTES extra attempts) instead of launch.ts's fuller
326
+ // multi-round loop with live provider-reachability probing. Pairing the bail
327
+ // predicate with an ACTUAL reroute-and-retry loop is not optional — per 02b7e59's
328
+ // doc comment, bailing the retry ladder early WITHOUT a real switch-and-retry
329
+ // around it is a net regression (fails faster with the identical outcome, trading
330
+ // away the one real win the full backoff still offers: a transient <~90s rate-limit
331
+ // blip clearing mid-wait and the ORIGINAL call succeeding).
332
+ const tier = inferTierForModel(initialModel);
333
+ const attemptedModels = new Set<string>([initialModel]);
334
+ // Batch-shared when a fan-out worker passes one (see RunSubagentOptions'
335
+ // doc comment); a fresh local Set for a single-task/detached run.
336
+ const excludedCredentialScopes = opts.excludedCredentialScopes ?? new Set<string>();
337
+ // Synchronous, config-only candidate search — same tierModelPool/credentialScopeFor
338
+ // filtering launch.ts's fast-bail predicate uses, deliberately WITHOUT the async
339
+ // describeModel/describeProvider/local-reachability validation `equivalentRouteFallback`
340
+ // does: a bounded 1-2-attempt subagent reroute does not warrant that extra latency/
341
+ // complexity — tierModelPool already filters to config-servable models
342
+ // (modelServableWithConfig), so a candidate here is at minimum "configured", even if
343
+ // (rarely) it turns out unreachable mid-call, in which case the reroute loop below
344
+ // just reports that real failure instead of masking it.
345
+ const fallbackCandidates = (): string[] => {
346
+ const currentScope = credentialScopeFor(activeModel, opts.config);
347
+ return tierModelPool(tier, opts.config)
348
+ .filter(m => !attemptedModels.has(m))
349
+ .filter(m => {
350
+ const scope = credentialScopeFor(m, opts.config);
351
+ if (!scope) return true; // API-key-served (or keyless) — independent budget
352
+ if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
353
+ return !excludedCredentialScopes.has(scope.key);
354
+ });
355
+ };
356
+ // Fed to AgentLoopOptions.rateLimitFallbackAvailable — lets the engine bail a 429
357
+ // retry ladder on the FIRST failed attempt instead of riding ~90s of backoff when
358
+ // a genuinely different-credential-scope candidate is available RIGHT NOW.
359
+ const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
360
+
361
+ let activeModel = initialModel;
362
+ emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
363
+ const runOnce = () => {
364
+ return runAgentLoop(history, {
365
+ cwd,
366
+ model: activeModel,
367
+ maxSteps,
368
+ maxTokens: resolveMaxOutputTokens(activeModel, thinking),
369
+ sessionKey,
370
+ // Bounded delegation: a subagent's step contract stays exact — the parent
371
+ // owns any retry/extension decision, so the gjc retry flow is disabled here.
372
+ budget: { maxExtensions: 0 },
373
+ signal,
374
+ steer,
375
+ tools: subagentToolset(role),
376
+ rateLimitFallbackAvailable,
377
+ events: {
378
+ onStep: n => { currentStep = n; },
379
+ // Live reasoning preview (native extended-thinking models only — the JSON-protocol
380
+ // "reasoning" field the main turn also extracts from onModelStream is intentionally
381
+ // NOT wired here: a subagent's forming tool-call JSON is rarely useful mid-stream and
382
+ // doubling the stream sinks would double emit() calls for the same underlying delta).
383
+ // Tail-sliced + whitespace-collapsed to a compact one-line preview — the ledger never
384
+ // records this (see the `kind` doc comment); it only drives the live per-slot status.
385
+ onReasoningStream: textSoFar => {
386
+ const tail = textSoFar.length > 200 ? textSoFar.slice(textSoFar.length - 200) : textSoFar;
387
+ const preview = tail.replace(/\s+/g, " ").trim();
388
+ if (preview) emit({ role: role.id, kind: "thinking", detail: preview, step: currentStep, maxSteps, model: activeModel });
389
+ },
390
+ onAssistant: (_raw, invocation) => {
391
+ if (invocation && invocation.tool && invocation.tool !== "done") {
392
+ lastTarget = toolTarget(invocation.tool, invocation.arguments);
393
+ trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
394
+ emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model: activeModel });
395
+ }
396
+ },
397
+ onToolResult: (tool, success, output) => {
398
+ if (success) {
399
+ if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
400
+ else if (tool === "bash") bashRuns++;
401
+ }
402
+ const label = lastTarget || tool;
403
+ const summary = firstUsefulLine(output);
404
+ const suffix = summary ? ` — ${summary}` : "";
405
+ trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
406
+ emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model: activeModel });
407
+ lastTarget = "";
408
+ },
409
+ // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
410
+ // parent's monitor shows WHY a subagent is pausing instead of going silent.
411
+ onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model: activeModel }),
412
+ // Mid-turn steering reached this subagent: surface it as a live beat so the
413
+ // parent's monitor shows the redirect instead of an unexplained behavior change.
414
+ onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model: activeModel }),
310
415
  },
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
- });
416
+ });
417
+ };
418
+
419
+ let result = await runOnce();
420
+ for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
421
+ if (result.done || !isRateLimitError(new Error(result.doneReason ?? ""))) break;
422
+ // The model that just 429'd: exclude its WHOLE credential scope (not just its
423
+ // own id) so a sibling model riding the SAME exhausted OAuth subscription is
424
+ // never proposed as a "fallback" (cca5fe2's fix, mirrored here).
425
+ const failedScope = credentialScopeFor(activeModel, opts.config);
426
+ if (failedScope) excludedCredentialScopes.add(failedScope.key);
427
+ const candidates = fallbackCandidates();
428
+ if (candidates.length === 0) break;
429
+ const previousModel = activeModel;
430
+ activeModel = selectFromPool(candidates, undefined);
431
+ attemptedModels.add(activeModel);
432
+ emit({ role: role.id, kind: "step", detail: `↳ rate limited on '${previousModel}' — switching to equivalent '${activeModel}'`, step: currentStep, maxSteps, model: activeModel });
433
+ result = await runOnce();
434
+ }
435
+
319
436
  const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
320
437
  const validation = validateSubagentDoneReason(role, reason);
321
438
  const complete = result.done && validation.ok;
322
439
  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 });
440
+ 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
441
  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}.`;
442
+ const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${activeModel}${tokNote}.`;
326
443
  const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
327
444
  // Parent-side audit: a mutating role that "completed" without a successful file
328
445
  // mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
@@ -353,17 +470,22 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
353
470
  /** Run ONE subagent via the shared execution core, binding this tool instance's
354
471
  * config/signal/onEvent (the original single-task path). */
355
472
  const runOne = (
356
- role: ReturnType<typeof getSubagentRole> & {},
473
+ role: SubagentRole,
357
474
  taskText: string,
358
475
  context: string,
359
476
  cwd: string,
360
477
  extra: {
361
478
  steer?: () => string[];
362
479
  slot?: { index: number; total: number };
363
- projectContext?: Awaited<ReturnType<typeof loadProjectContext>>;
480
+ projectContext?: ProjectContextFile[];
364
481
  /** Overrides opts.signal — a detached run uses its own registry signal so it
365
482
  * is cancellable independently of the parent turn. */
366
483
  signal?: AbortSignal;
484
+ /** Batch-shared exhausted-scope set — see RunSubagentOptions' doc comment.
485
+ * Omitted for the single-task/detached paths (each gets its own local Set). */
486
+ excludedCredentialScopes?: Set<string>;
487
+ /** See RunSubagentOptions.modelOverride. */
488
+ modelOverride?: string;
367
489
  } = {},
368
490
  ): Promise<ToolResult> =>
369
491
  runSubagentOnce(role, taskText, context, cwd, {
@@ -373,6 +495,8 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
373
495
  steer: extra.steer,
374
496
  slot: extra.slot,
375
497
  projectContext: extra.projectContext,
498
+ excludedCredentialScopes: extra.excludedCredentialScopes,
499
+ modelOverride: extra.modelOverride,
376
500
  });
377
501
 
378
502
  return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
@@ -435,6 +559,24 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
435
559
  // Load project context ONCE per batch instead of re-scanning AGENTS.md for
436
560
  // every fan-out task (redundant IO + duplicated tokens).
437
561
  const batchContext = await loadProjectContext(cwd);
562
+ // Cost tier: an UNPINNED fan-out batch (no explicit 'role' arg — the common
563
+ // case, MAX_FANOUT concurrent items all defaulting to executor) is bulk/
564
+ // high-volume work by construction, not a single deep task — default it to
565
+ // a mid-tier model instead of executor's normal strongest-tier resolution.
566
+ // An EXPLICIT role arg (a caller deliberately fanning out e.g. architect
567
+ // reviews) is left untouched: that is a real per-role choice, not the
568
+ // "whatever's unpinned" case this discount targets. roles.high (explicit
569
+ // pin) wins first, mirroring 'planner''s own resolution order.
570
+ const fanoutModelOverride = roleArg
571
+ ? undefined
572
+ : (opts.config.roles?.high || strongestMidTierCredentialed(opts.config) || undefined);
573
+ // Batch-scoped, shared across every CONCURRENT worker in THIS fan-out (not
574
+ // across separate `task` calls) — see RunSubagentOptions.excludedCredentialScopes'
575
+ // doc comment: several workers can 429 the SAME OAuth-scoped model near-
576
+ // simultaneously (the common case, since bare `task` calls all default to
577
+ // `executor`'s single resolved model), so the first worker to exclude a
578
+ // scope saves every sibling worker the same wasted round.
579
+ const batchExcludedScopes = new Set<string>();
438
580
  const results: ToolResult[] = new Array(items.length);
439
581
  let next = 0;
440
582
  // #7: broadcast steering hub — each concurrent worker sees every parent
@@ -452,7 +594,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
452
594
  // dead/misleading once the executor stopped being forced-serial). Each
453
595
  // task runs isolated on its own slice of context; a task that genuinely
454
596
  // 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 });
597
+ 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
598
  }
457
599
 
458
600
  };
@@ -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. */
package/src/cli/runner.ts CHANGED
@@ -119,11 +119,23 @@ export const COMMANDS: readonly CommandSpec[] = [
119
119
  return args => m.runDaemonCommand(args);
120
120
  },
121
121
  },
122
+ {
123
+ name: "routine",
124
+ summary: "Generate a GitHub Actions workflow that runs jeo headlessly on a schedule or repo event.",
125
+ usage: 'routine init --trigger <schedule|issues|pull_request> --prompt "<task>" [--cron <expr>] [--name <name>] [--out <path>] [--no-pr] [--force] [--dry-run] [--json]',
126
+ loader: async () => {
127
+ // Dynamic import: lazy-loaded command registry entry (CommandSpec.loader
128
+ // contract), matching every other entry in this array — avoids eagerly
129
+ // importing all ~25 command modules at CLI startup.
130
+ const m = await import("../commands/routine");
131
+ return args => m.runRoutineCommand(args);
132
+ },
133
+ },
122
134
 
123
135
  {
124
136
  name: "skills",
125
137
  summary: "List bundled workflow skills, or sync them into ~/.jeo/skills (jeo skills <name> for details).",
126
- usage: "skills [list|read <name>|sync [--check|--force] [dir]|--write [dir]] [--json]",
138
+ usage: "skills [list|read <name>|sync [--check|--force] [dir]|--write [dir]|lesson <skill> <failure|anti-pattern> \"<title>\" \"<detail>\"|eval <skill>] [--json]",
127
139
  loader: async () => {
128
140
  const m = await import("../commands/skills");
129
141
  return args => m.runSkillsCommand(args);