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/commands/launch.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { executeComputerAction } from "./computer";
|
|
|
8
8
|
import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
|
|
9
9
|
import { initialDynamicStepLimit } from "../agent/step-budget";
|
|
10
10
|
import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
|
|
11
|
+
import { recordSkillLesson } from "../agent/skill-lessons";
|
|
11
12
|
import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
|
|
12
13
|
import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
|
|
13
14
|
import { SubagentRegistry } from "../agent/subagent-registry";
|
|
@@ -51,7 +52,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
|
|
|
51
52
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
52
53
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
53
54
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
|
|
54
|
-
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
|
|
55
|
+
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, credentialScopeFor, resolveVerifierModel, type PromptTier, type RouteDecision, type RoutingConfig } from "../agent/prompt-router";
|
|
55
56
|
import { RouteHistory } from "../agent/route-history";
|
|
56
57
|
|
|
57
58
|
import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
|
|
@@ -976,11 +977,54 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
976
977
|
const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
|
|
977
978
|
let routeCredentialNotice: string | undefined;
|
|
978
979
|
let routeVetoNote: string | undefined;
|
|
980
|
+
// Credential SCOPES that have shown an ACCOUNT-LEVEL failure (rate limit,
|
|
981
|
+
// usage/quota limit, or zero usable credential) THIS turn — accumulates across
|
|
982
|
+
// every fallback round. Read by both `equivalentRouteFallback` (post/pre-call
|
|
983
|
+
// fallback search) and `rateLimitFallbackAvailable` (the engine's fast-bail
|
|
984
|
+
// predicate) so neither path proposes a candidate doomed to fail the same way.
|
|
985
|
+
//
|
|
986
|
+
// Scoped to the CREDENTIAL, not the bare provider (see `credentialScopeFor` in
|
|
987
|
+
// prompt-router.ts for the full classification rationale/doc comment) — an OAuth
|
|
988
|
+
// SUBSCRIPTION shares ONE account-wide rate-limit window across every model it
|
|
989
|
+
// serves; an API key is typically its OWN independent per-key/per-model budget.
|
|
990
|
+
// `null` (from `credentialScopeFor`) means "API-key-served, or otherwise not
|
|
991
|
+
// sharing a subscription credential" — never added to or matched against this set.
|
|
992
|
+
const excludedCredentialScopes = new Set<string>();
|
|
993
|
+
// Matches routeFailureReason's post-call reasons ("hit a rate limit", "hit a
|
|
994
|
+
// usage/quota limit", "is unauthenticated for this session", "requires
|
|
995
|
+
// billing/payment on this provider account") AND the pre-call credential gate's
|
|
996
|
+
// "(<provider>) has no usable credential this turn" — every string that means
|
|
997
|
+
// "this CREDENTIAL is exhausted/broken", as opposed to a model-specific issue
|
|
998
|
+
// (OAuth serves only some models, a single model is retired/gated — see
|
|
999
|
+
// routeFailureReason's own doc comments for why those stay model-scoped).
|
|
1000
|
+
//
|
|
1001
|
+
// Billing/payment (402) is intentionally included even though the ORIGINAL field
|
|
1002
|
+
// observation behind routeFailureReason's 402 branch (Tencent: kimi-k2.5/
|
|
1003
|
+
// deepseek-v3.2 402 while deepseek-v4-flash/glm-5.2 kept working on the SAME key)
|
|
1004
|
+
// is a per-MODEL billing block on an API-KEY-served provider — this does NOT
|
|
1005
|
+
// contradict including it here: `credentialScopeFor` already returns `null` for
|
|
1006
|
+
// every API-key-served model (never group-excluded regardless of this regex's
|
|
1007
|
+
// answer), so the Tencent case is unaffected either way. What this DOES newly
|
|
1008
|
+
// catch: an OAuth SUBSCRIPTION billing failure (e.g. a Claude Pro/Max payment
|
|
1009
|
+
// lapse) is account-wide by construction — every model riding that subscription
|
|
1010
|
+
// is equally blocked — and previously wasted a full fallback round retrying a
|
|
1011
|
+
// same-scope sibling model guaranteed to 402 identically before ever reaching a
|
|
1012
|
+
// genuinely different credential.
|
|
1013
|
+
const isAccountLevelRouteFailure = (reason: string): boolean =>
|
|
1014
|
+
/hit a rate limit|hit a usage\/quota limit|is unauthenticated for this session|has no usable credential|requires billing\/payment on this provider account/i.test(reason);
|
|
979
1015
|
const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
|
|
1016
|
+
if (isAccountLevelRouteFailure(reason)) {
|
|
1017
|
+
const scope = credentialScopeFor(decision.model, turnConfig);
|
|
1018
|
+
if (scope) excludedCredentialScopes.add(scope.key);
|
|
1019
|
+
}
|
|
980
1020
|
const excluded = new Set([decision.model, ...exclude]);
|
|
981
1021
|
const poolCandidates = (): string[] =>
|
|
982
1022
|
tierModelPool(decision.tier, turnConfig)
|
|
983
1023
|
.filter(model => !excluded.has(model))
|
|
1024
|
+
.filter(model => {
|
|
1025
|
+
const scope = credentialScopeFor(model, turnConfig);
|
|
1026
|
+
return !scope || !excludedCredentialScopes.has(scope.key);
|
|
1027
|
+
})
|
|
984
1028
|
.filter(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
985
1029
|
let candidates = poolCandidates();
|
|
986
1030
|
// The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
|
|
@@ -1383,7 +1427,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1383
1427
|
}
|
|
1384
1428
|
|
|
1385
1429
|
if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
|
|
1386
|
-
const llmVerdict = await verifyGoal(goalState.condition, history,
|
|
1430
|
+
const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
|
|
1387
1431
|
// Deterministic downgrade: a fresh MET from the transcript-only LLM
|
|
1388
1432
|
// judge still needs mutation/verification evidence to back it up —
|
|
1389
1433
|
// see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
|
|
@@ -1448,6 +1492,57 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1448
1492
|
tags: ["jeo", "launch"],
|
|
1449
1493
|
});
|
|
1450
1494
|
opik.startTurn();
|
|
1495
|
+
// Hoisted above `runLoopWithModel` (moved from after its first call) so the
|
|
1496
|
+
// rate-limit fast-fallback predicate below can see the CURRENT attempted-model
|
|
1497
|
+
// set on every call, including the very first one. `attemptedRouteModels` is
|
|
1498
|
+
// mutated in place (`.add`) as the fallback loop below tries more models — this
|
|
1499
|
+
// closure always reads its live contents, never a stale snapshot.
|
|
1500
|
+
const attemptedRouteModels = new Set<string>([activeModel]);
|
|
1501
|
+
// A NON-routed turn (routing disabled, or the user pinned a model via /model)
|
|
1502
|
+
// still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
|
|
1503
|
+
// failure — the exact "5-hour usage window exhausted, model just stops working"
|
|
1504
|
+
// case this loop exists to fix. Synthesize a same-size-class RouteDecision
|
|
1505
|
+
// (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
|
|
1506
|
+
// with routing off; a real `routed` decision (routing enabled) is used unchanged.
|
|
1507
|
+
let fallbackBaseDecision: RouteDecision = routed ?? {
|
|
1508
|
+
model: activeModel,
|
|
1509
|
+
thinking: activeThinking,
|
|
1510
|
+
tier: inferTierForModel(activeModel),
|
|
1511
|
+
confidence: 1,
|
|
1512
|
+
source: "heuristic",
|
|
1513
|
+
signals: [],
|
|
1514
|
+
};
|
|
1515
|
+
// Cheap SYNC check (no network/credential probing — that full validation is
|
|
1516
|
+
// `equivalentRouteFallback`'s job when a switch actually happens) for "does an
|
|
1517
|
+
// untried, DIFFERENT-PROVIDER candidate exist RIGHT NOW". Feeds
|
|
1518
|
+
// AgentLoopOptions.rateLimitFallbackAvailable so the engine can bail a 429 retry
|
|
1519
|
+
// ladder on the first failed attempt instead of riding ~90s of backoff on a
|
|
1520
|
+
// model that has an equivalent sitting right there. Mirrors
|
|
1521
|
+
// `equivalentRouteFallback`'s own `poolCandidates` filters (tier pool minus
|
|
1522
|
+
// already-tried models minus image-incapable models when this turn has images)
|
|
1523
|
+
// PLUS excludes any candidate sharing the SAME CREDENTIAL SCOPE as the model
|
|
1524
|
+
// CURRENTLY being attempted (a 429 on this model means ITS credential's
|
|
1525
|
+
// account/subscription window is exhausted THIS instant — another model
|
|
1526
|
+
// riding the SAME OAuth subscription hits that exact limit and is not a real
|
|
1527
|
+
// fallback; an API-key-served candidate has an independent budget and is NOT
|
|
1528
|
+
// excluded) and any scope already excluded by an earlier account-level
|
|
1529
|
+
// failure this turn — see `excludedCredentialScopes`'s doc comment. Without
|
|
1530
|
+
// this, the predicate could see e.g. `claude-sonnet-4-6` as "available" while
|
|
1531
|
+
// `claude-sonnet-5` (same Anthropic OAuth subscription) is the one currently
|
|
1532
|
+
// 429ing, bail, switch to it, and immediately re-429 — which looks
|
|
1533
|
+
// indistinguishable from "never escaping auto-retry #1".
|
|
1534
|
+
const rateLimitFallbackAvailable = (): boolean => {
|
|
1535
|
+
const currentScope = credentialScopeFor(fallbackBaseDecision.model, turnConfig);
|
|
1536
|
+
return tierModelPool(fallbackBaseDecision.tier, turnConfig)
|
|
1537
|
+
.filter(model => !attemptedRouteModels.has(model))
|
|
1538
|
+
.filter(model => {
|
|
1539
|
+
const scope = credentialScopeFor(model, turnConfig);
|
|
1540
|
+
if (!scope) return true; // API-key-served (or keyless) — independent budget
|
|
1541
|
+
if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
|
|
1542
|
+
return !excludedCredentialScopes.has(scope.key);
|
|
1543
|
+
})
|
|
1544
|
+
.some(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
1545
|
+
};
|
|
1451
1546
|
const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
|
|
1452
1547
|
cwd,
|
|
1453
1548
|
tools,
|
|
@@ -1459,6 +1554,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1459
1554
|
signal: ac.signal,
|
|
1460
1555
|
sessionKey: cacheKey,
|
|
1461
1556
|
steer: drainSteer,
|
|
1557
|
+
rateLimitFallbackAvailable,
|
|
1462
1558
|
events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
|
|
1463
1559
|
});
|
|
1464
1560
|
const routeFailureReason = (doneReason: unknown): string | null => {
|
|
@@ -1530,21 +1626,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1530
1626
|
const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
|
|
1531
1627
|
? Math.floor(routeFallbackMaxRaw)
|
|
1532
1628
|
: 3;
|
|
1533
|
-
const attemptedRouteModels = new Set<string>([activeModel]);
|
|
1534
|
-
// A NON-routed turn (routing disabled, or the user pinned a model via /model)
|
|
1535
|
-
// still needs the equivalent-pool fallback on a usage-limit/rate-limit/credential
|
|
1536
|
-
// failure — the exact "5-hour usage window exhausted, model just stops working"
|
|
1537
|
-
// case this loop exists to fix. Synthesize a same-size-class RouteDecision
|
|
1538
|
-
// (inferTierForModel) so `equivalentRouteFallback` has a tier/pool to search even
|
|
1539
|
-
// with routing off; a real `routed` decision (routing enabled) is used unchanged.
|
|
1540
|
-
let fallbackBaseDecision: RouteDecision = routed ?? {
|
|
1541
|
-
model: activeModel,
|
|
1542
|
-
thinking: activeThinking,
|
|
1543
|
-
tier: inferTierForModel(activeModel),
|
|
1544
|
-
confidence: 1,
|
|
1545
|
-
source: "heuristic",
|
|
1546
|
-
signals: [],
|
|
1547
|
-
};
|
|
1548
1629
|
for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
|
|
1549
1630
|
const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
|
|
1550
1631
|
if (!routedFailureReason) break;
|
|
@@ -1592,6 +1673,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1592
1673
|
signal: ac.signal,
|
|
1593
1674
|
sessionKey: turnSessionKey,
|
|
1594
1675
|
steer: drainSteer,
|
|
1676
|
+
// Deliberately NOT wiring rateLimitFallbackAvailable here: this repair
|
|
1677
|
+
// retry has no reroute loop around it (unlike runLoopWithModel's
|
|
1678
|
+
// routeFallbackAttempt loop) — bailing the retry ladder early on a 429
|
|
1679
|
+
// would only fail FASTER with the identical outcome (no fallback model to
|
|
1680
|
+
// switch to either way), trading away the one real win the full backoff
|
|
1681
|
+
// ladder still offers here: a transient (<~90s) rate-limit blip clearing
|
|
1682
|
+
// mid-wait and this retry succeeding. See launch.ts's `rateLimitFallbackAvailable`
|
|
1683
|
+
// doc comment / commit history for why this predicate is paired with an
|
|
1684
|
+
// actual model-switch loop everywhere else it's used.
|
|
1595
1685
|
events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
|
|
1596
1686
|
});
|
|
1597
1687
|
const usage =
|
|
@@ -1685,12 +1775,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1685
1775
|
const tags = Array.from(new Set(
|
|
1686
1776
|
task.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? [],
|
|
1687
1777
|
)).slice(0, 8);
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1778
|
+
const title = `Stalled on: ${excerpt}`;
|
|
1779
|
+
const description = `A prior turn stalled (${why}) on this task — change approach before retrying.`;
|
|
1780
|
+
const body = `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
|
|
1781
|
+
`Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`;
|
|
1782
|
+
void recordFailedAttempt(cwd, { title, description, body, tags }).catch(() => {});
|
|
1783
|
+
// Also offer the same failure to the skill-lessons system (deterministic
|
|
1784
|
+
// free-text -> skill match; writes nothing when no skill clearly relates).
|
|
1785
|
+
void recordSkillLesson(cwd, `${title} ${description}`, {
|
|
1786
|
+
kind: "failure-mode",
|
|
1787
|
+
title,
|
|
1788
|
+
detail: body.slice(0, 300),
|
|
1694
1789
|
}).catch(() => {});
|
|
1695
1790
|
}
|
|
1696
1791
|
const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
|
package/src/commands/ralplan.ts
CHANGED
|
@@ -19,6 +19,9 @@ import {
|
|
|
19
19
|
resolveSubagentMaxSteps,
|
|
20
20
|
} from "../agent/subagents";
|
|
21
21
|
import { runAgentLoop } from "../agent/engine";
|
|
22
|
+
import { inferTierForModel, tierModelPool, credentialScopeFor, selectFromPool } from "../agent/prompt-router";
|
|
23
|
+
import { isRateLimitError } from "../util/retry";
|
|
24
|
+
import { MAX_SUBAGENT_REROUTES } from "../agent/task-tool";
|
|
22
25
|
|
|
23
26
|
/** Round-11 (architect ref 8-Round10Planning #1): the REAL consensus gate. A
|
|
24
27
|
* read-only critic SUBAGENT (repo access via read/search/find) reviews the
|
|
@@ -33,7 +36,7 @@ export async function runConsensusCriticGate(args: {
|
|
|
33
36
|
}): Promise<{ verdict: "okay" | "iterate" | "reject" | "unverified"; detail: string }> {
|
|
34
37
|
const role = getSubagentRole("critic")!;
|
|
35
38
|
const config = await readGlobalConfig();
|
|
36
|
-
const
|
|
39
|
+
const initialModel = resolveSubagentModel(role.id, config);
|
|
37
40
|
const maxSteps = resolveSubagentMaxSteps(role.id, config);
|
|
38
41
|
const history: Message[] = [
|
|
39
42
|
{ role: "system", content: subagentSystemPrompt(role) },
|
|
@@ -48,14 +51,48 @@ export async function runConsensusCriticGate(args: {
|
|
|
48
51
|
`Then call done — your reason MUST start with [OKAY], [ITERATE], or [REJECT] and include a 'Justification:' section.`,
|
|
49
52
|
},
|
|
50
53
|
];
|
|
51
|
-
|
|
54
|
+
// Rate-limit fast fallback (see task-tool.ts's `runSubagentOnce` for the full
|
|
55
|
+
// design doc — same pattern, single-call variant: no fan-out here, so no
|
|
56
|
+
// batch-shared excludedCredentialScopes need apply). A bail-only predicate with
|
|
57
|
+
// no real reroute-and-retry loop around it is a net regression (02b7e59) — this
|
|
58
|
+
// gate pairs `rateLimitFallbackAvailable` with the actual switch-and-retry below.
|
|
59
|
+
const tier = inferTierForModel(initialModel);
|
|
60
|
+
const attemptedModels = new Set<string>([initialModel]);
|
|
61
|
+
const excludedCredentialScopes = new Set<string>();
|
|
62
|
+
const fallbackCandidates = (): string[] => {
|
|
63
|
+
const currentScope = credentialScopeFor(activeModel, config);
|
|
64
|
+
return tierModelPool(tier, config)
|
|
65
|
+
.filter(m => !attemptedModels.has(m))
|
|
66
|
+
.filter(m => {
|
|
67
|
+
const scope = credentialScopeFor(m, config);
|
|
68
|
+
if (!scope) return true; // API-key-served (or keyless) — independent budget
|
|
69
|
+
if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
|
|
70
|
+
return !excludedCredentialScopes.has(scope.key);
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
|
|
74
|
+
|
|
75
|
+
let activeModel = initialModel;
|
|
76
|
+
const runOnce = () => runAgentLoop(history, {
|
|
52
77
|
cwd: args.cwd,
|
|
53
|
-
model,
|
|
78
|
+
model: activeModel,
|
|
54
79
|
maxSteps,
|
|
55
80
|
budget: { maxExtensions: 0 },
|
|
56
81
|
signal: args.signal,
|
|
57
82
|
tools: subagentToolset(role),
|
|
83
|
+
rateLimitFallbackAvailable,
|
|
58
84
|
});
|
|
85
|
+
let result = await runOnce();
|
|
86
|
+
for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
|
|
87
|
+
if (result.done || !isRateLimitError(new Error(result.doneReason ?? ""))) break;
|
|
88
|
+
const failedScope = credentialScopeFor(activeModel, config);
|
|
89
|
+
if (failedScope) excludedCredentialScopes.add(failedScope.key);
|
|
90
|
+
const candidates = fallbackCandidates();
|
|
91
|
+
if (candidates.length === 0) break;
|
|
92
|
+
activeModel = selectFromPool(candidates, undefined);
|
|
93
|
+
attemptedModels.add(activeModel);
|
|
94
|
+
result = await runOnce();
|
|
95
|
+
}
|
|
59
96
|
const reason = result.doneReason?.trim() ?? "";
|
|
60
97
|
if (!result.done) return { verdict: "unverified", detail: reason || `critic did not converge within ${result.steps} steps` };
|
|
61
98
|
const contract = validateSubagentDoneReason(role, reason);
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jeo routine init` — generate a GitHub Actions workflow that runs
|
|
3
|
+
* `jeo "<prompt>" -p` headlessly on a schedule or repo event.
|
|
4
|
+
*
|
|
5
|
+
* This is the SCOPED-SAFE take on "Routines": jeo-code itself never runs a
|
|
6
|
+
* scheduler, listens on a port, or accepts a webhook — it's a pure template
|
|
7
|
+
* generator. GitHub's own hosted runners do the actual triggering/running,
|
|
8
|
+
* which achieves "runs without your laptop" with zero new attack surface
|
|
9
|
+
* inside jeo-code's own process. See ../util/routine-template.ts for the
|
|
10
|
+
* pure YAML-building logic this command wraps with flag parsing + I/O.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs/promises";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { renderRoutineWorkflow, validateCron, type RoutineConfig, type RoutineTrigger } from "../util/routine-template";
|
|
15
|
+
import { slugify } from "../agent/memory-okf";
|
|
16
|
+
|
|
17
|
+
const TRIGGERS: readonly RoutineTrigger[] = ["schedule", "issues", "pull_request"];
|
|
18
|
+
function isTrigger(val: string | undefined): val is RoutineTrigger {
|
|
19
|
+
return val !== undefined && (TRIGGERS as readonly string[]).includes(val);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const VALUE_FLAGS: Record<string, true> = { "--name": true, "--trigger": true, "--cron": true, "--prompt": true, "--api-key-env": true, "--out": true };
|
|
23
|
+
|
|
24
|
+
interface ParsedInit {
|
|
25
|
+
name: string;
|
|
26
|
+
triggerRaw?: string;
|
|
27
|
+
cron?: string;
|
|
28
|
+
prompt?: string;
|
|
29
|
+
apiKeyEnvVar: string;
|
|
30
|
+
openPr: boolean;
|
|
31
|
+
out?: string;
|
|
32
|
+
force: boolean;
|
|
33
|
+
dryRun: boolean;
|
|
34
|
+
json: boolean;
|
|
35
|
+
help: boolean;
|
|
36
|
+
/** Parse-level problems: unknown flags, or a value-flag given with no value. */
|
|
37
|
+
errors: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `--flag value` or `--flag=value`, matching launch/flags.ts's takeValue shape
|
|
41
|
+
* (a bare value-flag followed by another `-`-prefixed token is treated as
|
|
42
|
+
* missing, not as accidentally swallowing the next flag). */
|
|
43
|
+
function takeValue(args: string[], index: number, flag: string): { value?: string; nextIndex: number } {
|
|
44
|
+
const current = args[index]!;
|
|
45
|
+
const inline = `${flag}=`;
|
|
46
|
+
if (current.startsWith(inline)) return { value: current.slice(inline.length), nextIndex: index };
|
|
47
|
+
const next = args[index + 1];
|
|
48
|
+
if (next !== undefined && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
|
|
49
|
+
return { nextIndex: index };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseInitArgs(args: string[]): ParsedInit {
|
|
53
|
+
const parsed: ParsedInit = {
|
|
54
|
+
name: "jeo routine",
|
|
55
|
+
apiKeyEnvVar: "ANTHROPIC_API_KEY",
|
|
56
|
+
openPr: true,
|
|
57
|
+
force: false,
|
|
58
|
+
dryRun: false,
|
|
59
|
+
json: false,
|
|
60
|
+
help: false,
|
|
61
|
+
errors: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < args.length; i++) {
|
|
65
|
+
const a = args[i]!;
|
|
66
|
+
const flag = a.includes("=") ? a.slice(0, a.indexOf("=")) : a;
|
|
67
|
+
|
|
68
|
+
if (flag === "-h" || flag === "--help") { parsed.help = true; continue; }
|
|
69
|
+
if (flag === "--no-pr") { parsed.openPr = false; continue; }
|
|
70
|
+
if (flag === "--force") { parsed.force = true; continue; }
|
|
71
|
+
if (flag === "--dry-run") { parsed.dryRun = true; continue; }
|
|
72
|
+
if (flag === "--json") { parsed.json = true; continue; }
|
|
73
|
+
|
|
74
|
+
if (VALUE_FLAGS[flag]) {
|
|
75
|
+
const { value, nextIndex } = takeValue(args, i, flag);
|
|
76
|
+
i = nextIndex;
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
parsed.errors.push(`${flag} requires a value`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
switch (flag) {
|
|
82
|
+
case "--name": parsed.name = value; break;
|
|
83
|
+
case "--trigger": parsed.triggerRaw = value; break;
|
|
84
|
+
case "--cron": parsed.cron = value; break;
|
|
85
|
+
case "--prompt": parsed.prompt = value; break;
|
|
86
|
+
case "--api-key-env": parsed.apiKeyEnvVar = value; break;
|
|
87
|
+
case "--out": parsed.out = value; break;
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
parsed.errors.push(`Unknown flag: ${a}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function printUsage(): void {
|
|
99
|
+
console.log("Usage: jeo routine init [options]");
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log('Generate a GitHub Actions workflow that runs `jeo "<prompt>" -p` headlessly');
|
|
102
|
+
console.log("on a schedule or repo event. GitHub's own hosted runners do the triggering —");
|
|
103
|
+
console.log("jeo-code never runs a scheduler or listens on a port for this.");
|
|
104
|
+
console.log("");
|
|
105
|
+
console.log("Options:");
|
|
106
|
+
console.log(" --trigger <schedule|issues|pull_request> Required. What fires the routine.");
|
|
107
|
+
console.log(" --prompt <text> Required. The task/goal text passed to `jeo -p`.");
|
|
108
|
+
console.log(' --cron <expr> Required when --trigger schedule (e.g. "0 7 * * *").');
|
|
109
|
+
console.log(' --name <name> Workflow display name. Default: "jeo routine".');
|
|
110
|
+
console.log(" --api-key-env <VAR_NAME> Secret env var name. Default: ANTHROPIC_API_KEY.");
|
|
111
|
+
console.log(" --no-pr Commit directly to the branch instead of opening a PR.");
|
|
112
|
+
console.log(" --out <path> Output path. Default: .github/workflows/jeo-routine-<slug>.yml");
|
|
113
|
+
console.log(" --force Overwrite an existing file at --out.");
|
|
114
|
+
console.log(" --dry-run Print the rendered YAML instead of writing it.");
|
|
115
|
+
console.log(" --json Machine-readable output.");
|
|
116
|
+
console.log(" -h, --help Show this help message.");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function runInit(args: string[]): Promise<void> {
|
|
120
|
+
const parsed = parseInitArgs(args);
|
|
121
|
+
|
|
122
|
+
if (parsed.errors.length > 0) {
|
|
123
|
+
for (const e of parsed.errors) console.log(`Error: ${e}`);
|
|
124
|
+
printUsage();
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (parsed.help) {
|
|
130
|
+
printUsage();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const problems: string[] = [];
|
|
135
|
+
if (!isTrigger(parsed.triggerRaw)) {
|
|
136
|
+
problems.push(
|
|
137
|
+
parsed.triggerRaw
|
|
138
|
+
? `--trigger must be one of: schedule, issues, pull_request (got '${parsed.triggerRaw}')`
|
|
139
|
+
: "--trigger is required (one of: schedule, issues, pull_request)",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (!parsed.prompt) {
|
|
143
|
+
problems.push("--prompt is required");
|
|
144
|
+
}
|
|
145
|
+
const trigger = isTrigger(parsed.triggerRaw) ? parsed.triggerRaw : undefined;
|
|
146
|
+
if (trigger === "schedule") {
|
|
147
|
+
if (!parsed.cron) {
|
|
148
|
+
problems.push("--cron is required when --trigger schedule");
|
|
149
|
+
} else if (!validateCron(parsed.cron)) {
|
|
150
|
+
problems.push(`--cron '${parsed.cron}' does not look like a valid 5-field cron expression`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (problems.length > 0) {
|
|
155
|
+
for (const p of problems) console.log(`Error: ${p}`);
|
|
156
|
+
printUsage();
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const config: RoutineConfig = {
|
|
162
|
+
name: parsed.name,
|
|
163
|
+
trigger: trigger!,
|
|
164
|
+
cron: parsed.cron,
|
|
165
|
+
prompt: parsed.prompt!,
|
|
166
|
+
apiKeyEnvVar: parsed.apiKeyEnvVar,
|
|
167
|
+
openPr: parsed.openPr,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
let yaml: string;
|
|
171
|
+
try {
|
|
172
|
+
yaml = renderRoutineWorkflow(config);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
if (parsed.json) console.log(JSON.stringify({ error: msg }));
|
|
176
|
+
else console.log(`Error: ${msg}`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const outPath = parsed.out ?? path.join(".github", "workflows", `jeo-routine-${slugify(parsed.name)}.yml`);
|
|
182
|
+
const resolvedOut = path.resolve(process.cwd(), outPath);
|
|
183
|
+
|
|
184
|
+
if (parsed.dryRun) {
|
|
185
|
+
if (parsed.json) console.log(JSON.stringify({ dryRun: true, out: resolvedOut, yaml }, null, 2));
|
|
186
|
+
else console.log(yaml);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let exists = true;
|
|
191
|
+
try {
|
|
192
|
+
await fs.access(resolvedOut);
|
|
193
|
+
} catch {
|
|
194
|
+
exists = false;
|
|
195
|
+
}
|
|
196
|
+
if (exists && !parsed.force) {
|
|
197
|
+
const msg = `${resolvedOut} already exists. Use --force to overwrite.`;
|
|
198
|
+
if (parsed.json) console.log(JSON.stringify({ error: msg }));
|
|
199
|
+
else console.log(`Error: ${msg}`);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await fs.mkdir(path.dirname(resolvedOut), { recursive: true });
|
|
205
|
+
// Atomic write: build the file fully off to the side, then rename into
|
|
206
|
+
// place, so a crash mid-write never leaves a truncated workflow file.
|
|
207
|
+
const tmpPath = `${resolvedOut}.tmp-${process.pid}`;
|
|
208
|
+
await fs.writeFile(tmpPath, yaml, "utf-8");
|
|
209
|
+
await fs.rename(tmpPath, resolvedOut);
|
|
210
|
+
|
|
211
|
+
if (parsed.json) {
|
|
212
|
+
console.log(JSON.stringify({ wrote: resolvedOut, trigger, apiKeyEnvVar: parsed.apiKeyEnvVar, openPr: parsed.openPr }, null, 2));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(`Wrote ${resolvedOut}`);
|
|
217
|
+
console.log("");
|
|
218
|
+
console.log("Next steps:");
|
|
219
|
+
console.log(` - Add '${parsed.apiKeyEnvVar}' as a repo secret: Settings -> Secrets and variables -> Actions.`);
|
|
220
|
+
if (trigger === "schedule") {
|
|
221
|
+
console.log(" - Scheduled workflows only run on the repo's default branch, and GitHub may");
|
|
222
|
+
console.log(" delay or skip scheduled runs on low-activity repos — this is documented");
|
|
223
|
+
console.log(" GitHub Actions behavior, not a bug in the generated workflow.");
|
|
224
|
+
}
|
|
225
|
+
console.log(` - Commit and push ${resolvedOut} to enable it.`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function runRoutineCommand(args: string[]): Promise<void> {
|
|
229
|
+
const sub = args[0];
|
|
230
|
+
|
|
231
|
+
if (sub === "-h" || sub === "--help") {
|
|
232
|
+
printUsage();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (sub === undefined) {
|
|
237
|
+
printUsage();
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (sub !== "init") {
|
|
243
|
+
console.log(`Unknown 'jeo routine' subcommand '${sub}'. Usage: jeo routine init [options]`);
|
|
244
|
+
process.exitCode = 1;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await runInit(args.slice(1));
|
|
249
|
+
}
|
package/src/commands/skills.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
|
|
4
4
|
import { getLocalJeoDir } from "../agent/state";
|
|
5
|
+
import { appendSkillLesson, evalSkillLessons, type SkillLesson } from "../agent/skill-lessons";
|
|
5
6
|
|
|
6
7
|
export { userSkillsDir };
|
|
7
8
|
|
|
@@ -147,6 +148,62 @@ export async function runSkillsCommand(args: string[] = []): Promise<void> {
|
|
|
147
148
|
return;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// `jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"` — manual
|
|
152
|
+
// lesson entry into the project-level skill file (the same compounding write the
|
|
153
|
+
// launch.ts stall-guard fires automatically; lets an agent or human record one directly).
|
|
154
|
+
if (cleanArgs[0] === "lesson") {
|
|
155
|
+
const [, skillName, kindArg, title, detail] = cleanArgs;
|
|
156
|
+
if (!skillName || !kindArg || !title || !detail) {
|
|
157
|
+
console.log('Usage: jeo skills lesson <skill> <failure|anti-pattern> "<title>" "<detail>"');
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const kind: SkillLesson["kind"] | null =
|
|
162
|
+
kindArg === "failure" ? "failure-mode" : kindArg === "anti-pattern" ? "anti-pattern" : null;
|
|
163
|
+
if (!kind) {
|
|
164
|
+
console.log(`Error: kind must be 'failure' or 'anti-pattern' (got '${kindArg}').`);
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const result = await appendSkillLesson(cwd, { skill: skillName, kind, title, detail });
|
|
169
|
+
if (isJson) {
|
|
170
|
+
console.log(JSON.stringify(result, null, 2));
|
|
171
|
+
} else if (result.appended) {
|
|
172
|
+
console.log("Appended.");
|
|
173
|
+
} else if (result.reason === "duplicate") {
|
|
174
|
+
console.log("Already recorded (duplicate).");
|
|
175
|
+
} else {
|
|
176
|
+
console.log(`Error: ${result.reason ?? "unknown error"}`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// `jeo skills eval <skill>` — self-eval half: judge whether recorded lessons are
|
|
183
|
+
// still covered by the skill's current guidance, or have gone stale.
|
|
184
|
+
if (cleanArgs[0] === "eval") {
|
|
185
|
+
const skillName = cleanArgs[1];
|
|
186
|
+
if (!skillName) {
|
|
187
|
+
console.log("Error: Missing skill name for 'eval' command.");
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const result = await evalSkillLessons(cwd, skillName);
|
|
192
|
+
if (result === null) {
|
|
193
|
+
console.log(`No recorded lessons for '${skillName}' yet.`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (isJson) {
|
|
197
|
+
console.log(JSON.stringify(result, null, 2));
|
|
198
|
+
} else {
|
|
199
|
+
console.log(`${result.covered}/${result.total} lessons still covered by current guidance.`);
|
|
200
|
+
for (const s of result.stale) {
|
|
201
|
+
console.log(` STALE ${s.title}: ${s.reason}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
150
207
|
// List/lookup over the MERGED set (bundled + ~/.jeo/skills + ~/.agents/skills + project dirs), matching the REPL /skill.
|
|
151
208
|
const skills = await loadSkills(cwd);
|
|
152
209
|
const command = cleanArgs[0];
|
|
@@ -117,6 +117,21 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
117
117
|
if (status === 401 || status === 403 || /\b40[13]\b/.test(msg)) {
|
|
118
118
|
return `${provider} rejected the credential (HTTP ${status ?? "401/403"}). Run 'jeo auth status', re-login with /provider login <name>, and for Antigravity prefer '/provider login antigravity' (gemini login only works when the Cloud Code Assist backend authorizes that token).`;
|
|
119
119
|
}
|
|
120
|
+
// A persistent 5xx (server-side fault on the provider's backend) — `ProviderHttpError`
|
|
121
|
+
// already embeds the numeric status directly in its message ("… failed (HTTP 500) …",
|
|
122
|
+
// caught here too, redundantly but harmlessly), but `ProviderStreamError` (the in-band
|
|
123
|
+
// SSE/stream error shape — antigravity.ts/gemini.ts's `google.rpc.Status` and
|
|
124
|
+
// openai-responses.ts's `response.failed`/`error` events) composes its `.message` from
|
|
125
|
+
// a STRING status code only ("Antigravity stream failed (INTERNAL): internal error"),
|
|
126
|
+
// never the numeric one, even though `.status` carries it correctly (explicitStatus
|
|
127
|
+
// for antigravity/gemini's google.rpc.Status `error.code`; the 500 default for
|
|
128
|
+
// openai-responses's non-rate_limit_exceeded codes). Without a digit anywhere in the
|
|
129
|
+
// text, launch.ts's `routeFailureReason` `/\b(?:500|502|503|504|529)\b/` text regex
|
|
130
|
+
// never matched, so the equivalent-pool fallback never fired for a stream-shaped 5xx
|
|
131
|
+
// even with a credentialed same-tier fallback sitting right there.
|
|
132
|
+
if (status !== undefined && status >= 500 && status < 600) {
|
|
133
|
+
return `${provider} hit a persistent server-side error (HTTP ${status}) — ${msg}. The provider's backend itself is failing; switch model with /model or wait and retry.`;
|
|
134
|
+
}
|
|
120
135
|
if ((err instanceof Error && err.name === "TimeoutError") || /the operation timed out/i.test(msg)) {
|
|
121
136
|
return `${provider} did not complete the request within the call timeout (default 30min). This is expected for a HIGH/XHIGH-reasoning-effort completion that legitimately runs long on a hard problem — raise JEO_CALL_TIMEOUT_MS (ms) if this recurs, or lower the thinking level with /model.`;
|
|
122
137
|
}
|
package/src/util/retry.ts
CHANGED
|
@@ -5,8 +5,16 @@ export interface RetryOptions {
|
|
|
5
5
|
isRetryable?: (err: unknown, attempt: number) => boolean;
|
|
6
6
|
sleep?: (ms: number) => Promise<void>; // injectable for tests (default real setTimeout)
|
|
7
7
|
random?: () => number; // injectable RNG for jitter (default Math.random); equal-jitter in [0.5x, 1x]
|
|
8
|
-
/** Notified before each backoff wait; `delayMs` is the wait actually applied.
|
|
9
|
-
|
|
8
|
+
/** Notified before each backoff wait; `delayMs` is the wait actually applied.
|
|
9
|
+
* Returning `false` (explicitly) aborts the retry ladder immediately — the
|
|
10
|
+
* pending error is thrown right away instead of sleeping and re-attempting.
|
|
11
|
+
* Lets a caller with a same-tier FALLBACK model available bail out of a
|
|
12
|
+
* same-model rate-limit retry loop early rather than riding the full
|
|
13
|
+
* `rateLimitRetries` budget (up to ~90s of escalating backoff) before the
|
|
14
|
+
* turn-level equivalent-pool fallback ever gets a chance to switch models.
|
|
15
|
+
* Any other return value (including `undefined`, the common case) continues
|
|
16
|
+
* the retry ladder unchanged. */
|
|
17
|
+
onRetry?: (attempt: number, err: unknown, delayMs: number) => void | false;
|
|
10
18
|
/** Aborts an in-progress backoff wait (e.g. Ctrl-C / turn cancel). Already-aborted or
|
|
11
19
|
* aborted-mid-wait rejects immediately instead of completing the sleep — the escape hatch
|
|
12
20
|
* for a long, honored provider `Retry-After` (see `rateLimitMaxServerDelayMs`: those are no
|
|
@@ -200,8 +208,11 @@ export async function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): P
|
|
|
200
208
|
: 0;
|
|
201
209
|
const delay = Math.max(base, floor);
|
|
202
210
|
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
// A `false` return from onRetry means the caller wants to bail NOW instead of
|
|
212
|
+
// sleeping and re-attempting (see RetryOptions.onRetry's doc comment) — throw
|
|
213
|
+
// the pending error immediately rather than entering the backoff wait.
|
|
214
|
+
if (onRetry && onRetry(attempt, err, delay) === false) {
|
|
215
|
+
throw err;
|
|
205
216
|
}
|
|
206
217
|
|
|
207
218
|
await abortableSleep(sleep, delay, signal);
|