@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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Q24: one controller per live Session, shared by the TUI and structured adapters. */
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { goalView, contextLens, type GoalView, type ContextLens } from "../core/control-view.ts";
|
|
5
|
+
import { DEFAULT_CONTEXT_POLICY } from "../core/context-policy.ts";
|
|
6
|
+
import { userInputData, type UserInput } from "../core/images.ts";
|
|
7
|
+
import type { Phase } from "../core/queue.ts";
|
|
8
|
+
import type { PermissionRequest } from "../core/permissions.ts";
|
|
9
|
+
import type { ExecutionOutcome } from "../core/outcome.ts";
|
|
10
|
+
import type { LogEvent } from "../core/events.ts";
|
|
11
|
+
import { readNodeHistory } from "../store/restore.ts";
|
|
12
|
+
import type { Session, LoopEvent } from "./loop.ts";
|
|
13
|
+
import type { ApprovalChoice } from "./tools.ts";
|
|
14
|
+
|
|
15
|
+
export type ControlNotice = { method: string; params: { threadId: string; streamId: string; sequence: number; turnId?: string; [key: string]: unknown } };
|
|
16
|
+
export type PendingApproval = { id: string; turnId: string; request: PermissionRequest };
|
|
17
|
+
export type ControlSnapshot = {
|
|
18
|
+
version: "xeno-control/0.1"; threadId: string; streamId: string; sequence: number; node: number;
|
|
19
|
+
activeTurnId: string | null; goal: GoalView | null; context: ContextLens;
|
|
20
|
+
waiting: "permission" | "question" | "auth" | null; approvals: PendingApproval[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class SessionControl {
|
|
24
|
+
readonly session: Session;
|
|
25
|
+
readonly threadId: string;
|
|
26
|
+
readonly streamId = randomUUID();
|
|
27
|
+
private sequence = 0;
|
|
28
|
+
private listeners = new Set<(event: ControlNotice) => void>();
|
|
29
|
+
private active: { id: string; abort: AbortController; queue: UserInput[] } | null = null;
|
|
30
|
+
private task: Promise<void> | null = null;
|
|
31
|
+
private approvals = new Map<string, { data: PendingApproval; resolve: (choice: ApprovalChoice) => void }>();
|
|
32
|
+
private waiting: ControlSnapshot["waiting"] = null;
|
|
33
|
+
private changing = false;
|
|
34
|
+
private inputSource: ((phase: Phase) => Promise<UserInput | null>) | undefined;
|
|
35
|
+
|
|
36
|
+
constructor(session: Session) {
|
|
37
|
+
this.session = session;
|
|
38
|
+
// Same identity rule as provider affinity; no new durable identity store (D8).
|
|
39
|
+
this.threadId = createHash("sha256").update(resolve(session.config.logDir)).digest("hex");
|
|
40
|
+
session.subscribeRecords(item => this.emit("item/completed", { item }));
|
|
41
|
+
}
|
|
42
|
+
get activeTurnId(): string | null { return this.active?.id ?? null; }
|
|
43
|
+
get busy(): boolean { return this.changing || this.active !== null || this.session.running; }
|
|
44
|
+
|
|
45
|
+
subscribe(listener: (event: ControlNotice) => void): () => void {
|
|
46
|
+
this.listeners.add(listener); return () => { this.listeners.delete(listener); };
|
|
47
|
+
}
|
|
48
|
+
/** The attached editor owns retractable drafts even when an adapter starts the turn. */
|
|
49
|
+
attachInput(pull: (phase: Phase) => Promise<UserInput | null>): () => void {
|
|
50
|
+
if (this.inputSource) throw Error("the session already has an attached input owner");
|
|
51
|
+
this.inputSource = pull;
|
|
52
|
+
return () => { if (this.inputSource === pull) this.inputSource = undefined; };
|
|
53
|
+
}
|
|
54
|
+
private emit(method: string, data: Record<string, unknown> = {}): void {
|
|
55
|
+
const event: ControlNotice = { method, params: { ...data, threadId: this.threadId, streamId: this.streamId,
|
|
56
|
+
sequence: ++this.sequence, ...(this.active ? { turnId: this.active.id } : {}) } };
|
|
57
|
+
for (const listener of this.listeners) {
|
|
58
|
+
// Observers are outside the evidence owner: mutations and failures cannot alter execution.
|
|
59
|
+
try { listener(structuredClone(event)); } catch { /* the transport owns delivery errors */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async snapshot(observe = true): Promise<ControlSnapshot> {
|
|
63
|
+
if (observe && !this.busy) await this.session.acceptanceState();
|
|
64
|
+
const node = this.session.state.node;
|
|
65
|
+
const history = await readNodeHistory(this.session.config.logDir, node, undefined, "context");
|
|
66
|
+
if (!history.ok) throw Error(history.error);
|
|
67
|
+
if (node !== this.session.state.node) throw Error("session changed while reading; refresh the snapshot");
|
|
68
|
+
const s = this.session;
|
|
69
|
+
// Ancestors are sealed. The live segment must share the cursor's publication
|
|
70
|
+
// boundary: a disk read can finish between append and in-memory publication.
|
|
71
|
+
const parts = history.nodes.map(part => part.node === node ? { node, events: structuredClone(s.state.events) } : part);
|
|
72
|
+
const projectedGoal = goalView(parts);
|
|
73
|
+
const goal = this.active && projectedGoal?.state.request.event !== this.active.id ? null : projectedGoal;
|
|
74
|
+
return { version: "xeno-control/0.1", threadId: this.threadId, streamId: this.streamId, sequence: this.sequence, node,
|
|
75
|
+
activeTurnId: this.activeTurnId, goal, waiting: this.waiting,
|
|
76
|
+
approvals: structuredClone([...this.approvals.values()].map(item => item.data)),
|
|
77
|
+
context: contextLens({ ...s.contextInput(), anchor: s.state.anchor, policy: s.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
|
|
78
|
+
contextWindow: s.provider.config.spec.contextWindow, outputReserve: s.provider.config.maxTokens,
|
|
79
|
+
...(s.config.counter ? { counter: s.config.counter } : {}), ...(s.config.boundary ? { boundary: s.config.boundary } : {}) }) };
|
|
80
|
+
}
|
|
81
|
+
async items(): Promise<{ node: number; event: LogEvent }[]> {
|
|
82
|
+
const node = this.session.state.node;
|
|
83
|
+
const history = await readNodeHistory(this.session.config.logDir, node, undefined, "context");
|
|
84
|
+
if (!history.ok) throw Error(history.error);
|
|
85
|
+
if (node !== this.session.state.node) throw Error("session changed while reading; refresh the snapshot");
|
|
86
|
+
return history.nodes.flatMap(part => (part.node === node ? structuredClone(this.session.state.events) : part.events)
|
|
87
|
+
.map(event => ({ node: part.node, event })));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Both local and remote turns pass through this iterator; Session remains the execution owner. */
|
|
91
|
+
async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null>): AsyncGenerator<LoopEvent> {
|
|
92
|
+
if (this.busy) throw Error("a turn is already running");
|
|
93
|
+
const active = { id: this.session.clock.nextId(), abort: new AbortController(), queue: [] as UserInput[] };
|
|
94
|
+
this.active = active;
|
|
95
|
+
const signals = signal ? AbortSignal.any([signal, active.abort.signal]) : active.abort.signal;
|
|
96
|
+
let outcome: ExecutionOutcome | undefined;
|
|
97
|
+
try {
|
|
98
|
+
this.emit("turn/started");
|
|
99
|
+
const steering = async (phase: Phase): Promise<UserInput | null> => {
|
|
100
|
+
const external = active.queue.splice(0), local = await (pull ?? this.inputSource)?.(phase);
|
|
101
|
+
if (local) external.push(local);
|
|
102
|
+
const data = external.map(value => userInputData(value));
|
|
103
|
+
const text = data.map(value => value.text).join("\n\n"), images = data.flatMap(value => value.images ?? []);
|
|
104
|
+
return images.length ? { text, images } : text || null;
|
|
105
|
+
};
|
|
106
|
+
for await (const event of this.session.send(input, signals, steering, active.id)) {
|
|
107
|
+
this.emit("turn/event", { event });
|
|
108
|
+
if (event.kind === "done") outcome = event.outcome;
|
|
109
|
+
yield event;
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
// Raw provider errors may contain credentials. The owner can report a sanitized error separately.
|
|
113
|
+
this.emit("turn/error", { message: "turn failed; inspect the session outcome" });
|
|
114
|
+
throw error;
|
|
115
|
+
} finally {
|
|
116
|
+
for (const approval of [...this.approvals.values()]) approval.resolve("deny");
|
|
117
|
+
this.waiting = null;
|
|
118
|
+
// A thrown provider error also appends an outcome. Clients must be able to discover it.
|
|
119
|
+
try {
|
|
120
|
+
if (!outcome) {
|
|
121
|
+
const state = (await this.snapshot(false)).goal?.state;
|
|
122
|
+
if (state?.request.event === active.id) outcome = state.execution;
|
|
123
|
+
}
|
|
124
|
+
} catch { /* unreadable storage stays unknown, never an invented completion */ }
|
|
125
|
+
this.emit("turn/completed", { ...(outcome ? { outcome } : {}), steeringUndelivered: active.queue.length });
|
|
126
|
+
this.active = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
start(input: UserInput): string {
|
|
130
|
+
if (this.busy) throw Error("a turn is already running");
|
|
131
|
+
this.task = (async () => { for await (const _event of this.send(input)) { /* notices are the client stream */ } })();
|
|
132
|
+
void this.task.catch(() => {});
|
|
133
|
+
return this.active!.id;
|
|
134
|
+
}
|
|
135
|
+
async whenIdle(): Promise<void> { await this.task; await this.session.whenIdle(); }
|
|
136
|
+
private current(turnId: string): NonNullable<SessionControl["active"]> {
|
|
137
|
+
if (!this.active || this.active.id !== turnId) throw Error("stale or unknown turnId");
|
|
138
|
+
return this.active;
|
|
139
|
+
}
|
|
140
|
+
steer(turnId: string, input: UserInput): void {
|
|
141
|
+
const turn = this.current(turnId);
|
|
142
|
+
// Assumed ingress bound, not an execution policy. Keep a client from queuing unbounded work.
|
|
143
|
+
if (turn.queue.length >= 32) throw Error("steering queue is full");
|
|
144
|
+
turn.queue.push(structuredClone(input));
|
|
145
|
+
this.emit("turn/steeringQueued", { count: turn.queue.length });
|
|
146
|
+
}
|
|
147
|
+
interrupt(turnId: string): void {
|
|
148
|
+
this.current(turnId).abort.abort();
|
|
149
|
+
for (const approval of [...this.approvals.values()]) approval.resolve("deny");
|
|
150
|
+
this.session.cancel();
|
|
151
|
+
}
|
|
152
|
+
setWaiting(kind: ControlSnapshot["waiting"]): void {
|
|
153
|
+
this.waiting = this.approvals.size ? "permission" : kind;
|
|
154
|
+
this.emit("thread/statusChanged", { waiting: this.waiting });
|
|
155
|
+
}
|
|
156
|
+
ask(request: PermissionRequest, present?: (id: string, resolve: (choice: ApprovalChoice) => void) => (() => void)): Promise<ApprovalChoice> {
|
|
157
|
+
if (!this.active) return Promise.resolve("deny");
|
|
158
|
+
const data = { id: randomUUID(), turnId: this.active.id, request: structuredClone(request) };
|
|
159
|
+
return new Promise(resolveChoice => {
|
|
160
|
+
let close: (() => void) | undefined;
|
|
161
|
+
const resolveOnce = (choice: ApprovalChoice) => {
|
|
162
|
+
if (!this.approvals.delete(data.id)) return;
|
|
163
|
+
close?.(); this.waiting = this.approvals.size ? "permission" : null;
|
|
164
|
+
this.emit("approval/resolved", { approvalId: data.id }); resolveChoice(choice);
|
|
165
|
+
};
|
|
166
|
+
this.approvals.set(data.id, { data, resolve: resolveOnce }); this.waiting = "permission";
|
|
167
|
+
this.emit("approval/request", { approval: data });
|
|
168
|
+
if (this.approvals.has(data.id)) {
|
|
169
|
+
try { close = present?.(data.id, resolveOnce); }
|
|
170
|
+
catch { resolveOnce("deny"); }
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
respond(turnId: string, id: string, choice: ApprovalChoice): void {
|
|
175
|
+
this.current(turnId);
|
|
176
|
+
const approval = this.approvals.get(id);
|
|
177
|
+
if (!approval || approval.data.turnId !== turnId) throw Error("stale or unknown approvalId");
|
|
178
|
+
approval.resolve(choice);
|
|
179
|
+
}
|
|
180
|
+
denyApprovals(): void { for (const approval of [...this.approvals.values()]) approval.resolve("deny"); }
|
|
181
|
+
async accept(requestId: string, criterion: string): Promise<void> {
|
|
182
|
+
if (this.busy) throw Error("acceptance requires an idle session");
|
|
183
|
+
this.changing = true;
|
|
184
|
+
try {
|
|
185
|
+
const state = await this.session.requestState();
|
|
186
|
+
if (state?.request.event !== requestId) throw Error("stale or unknown requestId");
|
|
187
|
+
await this.session.acceptCriterion(criterion);
|
|
188
|
+
this.emit("thread/statusChanged");
|
|
189
|
+
} finally { this.changing = false; }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const controls = new WeakMap<Session, SessionControl>();
|
|
194
|
+
export function sessionControl(session: Session): SessionControl {
|
|
195
|
+
let control = controls.get(session);
|
|
196
|
+
if (!control) { control = new SessionControl(session); controls.set(session, control); }
|
|
197
|
+
return control;
|
|
198
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Q.19: fixed workloads and admission overlays share production tools and the v2 auditor. */
|
|
2
|
+
import { queryEvalVerifier } from "./query-eval-verifier.ts";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { seededRandom, type EvalJob } from "../core/experiment.ts";
|
|
6
|
+
import { TOOL_MODE_TASKS, type ToolModeTask, type ToolModeMetrics } from "../core/tool-mode-protocol.ts";
|
|
7
|
+
import { queryEvalContract } from "../core/query-eval-contract.ts";
|
|
8
|
+
import { inputError } from "../core/tool-contract.ts";
|
|
9
|
+
import type { LogEvent, ArtifactRef } from "../core/events.ts";
|
|
10
|
+
import { readObservation } from "../store/observations.ts";
|
|
11
|
+
import { TOOL_DEFINITIONS, modelToolSchema, type ToolContext } from "./tools.ts";
|
|
12
|
+
import type { ToolSchema } from "./provider.ts";
|
|
13
|
+
import type { ComparisonBackend } from "./harness-comparison.ts";
|
|
14
|
+
import type { EvalFixture } from "./harness-eval.ts";
|
|
15
|
+
|
|
16
|
+
const digest = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
17
|
+
export const TOOL_MODE_SYSTEM = "Solve the supplied data task using query over the complete source files. Preserve inputs and verifier. " +
|
|
18
|
+
"Use the declared query syntax. Inspect all failures and original evidence. " +
|
|
19
|
+
"Write only answer.txt, run bun run gate, and report the observed outcome. Do not delegate.";
|
|
20
|
+
export interface ToolModeFixture {
|
|
21
|
+
answer: string;
|
|
22
|
+
queries: { path: string; json: Record<string, unknown>; sql: Record<string, unknown> }[];
|
|
23
|
+
}
|
|
24
|
+
export function toolModeFixture(seed: number, task: ToolModeTask, repeat: number): EvalFixture {
|
|
25
|
+
const random = seededRandom(seed + TOOL_MODE_TASKS.indexOf(task) * 1000 + repeat);
|
|
26
|
+
const count = task === "tool-batch" ? 8 : 1, rows = task === "tool-small" ? 1 : task === "tool-batch" ? 25 : 9000;
|
|
27
|
+
const files: Record<string, string> = {}, queries: ToolModeFixture["queries"] = [], expected: { path: string; rows: unknown[] }[] = [];
|
|
28
|
+
for (let i = 0; i < count; i++) {
|
|
29
|
+
const path = "data-" + i + ".jsonl", data = Array.from({ length: rows }, (_, row) => ({
|
|
30
|
+
team: "team" + row % 4, enabled: row % 3 !== 0, amount: 1 + Math.floor(random() * 97),
|
|
31
|
+
}));
|
|
32
|
+
files[path] = data.map(row => JSON.stringify(row)).join("\n") + "\n";
|
|
33
|
+
const base = { paths: [path], format: "jsonl" };
|
|
34
|
+
if (task === "tool-small") {
|
|
35
|
+
queries.push({ path, json: { ...base, select: ["amount"] }, sql: { ...base, sql: "SELECT amount FROM source" } });
|
|
36
|
+
expected.push({ path, rows: [{ amount: data[0]!.amount }] });
|
|
37
|
+
} else if (task === "tool-batch") {
|
|
38
|
+
queries.push({ path, json: { ...base, aggregate: [{ op: "sum", field: "amount", as: "total" }] }, sql: { ...base, sql: "SELECT SUM(amount) AS total FROM source" } });
|
|
39
|
+
expected.push({ path, rows: [{ total: data.reduce((n, row) => n + row.amount, 0) }] });
|
|
40
|
+
} else {
|
|
41
|
+
queries.push({ path, json: { ...base, where: [{ field: "enabled", op: "eq", value: true }], group_by: ["team"],
|
|
42
|
+
aggregate: [{ op: "sum", field: "amount", as: "total" }], order_by: [{ field: "team" }] },
|
|
43
|
+
sql: { ...base, sql: "SELECT team, SUM(amount) AS total FROM source WHERE enabled = TRUE GROUP BY team ORDER BY team" } });
|
|
44
|
+
// Independent fixture arithmetic; do not ask the query kernel to grade itself.
|
|
45
|
+
expected.push({ path, rows: Array.from({ length: 4 }, (_, n) => {
|
|
46
|
+
const team = "team" + n;
|
|
47
|
+
return { team, total: data.filter(row => row.enabled && row.team === team).reduce((sum, row) => sum + row.amount, 0) };
|
|
48
|
+
}) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const answer = JSON.stringify(expected) + "\n", answerSha256 = digest(answer);
|
|
52
|
+
files["package.json"] = JSON.stringify({ scripts: { gate: "bun verify.ts" } }) + "\n";
|
|
53
|
+
const contract = queryEvalContract(task, queries.map(query => query.path), answerSha256);
|
|
54
|
+
files["verify.ts"] = queryEvalVerifier(contract);
|
|
55
|
+
return { task, repeat, files, digests: Object.fromEntries(Object.entries(files).map(([path, body]) => [path, digest(body)])),
|
|
56
|
+
answerSha256, prompts: [contract.instruction], toolMode: { answer, queries } };
|
|
57
|
+
}
|
|
58
|
+
export type ToolModeSchemas = Record<"json" | "sql", ToolSchema[]>;
|
|
59
|
+
export function toolModeSchemas(): ToolModeSchemas {
|
|
60
|
+
const build = (syntax: "json" | "sql"): ToolSchema[] => {
|
|
61
|
+
const names = ["read", "query", "history", "write", "exec_command", "write_stdin", "note"];
|
|
62
|
+
return names.map(name => {
|
|
63
|
+
const definition = TOOL_DEFINITIONS.find(tool => tool.name === name)!;
|
|
64
|
+
if (name !== "query") return modelToolSchema(definition, names);
|
|
65
|
+
const schema = structuredClone(definition.input_schema) as ToolSchema["input_schema"] & { additionalProperties?: boolean };
|
|
66
|
+
const jsonKeys = ["where", "select", "group_by", "aggregate", "order_by"];
|
|
67
|
+
for (const key of syntax === "json" ? ["sql"] : jsonKeys) delete schema.properties[key];
|
|
68
|
+
if (syntax === "sql") schema.required.push("sql");
|
|
69
|
+
schema.additionalProperties = false;
|
|
70
|
+
return { name, description: definition.description + " This trial admits only " + syntax.toUpperCase() + " query syntax.", input_schema: schema };
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
return { json: build("json"), sql: build("sql") };
|
|
74
|
+
}
|
|
75
|
+
export const toolModeSchemaKey = (job: EvalJob): keyof ToolModeSchemas => job.policy.querySyntax === "sql" ? "sql" : "json";
|
|
76
|
+
/** Schema visibility is not authority: the same frozen restriction runs before every admitted query. */
|
|
77
|
+
export function toolModeHooks(schemas: readonly ToolSchema[]): NonNullable<ToolContext["extensionHooks"]> {
|
|
78
|
+
const query = schemas.find(schema => schema.name === "query")!;
|
|
79
|
+
return { async before(name, input) {
|
|
80
|
+
if (name !== "query") return undefined;
|
|
81
|
+
const error = inputError(query.input_schema, input);
|
|
82
|
+
const properties = (query.input_schema as { properties: Record<string, unknown> }).properties;
|
|
83
|
+
const extra = input && typeof input === "object" ? Object.keys(input).find(key => !Object.hasOwn(properties, key)) : undefined;
|
|
84
|
+
return error || extra ? "Frozen query syntax restriction: " + (error ?? "unsupported field " + extra) : undefined;
|
|
85
|
+
}, async event() {} };
|
|
86
|
+
}
|
|
87
|
+
/** Oracle driver tests the machinery; neither its answers nor call choices are model evidence. */
|
|
88
|
+
export function toolModeBackend(input: EvalFixture): ComparisonBackend {
|
|
89
|
+
const fixture = input.toolMode!;
|
|
90
|
+
let step = 0;
|
|
91
|
+
return async payload => {
|
|
92
|
+
const declarations = payload["tools"] as { function: { name: string; parameters: { properties: Record<string, unknown> } } }[];
|
|
93
|
+
const syntax = declarations.find(tool => tool.function.name === "query")!.function.parameters.properties["sql"] ? "sql" : "json";
|
|
94
|
+
const calls: { name: string; input: unknown }[] = [], index = step++;
|
|
95
|
+
if (index === 0) {
|
|
96
|
+
for (const query of fixture.queries) calls.push({ name: "query", input: query[syntax] });
|
|
97
|
+
}
|
|
98
|
+
if (index === 1) {
|
|
99
|
+
const messages = payload["messages"] as { role: string; content?: unknown }[];
|
|
100
|
+
const observed = messages.filter(message => message.role === "tool").flatMap(message => {
|
|
101
|
+
const value = JSON.parse(String(message.content));
|
|
102
|
+
return [value];
|
|
103
|
+
}) as { sources: { path: string }[]; rows: unknown[] }[];
|
|
104
|
+
const answer = fixture.queries.map(query => {
|
|
105
|
+
const value = observed.find(value => value.sources?.length === 1 && value.sources[0]!.path === query.path);
|
|
106
|
+
if (!value) throw Error("mechanical driver lacks a successful source query receipt");
|
|
107
|
+
return { path: query.path, rows: value.rows };
|
|
108
|
+
});
|
|
109
|
+
calls.push({ name: "write", input: { path: "answer.txt", content: JSON.stringify(answer) + "\n" } });
|
|
110
|
+
}
|
|
111
|
+
if (index === 2) calls.push({ name: "exec_command", input: { cmd: "bun run gate", yield_time_ms: 1000 } });
|
|
112
|
+
return { id: "tool-mode-" + index, model: "eval-mechanical", choices: [{ index: 0, message: {
|
|
113
|
+
role: "assistant", content: calls.length ? null : "Mechanical protocol fixture finished.",
|
|
114
|
+
...(calls.length ? { tool_calls: calls.map((call, i) => ({ id: "mode-" + index + "-" + i, type: "function",
|
|
115
|
+
function: { name: call.name, arguments: JSON.stringify(call.input) } })) } : {}),
|
|
116
|
+
}, finish_reason: calls.length ? "tool_calls" : "stop" }], usage: { prompt_tokens: 200, completion_tokens: 32, prompt_cache_hit_tokens: 0 } };
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export async function toolModeMetrics(directory: string, input: EvalFixture, events: readonly LogEvent[], elapsedMs: number): Promise<ToolModeMetrics> {
|
|
120
|
+
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) throw Error("missing tool mode elapsed observation");
|
|
121
|
+
const fixture = input.toolMode!, invocations = new Map(events.filter(event => event.t === "tool_invocation").map(event => [event.id, event]));
|
|
122
|
+
const sources = new Set<string>();
|
|
123
|
+
const original = async (ref: ArtifactRef): Promise<unknown> => {
|
|
124
|
+
const parts: Uint8Array[] = [];
|
|
125
|
+
for (let offset = 0;;) {
|
|
126
|
+
const page = await readObservation(join(directory, "observations"), ref.sha256, offset, 1024 * 1024);
|
|
127
|
+
parts.push(Buffer.from(page.base64, "base64"));
|
|
128
|
+
if (page.next === null) break;
|
|
129
|
+
offset = page.next;
|
|
130
|
+
}
|
|
131
|
+
return JSON.parse(Buffer.concat(parts).toString("utf8"));
|
|
132
|
+
};
|
|
133
|
+
for (const event of events) if (event.t === "tool_result" && event.ok && event.invocation_ref) {
|
|
134
|
+
const invocation = invocations.get(event.invocation_ref.event);
|
|
135
|
+
if (!invocation || invocation.call_id !== event.call_id || invocation.name !== "query") continue;
|
|
136
|
+
const value = event.data_ref ? await original(event.data_ref) : event.content_ref ? await original(event.content_ref) : event.data;
|
|
137
|
+
if (!value || typeof value !== "object" || !("sources" in value) || !Array.isArray(value.sources)) continue;
|
|
138
|
+
for (const source of value.sources as { path?: string; sha256?: string }[]) {
|
|
139
|
+
if (typeof source.path === "string" && fixture.queries.some(query => query.path === source.path) && input.digests[source.path] === source.sha256) sources.add(source.path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const calls = [...invocations.values()], sourcePaths = [...sources].sort(), requiredSourcePaths = fixture.queries.map(query => query.path).sort();
|
|
143
|
+
return { elapsedMs, queryCalls: calls.filter(call => call.name === "query").length,
|
|
144
|
+
primitiveCalls: calls.length, sourcePaths, requiredSourcePaths,
|
|
145
|
+
sourceComplete: requiredSourcePaths.every(path => sources.has(path)) };
|
|
146
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Pi A / O.5: ordered admission, bounded readers and an exclusive write barrier. */
|
|
2
|
+
import { schedulingWindow } from "../core/scheduling.ts";
|
|
3
|
+
|
|
4
|
+
type Mode = "parallel" | "exclusive";
|
|
5
|
+
type Job = { mode: Mode; prepare(): Promise<() => Promise<unknown>>; resolve(value: unknown): void; reject(error: unknown): void };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A composing call owns an exclusive slot in its parent queue and gives its
|
|
9
|
+
* children a fresh queue. Re-entering the held parent slot would deadlock.
|
|
10
|
+
* Only exclusive tools may compose; concurrent readers therefore cannot each
|
|
11
|
+
* multiply the session's parallel limit with another queue.
|
|
12
|
+
*/
|
|
13
|
+
export class ToolQueue {
|
|
14
|
+
private readonly limit: number;
|
|
15
|
+
private readonly pending: Job[] = [];
|
|
16
|
+
private active = 0;
|
|
17
|
+
private exclusive = false;
|
|
18
|
+
private pumping = false;
|
|
19
|
+
private readonly idle = new Set<() => void>();
|
|
20
|
+
private closed = false;
|
|
21
|
+
|
|
22
|
+
constructor(limit: number) {
|
|
23
|
+
schedulingWindow([], 0, limit);
|
|
24
|
+
this.limit = limit;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
submit<T>(mode: Mode, prepare: () => Promise<() => Promise<T>>): Promise<T> {
|
|
28
|
+
if (this.closed) return Promise.reject(new Error("tool scope is closed; no invocation started"));
|
|
29
|
+
const result = new Promise<T>((resolve, reject) => {
|
|
30
|
+
this.pending.push({ mode, prepare, resolve: value => resolve(value as T), reject });
|
|
31
|
+
});
|
|
32
|
+
this.kick();
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private kick(): void { if (!this.pumping) { this.pumping = true; void this.pump(); } }
|
|
37
|
+
|
|
38
|
+
private async pump(): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
while (this.pending.length > 0) {
|
|
41
|
+
const next = this.pending[0]!;
|
|
42
|
+
const occupied: Mode[] = Array.from({ length: this.active }, () => this.exclusive ? "exclusive" : "parallel");
|
|
43
|
+
if (schedulingWindow([...occupied, next.mode], 0, this.limit) <= this.active) break;
|
|
44
|
+
const job = this.pending.shift()!;
|
|
45
|
+
this.active++; this.exclusive = job.mode === "exclusive";
|
|
46
|
+
const finished = () => { this.active--; if (job.mode === "exclusive") this.exclusive = false; this.kick(); };
|
|
47
|
+
try {
|
|
48
|
+
const execute = await job.prepare();
|
|
49
|
+
void Promise.resolve().then(execute).then(job.resolve, job.reject).finally(finished);
|
|
50
|
+
} catch (error) { job.reject(error); this.active--; this.exclusive = false; }
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
this.pumping = false;
|
|
54
|
+
if (this.active === 0 && this.pending.length === 0) { for (const resolve of this.idle) resolve(); this.idle.clear(); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A generator can retain its slot across admission/display yields without executing early. */
|
|
59
|
+
acquire(mode: Mode): Promise<() => void> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
let release!: () => void;
|
|
62
|
+
const done = new Promise<void>(finish => { release = finish; });
|
|
63
|
+
void this.submit(mode, async () => async () => { resolve(release); await done; }).catch(reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
whenIdle(): Promise<void> {
|
|
68
|
+
if (!this.pumping && this.active === 0 && this.pending.length === 0) return Promise.resolve();
|
|
69
|
+
return new Promise(resolve => this.idle.add(resolve));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Accepted jobs drain even when their caller forgot to await their promises. */
|
|
73
|
+
async close(): Promise<void> {
|
|
74
|
+
this.closed = true;
|
|
75
|
+
await this.whenIdle();
|
|
76
|
+
}
|
|
77
|
+
}
|