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.
- package/CHANGELOG.md +37 -0
- package/README.ja.md +19 -3
- package/README.ko.md +19 -3
- package/README.md +19 -3
- package/README.zh.md +19 -3
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/browser-tool.ts +32 -3
- package/src/agent/engine.ts +80 -4
- package/src/agent/eval-tool.ts +433 -0
- package/src/agent/loop.ts +3 -2
- package/src/agent/memory.ts +24 -3
- package/src/agent/plan.ts +55 -0
- package/src/agent/prompt-router.ts +83 -4
- package/src/agent/skill-lessons.ts +316 -0
- package/src/agent/state.ts +26 -0
- package/src/agent/subagents.ts +7 -6
- package/src/agent/task-tool.ts +261 -72
- package/src/agent/vision-verify.ts +75 -0
- package/src/ai/model-catalog.ts +16 -0
- package/src/ai/model-manager.ts +11 -2
- package/src/ai/types.ts +4 -2
- package/src/cli/runner.ts +13 -1
- package/src/commands/launch.ts +197 -26
- package/src/commands/ralplan.ts +85 -15
- package/src/commands/routine.ts +249 -0
- package/src/commands/skills.ts +57 -0
- package/src/commands/team.ts +19 -0
- package/src/util/provider-error.ts +15 -0
- package/src/util/retry.ts +15 -4
- package/src/util/routine-template.ts +206 -0
package/src/agent/task-tool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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?:
|
|
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:
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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 ${
|
|
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:
|
|
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?:
|
|
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
|
+
}
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -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
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -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
|
-
|
|
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. */
|