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
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `eval` tool — Dynamic Workflows: lets the interactive agent write actual JS
|
|
3
|
+
* control flow (loops, conditionals, sequential composition) around subagent
|
|
4
|
+
* dispatch, closing the gap `task`'s `tasks[]` batch cannot: `tasks[]` is ONE
|
|
5
|
+
* declarative, single-stage, parallel-only fan-out — no way to pipe one
|
|
6
|
+
* subagent's output into the next, branch on a result, or run N stages with a
|
|
7
|
+
* barrier between them. `eval` exposes exactly three orchestration primitives
|
|
8
|
+
* (`task`, `parallel`, `pipeline` — the shape this harness's own `eval` tool
|
|
9
|
+
* gives the model, so the mental model transfers) plus `log`; nothing else.
|
|
10
|
+
*
|
|
11
|
+
* Trust model (2026 consensus — see the module-level doc below): this is NOT
|
|
12
|
+
* a security sandbox. `node:vm`/`vm2`-style same-process isolation is not a
|
|
13
|
+
* real security boundary (both have known escapes; a genuine one needs a
|
|
14
|
+
* microVM/container, which contradicts jeo's zero-native-deps philosophy).
|
|
15
|
+
* `eval` runs at the SAME trust tier as the `bash` tool — full process
|
|
16
|
+
* access, gated by the identical deep-interview mutation lock. A subagent's
|
|
17
|
+
* own toolset (`subagentToolset`) never includes `eval` (mirrors `task`'s own
|
|
18
|
+
* exclusion), so nested eval-authored recursion cannot occur.
|
|
19
|
+
*
|
|
20
|
+
* WHY A WORKER (not `node:vm` or a bare `AsyncFunction`, which browser-tool.ts's
|
|
21
|
+
* `{action:"run"}` uses): a bare `AsyncFunction` shares jeo's own event loop —
|
|
22
|
+
* a script with a synchronous bug (`while (true) {}`, an unbounded non-yielding
|
|
23
|
+
* loop) blocks that ONE shared thread forever, and `Promise.race([fn(), timeoutPromise])`
|
|
24
|
+
* can NEVER preempt it: the timeout's own callback needs the same blocked
|
|
25
|
+
* loop to fire (verified empirically — see this file's own test coverage).
|
|
26
|
+
* A `Worker` runs on a genuinely separate OS thread; `worker.terminate()`
|
|
27
|
+
* preempts it unconditionally, exactly like `bashTool`'s SIGTERM/SIGKILL
|
|
28
|
+
* escalation on a spawned OS process (spawnTextWithTimeout in tools.ts) —
|
|
29
|
+
* the established hard-timeout pattern this codebase already uses elsewhere,
|
|
30
|
+
* just via a Worker instead of a child process (lighter, same-runtime, and
|
|
31
|
+
* `task`/`parallel`/`pipeline` need no native/OS capability a Worker lacks).
|
|
32
|
+
* The worker has NO live config/credential/network access of its own — every
|
|
33
|
+
* `task()` call it makes crosses back to the main thread over `postMessage` as
|
|
34
|
+
* an RPC request, so config/credentials/`runSubagentOnce` never leave the main
|
|
35
|
+
* thread and every dispatch stays subject to the main thread's own limits
|
|
36
|
+
* (MAX_EVAL_TASK_CALLS' total-call cap, an acquireFanoutSlot/releaseFanoutSlot
|
|
37
|
+
* semaphore capping CONCURRENT dispatches at MAX_FANOUT regardless of whether
|
|
38
|
+
* the script used parallel()/pipeline() or fired N raw task() calls directly,
|
|
39
|
+
* excludedCredentialScopes, the abort signal).
|
|
40
|
+
*/
|
|
41
|
+
import { assertBashAllowed, type ToolResult } from "./tools";
|
|
42
|
+
import type { ToolHandler } from "./engine";
|
|
43
|
+
import {
|
|
44
|
+
runSubagentOnce,
|
|
45
|
+
MAX_FANOUT,
|
|
46
|
+
type SubagentTaskConfig,
|
|
47
|
+
type SubagentRunResult,
|
|
48
|
+
type TaskSubEvent,
|
|
49
|
+
} from "./task-tool";
|
|
50
|
+
import { getSubagentRole, defaultSubagentRole, subagentRoleIds } from "./subagents";
|
|
51
|
+
import { loadProjectContext, type ProjectContextFile } from "./context-files";
|
|
52
|
+
|
|
53
|
+
export interface EvalToolOptions {
|
|
54
|
+
config: SubagentTaskConfig;
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
onEvent?: (ev: TaskSubEvent) => void;
|
|
57
|
+
/** Mid-turn steering drain — forwarded to every `task()` dispatch the script
|
|
58
|
+
* makes, same contract as TaskToolOptions.steer. */
|
|
59
|
+
steer?: () => string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Structured result `task()` resolves to inside eval-authored code — a
|
|
63
|
+
* deliberately NARROWER view than the full `SubagentRunResult` (drops the
|
|
64
|
+
* internal contract/mutation-audit bookkeeping fields that belong to jeo's
|
|
65
|
+
* own callers, e.g. `team.ts`'s role gate) since orchestration script logic
|
|
66
|
+
* only needs to branch on "did it work" and "what did it say". */
|
|
67
|
+
export interface EvalTaskResult {
|
|
68
|
+
success: boolean;
|
|
69
|
+
output: string;
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toEvalResult(r: SubagentRunResult): EvalTaskResult {
|
|
74
|
+
return r.error ? { success: r.success, output: r.output, error: r.error } : { success: r.success, output: r.output };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Hard ceiling on how many `task()` dispatches ONE eval call may make.
|
|
78
|
+
* Enforced ONLY on the main thread's RPC handler (the sole path any dispatch
|
|
79
|
+
* can take — the worker has no other way to reach `runSubagentOnce`), so a
|
|
80
|
+
* script cannot bypass it via `parallel()`/`pipeline()`/a bare loop no matter
|
|
81
|
+
* how it shapes the calls. Generous (workflows genuinely chaining many small
|
|
82
|
+
* steps are the whole point of this tool) but not unbounded. */
|
|
83
|
+
const MAX_EVAL_TASK_CALLS = 40;
|
|
84
|
+
|
|
85
|
+
/** Hard cap on a single `parallel()`/`pipeline()` call's item-array length
|
|
86
|
+
* (queue size, not concurrency — both always run at most MAX_FANOUT at once
|
|
87
|
+
* regardless of how many items are queued behind that). Mirrors
|
|
88
|
+
* MAX_SERIAL_EXECUTOR's philosophy in task-tool.ts: an eval script is
|
|
89
|
+
* self-documenting code (unlike a one-shot `tasks[]` JSON batch), so no
|
|
90
|
+
* justification-string gate is required, but a hard ceiling still exists so
|
|
91
|
+
* a buggy/malicious loop cannot queue an unbounded batch. */
|
|
92
|
+
const MAX_PARALLEL_QUEUE = 20;
|
|
93
|
+
|
|
94
|
+
/** Wall-clock budget for one `eval` call — mirrors `bashTool`'s own default
|
|
95
|
+
* timeout philosophy (a runaway script must not block the turn forever).
|
|
96
|
+
* Overridable per-call via `args.timeoutMs` (capped at the same ceiling
|
|
97
|
+
* `bashTool` allows) for a script that's genuinely running many sequential
|
|
98
|
+
* subagent stages. Enforced by `worker.terminate()` — a REAL preemptive
|
|
99
|
+
* kill, not a same-thread race (see module doc). */
|
|
100
|
+
const DEFAULT_EVAL_TIMEOUT_MS = 300_000; // 5 minutes
|
|
101
|
+
const MAX_EVAL_TIMEOUT_MS = 1_800_000; // 30 minutes — same outer ceiling as JEO_CALL_TIMEOUT_MS's default
|
|
102
|
+
|
|
103
|
+
export function evalToolProtocolLine(config?: Pick<import("./state").Config, "subagents">): string {
|
|
104
|
+
return (
|
|
105
|
+
`eval {code, timeoutMs?} — Dynamic Workflows: write an async JS function BODY (return value or throw) ` +
|
|
106
|
+
`composing subagent dispatch with REAL control flow (loops/conditionals/sequencing) instead of one static tool call. ` +
|
|
107
|
+
`In scope: task(role, taskText, context?) -> {success, output, error?} runs ONE subagent to completion ` +
|
|
108
|
+
`(role: ${subagentRoleIds(config).join("|")}, same roles as the 'task' tool); ` +
|
|
109
|
+
`parallel(thunks) -> results[] runs an array of () => Promise thunks through a bounded pool (max ${MAX_FANOUT} concurrent, input order kept); ` +
|
|
110
|
+
`pipeline(items, ...stages) -> results[] maps items through one-arg async stages left-to-right with a barrier between stages, each stage itself bounded at ${MAX_FANOUT} concurrent ` +
|
|
111
|
+
`(stage 1 gets the item, later stages get the previous stage's result); log(message) prints a progress line. ` +
|
|
112
|
+
`Runs in an isolated worker with a hard wall-clock timeout (default ${Math.round(DEFAULT_EVAL_TIMEOUT_MS / 1000)}s, override with {timeoutMs} up to ${Math.round(MAX_EVAL_TIMEOUT_MS / 1000)}s). ` +
|
|
113
|
+
`Use this ONLY when the work genuinely needs sequential composition or branching across multiple subagent calls that 'task's tasks[] (single-stage, parallel-only) cannot express — ` +
|
|
114
|
+
`prefer a plain 'task' call for anything that fits in one dispatch. TRUST NOTE: eval runs with the SAME full process access as 'bash' (no sandbox) — gated by the identical interview mutation lock.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Worker bootstrap (runs in the isolated thread) ──────────────────────────
|
|
119
|
+
// A fixed, non-user-authored script: the ONLY untrusted input crossing into
|
|
120
|
+
// the worker is the `code` string itself, delivered via postMessage (never
|
|
121
|
+
// string-interpolated into this bootstrap — avoids any quoting/escaping
|
|
122
|
+
// hazard). `task`/`parallel`/`pipeline`/`log` are defined HERE, in the
|
|
123
|
+
// worker's own scope, so `parallel`'s thunks and `pipeline`'s stage functions
|
|
124
|
+
// (arbitrary closures the script defines) never need to cross the postMessage
|
|
125
|
+
// boundary — only `task()`'s plain-data request/response does.
|
|
126
|
+
const WORKER_BOOTSTRAP = `
|
|
127
|
+
const MAX_FANOUT = ${MAX_FANOUT};
|
|
128
|
+
const MAX_PARALLEL_QUEUE = ${MAX_PARALLEL_QUEUE};
|
|
129
|
+
let nextReqId = 0;
|
|
130
|
+
const pending = new Map();
|
|
131
|
+
|
|
132
|
+
self.onmessage = async (ev) => {
|
|
133
|
+
const msg = ev.data;
|
|
134
|
+
if (msg.type === "task_response") {
|
|
135
|
+
const resolver = pending.get(msg.id);
|
|
136
|
+
if (!resolver) return;
|
|
137
|
+
pending.delete(msg.id);
|
|
138
|
+
if (msg.ok) resolver.resolve(msg.result);
|
|
139
|
+
else resolver.reject(new Error(msg.error));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (msg.type !== "run") return;
|
|
143
|
+
|
|
144
|
+
const task = (role, taskText, context) => {
|
|
145
|
+
const id = nextReqId++;
|
|
146
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
147
|
+
pending.set(id, { resolve, reject });
|
|
148
|
+
self.postMessage({ type: "task_request", id, role, taskText, context });
|
|
149
|
+
return promise;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const boundedMap = async (items, fn) => {
|
|
153
|
+
if (!Array.isArray(items)) throw new Error("items must be an array.");
|
|
154
|
+
if (items.length === 0) return [];
|
|
155
|
+
if (items.length > MAX_PARALLEL_QUEUE) {
|
|
156
|
+
throw new Error(\`item array of \${items.length} exceeds the cap of \${MAX_PARALLEL_QUEUE} — split into multiple calls.\`);
|
|
157
|
+
}
|
|
158
|
+
const results = new Array(items.length);
|
|
159
|
+
const limit = Math.min(items.length, MAX_FANOUT);
|
|
160
|
+
let next = 0;
|
|
161
|
+
const worker = async () => {
|
|
162
|
+
while (true) {
|
|
163
|
+
const i = next++;
|
|
164
|
+
if (i >= items.length) return;
|
|
165
|
+
results[i] = await fn(items[i], i);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
169
|
+
return results;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const parallel = (thunks) => {
|
|
173
|
+
if (!Array.isArray(thunks) || thunks.some(t => typeof t !== "function")) {
|
|
174
|
+
throw new Error("parallel(thunks): every element must be a () => Promise function.");
|
|
175
|
+
}
|
|
176
|
+
return boundedMap(thunks, (t) => t());
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const pipeline = async (items, ...stages) => {
|
|
180
|
+
if (!Array.isArray(items)) throw new Error("pipeline(items, ...stages): items must be an array.");
|
|
181
|
+
if (stages.length === 0) return items;
|
|
182
|
+
let current = items;
|
|
183
|
+
for (const stage of stages) {
|
|
184
|
+
if (typeof stage !== "function") throw new Error("pipeline(): every stage must be a function.");
|
|
185
|
+
current = await boundedMap(current, (v) => stage(v));
|
|
186
|
+
}
|
|
187
|
+
return current;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const log = (message) => {
|
|
191
|
+
self.postMessage({ type: "log", message: typeof message === "string" ? message : JSON.stringify(message) });
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
196
|
+
const fn = new AsyncFunction("task", "parallel", "pipeline", "log", msg.code);
|
|
197
|
+
const result = await fn(task, parallel, pipeline, log);
|
|
198
|
+
self.postMessage({ type: "done", ok: true, result });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
self.postMessage({ type: "done", ok: false, error: (e && e.message) ? e.message : String(e) });
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
/** One RPC round-trip's shape, as posted by the worker. */
|
|
206
|
+
interface TaskRequestMsg {
|
|
207
|
+
type: "task_request";
|
|
208
|
+
id: number;
|
|
209
|
+
role: unknown;
|
|
210
|
+
taskText: unknown;
|
|
211
|
+
context: unknown;
|
|
212
|
+
}
|
|
213
|
+
interface LogMsg {
|
|
214
|
+
type: "log";
|
|
215
|
+
message: string;
|
|
216
|
+
}
|
|
217
|
+
interface DoneMsg {
|
|
218
|
+
type: "done";
|
|
219
|
+
ok: boolean;
|
|
220
|
+
result?: unknown;
|
|
221
|
+
error?: string;
|
|
222
|
+
}
|
|
223
|
+
type WorkerMsg = TaskRequestMsg | LogMsg | DoneMsg;
|
|
224
|
+
|
|
225
|
+
export function createEvalTool(opts: EvalToolOptions): ToolHandler {
|
|
226
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
227
|
+
const code = typeof args.code === "string" ? args.code : "";
|
|
228
|
+
if (!code.trim()) {
|
|
229
|
+
return { success: false, output: "", error: `eval requires a non-empty 'code' body (an async function BODY — the last expression or an explicit 'return' becomes the result).` };
|
|
230
|
+
}
|
|
231
|
+
// Same gate bashTool itself opens with — eval carries the identical
|
|
232
|
+
// full-process trust level, so it must never bypass the interview lock a
|
|
233
|
+
// plain bash call would be stopped by.
|
|
234
|
+
try {
|
|
235
|
+
await assertBashAllowed(cwd);
|
|
236
|
+
} catch (e: any) {
|
|
237
|
+
return { success: false, output: "", error: e.message ?? String(e) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const timeoutRaw = Number(args.timeoutMs);
|
|
241
|
+
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0
|
|
242
|
+
? Math.min(timeoutRaw, MAX_EVAL_TIMEOUT_MS)
|
|
243
|
+
: DEFAULT_EVAL_TIMEOUT_MS;
|
|
244
|
+
|
|
245
|
+
const logs: string[] = [];
|
|
246
|
+
let taskCalls = 0;
|
|
247
|
+
// Batch-scoped, shared across every task() dispatch this script makes
|
|
248
|
+
// (sequential or via parallel/pipeline) — mirrors createTaskTool's
|
|
249
|
+
// fan-out batchExcludedScopes: the first dispatch to hit an exhausted
|
|
250
|
+
// OAuth-subscription window saves every later dispatch the same wasted round.
|
|
251
|
+
const excludedCredentialScopes = new Set<string>();
|
|
252
|
+
// Loaded once per eval call, shared by every task() dispatch — mirrors
|
|
253
|
+
// createTaskTool's fan-out batchContext (avoids re-scanning AGENTS.md per
|
|
254
|
+
// subagent when a script dispatches many in sequence/parallel).
|
|
255
|
+
let projectContext: ProjectContextFile[] | undefined;
|
|
256
|
+
// Concurrency chokepoint (distinct from MAX_EVAL_TASK_CALLS' total-count
|
|
257
|
+
// cap above): every task() dispatch — whether made sequentially, via
|
|
258
|
+
// parallel()/pipeline()'s own worker-pool bound, or via N raw concurrent
|
|
259
|
+
// task() calls a script fires directly (bypassing parallel/pipeline
|
|
260
|
+
// entirely, e.g. Promise.all(items.map(i => task(...)))) — passes through
|
|
261
|
+
// this ONE main-thread RPC handler (task_request below), so gating
|
|
262
|
+
// concurrency HERE, not just inside the worker's own boundedMap, is what
|
|
263
|
+
// actually makes "at most MAX_FANOUT concurrent" unbypassable regardless
|
|
264
|
+
// of how the script shapes its calls.
|
|
265
|
+
let inFlight = 0;
|
|
266
|
+
const fanoutQueue: Array<{ entry: () => void; remove: () => void }> = [];
|
|
267
|
+
// `abortSignal` here is `rpcSignal` (settle-aware — see below), NOT
|
|
268
|
+
// `combinedSignal`: a QUEUED (not yet dispatched) request must stop
|
|
269
|
+
// waiting for a slot the instant the tool settles, even though nothing
|
|
270
|
+
// was ever sent to runSubagentOnce for it to cancel.
|
|
271
|
+
const acquireFanoutSlot = (abortSignal: AbortSignal): Promise<void> => {
|
|
272
|
+
if (abortSignal.aborted) return Promise.reject(new Error("eval settled before this task() dispatch reached the front of the queue."));
|
|
273
|
+
if (inFlight < MAX_FANOUT) {
|
|
274
|
+
inFlight++;
|
|
275
|
+
return Promise.resolve();
|
|
276
|
+
}
|
|
277
|
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
278
|
+
const entry = () => { inFlight++; resolve(); };
|
|
279
|
+
const queued = { entry, remove: () => {} };
|
|
280
|
+
const onQueueAbort = () => {
|
|
281
|
+
const idx = fanoutQueue.indexOf(queued);
|
|
282
|
+
if (idx !== -1) fanoutQueue.splice(idx, 1); // still waiting — drop it, never dispatched
|
|
283
|
+
reject(new Error("eval settled while this task() dispatch was still queued."));
|
|
284
|
+
};
|
|
285
|
+
abortSignal.addEventListener("abort", onQueueAbort);
|
|
286
|
+
queued.remove = () => abortSignal.removeEventListener("abort", onQueueAbort);
|
|
287
|
+
fanoutQueue.push(queued);
|
|
288
|
+
return promise.finally(queued.remove);
|
|
289
|
+
};
|
|
290
|
+
const releaseFanoutSlot = (): void => {
|
|
291
|
+
inFlight--;
|
|
292
|
+
const next = fanoutQueue.shift();
|
|
293
|
+
if (next) next.entry();
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const blob = new Blob([WORKER_BOOTSTRAP], { type: "application/javascript" });
|
|
297
|
+
const url = URL.createObjectURL(blob);
|
|
298
|
+
const worker = new Worker(url);
|
|
299
|
+
|
|
300
|
+
// Combined signal: EITHER the caller's own abort (Ctrl-C / turn cancel)
|
|
301
|
+
// OR this call's own timeout terminates the worker AND cancels whatever
|
|
302
|
+
// task() dispatch is currently in flight on the main thread — mirrors
|
|
303
|
+
// model-manager.ts's composeAbort (AbortSignal.any is native on the
|
|
304
|
+
// Bun version this CLI enforces; no polyfill needed).
|
|
305
|
+
const timeoutController = new AbortController();
|
|
306
|
+
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
|
|
307
|
+
const combinedSignal = opts.signal
|
|
308
|
+
? AbortSignal.any([opts.signal, timeoutController.signal])
|
|
309
|
+
: timeoutController.signal;
|
|
310
|
+
|
|
311
|
+
// Settle-aware signal (v0.8.24 fix): a script that fires `task()` WITHOUT
|
|
312
|
+
// awaiting it (`task("executor", "x"); return "done";`) can have the
|
|
313
|
+
// worker post `done` while that dispatch is still in flight (mid
|
|
314
|
+
// `runSubagentOnce`) OR still queued behind MAX_FANOUT other dispatches.
|
|
315
|
+
// Without this, the tool returns "success" to the model while a REAL
|
|
316
|
+
// subagent keeps running invisibly in the background — burning LLM
|
|
317
|
+
// calls/tokens/mutations whose result is silently discarded, and (for a
|
|
318
|
+
// queued one) potentially never even starting the wait it's blocked on.
|
|
319
|
+
// `rpcSignal` fires on EVERY settlement path (done/error/timeout/abort),
|
|
320
|
+
// strictly BEFORE the tool returns, and is threaded into BOTH
|
|
321
|
+
// acquireFanoutSlot (cancels a QUEUED wait) and runSubagentOnce's own
|
|
322
|
+
// `signal` (cancels an IN-FLIGHT dispatch) — a fire-and-forget task()
|
|
323
|
+
// gets a bounded cancellation instead of an unbounded background tail.
|
|
324
|
+
const rpcSettleController = new AbortController();
|
|
325
|
+
const rpcSignal = AbortSignal.any([combinedSignal, rpcSettleController.signal]);
|
|
326
|
+
const settleRpc = () => rpcSettleController.abort();
|
|
327
|
+
|
|
328
|
+
const { promise: donePromise, resolve: resolveDone, reject: rejectDone } = Promise.withResolvers<unknown>();
|
|
329
|
+
let settled = false;
|
|
330
|
+
|
|
331
|
+
const onAbort = () => {
|
|
332
|
+
if (settled) return;
|
|
333
|
+
settled = true;
|
|
334
|
+
settleRpc();
|
|
335
|
+
worker.terminate();
|
|
336
|
+
rejectDone(new Error(
|
|
337
|
+
opts.signal?.aborted
|
|
338
|
+
? "eval cancelled."
|
|
339
|
+
: `eval exceeded its ${timeoutMs < 10_000 ? (timeoutMs / 1000).toFixed(1) : Math.round(timeoutMs / 1000)}s timeout (raise via {timeoutMs} up to ${Math.round(MAX_EVAL_TIMEOUT_MS / 1000)}s for a long-running workflow) — the isolated worker was terminated.`
|
|
340
|
+
));
|
|
341
|
+
};
|
|
342
|
+
combinedSignal.addEventListener("abort", onAbort);
|
|
343
|
+
if (combinedSignal.aborted) onAbort();
|
|
344
|
+
|
|
345
|
+
// A response racing a just-terminated worker (e.g. an in-flight
|
|
346
|
+
// runSubagentOnce that was cancelled by settleRpc but still reaches its
|
|
347
|
+
// own catch block a tick later) must never throw and crash the handler —
|
|
348
|
+
// the worker is already gone; the response has nowhere to go.
|
|
349
|
+
const safePostMessage = (msg: Record<string, unknown>): void => {
|
|
350
|
+
try {
|
|
351
|
+
worker.postMessage(msg);
|
|
352
|
+
} catch {
|
|
353
|
+
// worker already terminated — nothing to deliver to.
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
worker.onmessage = async (ev: MessageEvent<WorkerMsg>) => {
|
|
358
|
+
const msg = ev.data;
|
|
359
|
+
if (settled) return;
|
|
360
|
+
if (msg.type === "log") {
|
|
361
|
+
logs.push(msg.message);
|
|
362
|
+
opts.onEvent?.({ role: "executor", kind: "step", detail: msg.message });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (msg.type === "done") {
|
|
366
|
+
settled = true;
|
|
367
|
+
settleRpc(); // cancel any fire-and-forget task() still in flight/queued
|
|
368
|
+
if (msg.ok) resolveDone(msg.result);
|
|
369
|
+
else rejectDone(new Error(msg.error));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (msg.type === "task_request") {
|
|
373
|
+
const { id, role: roleArg, taskText, context } = msg;
|
|
374
|
+
try {
|
|
375
|
+
const trimmedTask = typeof taskText === "string" ? taskText.trim() : "";
|
|
376
|
+
if (!trimmedTask) throw new Error("task(role, taskText, context?): taskText must be a non-empty string.");
|
|
377
|
+
const role = typeof roleArg === "string" && roleArg
|
|
378
|
+
? getSubagentRole(roleArg, opts.config)
|
|
379
|
+
: defaultSubagentRole();
|
|
380
|
+
if (!role) throw new Error(`Unknown subagent role '${String(roleArg)}'. Valid roles: ${subagentRoleIds(opts.config).join(", ")}.`);
|
|
381
|
+
if (taskCalls >= MAX_EVAL_TASK_CALLS) {
|
|
382
|
+
throw new Error(`eval script exceeded the per-call task() dispatch cap (${MAX_EVAL_TASK_CALLS}) — split the workflow across multiple eval calls.`);
|
|
383
|
+
}
|
|
384
|
+
taskCalls++;
|
|
385
|
+
await acquireFanoutSlot(rpcSignal);
|
|
386
|
+
try {
|
|
387
|
+
if (projectContext === undefined) projectContext = await loadProjectContext(cwd);
|
|
388
|
+
const ctxText = typeof context === "string" && context.trim() ? `\n\nContext:\n${context.trim()}` : "";
|
|
389
|
+
const result = await runSubagentOnce(role, trimmedTask, ctxText, cwd, {
|
|
390
|
+
config: opts.config,
|
|
391
|
+
signal: rpcSignal,
|
|
392
|
+
onEvent: opts.onEvent,
|
|
393
|
+
steer: opts.steer,
|
|
394
|
+
projectContext,
|
|
395
|
+
excludedCredentialScopes,
|
|
396
|
+
});
|
|
397
|
+
safePostMessage({ type: "task_response", id, ok: true, result: toEvalResult(result) });
|
|
398
|
+
} finally {
|
|
399
|
+
releaseFanoutSlot();
|
|
400
|
+
}
|
|
401
|
+
} catch (e: any) {
|
|
402
|
+
safePostMessage({ type: "task_response", id, ok: false, error: e?.message ?? String(e) });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
worker.onerror = (ev: ErrorEvent) => {
|
|
407
|
+
if (settled) return;
|
|
408
|
+
settled = true;
|
|
409
|
+
settleRpc();
|
|
410
|
+
rejectDone(new Error(`eval worker crashed: ${ev.message ?? "unknown error"}`));
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
worker.postMessage({ type: "run", code });
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const result = await donePromise;
|
|
417
|
+
const resultText = result === undefined
|
|
418
|
+
? ""
|
|
419
|
+
: `\n\nReturn value:\n${typeof result === "string" ? result : JSON.stringify(result, null, 2)}`;
|
|
420
|
+
const combined = `${logs.join("\n")}${resultText}`.trim();
|
|
421
|
+
return { success: true, output: combined || "(eval completed with no output and no return value)" };
|
|
422
|
+
} catch (e: any) {
|
|
423
|
+
const combined = logs.length ? `${logs.join("\n")}\n\n` : "";
|
|
424
|
+
return { success: false, output: "", error: `${combined}eval failed: ${e?.message ?? String(e)}` };
|
|
425
|
+
} finally {
|
|
426
|
+
settleRpc(); // backstop — every settlement path above already calls this, idempotent
|
|
427
|
+
clearTimeout(timer);
|
|
428
|
+
combinedSignal.removeEventListener("abort", onAbort);
|
|
429
|
+
worker.terminate();
|
|
430
|
+
URL.revokeObjectURL(url);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
}
|
package/src/agent/memory.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { jeoEnv } from "../util/env";
|
|
|
18
18
|
import { parseConcept, serializeConcept, slugify, isReservedFile, conceptId } from "./memory-okf";
|
|
19
19
|
import { tryExtractJsonObject } from "./json";
|
|
20
20
|
import { buildConceptGraph, expandByGraph, lintConceptGraph, type ConceptGraph, type GraphLintReport } from "./memory-graph";
|
|
21
|
+
import { ensureJeoGitignore } from "./state";
|
|
21
22
|
|
|
22
23
|
/** On-disk document cap — the distill prompt instructs the model to stay under it. */
|
|
23
24
|
export const MEMORY_MAX_CHARS = 6_000;
|
|
@@ -217,6 +218,24 @@ export interface Concept {
|
|
|
217
218
|
relPath: string;
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
/** Pure mechanical staleness check (2026 industry-pattern research: confidence
|
|
222
|
+
* must be EARNED via a verification event and staleness must be ACTIVELY
|
|
223
|
+
* tracked, not read off a passive write-timestamp). A concept is stale when
|
|
224
|
+
* it has never been verified (`last_verified` absent/unparseable) or its last
|
|
225
|
+
* verification is older than `staleDays` (default 30). Pure/no I/O so it is
|
|
226
|
+
* trivially unit-testable and wireable into a future `lintConceptGraph` pass
|
|
227
|
+
* or a `jeo memory verify` command without touching this module again. */
|
|
228
|
+
export function isConceptStale(
|
|
229
|
+
concept: Pick<Concept, "confidence"> & { last_verified?: string },
|
|
230
|
+
opts: { staleDays?: number } = {},
|
|
231
|
+
): boolean {
|
|
232
|
+
if (!concept.last_verified) return true;
|
|
233
|
+
const verifiedAtMs = new Date(concept.last_verified).getTime();
|
|
234
|
+
if (Number.isNaN(verifiedAtMs)) return true;
|
|
235
|
+
const staleDays = opts.staleDays ?? 30;
|
|
236
|
+
return Date.now() - verifiedAtMs > staleDays * 24 * 60 * 60 * 1000;
|
|
237
|
+
}
|
|
238
|
+
|
|
220
239
|
/** Per-file parse cache keyed by absolute path → (mtimeMs+size signature, parsed
|
|
221
240
|
* Concept or null for a frontmatter-less/unparseable file). loadConcepts walks
|
|
222
241
|
* `.jeo/memory/` and read+parses every concept file; team/ralph/autopilot call
|
|
@@ -849,6 +868,7 @@ async function cleanupStalePendingFiles(dir: string): Promise<void> {
|
|
|
849
868
|
async function upsertConceptFile(
|
|
850
869
|
bundleDir: string,
|
|
851
870
|
c: { type: string; title: string; description?: string; body?: string; tags?: string[]; confidence?: string; links?: string[]; pinned?: boolean },
|
|
871
|
+
opts: { verified?: boolean } = {},
|
|
852
872
|
): Promise<string> {
|
|
853
873
|
const dir = DIR_BY_TYPE[c.type] ?? "facts";
|
|
854
874
|
await fs.mkdir(path.join(bundleDir, dir), { recursive: true });
|
|
@@ -879,8 +899,8 @@ async function upsertConceptFile(
|
|
|
879
899
|
tags: c.tags ?? [],
|
|
880
900
|
timestamp: new Date().toISOString(),
|
|
881
901
|
confidence: c.confidence ?? "high",
|
|
882
|
-
last_verified: new Date().toISOString().split("T")[0],
|
|
883
902
|
links: c.links ?? [],
|
|
903
|
+
...(opts.verified ? { last_verified: new Date().toISOString().split("T")[0] } : {}),
|
|
884
904
|
...(c.pinned ? { pinned: true } : {}),
|
|
885
905
|
};
|
|
886
906
|
const tmpPath = `${fullPath}.tmp-${process.pid}`;
|
|
@@ -892,7 +912,7 @@ async function upsertConceptFile(
|
|
|
892
912
|
export async function distillSessionMemory(
|
|
893
913
|
history: Message[],
|
|
894
914
|
cwd: string,
|
|
895
|
-
opts: { model?: string; timeoutMs?: number } = {},
|
|
915
|
+
opts: { model?: string; timeoutMs?: number; sessionVerified?: boolean } = {},
|
|
896
916
|
): Promise<DistillResult> {
|
|
897
917
|
if (jeoEnv("NO_MEMORY") === "1") return { updated: false, skipped: "disabled (JEO_NO_MEMORY=1)" };
|
|
898
918
|
const body = history.filter(m => m.role !== "system");
|
|
@@ -983,6 +1003,7 @@ export async function distillSessionMemory(
|
|
|
983
1003
|
|
|
984
1004
|
if (parsedJson && Array.isArray(parsedJson.concepts)) {
|
|
985
1005
|
await fs.mkdir(bundleDir, { recursive: true });
|
|
1006
|
+
await ensureJeoGitignore(cwd);
|
|
986
1007
|
const updatedConcepts: { title: string; type: string }[] = [];
|
|
987
1008
|
|
|
988
1009
|
for (const raw of parsedJson.concepts) {
|
|
@@ -1008,7 +1029,7 @@ export async function distillSessionMemory(
|
|
|
1008
1029
|
tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
|
|
1009
1030
|
confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
|
|
1010
1031
|
links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
|
|
1011
|
-
});
|
|
1032
|
+
}, { verified: opts.sessionVerified === true });
|
|
1012
1033
|
updatedConcepts.push({ title, type });
|
|
1013
1034
|
} catch {
|
|
1014
1035
|
// Skip just this concept; keep distilling the rest of the batch.
|
package/src/agent/plan.ts
CHANGED
|
@@ -53,6 +53,61 @@ export const PlanSchema = z.object({
|
|
|
53
53
|
}
|
|
54
54
|
prevGroup = group;
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
// Maker -> verifier ORDERING (not mere presence): a plan containing any
|
|
58
|
+
// mutating work must have a DEDICATED architect/critic step AFTER the last
|
|
59
|
+
// such mutation, or `jeo team` never runs an independent, evidence-grounded
|
|
60
|
+
// gate over what was actually changed (team.ts's parseRoleGateVerdict is
|
|
61
|
+
// fail-closed and genuinely reads real files — but only fires for a step
|
|
62
|
+
// that HAS an architect/critic role in the right position; nothing before
|
|
63
|
+
// this rule required the plan to include one at all, or forbade a verifier
|
|
64
|
+
// sitting BEFORE the mutations it's supposed to check). Roles read-only by
|
|
65
|
+
// the SUBAGENT_ROLES registry (planner/architect/critic) never mutate;
|
|
66
|
+
// architect/critic ALSO carry a verdict contract team.ts enforces — planner
|
|
67
|
+
// does not, so it can't discharge this gate. `executor` (or an unset/unknown
|
|
68
|
+
// role, which resolves to executor's default) mutates. Grouped
|
|
69
|
+
// (`parallel_group`) steps are evaluated as ONE atomic unit: concurrent
|
|
70
|
+
// steps cannot verify each other (they run in isolated worktrees with no
|
|
71
|
+
// visibility into siblings' STILL-IN-FLIGHT changes), so a critic/architect
|
|
72
|
+
// inside the SAME group as a mutating sibling does not clear the gate —
|
|
73
|
+
// only a later, separate unit does.
|
|
74
|
+
// Local literal, not imported from subagents.ts — `plan.ts` stays a pure,
|
|
75
|
+
// dependency-free schema module (mirrors subagents.ts's own DEFAULT_ROLE_ID).
|
|
76
|
+
const DEFAULT_STEP_ROLE = "executor";
|
|
77
|
+
const READONLY_ROLES: Record<string, true> = { planner: true, architect: true, critic: true };
|
|
78
|
+
const VERIFIER_ROLES: Record<string, true> = { architect: true, critic: true };
|
|
79
|
+
type Unit = { steps: typeof plan.steps; label: string };
|
|
80
|
+
const units: Unit[] = [];
|
|
81
|
+
for (const step of plan.steps) {
|
|
82
|
+
const group = step.parallel_group?.trim() || undefined;
|
|
83
|
+
const last = units[units.length - 1];
|
|
84
|
+
if (group && last && last.label === group) {
|
|
85
|
+
last.steps.push(step);
|
|
86
|
+
} else {
|
|
87
|
+
units.push({ steps: [step], label: group ?? `#solo:${step.name}` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let pendingUnverifiedMutation: string | undefined;
|
|
91
|
+
for (const unit of units) {
|
|
92
|
+
const roleIds = unit.steps.map(s => (s.role?.trim() || DEFAULT_STEP_ROLE));
|
|
93
|
+
const unitMutates = roleIds.some(r => READONLY_ROLES[r] !== true);
|
|
94
|
+
const unitVerifies = roleIds.some(r => VERIFIER_ROLES[r] === true);
|
|
95
|
+
if (unitMutates) {
|
|
96
|
+
pendingUnverifiedMutation = unit.steps[unit.steps.length - 1]!.name;
|
|
97
|
+
} else if (unitVerifies) {
|
|
98
|
+
pendingUnverifiedMutation = undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (pendingUnverifiedMutation) {
|
|
102
|
+
ctx.addIssue({
|
|
103
|
+
code: z.ZodIssueCode.custom,
|
|
104
|
+
message:
|
|
105
|
+
`plan ends with an unverified mutation (last mutating step: "${pendingUnverifiedMutation}") — add an ` +
|
|
106
|
+
`architect or critic step AFTER it (never inside the same parallel_group, which runs concurrently and ` +
|
|
107
|
+
`cannot verify a still-in-flight sibling) so team.ts's evidence-grounded role gate actually runs over the ` +
|
|
108
|
+
`real changes before the plan is considered complete.`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
56
111
|
});
|
|
57
112
|
|
|
58
113
|
/**
|
|
@@ -22,7 +22,7 @@ import * as path from "node:path";
|
|
|
22
22
|
import { SKILLS, getSkill, bundledSkillFileContent } from "../skills/catalog";
|
|
23
23
|
import { callLlm } from "./loop";
|
|
24
24
|
import { resolveVerifierModel } from "./prompt-router";
|
|
25
|
-
import { readGlobalConfig } from "./state";
|
|
25
|
+
import { readGlobalConfig, ensureJeoGitignore } from "./state";
|
|
26
26
|
import { tryExtractJsonObject } from "./json";
|
|
27
27
|
|
|
28
28
|
export interface SkillLesson {
|
|
@@ -154,6 +154,7 @@ export async function appendSkillLesson(cwd: string, lesson: SkillLesson): Promi
|
|
|
154
154
|
|
|
155
155
|
const finalContent = lines.join("\n").replace(/\n{3,}$/, "\n\n");
|
|
156
156
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
157
|
+
await ensureJeoGitignore(cwd);
|
|
157
158
|
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
158
159
|
await fs.writeFile(tmpPath, finalContent, "utf-8");
|
|
159
160
|
await fs.rename(tmpPath, filePath);
|
package/src/agent/state.ts
CHANGED
|
@@ -541,6 +541,32 @@ export function getLocalJeoDir(cwd: string = process.cwd()): string {
|
|
|
541
541
|
return path.join(cwd, ".jeo");
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
/** Ensure `.jeo/` self-ignores in git — memory/skill-lesson writes under `.jeo/`
|
|
545
|
+
* are agent-local working state, not source; without this a CI routine (`jeo
|
|
546
|
+
* routine init`, running `jeo -p` then committing via create-pull-request)
|
|
547
|
+
* ships `.jeo/memory/*`/`.jeo/skills/*` churn into every generated PR. Writes
|
|
548
|
+
* a bare `*` pattern ONCE on first `.jeo/` creation — idempotent (checked via
|
|
549
|
+
* a stat, never overwrites a file that already exists, so a user's own
|
|
550
|
+
* customized `.jeo/.gitignore` is never clobbered). Best-effort: any I/O
|
|
551
|
+
* failure is swallowed, never thrown (this is a hygiene nicety, not a
|
|
552
|
+
* correctness requirement — a missing .gitignore degrades gracefully to "no
|
|
553
|
+
* ignore", not a broken feature). */
|
|
554
|
+
export async function ensureJeoGitignore(cwd: string = process.cwd()): Promise<void> {
|
|
555
|
+
const gitignorePath = path.join(getLocalJeoDir(cwd), ".gitignore");
|
|
556
|
+
try {
|
|
557
|
+
await fs.access(gitignorePath);
|
|
558
|
+
return; // already exists — never overwrite (user customization respected)
|
|
559
|
+
} catch {
|
|
560
|
+
// does not exist — fall through to create
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
await fs.mkdir(getLocalJeoDir(cwd), { recursive: true });
|
|
564
|
+
await fs.writeFile(gitignorePath, "*\n", "utf-8");
|
|
565
|
+
} catch {
|
|
566
|
+
// best-effort — a failed write here must never break the caller's real work
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
544
570
|
// mtime+size-validated cache for the small per-skill workflow-state JSON. readWorkflowState
|
|
545
571
|
// runs repeatedly (the mutation guard reads before every mutating tool), so re-reading +
|
|
546
572
|
// re-parsing each call is wasteful. CROSS-PROCESS-SAFE: a write by another process bumps
|