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.
- package/CHANGELOG.md +21 -0
- package/README.ja.md +14 -2
- package/README.ko.md +14 -2
- package/README.md +14 -2
- package/README.zh.md +14 -2
- 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 +24 -4
- package/src/agent/loop.ts +3 -2
- package/src/agent/prompt-router.ts +83 -4
- package/src/agent/skill-lessons.ts +315 -0
- package/src/agent/subagents.ts +7 -6
- package/src/agent/task-tool.ts +210 -68
- 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 +118 -23
- package/src/commands/ralplan.ts +40 -3
- package/src/commands/routine.ts +249 -0
- package/src/commands/skills.ts +57 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -227,7 +274,7 @@ export interface SubagentRunResult extends ToolResult {
|
|
|
227
274
|
* plan step.
|
|
228
275
|
*/
|
|
229
276
|
export async function runSubagentOnce(
|
|
230
|
-
role:
|
|
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
|
|
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
|
-
|
|
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
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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 ${
|
|
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:
|
|
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?:
|
|
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
|
+
}
|
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. */
|
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);
|