@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
package/src/runtime/tools.ts
CHANGED
|
@@ -14,34 +14,43 @@
|
|
|
14
14
|
* is what lets deriveHandoff verify a completion claim against evidence rather
|
|
15
15
|
* than believing it.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* workspace-snapshot cross-check to the layer above.
|
|
17
|
+
* P.3 separates shell execution facts from recoverable file changes. Commands
|
|
18
|
+
* observe a bounded source scope for verification; optional file checkpoints
|
|
19
|
+
* belong to the admitted execution boundary, not session startup or turn end.
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
|
-
import { readFile,
|
|
22
|
+
import { readFile, stat } from "node:fs/promises";
|
|
24
23
|
import { createHash } from "node:crypto";
|
|
25
|
-
import {
|
|
24
|
+
import { hostname } from "node:os";
|
|
25
|
+
import { resolve, relative, isAbsolute, join, sep } from "node:path";
|
|
26
26
|
import type { Effect, LineRange, ArtifactRef } from "../core/events.ts";
|
|
27
|
-
import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
|
|
27
|
+
import { archiveObservation, readObservation, type ArchivedObservation } from "../store/observations.ts";
|
|
28
28
|
import { blobAddress } from "../store/blobs.ts";
|
|
29
29
|
import { commandHeader, headTailView } from "../core/observation-view.ts";
|
|
30
30
|
import {
|
|
31
|
-
DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, runningSessions, startSession,
|
|
31
|
+
DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, closedSession, runningSessions, startSession, pollSession,
|
|
32
32
|
} from "./exec-sessions.ts";
|
|
33
|
-
import {
|
|
33
|
+
import { recoverProcessReceipt, type ProcessReceipt, type latestProcessReceipt } from "../core/process-receipt.ts";
|
|
34
|
+
import { observeCommand, observeArgvCommand } from "../store/command.ts";
|
|
34
35
|
import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
36
|
+
import { commandCompletion, commandTimeout } from "../core/command-contract.ts";
|
|
37
|
+
import { previewReadObservation, readObservationText, type ObservationReceipt } from "../core/read-observation.ts";
|
|
38
|
+
import { checkFilePrecondition, isFileDigest, isFileReadDescriptor, type FileReadDescriptor, type FilePrecondition } from "../core/file-precondition.ts";
|
|
39
|
+
import { previewSearchObservation, searchObservationText } from "../core/search-observation.ts";
|
|
35
40
|
import { waitForAdmission } from "./execution-control.ts";
|
|
36
41
|
import { assertNever } from "../core/exhaustive.ts";
|
|
42
|
+
import { artifactRecovery, toolDescription, type ToolGuidance } from "../core/prompt-policy.ts";
|
|
37
43
|
import { classifyGate } from "../core/evidence.ts";
|
|
38
44
|
import { readBounds } from "../core/read-bounds.ts";
|
|
39
|
-
import { closestName, inputError, isJsonValue, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
|
|
45
|
+
import { closestName, inputError, isJsonValue, minimalInput, type JsonValue, type ToolDiagnostic } from "../core/tool-contract.ts";
|
|
46
|
+
import { failureDiagnostic, failureFromError, scopeFailureGuidance, NOT_STARTED, READ_ATTEMPTED, UNKNOWN_EFFECT, type FailureFacts } from "../core/tool-failure.ts";
|
|
40
47
|
import { planEdits, type EditSpec } from "../core/edit-plan.ts";
|
|
41
48
|
import { toView } from "../core/text-normalize.ts";
|
|
42
49
|
import { repairToolInput } from "../core/tool-input.ts";
|
|
43
50
|
import { HISTORY_SECTIONS } from "../core/history.ts";
|
|
44
|
-
import {
|
|
51
|
+
import { RECOVERY_SECTIONS } from "../core/recovery-sources.ts";
|
|
52
|
+
import { queryData } from "../core/data-query.ts";
|
|
53
|
+
import { parseQueryInput, MAX_SQL_CHARS, SQL_QUERY_HELP } from "../core/sql-query.ts";
|
|
45
54
|
import { readQuerySources } from "../store/query-source.ts";
|
|
46
55
|
import {
|
|
47
56
|
askWithoutTtyMessage,
|
|
@@ -52,14 +61,25 @@ import {
|
|
|
52
61
|
type PermissionRule,
|
|
53
62
|
} from "../core/permissions.ts";
|
|
54
63
|
import type { ToolSchema } from "./provider.ts";
|
|
64
|
+
import type { HistoryCache } from "./history-cache.ts";
|
|
55
65
|
import type { ImageRef } from "../core/images.ts";
|
|
56
66
|
import { imageDescription } from "../core/images.ts";
|
|
57
67
|
import { attachImageBytes } from "./images.ts";
|
|
58
68
|
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
59
|
-
import { find as findFiles, grep, ls } from "../store/search.ts";
|
|
69
|
+
import { find as findFiles, grep, ls, MAX_FILE_BYTES, MAX_SEARCH_CAPTURE_BYTES, type SearchResult } from "../store/search.ts";
|
|
60
70
|
import { scanWorkspace, workspaceVersion, DEFAULT_IGNORE } from "../store/workspace.ts";
|
|
71
|
+
import { observeWorkspace } from "../store/workspace-observation.ts";
|
|
72
|
+
import { readEditableFile, replaceFile } from "../store/file-checkpoints.ts";
|
|
73
|
+
import type { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
74
|
+
import { environmentProbeCommand } from "../core/environment.ts";
|
|
75
|
+
import { observeEnvironment } from "./environment-tools.ts";
|
|
76
|
+
import { beginCommandArtifacts, prepareArtifactArguments, commandArtifactSummary } from "./command-artifacts.ts";
|
|
77
|
+
import { MAX_COMMAND_ARTIFACTS } from "../core/command-artifacts.ts";
|
|
78
|
+
import { checkSourceCancellation } from "../store/source-cancellation.ts";
|
|
61
79
|
|
|
62
80
|
export interface ToolResult {
|
|
81
|
+
/** Broker-owned identity, returned to a composer only after the original receipt commits. */
|
|
82
|
+
invocation?: { call_id: string; event: string };
|
|
63
83
|
images?: readonly ImageRef[];
|
|
64
84
|
/** What the model sees. */
|
|
65
85
|
content: string;
|
|
@@ -70,18 +90,50 @@ export interface ToolResult {
|
|
|
70
90
|
content_sha256?: string;
|
|
71
91
|
/** Observed bytes without presentation/version metadata, for D9 novelty. */
|
|
72
92
|
observation_sha256?: string;
|
|
93
|
+
observation?: ObservationReceipt;
|
|
73
94
|
outcome?: ExecutionOutcome;
|
|
74
95
|
data?: JsonValue;
|
|
75
96
|
diagnostic?: ToolDiagnostic;
|
|
97
|
+
warnings?: readonly ToolDiagnostic[];
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
export interface ToolContext {
|
|
101
|
+
/** R10: model-visible names constrain new advice without granting tool admission. */
|
|
102
|
+
advice?: readonly string[];
|
|
103
|
+
/** Runtime adapter identity, never supplied by a model tool argument. */
|
|
104
|
+
hostId?: string;
|
|
105
|
+
/** R4: trusted Session projection over original log ancestry, never model input. */
|
|
106
|
+
observedFileVersion?: (absolute: string) => Promise<string | undefined>;
|
|
107
|
+
/** R7: original active ancestry, scoped by Session rather than a model-supplied log path. */
|
|
108
|
+
observedProcess?: (session: number) => Promise<ReturnType<typeof latestProcessReceipt>>;
|
|
109
|
+
/** P.3: admitted execution owns file recovery; extensions cannot supply model-authored effects. */
|
|
110
|
+
workspace?: WorkspaceLifecycle;
|
|
111
|
+
ownedStatePaths?: readonly string[];
|
|
112
|
+
/** Historical fingerprint compatibility, never enabled by the normal CLI. */
|
|
113
|
+
legacyWorkspace?: boolean;
|
|
114
|
+
/** O.15: optional disposable view; the reader still validates the original log. */
|
|
115
|
+
historyCache?: HistoryCache;
|
|
116
|
+
/** O.14: opaque process ownership is supplied by Session, never by model input. */
|
|
117
|
+
execOwner?: string;
|
|
79
118
|
/** Session-owned registrations never mutate the built-in table (D13). */
|
|
80
119
|
extensions?: ReadonlyMap<string, ToolDefinition>;
|
|
120
|
+
/** R11: current locally selected optional schemas, never restored as authority from historical text. */
|
|
121
|
+
deferredTools?: readonly ToolSchema[];
|
|
81
122
|
callId?: string;
|
|
123
|
+
parentCallId?: string;
|
|
124
|
+
/** Pi A: a live composing scope supplied by Session, never model arguments. */
|
|
125
|
+
invoke?: (name: string, input: unknown, options?: NestedInvocationOptions) => Promise<ToolResult>;
|
|
126
|
+
/** Trusted extension host capability. Never exposed in ExtensionContext or a provider schema. */
|
|
127
|
+
invokeHost?: (input: NativeCommandInput, signal?: AbortSignal) => Promise<ToolResult>;
|
|
128
|
+
runOwnedCommand?: (execute: () => Promise<ToolResult>) => Promise<ToolResult>;
|
|
129
|
+
drainNested?: () => Promise<void>;
|
|
130
|
+
origin?: "extension";
|
|
131
|
+
readonlyBranch?: boolean;
|
|
82
132
|
extensionDepth?: number;
|
|
83
133
|
extensionHooks?: {
|
|
84
|
-
|
|
134
|
+
serial?(): boolean;
|
|
135
|
+
scope?<T>(ctx: ToolContext, action: () => T): T;
|
|
136
|
+
before(name: string, input: unknown, id: string | undefined, parentId?: string): Promise<string | undefined>;
|
|
85
137
|
event(type: string, fields: Record<string, unknown>): Promise<void>;
|
|
86
138
|
};
|
|
87
139
|
imageStore?: string;
|
|
@@ -97,6 +149,8 @@ export interface ToolContext {
|
|
|
97
149
|
* paths and the scope of every fingerprint and snapshot -- the whole disk.
|
|
98
150
|
*/
|
|
99
151
|
pathRoot?: string;
|
|
152
|
+
/** O.12: exact frozen skill resources can be read without widening the path boundary. */
|
|
153
|
+
skillResources?: readonly { path: string; text: string; modelInvocable: boolean }[];
|
|
100
154
|
/**
|
|
101
155
|
* Which tools this caller may run at all.
|
|
102
156
|
*
|
|
@@ -141,6 +195,8 @@ export interface ToolContext {
|
|
|
141
195
|
askSlot?: { current?: (request: PermissionRequest) => Promise<ApprovalChoice> };
|
|
142
196
|
}
|
|
143
197
|
|
|
198
|
+
export interface NestedInvocationOptions { signal?: AbortSignal }
|
|
199
|
+
|
|
144
200
|
/**
|
|
145
201
|
* What the person picked at the approval prompt.
|
|
146
202
|
*
|
|
@@ -152,7 +208,8 @@ export type ApprovalChoice = "once" | "command" | "tool" | "deny";
|
|
|
152
208
|
export const DEFAULT_TOOL_CONTEXT: Omit<ToolContext, "root"> = {
|
|
153
209
|
// Runner prefixes are resolved in command position so `python3 -m pytest`
|
|
154
210
|
// works without letting `echo pytest` forge a gate (D11 / M.2).
|
|
155
|
-
gates: { pytest: "pytest", tsc: "typecheck", cargo: "cargo", jest: "test", vitest: "test", go: "go"
|
|
211
|
+
gates: { pytest: "pytest", tsc: "typecheck", cargo: "cargo", jest: "test", vitest: "test", go: "go",
|
|
212
|
+
test: "test", typecheck: "typecheck", check: "check", build: "build", lint: "lint", gate: "gate" },
|
|
156
213
|
// Five minutes covers typical compiles and installs; longer work asks for
|
|
157
214
|
// its own `timeout` up to the hour ceiling (omp uses the same 300 / 3600).
|
|
158
215
|
// Approved change from 120s (2026-09-28): 120s hard-killed real builds.
|
|
@@ -163,6 +220,16 @@ export const DEFAULT_TOOL_CONTEXT: Omit<ToolContext, "root"> = {
|
|
|
163
220
|
|
|
164
221
|
const sha256 = (s: string | Uint8Array) => createHash("sha256").update(s).digest("hex");
|
|
165
222
|
|
|
223
|
+
function failure(code: ToolDiagnostic["code"], content: string, facts: FailureFacts = NOT_STARTED,
|
|
224
|
+
extra: Pick<ToolDiagnostic, "recover" | "example"> = {}): ToolResult {
|
|
225
|
+
return { ok: false, content, diagnostic: failureDiagnostic(code, content, facts, extra) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function caughtFailure(error: unknown, facts: FailureFacts, prefix = ""): ToolResult {
|
|
229
|
+
const diagnostic = failureFromError(error, facts);
|
|
230
|
+
return { ok: false, content: prefix + diagnostic.message, diagnostic };
|
|
231
|
+
}
|
|
232
|
+
|
|
166
233
|
/**
|
|
167
234
|
* The configured gate a command counts as, if any.
|
|
168
235
|
*
|
|
@@ -205,6 +272,13 @@ const searchRoot = (ctx: ToolContext, abs: string) => within(ctx.root, abs) ? ct
|
|
|
205
272
|
// ---------------------------------------------------------------------------
|
|
206
273
|
|
|
207
274
|
export interface ToolDefinition extends ToolSchema {
|
|
275
|
+
/** Q.18: opt-in builtins are absent from every default profile, including full. */
|
|
276
|
+
optional?: boolean;
|
|
277
|
+
/** Q.13: current adapter observation only; excluded from the frozen model schema. */
|
|
278
|
+
integrationHealth?: () => import("./integrations.ts").IntegrationObservation;
|
|
279
|
+
/** R10: optional companion advice is rendered only for the actual builtin capabilities. */
|
|
280
|
+
guidance?: readonly ToolGuidance[];
|
|
281
|
+
example?: { readonly [key: string]: JsonValue };
|
|
208
282
|
access: "read" | "write" | "shell" | "state";
|
|
209
283
|
execution: "parallel" | "exclusive";
|
|
210
284
|
branch: "read" | "write" | "none";
|
|
@@ -214,9 +288,20 @@ export interface ToolDefinition extends ToolSchema {
|
|
|
214
288
|
run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
|
|
215
289
|
}
|
|
216
290
|
|
|
291
|
+
const commandArtifactsSchema = {
|
|
292
|
+
type: "object", required: ["paths"], additionalProperties: false,
|
|
293
|
+
description: "Optionally observe up to 16 workspace-relative ordinary output files before/after this command (512 MiB per phase). Records digests and input scope, not proof of production or runtime acceptance. Missing/unstable files remain unknown; the command exit is retained.",
|
|
294
|
+
properties: {
|
|
295
|
+
paths: { type: "array", minItems: 1, maxItems: MAX_COMMAND_ARTIFACTS, items: { type: "string" } },
|
|
296
|
+
output_roots: { type: "array", minItems: 1, maxItems: MAX_COMMAND_ARTIFACTS, items: { type: "string" },
|
|
297
|
+
description: "Nonoverlapping output directories/files excluded from the input fingerprint, each covering requested files. Defaults to paths. Never exclude source inputs." },
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
217
301
|
export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
218
302
|
{
|
|
219
303
|
name: "query",
|
|
304
|
+
example: { paths: ["data.csv"], format: "csv", aggregate: [{ op: "count", as: "rows" }] },
|
|
220
305
|
access: "read", execution: "parallel", branch: "read", run: toolQuery,
|
|
221
306
|
description: "Compute over complete confined files (up to 32 MiB / 250000 records). Concatenates paths in order. CSV supports quoted records; JSON pointer selects nested data; JSONL parses each record; lines exposes line/text columns. Filter, group, aggregate and sort without shell or code execution. Returns full result with source hashes; large output has retrievable artifact pages. Numeric aggregates reject non-numeric values; empty/null values are excluded.",
|
|
222
307
|
input_schema: {
|
|
@@ -224,6 +309,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
224
309
|
paths: { type: "array", items: { type: "string" } },
|
|
225
310
|
format: { type: "string", enum: ["csv", "json", "jsonl", "lines"] },
|
|
226
311
|
pointer: { type: "string", description: "JSON pointer, e.g. /records" },
|
|
312
|
+
sql: { type: "string", maxLength: MAX_SQL_CHARS, description: SQL_QUERY_HELP + " Example: SELECT category, SUM(amount) AS total FROM source GROUP BY category ORDER BY total DESC. Mutually exclusive with JSON operators. Fields preserve case and use exact-key-first dotted lookup. Uses the JSON query kernel: numeric literals coerce nonempty numeric strings; ordering keeps string values lexical; COUNT(field) excludes null/missing/empty strings. SQL-style, not ANSI SQL." },
|
|
227
313
|
where: { type: "array", items: { type: "object", required: ["field", "op", "value"], properties: {
|
|
228
314
|
field: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "contains", "gt", "gte", "lt", "lte"] }, value: {},
|
|
229
315
|
} } },
|
|
@@ -238,8 +324,21 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
238
324
|
},
|
|
239
325
|
},
|
|
240
326
|
},
|
|
327
|
+
{
|
|
328
|
+
name: "target",
|
|
329
|
+
guidance: [{ requires: ["write_stdin"], text: "Continue or stop the returned handle with write_stdin." }],
|
|
330
|
+
access: "shell", execution: "exclusive", branch: "write",
|
|
331
|
+
description: "Start or inspect a runtime declared by the trusted acceptance contract. Start requires a matching successful build command with the exact declared artifacts plan. Launches literal argv under this session's process owner; never adopts a PID or reuses an existing server. Inspect checks current build inputs, entry, executable and optional IPv4 loopback port ownership. Ready still requires user acceptance. No persistent service handoff.",
|
|
332
|
+
input_schema: { type: "object", required: ["action", "target"], properties: {
|
|
333
|
+
action: { type: "string", enum: ["start", "inspect"] }, target: { type: "string" },
|
|
334
|
+
session_id: { type: "integer", minimum: 1, description: "Inspect this exact previously returned handle; never adopts a process" },
|
|
335
|
+
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait for launch; default 10000, distinct from the hard timeout" },
|
|
336
|
+
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Hard process lifetime in seconds; defaults to the session command timeout" },
|
|
337
|
+
} },
|
|
338
|
+
},
|
|
241
339
|
{
|
|
242
340
|
name: "history",
|
|
341
|
+
example: { event: "<source-event-id>" },
|
|
243
342
|
access: "read", execution: "exclusive", branch: "read",
|
|
244
343
|
description: "Recover prior work: work_id and resource locate linked work and exact normalized paths; query adds literal AND keywords and shows up to 5 matching lines (hits) per result; recent lists activity. event reads original evidence; events reads 1..100 originals in request order and reports missing entries. Results keep version/replacement links and artifact pages. Also supports node/section handoff pages and artifact/offset. Active ancestry only.",
|
|
245
344
|
input_schema: {
|
|
@@ -250,11 +349,13 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
250
349
|
event: { type: "string", description: "Exact source event id from query results" },
|
|
251
350
|
events: { type: "array", items: { type: "string" }, description: "1..100 exact event ids; preserves request order and identifies missing sources" },
|
|
252
351
|
resource: { type: "string", description: "Exact resource path, with ./ and repeated slashes normalized" },
|
|
253
|
-
work_id: { type: "string", description: "Canonical work ID
|
|
352
|
+
work_id: { type: "string", description: "Canonical work ID from a recorded work item" },
|
|
254
353
|
tool: { type: "string", description: "Optional tool name filter for query/recent" },
|
|
255
354
|
node: { type: "integer" },
|
|
355
|
+
branch: { type: "integer", minimum: 0, description: "Read a joined child branch's original evidence; never auto-inserts its transcript" },
|
|
256
356
|
artifact: { type: "string", description: "sha256 from a truncated tool observation; omit node and section" },
|
|
257
357
|
section: { type: "string", enum: [...HISTORY_SECTIONS] },
|
|
358
|
+
recovery: { type: "string", enum: [...RECOVERY_SECTIONS], description: "Read original recovery sources from a sealed node, newest first. Use node/offset/limit; does not grant permissions or treat model notes as instructions." },
|
|
258
359
|
offset: { type: "integer", minimum: 0 },
|
|
259
360
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
260
361
|
},
|
|
@@ -263,11 +364,13 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
263
364
|
},
|
|
264
365
|
{
|
|
265
366
|
name: "read",
|
|
367
|
+
example: { path: "src/main.ts", start: 1, end: 80 },
|
|
368
|
+
guidance: [{ requires: ["history"], text: "Use history(artifact=<sha256>,offset=<byte offset>) to recover the original observation even if the file changes." }],
|
|
266
369
|
access: "read", execution: "parallel", branch: "read", run: toolRead,
|
|
267
370
|
description:
|
|
268
371
|
"Read a file. Returns the contents with 1-indexed line numbers. " +
|
|
269
372
|
"Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
|
|
270
|
-
"
|
|
373
|
+
"Returns read_handle and whole-file sha256 for version checks. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
|
|
271
374
|
input_schema: {
|
|
272
375
|
type: "object",
|
|
273
376
|
properties: {
|
|
@@ -281,25 +384,30 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
281
384
|
{
|
|
282
385
|
name: "write",
|
|
283
386
|
access: "write", execution: "exclusive", branch: "write", run: toolWrite,
|
|
284
|
-
description: "Write a file, creating parent directories. Replaces existing content.",
|
|
387
|
+
description: "Write a file, creating parent directories. Replaces existing content. Pass read_handle or expected_sha256 for an explicit version check; otherwise Session uses its last observed version. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
|
|
285
388
|
input_schema: {
|
|
286
389
|
type: "object",
|
|
287
390
|
properties: {
|
|
288
391
|
path: { type: "string" },
|
|
289
392
|
content: { type: "string" },
|
|
393
|
+
read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
|
|
394
|
+
expected_sha256: { type: "string", description: "Expected whole-file raw-byte SHA-256" },
|
|
395
|
+
create_only: { type: "boolean", description: "Require that the target does not exist; excludes read_handle and expected_sha256" },
|
|
290
396
|
},
|
|
291
397
|
required: ["path", "content"],
|
|
292
398
|
},
|
|
293
399
|
},
|
|
294
400
|
{
|
|
295
401
|
name: "edit",
|
|
402
|
+
example: { path: "src/main.ts", old_string: "const size = 1;", new_string: "const size = 2;" },
|
|
403
|
+
guidance: [{ requires: ["read"], text: "Use read to obtain current text and a version receipt before an edit." }],
|
|
296
404
|
access: "write", execution: "exclusive", branch: "write", run: toolEdit,
|
|
297
405
|
description:
|
|
298
|
-
"Replace exact text in a file. old_string must match once unless replace_all is set; copy it from
|
|
406
|
+
"Replace exact text in a file. old_string must match once unless replace_all is set; copy it from observed file content, " +
|
|
299
407
|
"including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
|
|
300
408
|
"each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
|
|
301
409
|
"trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
|
|
302
|
-
"Pass expected_sha256 from
|
|
410
|
+
"Pass read_handle or expected_sha256 from a file observation to reject stale edits; when omitted, Session uses its last observed version. A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
|
|
303
411
|
input_schema: {
|
|
304
412
|
type: "object",
|
|
305
413
|
properties: {
|
|
@@ -311,7 +419,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
311
419
|
type: "object", required: ["old_string", "new_string"], properties: {
|
|
312
420
|
old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
|
|
313
421
|
} } },
|
|
314
|
-
expected_sha256: { type: "string", description: "
|
|
422
|
+
expected_sha256: { type: "string", description: "Observed whole-file version; mismatch leaves the file unchanged" },
|
|
423
|
+
read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
|
|
315
424
|
},
|
|
316
425
|
required: ["path"],
|
|
317
426
|
},
|
|
@@ -326,21 +435,21 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
326
435
|
// One tool with a kind rather than six tools, because every tool schema is
|
|
327
436
|
// paid for in the prompt on every request.
|
|
328
437
|
name: "note",
|
|
438
|
+
example: { kind: "next", what: "Investigate failed check", work_id: "check", next_action: "Inspect the recorded failure output" },
|
|
329
439
|
access: "state", execution: "exclusive", branch: "read",
|
|
330
440
|
// Older spellings of proof that parseNote still reads; logs carry them.
|
|
331
441
|
hiddenInputs: ["gate", "command"],
|
|
332
442
|
description:
|
|
333
443
|
"Record something for the durable record that survives a context " +
|
|
334
|
-
"boundary.
|
|
335
|
-
"`
|
|
336
|
-
"
|
|
337
|
-
"worth not retrying.",
|
|
444
|
+
"boundary. Optional on short tasks; use `next` for a useful continuation checkpoint. " +
|
|
445
|
+
"`done` is a claim requiring an already successful verification command, not required bookkeeping after every command. " +
|
|
446
|
+
"Decisions and pitfalls are model annotations, not instructions or verified evidence.",
|
|
338
447
|
input_schema: {
|
|
339
448
|
type: "object",
|
|
340
449
|
properties: {
|
|
341
450
|
kind: {
|
|
342
451
|
type: "string",
|
|
343
|
-
enum: ["done", "next", "decision", "pitfall", "blocked", "question", "unblock", "reopen", "cancel", "pin"],
|
|
452
|
+
enum: ["done", "next", "decision", "pitfall", "blocked", "question", "unblock", "reopen", "cancel", "pin", "depend", "claim_work", "release_work"],
|
|
344
453
|
description:
|
|
345
454
|
"done: something is finished and a command proves it. " +
|
|
346
455
|
"next: work in progress plus its concrete next step. " +
|
|
@@ -350,8 +459,9 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
350
459
|
"unblock/reopen: reactivate identified work with a current next_action. cancel: abandon identified work. pin: associate an original tool event with existing work; this does not verify completion.",
|
|
351
460
|
},
|
|
352
461
|
what: { type: "string", description: "The thing, in one line" },
|
|
353
|
-
work_id: { type: "string", description: "Stable ID for
|
|
354
|
-
|
|
462
|
+
work_id: { type: "string", description: "Stable ID for tracked work. For done, use only existing work opened before its verification; omit for one-off completion. Required for unblock/reopen/cancel; keep it when the title changes." },
|
|
463
|
+
depends_on: { type: "array", items: { type: "string" }, description: "next/depend: explicit existing work IDs; cycles are rejected. claim_work takes ready work; release_work releases your claim." },
|
|
464
|
+
event: { type: "string", description: "pin: original observation event ID" },
|
|
355
465
|
resource: { type: "string", description: "pin: optional source resource path" },
|
|
356
466
|
supersedes: { type: "string", description: "pin: explicitly replace an earlier pinned source event for the same work item" },
|
|
357
467
|
proof: {
|
|
@@ -384,6 +494,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
384
494
|
* five times the cost, while being slower. Sequential is usually right.
|
|
385
495
|
*/
|
|
386
496
|
name: "branch",
|
|
497
|
+
guidance: [{ requires: ["resolve"], text: "Use resolve status/wait/cancel to observe or join a background branch." }],
|
|
387
498
|
access: "state", execution: "exclusive", branch: "none",
|
|
388
499
|
description:
|
|
389
500
|
"Run a bounded subtask in a separate context and return its outcome and handoff reference. " +
|
|
@@ -394,6 +505,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
394
505
|
type: "object",
|
|
395
506
|
properties: {
|
|
396
507
|
purpose: { type: "string", description: "What this branch is for, in one line" },
|
|
508
|
+
background: { type: "boolean", description: "Return a reference while the child runs. Requires a branch-observation capability for later results; without it, prefer the default foreground call." },
|
|
397
509
|
prompt: {
|
|
398
510
|
type: "string",
|
|
399
511
|
description:
|
|
@@ -417,13 +529,14 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
417
529
|
name: "resolve",
|
|
418
530
|
access: "state", execution: "exclusive", branch: "none",
|
|
419
531
|
description:
|
|
420
|
-
"
|
|
532
|
+
"Wait, cancel, inspect, adopt or discard a branch. Every branch must be " +
|
|
421
533
|
"resolved; an unresolved one stays an open question.",
|
|
422
534
|
input_schema: {
|
|
423
535
|
type: "object",
|
|
424
536
|
properties: {
|
|
425
537
|
node: { type: "number", description: "The branch's node number" },
|
|
426
|
-
resolution: { type: "string", enum: ["adopt", "discard"] },
|
|
538
|
+
resolution: { type: "string", enum: ["adopt", "discard", "wait", "cancel", "status"] },
|
|
539
|
+
wait_ms: { type: "number", minimum: 0, maximum: 60000, description: "wait: bounded milliseconds, defaults to 10000" },
|
|
427
540
|
why: { type: "string", description: "discard: why it was rejected" },
|
|
428
541
|
},
|
|
429
542
|
required: ["node", "resolution"],
|
|
@@ -431,37 +544,64 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
431
544
|
},
|
|
432
545
|
{
|
|
433
546
|
name: "bash",
|
|
547
|
+
example: { command: "bun test", workdir: ".", timeout: 300 },
|
|
548
|
+
guidance: [{ requires: ["exec_command", "write_stdin"], text: "For foreground servers or interactive input, use exec_command and observe the handle with write_stdin." }],
|
|
434
549
|
access: "shell", execution: "exclusive", branch: "write", run: toolBash,
|
|
550
|
+
prepareArguments: prepareArtifactArguments,
|
|
435
551
|
description:
|
|
436
552
|
"Run a command with bash in a fresh non-interactive shell (no TTY; cwd, variables and `cd` do not persist between calls). " +
|
|
437
553
|
"Returns a header `exit N · wall time` with any timeout, then stdout and stderr interleaved; long output keeps its head and tail. " +
|
|
438
|
-
"Default timeout
|
|
439
|
-
"
|
|
554
|
+
"Default timeout 300 seconds; pass timeout for longer work. Timeout or cancellation cleans the process group. " +
|
|
555
|
+
"Normal shell exit can leave background children; this call exposes no handle for later control. Keep work in the foreground and do not start detached servers or watchers here.",
|
|
440
556
|
input_schema: {
|
|
441
557
|
type: "object",
|
|
442
558
|
properties: {
|
|
443
559
|
command: { type: "string" },
|
|
560
|
+
artifacts: commandArtifactsSchema,
|
|
444
561
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the process group is killed; default 300" },
|
|
445
562
|
workdir: { type: "string", description: "Directory to run in, relative to the workspace root; default the root" },
|
|
446
563
|
},
|
|
447
564
|
required: ["command"],
|
|
448
565
|
},
|
|
449
566
|
},
|
|
567
|
+
{
|
|
568
|
+
name: "environment",
|
|
569
|
+
example: { program: "python3", probe: "python" },
|
|
570
|
+
access: "shell", execution: "exclusive", branch: "write", run: toolEnvironment,
|
|
571
|
+
description: "Probe one program in the same fresh login Bash as commands. Requires shell permission. Returns the resolved executable, reported version, source identity, observation time and raw output reference. probe=python uses isolated Python to inspect actual tarfile.extractall filter support. Every call probes again; old observations can expire or be invalidated. This describes this executor only, not an SSH target or another container. Functions and aliases require an exact executable path. Bounded to 5s and an output stop budget of 256 KiB; large or incomplete reports remain unconfirmed.",
|
|
572
|
+
hiddenInputs: ["command"],
|
|
573
|
+
prepareArguments(input) {
|
|
574
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return input;
|
|
575
|
+
const args = input as Record<string, unknown>;
|
|
576
|
+
return { ...args, probe: args["probe"] ?? "version", command: environmentProbeCommand(args["program"], args["probe"] ?? "version") };
|
|
577
|
+
},
|
|
578
|
+
input_schema: { type: "object", required: ["program"], properties: {
|
|
579
|
+
program: { type: "string", description: "Executable name or literal path; never shell code. Prefer the exact interpreter used by the failing command." },
|
|
580
|
+
probe: { type: "string", enum: ["version", "python"], description: "version runs --version (default); python observes the actual interpreter API in isolated mode." },
|
|
581
|
+
workdir: { type: "string", description: "Directory to run in, relative to the workspace root; default the root" },
|
|
582
|
+
} },
|
|
583
|
+
},
|
|
450
584
|
{
|
|
451
585
|
name: "exec_command",
|
|
586
|
+
example: { cmd: "bun test", workdir: ".", yield_time_ms: 1000, timeout: 300 },
|
|
587
|
+
guidance: [{ requires: ["write_stdin"], text: "Continue the returned handle with write_stdin until exit; empty chars polls without replaying the command." }],
|
|
452
588
|
access: "shell", execution: "exclusive", branch: "write", run: toolExec,
|
|
589
|
+
prepareArguments: prepareArtifactArguments,
|
|
453
590
|
description:
|
|
454
591
|
"Start a command that may keep running: a server, a REPL, a long build, or a program that asks for input. " +
|
|
455
|
-
"
|
|
456
|
-
"
|
|
457
|
-
"
|
|
592
|
+
"Uses a fresh login shell; set workdir because cwd and variables do not persist. " +
|
|
593
|
+
"Waits up to yield_time_ms milliseconds; if it has exited, returns `exit N · wall time` and its output, otherwise " +
|
|
594
|
+
"`running · session <id>` with the output so far while the process keeps running. Running is not completion; if no handle-observation capability is available, report unfinished execution rather than rerunning it. " +
|
|
595
|
+
"tty=true runs it in a terminal for REPLs and prompts. Keep servers in the foreground: the owned group is cleaned when the command exits, times out, or its xeno session closes. " +
|
|
596
|
+
"A wait deadline only ends this call; timeout is the command's hard deadline. No persistent service handoff.",
|
|
458
597
|
input_schema: {
|
|
459
598
|
type: "object",
|
|
460
599
|
properties: {
|
|
461
|
-
cmd: { type: "string", description: "Shell command, run with bash -lc" },
|
|
600
|
+
cmd: { type: "string", description: "Shell command, run with bash -o pipefail -lc" },
|
|
601
|
+
artifacts: commandArtifactsSchema,
|
|
462
602
|
workdir: { type: "string", description: "Directory to run in; default the workspace root" },
|
|
463
603
|
tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
|
|
464
|
-
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "
|
|
604
|
+
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait before returning; default 10000" },
|
|
465
605
|
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
|
|
466
606
|
},
|
|
467
607
|
required: ["cmd"],
|
|
@@ -469,17 +609,19 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
469
609
|
},
|
|
470
610
|
{
|
|
471
611
|
name: "write_stdin",
|
|
612
|
+
example: { session_id: 1, chars: "", yield_time_ms: 1000 },
|
|
472
613
|
access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
|
|
473
614
|
description:
|
|
474
|
-
"Send characters to
|
|
615
|
+
"Send characters to an owned running process handle and return the output since the last call. " +
|
|
475
616
|
"Empty chars polls. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
|
|
476
|
-
"
|
|
617
|
+
"After exit, empty chars retrieves the last receipt without rerunning or producing fresh verification. Source and byte cursor are in data; full_output recovers all captured output. " +
|
|
618
|
+
"Lost handles cannot accept input; a last running observation is not an exit. Ctrl-C may interrupt a PTY foreground job while its REPL stays alive.",
|
|
477
619
|
input_schema: {
|
|
478
620
|
type: "object",
|
|
479
621
|
properties: {
|
|
480
622
|
session_id: { type: "integer", minimum: 1 },
|
|
481
623
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
482
|
-
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "
|
|
624
|
+
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Milliseconds to wait for output; default 10000" },
|
|
483
625
|
},
|
|
484
626
|
required: ["session_id"],
|
|
485
627
|
},
|
|
@@ -493,7 +635,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
493
635
|
"Search file contents for a regex. Stays inside the workspace, does " +
|
|
494
636
|
"not follow symlinks, and skips .git, node_modules and .xeno. Uses " +
|
|
495
637
|
"ripgrep when it is already on PATH, otherwise a TypeScript walk. " +
|
|
496
|
-
"
|
|
638
|
+
"Reports scan completeness separately from preview truncation. Long matches and omitted results remain in an immutable history artifact; use its sha256 and byte offset to recover them. Unknown or partial coverage cannot prove absence.",
|
|
497
639
|
input_schema: {
|
|
498
640
|
type: "object",
|
|
499
641
|
properties: {
|
|
@@ -511,7 +653,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
511
653
|
description:
|
|
512
654
|
"Find files by glob pattern. Stays inside the workspace, does not " +
|
|
513
655
|
"follow symlinks, and skips .git, node_modules and .xeno. Uses fd " +
|
|
514
|
-
"when it is already on PATH, otherwise a TypeScript walk.",
|
|
656
|
+
"when it is already on PATH, otherwise a TypeScript walk. Omitted paths remain in a history artifact; scan completeness is reported separately.",
|
|
515
657
|
input_schema: {
|
|
516
658
|
type: "object",
|
|
517
659
|
properties: {
|
|
@@ -528,7 +670,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
528
670
|
description:
|
|
529
671
|
"List a directory. Directories are marked with a trailing '/'. Stays " +
|
|
530
672
|
"inside the workspace, does not follow symlinks, and skips .git, " +
|
|
531
|
-
"node_modules and .xeno.",
|
|
673
|
+
"node_modules and .xeno. Omitted entries remain in a history artifact with exact byte offsets.",
|
|
532
674
|
input_schema: {
|
|
533
675
|
type: "object",
|
|
534
676
|
properties: {
|
|
@@ -541,12 +683,94 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
541
683
|
];
|
|
542
684
|
|
|
543
685
|
/** Runtime policy metadata never leaks into provider-specific wire schemas. */
|
|
544
|
-
export
|
|
686
|
+
export function modelToolSchema(tool: ToolSchema & Pick<ToolDefinition, "guidance" | "example">, builtins: readonly string[]): ToolSchema {
|
|
687
|
+
const description = toolDescription(tool.description, tool.guidance ?? [], builtins);
|
|
688
|
+
return { name: tool.name, description: description + (tool.example ? ` Example input (paths and IDs are illustrative): ${JSON.stringify(tool.example)}` : ""), input_schema: tool.input_schema };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export const ALL_TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.map(tool => modelToolSchema(tool, TOOL_DEFINITIONS.map(item => item.name)));
|
|
692
|
+
export const TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.filter(tool => !tool.optional).map(tool => modelToolSchema(tool, TOOL_DEFINITIONS.filter(item => !item.optional).map(item => item.name)));
|
|
545
693
|
|
|
546
694
|
export function toolDefinition(name: string, ctx?: ToolContext): ToolDefinition | undefined {
|
|
547
695
|
return ctx?.extensions?.get(name) ?? TOOL_DEFINITIONS.find(tool => tool.name === name);
|
|
548
696
|
}
|
|
549
697
|
|
|
698
|
+
/** Visible builtin contracts only; a same-named extension does not implement that contract. */
|
|
699
|
+
export function adviceTools(ctx: ToolContext): string[] {
|
|
700
|
+
return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
|
|
701
|
+
(ctx.advice === undefined || ctx.advice.includes(name)) &&
|
|
702
|
+
(ctx.allow === undefined || ctx.allow.includes(name)) &&
|
|
703
|
+
decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], { tool: name }) !== "deny");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
|
|
707
|
+
function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
|
|
708
|
+
const names = adviceTools(ctx);
|
|
709
|
+
const scope = (diagnostic: ToolDiagnostic): ToolDiagnostic => {
|
|
710
|
+
let allowed = false;
|
|
711
|
+
const recover = diagnostic.recover;
|
|
712
|
+
if (recover && names.includes(recover.tool)) {
|
|
713
|
+
try {
|
|
714
|
+
const input = normalizeToolInput(ctx, recover.tool, recover.input);
|
|
715
|
+
const definition = toolDefinition(recover.tool, ctx)!;
|
|
716
|
+
allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
|
|
717
|
+
decide([...(ctx.sessionRules ?? []), ...(ctx.rules ?? [])], permissionRequest(recover.tool, input)) !== "deny";
|
|
718
|
+
} catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
|
|
719
|
+
}
|
|
720
|
+
return scopeFailureGuidance(diagnostic, names, allowed);
|
|
721
|
+
};
|
|
722
|
+
return { ...result, ...(result.diagnostic ? { diagnostic: scope(result.diagnostic) } : {}),
|
|
723
|
+
...(result.warnings ? { warnings: result.warnings.map(scope) } : {}) };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export const NATIVE_COMMAND_NAME = "$extension.exec";
|
|
727
|
+
export interface NativeCommandInput { argv: string[]; cwd?: string; timeout?: number }
|
|
728
|
+
export interface NativeCommandResult { stdout: string; stderr: string; code: number; killed: boolean }
|
|
729
|
+
|
|
730
|
+
/** Pi A: host authority is installed by the broker, not selected by a model parameter. */
|
|
731
|
+
export function nativeCommandContext(ctx: ToolContext, signal?: AbortSignal): ToolContext {
|
|
732
|
+
return { ...ctx, origin: "extension", allow: ctx.readonlyBranch ? [] : [NATIVE_COMMAND_NAME], rules: [], sessionRules: [],
|
|
733
|
+
extensions: new Map([[NATIVE_COMMAND_NAME, nativeCommandDefinition]]),
|
|
734
|
+
...(signal ? { signal: ctx.signal ? AbortSignal.any([ctx.signal, signal]) : signal } : {}) };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const nativeCommandDefinition: ToolDefinition = {
|
|
738
|
+
name: NATIVE_COMMAND_NAME, description: "Trusted local extension command with literal argv and independent pipe artifacts.",
|
|
739
|
+
access: "write", execution: "exclusive", branch: "none",
|
|
740
|
+
input_schema: { type: "object", properties: { argv: { type: "array", minItems: 1, items: { type: "string" } },
|
|
741
|
+
cwd: { type: "string" }, timeout: { type: "number", exclusiveMinimum: 0 } }, required: ["argv"] },
|
|
742
|
+
run: async (ctx, input) => {
|
|
743
|
+
const argv = input["argv"] as string[];
|
|
744
|
+
const requested = input["timeout"] as number | undefined;
|
|
745
|
+
if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) return failure("invalid_input", "native timeout must be positive milliseconds");
|
|
746
|
+
const timeoutMs = commandTimeout(requested === undefined ? undefined : requested / 1000, ctx.timeoutMs, ctx.maxTimeoutMs);
|
|
747
|
+
const captured = await observeArgvCommand({ argv, cwd: typeof input["cwd"] === "string" ? resolve(ctx.root, input["cwd"]) : ctx.root,
|
|
748
|
+
outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
|
|
749
|
+
...(ctx.signal ? { signal: ctx.signal } : {}), ...(ctx.onOutput ? { onOutput: ctx.onOutput } : {}) });
|
|
750
|
+
// A display representation only: execution above never reparses a command string.
|
|
751
|
+
const command = argv.map(arg => "'" + arg.replaceAll("'", "'\\''") + "'").join(" ");
|
|
752
|
+
const gate = matchGate(ctx, command);
|
|
753
|
+
const header = commandHeader({ exit: captured.exit, wallMs: captured.wallMs, timeoutMs,
|
|
754
|
+
...(requested !== undefined ? { requestedMs: requested } : {}), ...(captured.stopped ? { stopped: captured.stopped } : {}) });
|
|
755
|
+
return { ...commandCompletion(captured.exit, captured.stopped, captured.cleanup),
|
|
756
|
+
content: header + "\n" + observationView(captured, ctx.maxOutputBytes - utf8Length(header) - 1, captured.prefixBytes, adviceTools(ctx)),
|
|
757
|
+
content_ref: captured.ref,
|
|
758
|
+
data: { code: captured.exit, killed: captured.stopped !== undefined, capture: captured.capture, stdout: captured.stdout, stderr: captured.stderr,
|
|
759
|
+
...(captured.cleanup ? { cleanup: "unknown" } : {}) },
|
|
760
|
+
effect: { kind: "command", cmd: command, exit: captured.exit, ...(gate ? { gate } : {}), ...await commandFingerprint(ctx) } };
|
|
761
|
+
},
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
/** SDK strings are hydrated from integrity-checked artifacts after the independent receipt commits. */
|
|
765
|
+
export async function nativeCommandResult(result: ToolResult, store: string): Promise<NativeCommandResult> {
|
|
766
|
+
if (result.effect?.kind !== "command" || typeof result.data !== "object" || !result.data || Array.isArray(result.data)) throw new Error(result.content);
|
|
767
|
+
const data = result.data as { code: number; killed: boolean; capture: string; stdout: ArtifactRef; stderr: ArtifactRef };
|
|
768
|
+
if ("cleanup" in data && data.cleanup === "unknown") throw new Error("native command exited but process-group cleanup is unconfirmed; inspect the retained receipt before retrying");
|
|
769
|
+
if (data.capture !== "complete") throw new Error("native command exited but pipe capture is partial; inspect the retained receipt before retrying");
|
|
770
|
+
const [stdout, stderr] = await Promise.all([data.stdout, data.stderr].map(ref => readObservation(store, ref.sha256, 0, Math.max(1, ref.bytes))));
|
|
771
|
+
return { stdout: stdout!.text, stderr: stderr!.text, code: data.code, killed: data.killed };
|
|
772
|
+
}
|
|
773
|
+
|
|
550
774
|
// ---------------------------------------------------------------------------
|
|
551
775
|
// Implementations
|
|
552
776
|
// ---------------------------------------------------------------------------
|
|
@@ -557,25 +781,23 @@ export function toolDefinition(name: string, ctx?: ToolContext): ToolDefinition
|
|
|
557
781
|
* or a minified bundle cannot spend the observation budget on its own.
|
|
558
782
|
*/
|
|
559
783
|
const READ_DEFAULT_LINES = 2_000;
|
|
560
|
-
const READ_MAX_LINE_CHARS = 2_000;
|
|
561
|
-
|
|
562
|
-
function clipLine(line: string, number: number): string {
|
|
563
|
-
if (line.length <= READ_MAX_LINE_CHARS) return line;
|
|
564
|
-
return `${line.slice(0, READ_MAX_LINE_CHARS)} [line truncated: ${line.length} chars; see the rest with bash, e.g. \`sed -n '${number}p' FILE | cut -c${READ_MAX_LINE_CHARS + 1}-\`]`;
|
|
565
|
-
}
|
|
566
784
|
|
|
567
785
|
async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
568
786
|
const path = String(input["path"] ?? "");
|
|
569
|
-
const
|
|
570
|
-
if (
|
|
787
|
+
const resource = ctx.skillResources?.find(item => item.path === resolve(ctx.root, path));
|
|
788
|
+
if (resource && !resource.modelInvocable) return failure("permission_denied", "this skill requires explicit user invocation");
|
|
789
|
+
const abs = resource ? resource.path : confine(ctx, path);
|
|
790
|
+
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
571
791
|
|
|
572
792
|
let text: string;
|
|
573
793
|
let bytes: Uint8Array;
|
|
574
794
|
try {
|
|
575
|
-
|
|
795
|
+
checkSourceCancellation(ctx.signal);
|
|
796
|
+
bytes = resource ? new TextEncoder().encode(resource.text) : await readFile(abs, ctx.signal ? { signal: ctx.signal } : {});
|
|
797
|
+
checkSourceCancellation(ctx.signal);
|
|
576
798
|
text = Buffer.from(bytes).toString("utf8");
|
|
577
799
|
} catch (err) {
|
|
578
|
-
return
|
|
800
|
+
return caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `);
|
|
579
801
|
}
|
|
580
802
|
|
|
581
803
|
if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
|
|
@@ -583,7 +805,7 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
583
805
|
return { ok: true, content: imageDescription(image), images: [image], effect: { kind: "read", path, sha256: blobAddress(bytes) } };
|
|
584
806
|
}
|
|
585
807
|
if (bytes.subarray(0, 8_192).includes(0)) {
|
|
586
|
-
return
|
|
808
|
+
return failure("invalid_input", `${path} looks binary (${bytes.length} bytes, contains NUL); use a binary inspection tool such as xxd if permitted`, READ_ATTEMPTED);
|
|
587
809
|
}
|
|
588
810
|
const lines = toView(text).text.split("\n");
|
|
589
811
|
// Without an end, a read stops after READ_DEFAULT_LINES so one large file
|
|
@@ -591,52 +813,106 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
591
813
|
const first = input["start"] ?? 1;
|
|
592
814
|
const end = input["end"] === undefined && Number.isSafeInteger(first) ? (first as number) + READ_DEFAULT_LINES - 1 : input["end"];
|
|
593
815
|
const selected = readBounds(lines.length, input["start"], end);
|
|
594
|
-
if (!selected.ok) return
|
|
816
|
+
if (!selected.ok) return failure("invalid_input", selected.message, READ_ATTEMPTED);
|
|
595
817
|
const { start } = selected;
|
|
596
818
|
const slice = lines.slice(start - 1, selected.end);
|
|
597
|
-
const
|
|
819
|
+
const version = sha256(bytes);
|
|
820
|
+
const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
821
|
+
async function* one(value: Uint8Array): AsyncGenerator<Uint8Array> { yield value; }
|
|
822
|
+
// Archive actual bytes before either presentation cut. Re-reading the current file is not recovery.
|
|
823
|
+
const raw = await archiveObservation(store, one(bytes), 0);
|
|
824
|
+
const source = { host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), resource: abs, sha256: version, raw: raw.ref };
|
|
825
|
+
const selection = { start, end: selected.end, total_lines: lines.length };
|
|
826
|
+
const descriptor: FileReadDescriptor = { kind: "file_read", version: 1, source, selection };
|
|
827
|
+
const handle = await archiveObservation(store, one(new TextEncoder().encode(JSON.stringify(descriptor))), 0);
|
|
828
|
+
const header = `${selected.notice}\nsha256: ${version}\nread_handle: ${handle.ref.sha256}\n`;
|
|
829
|
+
const full = new TextEncoder().encode(readObservationText(header, slice, start));
|
|
830
|
+
const observation = await archiveObservation(store, one(full), 0);
|
|
831
|
+
const preview = previewReadObservation(header, slice, start, observation.ref.sha256, ctx.maxOutputBytes, undefined, adviceTools(ctx));
|
|
598
832
|
|
|
599
833
|
const ranges: LineRange[] = [[start, selected.end]];
|
|
600
834
|
return {
|
|
601
|
-
|
|
835
|
+
content: preview.text, content_ref: observation.ref, content_sha256: observation.ref.sha256,
|
|
836
|
+
observation: { version: 1, source, artifact: observation.ref, descriptor: handle.ref, selection, preview: preview.preview },
|
|
602
837
|
ok: true,
|
|
603
|
-
data: { path, sha256: sha256
|
|
604
|
-
observation_sha256: sha256(
|
|
838
|
+
data: { path, sha256: version, read_handle: handle.ref.sha256, start, end: selected.end, total_lines: lines.length, next_start: selected.end < lines.length ? selected.end + 1 : null },
|
|
839
|
+
observation_sha256: sha256(preview.semantic),
|
|
605
840
|
// The digest is of the whole file, not the slice: it answers "has this
|
|
606
841
|
// file changed since the agent looked", which a partial read still bears on.
|
|
607
|
-
effect: { kind: "read", path, sha256:
|
|
842
|
+
effect: { kind: "read", path, sha256: version, ranges },
|
|
608
843
|
};
|
|
609
844
|
}
|
|
610
845
|
|
|
846
|
+
/** Read descriptors are CAS data, not executable state or an alternate permission path. */
|
|
847
|
+
async function filePrecondition(ctx: ToolContext, input: Record<string, unknown>, absolute: string, current: string | null):
|
|
848
|
+
Promise<{ ok: true; precondition: FilePrecondition } | { ok: false; result: ToolResult }> {
|
|
849
|
+
const path = String(input["path"]), handle = input["read_handle"] as string | undefined;
|
|
850
|
+
const expected = input["expected_sha256"] as string | undefined, createOnly = input["create_only"] === true;
|
|
851
|
+
const recover = { tool: "read", input: { path } };
|
|
852
|
+
const reject = (code: ToolDiagnostic["code"], message: string): { ok: false; result: ToolResult } => ({ ok: false,
|
|
853
|
+
result: { ...failure(code, message, READ_ATTEMPTED, { recover }), data: { path, sha256: current } } });
|
|
854
|
+
let descriptor: FileReadDescriptor | undefined;
|
|
855
|
+
if (handle !== undefined) {
|
|
856
|
+
if (!isFileDigest(handle)) return reject("invalid_input", "read_handle must be a descriptor SHA-256 returned by read");
|
|
857
|
+
try {
|
|
858
|
+
// Assumed metadata budget: descriptors contain identity and range, never file text.
|
|
859
|
+
const page = await readObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), handle, 0, 64 * 1024);
|
|
860
|
+
if (page.next !== null) return reject("invalid_input", "read_handle descriptor exceeds the 64 KiB metadata limit");
|
|
861
|
+
const value: unknown = JSON.parse(page.text);
|
|
862
|
+
if (!isFileReadDescriptor(value)) return reject("invalid_input", "read_handle does not name a file read descriptor");
|
|
863
|
+
descriptor = value;
|
|
864
|
+
} catch { return reject("not_found", "read_handle descriptor is missing, corrupt, or unreadable; read the file here again"); }
|
|
865
|
+
}
|
|
866
|
+
const observed = handle === undefined && expected === undefined && !createOnly ? await ctx.observedFileVersion?.(absolute) : undefined;
|
|
867
|
+
const decision = checkFilePrecondition({ host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), resource: absolute }, current, {
|
|
868
|
+
...(descriptor === undefined ? {} : { descriptor }), ...(handle === undefined ? {} : { read_handle: handle }),
|
|
869
|
+
...(expected === undefined ? {} : { expected_sha256: expected }), ...(observed === undefined ? {} : { observed }), create_only: createOnly,
|
|
870
|
+
});
|
|
871
|
+
if (decision.ok) return decision;
|
|
872
|
+
const rejected = reject(decision.code, `${decision.message} (${path})`);
|
|
873
|
+
if (decision.precondition) rejected.result.data = { path, sha256: current, precondition: { ...decision.precondition } };
|
|
874
|
+
return rejected;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function fileCommitFailure(ctx: ToolContext, absolute: string, path: string, error: unknown): Promise<ToolResult> {
|
|
878
|
+
const result = caughtFailure(error, UNKNOWN_EFFECT, `cannot write ${path}: `);
|
|
879
|
+
if (result.diagnostic?.code !== "version_mismatch") return result;
|
|
880
|
+
try {
|
|
881
|
+
const current = await readEditableFile(ctx.pathRoot ?? ctx.root, absolute);
|
|
882
|
+
const version = current.bytes === null ? null : sha256(current.bytes);
|
|
883
|
+
return { ...result, content: `${result.content}\ncurrent sha256: ${version ?? "absent"}`, data: { path, sha256: version } };
|
|
884
|
+
} catch { return result; } // A failed follow-up observation cannot invent the current version.
|
|
885
|
+
}
|
|
886
|
+
|
|
611
887
|
async function toolWrite(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
612
888
|
const path = String(input["path"] ?? "");
|
|
613
889
|
const content = String(input["content"] ?? "");
|
|
614
890
|
const abs = confine(ctx, path);
|
|
615
|
-
if (typeof abs !== "string") return
|
|
891
|
+
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
616
892
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
893
|
+
const saved = await readEditableFile(ctx.pathRoot ?? ctx.root, abs);
|
|
894
|
+
const before = saved.bytes;
|
|
895
|
+
const previous = before === null ? null : sha256(before), after = new TextEncoder().encode(content), version = sha256(after);
|
|
896
|
+
const checked = await filePrecondition(ctx, input, abs, previous);
|
|
897
|
+
if (!checked.ok) return checked.result;
|
|
898
|
+
const data = { path, bytes: after.length, sha256_before: previous, sha256_after: version, precondition: { ...checked.precondition } };
|
|
899
|
+
if (previous === version) return { ok: true, content: `no change to ${path}; sha256: ${version}`, data: { ...data, changed: false } };
|
|
623
900
|
|
|
624
901
|
try {
|
|
625
|
-
await
|
|
626
|
-
await writeFile(abs, content, "utf8");
|
|
902
|
+
await commitEdit(ctx, abs, saved, after);
|
|
627
903
|
} catch (err) {
|
|
628
|
-
return
|
|
904
|
+
return fileCommitFailure(ctx, abs, path, err);
|
|
629
905
|
}
|
|
630
906
|
|
|
631
907
|
return {
|
|
632
908
|
content: `wrote ${new TextEncoder().encode(content).length} bytes to ${path}`,
|
|
633
909
|
ok: true,
|
|
634
|
-
data: {
|
|
910
|
+
data: { ...data, changed: true },
|
|
635
911
|
effect: {
|
|
636
912
|
kind: "edit",
|
|
637
913
|
path,
|
|
638
|
-
sha256_before:
|
|
639
|
-
sha256_after:
|
|
914
|
+
sha256_before: previous,
|
|
915
|
+
sha256_after: version,
|
|
640
916
|
},
|
|
641
917
|
};
|
|
642
918
|
}
|
|
@@ -651,26 +927,21 @@ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
651
927
|
: [];
|
|
652
928
|
if (edits.length === 0) {
|
|
653
929
|
const message = "edit needs old_string and new_string, or edits[]";
|
|
654
|
-
return
|
|
930
|
+
return failure("invalid_input", message);
|
|
655
931
|
}
|
|
656
932
|
|
|
657
933
|
const abs = confine(ctx, path);
|
|
658
|
-
if (typeof abs !== "string") return
|
|
659
|
-
|
|
660
|
-
let before: string;
|
|
661
|
-
try {
|
|
662
|
-
before = await readFile(abs, "utf8");
|
|
663
|
-
} catch (err) {
|
|
664
|
-
return { content: `cannot read ${path}: ${(err as Error).message}`, ok: false };
|
|
665
|
-
}
|
|
934
|
+
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
666
935
|
|
|
667
|
-
const
|
|
936
|
+
const saved = await readEditableFile(ctx.pathRoot ?? ctx.root, abs);
|
|
937
|
+
const version = saved.bytes === null ? null : sha256(saved.bytes);
|
|
938
|
+
const checked = await filePrecondition(ctx, input, abs, version);
|
|
939
|
+
if (!checked.ok) return checked.result;
|
|
940
|
+
if (saved.bytes === null) return failure("not_found", `cannot read ${path}: file does not exist`, READ_ATTEMPTED);
|
|
668
941
|
const recover = { tool: "read", input: { path } };
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
diagnostic: { code: "version_mismatch", message, recover } };
|
|
673
|
-
}
|
|
942
|
+
let before: string;
|
|
943
|
+
try { before = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(saved.bytes); }
|
|
944
|
+
catch { return failure("invalid_input", `cannot edit ${path}: invalid UTF-8; byte-preserving inspection is required`, READ_ATTEMPTED); }
|
|
674
945
|
const plan = planEdits(before, edits);
|
|
675
946
|
if (!plan.ok) {
|
|
676
947
|
const closest = plan.closest === undefined ? "" :
|
|
@@ -681,38 +952,47 @@ async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
681
952
|
return { ok: false, content: `${message}${closest}\ncurrent sha256: ${version}`,
|
|
682
953
|
data: { path, sha256: version, occurrences: plan.occurrences, edit: plan.edit,
|
|
683
954
|
...(plan.closest === undefined ? {} : { closest: { start_line: plan.closest.startLine, end_line: plan.closest.endLine, score: plan.closest.score } }) },
|
|
684
|
-
diagnostic:
|
|
685
|
-
: { tool: "read", input: { path, start: plan.closest.startLine, end: plan.closest.endLine } } } };
|
|
955
|
+
diagnostic: failureDiagnostic(plan.code, message, READ_ATTEMPTED, { recover: plan.closest === undefined ? recover
|
|
956
|
+
: { tool: "read", input: { path, start: plan.closest.startLine, end: plan.closest.endLine } } }) };
|
|
686
957
|
}
|
|
687
958
|
const { after } = plan;
|
|
688
959
|
try {
|
|
689
|
-
await
|
|
960
|
+
await commitEdit(ctx, abs, saved, new TextEncoder().encode(after));
|
|
690
961
|
} catch (err) {
|
|
691
|
-
return
|
|
962
|
+
return fileCommitFailure(ctx, abs, path, err);
|
|
692
963
|
}
|
|
693
964
|
|
|
694
965
|
const fuzzy = plan.fuzzy ? " (fuzzy: matched ignoring trailing whitespace, quote and dash style; check the diff)" : "";
|
|
695
966
|
return {
|
|
696
967
|
...await presentObservation(ctx, `replaced ${plan.replacements} occurrence(s) in ${path}${fuzzy}\nsha256: ${sha256(after)}\n${plan.diff}`),
|
|
697
968
|
ok: true,
|
|
698
|
-
data: { path, replacements: plan.replacements, fuzzy: plan.fuzzy, first_changed_line: plan.firstLine, sha256_before: version, sha256_after: sha256(after) },
|
|
969
|
+
data: { path, changed: true, precondition: { ...checked.precondition }, replacements: plan.replacements, fuzzy: plan.fuzzy, first_changed_line: plan.firstLine, sha256_before: version, sha256_after: sha256(after) },
|
|
699
970
|
effect: {
|
|
700
971
|
kind: "edit",
|
|
701
972
|
path,
|
|
702
|
-
sha256_before:
|
|
973
|
+
sha256_before: version,
|
|
703
974
|
sha256_after: sha256(after),
|
|
704
975
|
},
|
|
705
976
|
};
|
|
706
977
|
}
|
|
707
978
|
|
|
979
|
+
async function toolEnvironment(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
980
|
+
const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
|
|
981
|
+
if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
|
|
982
|
+
const result = await observeEnvironment(ctx, cwd, String(input["program"]), input["probe"] === "python" ? "python" : "version", String(input["command"]));
|
|
983
|
+
return result.effect?.kind === "command" ? { ...result, effect: { ...result.effect, ...await commandFingerprint(ctx) } } : result;
|
|
984
|
+
}
|
|
985
|
+
|
|
708
986
|
async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
709
987
|
const command = String(input["command"] ?? "");
|
|
710
|
-
if (command.trim().length === 0) return
|
|
988
|
+
if (command.trim().length === 0) return failure("invalid_input", "empty command");
|
|
711
989
|
const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
|
|
712
|
-
if (typeof cwd !== "string") return
|
|
990
|
+
if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
|
|
713
991
|
const requestedMs = typeof input["timeout"] === "number" ? input["timeout"] * 1000 : undefined;
|
|
714
|
-
const timeoutMs =
|
|
992
|
+
const timeoutMs = commandTimeout(typeof input["timeout"] === "number" ? input["timeout"] : undefined, ctx.timeoutMs, ctx.maxTimeoutMs);
|
|
715
993
|
|
|
994
|
+
const observer = await beginCommandArtifacts(ctx, command, cwd, input["artifacts"]);
|
|
995
|
+
if (ctx.signal?.aborted) return cancelledTool();
|
|
716
996
|
const captured = await observeCommand({ command, cwd,
|
|
717
997
|
outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"),
|
|
718
998
|
maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
|
|
@@ -722,26 +1002,29 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
722
1002
|
|
|
723
1003
|
const gate = matchGate(ctx, command);
|
|
724
1004
|
const exit = captured.exit;
|
|
725
|
-
const
|
|
1005
|
+
const artifacts = await observer?.finish(captured);
|
|
1006
|
+
const observation = await commandFingerprint(ctx);
|
|
726
1007
|
const header = commandHeader({ exit, wallMs: captured.wallMs, timeoutMs,
|
|
727
1008
|
...(requestedMs !== undefined ? { requestedMs } : {}),
|
|
728
1009
|
...(captured.stopped ? { stopped: captured.stopped } : {}) });
|
|
1010
|
+
const artifactSummary = commandArtifactSummary(artifacts);
|
|
729
1011
|
|
|
730
1012
|
return {
|
|
731
1013
|
// The artifact starts with `exit N\n`; the view replaces that line with
|
|
732
1014
|
// the full header and pages the rest, so offsets stay artifact offsets.
|
|
733
|
-
content: header + "\n" + observationView(captured,
|
|
1015
|
+
content: header + artifactSummary + "\n" + observationView(captured,
|
|
1016
|
+
ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, captured.prefixBytes, adviceTools(ctx)),
|
|
734
1017
|
content_ref: captured.ref,
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
data: { exit, output: captured.ref },
|
|
1018
|
+
...commandCompletion(exit, captured.stopped, captured.cleanup),
|
|
1019
|
+
data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
|
|
738
1020
|
effect: {
|
|
739
1021
|
kind: "command",
|
|
740
1022
|
cmd: command,
|
|
741
1023
|
exit,
|
|
742
1024
|
// Attached from configured patterns, never guessed from the text.
|
|
743
1025
|
...(gate !== undefined ? { gate } : {}),
|
|
744
|
-
...
|
|
1026
|
+
...observation,
|
|
1027
|
+
...(artifacts ? { artifacts } : {}),
|
|
745
1028
|
},
|
|
746
1029
|
};
|
|
747
1030
|
}
|
|
@@ -759,61 +1042,100 @@ const FINGERPRINT_IGNORE: readonly string[] = [...DEFAULT_IGNORE,
|
|
|
759
1042
|
"target", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox",
|
|
760
1043
|
".gradle", ".next", ".turbo", ".cache"];
|
|
761
1044
|
|
|
1045
|
+
async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<ReturnType<typeof readEditableFile>>, after: Uint8Array): Promise<void> {
|
|
1046
|
+
const path = relative(ctx.root, absolute);
|
|
1047
|
+
if (ctx.workspace && path !== ".." && !path.startsWith(`..${sep}`)) await ctx.workspace.edit(path, before, after, ctx.signal);
|
|
1048
|
+
else await replaceFile(ctx.pathRoot ?? ctx.root, absolute, before.version, after, before.version?.mode ?? (0o666 & ~process.umask()), ctx.signal);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function commandFingerprint(ctx: ToolContext): Promise<{ workspace_sha256?: string; workspace_observed?: boolean; workspace_scope?: "source-v1" }> {
|
|
1052
|
+
if (ctx.legacyWorkspace) {
|
|
1053
|
+
const observed = await scanWorkspace(ctx.root, FINGERPRINT_IGNORE);
|
|
1054
|
+
return observed.ok ? { workspace_sha256: workspaceVersion(observed.files) } : {};
|
|
1055
|
+
}
|
|
1056
|
+
const observed = await observeWorkspace(ctx.root, ctx.signal, ctx.ownedStatePaths);
|
|
1057
|
+
return observed.ok ? { workspace_sha256: observed.fingerprint, workspace_observed: true, workspace_scope: "source-v1" } : { workspace_observed: false, workspace_scope: "source-v1" };
|
|
1058
|
+
}
|
|
1059
|
+
|
|
762
1060
|
const clampYield = (value: unknown) =>
|
|
763
1061
|
Math.min(MAX_YIELD_MS, Math.max(MIN_YIELD_MS, typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_YIELD_MS));
|
|
764
1062
|
|
|
765
1063
|
async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
766
1064
|
const command = String(input["cmd"] ?? "");
|
|
767
|
-
if (command.trim().length === 0) return
|
|
1065
|
+
if (command.trim().length === 0) return failure("invalid_input", "empty command");
|
|
768
1066
|
const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
|
|
769
|
-
if (typeof cwd !== "string") return
|
|
770
|
-
const
|
|
771
|
-
const timeoutMs = Math.min(requestedMs ?? ctx.timeoutMs, ctx.maxTimeoutMs ?? ctx.timeoutMs);
|
|
1067
|
+
if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
|
|
1068
|
+
const timeoutMs = commandTimeout(typeof input["timeout"] === "number" ? input["timeout"] : undefined, ctx.timeoutMs, ctx.maxTimeoutMs);
|
|
772
1069
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
773
|
-
const
|
|
774
|
-
if (
|
|
775
|
-
await
|
|
776
|
-
|
|
1070
|
+
const artifactObserver = await beginCommandArtifacts(ctx, command, cwd, input["artifacts"]);
|
|
1071
|
+
if (ctx.signal?.aborted) return cancelledTool();
|
|
1072
|
+
const session = await startSession({ command, cwd, tty: input["tty"] === true, timeoutMs, spoolDir: outputStore,
|
|
1073
|
+
source: { host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), cwd, runtime: `bun/${Bun.version}` },
|
|
1074
|
+
...(artifactObserver ? { artifactObserver } : {}),
|
|
1075
|
+
...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
|
|
1076
|
+
if ("error" in session) return failure("execution_failed", session.error);
|
|
1077
|
+
return sessionResult(ctx, command, timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), "", ctx.signal));
|
|
777
1078
|
}
|
|
778
1079
|
|
|
779
1080
|
async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
780
1081
|
const id = Number(input["session_id"]);
|
|
781
|
-
const
|
|
1082
|
+
const chars = typeof input["chars"] === "string" ? input["chars"] : "";
|
|
1083
|
+
const session = findSession(id, ctx.execOwner);
|
|
782
1084
|
if (session === undefined) {
|
|
783
|
-
const
|
|
784
|
-
return {
|
|
785
|
-
|
|
1085
|
+
const closed = closedSession(id, ctx.execOwner);
|
|
1086
|
+
if (closed) return receiptResult(ctx, { ...closed.receipt, replayed: true }, chars);
|
|
1087
|
+
const original = await ctx.observedProcess?.(id);
|
|
1088
|
+
if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin), chars);
|
|
1089
|
+
const running = runningSessions(ctx.execOwner).map((s) => s.id).join(", ");
|
|
1090
|
+
return { ...failure("unknown_effect", `no running session ${id}; no observation in this session's active history` +
|
|
1091
|
+
(running.length > 0 ? `; running sessions: ${running}` : ""), UNKNOWN_EFFECT),
|
|
1092
|
+
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
786
1093
|
}
|
|
787
|
-
const chars = typeof input["chars"] === "string" ? input["chars"] : "";
|
|
788
|
-
if (chars.length > 0 && session.exit === null) session.write(chars);
|
|
789
|
-
await waitSession(session, clampYield(input["yield_time_ms"]), ctx.signal);
|
|
790
1094
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
791
|
-
return sessionResult(ctx,
|
|
1095
|
+
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
|
1099
|
+
function receiptResult(ctx: ToolContext, receipt: ProcessReceipt, chars: string): ToolResult {
|
|
1100
|
+
const location = receipt.origin ? `; original node ${receipt.origin.node} event ${receipt.origin.event}` : "";
|
|
1101
|
+
const observed = receipt.running === false ? `last observed ${receipt.state}, exit ${receipt.exit}` : "last observed running; current process state unknown";
|
|
1102
|
+
const artifact = receipt.full_output ?? receipt.output;
|
|
1103
|
+
const content = `session ${receipt.session_id}: ${observed}; control ${receipt.capability}; historical receipt, no execution${location}` + commandArtifactSummary(receipt.artifacts) +
|
|
1104
|
+
(artifact ? `\nCaptured output: ${artifactRecovery(artifact.sha256, 0, adviceTools(ctx))}` : "");
|
|
1105
|
+
return { ...(chars.length > 0 ? failure("not_found", "no live handle; input was not sent", NOT_STARTED)
|
|
1106
|
+
: receipt.running === false ? { ok: true } : failure("unknown_effect", "live handle lost; inspect existing state before replay", UNKNOWN_EFFECT)),
|
|
1107
|
+
content: content + (chars.length > 0 ? "\nInput was not sent: no live handle." : ""), data: receipt,
|
|
1108
|
+
...(receipt.output ? { content_ref: receipt.output, content_sha256: receipt.output.sha256 } : {}) };
|
|
792
1109
|
}
|
|
793
1110
|
|
|
794
1111
|
/**
|
|
795
1112
|
* One call's view of a session. A running session proves nothing yet, so it
|
|
796
1113
|
* carries no effect; only an observed exit does, exactly as bash's would (D11).
|
|
797
1114
|
*/
|
|
798
|
-
async function sessionResult(ctx: ToolContext,
|
|
799
|
-
taken: Awaited<ReturnType<typeof
|
|
1115
|
+
export async function sessionResult(ctx: ToolContext, command: string, timeoutMs: number,
|
|
1116
|
+
taken: Awaited<ReturnType<typeof pollSession>>): Promise<ToolResult> {
|
|
800
1117
|
const { stored, exit } = taken;
|
|
1118
|
+
const id = taken.receipt.session_id;
|
|
1119
|
+
if (taken.receipt.replayed) return receiptResult(ctx, taken.receipt, "");
|
|
801
1120
|
const header = exit === null
|
|
802
|
-
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed;
|
|
803
|
-
|
|
804
|
-
|
|
1121
|
+
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
|
|
1122
|
+
? `poll or send input with write_stdin(session_id=${id})`
|
|
1123
|
+
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1124
|
+
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed`;
|
|
1125
|
+
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1126
|
+
const content = header + artifactSummary + "\n" + observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx));
|
|
1127
|
+
const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
|
|
805
1128
|
if (exit === null) {
|
|
806
|
-
return { ok: true, content, content_ref: stored.ref,
|
|
1129
|
+
return { ok: true, content, content_ref: stored.ref, content_sha256: stored.ref.sha256, data: taken.receipt, ...(warnings.length ? { warnings } : {}) };
|
|
807
1130
|
}
|
|
808
1131
|
const gate = matchGate(ctx, command);
|
|
809
|
-
const
|
|
1132
|
+
const observation = await commandFingerprint(ctx);
|
|
810
1133
|
return {
|
|
811
|
-
|
|
812
|
-
content, content_ref: stored.ref,
|
|
813
|
-
|
|
814
|
-
data: { session_id: id, running: false, exit, output: stored.ref },
|
|
1134
|
+
...commandCompletion(exit, taken.stopped),
|
|
1135
|
+
content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
|
|
1136
|
+
data: taken.receipt, ...(warnings.length ? { warnings } : {}),
|
|
815
1137
|
effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
|
|
816
|
-
...(
|
|
1138
|
+
...observation, ...(taken.receipt.artifacts ? { artifacts: taken.receipt.artifacts } : {}) },
|
|
817
1139
|
};
|
|
818
1140
|
}
|
|
819
1141
|
|
|
@@ -825,11 +1147,11 @@ const utf8Length = (text: string) => new TextEncoder().encode(text).length;
|
|
|
825
1147
|
* The marker names the artifact and the byte offset where the gap starts, so
|
|
826
1148
|
* the missing middle is one `history` call away rather than a rerun.
|
|
827
1149
|
*/
|
|
828
|
-
function observationView(stored: ArchivedObservation, limit: number, skip = 0): string {
|
|
1150
|
+
function observationView(stored: ArchivedObservation, limit: number, skip = 0, tools?: readonly string[]): string {
|
|
829
1151
|
const { ref } = stored;
|
|
830
1152
|
const marker = (omitted: number, offset: number) =>
|
|
831
1153
|
`\n[truncated ${omitted} bytes at offset ${offset + skip}; full output: ${ref.path}; ` +
|
|
832
|
-
|
|
1154
|
+
`${artifactRecovery(ref.sha256, offset + skip, tools)}]\n`;
|
|
833
1155
|
const total = ref.bytes - skip;
|
|
834
1156
|
const tail = stored.tail.subarray(Math.max(0, stored.tail.length - total));
|
|
835
1157
|
return headTailView(stored.head.subarray(skip), tail, total, Math.max(0, limit), marker);
|
|
@@ -840,7 +1162,7 @@ export async function presentObservation(ctx: ToolContext, content: string): Pro
|
|
|
840
1162
|
if (bytes.length <= ctx.maxOutputBytes) return { content, content_sha256: sha256(bytes) };
|
|
841
1163
|
async function* source(): AsyncGenerator<Uint8Array> { yield bytes; }
|
|
842
1164
|
const stored = await archiveObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), source(), ctx.maxOutputBytes);
|
|
843
|
-
return { content: observationView(stored, ctx.maxOutputBytes), content_ref: stored.ref, content_sha256: stored.ref.sha256 };
|
|
1165
|
+
return { content: observationView(stored, ctx.maxOutputBytes, 0, adviceTools(ctx)), content_ref: stored.ref, content_sha256: stored.ref.sha256 };
|
|
844
1166
|
}
|
|
845
1167
|
|
|
846
1168
|
function toolPath(ctx: ToolContext, input: Record<string, unknown>): string | { error: string } {
|
|
@@ -861,7 +1183,7 @@ function grepEngine(input: Record<string, unknown>): "auto" | "ripgrep" | "walk"
|
|
|
861
1183
|
|
|
862
1184
|
async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
863
1185
|
const abs = toolPath(ctx, input);
|
|
864
|
-
if (typeof abs !== "string") return
|
|
1186
|
+
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
865
1187
|
const glob = typeof input["glob"] === "string" ? input["glob"] : undefined;
|
|
866
1188
|
const limit = optionalLimit(input);
|
|
867
1189
|
const result = await grep({
|
|
@@ -870,16 +1192,50 @@ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
870
1192
|
pattern: String(input["pattern"] ?? ""),
|
|
871
1193
|
engine: grepEngine(input),
|
|
872
1194
|
timeoutMs: ctx.timeoutMs,
|
|
1195
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
873
1196
|
...(glob !== undefined ? { glob } : {}),
|
|
874
1197
|
...(limit !== undefined ? { limit } : {}),
|
|
875
1198
|
});
|
|
876
|
-
return
|
|
1199
|
+
return presentSearch(ctx, "grep", input, abs, result);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function presentSearch(ctx: ToolContext, tool: string, input: Record<string, unknown>, resource: string, result: SearchResult): Promise<ToolResult> {
|
|
1203
|
+
const observation = result.observation;
|
|
1204
|
+
if (!observation) return { ok: result.ok, ...await presentObservation(ctx, result.content), ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}) };
|
|
1205
|
+
const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1206
|
+
const archive = async (bytes: Uint8Array) => {
|
|
1207
|
+
async function* source() { yield bytes; }
|
|
1208
|
+
return (await archiveObservation(store, source(), 0)).ref;
|
|
1209
|
+
};
|
|
1210
|
+
const full = await archive(new TextEncoder().encode(searchObservationText(observation.rows)));
|
|
1211
|
+
const raw = observation.raw ? await archive(observation.raw) : undefined;
|
|
1212
|
+
const rawErrors = observation.rawErrors?.length ? await archive(observation.rawErrors) : undefined;
|
|
1213
|
+
const manifest = await archive(new TextEncoder().encode(JSON.stringify({ version: 1, tool, input, resource, engine: observation.engine,
|
|
1214
|
+
scan: observation.scan, sources: observation.sources, scope: { ignored: DEFAULT_IGNORE, follows_symlinks: false,
|
|
1215
|
+
max_file_bytes: tool === "grep" ? MAX_FILE_BYTES : null, execution_byte_budget: MAX_SEARCH_CAPTURE_BYTES },
|
|
1216
|
+
matches: observation.rows.length, artifact: full, ...(raw ? { raw } : {}), ...(rawErrors ? { raw_errors: rawErrors } : {}) })));
|
|
1217
|
+
const preview = previewSearchObservation(observation, full.sha256, ctx.maxOutputBytes, 500,
|
|
1218
|
+
`[engine=${observation.engine}; scan=${observation.scan.status}; sources=${observation.scan.source_versions}; manifest=${manifest.sha256}]` +
|
|
1219
|
+
(result.diagnostic ? `\n[${result.diagnostic.message}]` : ""), adviceTools(ctx));
|
|
1220
|
+
const data = { kind: "search", version: 1, source: { host: ctx.hostId ?? hostname(), workspace: ctx.root, resource },
|
|
1221
|
+
engine: observation.engine, scan: { status: observation.scan.status, reasons: [...new Set(observation.scan.reasons.map(reason => reason.code))],
|
|
1222
|
+
examined_files: observation.scan.examined_files, source_versions: observation.scan.source_versions },
|
|
1223
|
+
matches: observation.rows.length, artifact: full, manifest, ...(raw ? { raw } : {}), ...(rawErrors ? { raw_errors: rawErrors } : {}), preview: preview.preview };
|
|
1224
|
+
if (!isJsonValue(data)) throw new Error("search produced a non-JSON receipt");
|
|
1225
|
+
return { ok: result.ok, content: preview.text, content_ref: full, content_sha256: full.sha256,
|
|
1226
|
+
observation_sha256: sha256(preview.semantic), data, ...(result.diagnostic ? { diagnostic: result.diagnostic,
|
|
1227
|
+
...(result.diagnostic.code === "cancelled" || result.diagnostic.code === "timeout" ? { outcome: executionOutcome(result.diagnostic.code) } : {}) } : {}) };
|
|
877
1228
|
}
|
|
878
1229
|
|
|
879
1230
|
async function toolQuery(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1231
|
+
let query: ReturnType<typeof parseQueryInput>;
|
|
1232
|
+
try { query = parseQueryInput(input); }
|
|
1233
|
+
catch (error) { return failure("invalid_input", String(error)); }
|
|
1234
|
+
const { text, sources } = await readQuerySources(ctx.root, input["paths"], ctx.signal);
|
|
1235
|
+
checkSourceCancellation(ctx.signal);
|
|
1236
|
+
let value: { sources: typeof sources } & ReturnType<typeof queryData>;
|
|
1237
|
+
try { value = { sources, ...queryData(text, query) }; }
|
|
1238
|
+
catch (error) { return failure("invalid_input", String(error), READ_ATTEMPTED); }
|
|
883
1239
|
if (!isJsonValue(value)) throw new Error("query produced a non-JSON value");
|
|
884
1240
|
const observation = await presentObservation(ctx, JSON.stringify(value));
|
|
885
1241
|
return { ok: true, ...observation, data: observation.content_ref ? { artifact: observation.content_ref, format: "json" } : value };
|
|
@@ -887,28 +1243,30 @@ async function toolQuery(ctx: ToolContext, input: Record<string, unknown>): Prom
|
|
|
887
1243
|
|
|
888
1244
|
async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
889
1245
|
const abs = toolPath(ctx, input);
|
|
890
|
-
if (typeof abs !== "string") return
|
|
1246
|
+
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
891
1247
|
const limit = optionalLimit(input);
|
|
892
1248
|
const result = await findFiles({
|
|
893
1249
|
root: searchRoot(ctx, abs),
|
|
894
1250
|
path: abs,
|
|
895
1251
|
pattern: String(input["pattern"] ?? ""),
|
|
896
1252
|
timeoutMs: ctx.timeoutMs,
|
|
1253
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
897
1254
|
...(limit !== undefined ? { limit } : {}),
|
|
898
1255
|
});
|
|
899
|
-
return
|
|
1256
|
+
return presentSearch(ctx, "find", input, abs, result);
|
|
900
1257
|
}
|
|
901
1258
|
|
|
902
1259
|
async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
903
1260
|
const abs = toolPath(ctx, input);
|
|
904
|
-
if (typeof abs !== "string") return
|
|
1261
|
+
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
905
1262
|
const limit = optionalLimit(input);
|
|
906
1263
|
const result = await ls({
|
|
907
1264
|
root: searchRoot(ctx, abs),
|
|
908
1265
|
path: abs,
|
|
1266
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
909
1267
|
...(limit !== undefined ? { limit } : {}),
|
|
910
1268
|
});
|
|
911
|
-
return
|
|
1269
|
+
return presentSearch(ctx, "ls", input, abs, result);
|
|
912
1270
|
}
|
|
913
1271
|
|
|
914
1272
|
/**
|
|
@@ -924,11 +1282,12 @@ export const BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch ===
|
|
|
924
1282
|
/** Writing adds effects to the shared evidence tools; new readers cannot go missing. */
|
|
925
1283
|
export const WRITING_BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch !== "none").map(tool => tool.name);
|
|
926
1284
|
|
|
927
|
-
export function branchTools(writes = false): ToolSchema[] {
|
|
1285
|
+
export function branchTools(writes = false, only?: readonly string[]): ToolSchema[] {
|
|
928
1286
|
const allowed = new Set<string>(
|
|
929
1287
|
writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES,
|
|
930
1288
|
);
|
|
931
|
-
|
|
1289
|
+
const definitions = TOOL_DEFINITIONS.filter(t => allowed.has(t.name) && (only === undefined || only.includes(t.name)));
|
|
1290
|
+
return definitions.map(tool => modelToolSchema(tool, definitions.map(item => item.name)));
|
|
932
1291
|
}
|
|
933
1292
|
|
|
934
1293
|
/**
|
|
@@ -951,15 +1310,16 @@ export async function gateTool(
|
|
|
951
1310
|
// the approval prompt. The model should not have seen it; if a call
|
|
952
1311
|
// arrives anyway it is answered, not executed.
|
|
953
1312
|
if (ctx.allow && !ctx.allow.includes(name)) {
|
|
954
|
-
return {
|
|
955
|
-
content: `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`,
|
|
956
|
-
ok: false,
|
|
957
|
-
};
|
|
1313
|
+
return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
|
|
958
1314
|
}
|
|
959
1315
|
const definition = toolDefinition(name, ctx);
|
|
960
1316
|
input = repairToolInput(name, input);
|
|
961
1317
|
const invalid = definition ? inputError(definition.input_schema, input, "input", definition.hiddenInputs) : null;
|
|
962
|
-
if (invalid)
|
|
1318
|
+
if (invalid) {
|
|
1319
|
+
const example = minimalInput(definition!.input_schema);
|
|
1320
|
+
return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
|
|
1321
|
+
inputError(definition!.input_schema, example) === null ? { example: example as { [key: string]: JsonValue } } : {});
|
|
1322
|
+
}
|
|
963
1323
|
if (ctx.rules === undefined) return null;
|
|
964
1324
|
|
|
965
1325
|
const request = permissionRequest(name, input);
|
|
@@ -969,13 +1329,15 @@ export async function gateTool(
|
|
|
969
1329
|
case "allow":
|
|
970
1330
|
return null;
|
|
971
1331
|
case "deny":
|
|
972
|
-
return
|
|
1332
|
+
return failure("permission_denied", deniedMessage(request));
|
|
973
1333
|
case "ask": {
|
|
974
1334
|
const ask = ctx.ask ?? ctx.askSlot?.current;
|
|
975
1335
|
if (ask === undefined) {
|
|
976
|
-
return
|
|
1336
|
+
return failure("permission_denied", askWithoutTtyMessage(request));
|
|
977
1337
|
}
|
|
978
|
-
|
|
1338
|
+
let choice: ApprovalChoice | undefined;
|
|
1339
|
+
try { choice = await waitForAdmission(ask(request), ctx.signal); }
|
|
1340
|
+
catch (error) { return caughtFailure(error, NOT_STARTED, "approval unavailable: "); }
|
|
979
1341
|
if (choice === undefined || ctx.signal?.aborted) return cancelledTool();
|
|
980
1342
|
switch (choice) {
|
|
981
1343
|
case "once":
|
|
@@ -997,7 +1359,7 @@ export async function gateTool(
|
|
|
997
1359
|
return null;
|
|
998
1360
|
}
|
|
999
1361
|
case "deny":
|
|
1000
|
-
return
|
|
1362
|
+
return failure("permission_denied", deniedMessage(request));
|
|
1001
1363
|
default:
|
|
1002
1364
|
return assertNever(choice, "gateTool");
|
|
1003
1365
|
}
|
|
@@ -1018,21 +1380,79 @@ export async function runTool(
|
|
|
1018
1380
|
|
|
1019
1381
|
/** O.5: ordered admission returns a capability for this exact call, never a policy bypass flag. */
|
|
1020
1382
|
export async function prepareTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult | (() => Promise<ToolResult>)> {
|
|
1021
|
-
try { input =
|
|
1022
|
-
catch (error) { return finishToolResult(
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1383
|
+
try { input = normalizeToolInput(ctx, name, input); }
|
|
1384
|
+
catch (error) { return scopeToolAdvice(ctx, finishToolResult(failure("invalid_input", `Invalid ${name} arguments: ${String(error)}`))); }
|
|
1385
|
+
return prepareNormalizedTool(ctx, name, input);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/** Shared with the broker so the durable invocation names the arguments actually admitted. */
|
|
1389
|
+
export function normalizeToolInput(ctx: ToolContext, name: string, input: unknown): unknown {
|
|
1390
|
+
// Freeze the adapter's normalized arguments before admission and durable logging.
|
|
1391
|
+
return repairToolInput(name, toolDefinition(name, ctx)?.prepareArguments?.(input) ?? input);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** D11: notification observers receive values, never capabilities or mutable execution evidence. */
|
|
1395
|
+
function observerValue<T>(value: T): T {
|
|
1396
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value)) as T;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/** Normalization is not admission: this entrance still enforces every permission and hook. */
|
|
1400
|
+
export async function prepareNormalizedTool(ctx: ToolContext, name: string, input: unknown,
|
|
1401
|
+
prepareAction?: () => Promise<() => Promise<ToolResult>>): Promise<ToolResult | (() => Promise<ToolResult>)> {
|
|
1402
|
+
const scoped = <T>(action: () => T): T => ctx.extensionHooks?.scope ? ctx.extensionHooks.scope(ctx, action) : action();
|
|
1403
|
+
try {
|
|
1404
|
+
const gated = await gateTool(ctx, name, input);
|
|
1405
|
+
if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
|
|
1406
|
+
const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
|
|
1407
|
+
await ctx.drainNested?.();
|
|
1408
|
+
if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
|
|
1409
|
+
} catch (error) { return scopeToolAdvice(ctx, finishToolResult(caughtFailure(error, UNKNOWN_EFFECT))); }
|
|
1410
|
+
let owned: (() => Promise<ToolResult>) | undefined;
|
|
1411
|
+
let begun = false;
|
|
1412
|
+
const lifecycle = { toolName: name, toolCallId: ctx.callId, ...(ctx.parentCallId ? { parentToolCallId: ctx.parentCallId } : {}), ...(ctx.origin ? { origin: ctx.origin } : {}) };
|
|
1413
|
+
if (prepareAction) {
|
|
1414
|
+
// Branch spawn preparation is ordered after admission and before siblings
|
|
1415
|
+
// begin, under the same lifecycle observers as ordinary workspace tools.
|
|
1416
|
+
try {
|
|
1417
|
+
begun = true;
|
|
1418
|
+
await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
|
|
1419
|
+
await ctx.drainNested?.();
|
|
1420
|
+
owned = ctx.signal?.aborted ? async () => cancelledTool() : await prepareAction();
|
|
1421
|
+
} catch (error) { owned = async () => { throw error; }; }
|
|
1422
|
+
}
|
|
1027
1423
|
return async () => {
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1424
|
+
let observed: ToolResult | undefined;
|
|
1425
|
+
const warnings: ToolDiagnostic[] = [], updates = new Set<Promise<void>>();
|
|
1426
|
+
try {
|
|
1427
|
+
if (!begun) await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
|
|
1428
|
+
await ctx.drainNested?.();
|
|
1429
|
+
const execute = async () => {
|
|
1430
|
+
const executionContext: ToolContext = { ...ctx, onOutput: chunk => {
|
|
1431
|
+
ctx.onOutput?.(chunk);
|
|
1432
|
+
const update = scoped(() => ctx.extensionHooks?.event("tool_execution_update", { ...lifecycle, partialResult: { content: [{ type: "text", text: new TextDecoder().decode(chunk) }] } }));
|
|
1433
|
+
if (update) {
|
|
1434
|
+
const pending = update.catch(error => { warnings.push(failureFromError(error, UNKNOWN_EFFECT)); }).finally(() => { updates.delete(pending); });
|
|
1435
|
+
updates.add(pending);
|
|
1436
|
+
}
|
|
1437
|
+
} };
|
|
1438
|
+
observed = owned ? finishToolResult(ctx.signal?.aborted ? cancelledTool() : await owned(),
|
|
1439
|
+
toolDefinition(name, ctx)?.access === "read" ? READ_ATTEMPTED : UNKNOWN_EFFECT) : await dispatchTool(executionContext, name, input);
|
|
1440
|
+
return observed;
|
|
1441
|
+
};
|
|
1442
|
+
const command = ["bash", "environment", "target", "exec_command", "write_stdin", NATIVE_COMMAND_NAME].includes(name);
|
|
1443
|
+
const operation = () => ctx.workspace && command ? ctx.workspace.command(name, input, execute, ctx.signal) : execute();
|
|
1444
|
+
// Progress handlers may enqueue native commands. They run only after this
|
|
1445
|
+
// process drains, before the outer receipt; awaiting them inside it deadlocks.
|
|
1446
|
+
observed = command && ctx.runOwnedCommand ? await ctx.runOwnedCommand(operation) : await operation();
|
|
1447
|
+
await scoped(() => ctx.extensionHooks?.event("tool_result", { ...lifecycle, input: observerValue(input), content: [{ type: "text", text: observed!.content }], isError: !observed!.ok, details: observerValue(observed!.data) }));
|
|
1448
|
+
await scoped(() => ctx.extensionHooks?.event("tool_execution_end", { ...lifecycle, result: observerValue(observed), isError: !observed!.ok }));
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
// Notification/checkpoint failures after an observed operation cannot erase its actual result.
|
|
1451
|
+
if (observed) warnings.push(failureFromError(error, UNKNOWN_EFFECT));
|
|
1452
|
+
else observed = finishToolResult(caughtFailure(error, UNKNOWN_EFFECT));
|
|
1453
|
+
}
|
|
1454
|
+
await Promise.all(updates);
|
|
1455
|
+
return scopeToolAdvice(ctx, warnings.length ? { ...observed, warnings: [...(observed.warnings ?? []), ...warnings] } : observed);
|
|
1036
1456
|
};
|
|
1037
1457
|
}
|
|
1038
1458
|
|
|
@@ -1040,30 +1460,36 @@ async function dispatchTool(ctx: ToolContext, name: string, input: unknown): Pro
|
|
|
1040
1460
|
if (ctx.signal?.aborted) return finishToolResult(cancelledTool());
|
|
1041
1461
|
const impl = toolDefinition(name, ctx)?.run;
|
|
1042
1462
|
if (!impl) {
|
|
1043
|
-
const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined).map((tool) => tool.name));
|
|
1044
|
-
return finishToolResult(
|
|
1463
|
+
const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined && (ctx.allow === undefined || ctx.allow.includes(tool.name))).map((tool) => tool.name));
|
|
1464
|
+
return finishToolResult(failure("invalid_input", `unknown tool: ${name}` + (near === undefined ? "" : `; did you mean ${near}?`)));
|
|
1045
1465
|
}
|
|
1046
1466
|
if (typeof input !== "object" || input === null) {
|
|
1047
|
-
return finishToolResult(
|
|
1467
|
+
return finishToolResult(failure("invalid_input", `tool ${name} needs an object input`));
|
|
1048
1468
|
}
|
|
1049
1469
|
try {
|
|
1050
1470
|
const result = await impl(ctx, repairToolInput(name, input) as Record<string, unknown>);
|
|
1051
|
-
return finishToolResult(result);
|
|
1471
|
+
return finishToolResult(result, toolDefinition(name, ctx)?.access === "read" && !ctx.extensions?.has(name) ? READ_ATTEMPTED : UNKNOWN_EFFECT);
|
|
1052
1472
|
} catch (err) {
|
|
1053
1473
|
// A tool that throws must still produce a result: an unanswered tool_use
|
|
1054
1474
|
// makes the next request unpairable.
|
|
1055
|
-
return finishToolResult(
|
|
1475
|
+
return finishToolResult(caughtFailure(err, toolDefinition(name, ctx)?.access === "read" && !ctx.extensions?.has(name) ? READ_ATTEMPTED : UNKNOWN_EFFECT, `tool ${name} failed: `));
|
|
1056
1476
|
}
|
|
1057
1477
|
}
|
|
1058
1478
|
|
|
1059
1479
|
/** One finalizer also covers session-owned tools; result text never determines success. */
|
|
1060
|
-
export function finishToolResult(result: ToolResult): ToolResult {
|
|
1061
|
-
return { ...result, outcome: result.outcome ?? executionOutcome(
|
|
1062
|
-
|
|
1480
|
+
export function finishToolResult(result: ToolResult, facts = UNKNOWN_EFFECT): ToolResult {
|
|
1481
|
+
if (result.ok) return { ...result, outcome: result.outcome ?? executionOutcome("tool_completed") };
|
|
1482
|
+
const reason = result.outcome?.reason;
|
|
1483
|
+
const actual = result.effect?.kind === "command" ? { execution: "started" as const, effects: "possible" as const } : facts;
|
|
1484
|
+
const code = reason === "cancelled" || reason === "not_started" ? "cancelled" : reason === "timeout" ? "timeout"
|
|
1485
|
+
: result.effect?.kind === "command" ? "command_failed" : actual.effects === "possible" ? "unknown_effect" : "execution_failed";
|
|
1486
|
+
const diagnostic = result.diagnostic?.execution !== undefined ? result.diagnostic : failureDiagnostic(result.diagnostic?.code ?? code,
|
|
1487
|
+
result.diagnostic?.message ?? result.content, actual, { ...(result.diagnostic?.recover ? { recover: result.diagnostic.recover } : {}) });
|
|
1488
|
+
return { ...result, diagnostic, outcome: result.outcome ?? executionOutcome("tool_failed") };
|
|
1063
1489
|
}
|
|
1064
1490
|
|
|
1065
1491
|
function cancelledTool(): ToolResult {
|
|
1066
|
-
return {
|
|
1492
|
+
return { ...failure("cancelled", "interrupted: cancelled before this tool started"), outcome: executionOutcome("not_started") };
|
|
1067
1493
|
}
|
|
1068
1494
|
|
|
1069
1495
|
export async function workspaceRoot(path: string): Promise<string> {
|
|
@@ -1095,7 +1521,9 @@ export type Annotation =
|
|
|
1095
1521
|
proof?: string;
|
|
1096
1522
|
work_id?: string;
|
|
1097
1523
|
}
|
|
1098
|
-
| { t: "next_action"; what: string; next_action: string; work_id?: string }
|
|
1524
|
+
| { t: "next_action"; what: string; next_action: string; work_id?: string; depends_on?: readonly string[] }
|
|
1525
|
+
| { t: "work_dependencies"; what: string; work_id: string; depends_on: readonly string[] }
|
|
1526
|
+
| { t: "work_owner"; what: string; work_id: string; operation: "claim" | "release" }
|
|
1099
1527
|
| { t: "decision"; chose: string; because: string; rejected?: readonly string[] }
|
|
1100
1528
|
| { t: "pitfall"; tried: string; failed_because: string }
|
|
1101
1529
|
| { t: "blocked"; what: string; reason: string; work_id?: string }
|
|
@@ -1109,6 +1537,12 @@ export interface NoteOutcome {
|
|
|
1109
1537
|
}
|
|
1110
1538
|
|
|
1111
1539
|
export function parseNote(input: unknown): NoteOutcome {
|
|
1540
|
+
const parsed = parseNoteInput(input);
|
|
1541
|
+
return parsed.result.ok ? parsed : { ...parsed,
|
|
1542
|
+
result: { ...failure("invalid_input", parsed.result.content), data: { recorded: false } } };
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function parseNoteInput(input: unknown): NoteOutcome {
|
|
1112
1546
|
if (typeof input !== "object" || input === null) {
|
|
1113
1547
|
return { result: { content: "note needs an object input", ok: false } };
|
|
1114
1548
|
}
|
|
@@ -1121,8 +1555,11 @@ export function parseNote(input: unknown): NoteOutcome {
|
|
|
1121
1555
|
return { result: { ok: false, content: "work_id must be a non-empty string" } };
|
|
1122
1556
|
}
|
|
1123
1557
|
const identity = typeof id === "string" ? { work_id: id.trim() } : {};
|
|
1124
|
-
|
|
1125
|
-
|
|
1558
|
+
const deps = o["depends_on"];
|
|
1559
|
+
if (deps !== undefined && (identity.work_id === undefined || !["next", "depend"].includes(kind) || !Array.isArray(deps) ||
|
|
1560
|
+
deps.some(value => typeof value !== "string" || !value.trim()))) return { result: { ok: false, content: "depends_on requires next/depend with a work_id and explicit non-empty IDs" } };
|
|
1561
|
+
if (id !== undefined && !["next", "done", "blocked", "unblock", "reopen", "cancel", "pin", "decision", "pitfall", "depend", "claim_work", "release_work"].includes(kind)) {
|
|
1562
|
+
return { result: { ok: false, content: "work_id applies to work lifecycle, decisions, pitfalls and pins" } };
|
|
1126
1563
|
}
|
|
1127
1564
|
|
|
1128
1565
|
if (what.length === 0) {
|
|
@@ -1130,6 +1567,14 @@ export function parseNote(input: unknown): NoteOutcome {
|
|
|
1130
1567
|
}
|
|
1131
1568
|
|
|
1132
1569
|
switch (kind) {
|
|
1570
|
+
case "depend": {
|
|
1571
|
+
if (!identity.work_id || !Array.isArray(deps)) return { result: { ok: false, content: "depend requires work_id and depends_on" } };
|
|
1572
|
+
return { result: { ok: true, content: "recorded dependencies" }, annotation: { t: "work_dependencies", what, work_id: identity.work_id, depends_on: deps as string[] } };
|
|
1573
|
+
}
|
|
1574
|
+
case "claim_work": case "release_work": {
|
|
1575
|
+
if (!identity.work_id) return { result: { ok: false, content: "work ownership requires a work_id" } };
|
|
1576
|
+
return { result: { ok: true, content: "recorded work ownership" }, annotation: { t: "work_owner", what, work_id: identity.work_id, operation: kind === "claim_work" ? "claim" : "release" } };
|
|
1577
|
+
}
|
|
1133
1578
|
case "pin": {
|
|
1134
1579
|
const source = o["event"];
|
|
1135
1580
|
if (!identity.work_id || typeof source !== "string" || !source.trim()) return { result: { ok: false, content: "pin requires an existing work_id and original event id" } };
|
|
@@ -1169,7 +1614,7 @@ export function parseNote(input: unknown): NoteOutcome {
|
|
|
1169
1614
|
}
|
|
1170
1615
|
return {
|
|
1171
1616
|
result: { content: `recorded: next "${what}" -> ${next}`, ok: true },
|
|
1172
|
-
annotation: { t: "next_action", what, next_action: next, ...identity },
|
|
1617
|
+
annotation: { t: "next_action", what, next_action: next, ...identity, ...(Array.isArray(deps) ? { depends_on: deps as string[] } : {}) },
|
|
1173
1618
|
};
|
|
1174
1619
|
}
|
|
1175
1620
|
case "decision": {
|
|
@@ -1182,7 +1627,7 @@ export function parseNote(input: unknown): NoteOutcome {
|
|
|
1182
1627
|
return {
|
|
1183
1628
|
result: { content: `recorded: decision "${what}"`, ok: true },
|
|
1184
1629
|
annotation: {
|
|
1185
|
-
t: "decision", chose: what, because: why,
|
|
1630
|
+
t: "decision", chose: what, because: why, ...identity,
|
|
1186
1631
|
...(rejected && rejected.length > 0 ? { rejected } : {}),
|
|
1187
1632
|
},
|
|
1188
1633
|
};
|
|
@@ -1193,7 +1638,7 @@ export function parseNote(input: unknown): NoteOutcome {
|
|
|
1193
1638
|
}
|
|
1194
1639
|
return {
|
|
1195
1640
|
result: { content: `recorded: pitfall "${what}"`, ok: true },
|
|
1196
|
-
annotation: { t: "pitfall", tried: what, failed_because: why },
|
|
1641
|
+
annotation: { t: "pitfall", tried: what, failed_because: why, ...identity },
|
|
1197
1642
|
};
|
|
1198
1643
|
}
|
|
1199
1644
|
case "blocked": {
|