@visual-z/xeno 0.2.1 → 0.4.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 +18 -0
- package/README.md +126 -34
- package/docs/app-server.md +84 -0
- package/docs/install.md +51 -13
- package/docs/providers.md +24 -3
- package/package.json +7 -5
- package/src/apps/app-server.ts +28 -0
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +45 -8
- package/src/apps/completions.ts +78 -22
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/model-catalog.ts +2 -1
- 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 +333 -1542
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +149 -94
- package/src/apps/ui/app.ts +1553 -0
- package/src/apps/ui/control-model.ts +26 -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 +86 -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-action.ts +20 -0
- package/src/core/completion-review.ts +37 -21
- 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/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- 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 +81 -6
- package/src/core/events.ts +99 -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/harness-diagnostics.ts +206 -0
- 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/oauth-fields.ts +44 -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/program-status.ts +31 -0
- package/src/core/project.ts +70 -19
- 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/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -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 +33 -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-input.ts +6 -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 +11 -4
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/catalog.ts +13 -3
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/control-connection.ts +104 -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 +462 -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 +1088 -282
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +146 -0
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- 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/session-control.ts +198 -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 +200 -0
- package/src/tui/ui/control-panel.ts +23 -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 +279 -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
|
@@ -36,6 +36,9 @@ export function observedVersion(events: readonly LogEvent[], resource: string):
|
|
|
36
36
|
if (event.removed.map(resourceKey).includes(resource)) version = null;
|
|
37
37
|
for (const [path, value] of Object.entries({ ...event.added, ...event.changed })) if (resourceKey(path) === resource) version = value;
|
|
38
38
|
}
|
|
39
|
+
if (event.t === "checkpoint" && event.phase === "applied") for (const [path, change] of Object.entries(event.changes)) {
|
|
40
|
+
if (resourceKey(path) === resource) version = change.after?.sha256 ?? null;
|
|
41
|
+
}
|
|
39
42
|
}
|
|
40
43
|
return version;
|
|
41
44
|
}
|
|
@@ -65,6 +68,10 @@ export function deriveEvidenceLinks(previous: readonly EvidenceLink[], events: r
|
|
|
65
68
|
if (resourceKey(path) === link.resource) link.freshness = version === link.version ? "current" : "stale";
|
|
66
69
|
}
|
|
67
70
|
}
|
|
71
|
+
if (event.t === "checkpoint" && event.phase === "applied") for (const [path, change] of Object.entries(event.changes)) {
|
|
72
|
+
if (resourceKey(path) === link.resource) link.freshness = change.after?.sha256 === link.version ? "current" : "stale";
|
|
73
|
+
}
|
|
74
|
+
if (event.t === "workspace_observation" && event.changed || event.t === "checkpoint" && event.phase === "unavailable") link.freshness = "unknown";
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
return links;
|
package/src/core/evidence.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Shared executed-evidence rules for handoff derivation and the turn watchdog. */
|
|
2
2
|
import type { EvidenceQuery, LogEvent, ToolResultEvent } from "./events.ts";
|
|
3
3
|
import type { Evidence } from "./handoff.ts";
|
|
4
|
+
import { changedPaths } from "./checkpoints.ts";
|
|
4
5
|
|
|
5
6
|
type CommandEffect = Extract<NonNullable<ToolResultEvent["effect"]>, { kind: "command" }>;
|
|
6
7
|
export type CommandResult = ToolResultEvent & { effect: CommandEffect };
|
|
@@ -15,9 +16,14 @@ export function evidenceStale(events: readonly LogEvent[], evidence: Evidence, i
|
|
|
15
16
|
let stale = initially;
|
|
16
17
|
for (const event of events.slice(start + 1)) {
|
|
17
18
|
if (event.t === "tool_result" && event.effect?.kind === "edit") stale = true;
|
|
18
|
-
|
|
19
|
+
if (event.t === "checkpoint" && (event.phase === "unavailable" || event.phase === "applied" && changedPaths(event.changes).length > 0)) stale = true;
|
|
20
|
+
if (event.t === "workspace_observation" && event.fingerprint === undefined ||
|
|
21
|
+
event.t === "tool_result" && event.effect?.kind === "command" && event.effect.workspace_observed === false || event.t === "user_bash") stale = true;
|
|
22
|
+
const sha = event.t === "workspace_observation" ? event.fingerprint : event.t === "snapshot" ? event.workspace_sha256
|
|
19
23
|
: event.t === "tool_result" && event.effect?.kind === "command" ? event.effect.workspace_sha256 : undefined;
|
|
20
|
-
|
|
24
|
+
const scope = event.t === "workspace_observation" ? event.scope : event.t === "tool_result" && event.effect?.kind === "command" ? event.effect.workspace_scope : undefined;
|
|
25
|
+
if (sha !== undefined && evidence.workspace_sha256 !== undefined) stale = evidence.workspace_scope === "source-v1"
|
|
26
|
+
? stale || scope !== evidence.workspace_scope || sha !== evidence.workspace_sha256 : scope !== undefined || sha !== evidence.workspace_sha256;
|
|
21
27
|
else if (event.t === "snapshot" && (Object.keys(event.added).length + Object.keys(event.changed).length + event.removed.length > 0)) stale = true;
|
|
22
28
|
}
|
|
23
29
|
return stale;
|
|
@@ -33,7 +39,9 @@ export function resolveEvidence(events: readonly LogEvent[], query: EvidenceQuer
|
|
|
33
39
|
const result = events.slice(start).findLast(event => isCommandResult(event) && matchesQuery(query, event.effect));
|
|
34
40
|
if (!result || !isCommandResult(result)) return { ok: false, reason: "no_matching_event" };
|
|
35
41
|
if (result.effect.exit !== 0) return { ok: false, reason: "nonzero_exit", exit: result.effect.exit };
|
|
42
|
+
if (result.effect.workspace_observed === false) return { ok: false, reason: "stale_workspace" };
|
|
36
43
|
const evidence: Evidence = { command: result.effect.cmd, exit: result.effect.exit, at: result.at, event_id: result.id,
|
|
44
|
+
...(result.effect.workspace_scope ? { workspace_scope: result.effect.workspace_scope } : {}),
|
|
37
45
|
...(result.effect.gate !== undefined ? { gate: result.effect.gate } : {}),
|
|
38
46
|
...(result.effect.workspace_sha256 ? { workspace_sha256: result.effect.workspace_sha256 } : {}) };
|
|
39
47
|
return evidenceStale(events, evidence) ? { ok: false, reason: "stale_workspace" } : { ok: true, evidence };
|
|
@@ -44,8 +52,9 @@ export function eligibleProgressChecks(events: readonly LogEvent[], claimed: Rea
|
|
|
44
52
|
for (const event of events) {
|
|
45
53
|
if (!isCommandResult(event)) continue;
|
|
46
54
|
const gate = event.effect.gate;
|
|
47
|
-
if (event.effect.exit !== 0 || (gate === undefined && !claimed.has(event.id)) || (gate !== undefined && failingGates.has(gate))) continue;
|
|
55
|
+
if (event.effect.exit !== 0 || event.effect.workspace_observed === false || (gate === undefined && !claimed.has(event.id)) || (gate !== undefined && failingGates.has(gate))) continue;
|
|
48
56
|
if (!evidenceStale(events, { command: event.effect.cmd, exit: 0, at: event.at, event_id: event.id,
|
|
57
|
+
...(event.effect.workspace_scope ? { workspace_scope: event.effect.workspace_scope } : {}),
|
|
49
58
|
...(event.effect.workspace_sha256 === undefined ? {} : { workspace_sha256: event.effect.workspace_sha256 }) })) eligible.add(event.id);
|
|
50
59
|
}
|
|
51
60
|
return eligible;
|
|
@@ -74,6 +83,7 @@ export function classifyGate(gates: Readonly<Record<string, string>>, command: s
|
|
|
74
83
|
if (tokens.some((t) => informational.includes(t))) return undefined;
|
|
75
84
|
let i = 0;
|
|
76
85
|
const first = tokens[0];
|
|
86
|
+
if (["bun", "npm", "pnpm", "yarn"].includes(first ?? "") && tokens[1] === "test") return gates["test"];
|
|
77
87
|
if (first === "python" || first === "python3") {
|
|
78
88
|
if (tokens[1] !== "-m") return undefined;
|
|
79
89
|
i = 2;
|
package/src/core/execution.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/** D5/D11: incomplete execution is unknown, never an instruction to run twice. */
|
|
2
2
|
import type { LogEvent, AssistantBlock } from "./events.ts";
|
|
3
3
|
|
|
4
|
-
export
|
|
5
|
-
|
|
4
|
+
export type PendingToolCall = Extract<AssistantBlock, { type: "tool_use" }> & { parent_call_id?: string; invocation_id?: string; origin?: "extension" };
|
|
5
|
+
export function pendingToolCalls(events: readonly LogEvent[]): PendingToolCall[] {
|
|
6
|
+
const pending = new Map<string, PendingToolCall>();
|
|
6
7
|
for (const event of events) {
|
|
7
8
|
if (event.t === "assistant") {
|
|
8
9
|
for (const block of event.blocks) if (block.type === "tool_use") pending.set(block.call_id, block);
|
|
10
|
+
} else if (event.t === "tool_invocation") {
|
|
11
|
+
const existing = pending.get(event.call_id);
|
|
12
|
+
pending.set(event.call_id, { type: "tool_use", call_id: event.call_id, name: event.name, input: event.input ?? existing?.input ?? null,
|
|
13
|
+
invocation_id: event.id, ...(event.origin ? { origin: event.origin } : {}),
|
|
14
|
+
...(event.parent_call_id ? { parent_call_id: event.parent_call_id } : {}) });
|
|
9
15
|
} else if (event.t === "tool_result") pending.delete(event.call_id);
|
|
10
16
|
}
|
|
11
17
|
return [...pending.values()];
|
package/src/core/experiment.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** Experimental projections never mutate the durable event stream (D8, L). */
|
|
2
2
|
import type { Message } from "./project.ts";
|
|
3
|
+
import { isTrialLifecycle, type TrialLifecycle } from "./trial-lifecycle.ts";
|
|
4
|
+
import { RECOVERY_TASKS, recoveryCase, type RecoveryTask } from "./recovery-corpus.ts";
|
|
5
|
+
import { isRecoveryMetrics, type RecoveryMetrics } from "./recovery-evaluation.ts";
|
|
6
|
+
import { TOOL_MODE_FACTORS, TOOL_MODE_TASKS, isToolModeTask, isToolModeMetrics, type ToolModeTask, type ToolModeMetrics } from "./tool-mode-protocol.ts";
|
|
3
7
|
|
|
4
8
|
export type ContextArm = "node" | "replay" | "truncate" | "mask";
|
|
5
9
|
|
|
@@ -38,3 +42,132 @@ export function shuffled<T>(items: readonly T[], seed: number): T[] {
|
|
|
38
42
|
}
|
|
39
43
|
return out;
|
|
40
44
|
}
|
|
45
|
+
|
|
46
|
+
/** O.16: each contrast changes exactly one policy, with paired task repeats. */
|
|
47
|
+
export const EVAL_FACTORS = ["profile", "workingSet", "strictVerification"] as const;
|
|
48
|
+
export const EVAL_TASKS = ["short", "boundary-recall", "false-completion", "human-boundary"] as const;
|
|
49
|
+
const supportedFactors = [...EVAL_FACTORS, ...TOOL_MODE_FACTORS] as const;
|
|
50
|
+
export type EvalFactor = typeof supportedFactors[number];
|
|
51
|
+
export type EvalTask = typeof EVAL_TASKS[number] | RecoveryTask | ToolModeTask;
|
|
52
|
+
const supportedTasks:readonly EvalTask[]=[...EVAL_TASKS,...RECOVERY_TASKS,...TOOL_MODE_TASKS];
|
|
53
|
+
export type EvalArm = "control" | "treatment";
|
|
54
|
+
export type EvalPolicy = { profile: "full" | "coding"; workingSet: boolean; strictVerification: boolean; querySyntax?: "json" | "sql" };
|
|
55
|
+
export type EvalJob = { id: string; factor: EvalFactor; task: EvalTask; repeat: number; arm: EvalArm; policy: EvalPolicy };
|
|
56
|
+
export function evalPolicy(factor: EvalFactor, arm: EvalArm): EvalPolicy {
|
|
57
|
+
const policy: EvalPolicy = { profile: "coding", workingSet: true, strictVerification: true };
|
|
58
|
+
if (factor === "querySyntax") return { ...policy, querySyntax: arm === "treatment" ? "sql" : "json" };
|
|
59
|
+
if (arm === "control") {
|
|
60
|
+
if (factor === "profile") policy.profile = "full";
|
|
61
|
+
else policy[factor] = false;
|
|
62
|
+
}
|
|
63
|
+
return policy;
|
|
64
|
+
}
|
|
65
|
+
export function singleFactorSchedule(seed: number, repeats = 1, tasks: readonly EvalTask[] = EVAL_TASKS, factors: readonly EvalFactor[] = EVAL_FACTORS): EvalJob[] {
|
|
66
|
+
if (!Number.isSafeInteger(seed) || !Number.isSafeInteger(repeats) || repeats < 1 || repeats > 20 || !tasks.length ||
|
|
67
|
+
new Set(tasks).size !== tasks.length || tasks.some(task => !supportedTasks.includes(task)) || !factors.length || new Set(factors).size !== factors.length ||
|
|
68
|
+
factors.some(factor => !supportedFactors.includes(factor) || tasks.some(task => isToolModeTask(task) !== (TOOL_MODE_FACTORS as readonly string[]).includes(factor)))) throw new RangeError("invalid eval schedule");
|
|
69
|
+
const pairs = shuffled(factors.flatMap(factor => [...tasks].sort().flatMap(task =>
|
|
70
|
+
Array.from({ length: repeats }, (_, repeat) => ({ factor, task, repeat })))), seed);
|
|
71
|
+
// Alternate first position before shuffling: adjacent LCG seeds are correlated.
|
|
72
|
+
const positions = new Map<EvalFactor, number>();
|
|
73
|
+
const order = shuffled(["control", "treatment"] as const, seed + 1);
|
|
74
|
+
return pairs.flatMap(pair => {
|
|
75
|
+
const offset = positions.get(pair.factor) ?? 0; positions.set(pair.factor, offset + 1);
|
|
76
|
+
return order.map((_, i) => {
|
|
77
|
+
const arm = order[(offset + i) % 2]!;
|
|
78
|
+
return { ...pair, arm, id: `${pair.factor}/${pair.task}/${pair.repeat}/${arm}`, policy: evalPolicy(pair.factor, arm) };
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function validateEvalJobs(jobs: readonly EvalJob[]): void {
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
for (const job of jobs) {
|
|
85
|
+
if (!supportedFactors.includes(job.factor) || !supportedTasks.includes(job.task) || !["control", "treatment"].includes(job.arm) ||
|
|
86
|
+
isToolModeTask(job.task) !== (TOOL_MODE_FACTORS as readonly string[]).includes(job.factor) ||
|
|
87
|
+
!Number.isSafeInteger(job.repeat) || job.repeat < 0 || job.id !== `${job.factor}/${job.task}/${job.repeat}/${job.arm}` || seen.has(job.id) ||
|
|
88
|
+
JSON.stringify(job.policy) !== JSON.stringify(evalPolicy(job.factor, job.arm))) throw new Error("invalid or confounded eval job");
|
|
89
|
+
seen.add(job.id);
|
|
90
|
+
}
|
|
91
|
+
for (const job of jobs) if (!seen.has(`${job.factor}/${job.task}/${job.repeat}/${job.arm === "control" ? "treatment" : "control"}`)) throw new Error("unpaired eval job");
|
|
92
|
+
}
|
|
93
|
+
export interface EvalResult {
|
|
94
|
+
toolMode?: ToolModeMetrics;
|
|
95
|
+
recovery?: RecoveryMetrics;
|
|
96
|
+
/** v2 separates an unmet final state from a machine-readable unsupported claim. */
|
|
97
|
+
unverifiedEnding?: boolean;
|
|
98
|
+
/** Absent in the frozen O.16 archives; absence is unknown, not an uninterrupted run. */
|
|
99
|
+
lifecycle?: TrialLifecycle;
|
|
100
|
+
id: string;
|
|
101
|
+
status: "finished" | "error" | "budget" | "stopped";
|
|
102
|
+
scenarioPassed: boolean;
|
|
103
|
+
artifactCorrect: boolean;
|
|
104
|
+
verified: boolean;
|
|
105
|
+
awaitingHuman: boolean;
|
|
106
|
+
falseCompletion: boolean;
|
|
107
|
+
requests: number;
|
|
108
|
+
toolFailures: number;
|
|
109
|
+
repeatedReads: number;
|
|
110
|
+
promptTokens: number | null;
|
|
111
|
+
cacheTokens: number | null;
|
|
112
|
+
outputTokens: number | null;
|
|
113
|
+
costUsd: number | null;
|
|
114
|
+
reservedUnknownUsd: number;
|
|
115
|
+
}
|
|
116
|
+
export function summarizeSingleFactor(jobs: readonly EvalJob[], results: readonly EvalResult[]) {
|
|
117
|
+
validateEvalJobs(jobs);
|
|
118
|
+
const byId = new Map<string, EvalResult>(), planned = new Set(jobs.map(job => job.id));
|
|
119
|
+
for (const row of results) {
|
|
120
|
+
if (row.toolMode !== undefined && (!isToolModeMetrics(row.toolMode) || row.scenarioPassed && !row.toolMode.sourceComplete)) throw Error("invalid tool mode metrics");
|
|
121
|
+
if (row.unverifiedEnding !== undefined && typeof row.unverifiedEnding !== "boolean") throw Error("invalid ending metric");
|
|
122
|
+
if(row.recovery!==undefined&&(!isRecoveryMetrics(row.recovery)||row.recovery.finalTaskSuccess!==row.scenarioPassed||row.recovery.toolFailures!==row.toolFailures||row.falseCompletion!==(row.recovery.unsupportedClaims>0)))throw Error("invalid recovery metrics");
|
|
123
|
+
if (row.lifecycle !== undefined && (!isTrialLifecycle(row.lifecycle) || row.lifecycle.backendRequests > row.requests ||
|
|
124
|
+
row.lifecycle.interruption !== null && row.status === "finished" || row.lifecycle.cleanup === "failed" && row.status !== "error"))
|
|
125
|
+
throw new Error("invalid eval lifecycle result");
|
|
126
|
+
if (!planned.has(row.id) || byId.has(row.id) || !["finished", "error", "budget", "stopped"].includes(row.status) ||
|
|
127
|
+
[row.scenarioPassed, row.artifactCorrect, row.verified, row.awaitingHuman, row.falseCompletion].some(value => typeof value !== "boolean") ||
|
|
128
|
+
row.scenarioPassed && (row.status !== "finished" || !row.verified || row.falseCompletion && row.recovery === undefined) ||
|
|
129
|
+
[row.requests, row.toolFailures, row.repeatedReads].some(n => !Number.isSafeInteger(n) || n < 0) ||
|
|
130
|
+
[row.promptTokens, row.cacheTokens, row.outputTokens].some(n => n !== null && (!Number.isSafeInteger(n) || n < 0)) ||
|
|
131
|
+
[row.costUsd, row.reservedUnknownUsd].some(n => n !== null && (!Number.isFinite(n) || n < 0)) ||
|
|
132
|
+
row.cacheTokens !== null && row.promptTokens !== null && row.cacheTokens > row.promptTokens) throw new Error("invalid or duplicate eval result");
|
|
133
|
+
byId.set(row.id, row);
|
|
134
|
+
}
|
|
135
|
+
const factors = supportedFactors.filter(factor => jobs.some(job => job.factor === factor));
|
|
136
|
+
const arms = factors.flatMap(factor => (["control", "treatment"] as const).map(arm => {
|
|
137
|
+
const selected = jobs.filter(job => job.factor === factor && job.arm === arm), rows = selected.flatMap(job => byId.has(job.id) ? [byId.get(job.id)!] : []);
|
|
138
|
+
const complete = rows.length === selected.length, scenarioPassed = rows.filter(row => row.scenarioPassed).length;
|
|
139
|
+
const knownCostSubtotal = rows.reduce((sum, row) => sum + (row.costUsd ?? 0), 0);
|
|
140
|
+
// Missing jobs and unknown charges cannot become a zero-priced success.
|
|
141
|
+
const costUsd = complete && rows.length > 0 && rows.every(row => row.costUsd !== null) ? knownCostSubtotal : null;
|
|
142
|
+
const total = (field: "promptTokens" | "cacheTokens" | "outputTokens") => complete && rows.length > 0 && rows.every(row => row[field] !== null)
|
|
143
|
+
? rows.reduce((sum, row) => sum + row[field]!, 0) : null;
|
|
144
|
+
const recovery=selected.some(job=>recoveryCase(job.task)||isToolModeTask(job.task))?{observed:rows.filter(row=>row.recovery).length,
|
|
145
|
+
missing:selected.length-rows.filter(row=>row.recovery).length,
|
|
146
|
+
firstToolSuccess:rows.filter(row=>row.recovery?.firstToolSuccess===true).length,
|
|
147
|
+
firstPassTaskSuccess:rows.filter(row=>row.recovery?.firstPassTaskSuccess===true).length,
|
|
148
|
+
...Object.fromEntries((["toolCalls","outerCalls","nestedCalls","unresolvedCalls","recoveryCalls","unchangedFailureRetries","originalEvidenceRetrievals","unsupportedClaims"] as const)
|
|
149
|
+
.map(key=>[key,rows.reduce((n,row)=>n+(row.recovery?.[key]??0),0)]))}:undefined;
|
|
150
|
+
return { factor, arm, ...(recovery?{recovery}:{}), planned: selected.length, attempted: rows.length, missing: selected.length - rows.length,
|
|
151
|
+
failed: rows.filter(row => !row.scenarioPassed).length, scenarioPassed, verified: rows.filter(row => row.verified).length,
|
|
152
|
+
awaitingHuman: rows.filter(row => row.awaitingHuman).length, falseCompletions: rows.filter(row => row.falseCompletion).length,
|
|
153
|
+
plannedPassFraction: selected.length ? scenarioPassed / selected.length : null, complete,
|
|
154
|
+
requests: rows.reduce((n, row) => n + row.requests, 0), toolFailures: rows.reduce((n, row) => n + row.toolFailures, 0),
|
|
155
|
+
repeatedReads: rows.reduce((n, row) => n + row.repeatedReads, 0), promptTokens: total("promptTokens"), cacheTokens: total("cacheTokens"), outputTokens: total("outputTokens"),
|
|
156
|
+
knownCostSubtotal, costUsd, unknownCostJobs: rows.filter(row => row.costUsd === null).length,
|
|
157
|
+
reservedUnknownUsd: rows.reduce((n, row) => n + row.reservedUnknownUsd, 0),
|
|
158
|
+
costPerScenarioSuccess: costUsd !== null && scenarioPassed > 0 ? costUsd / scenarioPassed : null };
|
|
159
|
+
}));
|
|
160
|
+
const comparisons = factors.map(factor => {
|
|
161
|
+
const pairs = jobs.filter(job => job.factor === factor && job.arm === "control");
|
|
162
|
+
const clusters = [...new Set(pairs.map(job => job.task))].sort().map(task => {
|
|
163
|
+
const matched = pairs.filter(job => job.task === task).flatMap(job => {
|
|
164
|
+
const control = byId.get(job.id), treatment = byId.get(`${factor}/${task}/${job.repeat}/treatment`);
|
|
165
|
+
return control && treatment ? [{ delta: Number(treatment.scenarioPassed) - Number(control.scenarioPassed) }] : [];
|
|
166
|
+
});
|
|
167
|
+
return { task, matchedPairs: matched.length, passDelta: matched.length ? matched.reduce((n, pair) => n + pair.delta, 0) / matched.length : null };
|
|
168
|
+
});
|
|
169
|
+
const matchedPairs = clusters.reduce((n, row) => n + row.matchedPairs, 0);
|
|
170
|
+
return { factor, matchedPairs, missingPairs: pairs.length - matchedPairs, clusters };
|
|
171
|
+
});
|
|
172
|
+
return { arms, comparisons };
|
|
173
|
+
}
|
package/src/core/export.ts
CHANGED
|
@@ -91,6 +91,9 @@ function escapeHtml(s: string): string {
|
|
|
91
91
|
|
|
92
92
|
function eventHtml(e: LogEvent): string {
|
|
93
93
|
switch (e.t) {
|
|
94
|
+
case "tool_invocation": return wrap("event", "tool invocation", e.at, `${e.name} · ${e.call_id}${e.parent_call_id ? ` · parent ${e.parent_call_id}` : ""} · input ${e.input_ref.sha256} · schema ${e.schema_ref?.sha256 ?? "unavailable"}`);
|
|
95
|
+
case "checkpoint": return wrap("event", `file checkpoint ${e.phase}`, e.at, e.reason ?? Object.keys(e.changes).join("\n"));
|
|
96
|
+
case "workspace_observation": return wrap("event", "verification observation", e.at, e.fingerprint ?? e.reason ?? "unavailable");
|
|
94
97
|
case "extension": return e.display ? wrap("event", e.customType, e.at, e.content || JSON.stringify(e.data)) : "";
|
|
95
98
|
case "user":
|
|
96
99
|
return wrap("user", "user", e.at, [e.text, ...(e.images ?? []).map(imageDescription)].join("\n"));
|
|
@@ -143,6 +146,12 @@ function eventHtml(e: LogEvent): string {
|
|
|
143
146
|
return wrap("event", `work ${e.operation}`, e.at, `${e.work_id}: ${e.what}`);
|
|
144
147
|
case "evidence_link":
|
|
145
148
|
return wrap("event", "evidence link", e.at, `${e.work}: ${e.what} → ${e.source}`);
|
|
149
|
+
case "work_owner": case "work_dependencies": return wrap("event", e.t, e.at, JSON.stringify(e));
|
|
150
|
+
case "skill_loaded": return wrap("event", "skill loaded", e.at, `${e.name} · ${e.path} · ${e.sha256} · ${e.mode}`);
|
|
151
|
+
case "process_exit": return wrap("event", "process exit", e.at, `${e.session} · exit ${e.exit} · output ${e.output.sha256}`);
|
|
152
|
+
case "acceptance_contract": return wrap("event", "acceptance contract", e.at, JSON.stringify(e.contract));
|
|
153
|
+
case "target_observation": return wrap("event", "runtime target", e.at, JSON.stringify(e.observation));
|
|
154
|
+
case "acceptance_decision": return wrap("event", "human acceptance", e.at, `${e.contract_id}: ${e.criterion}`);
|
|
146
155
|
default:
|
|
147
156
|
return assertNever(e, "eventHtml");
|
|
148
157
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** R5 / D8: replayable failure premises; advice never authorizes or automatically retries a tool. */
|
|
2
|
+
import type { GuidanceEvent, LogEvent, ToolResultEvent } from "./events.ts";
|
|
3
|
+
import type { ToolDiagnostic } from "./tool-contract.ts";
|
|
4
|
+
import { canonicalValue } from "./continuation.ts";
|
|
5
|
+
import { repairToolInput } from "./tool-input.ts";
|
|
6
|
+
import { failureDiagnostic, scopeFailureGuidance } from "./tool-failure.ts";
|
|
7
|
+
|
|
8
|
+
/** Reuses the existing three-attempt diagnostic hypothesis, not a measured optimum or a stop threshold. */
|
|
9
|
+
export const RECOVERY_FAILURES = 3;
|
|
10
|
+
type Call = { name: string; input: unknown };
|
|
11
|
+
export interface FailureIdentity {
|
|
12
|
+
family: string;
|
|
13
|
+
resource: string;
|
|
14
|
+
version: string;
|
|
15
|
+
code: ToolDiagnostic["code"];
|
|
16
|
+
execution: NonNullable<ToolDiagnostic["execution"]>;
|
|
17
|
+
effects: NonNullable<ToolDiagnostic["effects"]>;
|
|
18
|
+
premise: string;
|
|
19
|
+
}
|
|
20
|
+
interface FailureRun {
|
|
21
|
+
identity: FailureIdentity;
|
|
22
|
+
key: string;
|
|
23
|
+
count: number;
|
|
24
|
+
sources: readonly string[];
|
|
25
|
+
notified: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface RecoveryState {
|
|
28
|
+
calls: ReadonlyMap<string, Call>;
|
|
29
|
+
versions: ReadonlyMap<string, string>;
|
|
30
|
+
failures: ReadonlyMap<string, FailureRun>;
|
|
31
|
+
change: number;
|
|
32
|
+
}
|
|
33
|
+
export function emptyRecoveryState(): RecoveryState {
|
|
34
|
+
return { calls: new Map(), versions: new Map(), failures: new Map(), change: 0 };
|
|
35
|
+
}
|
|
36
|
+
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
37
|
+
const digest = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
38
|
+
const fileTools = ["read", "write", "edit", "grep", "find", "ls"];
|
|
39
|
+
const family = (name: string): string => name === "bash" || name === "exec_command" ? "command"
|
|
40
|
+
: name === "edit" || name === "write" ? "file_change" : name;
|
|
41
|
+
|
|
42
|
+
function target(call: Call, identify: (path: string) => string): { family: string; resource: string; input: Record<string, unknown> } {
|
|
43
|
+
const repaired = repairToolInput(call.name, call.input), input = record(repaired) ? repaired : { value: repaired };
|
|
44
|
+
const kind = family(call.name);
|
|
45
|
+
return { family: kind, input, resource: fileTools.includes(call.name) && typeof input["path"] === "string" ? identify(input["path"])
|
|
46
|
+
: kind === "command" ? identify(typeof input["workdir"] === "string" ? input["workdir"] : ".")
|
|
47
|
+
: call.name === "write_stdin" ? `session:${String(input["session_id"])}` : call.name };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function groupKey(call: Call, result: ToolResultEvent, identify: (path: string) => string): string {
|
|
51
|
+
const context = target(call, identify);
|
|
52
|
+
return canonicalValue([context.family, context.resource, context.family === "command"
|
|
53
|
+
? result.effect?.kind === "command" ? result.effect.cmd.trim() : String(context.input["command"] ?? context.input["cmd"] ?? "").trim() : null]);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Only recorded versions and structured failures are facts; stderr words never identify an error. */
|
|
57
|
+
export function failureIdentity(call: Call, result: ToolResultEvent, state: RecoveryState,
|
|
58
|
+
identify: (path: string) => string): FailureIdentity | null {
|
|
59
|
+
const diagnostic = result.diagnostic;
|
|
60
|
+
if (result.ok || !diagnostic || diagnostic.execution === undefined || diagnostic.effects === undefined) return null;
|
|
61
|
+
const context = target(call, identify), data = record(result.data) ? result.data : {};
|
|
62
|
+
const current = data["sha256"];
|
|
63
|
+
const effect = result.effect;
|
|
64
|
+
const known = state.versions.get(context.resource);
|
|
65
|
+
const version = diagnostic.code === "permission_denied" ? "unknown" : current === null ? "absent" : digest(current) ? current
|
|
66
|
+
: effect?.kind === "command" && effect.workspace_observed !== false && digest(effect.workspace_sha256) ? effect.workspace_sha256
|
|
67
|
+
: digest(known) ? known : "unknown";
|
|
68
|
+
const input = context.input;
|
|
69
|
+
let premise: unknown;
|
|
70
|
+
if (diagnostic.code === "version_mismatch") {
|
|
71
|
+
const precondition = record(data["precondition"]) ? data["precondition"] : {};
|
|
72
|
+
premise = { expected: precondition["expected"] === null ? "absent"
|
|
73
|
+
: precondition["expected"] ?? input["expected_sha256"] ?? input["read_handle"] ?? "unknown" };
|
|
74
|
+
} else if (diagnostic.code === "permission_denied") {
|
|
75
|
+
// Replacement wording and requested shell timeouts cannot change a denied capability.
|
|
76
|
+
premise = { operation: context.family === "command" ? effect?.kind === "command" ? effect.cmd.trim()
|
|
77
|
+
: String(input["command"] ?? input["cmd"] ?? "").trim() : context.family };
|
|
78
|
+
} else if (context.family === "file_change" && ["not_found", "ambiguous_edit"].includes(diagnostic.code)) {
|
|
79
|
+
const edits = Array.isArray(input["edits"]) ? input["edits"] : [input];
|
|
80
|
+
premise = edits.map(edit => record(edit) ? { old_string: edit["old_string"], replace_all: edit["replace_all"] === true } : edit);
|
|
81
|
+
} else if (context.family === "command") {
|
|
82
|
+
// Shared launch contract equates bash/exec_command; arbitrary shell strings are not parsed as equivalent.
|
|
83
|
+
premise = { command: effect?.kind === "command" ? effect.cmd.trim() : String(input["command"] ?? input["cmd"] ?? "").trim(),
|
|
84
|
+
exit: effect?.kind === "command" ? effect.exit : null,
|
|
85
|
+
timeout: input["timeout"] ?? null, tty: input["tty"] === true, change: state.change };
|
|
86
|
+
} else {
|
|
87
|
+
const normalized = { ...input };
|
|
88
|
+
if (fileTools.includes(call.name) && typeof normalized["path"] === "string") normalized["path"] = context.resource;
|
|
89
|
+
premise = normalized;
|
|
90
|
+
}
|
|
91
|
+
return { family: context.family, resource: context.resource, version, code: diagnostic.code,
|
|
92
|
+
execution: diagnostic.execution, effects: diagnostic.effects, premise: canonicalValue(premise) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function changedPremises(state: RecoveryState, changed: boolean, resource?: string, version?: string): RecoveryState {
|
|
96
|
+
return { ...state, change: state.change + Number(changed), failures: new Map([...state.failures].filter(([, run]) =>
|
|
97
|
+
run.identity.code === "permission_denied" || !(changed && run.identity.family === "command" ||
|
|
98
|
+
resource !== undefined && version !== undefined && run.identity.resource === resource && run.identity.version !== version))) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Incremental disposable projection; folding the original ancestry yields exactly the same state. */
|
|
102
|
+
export function advanceRecovery(state: RecoveryState, events: readonly LogEvent[], identify: (path: string) => string): RecoveryState {
|
|
103
|
+
let current = state;
|
|
104
|
+
for (const event of events) {
|
|
105
|
+
if (event.t === "user") { current = { ...current, failures: new Map() }; continue; }
|
|
106
|
+
if (event.t === "assistant") {
|
|
107
|
+
const calls = new Map(current.calls);
|
|
108
|
+
for (const block of event.blocks) if (block.type === "tool_use") calls.set(block.call_id, { name: block.name, input: block.input });
|
|
109
|
+
current = { ...current, calls }; continue;
|
|
110
|
+
}
|
|
111
|
+
if (event.t === "tool_invocation") {
|
|
112
|
+
if (event.input !== undefined) current = { ...current, calls: new Map(current.calls).set(event.call_id, { name: event.name, input: event.input }) };
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (event.t === "guidance" && event.reason === "churn") {
|
|
116
|
+
current = { ...current, failures: new Map([...current.failures].map(([group, run]) =>
|
|
117
|
+
[group, event.sources.includes(run.sources[0]!) ? { ...run, notified: true } : run])) }; continue;
|
|
118
|
+
}
|
|
119
|
+
if (event.t === "workspace_observation" && event.changed) {
|
|
120
|
+
current = changedPremises(current, true); continue;
|
|
121
|
+
}
|
|
122
|
+
if (event.t === "checkpoint" && event.phase === "applied") {
|
|
123
|
+
for (const [path, change] of Object.entries(event.changes))
|
|
124
|
+
current = changedPremises(current, change.before?.sha256 !== change.after?.sha256 || change.before?.mode !== change.after?.mode,
|
|
125
|
+
identify(path), change.after === null ? "absent" : change.after.sha256);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (event.t !== "tool_result") continue;
|
|
129
|
+
const call = current.calls.get(event.call_id), calls = new Map(current.calls); calls.delete(event.call_id);
|
|
130
|
+
current = { ...current, calls };
|
|
131
|
+
const effect = event.effect;
|
|
132
|
+
if (effect?.kind === "edit" || effect?.kind === "read") {
|
|
133
|
+
const resource = identify(effect.path), previous = current.versions.get(resource);
|
|
134
|
+
const version = effect.kind === "edit" ? effect.sha256_after : effect.sha256;
|
|
135
|
+
const changed = effect.kind === "edit" ? effect.sha256_before !== effect.sha256_after : digest(previous) && previous !== version;
|
|
136
|
+
current = changedPremises({ ...current, versions: new Map(current.versions).set(resource, version) }, changed,
|
|
137
|
+
resource, digest(version) ? version : undefined);
|
|
138
|
+
}
|
|
139
|
+
if (!call) continue;
|
|
140
|
+
const group = groupKey(call, event, identify);
|
|
141
|
+
if (event.ok) {
|
|
142
|
+
const failures = new Map(current.failures); failures.delete(group);
|
|
143
|
+
current = { ...current, failures }; continue;
|
|
144
|
+
}
|
|
145
|
+
const identity = failureIdentity(call, event, current, identify);
|
|
146
|
+
if (!identity) continue;
|
|
147
|
+
const key = canonicalValue(identity), previous = current.failures.get(group);
|
|
148
|
+
const run: FailureRun = previous?.key === key ? { ...previous, count: previous.count + 1,
|
|
149
|
+
sources: [...previous.sources.slice(0, 2), event.id] }
|
|
150
|
+
: { identity, key, count: 1, sources: [event.id], notified: false };
|
|
151
|
+
current = { ...current, failures: new Map(current.failures).set(group, run) };
|
|
152
|
+
}
|
|
153
|
+
return current;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function recoveryReview(state: RecoveryState, tools?: readonly string[]): Pick<GuidanceEvent, "reason" | "text" | "sources"> | null {
|
|
157
|
+
for (const run of state.failures.values()) {
|
|
158
|
+
if (run.count < RECOVERY_FAILURES || run.notified) continue;
|
|
159
|
+
const identity = run.identity;
|
|
160
|
+
// Recompute trusted instructions; never promote arbitrary tool error text to harness instructions.
|
|
161
|
+
const diagnostic = failureDiagnostic(identity.code, "", { execution: identity.execution, effects: identity.effects });
|
|
162
|
+
const policy = tools === undefined ? diagnostic : scopeFailureGuidance(diagnostic, tools);
|
|
163
|
+
return { reason: "churn", sources: run.sources,
|
|
164
|
+
text: `Harness: ${run.count} attempts share an unchanged failure premise in the recorded facts. ` +
|
|
165
|
+
`Tool family ${JSON.stringify(identity.family)}; resource label (data) ${JSON.stringify(identity.resource.slice(0, 160))}; ` +
|
|
166
|
+
`code ${identity.code}; version ${identity.version}; execution ${identity.execution}; effects ${identity.effects}. ` +
|
|
167
|
+
`${policy.next} ` + (policy.retry === "after_inspection" ? "Inspect actual state for partial effects before any replay. " : "") +
|
|
168
|
+
"Identify which relevant premise changed before another attempt; changing replacement wording is not a new premise. " +
|
|
169
|
+
(identity.version === "unknown" ? "The resource version is unknown; this notice does not prove external state is unchanged. " : "") +
|
|
170
|
+
"This is recovery advice, not a task-failure verdict or authorization to replay. Original result references: " + run.sources.join(", ") + "." };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** R4 / D8: a read descriptor names immutable version premises; it grants no authority. */
|
|
2
|
+
import type { ObservationReceipt } from "./read-observation.ts";
|
|
3
|
+
import { isObservationArtifact } from "./observation-view.ts";
|
|
4
|
+
import type { LogEvent } from "./events.ts";
|
|
5
|
+
|
|
6
|
+
export interface FileIdentity { host: string; workspace: string; resource: string }
|
|
7
|
+
export interface FileReadDescriptor {
|
|
8
|
+
kind: "file_read";
|
|
9
|
+
version: 1;
|
|
10
|
+
source: ObservationReceipt["source"];
|
|
11
|
+
selection: ObservationReceipt["selection"];
|
|
12
|
+
}
|
|
13
|
+
export interface FilePrecondition {
|
|
14
|
+
source: "read_handle" | "expected_sha256" | "session" | "create_only" | "unobserved";
|
|
15
|
+
expected: string | null;
|
|
16
|
+
read_handle?: string;
|
|
17
|
+
}
|
|
18
|
+
export type FilePreconditionDecision = { ok: true; precondition: FilePrecondition } |
|
|
19
|
+
{ ok: false; code: "invalid_input" | "version_mismatch"; message: string; precondition?: FilePrecondition };
|
|
20
|
+
|
|
21
|
+
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
22
|
+
const natural = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
23
|
+
export const isFileDigest = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
24
|
+
|
|
25
|
+
/** Preserve chronology across path aliases; handoff anchors intentionally keep their legacy spelling. */
|
|
26
|
+
export function latestObservedFileVersion(events: readonly LogEvent[], resource: string, identify: (path: string) => string): string | undefined {
|
|
27
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
28
|
+
const event = events[index]!;
|
|
29
|
+
if (event.t !== "tool_result") continue;
|
|
30
|
+
const effect = event.effect;
|
|
31
|
+
if ((effect?.kind === "read" || effect?.kind === "edit") && identify(effect.path) === resource)
|
|
32
|
+
return effect.kind === "read" ? effect.sha256 : effect.sha256_after;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isFileReadDescriptor(value: unknown): value is FileReadDescriptor {
|
|
38
|
+
if (!record(value) || value["kind"] !== "file_read" || value["version"] !== 1) return false;
|
|
39
|
+
const source = value["source"], selection = value["selection"];
|
|
40
|
+
return record(source) && ["host", "workspace", "resource"].every(key => typeof source[key] === "string" && source[key] !== "") &&
|
|
41
|
+
isFileDigest(source["sha256"]) && isObservationArtifact(source["raw"]) && source["raw"].sha256 === source["sha256"] &&
|
|
42
|
+
record(selection) && natural(selection["start"]) && selection["start"] >= 1 && natural(selection["end"]) &&
|
|
43
|
+
natural(selection["total_lines"]) && selection["start"] <= selection["end"] && selection["end"] <= selection["total_lines"];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Explicit premises take precedence; an omitted premise may use the complete log projection. */
|
|
47
|
+
export function checkFilePrecondition(identity: FileIdentity, current: string | null, input: {
|
|
48
|
+
descriptor?: FileReadDescriptor; read_handle?: string; expected_sha256?: string; observed?: string; create_only?: boolean;
|
|
49
|
+
}): FilePreconditionDecision {
|
|
50
|
+
const invalid = (message: string): FilePreconditionDecision => ({ ok: false, code: "invalid_input", message });
|
|
51
|
+
if (input.expected_sha256 !== undefined && !isFileDigest(input.expected_sha256)) return invalid("expected_sha256 must be a lowercase SHA-256 digest");
|
|
52
|
+
if (input.observed !== undefined && !isFileDigest(input.observed)) return invalid("the observed file version is invalid; inspect the original log");
|
|
53
|
+
if (input.create_only && (input.read_handle !== undefined || input.expected_sha256 !== undefined))
|
|
54
|
+
return invalid("create_only cannot be combined with read_handle or expected_sha256");
|
|
55
|
+
if ((input.read_handle === undefined) !== (input.descriptor === undefined)) return invalid("read_handle requires its verified descriptor");
|
|
56
|
+
if (input.descriptor) {
|
|
57
|
+
if (!isFileReadDescriptor(input.descriptor) || !isFileDigest(input.read_handle)) return invalid("invalid read_handle descriptor");
|
|
58
|
+
for (const field of ["host", "workspace", "resource"] as const)
|
|
59
|
+
if (input.descriptor.source[field] !== identity[field]) return invalid(`read_handle names another ${field}; read the intended file here`);
|
|
60
|
+
if (input.expected_sha256 !== undefined && input.expected_sha256 !== input.descriptor.source.sha256)
|
|
61
|
+
return invalid("read_handle and expected_sha256 name different versions");
|
|
62
|
+
}
|
|
63
|
+
const precondition: FilePrecondition = input.create_only ? { source: "create_only", expected: null }
|
|
64
|
+
: input.descriptor ? { source: "read_handle", expected: input.descriptor.source.sha256, read_handle: input.read_handle! }
|
|
65
|
+
: input.expected_sha256 !== undefined ? { source: "expected_sha256", expected: input.expected_sha256 }
|
|
66
|
+
: input.observed !== undefined ? { source: "session", expected: input.observed }
|
|
67
|
+
: { source: "unobserved", expected: null };
|
|
68
|
+
if (precondition.source !== "unobserved" && precondition.expected !== current)
|
|
69
|
+
return { ok: false, code: "version_mismatch", precondition,
|
|
70
|
+
message: `file version changed; expected ${precondition.expected ?? "absent"}, current sha256: ${current ?? "absent"}. Read the current file before retrying.` };
|
|
71
|
+
return { ok: true, precondition };
|
|
72
|
+
}
|