jeo-code 0.8.23 → 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 +16 -0
- package/README.ja.md +5 -1
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.zh.md +5 -1
- package/package.json +1 -1
- package/src/agent/engine.ts +56 -0
- package/src/agent/eval-tool.ts +433 -0
- package/src/agent/memory.ts +24 -3
- package/src/agent/plan.ts +55 -0
- package/src/agent/skill-lessons.ts +2 -1
- package/src/agent/state.ts +26 -0
- package/src/agent/task-tool.ts +61 -14
- package/src/commands/launch.ts +79 -3
- package/src/commands/ralplan.ts +45 -12
- package/src/commands/team.ts +19 -0
package/src/agent/task-tool.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* repo). Subagents are spawned with `subagentToolset(role)`, which never includes
|
|
10
10
|
* `task` itself, so delegation cannot recurse infinitely.
|
|
11
11
|
*/
|
|
12
|
-
import { runAgentLoop, type ToolHandler } from "./engine";
|
|
12
|
+
import { runAgentLoop, isSafetyFallbackBail, stripSafetyFallbackTag, type ToolHandler } from "./engine";
|
|
13
13
|
import { toolTarget } from "./step-budget";
|
|
14
14
|
|
|
15
15
|
import type { ToolResult } from "./tools";
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
validateSubagentDoneReason,
|
|
31
31
|
type SubagentRole,
|
|
32
32
|
} from "./subagents";
|
|
33
|
-
import { resolveMaxOutputTokens } from "../ai/model-manager";
|
|
33
|
+
import { resolveMaxOutputTokens, resolveProvider } from "../ai/model-manager";
|
|
34
34
|
import type { SubagentRegistry } from "./subagent-registry";
|
|
35
35
|
import { ensureSessionNotifyEndpoint, type SessionNotifyEndpoint } from "./notify/session-endpoint";
|
|
36
36
|
import { inferTierForModel, tierModelPool, credentialScopeFor, selectFromPool, strongestMidTierCredentialed, type RoutingConfig } from "./prompt-router";
|
|
@@ -107,7 +107,7 @@ export interface TaskToolOptions {
|
|
|
107
107
|
* disjoint files (documented in `taskToolProtocolLine`); overlapping scopes
|
|
108
108
|
* should either run sequentially (separate `task` calls) or coordinate — jeo has
|
|
109
109
|
* no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
|
|
110
|
-
const MAX_FANOUT = 4;
|
|
110
|
+
export const MAX_FANOUT = 4;
|
|
111
111
|
|
|
112
112
|
/** Small bounded reroute budget for a SUBAGENT's own 429 (distinct from
|
|
113
113
|
* launch.ts's `ROUTE_FALLBACK_MAX_ATTEMPTS`, default 3): a subagent is already
|
|
@@ -263,6 +263,14 @@ export interface SubagentRunResult extends ToolResult {
|
|
|
263
263
|
fileMutations: number;
|
|
264
264
|
/** Successful bash calls observed (tracked apart — bash CAN mutate but isn't proof). */
|
|
265
265
|
bashRuns: number;
|
|
266
|
+
/** Successful read/search/find/ast_grep/lsp calls observed — evidence a
|
|
267
|
+
* READ-ONLY role (architect/critic) actually inspected real files before
|
|
268
|
+
* rendering a verdict, mirroring goal-verifier.ts's `applyEvidenceGate`
|
|
269
|
+
* philosophy (a claim is only trustworthy when backed by an OBSERVED
|
|
270
|
+
* signal in THIS run, never by the model's own unverified assertion).
|
|
271
|
+
* Zero means the verdict's `Inspected:`/prose claims are unevidenced,
|
|
272
|
+
* regardless of what the text says. */
|
|
273
|
+
readOnlyEvidenceCalls: number;
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
/**
|
|
@@ -312,6 +320,7 @@ export async function runSubagentOnce(
|
|
|
312
320
|
// never which model dispatched a given step.
|
|
313
321
|
let fileMutations = 0;
|
|
314
322
|
let bashRuns = 0;
|
|
323
|
+
let readOnlyEvidenceCalls = 0;
|
|
315
324
|
// Per-run prompt-cache key: the subagent replays its own growing history each
|
|
316
325
|
// step, so a stable key (even across a model switch below — a stale cache key
|
|
317
326
|
// on a NEW provider is simply unused, never wrong) gets provider cache hits
|
|
@@ -357,6 +366,28 @@ export async function runSubagentOnce(
|
|
|
357
366
|
// retry ladder on the FIRST failed attempt instead of riding ~90s of backoff when
|
|
358
367
|
// a genuinely different-credential-scope candidate is available RIGHT NOW.
|
|
359
368
|
const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
|
|
369
|
+
// Providers that have ALREADY produced an uncategorized-refusal bail THIS run —
|
|
370
|
+
// accumulates across reroutes (mirrors excludedCredentialScopes' own pattern)
|
|
371
|
+
// so a 2nd safety bail (on the FIRST fallback model) can never cycle back to a
|
|
372
|
+
// provider already ruled out, even though `attemptedModels` only tracks exact
|
|
373
|
+
// model ids, not whole providers.
|
|
374
|
+
const excludedSafetyProviders = new Set<string>();
|
|
375
|
+
// Stricter than fallbackCandidates(): a same-provider sibling shares the SAME
|
|
376
|
+
// constitutional/moderation classifier (unlike a same-credential-scope sibling,
|
|
377
|
+
// which is a BILLING boundary, not a classifier boundary) — trying it after an
|
|
378
|
+
// uncategorized refusal is not a genuinely different verdict, it's very likely
|
|
379
|
+
// the identical false positive again. Always excludes the CURRENT provider (not
|
|
380
|
+
// just accumulated ones), so this is correct from the very first check too.
|
|
381
|
+
const safetyFallbackCandidates = (): string[] => {
|
|
382
|
+
const currentProvider = resolveProvider(activeModel);
|
|
383
|
+
return fallbackCandidates().filter(m => {
|
|
384
|
+
const p = resolveProvider(m);
|
|
385
|
+
return p !== currentProvider && !excludedSafetyProviders.has(p);
|
|
386
|
+
});
|
|
387
|
+
};
|
|
388
|
+
// Fed to AgentLoopOptions.safetyFallbackAvailable — see its doc comment for the
|
|
389
|
+
// "false positive -> try a different model" design this implements.
|
|
390
|
+
const safetyFallbackAvailable = (): boolean => safetyFallbackCandidates().length > 0;
|
|
360
391
|
|
|
361
392
|
let activeModel = initialModel;
|
|
362
393
|
emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model: activeModel });
|
|
@@ -374,6 +405,7 @@ export async function runSubagentOnce(
|
|
|
374
405
|
steer,
|
|
375
406
|
tools: subagentToolset(role),
|
|
376
407
|
rateLimitFallbackAvailable,
|
|
408
|
+
safetyFallbackAvailable,
|
|
377
409
|
events: {
|
|
378
410
|
onStep: n => { currentStep = n; },
|
|
379
411
|
// Live reasoning preview (native extended-thinking models only — the JSON-protocol
|
|
@@ -398,6 +430,7 @@ export async function runSubagentOnce(
|
|
|
398
430
|
if (success) {
|
|
399
431
|
if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
|
|
400
432
|
else if (tool === "bash") bashRuns++;
|
|
433
|
+
else if (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp") readOnlyEvidenceCalls++;
|
|
401
434
|
}
|
|
402
435
|
const label = lastTarget || tool;
|
|
403
436
|
const summary = firstUsefulLine(output);
|
|
@@ -418,22 +451,35 @@ export async function runSubagentOnce(
|
|
|
418
451
|
|
|
419
452
|
let result = await runOnce();
|
|
420
453
|
for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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;
|
|
454
|
+
const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
|
|
455
|
+
const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
|
|
456
|
+
if (!rateLimited && !safetyBail) break;
|
|
429
457
|
const previousModel = activeModel;
|
|
430
|
-
|
|
458
|
+
if (rateLimited) {
|
|
459
|
+
// The model that just 429'd: exclude its WHOLE credential scope (not just its
|
|
460
|
+
// own id) so a sibling model riding the SAME exhausted OAuth subscription is
|
|
461
|
+
// never proposed as a "fallback" (cca5fe2's fix, mirrored here).
|
|
462
|
+
const failedScope = credentialScopeFor(activeModel, opts.config);
|
|
463
|
+
if (failedScope) excludedCredentialScopes.add(failedScope.key);
|
|
464
|
+
const candidates = fallbackCandidates();
|
|
465
|
+
if (candidates.length === 0) break;
|
|
466
|
+
activeModel = selectFromPool(candidates, undefined);
|
|
467
|
+
emit({ role: role.id, kind: "step", detail: `↳ rate limited on '${previousModel}' — switching to equivalent '${activeModel}'`, step: currentStep, maxSteps, model: activeModel });
|
|
468
|
+
} else {
|
|
469
|
+
// Uncategorized refusal with a fallback available (engine already verified
|
|
470
|
+
// this via safetyFallbackAvailable before bailing) — exclude the WHOLE
|
|
471
|
+
// provider (see safetyFallbackCandidates' doc comment), not just this model.
|
|
472
|
+
excludedSafetyProviders.add(resolveProvider(activeModel));
|
|
473
|
+
const candidates = safetyFallbackCandidates();
|
|
474
|
+
if (candidates.length === 0) break;
|
|
475
|
+
activeModel = selectFromPool(candidates, undefined);
|
|
476
|
+
emit({ role: role.id, kind: "step", detail: `↳ refused on '${previousModel}' — switching to '${activeModel}' in case of a classifier false positive`, step: currentStep, maxSteps, model: activeModel });
|
|
477
|
+
}
|
|
431
478
|
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
479
|
result = await runOnce();
|
|
434
480
|
}
|
|
435
481
|
|
|
436
|
-
const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
|
|
482
|
+
const reason = stripSafetyFallbackTag(result.doneReason?.trim() || "") || `(subagent reached the ${result.steps}-step limit without signaling done)`;
|
|
437
483
|
const validation = validateSubagentDoneReason(role, reason);
|
|
438
484
|
const complete = result.done && validation.ok;
|
|
439
485
|
const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
|
|
@@ -459,6 +505,7 @@ export async function runSubagentOnce(
|
|
|
459
505
|
missingMarkers: validation.ok ? undefined : validation.missing,
|
|
460
506
|
fileMutations,
|
|
461
507
|
bashRuns,
|
|
508
|
+
readOnlyEvidenceCalls,
|
|
462
509
|
};
|
|
463
510
|
}
|
|
464
511
|
|
package/src/commands/launch.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
|
|
@@ -10,6 +10,7 @@ import { initialDynamicStepLimit } from "../agent/step-budget";
|
|
|
10
10
|
import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
|
|
11
11
|
import { recordSkillLesson } from "../agent/skill-lessons";
|
|
12
12
|
import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
|
|
13
|
+
import { createEvalTool, evalToolProtocolLine } from "../agent/eval-tool";
|
|
13
14
|
import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
|
|
14
15
|
import { SubagentRegistry } from "../agent/subagent-registry";
|
|
15
16
|
import { stopSessionNotifyEndpoint, startSessionNotifyEndpoint, type SessionNotifyEndpoint } from "../agent/notify/session-endpoint";
|
|
@@ -550,7 +551,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
550
551
|
// pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
|
|
551
552
|
const contextFiles = await loadProjectContext(cwd);
|
|
552
553
|
|
|
553
|
-
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"]);
|
|
554
555
|
let allowedTools = new Set(KNOWN_TOOLS);
|
|
555
556
|
|
|
556
557
|
if (flags.noTools) {
|
|
@@ -618,6 +619,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
618
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." +
|
|
619
620
|
(allowedTools.has("task") ? "\n\nDelegation: " + taskToolProtocolLine(cfg) +
|
|
620
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) : "") +
|
|
621
623
|
(allowedTools.has("todo") ? "\n\nPlanning: " + TODO_TOOL_PROTOCOL_LINE : "") +
|
|
622
624
|
(allowedTools.has("subagent") ? "\n\nDetached subagents: " + SUBAGENT_TOOL_PROTOCOL_LINE +
|
|
623
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." : "") +
|
|
@@ -1012,11 +1014,33 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1012
1014
|
// genuinely different credential.
|
|
1013
1015
|
const isAccountLevelRouteFailure = (reason: string): boolean =>
|
|
1014
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);
|
|
1015
1033
|
const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
|
|
1016
1034
|
if (isAccountLevelRouteFailure(reason)) {
|
|
1017
1035
|
const scope = credentialScopeFor(decision.model, turnConfig);
|
|
1018
1036
|
if (scope) excludedCredentialScopes.add(scope.key);
|
|
1019
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));
|
|
1020
1044
|
const excluded = new Set([decision.model, ...exclude]);
|
|
1021
1045
|
const poolCandidates = (): string[] =>
|
|
1022
1046
|
tierModelPool(decision.tier, turnConfig)
|
|
@@ -1025,6 +1049,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1025
1049
|
const scope = credentialScopeFor(model, turnConfig);
|
|
1026
1050
|
return !scope || !excludedCredentialScopes.has(scope.key);
|
|
1027
1051
|
})
|
|
1052
|
+
.filter(model => !excludedSafetyProviders.has(resolveProvider(model)))
|
|
1028
1053
|
.filter(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
1029
1054
|
let candidates = poolCandidates();
|
|
1030
1055
|
// The static MODEL_CATALOG only lists a couple of OpenAI-compatible clouds by
|
|
@@ -1465,6 +1490,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1465
1490
|
? (e => tui?.onSubagentEvent(e))
|
|
1466
1491
|
: (e => logTaskSubEvent(e)),
|
|
1467
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
|
+
}),
|
|
1468
1501
|
todo: createTodoTool({ onChange: items => { turnTodos = items; tui?.setTodos(items); } }),
|
|
1469
1502
|
subagent: createSubagentTool(subagentRegistry),
|
|
1470
1503
|
job: createJobTool(jobRegistry),
|
|
@@ -1543,6 +1576,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1543
1576
|
})
|
|
1544
1577
|
.some(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
1545
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
|
+
};
|
|
1546
1598
|
const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
|
|
1547
1599
|
cwd,
|
|
1548
1600
|
tools,
|
|
@@ -1555,11 +1607,20 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1555
1607
|
sessionKey: cacheKey,
|
|
1556
1608
|
steer: drainSteer,
|
|
1557
1609
|
rateLimitFallbackAvailable,
|
|
1610
|
+
safetyFallbackAvailable,
|
|
1558
1611
|
events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
|
|
1559
1612
|
});
|
|
1560
1613
|
const routeFailureReason = (doneReason: unknown): string | null => {
|
|
1561
1614
|
const message = String(doneReason ?? "");
|
|
1562
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)";
|
|
1563
1624
|
const asError = new Error(message);
|
|
1564
1625
|
if (isRateLimitError(asError)) return "hit a rate limit";
|
|
1565
1626
|
if (isUsageLimitError(asError)) return "hit a usage/quota limit";
|
|
@@ -1720,7 +1781,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1720
1781
|
}
|
|
1721
1782
|
// A completed turn with an empty done-reason must NOT masquerade as a step-limit
|
|
1722
1783
|
// failure ("reached the 3-step limit" after the model called done at step 3).
|
|
1723
|
-
const reply = result.doneReason
|
|
1784
|
+
const reply = stripSafetyFallbackTag(result.doneReason ?? "")
|
|
1724
1785
|
|| (result.done
|
|
1725
1786
|
? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
|
|
1726
1787
|
: `(reached the ${result.steps}-step limit without signaling done)`);
|
|
@@ -1820,6 +1881,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1820
1881
|
|
|
1821
1882
|
const joinedArgs = flags.message;
|
|
1822
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
|
+
};
|
|
1823
1896
|
|
|
1824
1897
|
if (isOneShot) {
|
|
1825
1898
|
let messageContent = joinedArgs;
|
|
@@ -2006,12 +2079,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2006
2079
|
for (const inv of skillChain.invocations) {
|
|
2007
2080
|
await runOneSkillShot(inv);
|
|
2008
2081
|
}
|
|
2082
|
+
await maybeDistillOneShot();
|
|
2009
2083
|
return;
|
|
2010
2084
|
}
|
|
2011
2085
|
// A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
|
|
2012
2086
|
const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
|
|
2013
2087
|
if (skillInvocation) {
|
|
2014
2088
|
await runOneSkillShot(skillInvocation);
|
|
2089
|
+
await maybeDistillOneShot();
|
|
2015
2090
|
return;
|
|
2016
2091
|
}
|
|
2017
2092
|
try {
|
|
@@ -2021,6 +2096,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2021
2096
|
} catch (err) {
|
|
2022
2097
|
console.log(`! ${friendlyProviderError(err)}`);
|
|
2023
2098
|
}
|
|
2099
|
+
await maybeDistillOneShot();
|
|
2024
2100
|
return;
|
|
2025
2101
|
}
|
|
2026
2102
|
|
package/src/commands/ralplan.ts
CHANGED
|
@@ -73,6 +73,13 @@ export async function runConsensusCriticGate(args: {
|
|
|
73
73
|
const rateLimitFallbackAvailable = (): boolean => fallbackCandidates().length > 0;
|
|
74
74
|
|
|
75
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;
|
|
76
83
|
const runOnce = () => runAgentLoop(history, {
|
|
77
84
|
cwd: args.cwd,
|
|
78
85
|
model: activeModel,
|
|
@@ -81,6 +88,11 @@ export async function runConsensusCriticGate(args: {
|
|
|
81
88
|
signal: args.signal,
|
|
82
89
|
tools: subagentToolset(role),
|
|
83
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
|
+
},
|
|
84
96
|
});
|
|
85
97
|
let result = await runOnce();
|
|
86
98
|
for (let attempt = 0; attempt < MAX_SUBAGENT_REROUTES; attempt++) {
|
|
@@ -97,6 +109,9 @@ export async function runConsensusCriticGate(args: {
|
|
|
97
109
|
if (!result.done) return { verdict: "unverified", detail: reason || `critic did not converge within ${result.steps} steps` };
|
|
98
110
|
const contract = validateSubagentDoneReason(role, reason);
|
|
99
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
|
+
}
|
|
100
115
|
const firstLine = reason.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
101
116
|
const verdict = firstLine === "[OKAY]" ? "okay" : firstLine === "[ITERATE]" ? "iterate" : firstLine === "[REJECT]" ? "reject" : "unverified";
|
|
102
117
|
return { verdict, detail: reason };
|
|
@@ -189,7 +204,14 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
189
204
|
`reading or building on another's output. Never mark two steps parallel if one depends on, ` +
|
|
190
205
|
`reads, or extends a file another writes. When unsure, leave parallel_group out — a plain ` +
|
|
191
206
|
`serial step is always correct; a wrongly-parallel step risks a merge conflict that halts ` +
|
|
192
|
-
`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.`;
|
|
193
215
|
|
|
194
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;
|
|
195
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;
|
|
@@ -212,16 +234,26 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
212
234
|
const raw = await callLlm([{ role: "user" as const, content: userContent }], { systemPrompt, model });
|
|
213
235
|
return raw.replace(/```yaml|```/g, "").trim();
|
|
214
236
|
};
|
|
215
|
-
|
|
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 => {
|
|
216
244
|
try {
|
|
217
245
|
const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(yaml)));
|
|
218
|
-
if (!parsed.success)
|
|
246
|
+
if (!parsed.success) {
|
|
247
|
+
return parsed.error.issues[0]?.message ?? "plan did not match the required schema";
|
|
248
|
+
}
|
|
219
249
|
// Round-10 #2 (architect ref 8-Round10Planning): write-time parity with
|
|
220
250
|
// team's execution gate — an unknown role (e.g. "developer") used to pass
|
|
221
251
|
// here and abort only at `jeo team`, after the planning model was gone.
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
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";
|
|
225
257
|
}
|
|
226
258
|
};
|
|
227
259
|
|
|
@@ -266,11 +298,12 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
266
298
|
// complete (round-10 #2) — failing here, while the model is still in the loop,
|
|
267
299
|
// beats failing later at `jeo team` with the same plan.
|
|
268
300
|
let planValid = true;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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));
|
|
274
307
|
if (fallback) {
|
|
275
308
|
cleanPlan = fallback;
|
|
276
309
|
log("[ralplan] Using an earlier valid pass output (Critic output was unparseable).");
|
|
@@ -314,7 +347,7 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
314
347
|
`Revise the plan to address every point.\n\n${SCHEMA_SPEC}\n\nCurrent plan:\n\n${cleanPlan}`,
|
|
315
348
|
CRITIC_MODEL,
|
|
316
349
|
);
|
|
317
|
-
if (
|
|
350
|
+
if (!planValidationError(revised)) {
|
|
318
351
|
cleanPlan = revised;
|
|
319
352
|
await fs.writeFile(planPath, cleanPlan, "utf-8");
|
|
320
353
|
gate = await runConsensusCriticGate({ cwd, seedContent, plan: cleanPlan, signal: opts.signal });
|
package/src/commands/team.ts
CHANGED
|
@@ -519,6 +519,25 @@ function evaluateSubagentResult(
|
|
|
519
519
|
return false;
|
|
520
520
|
}
|
|
521
521
|
|
|
522
|
+
// Evidence gate (UNCONDITIONAL — same tier as parseRoleGateVerdict, never
|
|
523
|
+
// softened by --strict-mutations): an architect/critic verdict is worthless
|
|
524
|
+
// if the subagent never actually looked at anything. Mirrors goal-verifier.ts's
|
|
525
|
+
// applyEvidenceGate philosophy applied to the OTHER independent-verifier gate
|
|
526
|
+
// in this codebase — a formatted "Architectural Status: CLEAR" or "[OKAY]"
|
|
527
|
+
// first line is text the model can emit with ZERO real inspection; only an
|
|
528
|
+
// OBSERVED read/search/find/ast_grep/lsp call in this run counts as evidence
|
|
529
|
+
// the verdict is grounded in the actual repository state, not the model's own
|
|
530
|
+
// unverified assertion (2026 pattern: "avoid vibes verification — a claim is
|
|
531
|
+
// only trustworthy when backed by a programmatic, observed signal").
|
|
532
|
+
if ((role.id === "architect" || role.id === "critic") && result.readOnlyEvidenceCalls === 0) {
|
|
533
|
+
log(formatRalphStreamEvent(
|
|
534
|
+
"error",
|
|
535
|
+
`${role.title} returned a verdict with ZERO observed read/search/find/ast_grep/lsp calls — an unevidenced verdict is not trustworthy regardless of what the text claims. Blocking.`,
|
|
536
|
+
renderOpts,
|
|
537
|
+
));
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
|
|
522
541
|
// Round-8: a mutating role finished without a successful file mutation — the
|
|
523
542
|
// task may be legitimately read-only, but its "Changed Files:" claim is
|
|
524
543
|
// unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
|