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/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);
|
package/src/commands/launch.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { emitKeypressEvents } from "node:readline";
|
|
3
3
|
import { PassThrough } from "node:stream";
|
|
4
|
-
import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, type AgentLoopEvents } from "../agent/engine";
|
|
4
|
+
import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, isSafetyFallbackBail, stripSafetyFallbackTag, type AgentLoopEvents } from "../agent/engine";
|
|
5
5
|
import { computerSupervisor } from "../agent/computer-supervisor";
|
|
6
6
|
import { executeComputerAction } from "./computer";
|
|
7
7
|
|
|
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";
|
|
13
|
+
import { createEvalTool, evalToolProtocolLine } from "../agent/eval-tool";
|
|
12
14
|
import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
|
|
13
15
|
import { SubagentRegistry } from "../agent/subagent-registry";
|
|
14
16
|
import { stopSessionNotifyEndpoint, startSessionNotifyEndpoint, type SessionNotifyEndpoint } from "../agent/notify/session-endpoint";
|
|
@@ -51,7 +53,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
|
|
|
51
53
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
52
54
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
53
55
|
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";
|
|
56
|
+
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, credentialScopeFor, resolveVerifierModel, type PromptTier, type RouteDecision, type RoutingConfig } from "../agent/prompt-router";
|
|
55
57
|
import { RouteHistory } from "../agent/route-history";
|
|
56
58
|
|
|
57
59
|
import { isRateLimitError, isUsageLimitError, isConnectionError } from "../util/retry";
|
|
@@ -549,7 +551,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
549
551
|
// pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
|
|
550
552
|
const contextFiles = await loadProjectContext(cwd);
|
|
551
553
|
|
|
552
|
-
const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
|
|
554
|
+
const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "eval", "todo", "subagent", "job", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
|
|
553
555
|
let allowedTools = new Set(KNOWN_TOOLS);
|
|
554
556
|
|
|
555
557
|
if (flags.noTools) {
|
|
@@ -617,6 +619,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
617
619
|
"\nWhen you have finished the user's request, or need to reply to or ask the user something, call done with {\"reason\": <your natural-language reply to the user>}. The reason text is shown to the user as your message." +
|
|
618
620
|
(allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
|
|
619
621
|
" Call task with {\"role\": <one of the advertised roles>, \"task\": <assignment>, \"context\": <optional>} to hand a focused slice to a subagent." : "") +
|
|
622
|
+
(allowedTools.has("eval") ? "\n\nDynamic Workflows: " + evalToolProtocolLine(cfg) : "") +
|
|
620
623
|
(allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
|
|
621
624
|
(allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
|
|
622
625
|
" Launch background work with task {\"detached\": true, \"role\": <role>, \"task\": <assignment>}; it returns a subagent id immediately so you can keep working and collect the result later." : "") +
|
|
@@ -976,11 +979,77 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
976
979
|
const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
|
|
977
980
|
let routeCredentialNotice: string | undefined;
|
|
978
981
|
let routeVetoNote: string | undefined;
|
|
982
|
+
// Credential SCOPES that have shown an ACCOUNT-LEVEL failure (rate limit,
|
|
983
|
+
// usage/quota limit, or zero usable credential) THIS turn — accumulates across
|
|
984
|
+
// every fallback round. Read by both `equivalentRouteFallback` (post/pre-call
|
|
985
|
+
// fallback search) and `rateLimitFallbackAvailable` (the engine's fast-bail
|
|
986
|
+
// predicate) so neither path proposes a candidate doomed to fail the same way.
|
|
987
|
+
//
|
|
988
|
+
// Scoped to the CREDENTIAL, not the bare provider (see `credentialScopeFor` in
|
|
989
|
+
// prompt-router.ts for the full classification rationale/doc comment) — an OAuth
|
|
990
|
+
// SUBSCRIPTION shares ONE account-wide rate-limit window across every model it
|
|
991
|
+
// serves; an API key is typically its OWN independent per-key/per-model budget.
|
|
992
|
+
// `null` (from `credentialScopeFor`) means "API-key-served, or otherwise not
|
|
993
|
+
// sharing a subscription credential" — never added to or matched against this set.
|
|
994
|
+
const excludedCredentialScopes = new Set<string>();
|
|
995
|
+
// Matches routeFailureReason's post-call reasons ("hit a rate limit", "hit a
|
|
996
|
+
// usage/quota limit", "is unauthenticated for this session", "requires
|
|
997
|
+
// billing/payment on this provider account") AND the pre-call credential gate's
|
|
998
|
+
// "(<provider>) has no usable credential this turn" — every string that means
|
|
999
|
+
// "this CREDENTIAL is exhausted/broken", as opposed to a model-specific issue
|
|
1000
|
+
// (OAuth serves only some models, a single model is retired/gated — see
|
|
1001
|
+
// routeFailureReason's own doc comments for why those stay model-scoped).
|
|
1002
|
+
//
|
|
1003
|
+
// Billing/payment (402) is intentionally included even though the ORIGINAL field
|
|
1004
|
+
// observation behind routeFailureReason's 402 branch (Tencent: kimi-k2.5/
|
|
1005
|
+
// deepseek-v3.2 402 while deepseek-v4-flash/glm-5.2 kept working on the SAME key)
|
|
1006
|
+
// is a per-MODEL billing block on an API-KEY-served provider — this does NOT
|
|
1007
|
+
// contradict including it here: `credentialScopeFor` already returns `null` for
|
|
1008
|
+
// every API-key-served model (never group-excluded regardless of this regex's
|
|
1009
|
+
// answer), so the Tencent case is unaffected either way. What this DOES newly
|
|
1010
|
+
// catch: an OAuth SUBSCRIPTION billing failure (e.g. a Claude Pro/Max payment
|
|
1011
|
+
// lapse) is account-wide by construction — every model riding that subscription
|
|
1012
|
+
// is equally blocked — and previously wasted a full fallback round retrying a
|
|
1013
|
+
// same-scope sibling model guaranteed to 402 identically before ever reaching a
|
|
1014
|
+
// genuinely different credential.
|
|
1015
|
+
const isAccountLevelRouteFailure = (reason: string): boolean =>
|
|
1016
|
+
/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);
|
|
1017
|
+
// Providers that have ALREADY produced a safety-classifier bail THIS turn —
|
|
1018
|
+
// accumulates across fallback rounds (mirrors excludedCredentialScopes exactly,
|
|
1019
|
+
// one paragraph up). Provider-scoped rather than credential-scoped: a
|
|
1020
|
+
// same-provider sibling model shares the SAME constitutional/moderation
|
|
1021
|
+
// classifier regardless of which credential serves it (unlike a rate limit,
|
|
1022
|
+
// which is a billing/quota boundary, not a classifier boundary) — trying it
|
|
1023
|
+
// after an uncategorized refusal is very likely the identical false positive,
|
|
1024
|
+
// not a genuinely different verdict.
|
|
1025
|
+
const excludedSafetyProviders = new Set<string>();
|
|
1026
|
+
// Matches ONLY routeFailureReason's dedicated safety-fallback branch (the
|
|
1027
|
+
// engine's `SafetyFallback (uncategorized):`-tagged bail, stripped to this
|
|
1028
|
+
// reason text) — deliberately narrower than isRefusalError/isSafetyFallbackBail
|
|
1029
|
+
// so a genuinely categorized (true-positive) refusal, which never reaches this
|
|
1030
|
+
// branch at all, can never be classified as an account-level OR safety-level
|
|
1031
|
+
// route failure by this predicate.
|
|
1032
|
+
const isSafetyRouteFailure = (reason: string): boolean => /was refused \(possible safety classifier false positive\)/i.test(reason);
|
|
979
1033
|
const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
|
|
1034
|
+
if (isAccountLevelRouteFailure(reason)) {
|
|
1035
|
+
const scope = credentialScopeFor(decision.model, turnConfig);
|
|
1036
|
+
if (scope) excludedCredentialScopes.add(scope.key);
|
|
1037
|
+
}
|
|
1038
|
+
// Provider-level exclusion (not just the credential scope above): a
|
|
1039
|
+
// same-provider sibling shares the identical classifier a safety refusal
|
|
1040
|
+
// just tripped on, so it is excluded ENTIRELY here — a stricter exclusion
|
|
1041
|
+
// than the account-level branch's credential-scope-only one, matching
|
|
1042
|
+
// task-tool.ts's `safetyFallbackCandidates` (same rationale, same shape).
|
|
1043
|
+
if (isSafetyRouteFailure(reason)) excludedSafetyProviders.add(resolveProvider(decision.model));
|
|
980
1044
|
const excluded = new Set([decision.model, ...exclude]);
|
|
981
1045
|
const poolCandidates = (): string[] =>
|
|
982
1046
|
tierModelPool(decision.tier, turnConfig)
|
|
983
1047
|
.filter(model => !excluded.has(model))
|
|
1048
|
+
.filter(model => {
|
|
1049
|
+
const scope = credentialScopeFor(model, turnConfig);
|
|
1050
|
+
return !scope || !excludedCredentialScopes.has(scope.key);
|
|
1051
|
+
})
|
|
1052
|
+
.filter(model => !excludedSafetyProviders.has(resolveProvider(model)))
|
|
984
1053
|
.filter(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
985
1054
|
let candidates = poolCandidates();
|
|
986
1055
|
// The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
|
|
@@ -1383,7 +1452,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1383
1452
|
}
|
|
1384
1453
|
|
|
1385
1454
|
if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
|
|
1386
|
-
const llmVerdict = await verifyGoal(goalState.condition, history,
|
|
1455
|
+
const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
|
|
1387
1456
|
// Deterministic downgrade: a fresh MET from the transcript-only LLM
|
|
1388
1457
|
// judge still needs mutation/verification evidence to back it up —
|
|
1389
1458
|
// see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
|
|
@@ -1421,6 +1490,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1421
1490
|
? (e => tui?.onSubagentEvent(e))
|
|
1422
1491
|
: (e => logTaskSubEvent(e)),
|
|
1423
1492
|
}),
|
|
1493
|
+
eval: createEvalTool({
|
|
1494
|
+
config: { ...turnConfig, defaultModel: activeModel },
|
|
1495
|
+
signal: ac.signal,
|
|
1496
|
+
steer: drainSteer,
|
|
1497
|
+
onEvent: useTui
|
|
1498
|
+
? (e => tui?.onSubagentEvent(e))
|
|
1499
|
+
: (e => logTaskSubEvent(e)),
|
|
1500
|
+
}),
|
|
1424
1501
|
todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
|
|
1425
1502
|
subagent: createSubagentTool(subagentRegistry),
|
|
1426
1503
|
job: createJobTool(jobRegistry),
|
|
@@ -1448,6 +1525,76 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1448
1525
|
tags: ["jeo", "launch"],
|
|
1449
1526
|
});
|
|
1450
1527
|
opik.startTurn();
|
|
1528
|
+
// Hoisted above `runLoopWithModel` (moved from after its first call) so the
|
|
1529
|
+
// rate-limit fast-fallback predicate below can see the CURRENT attempted-model
|
|
1530
|
+
// set on every call, including the very first one. `attemptedRouteModels` is
|
|
1531
|
+
// mutated in place (`.add`) as the fallback loop below tries more models — this
|
|
1532
|
+
// closure always reads its live contents, never a stale snapshot.
|
|
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
|
+
// Cheap SYNC check (no network/credential probing — that full validation is
|
|
1549
|
+
// `equivalentRouteFallback`'s job when a switch actually happens) for "does an
|
|
1550
|
+
// untried, DIFFERENT-PROVIDER candidate exist RIGHT NOW". Feeds
|
|
1551
|
+
// AgentLoopOptions.rateLimitFallbackAvailable so the engine can bail a 429 retry
|
|
1552
|
+
// ladder on the first failed attempt instead of riding ~90s of backoff on a
|
|
1553
|
+
// model that has an equivalent sitting right there. Mirrors
|
|
1554
|
+
// `equivalentRouteFallback`'s own `poolCandidates` filters (tier pool minus
|
|
1555
|
+
// already-tried models minus image-incapable models when this turn has images)
|
|
1556
|
+
// PLUS excludes any candidate sharing the SAME CREDENTIAL SCOPE as the model
|
|
1557
|
+
// CURRENTLY being attempted (a 429 on this model means ITS credential's
|
|
1558
|
+
// account/subscription window is exhausted THIS instant — another model
|
|
1559
|
+
// riding the SAME OAuth subscription hits that exact limit and is not a real
|
|
1560
|
+
// fallback; an API-key-served candidate has an independent budget and is NOT
|
|
1561
|
+
// excluded) and any scope already excluded by an earlier account-level
|
|
1562
|
+
// failure this turn — see `excludedCredentialScopes`'s doc comment. Without
|
|
1563
|
+
// this, the predicate could see e.g. `claude-sonnet-4-6` as "available" while
|
|
1564
|
+
// `claude-sonnet-5` (same Anthropic OAuth subscription) is the one currently
|
|
1565
|
+
// 429ing, bail, switch to it, and immediately re-429 — which looks
|
|
1566
|
+
// indistinguishable from "never escaping auto-retry #1".
|
|
1567
|
+
const rateLimitFallbackAvailable = (): boolean => {
|
|
1568
|
+
const currentScope = credentialScopeFor(fallbackBaseDecision.model, turnConfig);
|
|
1569
|
+
return tierModelPool(fallbackBaseDecision.tier, turnConfig)
|
|
1570
|
+
.filter(model => !attemptedRouteModels.has(model))
|
|
1571
|
+
.filter(model => {
|
|
1572
|
+
const scope = credentialScopeFor(model, turnConfig);
|
|
1573
|
+
if (!scope) return true; // API-key-served (or keyless) — independent budget
|
|
1574
|
+
if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
|
|
1575
|
+
return !excludedCredentialScopes.has(scope.key);
|
|
1576
|
+
})
|
|
1577
|
+
.some(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
1578
|
+
};
|
|
1579
|
+
// Same shape as rateLimitFallbackAvailable above, plus a same-PROVIDER
|
|
1580
|
+
// exclusion (not just same-credential-scope): a sibling model from the
|
|
1581
|
+
// provider that JUST safety-bailed shares its classifier, so it is not a
|
|
1582
|
+
// genuinely different fallback candidate (see excludedSafetyProviders'
|
|
1583
|
+
// doc comment). Fed to AgentLoopOptions.safetyFallbackAvailable.
|
|
1584
|
+
const safetyFallbackAvailable = (): boolean => {
|
|
1585
|
+
const currentScope = credentialScopeFor(fallbackBaseDecision.model, turnConfig);
|
|
1586
|
+
const currentProvider = resolveProvider(fallbackBaseDecision.model);
|
|
1587
|
+
return tierModelPool(fallbackBaseDecision.tier, turnConfig)
|
|
1588
|
+
.filter(model => !attemptedRouteModels.has(model))
|
|
1589
|
+
.filter(model => resolveProvider(model) !== currentProvider && !excludedSafetyProviders.has(resolveProvider(model)))
|
|
1590
|
+
.filter(model => {
|
|
1591
|
+
const scope = credentialScopeFor(model, turnConfig);
|
|
1592
|
+
if (!scope) return true; // API-key-served (or keyless) — independent budget
|
|
1593
|
+
if (currentScope && scope.key === currentScope.key) return false; // same exhausted subscription
|
|
1594
|
+
return !excludedCredentialScopes.has(scope.key);
|
|
1595
|
+
})
|
|
1596
|
+
.some(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
1597
|
+
};
|
|
1451
1598
|
const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
|
|
1452
1599
|
cwd,
|
|
1453
1600
|
tools,
|
|
@@ -1459,11 +1606,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1459
1606
|
signal: ac.signal,
|
|
1460
1607
|
sessionKey: cacheKey,
|
|
1461
1608
|
steer: drainSteer,
|
|
1609
|
+
rateLimitFallbackAvailable,
|
|
1610
|
+
safetyFallbackAvailable,
|
|
1462
1611
|
events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
|
|
1463
1612
|
});
|
|
1464
1613
|
const routeFailureReason = (doneReason: unknown): string | null => {
|
|
1465
1614
|
const message = String(doneReason ?? "");
|
|
1466
1615
|
if (!message) return "stopped without a final answer";
|
|
1616
|
+
// MUST be checked before every pattern below: the engine's own tagged
|
|
1617
|
+
// safety-fallback bail wraps a `friendlyProviderError` refusal message,
|
|
1618
|
+
// which contains "refusal"/"safety" text that the safety-refusal guard
|
|
1619
|
+
// further down (`returned no content` branch) would otherwise swallow
|
|
1620
|
+
// (return null, never reroute) — this tag means the engine ALREADY
|
|
1621
|
+
// verified a fallback is available and deliberately asked for a reroute,
|
|
1622
|
+
// so it must win outright, not fall through to that guard.
|
|
1623
|
+
if (isSafetyFallbackBail(message)) return "was refused (possible safety classifier false positive)";
|
|
1467
1624
|
const asError = new Error(message);
|
|
1468
1625
|
if (isRateLimitError(asError)) return "hit a rate limit";
|
|
1469
1626
|
if (isUsageLimitError(asError)) return "hit a usage/quota limit";
|
|
@@ -1530,21 +1687,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1530
1687
|
const routeFallbackMax = Number.isFinite(routeFallbackMaxRaw) && routeFallbackMaxRaw >= 0
|
|
1531
1688
|
? Math.floor(routeFallbackMaxRaw)
|
|
1532
1689
|
: 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
1690
|
for (let routeFallbackAttempt = 0; routeFallbackAttempt < routeFallbackMax; routeFallbackAttempt++) {
|
|
1549
1691
|
const routedFailureReason = !result.done ? routeFailureReason(result.doneReason) : null;
|
|
1550
1692
|
if (!routedFailureReason) break;
|
|
@@ -1592,6 +1734,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1592
1734
|
signal: ac.signal,
|
|
1593
1735
|
sessionKey: turnSessionKey,
|
|
1594
1736
|
steer: drainSteer,
|
|
1737
|
+
// Deliberately NOT wiring rateLimitFallbackAvailable here: this repair
|
|
1738
|
+
// retry has no reroute loop around it (unlike runLoopWithModel's
|
|
1739
|
+
// routeFallbackAttempt loop) — bailing the retry ladder early on a 429
|
|
1740
|
+
// would only fail FASTER with the identical outcome (no fallback model to
|
|
1741
|
+
// switch to either way), trading away the one real win the full backoff
|
|
1742
|
+
// ladder still offers here: a transient (<~90s) rate-limit blip clearing
|
|
1743
|
+
// mid-wait and this retry succeeding. See launch.ts's `rateLimitFallbackAvailable`
|
|
1744
|
+
// doc comment / commit history for why this predicate is paired with an
|
|
1745
|
+
// actual model-switch loop everywhere else it's used.
|
|
1595
1746
|
events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
|
|
1596
1747
|
});
|
|
1597
1748
|
const usage =
|
|
@@ -1630,7 +1781,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1630
1781
|
}
|
|
1631
1782
|
// A completed turn with an empty done-reason must NOT masquerade as a step-limit
|
|
1632
1783
|
// failure ("reached the 3-step limit" after the model called done at step 3).
|
|
1633
|
-
const reply = result.doneReason
|
|
1784
|
+
const reply = stripSafetyFallbackTag(result.doneReason ?? "")
|
|
1634
1785
|
|| (result.done
|
|
1635
1786
|
? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
|
|
1636
1787
|
: `(reached the ${result.steps}-step limit without signaling done)`);
|
|
@@ -1685,12 +1836,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1685
1836
|
const tags = Array.from(new Set(
|
|
1686
1837
|
task.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? [],
|
|
1687
1838
|
)).slice(0, 8);
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1839
|
+
const title = `Stalled on: ${excerpt}`;
|
|
1840
|
+
const description = `A prior turn stalled (${why}) on this task — change approach before retrying.`;
|
|
1841
|
+
const body = `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
|
|
1842
|
+
`Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`;
|
|
1843
|
+
void recordFailedAttempt(cwd, { title, description, body, tags }).catch(() => {});
|
|
1844
|
+
// Also offer the same failure to the skill-lessons system (deterministic
|
|
1845
|
+
// free-text -> skill match; writes nothing when no skill clearly relates).
|
|
1846
|
+
void recordSkillLesson(cwd, `${title} ${description}`, {
|
|
1847
|
+
kind: "failure-mode",
|
|
1848
|
+
title,
|
|
1849
|
+
detail: body.slice(0, 300),
|
|
1694
1850
|
}).catch(() => {});
|
|
1695
1851
|
}
|
|
1696
1852
|
const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
|
|
@@ -1725,6 +1881,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1725
1881
|
|
|
1726
1882
|
const joinedArgs = flags.message;
|
|
1727
1883
|
const isOneShot = flags.print || joinedArgs.length > 0 || !process.stdin.isTTY;
|
|
1884
|
+
// hermes-style experience distill (plan/gjc-inheritance.md B6) for one-shot
|
|
1885
|
+
// (`-p`/`--print`/piped) mode — previously ONLY the interactive exit path
|
|
1886
|
+
// spawned this, so a one-shot invocation (the common CI/scripted shape, e.g.
|
|
1887
|
+
// `jeo "task" -p`) never wrote anything to session memory no matter how many
|
|
1888
|
+
// turns it ran. Mirrors the interactive path's exact gate (sessionUsage.turns
|
|
1889
|
+
// > 0) and stays silent (no stdout noise) since one-shot output may be
|
|
1890
|
+
// piped/parsed by a script.
|
|
1891
|
+
const maybeDistillOneShot = async (): Promise<void> => {
|
|
1892
|
+
if (sessionUsage.turns > 0) {
|
|
1893
|
+
await spawnDetachedDistill(history, cwd, sessionModel || defaultModel);
|
|
1894
|
+
}
|
|
1895
|
+
};
|
|
1728
1896
|
|
|
1729
1897
|
if (isOneShot) {
|
|
1730
1898
|
let messageContent = joinedArgs;
|
|
@@ -1911,12 +2079,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1911
2079
|
for (const inv of skillChain.invocations) {
|
|
1912
2080
|
await runOneSkillShot(inv);
|
|
1913
2081
|
}
|
|
2082
|
+
await maybeDistillOneShot();
|
|
1914
2083
|
return;
|
|
1915
2084
|
}
|
|
1916
2085
|
// A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
|
|
1917
2086
|
const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
|
|
1918
2087
|
if (skillInvocation) {
|
|
1919
2088
|
await runOneSkillShot(skillInvocation);
|
|
2089
|
+
await maybeDistillOneShot();
|
|
1920
2090
|
return;
|
|
1921
2091
|
}
|
|
1922
2092
|
try {
|
|
@@ -1926,6 +2096,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1926
2096
|
} catch (err) {
|
|
1927
2097
|
console.log(`! ${friendlyProviderError(err)}`);
|
|
1928
2098
|
}
|
|
2099
|
+
await maybeDistillOneShot();
|
|
1929
2100
|
return;
|
|
1930
2101
|
}
|
|
1931
2102
|
|
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,18 +51,67 @@ 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
|
+
// Evidence tracking (mirrors task-tool.ts's runSubagentOnce readOnlyEvidenceCalls
|
|
77
|
+
// + team.ts's evaluateSubagentResult gate): this consensus gate is the OTHER
|
|
78
|
+
// independent-verifier code path in jeo (team.ts's per-step gate is the first) —
|
|
79
|
+
// it does NOT go through runSubagentOnce, so it needs its own tracking. A verdict
|
|
80
|
+
// is gated the SAME way: zero observed read/search/find/ast_grep/lsp calls means
|
|
81
|
+
// the [OKAY]/[ITERATE]/[REJECT] text is unevidenced regardless of what it claims.
|
|
82
|
+
let evidenceCalls = 0;
|
|
83
|
+
const runOnce = () => runAgentLoop(history, {
|
|
52
84
|
cwd: args.cwd,
|
|
53
|
-
model,
|
|
85
|
+
model: activeModel,
|
|
54
86
|
maxSteps,
|
|
55
87
|
budget: { maxExtensions: 0 },
|
|
56
88
|
signal: args.signal,
|
|
57
89
|
tools: subagentToolset(role),
|
|
90
|
+
rateLimitFallbackAvailable,
|
|
91
|
+
events: {
|
|
92
|
+
onToolResult: (tool, success) => {
|
|
93
|
+
if (success && (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp")) evidenceCalls++;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
58
96
|
});
|
|
97
|
+
let result = await runOnce();
|
|
98
|
+
for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
|
|
99
|
+
if (result.done || !isRateLimitError(new Error(result.doneReason ?? ""))) break;
|
|
100
|
+
const failedScope = credentialScopeFor(activeModel, config);
|
|
101
|
+
if (failedScope) excludedCredentialScopes.add(failedScope.key);
|
|
102
|
+
const candidates = fallbackCandidates();
|
|
103
|
+
if (candidates.length === 0) break;
|
|
104
|
+
activeModel = selectFromPool(candidates, undefined);
|
|
105
|
+
attemptedModels.add(activeModel);
|
|
106
|
+
result = await runOnce();
|
|
107
|
+
}
|
|
59
108
|
const reason = result.doneReason?.trim() ?? "";
|
|
60
109
|
if (!result.done) return { verdict: "unverified", detail: reason || `critic did not converge within ${result.steps} steps` };
|
|
61
110
|
const contract = validateSubagentDoneReason(role, reason);
|
|
62
111
|
if (!contract.ok) return { verdict: "unverified", detail: `critic report incomplete (missing ${contract.missing?.join(", ")}): ${reason.slice(0, 300)}` };
|
|
112
|
+
if (evidenceCalls === 0) {
|
|
113
|
+
return { verdict: "unverified", detail: `critic returned a verdict with ZERO observed read/search/find/ast_grep/lsp calls — unevidenced, regardless of the verdict text: ${reason.slice(0, 300)}` };
|
|
114
|
+
}
|
|
63
115
|
const firstLine = reason.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
64
116
|
const verdict = firstLine === "[OKAY]" ? "okay" : firstLine === "[ITERATE]" ? "iterate" : firstLine === "[REJECT]" ? "reject" : "unverified";
|
|
65
117
|
return { verdict, detail: reason };
|
|
@@ -152,7 +204,14 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
152
204
|
`reading or building on another's output. Never mark two steps parallel if one depends on, ` +
|
|
153
205
|
`reads, or extends a file another writes. When unsure, leave parallel_group out — a plain ` +
|
|
154
206
|
`serial step is always correct; a wrongly-parallel step risks a merge conflict that halts ` +
|
|
155
|
-
`execution
|
|
207
|
+
`execution.\n\n` +
|
|
208
|
+
`MANDATORY: if ANY step mutates (role: executor, or role omitted — omitted defaults to ` +
|
|
209
|
+
`executor), the plan MUST end with a DEDICATED role: architect or role: critic step AFTER ` +
|
|
210
|
+
`the last mutating step (never inside the same parallel_group as a mutating step — concurrent ` +
|
|
211
|
+
`steps cannot verify each other). A plan of executor-only steps with no trailing verifier is ` +
|
|
212
|
+
`REJECTED. This is the independent verifier that inspects what was actually built before the ` +
|
|
213
|
+
`plan counts as done — a read-only-only plan (planner/architect/critic steps with no executor ` +
|
|
214
|
+
`at all) needs no verifier, since nothing mutated.`;
|
|
156
215
|
|
|
157
216
|
const PLANNER = `You are the PLANNER. From the crystallized spec, sequence the work into a logical, outcome-based progression of concrete, ordered tasks.\n` + SCHEMA_SPEC;
|
|
158
217
|
const ARCHITECT = `You are the ARCHITECT. Review the Planner's draft for technical feasibility, correct file targets, directory structure, and any missing setup/wiring/test steps. Return an improved plan (same shape).\n` + SCHEMA_SPEC;
|
|
@@ -175,16 +234,26 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
175
234
|
const raw = await callLlm([{ role: "user" as const, content: userContent }], { systemPrompt, model });
|
|
176
235
|
return raw.replace(/```yaml|```/g, "").trim();
|
|
177
236
|
};
|
|
178
|
-
|
|
237
|
+
/** Validates `yaml` against `PlanSchema` + role-name legality, returning the
|
|
238
|
+
* FIRST concrete reason it's invalid (zod issue message, or an unknown-role
|
|
239
|
+
* name) — or `undefined` when valid. Feeding the actual reason back into the
|
|
240
|
+
* repair prompt (instead of a generic "didn't match the schema") lets the
|
|
241
|
+
* Critic's one repair attempt fix the REAL problem (e.g. a missing trailing
|
|
242
|
+
* verifier step) instead of guessing. */
|
|
243
|
+
const planValidationError = (yaml: string): string | undefined => {
|
|
179
244
|
try {
|
|
180
245
|
const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(yaml)));
|
|
181
|
-
if (!parsed.success)
|
|
246
|
+
if (!parsed.success) {
|
|
247
|
+
return parsed.error.issues[0]?.message ?? "plan did not match the required schema";
|
|
248
|
+
}
|
|
182
249
|
// Round-10 #2 (architect ref 8-Round10Planning): write-time parity with
|
|
183
250
|
// team's execution gate — an unknown role (e.g. "developer") used to pass
|
|
184
251
|
// here and abort only at `jeo team`, after the planning model was gone.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
252
|
+
const badRole = parsed.data.steps.find(s => s.role?.trim() && !getSubagentRole(s.role));
|
|
253
|
+
if (badRole) return `step "${badRole.name}" declares unknown role "${badRole.role}" (must be executor|planner|architect|critic)`;
|
|
254
|
+
return undefined;
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return e instanceof Error ? e.message : "plan YAML failed to parse";
|
|
188
257
|
}
|
|
189
258
|
};
|
|
190
259
|
|
|
@@ -229,11 +298,12 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
229
298
|
// complete (round-10 #2) — failing here, while the model is still in the loop,
|
|
230
299
|
// beats failing later at `jeo team` with the same plan.
|
|
231
300
|
let planValid = true;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
301
|
+
const firstError = planValidationError(cleanPlan);
|
|
302
|
+
if (firstError) {
|
|
303
|
+
log(`[ralplan] Final plan did not match the required shape (${firstError}); requesting a corrected plan…`);
|
|
304
|
+
cleanPlan = await callRole(CRITIC, `Your previous output was invalid: ${firstError}\n\n${SCHEMA_SPEC}\n\nPlan to fix:\n\n${cleanPlan}`, CRITIC_MODEL);
|
|
305
|
+
if (planValidationError(cleanPlan)) {
|
|
306
|
+
const fallback = [reviewed, draft].find(p => !planValidationError(p));
|
|
237
307
|
if (fallback) {
|
|
238
308
|
cleanPlan = fallback;
|
|
239
309
|
log("[ralplan] Using an earlier valid pass output (Critic output was unparseable).");
|
|
@@ -277,7 +347,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
277
347
|
`Revise the plan to address every point.\n\n${SCHEMA_SPEC}\n\nCurrent plan:\n\n${cleanPlan}`,
|
|
278
348
|
CRITIC_MODEL,
|
|
279
349
|
);
|
|
280
|
-
if (
|
|
350
|
+
if (!planValidationError(revised)) {
|
|
281
351
|
cleanPlan = revised;
|
|
282
352
|
await fs.writeFile(planPath, cleanPlan, "utf-8");
|
|
283
353
|
gate = await runConsensusCriticGate({ cwd, seedContent, plan: cleanPlan, signal: opts.signal });
|