@visual-z/xeno 0.8.2 → 0.8.3
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 +42 -0
- package/package.json +1 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +4 -4
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +17 -2
- package/src/core/command-contract.ts +58 -16
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +124 -20
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +60 -0
- package/src/core/prompt-policy.ts +4 -2
- package/src/core/secrets.ts +188 -0
- package/src/core/stop-report.ts +25 -3
- package/src/core/tool-failure.ts +11 -3
- package/src/core/transcript.ts +3 -0
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +66 -18
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +98 -14
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +97 -27
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +1 -1
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
package/src/core/work-state.ts
CHANGED
|
@@ -50,6 +50,10 @@ export function scopedWorkId(namespace: string, requested: string, items: readon
|
|
|
50
50
|
? requested : `${namespace}:${requested}`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function unknownWork(id: string): string {
|
|
54
|
+
return `unknown work_id ${id}; create it with a next note (kind=next) first`;
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
export function workChangeError(items: readonly WorkItem[], event: LogEvent): string | null {
|
|
54
58
|
if (!("work_id" in event) || event.work_id === undefined) return null;
|
|
55
59
|
const item = items.find(item => item.id === event.work_id);
|
|
@@ -57,9 +61,10 @@ export function workChangeError(items: readonly WorkItem[], event: LogEvent): st
|
|
|
57
61
|
const error = dependencyError(items, event.work_id, event.depends_on);
|
|
58
62
|
if (error) return error;
|
|
59
63
|
}
|
|
60
|
-
|
|
64
|
+
// Every unknown-id rejection names the way to make the id exist, or the model retries the same call.
|
|
65
|
+
if (event.t === "work_dependencies" && !item) return unknownWork(event.work_id);
|
|
61
66
|
if (event.t === "work_owner") {
|
|
62
|
-
if (!item) return
|
|
67
|
+
if (!item) return unknownWork(event.work_id);
|
|
63
68
|
if (event.operation === "release") return item.owner === event.owner ? null : "only the recorded owner can release work";
|
|
64
69
|
const ready = workReadiness(items, item.id, event.owner);
|
|
65
70
|
return ready.ready ? null : `work ${item.id} is ${ready.reason}; cannot claim it`;
|
package/src/core/working-set.ts
CHANGED
|
@@ -7,9 +7,17 @@ import { estimateTokens } from "./tokens.ts";
|
|
|
7
7
|
import { RECOVERY_SECTIONS, recoverySources, type RecoveryNodes, type RecoveryRanges, type RecoveryReference } from "./recovery-sources.ts";
|
|
8
8
|
import { recoveryInstructions } from "./prompt-policy.ts";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The cut format new sessions record. Live cuts, cold replay and interrupted-cut
|
|
12
|
+
* repair all select through `carriedHandoff`, so this is only the default for
|
|
13
|
+
* cuts not yet written; a recorded version is never reinterpreted.
|
|
14
|
+
*/
|
|
15
|
+
export const CURRENT_WORKING_SET_VERSION = 3;
|
|
16
|
+
|
|
10
17
|
export type WorkingSet = {
|
|
11
|
-
version: 1 | 2; source: NodeRef;
|
|
12
|
-
|
|
18
|
+
version: 1 | 2 | 3; source: NodeRef;
|
|
19
|
+
/** v3 omits `what`, `next_action` and `reason` when the carried `state.work_items` holds the same item. */
|
|
20
|
+
work: (Pick<WorkItem, "id" | "status" | "next_action" | "reason" | "depends_on" | "owner"> & { what?: string; readiness?: WorkReadiness })[];
|
|
13
21
|
references: ({ node?: number; event: string; kind: string; work?: string } & Partial<Omit<RecoveryReference, "node" | "event" | "kind" | "work">>)[];
|
|
14
22
|
omitted: { work: number; references: number };
|
|
15
23
|
recovery_sections?: RecoveryRanges;
|
|
@@ -50,20 +58,40 @@ export function workingSet(full: Handoff, budget: number): WorkingSet | null {
|
|
|
50
58
|
|
|
51
59
|
/** R9: a new projection version keeps the original v1 ordering byte-stable. */
|
|
52
60
|
export function recoveryWorkingSet(full: Handoff, nodes: RecoveryNodes, budget: number, tools?: readonly string[]): WorkingSet | null {
|
|
61
|
+
return sourceView(full, nodes, { version: 2, users: true,
|
|
62
|
+
fits: view => estimateTokens.count([], emitYaml(view)) <= budget,
|
|
63
|
+
recovery: tools === undefined ? "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; event=ID reads one original. Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting. history(work_id=ID) recovers work." : recoveryInstructions(tools) });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* v3 shares v2's source ordering. Two things differ, both decided by the caller
|
|
68
|
+
* that owns the whole carried handoff: `fits` measures the view inside that
|
|
69
|
+
* handoff rather than standalone, and `users: false` leaves user sources out
|
|
70
|
+
* because the carried `user_turns` already hold every one verbatim.
|
|
71
|
+
*/
|
|
72
|
+
export function recoveryWorkingSetV3(full: Handoff, nodes: RecoveryNodes, options: {
|
|
73
|
+
users: boolean; recovery: string; fits: (view: WorkingSet) => boolean;
|
|
74
|
+
}): WorkingSet | null {
|
|
75
|
+
return sourceView(full, nodes, { version: 3, ...options });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sourceView(full: Handoff, nodes: RecoveryNodes, options: {
|
|
79
|
+
version: 2 | 3; users: boolean; recovery: string; fits: (view: WorkingSet) => boolean;
|
|
80
|
+
}): WorkingSet | null {
|
|
53
81
|
const legacy = workingSet(full, Infinity);
|
|
54
82
|
if (!legacy) return null;
|
|
55
83
|
const sources = recoverySources(nodes);
|
|
56
|
-
const view: WorkingSet = { version:
|
|
84
|
+
const view: WorkingSet = { version: options.version, source: legacy.source, work: [], references: [],
|
|
57
85
|
omitted: { work: legacy.work.length, references: legacy.references.length + RECOVERY_SECTIONS.reduce((sum, section) => sum + sources[section].length, 0) },
|
|
58
86
|
recovery_sections: Object.fromEntries(RECOVERY_SECTIONS.map(section => [section, { total: sources[section].length, omitted: sources[section].length }])) as RecoveryRanges,
|
|
59
|
-
recovery:
|
|
60
|
-
const fits = () =>
|
|
87
|
+
recovery: options.recovery };
|
|
88
|
+
const fits = () => options.fits(view);
|
|
61
89
|
if (!fits()) return null;
|
|
62
90
|
const queues = RECOVERY_SECTIONS.map(section => ({ section, items: [...sources[section]], at: 0 }));
|
|
63
91
|
// The latest and first live user messages anchor correction and initial scope;
|
|
64
92
|
// remaining user sources are newest first. Cancelled turns stay pageable.
|
|
65
93
|
const live = sources.users.filter(ref => !ref.abandoned), first = live.at(-1);
|
|
66
|
-
queues[0]!.items = [...live.slice(0, 1), ...(first && first !== live[0] ? [first] : []),
|
|
94
|
+
queues[0]!.items = !options.users ? [] : [...live.slice(0, 1), ...(first && first !== live[0] ? [first] : []),
|
|
67
95
|
...live.slice(1, -1), ...sources.users.filter(ref => ref.abandoned)];
|
|
68
96
|
const takeReference = (reference: WorkingSet["references"][number], section?: keyof RecoveryRanges) => {
|
|
69
97
|
view.references.push(reference); view.omitted.references--;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a provider may keep reusing pooled connections.
|
|
3
|
+
*
|
|
4
|
+
* A socket that a proxy, NAT or server dropped while it sat in the pool fails
|
|
5
|
+
* the next request the same way -- Horizyn 2026-10-12 lost the first request
|
|
6
|
+
* after "continue" to it. Claude Code answers by turning keep-alive off for
|
|
7
|
+
* the rest of the process once a connection dies; this does the same per
|
|
8
|
+
* provider instance, so tests sharing a process cannot leak state into each
|
|
9
|
+
* other and a fresh session starts from the cheap path again
|
|
10
|
+
* (feat-fresh-connection-after-drop).
|
|
11
|
+
*
|
|
12
|
+
* Bun honours `keepalive: false` by opening a new socket per request
|
|
13
|
+
* (measured on 1.3.10: three default fetches share one socket, three with
|
|
14
|
+
* keepalive:false open three). The cost is a TLS handshake per request,
|
|
15
|
+
* negligible next to a model call. Google, Vertex and Bedrock go through
|
|
16
|
+
* pi-ai's native HTTP with no fetch hook, so this cannot reach them.
|
|
17
|
+
*/
|
|
18
|
+
import { connectionFailure } from "./retry.ts";
|
|
19
|
+
|
|
20
|
+
export class ConnectionReuse {
|
|
21
|
+
private fresh = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Called with every failed attempt, retried or final. Only a connection
|
|
25
|
+
* that existed and died counts: one that never opened left nothing stale
|
|
26
|
+
* in the pool.
|
|
27
|
+
*/
|
|
28
|
+
note(error: unknown): void {
|
|
29
|
+
if (connectionFailure(error) === "stale") this.fresh = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
init(init: RequestInit | undefined): RequestInit | undefined {
|
|
33
|
+
return this.fresh ? { ...init, keepalive: false } : init;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A fetch that opens fresh connections once one has died. */
|
|
37
|
+
wrap(fetchImpl: typeof fetch): typeof fetch {
|
|
38
|
+
return Object.assign((input: RequestInfo | URL, init?: RequestInit) => fetchImpl(input, this.init(init)), { preconnect: fetchImpl.preconnect });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -46,6 +46,19 @@ export function yieldWait(value: unknown): { ms: number; note: string } {
|
|
|
46
46
|
const ms = Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, requested));
|
|
47
47
|
return { ms, note: ms === requested ? "" : ` · yield_time_ms ${requested} clamped to ${ms}` };
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Bounds on one owned empty wait's window, copied from Codex's empty
|
|
51
|
+
* `write_stdin` (MIN_EMPTY_YIELD_TIME_MS, DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS),
|
|
52
|
+
* not measured. The floor is what stops a chatty process from waking the
|
|
53
|
+
* model every line: below it, a 100 ms request brings the per-line wakeups back.
|
|
54
|
+
*/
|
|
55
|
+
export const MIN_EMPTY_YIELD_MS = 5_000;
|
|
56
|
+
export const MAX_EMPTY_YIELD_MS = 300_000;
|
|
57
|
+
export function emptyYieldWait(value: unknown): { ms: number; note: string } {
|
|
58
|
+
const requested = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS;
|
|
59
|
+
const ms = Math.min(MAX_EMPTY_YIELD_MS, Math.max(MIN_EMPTY_YIELD_MS, requested));
|
|
60
|
+
return { ms, note: ms === requested ? "" : ` · yield_time_ms ${requested} clamped to ${ms}` };
|
|
61
|
+
}
|
|
49
62
|
/**
|
|
50
63
|
* Concurrent sessions. An assumption, not a measurement: comfortably above a
|
|
51
64
|
* server + watcher + REPL, low enough that a runaway loop of starts is refused
|
|
@@ -61,6 +74,12 @@ export const CLOSED_RECEIPT_AGE_MS = 10 * 60_000;
|
|
|
61
74
|
type TakenOutput = {
|
|
62
75
|
stored: ArchivedObservation; receipt: ProcessReceipt; wallMs: number;
|
|
63
76
|
exit: number | null; stopped?: "timeout" | "cancelled";
|
|
77
|
+
/**
|
|
78
|
+
* Input that arrived after the process exited and was not written:
|
|
79
|
+
* "control" for interrupt/EOF, whose intent the exit already satisfied;
|
|
80
|
+
* "refused" for real input, which the caller must report as not sent.
|
|
81
|
+
*/
|
|
82
|
+
unsent?: "control" | "refused";
|
|
64
83
|
};
|
|
65
84
|
type ClosedSession = { owner?: string; id: number; endedAt: number; receipt: ProcessReceipt };
|
|
66
85
|
|
|
@@ -79,7 +98,6 @@ interface Session {
|
|
|
79
98
|
spoolPath: string;
|
|
80
99
|
queue: Promise<unknown>;
|
|
81
100
|
operations: Promise<unknown>;
|
|
82
|
-
outputListeners: Set<() => void>;
|
|
83
101
|
final?: TakenOutput;
|
|
84
102
|
captureError?: string;
|
|
85
103
|
written: number;
|
|
@@ -151,7 +169,6 @@ function append(session: Session, chunk: Uint8Array): void {
|
|
|
151
169
|
if (bytesWritten < 1) throw new Error("process output spool made no write progress");
|
|
152
170
|
offset += bytesWritten; session.written += bytesWritten;
|
|
153
171
|
}
|
|
154
|
-
for (const notify of session.outputListeners) notify();
|
|
155
172
|
}).catch(error => {
|
|
156
173
|
session.captureError = `process output capture failed: ${String(error)}`;
|
|
157
174
|
stopSession(session, "cancelled");
|
|
@@ -177,7 +194,7 @@ export async function startSession(input: {
|
|
|
177
194
|
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
|
178
195
|
const session = {
|
|
179
196
|
id: nextId++, ...(input.owner === undefined ? {} : { owner: input.owner }), command: input.command, cwd: input.cwd, tty: input.tty, source: input.source, started: performance.now(),
|
|
180
|
-
spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(),
|
|
197
|
+
spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), written: 0, read: 0,
|
|
181
198
|
exit: null, timeoutMs: input.timeoutMs, exited, drained: Promise.resolve(),
|
|
182
199
|
...(input.artifactObserver ? { artifactObserver: input.artifactObserver } : {}),
|
|
183
200
|
} as Omit<Session, "pid" | "write" | "kill" | "timer"> as Session;
|
|
@@ -302,26 +319,42 @@ export interface ObservationWait {
|
|
|
302
319
|
/** Steering releases this observation wait without cancelling the owned command. */
|
|
303
320
|
wake?: AbortSignal;
|
|
304
321
|
onWaiting?: (active: boolean) => void;
|
|
322
|
+
/** One window; output is delivered at a window's end, never as it arrives. */
|
|
323
|
+
windowMs: number;
|
|
305
324
|
}
|
|
306
325
|
|
|
307
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* OMP's job owner pattern: no paid model turn merely to observe elapsed time.
|
|
328
|
+
* Exit, timeout, cancellation and steering end the wait at once. Output does
|
|
329
|
+
* not: it is collected until the window ends, and a window with nothing new
|
|
330
|
+
* rolls into the next. Waking per byte made a process that prints a warning
|
|
331
|
+
* every few seconds cost one model request per line (session mv4wyiyg-lebu,
|
|
332
|
+
* fix-owned-wait-coalesce-output); Codex collects to the deadline the same way.
|
|
333
|
+
*/
|
|
308
334
|
async function waitForObservation(session: Session, signal: AbortSignal | undefined, wait: ObservationWait): Promise<void> {
|
|
309
335
|
await session.queue;
|
|
310
|
-
let
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
wait.wake?.
|
|
315
|
-
const pending =
|
|
336
|
+
let interrupt!: () => void;
|
|
337
|
+
const interrupted = new Promise<void>(resolve => { interrupt = resolve; });
|
|
338
|
+
signal?.addEventListener("abort", interrupt, { once: true });
|
|
339
|
+
wait.wake?.addEventListener("abort", interrupt, { once: true });
|
|
340
|
+
const ended = () => session.exit !== null || signal?.aborted === true || wait.wake?.aborted === true;
|
|
341
|
+
const pending = !ended();
|
|
342
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
316
343
|
try {
|
|
317
344
|
if (pending) {
|
|
318
345
|
wait.onWaiting?.(true);
|
|
319
|
-
|
|
346
|
+
while (!ended()) {
|
|
347
|
+
const deadline = new Promise<void>(resolve => { timer = setTimeout(resolve, wait.windowMs); });
|
|
348
|
+
await Promise.race([session.exited, interrupted, deadline]);
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
await session.queue;
|
|
351
|
+
if (session.written > session.read) break;
|
|
352
|
+
}
|
|
320
353
|
}
|
|
321
354
|
} finally {
|
|
322
|
-
|
|
323
|
-
signal?.removeEventListener("abort",
|
|
324
|
-
wait.wake?.removeEventListener("abort",
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
signal?.removeEventListener("abort", interrupt);
|
|
357
|
+
wait.wake?.removeEventListener("abort", interrupt);
|
|
325
358
|
if (pending) wait.onWaiting?.(false);
|
|
326
359
|
}
|
|
327
360
|
if (signal?.aborted) stopSession(session, "cancelled");
|
|
@@ -371,6 +404,17 @@ async function takeOutput(session: Session, outputStore: string, previewBytes: n
|
|
|
371
404
|
return taken;
|
|
372
405
|
}
|
|
373
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Input made only of Ctrl-C / Ctrl-D. Sent to a process that has already
|
|
409
|
+
* exited, it asks for something the exit already did (stop, end of input), so
|
|
410
|
+
* it is answered as a poll rather than refused -- otherwise the exit and the
|
|
411
|
+
* unread tail wait for a second call the model may never make (ticket session
|
|
412
|
+
* mv4ugd29, write-stdin-exited card).
|
|
413
|
+
*/
|
|
414
|
+
export function isControlInput(chars: string): boolean {
|
|
415
|
+
return chars.length > 0 && /^[\u0003\u0004]+$/.test(chars);
|
|
416
|
+
}
|
|
417
|
+
|
|
374
418
|
/** One lease covers stdin, waiting, archival and cursor commit; cancellation can interrupt a queued wait. */
|
|
375
419
|
export async function pollSession(session: Session, outputStore: string, previewBytes: number, yieldMs: number,
|
|
376
420
|
chars = "", signal?: AbortSignal, wait?: ObservationWait): Promise<TakenOutput> {
|
|
@@ -378,16 +422,20 @@ export async function pollSession(session: Session, outputStore: string, preview
|
|
|
378
422
|
if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
|
|
379
423
|
const result = session.operations.then(async () => {
|
|
380
424
|
if (session.final) {
|
|
381
|
-
if (chars.length > 0) throw new ToolFault("not_found", "process is closed; input was not sent", NOT_STARTED);
|
|
425
|
+
if (chars.length > 0 && !isControlInput(chars)) throw new ToolFault("not_found", "process is closed; input was not sent", NOT_STARTED);
|
|
382
426
|
return { ...session.final, receipt: { ...session.final.receipt, replayed: true } };
|
|
383
427
|
}
|
|
428
|
+
// An exit not yet reported is still delivered with any late input: the
|
|
429
|
+
// input is never written, but the exit and unread output are not withheld.
|
|
430
|
+
let unsent: TakenOutput["unsent"];
|
|
384
431
|
if (chars.length > 0) {
|
|
385
|
-
if (session.exit !== null)
|
|
386
|
-
session.write(chars);
|
|
432
|
+
if (session.exit !== null) unsent = isControlInput(chars) ? "control" : "refused";
|
|
433
|
+
else session.write(chars);
|
|
387
434
|
}
|
|
388
435
|
if (chars.length === 0 && wait) await waitForObservation(session, signal, wait);
|
|
389
436
|
else await waitSession(session, yieldMs, signal);
|
|
390
|
-
|
|
437
|
+
const taken = await takeOutput(session, outputStore, previewBytes);
|
|
438
|
+
return unsent === undefined ? taken : { ...taken, unsent };
|
|
391
439
|
});
|
|
392
440
|
session.operations = result.catch(() => {});
|
|
393
441
|
try { return await result; }
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { deriveHandoff } from "../core/derive.ts";
|
|
4
4
|
import type { Handoff } from "../core/handoff.ts";
|
|
5
|
-
import { HISTORY_SECTIONS, historyPage, type HistorySection } from "../core/history.ts";
|
|
5
|
+
import { HISTORY_SECTIONS, historyPage, userTurnImages, type HistorySection } from "../core/history.ts";
|
|
6
6
|
import { historyEntries, historyHits, historyIndex, queryHistory, type HistoryEntry } from "../core/history-query.ts";
|
|
7
7
|
import { resourceKey } from "../core/evidence-links.ts";
|
|
8
8
|
import { canonicalValue } from "../core/continuation.ts";
|
|
@@ -87,7 +87,12 @@ export async function runHistoryTool(logDir: string, currentNode: number, ctx: T
|
|
|
87
87
|
if (typeof args["event"] === "string") {
|
|
88
88
|
const found = indexed.byEvent.get(args["event"]);
|
|
89
89
|
if (!found) throw new ToolFault("not_found", "event is not readable in the active ancestry", READ_ATTEMPTED);
|
|
90
|
-
|
|
90
|
+
// A carried user turn names its images by digest only (D8). Reading the
|
|
91
|
+
// original turn reattaches them as a tool result, so their image tokens are
|
|
92
|
+
// paid once, when asked for, instead of in every later handoff.
|
|
93
|
+
const original = found.kind === "user" ? history.nodes.flatMap(part => part.events).find(event => event.id === found.event) : undefined;
|
|
94
|
+
const images = original?.t === "user" ? original.images ?? [] : [];
|
|
95
|
+
return { ...await historyValue(ctx, found), ...(images.length ? { images } : {}) };
|
|
91
96
|
}
|
|
92
97
|
if (typeof args["query"] === "string" || args["recent"] === true || typeof args["resource"] === "string" || typeof args["work_id"] === "string") {
|
|
93
98
|
const query = typeof args["query"] === "string" ? args["query"] : "";
|
|
@@ -144,6 +149,11 @@ export async function runHistoryTool(logDir: string, currentNode: number, ctx: T
|
|
|
144
149
|
if (part.node === node) break;
|
|
145
150
|
}
|
|
146
151
|
if (full === null) throw new ToolFault("not_found", "empty history", READ_ATTEMPTED);
|
|
152
|
+
// Pages match a v3 carried suffix: user turns name their images by digest (D8).
|
|
153
|
+
if (args["section"] === "user_turns") {
|
|
154
|
+
const at = history.nodes.findIndex(part => part.node === node);
|
|
155
|
+
full = { ...full, user_turns: userTurnImages(full.user_turns, history.nodes.slice(0, at + 1)) };
|
|
156
|
+
}
|
|
147
157
|
const page = historyPage(full, args["section"] as HistorySection,
|
|
148
158
|
typeof args["offset"] === "number" ? args["offset"] : 0,
|
|
149
159
|
typeof args["limit"] === "number" ? Math.min(args["limit"], 100) : 20);
|
package/src/runtime/loop.ts
CHANGED
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* request.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
|
|
21
|
+
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent, FailureCause } from "../core/events.ts";
|
|
22
22
|
import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
|
|
23
23
|
import { hydrateImages } from "./images.ts";
|
|
24
24
|
import type { Handoff } from "../core/handoff.ts";
|
|
25
25
|
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
26
|
+
import { CURRENT_WORKING_SET_VERSION } from "../core/working-set.ts";
|
|
26
27
|
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
27
28
|
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
28
29
|
import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
|
|
@@ -76,7 +77,11 @@ import { skillTools, type Skill } from "../core/skills.ts";
|
|
|
76
77
|
import type { WorkItem } from "../core/work-state.ts";
|
|
77
78
|
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
|
|
78
79
|
import { deadlineNotice } from "../core/deadline.ts";
|
|
79
|
-
import { statusOf } from "./retry.ts";
|
|
80
|
+
import { describeFailure, failureCause, statusOf } from "./retry.ts";
|
|
81
|
+
import { XENO_VERSION } from "./version.ts";
|
|
82
|
+
import { SECRET_GUIDANCE, SecretKeeper } from "./secrets.ts";
|
|
83
|
+
import { secretEnvironmentValues } from "../core/secrets.ts";
|
|
84
|
+
import { copyVault } from "../store/secrets.ts";
|
|
80
85
|
import { assertNever } from "../core/exhaustive.ts";
|
|
81
86
|
import { isLogEvent } from "../core/event-validation.ts";
|
|
82
87
|
import {
|
|
@@ -177,6 +182,8 @@ export interface LoopEvent {
|
|
|
177
182
|
/** branch_start: wall-clock milliseconds, for asserting the stagger. */
|
|
178
183
|
at?: number;
|
|
179
184
|
outcome?: ExecutionOutcome;
|
|
185
|
+
/** done: credential-free cause of a failed request, recorded on its outcome. */
|
|
186
|
+
failure?: FailureCause;
|
|
180
187
|
/** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
|
|
181
188
|
request?: RequestState;
|
|
182
189
|
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
|
|
@@ -214,6 +221,11 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
|
|
|
214
221
|
};
|
|
215
222
|
|
|
216
223
|
export interface SessionConfig {
|
|
224
|
+
/**
|
|
225
|
+
* Where secret-named environment values are read from (S.9). Defaults to
|
|
226
|
+
* process.env; a test supplies its own so it never depends on the machine.
|
|
227
|
+
*/
|
|
228
|
+
secretEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
217
229
|
/** Runtime-only output contract, explicitly selected by the current caller. */
|
|
218
230
|
validateOutput?: (text: string) => { ok: boolean; error?: string };
|
|
219
231
|
/** Persist a selector, never persisted profile code or authorization (D6/D13). */
|
|
@@ -381,6 +393,12 @@ export class Session {
|
|
|
381
393
|
private stall: StallState | null = null;
|
|
382
394
|
private recovery: RecoveryState | null = null;
|
|
383
395
|
private processWake = new AbortController();
|
|
396
|
+
/**
|
|
397
|
+
* The exception the last provider request ended with, compared by identity
|
|
398
|
+
* in send()'s catch. Wrapping the error instead would break every check that
|
|
399
|
+
* reads it (FailedResponseError, statusOf, context overflow).
|
|
400
|
+
*/
|
|
401
|
+
private providerFailure: { error: unknown; retries: number } | undefined;
|
|
384
402
|
private pendingAdoption: number | null = null;
|
|
385
403
|
/** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
|
|
386
404
|
private pendingRoot: string | null = null;
|
|
@@ -397,6 +415,8 @@ export class Session {
|
|
|
397
415
|
readonly clock: Clock;
|
|
398
416
|
state: SessionState;
|
|
399
417
|
private readonly allocation: { next: number };
|
|
418
|
+
/** S.9: the vault behind placeholders. A branch is handed its parent's, like `allocation`. */
|
|
419
|
+
private secrets: SecretKeeper;
|
|
400
420
|
|
|
401
421
|
constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
|
|
402
422
|
if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
|
|
@@ -406,6 +426,7 @@ export class Session {
|
|
|
406
426
|
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
407
427
|
validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
408
428
|
this.allocation = allocation;
|
|
429
|
+
this.secrets = new SecretKeeper(config.logDir, secretEnvironmentValues(config.secretEnvironment ?? process.env));
|
|
409
430
|
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
|
|
410
431
|
processWait: { wake: () => this.processWake.signal } } };
|
|
411
432
|
// A child or restored Session must never inherit another live invocation scope.
|
|
@@ -419,6 +440,8 @@ export class Session {
|
|
|
419
440
|
// Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
|
|
420
441
|
if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
|
|
421
442
|
this.config.tools.outputStore ??= join(config.logDir, "artifacts");
|
|
443
|
+
// Read at call time, so a branch that is handed its parent's keeper expands the parent's values.
|
|
444
|
+
this.config.tools.expandSecrets = input => this.secrets.expand(input);
|
|
422
445
|
this.config.tools.historyEvents = async () => {
|
|
423
446
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
424
447
|
if (!history.ok) throw Error(history.error);
|
|
@@ -498,6 +521,7 @@ export class Session {
|
|
|
498
521
|
async start(): Promise<void> {
|
|
499
522
|
this.resetDerivedToolState();
|
|
500
523
|
const dir = this.config.logDir;
|
|
524
|
+
await this.secrets.load();
|
|
501
525
|
const model = this.provider.config.spec.id;
|
|
502
526
|
const existing = await listNodes(dir);
|
|
503
527
|
const recordedCursor = await readCursor(dir);
|
|
@@ -607,6 +631,7 @@ export class Session {
|
|
|
607
631
|
*/
|
|
608
632
|
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
609
633
|
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
634
|
+
await copyVault(this.config.logDir, destLogDir);
|
|
610
635
|
if (untilEvent !== undefined) {
|
|
611
636
|
const cursor = await readCursor(destLogDir);
|
|
612
637
|
if (!cursor) throw new Error("missing fork cursor");
|
|
@@ -749,8 +774,18 @@ export class Session {
|
|
|
749
774
|
* must see that a person changed models, not that the node filled up.
|
|
750
775
|
* boundary.ts already produces this reason when checkpointRequested is
|
|
751
776
|
* set; switchModel is the other caller that wants it.
|
|
777
|
+
*
|
|
778
|
+
* An empty projection is the exception (fix-switch-model-empty-segment).
|
|
779
|
+
* With no events there is no old model's block to keep away from the new
|
|
780
|
+
* one, and the frozen prefix does not change here, so the cursor alone
|
|
781
|
+
* records the switch. Cutting anyway sealed an empty segment and opened
|
|
782
|
+
* the next from an empty handoff, which the model read as earlier work to
|
|
783
|
+
* resume. The check is the projection, not the node file: an inherit-basis
|
|
784
|
+
* node can be empty on disk while still projecting ancestor thinking.
|
|
785
|
+
*
|
|
786
|
+
* Returns whether a segment was cut, so the caller can say so truthfully.
|
|
752
787
|
*/
|
|
753
|
-
async switchModel(next: Provider): Promise<
|
|
788
|
+
async switchModel(next: Provider): Promise<boolean> {
|
|
754
789
|
if (this.running || this.isHalfRound()) {
|
|
755
790
|
throw new Error(
|
|
756
791
|
"cannot switch models mid-turn: a tool_use is still unpaired. " +
|
|
@@ -758,9 +793,12 @@ export class Session {
|
|
|
758
793
|
);
|
|
759
794
|
}
|
|
760
795
|
|
|
761
|
-
|
|
796
|
+
const cut = this.state.events.length > 0;
|
|
797
|
+
if (cut) await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
|
|
798
|
+
else this.state.anchor = null;
|
|
762
799
|
this.provider = next;
|
|
763
800
|
await this.saveCursor();
|
|
801
|
+
return cut;
|
|
764
802
|
}
|
|
765
803
|
|
|
766
804
|
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
@@ -890,6 +928,7 @@ export class Session {
|
|
|
890
928
|
updated_at: this.clock.now(),
|
|
891
929
|
workspace: this.config.tools.root,
|
|
892
930
|
context_ref: contextRef ?? await this.persistContext(),
|
|
931
|
+
xeno_version: XENO_VERSION,
|
|
893
932
|
});
|
|
894
933
|
}
|
|
895
934
|
|
|
@@ -902,6 +941,10 @@ export class Session {
|
|
|
902
941
|
// D8: runtime callers may retain mutable results. The in-memory projection
|
|
903
942
|
// must own the same JSON value that was appended, independent of later edits.
|
|
904
943
|
event = JSON.parse(JSON.stringify(event)) as LogEvent;
|
|
944
|
+
// S.9: credentials become placeholders before anything is written, so no
|
|
945
|
+
// projection, handoff, export or history page can carry the value.
|
|
946
|
+
const redacted = await this.secrets.redact(event, this.lastAssistantText());
|
|
947
|
+
event = redacted.event;
|
|
905
948
|
const saved = this.recordTail.then(async () => {
|
|
906
949
|
await appendEvent(this.config.logDir, this.state.node, event);
|
|
907
950
|
this.state.events.push(event);
|
|
@@ -913,6 +956,9 @@ export class Session {
|
|
|
913
956
|
});
|
|
914
957
|
this.recordTail = saved.catch(() => {});
|
|
915
958
|
await saved;
|
|
959
|
+
if (redacted.added > 0 && !this.state.events.some(item => item.t === "guidance" && item.reason === "secret")) {
|
|
960
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "secret", text: SECRET_GUIDANCE, sources: [event.id] });
|
|
961
|
+
}
|
|
916
962
|
if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
|
|
917
963
|
const path = resolve(this.config.tools.root, event.effect.path);
|
|
918
964
|
const skill = this.config.skills?.find(item => item.filePath === path);
|
|
@@ -925,6 +971,19 @@ export class Session {
|
|
|
925
971
|
}
|
|
926
972
|
}
|
|
927
973
|
|
|
974
|
+
/** The text the user is answering, for recognising a solicited credential. */
|
|
975
|
+
private lastAssistantText(): string | undefined {
|
|
976
|
+
for (let i = this.state.events.length - 1; i >= 0; i--) {
|
|
977
|
+
const event = this.state.events[i]!;
|
|
978
|
+
if (event.t === "user") return undefined;
|
|
979
|
+
if (event.t === "assistant") {
|
|
980
|
+
const text = event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
|
|
981
|
+
if (text.trim()) return text;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
986
|
+
|
|
928
987
|
private allowedNames(): string[] {
|
|
929
988
|
return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
|
|
930
989
|
(this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
|
|
@@ -1000,7 +1059,7 @@ export class Session {
|
|
|
1000
1059
|
|
|
1001
1060
|
private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
|
|
1002
1061
|
return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
1003
|
-
...(this.config.workingSet ? { working_set_version:
|
|
1062
|
+
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}), guidance_tools: tools });
|
|
1004
1063
|
}
|
|
1005
1064
|
|
|
1006
1065
|
async acceptanceState(): Promise<AcceptanceState | undefined> {
|
|
@@ -1256,6 +1315,7 @@ export class Session {
|
|
|
1256
1315
|
if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
|
|
1257
1316
|
t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1258
1317
|
reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
|
|
1318
|
+
...(item.value.failure ? { failure: item.value.failure } : {}),
|
|
1259
1319
|
});
|
|
1260
1320
|
const request = await this.requestState();
|
|
1261
1321
|
if (request) item.value.request = request;
|
|
@@ -1270,9 +1330,14 @@ export class Session {
|
|
|
1270
1330
|
yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
|
|
1271
1331
|
}
|
|
1272
1332
|
} catch (error) {
|
|
1273
|
-
// Raw
|
|
1333
|
+
// Raw exceptions can contain credentials; persist the category, never the message.
|
|
1334
|
+
// Only the provider request's own exception is a provider error: this catch
|
|
1335
|
+
// also sees failures in history reads and in the harness itself.
|
|
1336
|
+
const provider = this.providerFailure?.error === error ? this.providerFailure : undefined;
|
|
1337
|
+
const failure = failureCause(error, provider);
|
|
1274
1338
|
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1275
|
-
reason:
|
|
1339
|
+
reason: `request failed before completion (${describeFailure(failure)}); no complete response was committed`,
|
|
1340
|
+
outcome: executionOutcome(provider ? "provider_error" : "execution_error"), failure });
|
|
1276
1341
|
throw error;
|
|
1277
1342
|
} finally {
|
|
1278
1343
|
// Closing a consumer is not a user cancellation. Preserve the existing
|
|
@@ -1404,7 +1469,9 @@ export class Session {
|
|
|
1404
1469
|
}
|
|
1405
1470
|
if (phase === "finalize") {
|
|
1406
1471
|
yield { kind: "discard" };
|
|
1407
|
-
|
|
1472
|
+
const failure = failureCause(error, this.providerFailure?.error === error ? this.providerFailure : { retries: 0 });
|
|
1473
|
+
yield { kind: "done", reason: await report(`final response failed (${describeFailure(failure)}); no additional request was made`, turn + 1),
|
|
1474
|
+
outcome: executionOutcome("provider_error"), failure };
|
|
1408
1475
|
return;
|
|
1409
1476
|
}
|
|
1410
1477
|
if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
|
|
@@ -1737,8 +1804,9 @@ export class Session {
|
|
|
1737
1804
|
): AsyncGenerator<LoopEvent, Turn> {
|
|
1738
1805
|
const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1739
1806
|
this.provider.config.spec.input?.includes("image") ?? false);
|
|
1740
|
-
|
|
1741
|
-
|
|
1807
|
+
let retries = 0;
|
|
1808
|
+
this.providerFailure = undefined;
|
|
1809
|
+
const progress = (emit: (event: LoopEvent) => void) => (delta: StreamDelta): void => {
|
|
1742
1810
|
switch (delta.kind) {
|
|
1743
1811
|
case "text":
|
|
1744
1812
|
emit({ kind: "text_delta", text: delta.text });
|
|
@@ -1749,13 +1817,25 @@ export class Session {
|
|
|
1749
1817
|
case "discard":
|
|
1750
1818
|
emit({ kind: "discard" });
|
|
1751
1819
|
break;
|
|
1752
|
-
case "
|
|
1820
|
+
case "retry":
|
|
1821
|
+
retries = Math.max(retries, delta.attempt);
|
|
1822
|
+
emit({ kind: "progress", progress: delta });
|
|
1823
|
+
break;
|
|
1824
|
+
case "tool":
|
|
1753
1825
|
emit({ kind: "progress", progress: delta });
|
|
1754
1826
|
break;
|
|
1755
1827
|
default:
|
|
1756
1828
|
return assertNever(delta, "onDelta");
|
|
1757
1829
|
}
|
|
1758
|
-
}
|
|
1830
|
+
};
|
|
1831
|
+
try {
|
|
1832
|
+
return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
|
|
1833
|
+
this.config.frozen, hydrated, this.state.node, signal, progress(emit), options));
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
// The outcome needs to know this exception came from the request, and after how many retries.
|
|
1836
|
+
this.providerFailure = { error, retries };
|
|
1837
|
+
throw error;
|
|
1838
|
+
}
|
|
1759
1839
|
}
|
|
1760
1840
|
|
|
1761
1841
|
/**
|
|
@@ -1829,7 +1909,10 @@ export class Session {
|
|
|
1829
1909
|
let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
|
|
1830
1910
|
imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1831
1911
|
advice: this.allowedNames(),
|
|
1832
|
-
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {})
|
|
1912
|
+
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}),
|
|
1913
|
+
// feat-approval-events: the broker has already recorded this call's
|
|
1914
|
+
// invocation, and its result comes after execution, so the order is fixed.
|
|
1915
|
+
onApproval: approval => this.record({ t: "approval", id: this.clock.nextId(), at: this.clock.now(), ...approval }) };
|
|
1833
1916
|
if (native) context = nativeCommandContext(context);
|
|
1834
1917
|
const decoder = new TextDecoder();
|
|
1835
1918
|
context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
|
|
@@ -2092,6 +2175,7 @@ export class Session {
|
|
|
2092
2175
|
);
|
|
2093
2176
|
branch.state.node = node;
|
|
2094
2177
|
branch.state.nextNode = this.state.nextNode;
|
|
2178
|
+
branch.secrets = this.secrets;
|
|
2095
2179
|
if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
|
|
2096
2180
|
this.children.set(node, branch);
|
|
2097
2181
|
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
@@ -2881,7 +2965,7 @@ export class Session {
|
|
|
2881
2965
|
next_thinking: nextThinking,
|
|
2882
2966
|
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
2883
2967
|
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
2884
|
-
...(this.config.workingSet ? { working_set_version:
|
|
2968
|
+
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}),
|
|
2885
2969
|
guidance_tools: guidanceTools,
|
|
2886
2970
|
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
2887
2971
|
});
|