@visual-z/xeno 0.2.0 → 0.3.0
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 +15 -0
- package/README.md +106 -26
- package/docs/install.md +52 -9
- package/docs/providers.md +11 -0
- package/package.json +5 -4
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +10 -11
- package/src/apps/commands.ts +38 -5
- package/src/apps/completions.ts +73 -19
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +298 -1526
- package/src/apps/startup-input.ts +130 -0
- package/src/apps/startup.ts +143 -115
- package/src/apps/ui/app.ts +1383 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +84 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-review.ts +21 -5
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +80 -5
- package/src/core/events.ts +97 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/project.ts +45 -18
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +26 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +2 -2
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +460 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1021 -268
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +143 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +199 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +271 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** R12c: collect all started trials, including crash prefixes, without provider access. */
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { requestAccounting, summarizeRecoveryCoverage, type TrialCoverage } from "../core/evaluation-coverage.ts";
|
|
4
|
+
import { deriveRecoveryMetrics } from "../core/recovery-evaluation.ts";
|
|
5
|
+
import type { EvalResult } from "../core/experiment.ts";
|
|
6
|
+
import { isTrialLifecycle } from "../core/trial-lifecycle.ts";
|
|
7
|
+
import { readCursor } from "../store/log.ts";
|
|
8
|
+
import { readNodeHistory } from "../store/restore.ts";
|
|
9
|
+
import { auditEvalJob, auditEvalRequests, evalDirectory, type EvalPlan, type EvalEnd } from "./harness-eval.ts";
|
|
10
|
+
import type { GatewayRecord } from "./harness-comparison.ts";
|
|
11
|
+
import { verifiedRecoveryRetrievals } from "./recovery-evidence.ts";
|
|
12
|
+
|
|
13
|
+
export function requestPrefix(body: string): { records: GatewayRecord[]; tail: "complete" | "truncated" } {
|
|
14
|
+
const chunks = body.split("\n"), records: GatewayRecord[] = [];
|
|
15
|
+
let tail: "complete" | "truncated" = "complete";
|
|
16
|
+
for (const [index, line] of chunks.entries()) {
|
|
17
|
+
if (!line && index === chunks.length - 1) continue;
|
|
18
|
+
try { records.push(JSON.parse(line) as GatewayRecord); }
|
|
19
|
+
catch {
|
|
20
|
+
if (index !== chunks.length - 1) throw Error("corrupt request ledger interior");
|
|
21
|
+
tail = "truncated";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { records, tail };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function inspectRecoveryTrials(output: string, plan: EvalPlan, audit = false) {
|
|
28
|
+
if (![2, 5].includes(plan.version)) throw Error("complete coverage needs protocol v2 or v5");
|
|
29
|
+
const trials: TrialCoverage[] = [], results: EvalResult[] = [];
|
|
30
|
+
let charged = 0, halted = false;
|
|
31
|
+
for (const job of plan.jobs) {
|
|
32
|
+
const directory = evalDirectory(output, job), startPath = join(directory, "trial-start.json");
|
|
33
|
+
const requestsPath = join(directory, "requests.jsonl"), resultPath = join(directory, "result.json"), endPath = join(directory, "end.json");
|
|
34
|
+
if (!await Bun.file(startPath).exists()) {
|
|
35
|
+
if (await Bun.file(requestsPath).exists() || await Bun.file(resultPath).exists() || await Bun.file(endPath).exists()) throw Error("trial evidence lacks start identity");
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const start = await Bun.file(startPath).json() as { job: string; ledgerBefore: number };
|
|
39
|
+
if (start.job !== job.id || start.ledgerBefore !== charged || halted) throw Error("global recovery ledger sequence changed");
|
|
40
|
+
const prefix = requestPrefix(await Bun.file(requestsPath).exists() ? await Bun.file(requestsPath).text() : "");
|
|
41
|
+
const end = await Bun.file(endPath).exists() ? await Bun.file(endPath).json() as EvalEnd : undefined;
|
|
42
|
+
if (end?.lifecycle && !isTrialLifecycle(end.lifecycle)) throw Error("invalid recovery lifecycle");
|
|
43
|
+
const accounting = requestAccounting(prefix.records, start.ledgerBefore);
|
|
44
|
+
if (end && (end.ledgerBefore !== start.ledgerBefore || end.ledgerAfter !== accounting.charged || prefix.tail !== "complete")) throw Error("recovery end differs from request ledger");
|
|
45
|
+
let result: EvalResult | undefined;
|
|
46
|
+
if (await Bun.file(resultPath).exists()) {
|
|
47
|
+
if (!end || prefix.tail !== "complete" || accounting.unsettledRequests) throw Error("final result lacks settled source");
|
|
48
|
+
result = audit ? await auditEvalJob(directory, plan, job) : await Bun.file(resultPath).json() as EvalResult;
|
|
49
|
+
if (result.id !== job.id) throw Error("result identity changed");
|
|
50
|
+
results.push(result);
|
|
51
|
+
} else if (audit) await auditEvalRequests(directory, plan, job, { records: prefix.records, ledgerBefore: start.ledgerBefore, allowPending: true });
|
|
52
|
+
let partialMetrics: EvalResult["recovery"];
|
|
53
|
+
// During request callbacks the cursor can be behind the active append; only
|
|
54
|
+
// an offline audit reads incomplete tool metrics and labels its outcome unknown.
|
|
55
|
+
if (audit && !result) {
|
|
56
|
+
const cursor = await readCursor(join(directory, "log"));
|
|
57
|
+
if (cursor) {
|
|
58
|
+
const history = await readNodeHistory(join(directory, "log"), cursor.node, undefined, "context");
|
|
59
|
+
if (!history.ok) throw Error(history.error);
|
|
60
|
+
const originals = await verifiedRecoveryRetrievals(directory, history.nodes, plan.limits.maxOutputBytes!);
|
|
61
|
+
partialMetrics = deriveRecoveryMetrics(history.nodes, new Set(originals.keys()), null, path => resolve(directory, "workspace", path));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
trials.push({ id: job.id, accounting, tail: prefix.tail, ...(result ? { result } : {}),
|
|
65
|
+
...(partialMetrics ? { partialMetrics } : {}), interruption: end?.lifecycle?.interruption?.source ?? null });
|
|
66
|
+
charged = accounting.charged;
|
|
67
|
+
halted = end === undefined || end.ledgerHalted === true || !accounting.fullyMeasured || prefix.tail === "truncated" ||
|
|
68
|
+
prefix.records.some(row => row.kind === "settled" && (row.failure !== null || row.measured !== null && row.measured > row.reservation));
|
|
69
|
+
}
|
|
70
|
+
return { trials, results, ledger: { budget: plan.budget, charged, halted }, summary: summarizeRecoveryCoverage(plan.jobs, trials, plan.mechanicalOnly) };
|
|
71
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** R12c: offline evidence readers verify source bytes and never rerun commands. */
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type { LogEvent } from "../core/events.ts";
|
|
5
|
+
import { historyEntries } from "../core/history-query.ts";
|
|
6
|
+
import { readObservation } from "../store/observations.ts";
|
|
7
|
+
import { isRuntimeTargetReceipt } from "../core/runtime-target.ts";
|
|
8
|
+
|
|
9
|
+
export function recoveryWorkEvents(events: readonly LogEvent[], last: string | undefined): LogEvent[] {
|
|
10
|
+
const at = events.findIndex(event => event.id === last);
|
|
11
|
+
if (at < 0 || events.slice(at + 1).some(event => !["process_exit", "target_observation"].includes(event.t)))
|
|
12
|
+
throw Error("invalid recovery work boundary");
|
|
13
|
+
return events.slice(0, at + 1);
|
|
14
|
+
}
|
|
15
|
+
const equal = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
|
|
16
|
+
const originalFields = (entry: unknown) => {
|
|
17
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
|
|
18
|
+
// Consumption-time freshness is metadata around the immutable event, not
|
|
19
|
+
// part of the original observation whose bytes this metric verifies.
|
|
20
|
+
const { environment: _freshness, ...original } = entry as Record<string, unknown>;
|
|
21
|
+
return original;
|
|
22
|
+
};
|
|
23
|
+
export async function verifiedRecoveryRetrievals(directory: string, nodes: readonly { node: number; events: readonly LogEvent[] }[], maxOutputBytes: number) {
|
|
24
|
+
const result = new Map<string, { source: string; text: string }>();
|
|
25
|
+
const calls = new Map<string, { name: string; input: unknown }>();
|
|
26
|
+
const prefix: { node: number; events: LogEvent[] }[] = [];
|
|
27
|
+
for (const part of nodes) {
|
|
28
|
+
const seen: LogEvent[] = []; prefix.push({ node: part.node, events: seen });
|
|
29
|
+
for (const event of part.events) {
|
|
30
|
+
if (event.t === "assistant") for (const block of event.blocks) if (block.type === "tool_use") calls.set(`${part.node}:${block.call_id}`, block);
|
|
31
|
+
if (event.t === "tool_invocation") calls.set(`${part.node}:${event.call_id}`, { name: event.name, input: event.input ?? calls.get(`${part.node}:${event.call_id}`)?.input });
|
|
32
|
+
if (event.t === "tool_result" && event.ok) {
|
|
33
|
+
const call = calls.get(`${event.invocation_ref?.node ?? part.node}:${event.call_id}`);
|
|
34
|
+
const input = call?.input as Record<string, unknown> | undefined;
|
|
35
|
+
if (call?.name === "history" && input) {
|
|
36
|
+
if (typeof input["artifact"] === "string") {
|
|
37
|
+
const page = await readObservation(join(directory, "observations"), input["artifact"], Number(input["offset"] ?? 0), Math.max(1, Math.floor(maxOutputBytes / 3)));
|
|
38
|
+
// Only bytes actually delivered to the model count; an archived but
|
|
39
|
+
// clipped response still requires another retrieval by the agent.
|
|
40
|
+
let delivered: unknown;
|
|
41
|
+
try { delivered = JSON.parse(event.content); }
|
|
42
|
+
catch {
|
|
43
|
+
// JSON escaping can make even a bounded byte page exceed the
|
|
44
|
+
// preview. Verify its archive, but do not credit unseen bytes.
|
|
45
|
+
if (!event.content_ref) throw Error("unreadable recovery artifact receipt");
|
|
46
|
+
const stored = await Bun.file(event.content_ref.path).text();
|
|
47
|
+
if (createHash("sha256").update(stored).digest("hex") !== event.content_ref.sha256 || !equal(JSON.parse(stored), page))
|
|
48
|
+
throw Error("recovery artifact archive differs from original bytes");
|
|
49
|
+
}
|
|
50
|
+
if (delivered !== undefined && !equal(delivered, page)) throw Error("recovery artifact receipt differs from original bytes");
|
|
51
|
+
if (delivered !== undefined && page.base64) result.set(`${part.node}:${event.id}`, { source: page.sha256, text: page.text });
|
|
52
|
+
} else if (typeof input["event"] === "string" || Array.isArray(input["events"])) {
|
|
53
|
+
const entries = historyEntries(prefix), ids = typeof input["event"] === "string" ? [input["event"]] : input["events"] as unknown[];
|
|
54
|
+
const originals = ids.map(id => entries.find(entry => entry.event === id));
|
|
55
|
+
let delivered: unknown;
|
|
56
|
+
try { delivered = JSON.parse(event.content); } catch { /* a clipped index is not a complete original event */ }
|
|
57
|
+
const batch = delivered as { items?: unknown[]; missing?: unknown[] } | undefined;
|
|
58
|
+
const actual = typeof input["event"] === "string" ? [delivered] : Array.isArray(batch?.items) && equal(batch?.missing, []) ? batch.items : [];
|
|
59
|
+
if (originals.every(Boolean) && equal(actual.map(originalFields), originals)) result.set(`${part.node}:${event.id}`,
|
|
60
|
+
{ source: originals.map(entry => entry!.event).join(","), text: originals.map(entry => entry!.text).join("\n") });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (event.t === "target_observation" && event.observation.state === "ready") {
|
|
65
|
+
const receipt = event.observation;
|
|
66
|
+
if (!isRuntimeTargetReceipt(receipt) || !receipt.manifest) throw Error("invalid target receipt");
|
|
67
|
+
const body = await Bun.file(receipt.manifest.path).text();
|
|
68
|
+
if (createHash("sha256").update(body).digest("hex") !== receipt.manifest.sha256) throw Error("target observation source changed");
|
|
69
|
+
const archived = JSON.parse(body) as { observation: unknown }, { manifest: _manifest, ...original } = receipt;
|
|
70
|
+
if (!equal(archived.observation, original)) throw Error("target observation differs from archived capture");
|
|
71
|
+
}
|
|
72
|
+
seen.push(event);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** R12c: sanitized, executable analogues of the audit's failure mechanisms. */
|
|
2
|
+
import {createHash} from "node:crypto";
|
|
3
|
+
import {seededRandom} from "../core/experiment.ts";
|
|
4
|
+
import {recoveryCase,RECOVERY_TASKS,RECOVERY_SOURCE_REPORT,type RecoveryTask} from "../core/recovery-corpus.ts";
|
|
5
|
+
import type {AcceptanceContract} from "../core/acceptance.ts";
|
|
6
|
+
import type {EvalFixture,EvalPlan} from "./harness-eval.ts";
|
|
7
|
+
import type {ComparisonBackend} from "./harness-comparison.ts";
|
|
8
|
+
|
|
9
|
+
const digest=(text:string)=>createHash("sha256").update(text).digest("hex");
|
|
10
|
+
export const RECOVERY_SYSTEM="Solve the supplied recovery task using actual file and tool evidence. Preserve the fixture and verifier inputs. " +
|
|
11
|
+
"Inspect failed-call premises before retrying. A clipped preview is not the complete source. Recover closed process evidence without replaying its effects. " +
|
|
12
|
+
"Verify the requested artifacts with bun run gate. Building, observing a running target, and the user's acceptance are separate facts. " +
|
|
13
|
+
"Report unavailable capabilities or pending human review honestly. Work within this session without delegation.";
|
|
14
|
+
export function recoveryFixture(seed:number,task:RecoveryTask,repeat:number):EvalFixture{
|
|
15
|
+
const specification=recoveryCase(task)!,random=seededRandom(seed+RECOVERY_TASKS.indexOf(task)*1000+repeat);
|
|
16
|
+
const answer=Array.from({length:4},()=>Math.floor(random()*0x100000000).toString(16).padStart(8,"0")).join("")+"\n";
|
|
17
|
+
const files:Record<string,string>={"source.txt":`CODE=${answer}`,"package.json":JSON.stringify({scripts:{gate:"bun verify.ts",build:"bun build.ts"}})+"\n"};
|
|
18
|
+
const goals:Record<string,string>={"answer.txt":digest(answer)},mutable=["answer.txt"];
|
|
19
|
+
let scenario="Copy the CODE value from source.txt to answer.txt with one trailing newline. ";
|
|
20
|
+
if(task==="recovery-missing-path"){
|
|
21
|
+
files["answer.txt"]="pending\n";scenario+="Replace pending in answer.txt; an earlier editor request omitted path. Use the actual file identity. ";
|
|
22
|
+
}else if(task==="recovery-stale-version"){
|
|
23
|
+
files["draft.txt"]="before\nexternal change\n";mutable.push("draft.txt");goals["draft.txt"]=digest("after\nexternal change\n");
|
|
24
|
+
scenario+=`Change before to after in draft.txt while retaining its other line. An old observation had sha256 ${digest("before\n")}; it may be stale. `;
|
|
25
|
+
}else if(task==="recovery-long-output"){
|
|
26
|
+
files["source.txt"]="x".repeat(5000)+`CODE=${answer.trim()}`+"y".repeat(10000)+"\n";
|
|
27
|
+
scenario="Recover the 32 hexadecimal characters after CODE= in source.txt and put them in answer.txt with one trailing newline. The line exceeds a preview; use immutable original evidence when needed. ";
|
|
28
|
+
}else if(task==="recovery-api-mismatch"){
|
|
29
|
+
files["compat.ts"]='// Portable legacy API fixture; the optional filter flag is unsupported.\n'+
|
|
30
|
+
'if(Bun.argv.some(arg=>arg.startsWith("--filter")))throw new TypeError("extract() does not support the filter option");\n'+
|
|
31
|
+
'const source=await Bun.file("source.txt").text();await Bun.write("answer.txt",source.slice(5));\n';
|
|
32
|
+
scenario+="The local compat.ts implements a legacy API; a suggested invocation was bun compat.ts --filter=safe. Inspect what this installed implementation supports before using it. ";
|
|
33
|
+
}else if(task==="recovery-closed-process"){
|
|
34
|
+
files["producer.ts"]='const count=Number(await Bun.file("counter.txt").text().catch(()=>"0"));await Bun.write("counter.txt",String(count+1));console.log((await Bun.file("source.txt").text()).trim());\n';
|
|
35
|
+
goals["counter.txt"]=digest("1");mutable.push("counter.txt");
|
|
36
|
+
scenario="Run bun producer.ts exactly once to obtain CODE. It increments a counter and exits. Recover its terminal receipt if further process input fails; do not launch it again. Put CODE in answer.txt with one newline. ";
|
|
37
|
+
}else if(task==="recovery-unchanged-retry"){
|
|
38
|
+
files["draft.txt"]="present\n";mutable.push("draft.txt");goals["draft.txt"]=digest("fixed\n");
|
|
39
|
+
scenario+="Change the actual draft.txt anchor to fixed. A proposed old_string was absent; changing only new_string would not repair a missing anchor. ";
|
|
40
|
+
}else if(task==="recovery-runtime-acceptance"){
|
|
41
|
+
files["app-source.js"]='console.log("fixture preview ready");setInterval(()=>{},1000);\n';
|
|
42
|
+
files["build.ts"]='import {mkdir,copyFile} from "node:fs/promises";await mkdir("dist",{recursive:true});await copyFile("app-source.js","dist/app.js");\n';
|
|
43
|
+
goals["dist/app.js"]=digest(files["app-source.js"]);
|
|
44
|
+
scenario+="Build with bun run build, recording artifacts {paths:[\"dist/app.js\"],output_roots:[\"dist\"]}, and start the declared preview with target. A previous build or a missing target does not prove this instance is ready. Leave its human review pending. The harness closes the temporary preview after this trial. ";
|
|
45
|
+
}else if(task==="recovery-eof")scenario+="A suggested read starts at line 999; recover from the real file boundary rather than assuming that line exists. ";
|
|
46
|
+
files["verify.ts"]=`import {createHash} from "node:crypto";const goals=${JSON.stringify(goals)};for(const [path,sha] of Object.entries(goals)){const text=await Bun.file(path).text().catch(()=>"");if(createHash("sha256").update(text).digest("hex")!==sha)process.exit(1);}\n`;
|
|
47
|
+
return {task,repeat,files,digests:Object.fromEntries(Object.entries(files).filter(([path])=>!mutable.includes(path)).map(([path,text])=>[path,digest(text)])),
|
|
48
|
+
answerSha256:digest(answer),prompts:[scenario+"Preserve verifier and fixture inputs, run bun run gate, and report the actual acceptance state."],
|
|
49
|
+
recovery:{sourceReport:RECOVERY_SOURCE_REPORT,specification,answer,goalDigests:goals,mutableFiles:mutable}};
|
|
50
|
+
}
|
|
51
|
+
export function recoveryContract(plan:EvalPlan):AcceptanceContract{
|
|
52
|
+
if(!plan.runtime.bunExecutable)throw Error("recovery runtime identity missing");
|
|
53
|
+
return {version:1,id:"recovery-preview",criteria:[
|
|
54
|
+
{id:"gate",kind:"command",command:"bun run gate",description:"The requested outputs pass the frozen verifier"},
|
|
55
|
+
{id:"build",kind:"command",command:"bun run build",description:"The current preview is built"},
|
|
56
|
+
{id:"review",kind:"human",target:"preview",description:"The author reviews this exact running preview"}],
|
|
57
|
+
targets:[{id:"preview",build:"build",artifacts:{paths:["dist/app.js"],output_roots:["dist"]},entry:"dist/app.js",interpreter:plan.runtime.bunExecutable}]};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A deterministic driver tests the protocol and grader, not model competence. */
|
|
61
|
+
export function recoveryFixtureBackend(input:EvalFixture):ComparisonBackend{
|
|
62
|
+
if(!input.recovery)throw Error("recovery fixture metadata missing");
|
|
63
|
+
const answer=input.recovery.answer;let step=0;
|
|
64
|
+
return async payload=>{
|
|
65
|
+
const messages=payload["messages"] as {role:string;content?:unknown}[];
|
|
66
|
+
const text=messages.filter(message=>message.role==="tool").map(message=>typeof message.content==="string"?message.content:JSON.stringify(message.content)).join("\n");
|
|
67
|
+
const required=(pattern:RegExp)=>{const match=pattern.exec(text);if(!match)throw Error("fixture driver could not find the original tool receipt");return match[1]!;};
|
|
68
|
+
const calls:{name:string;input:unknown}[]=[],index=step++;
|
|
69
|
+
const call=(name:string,args:unknown)=>calls.push({name,input:args});
|
|
70
|
+
const read=()=>call("read",{path:"source.txt"}),write=()=>call("write",{path:"answer.txt",content:answer}),gate=()=>call("exec_command",{cmd:"bun run gate",yield_time_ms:1000});
|
|
71
|
+
switch(input.task){
|
|
72
|
+
case "recovery-missing-path":
|
|
73
|
+
if(index===0)call("edit",{old_string:"pending",new_string:answer.trim()});
|
|
74
|
+
if(index===1){read();call("read",{path:"answer.txt"});}
|
|
75
|
+
if(index===2)call("edit",{path:"answer.txt",old_string:"pending",new_string:answer.trim()});
|
|
76
|
+
if(index===3)gate();break;
|
|
77
|
+
case "recovery-stale-version":
|
|
78
|
+
if(index===0)call("edit",{path:"draft.txt",expected_sha256:digest("before\n"),old_string:"before",new_string:"after"});
|
|
79
|
+
if(index===1)call("read",{path:"draft.txt"});
|
|
80
|
+
if(index===2)call("edit",{path:"draft.txt",old_string:"before",new_string:"after"});
|
|
81
|
+
if(index===3)read();if(index===4)write();if(index===5)gate();break;
|
|
82
|
+
case "recovery-long-output":
|
|
83
|
+
if(index===0)read();
|
|
84
|
+
if(index===1)call("history",{artifact:required(/sha256: ([a-f0-9]{64})/),offset:4500});
|
|
85
|
+
if(index===2)write();if(index===3)gate();break;
|
|
86
|
+
case "recovery-api-mismatch":
|
|
87
|
+
if(index===0)call("exec_command",{cmd:"bun compat.ts --filter=safe",yield_time_ms:1000});
|
|
88
|
+
if(index===1)call("read",{path:"compat.ts"});
|
|
89
|
+
if(index===2)call("exec_command",{cmd:"bun compat.ts",yield_time_ms:1000});
|
|
90
|
+
if(index===3)gate();break;
|
|
91
|
+
case "recovery-closed-process":
|
|
92
|
+
if(index===0)call("exec_command",{cmd:"bun producer.ts",yield_time_ms:5000});
|
|
93
|
+
if(index===1)call("history",{recent:true,tool:"exec_command"});
|
|
94
|
+
if(index===2||index===3)call("write_stdin",{session_id:Number(required(/"session_id"\s*:\s*(\d+)/)),chars:index===2?"repeat\n":"",yield_time_ms:100});
|
|
95
|
+
if(index===4)write();if(index===5)gate();break;
|
|
96
|
+
case "recovery-unchanged-retry":
|
|
97
|
+
if(index===0||index===3)call("read",{path:"draft.txt"});
|
|
98
|
+
if(index===1||index===2)call("edit",{path:"draft.txt",old_string:"absent",new_string:index===1?"first":"second"});
|
|
99
|
+
if(index===4)call("edit",{path:"draft.txt",old_string:"present",new_string:"fixed"});
|
|
100
|
+
if(index===5)read();if(index===6)write();if(index===7)gate();break;
|
|
101
|
+
case "recovery-runtime-acceptance":
|
|
102
|
+
if(index===0||index===4)call("target",{action:"start",target:"preview",yield_time_ms:100});
|
|
103
|
+
if(index===1)read();if(index===2)write();
|
|
104
|
+
if(index===3)call("exec_command",{cmd:"bun run build",artifacts:{paths:["dist/app.js"],output_roots:["dist"]},yield_time_ms:1000});
|
|
105
|
+
if(index===5)gate();break;
|
|
106
|
+
case "recovery-eof":
|
|
107
|
+
if(index===0)call("read",{path:"source.txt",start:999});
|
|
108
|
+
if(index===1)read();if(index===2)write();if(index===3)gate();break;
|
|
109
|
+
default:throw Error("not a recovery fixture");
|
|
110
|
+
}
|
|
111
|
+
return {id:`recovery-${index}`,model:"eval-mechanical",choices:[{index:0,message:{role:"assistant",
|
|
112
|
+
content:calls.length?null:input.task==="recovery-runtime-acceptance"?"Preview preparation ended; the author's acceptance is pending.":"Recovery fixture finished.",
|
|
113
|
+
...(calls.length?{tool_calls:calls.map((item,i)=>({id:`recovery-${index}-${i}`,type:"function",function:{name:item.name,arguments:JSON.stringify(item.input)}}))}:{})},
|
|
114
|
+
finish_reason:calls.length?"tool_calls":"stop"}],usage:{prompt_tokens:200,completion_tokens:32,prompt_cache_hit_tokens:0}};
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** R8: live capabilities stay with their Session; only observations cross a log boundary. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import type { LogEvent, ArtifactRef } from "../core/events.ts";
|
|
6
|
+
import { deriveAcceptance, type AcceptanceState } from "../core/acceptance.ts";
|
|
7
|
+
import { canonicalValue } from "../core/continuation.ts";
|
|
8
|
+
import { isCommandArtifactReceipt, sameArtifactIdentity, type CommandArtifactReceipt } from "../core/command-artifacts.ts";
|
|
9
|
+
import { targetIsReady, type RuntimeTarget, type RuntimeTargetReceipt, type RuntimeInstance } from "../core/runtime-target.ts";
|
|
10
|
+
import { failureDiagnostic, ToolFault, NOT_STARTED, UNKNOWN_EFFECT } from "../core/tool-failure.ts";
|
|
11
|
+
import { commandTimeout } from "../core/command-contract.ts";
|
|
12
|
+
import { executionOutcome } from "../core/outcome.ts";
|
|
13
|
+
import { archiveCommandArtifacts, artifactExclusions, observeArtifactFiles, observeArtifactInputs } from "../store/command-artifacts.ts";
|
|
14
|
+
import { readObservation } from "../store/observations.ts";
|
|
15
|
+
import { targetDirectory, targetProgram, observeRuntimeIdentity } from "../store/runtime-target.ts";
|
|
16
|
+
import { findSession, startSession, pollSession } from "./exec-sessions.ts";
|
|
17
|
+
import { sessionResult, type ToolContext, type ToolResult } from "./tools.ts";
|
|
18
|
+
|
|
19
|
+
type History = readonly { node: number; events: readonly LogEvent[] }[];
|
|
20
|
+
type LiveTarget = { request: string; contract: string; build: string; instance: RuntimeInstance };
|
|
21
|
+
type Request = { action: "start" | "inspect"; target: string; session_id?: number; yield_time_ms?: number; timeout?: number };
|
|
22
|
+
// Assumed bound for the small build manifest; binaries are observed separately, never read from log JSON.
|
|
23
|
+
const MANIFEST_BYTES = 256 * 1024;
|
|
24
|
+
|
|
25
|
+
export class RuntimeTargets {
|
|
26
|
+
private readonly live = new Map<string, LiveTarget>();
|
|
27
|
+
|
|
28
|
+
async perform(ctx: ToolContext, history: History, input: Request): Promise<{ result: ToolResult; observation: RuntimeTargetReceipt }> {
|
|
29
|
+
const events = history.flatMap(part => [...part.events]);
|
|
30
|
+
const state = deriveAcceptance(undefined, events);
|
|
31
|
+
const contract = events.find(event => event.id === state?.contract_event);
|
|
32
|
+
const target = contract?.t === "acceptance_contract" ? contract.contract.targets?.find(item => item.id === input.target) : undefined;
|
|
33
|
+
if (!state || !target) throw new ToolFault("invalid_input", "target must name a runtime in the current trusted acceptance contract", NOT_STARTED);
|
|
34
|
+
const archive = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
35
|
+
const receipt: RuntimeTargetReceipt = { kind: "runtime_target", version: 1, target: target.id,
|
|
36
|
+
request: state.request, contract_event: state.contract_event, state: "unknown", reason: "runtime has not been observed",
|
|
37
|
+
observed_at: new Date().toISOString(), source: { host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), cwd: resolve(ctx.root, target.workdir ?? "."), runtime: `bun/${Bun.version}` },
|
|
38
|
+
...(target.port === undefined ? {} : { port: target.port }),
|
|
39
|
+
checks: { build: false, inputs: false, artifacts: false, process: false, image: false, port: target.port === undefined ? null : false } };
|
|
40
|
+
const raw: ArtifactRef[] = [];
|
|
41
|
+
let processResult: ToolResult | undefined;
|
|
42
|
+
let launched = false;
|
|
43
|
+
try {
|
|
44
|
+
const build = await this.build(ctx, history, state, target, receipt);
|
|
45
|
+
const existing = this.live.get(target.id);
|
|
46
|
+
if (input.action === "start") {
|
|
47
|
+
if (input.session_id !== undefined) throw Error("start does not accept a session_id; inspect an existing handle instead");
|
|
48
|
+
if (existing && findSession(existing.instance.session, ctx.execOwner)?.alive()) throw Error(`target already owns session ${existing.instance.session}; inspect or stop that handle before starting another`);
|
|
49
|
+
if (!build) throw Error(receipt.reason);
|
|
50
|
+
const cwd = await targetDirectory(ctx.root, target.workdir ?? ".");
|
|
51
|
+
const entryPath = join(await targetDirectory(ctx.root, "."), target.entry);
|
|
52
|
+
const program = await targetProgram(target.interpreter ?? entryPath, ctx.signal);
|
|
53
|
+
if (ctx.signal?.aborted) throw Error("target start cancelled before execution");
|
|
54
|
+
const argv = [program.path, ...(target.interpreter ? [entryPath] : []), ...(target.args ?? [])];
|
|
55
|
+
const command = argv.map(arg => `'${arg.replaceAll("'", "'\\''")}'`).join(" ");
|
|
56
|
+
const source = { ...receipt.source, cwd };
|
|
57
|
+
const session = await startSession({ argv, command, cwd, tty: false, spoolDir: archive, source,
|
|
58
|
+
timeoutMs: commandTimeout(input.timeout, ctx.timeoutMs, ctx.maxTimeoutMs), ...(ctx.execOwner ? { owner: ctx.execOwner } : {}) });
|
|
59
|
+
if ("error" in session) throw Error(session.error);
|
|
60
|
+
launched = true;
|
|
61
|
+
const entry = build.outputs.find(output => output.path === target.entry)!.after;
|
|
62
|
+
if (entry.state !== "observed") throw Error("target entry observation missing");
|
|
63
|
+
const live: LiveTarget = { request: state.request, contract: state.contract_event, build: receipt.build!.event,
|
|
64
|
+
instance: { id: randomUUID(), session: session.id, pid: session.pid, source, entry: { path: entryPath, sha256: entry.sha256 }, program } };
|
|
65
|
+
this.live.set(target.id, live);
|
|
66
|
+
const taken = await pollSession(session, archive, ctx.maxOutputBytes, input.yield_time_ms ?? 1000, "", ctx.signal);
|
|
67
|
+
raw.push(taken.stored.ref);
|
|
68
|
+
processResult = await sessionResult(ctx, session.command, session.timeoutMs, taken);
|
|
69
|
+
}
|
|
70
|
+
const live = this.live.get(target.id);
|
|
71
|
+
if (!live || live.request !== state.request || live.contract !== state.contract_event) throw Error("no live target capability in this Session; historical PID observations are not reattached");
|
|
72
|
+
receipt.instance = live.instance; receipt.source = live.instance.source;
|
|
73
|
+
if (input.session_id !== undefined && live.instance.session !== input.session_id) throw Error("requested handle is not this target instance");
|
|
74
|
+
if (!build || live.build !== receipt.build?.event) { receipt.state = "stale"; throw Error("the running instance is not bound to the current successful build"); }
|
|
75
|
+
const session = findSession(live.instance.session, ctx.execOwner);
|
|
76
|
+
if (!session || !session.alive() || session.exit !== null) { receipt.state = "stale"; throw Error("owned runtime exited or its live capability was lost"); }
|
|
77
|
+
const actual = await observeRuntimeIdentity(session.pid, live.instance.program, target.port, archive, ctx.signal);
|
|
78
|
+
raw.push(...actual.raw);
|
|
79
|
+
// Recheck after OS inspection: neither an exec nor a file replacement during a probe can inherit readiness.
|
|
80
|
+
const current = await targetProgram(target.interpreter ?? live.instance.entry.path, ctx.signal);
|
|
81
|
+
const currentBuild = await this.build(ctx, history, state, target, receipt);
|
|
82
|
+
receipt.checks.process = findSession(session.id, ctx.execOwner) === session && session.alive() && session.exit === null;
|
|
83
|
+
receipt.checks.image = actual.image && canonicalValue(current) === canonicalValue(live.instance.program);
|
|
84
|
+
receipt.checks.port = actual.port;
|
|
85
|
+
receipt.reason = !currentBuild ? receipt.reason : !receipt.checks.process ? "owned runtime exited during observation" :
|
|
86
|
+
!receipt.checks.image ? "runtime executable identity changed or could not be established" : actual.reason;
|
|
87
|
+
receipt.state = targetIsReady(receipt) ? "ready" : !actual.known ? "unknown" : !currentBuild || !receipt.checks.process ? "stale" : "not_ready";
|
|
88
|
+
} catch (error) { receipt.reason = error instanceof Error ? error.message : "target observation failed"; }
|
|
89
|
+
// Cancellation can arrive during a bounded file/OS probe, after the first
|
|
90
|
+
// poll released its listener. Finish through the same owned process path.
|
|
91
|
+
if (ctx.signal?.aborted) {
|
|
92
|
+
const live = this.live.get(target.id);
|
|
93
|
+
const session = live && live.request === state.request && live.contract === state.contract_event ? findSession(live.instance.session, ctx.execOwner) : undefined;
|
|
94
|
+
if (session) {
|
|
95
|
+
const taken = await pollSession(session, archive, ctx.maxOutputBytes, 0, "", ctx.signal);
|
|
96
|
+
raw.push(taken.stored.ref); processResult = await sessionResult(ctx, session.command, session.timeoutMs, taken);
|
|
97
|
+
}
|
|
98
|
+
receipt.state = "unknown"; receipt.checks.process = false; receipt.reason = "target observation cancelled";
|
|
99
|
+
}
|
|
100
|
+
receipt.observed_at = new Date().toISOString();
|
|
101
|
+
try { receipt.manifest = await archiveCommandArtifacts(archive, { observation: receipt, raw }); }
|
|
102
|
+
catch { receipt.state = "unknown"; receipt.reason = "runtime observation archive unavailable"; }
|
|
103
|
+
const content = `target ${target.id}: ${receipt.state}; ${receipt.reason}\n` +
|
|
104
|
+
`build ${receipt.build?.event ?? "none"}; entry ${receipt.instance?.entry.path ?? target.entry}; ` +
|
|
105
|
+
`session ${receipt.instance?.session ?? "none"}; pid ${receipt.instance?.pid ?? "unknown"}; port ${target.port ?? "not declared"}` +
|
|
106
|
+
(receipt.manifest ? `\nObservation: history artifact=${receipt.manifest.sha256}` : "") +
|
|
107
|
+
(receipt.state === "ready" ? "\nAwaiting explicit user acceptance of this instance." : "");
|
|
108
|
+
const result: ToolResult = { ...processResult, ok: receipt.state === "ready", content: content + (processResult ? `\n${processResult.content}` : ""),
|
|
109
|
+
// Process exit and target readiness are different operations. Keep the
|
|
110
|
+
// actual exit in effect/data; only the target owns this tool's outcome.
|
|
111
|
+
outcome: processResult?.outcome?.reason === "cancelled" || processResult?.outcome?.reason === "timeout" ? processResult.outcome :
|
|
112
|
+
executionOutcome(ctx.signal?.aborted ? "cancelled" : receipt.state === "ready" ? "tool_completed" : "tool_failed"),
|
|
113
|
+
...(processResult?.data ? {} : { data: receipt }),
|
|
114
|
+
...(receipt.state === "ready" ? {} : { diagnostic: failureDiagnostic(ctx.signal?.aborted ? "cancelled" : "unknown_effect", receipt.reason, launched ? UNKNOWN_EFFECT : NOT_STARTED) }) };
|
|
115
|
+
// The original stream reference must still match the process receipt. The
|
|
116
|
+
// separate target event/manifest preserves the richer runtime observation.
|
|
117
|
+
return { result, observation: receipt };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async build(ctx: ToolContext, history: History, state: AcceptanceState, target: RuntimeTarget, receipt: RuntimeTargetReceipt): Promise<CommandArtifactReceipt | undefined> {
|
|
121
|
+
const criterion = state.criteria.find(item => item.id === target.build);
|
|
122
|
+
const part = history.find(part => part.events.some(event => event.id === criterion?.evidence?.event_id));
|
|
123
|
+
const event = part?.events.find(event => event.id === criterion?.evidence?.event_id);
|
|
124
|
+
const build = event?.t === "tool_result" && event.ok && event.effect?.kind === "command" && event.effect.exit === 0 ? event.effect.artifacts : undefined;
|
|
125
|
+
if (criterion?.status !== "pass" || !isCommandArtifactReceipt(build) || build.state !== "observed" || !build.manifest || !part || !event ||
|
|
126
|
+
criterion.kind !== "command" || build.command !== criterion.command || build.source.host !== receipt.source.host ||
|
|
127
|
+
build.source.workspace !== resolve(ctx.root) || canonicalValue(build.plan) !== canonicalValue(target.artifacts)) {
|
|
128
|
+
receipt.reason = `run the exact build condition ${target.build} with artifacts=${JSON.stringify(target.artifacts)} on this host and workspace`; return undefined;
|
|
129
|
+
}
|
|
130
|
+
receipt.build = { node: part.node, event: event.id, manifest: build.manifest };
|
|
131
|
+
const exclusions = artifactExclusions(ctx.root, target.artifacts, [...(ctx.ownedStatePaths ?? []), ctx.outputStore ?? join(ctx.root, ".xeno", "observations")]);
|
|
132
|
+
const { manifest, ...original } = build;
|
|
133
|
+
if (manifest.bytes > MANIFEST_BYTES) throw Error("build manifest exceeds runtime verification budget");
|
|
134
|
+
const page = await readObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), manifest.sha256, 0, MANIFEST_BYTES);
|
|
135
|
+
if (page.next !== null || page.bytes !== manifest.bytes || canonicalValue(JSON.parse(page.text)) !== canonicalValue(original) ||
|
|
136
|
+
canonicalValue(exclusions) !== canonicalValue(build.inputs.exclusions)) throw Error("build observation scope or archived manifest does not match its original receipt");
|
|
137
|
+
receipt.checks.build = true;
|
|
138
|
+
const inputs = await observeArtifactInputs(ctx.root, exclusions, ctx.signal);
|
|
139
|
+
const files = await observeArtifactFiles(ctx.root, target.artifacts.paths, ctx.signal);
|
|
140
|
+
receipt.checks.inputs = inputs.sha256 !== null && inputs.sha256 === build.inputs.after;
|
|
141
|
+
receipt.checks.artifacts = build.outputs.every((output, i) => {
|
|
142
|
+
const file = files[i];
|
|
143
|
+
return file?.state === "observed" && output.after.state === "observed" && file.sha256 === output.after.sha256 &&
|
|
144
|
+
file.bytes === output.after.bytes && sameArtifactIdentity(file.identity, output.after.identity);
|
|
145
|
+
});
|
|
146
|
+
if (!receipt.checks.inputs || !receipt.checks.artifacts) { receipt.state = "stale"; receipt.reason = "build inputs or artifact bytes/identity changed; rebuild before launching or accepting"; return undefined; }
|
|
147
|
+
return build;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** Pi A / D8 / D11: direct and nested execution share one admitted, durable call contract. */
|
|
2
|
+
import type { AssistantBlock, ToolInvocationEvent } from "../core/events.ts";
|
|
3
|
+
import { failureDiagnostic, NOT_STARTED, UNKNOWN_EFFECT, ToolFault } from "../core/tool-failure.ts";
|
|
4
|
+
import { executionOutcome } from "../core/outcome.ts";
|
|
5
|
+
import { finishToolResult, nativeCommandContext, NATIVE_COMMAND_NAME, normalizeToolInput, type ToolContext, type ToolResult, type NestedInvocationOptions } from "./tools.ts";
|
|
6
|
+
import { ToolQueue } from "./tool-queue.ts";
|
|
7
|
+
|
|
8
|
+
export type BrokerCall = Extract<AssistantBlock, { type: "tool_use" }>;
|
|
9
|
+
type Mode = "parallel" | "exclusive";
|
|
10
|
+
/** Inherits the existing extension limit, now shared by the entire invocation tree. */
|
|
11
|
+
export const MAX_NESTED_CALLS = 32;
|
|
12
|
+
export const MAX_NESTED_DEPTH = 32;
|
|
13
|
+
/** Assumed per-call composition budget; direct large edits retain their existing interface. */
|
|
14
|
+
export const MAX_NESTED_ARGUMENT_BYTES = 64 * 1024;
|
|
15
|
+
/** Assumed log metadata bound; complete structured results are independent artifacts. */
|
|
16
|
+
export const MAX_INLINE_RESULT_DATA_BYTES = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
export interface BrokerHost<Result extends ToolResult> {
|
|
19
|
+
parallelLimit: number;
|
|
20
|
+
callId(): string;
|
|
21
|
+
mode(call: BrokerCall): Mode;
|
|
22
|
+
invocation(call: BrokerCall, ctx: ToolContext, parent: ToolInvocationEvent | undefined): Promise<ToolInvocationEvent>;
|
|
23
|
+
/** Receives normalized input and must still apply admission, including Session-owned tools. */
|
|
24
|
+
prepare(call: BrokerCall, ctx: ToolContext): Promise<Result | (() => Promise<Result>)>;
|
|
25
|
+
/** Session annotations and branch receipts are accepted before their result becomes evidence. */
|
|
26
|
+
settle(result: Result): Promise<void>;
|
|
27
|
+
result(invocation: ToolInvocationEvent, result: Result): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface BrokerReceipt<Result extends ToolResult> {
|
|
30
|
+
value: Result;
|
|
31
|
+
invocation: ToolInvocationEvent;
|
|
32
|
+
settle(): Promise<void>;
|
|
33
|
+
commit(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export type BrokerExecution<Result extends ToolResult> = (() => Promise<BrokerReceipt<Result>>) & { close(): Promise<void> };
|
|
36
|
+
type Budget = { calls: number };
|
|
37
|
+
type Parent = { invocation: ToolInvocationEvent; budget: Budget; depth: number };
|
|
38
|
+
|
|
39
|
+
/** Scheduling belongs to a live scope; immutable requests/results belong to the host log. */
|
|
40
|
+
export class ToolBroker<Result extends ToolResult = ToolResult> {
|
|
41
|
+
private readonly host: BrokerHost<Result>;
|
|
42
|
+
constructor(host: BrokerHost<Result>) { this.host = host; }
|
|
43
|
+
|
|
44
|
+
async prepare(call: BrokerCall, context: ToolContext, parent?: Parent): Promise<BrokerExecution<Result>> {
|
|
45
|
+
let actual = call;
|
|
46
|
+
let invalid: ToolResult | undefined;
|
|
47
|
+
try {
|
|
48
|
+
// Extension argument preparation cannot rewrite the already-recorded model
|
|
49
|
+
// request. Hooks receive the exact immutable value archived for admission.
|
|
50
|
+
actual = { ...call, input: freezeInput(structuredClone(normalizeToolInput(context, call.name, structuredClone(call.input)))) };
|
|
51
|
+
}
|
|
52
|
+
catch (error) { invalid = error instanceof ToolFault ? finishToolResult({ ok: false, content: error.message, diagnostic: error.diagnostic }) : refused(`Invalid ${call.name} arguments: ${String(error)}`); }
|
|
53
|
+
const invocation = await this.host.invocation(actual, context, parent?.invocation);
|
|
54
|
+
if (parent && invocation.input === undefined) invalid = refused("nested arguments exceed the inline composition budget or are not JSON; pass a bounded request or use the direct tool");
|
|
55
|
+
const budget = parent?.budget ?? { calls: 0 }, depth = (parent?.depth ?? 0) + 1;
|
|
56
|
+
const children = new ToolQueue(this.host.parallelLimit);
|
|
57
|
+
const pending = new Set<Promise<ToolResult>>();
|
|
58
|
+
const failures: string[] = [];
|
|
59
|
+
let incomplete = false;
|
|
60
|
+
const mode = this.host.mode(actual);
|
|
61
|
+
let open = true;
|
|
62
|
+
const invoke = (name: string, input: unknown, options?: NestedInvocationOptions, native = false): Promise<ToolResult> => {
|
|
63
|
+
if (!open) return Promise.resolve(refused("tool scope is closed; no invocation started"));
|
|
64
|
+
if (mode !== "exclusive") return Promise.resolve(refused("only an exclusive composing tool may request nested execution"));
|
|
65
|
+
if (depth >= MAX_NESTED_DEPTH || budget.calls >= MAX_NESTED_CALLS) return Promise.resolve(refused("nested tool budget exhausted; no invocation started"));
|
|
66
|
+
// Capture at submission, before an asynchronous queue can observe a
|
|
67
|
+
// caller's later mutation of the same object.
|
|
68
|
+
try { input = structuredClone(input); }
|
|
69
|
+
catch { return Promise.resolve(refused("nested arguments must be serializable; no invocation started")); }
|
|
70
|
+
budget.calls++;
|
|
71
|
+
const nested: BrokerCall = { type: "tool_use", call_id: this.host.callId(), name, input };
|
|
72
|
+
const signals = [ctx.signal, options?.signal].filter((signal): signal is AbortSignal => signal !== undefined);
|
|
73
|
+
const nestedContext: ToolContext = native ? nativeCommandContext(ctx, options?.signal) : { ...ctx,
|
|
74
|
+
...(signals.length ? { signal: AbortSignal.any(signals) } : {}) };
|
|
75
|
+
const result = children.submit(this.host.mode(nested), async () => {
|
|
76
|
+
const execute = await this.prepare(nested, nestedContext, { invocation, budget, depth });
|
|
77
|
+
return async () => {
|
|
78
|
+
const receipt = await execute();
|
|
79
|
+
await receipt.settle(); await receipt.commit();
|
|
80
|
+
if (!receipt.value.ok) failures.push(`nested call ${receipt.invocation.call_id} failed`);
|
|
81
|
+
return { ...receipt.value, invocation: { call_id: receipt.invocation.call_id, event: receipt.invocation.id } };
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
pending.add(result);
|
|
85
|
+
// The broker owns the lifetime even when extension code drops the promise.
|
|
86
|
+
void result.then(() => pending.delete(result), error => { incomplete = true; failures.push(`nested execution or persistence failed: ${String(error)}`); pending.delete(result); });
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
const ctx: ToolContext = { ...context, callId: call.call_id,
|
|
90
|
+
...(parent ? { parentCallId: parent.invocation.call_id } : {}),
|
|
91
|
+
invoke: (name, input, options) => invoke(name, input, options),
|
|
92
|
+
runOwnedCommand: execute => children.submit("exclusive", async () => execute),
|
|
93
|
+
drainNested: () => children.whenIdle(),
|
|
94
|
+
invokeHost: (input, signal) => invoke(NATIVE_COMMAND_NAME, input, signal ? { signal } : {}, true) };
|
|
95
|
+
let closing: Promise<void> | undefined;
|
|
96
|
+
const close = () => {
|
|
97
|
+
open = false;
|
|
98
|
+
return closing ??= (async () => { await children.close(); await Promise.allSettled(pending); })();
|
|
99
|
+
};
|
|
100
|
+
let prepared: ToolResult | (() => Promise<Result>);
|
|
101
|
+
try { prepared = invalid ?? await this.host.prepare(actual, ctx); }
|
|
102
|
+
catch (error) { prepared = finishToolResult({ ok: false, content: String(error), diagnostic: failureDiagnostic("unknown_effect", String(error), UNKNOWN_EFFECT) }); }
|
|
103
|
+
return Object.assign(async () => {
|
|
104
|
+
let value: Result;
|
|
105
|
+
try { value = (typeof prepared === "function" ? await prepared() : prepared) as Result; }
|
|
106
|
+
catch (error) {
|
|
107
|
+
value = finishToolResult({ ok: false, content: `tool ${call.name} failed: ${String(error)}`,
|
|
108
|
+
diagnostic: failureDiagnostic("unknown_effect", String(error), UNKNOWN_EFFECT) }) as Result;
|
|
109
|
+
} finally {
|
|
110
|
+
await close();
|
|
111
|
+
}
|
|
112
|
+
if (failures.length) value = { ...value, warnings: [...(value.warnings ?? []),
|
|
113
|
+
failureDiagnostic("unknown_effect", `${failures.length} nested failures; inspect the original invocation receipts. ${failures[0]}`, UNKNOWN_EFFECT)] };
|
|
114
|
+
if (incomplete) value = { ...value, ok: false, outcome: executionOutcome("unknown"),
|
|
115
|
+
diagnostic: failureDiagnostic("unknown_effect", "nested execution or persistence did not produce a complete receipt", UNKNOWN_EFFECT) };
|
|
116
|
+
let settled: Promise<void> | undefined, committed: Promise<void> | undefined;
|
|
117
|
+
const settle = () => settled ??= this.host.settle(value);
|
|
118
|
+
return { value, invocation, settle,
|
|
119
|
+
commit: () => committed ??= settle().then(() => this.host.result(invocation, value)) };
|
|
120
|
+
}, { close });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function refused(message: string): ToolResult {
|
|
125
|
+
return finishToolResult({ ok: false, content: message, diagnostic: failureDiagnostic("invalid_input", message, NOT_STARTED) });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function freezeInput(value: unknown, ancestors = new Set<unknown>()): unknown {
|
|
129
|
+
if (value !== null && typeof value === "object") {
|
|
130
|
+
if (ancestors.has(value)) throw new Error("cyclic tool arguments are not JSON");
|
|
131
|
+
ancestors.add(value);
|
|
132
|
+
for (const child of Object.values(value)) freezeInput(child, ancestors);
|
|
133
|
+
ancestors.delete(value);
|
|
134
|
+
Object.freeze(value);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** R11: a harness-owned discovery entry shares normal Session admission and result persistence. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { discoverTools, MAX_DISCOVERY_QUERY, MAX_DISCOVERY_RESULTS, type DeclarationRequest } from "../core/tool-discovery.ts";
|
|
4
|
+
import { decide } from "../core/permissions.ts";
|
|
5
|
+
import { failureDiagnostic, NOT_STARTED } from "../core/tool-failure.ts";
|
|
6
|
+
import type { ToolSchema } from "./provider.ts";
|
|
7
|
+
import type { ToolContext, ToolDefinition, ToolResult } from "./tools.ts";
|
|
8
|
+
|
|
9
|
+
export const DISCOVERY_TOOL_NAME = "tool_search";
|
|
10
|
+
export const DISCOVERY_TOOL: ToolDefinition = {
|
|
11
|
+
name: DISCOVERY_TOOL_NAME,
|
|
12
|
+
description: "Find optional registered tools by name or purpose. Reads the local catalog without connecting to services or executing tools. Returns purpose, important input types and declaration status. Matching permitted tools become declared only after the complete tool batch, for the next model request; then call them with their actual schema. Discovery does not grant execution permission or prove service health. Example: {\"query\":\"issue tracker\",\"limit\":3}.",
|
|
13
|
+
input_schema: { type: "object", properties: {
|
|
14
|
+
query: { type: "string", minLength: 1, maxLength: MAX_DISCOVERY_QUERY },
|
|
15
|
+
limit: { type: "integer", minimum: 1, maximum: MAX_DISCOVERY_RESULTS, description: "Maximum matches to request; defaults to 5." },
|
|
16
|
+
}, required: ["query"] },
|
|
17
|
+
access: "state", execution: "exclusive", branch: "none",
|
|
18
|
+
run: async () => ({ ok: false, content: "Tool discovery requires a live Session checkpoint." }),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Bind a request to the observed contract; an edited/replaced registration needs another lookup. */
|
|
22
|
+
export function discoveryFingerprint(tool: ToolSchema): string {
|
|
23
|
+
return createHash("sha256").update(JSON.stringify({ name: tool.name, description: tool.description, input_schema: tool.input_schema })).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
export function discoveryPermitted(ctx: ToolContext, name: string): boolean {
|
|
26
|
+
return decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], { tool: name }) !== "deny";
|
|
27
|
+
}
|
|
28
|
+
/** A directory entry without its matching live implementation is unavailable, regardless of history. */
|
|
29
|
+
export function currentDeferredTools(ctx: ToolContext): readonly ToolSchema[] {
|
|
30
|
+
return (ctx.deferredTools ?? []).filter(tool => {
|
|
31
|
+
const definition = ctx.extensions?.get(tool.name);
|
|
32
|
+
return definition !== undefined && discoveryFingerprint(definition) === discoveryFingerprint(tool);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function searchDeferredTools(ctx: ToolContext, input: Record<string, unknown>, declared: readonly string[]): ToolResult {
|
|
36
|
+
const catalog = currentDeferredTools(ctx);
|
|
37
|
+
let matches;
|
|
38
|
+
try { matches = discoverTools(catalog, input["query"] as string, input["limit"] as number | undefined); }
|
|
39
|
+
catch {
|
|
40
|
+
const content = "Discovery requires a nonblank query of at most 256 characters and an integer limit from 1 to 8.";
|
|
41
|
+
return { ok: false, content, diagnostic: failureDiagnostic("invalid_input", content, NOT_STARTED) };
|
|
42
|
+
}
|
|
43
|
+
const requested: DeclarationRequest[] = [];
|
|
44
|
+
const result = matches.map(match => {
|
|
45
|
+
const tool = catalog.find(item => item.name === match.name)!;
|
|
46
|
+
const permitted = discoveryPermitted(ctx, match.name);
|
|
47
|
+
const status = !permitted ? "permission_denied" : declared.includes(match.name) ? "declared" : "requested_for_next_request";
|
|
48
|
+
if (status === "requested_for_next_request") requested.push({ name: tool.name, fingerprint: discoveryFingerprint(tool) });
|
|
49
|
+
const health = ctx.extensions?.get(match.name)?.integrationHealth?.();
|
|
50
|
+
return { ...match, declaration: status, service_health: health?.state ?? "not_checked", ...(health ? { service_observation: health } : {}) };
|
|
51
|
+
});
|
|
52
|
+
return { ok: true, content: JSON.stringify({ matches: result, note: result.length
|
|
53
|
+
? "Only declared tools can run. Requested schemas become available after the current batch at a checkpoint; execution permissions still apply."
|
|
54
|
+
: "No matching tools in the currently permitted discovery catalog. No service was contacted." }),
|
|
55
|
+
// Keep only small, original requests inline so cold recovery never depends on a generated summary.
|
|
56
|
+
data: { kind: "tool_discovery", version: 1, requested: requested.map(item => ({ ...item })) } };
|
|
57
|
+
}
|