@dynamicagents/plugins 0.11.1 → 0.13.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/README.md +59 -79
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +53 -0
- package/dist/claude-code/cards.js +272 -0
- package/dist/claude-code/config.d.ts +73 -31
- package/dist/claude-code/config.js +0 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +15 -14
- package/dist/claude-code/egress.js +51 -14
- package/dist/claude-code/events.d.ts +153 -9
- package/dist/claude-code/events.js +289 -58
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +24 -80
- package/dist/claude-code/index.js +19 -234
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +342 -0
- package/dist/claude-code/run.d.ts +283 -92
- package/dist/claude-code/run.js +620 -114
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +285 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +19 -0
- package/dist/computer/context.js +222 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +40 -0
- package/dist/computer/gate.d.ts +12 -12
- package/dist/computer/gate.js +12 -12
- package/dist/computer/index.d.ts +57 -255
- package/dist/computer/index.js +98 -1039
- package/dist/computer/paths.d.ts +47 -22
- package/dist/computer/paths.js +64 -49
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +27 -38
- package/dist/computer/render.js +32 -65
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +268 -0
- package/dist/computer/tools-file.d.ts +12 -0
- package/dist/computer/tools-file.js +64 -0
- package/dist/computer/tools-grep.d.ts +3 -0
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +35 -6
- package/dist/repo/checkout.js +64 -6
- package/dist/repo/context.d.ts +135 -0
- package/dist/repo/context.js +582 -0
- package/dist/repo/index.d.ts +79 -57
- package/dist/repo/index.js +118 -1112
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/tools-pr.js +196 -0
- package/dist/repo/tools-review.d.ts +12 -0
- package/dist/repo/tools-review.js +393 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +305 -0
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +33 -20
- package/dist/{computer → workspace}/advisory.js +32 -7
- package/dist/workspace/ca-trust.d.ts +95 -0
- package/dist/workspace/ca-trust.js +312 -0
- package/dist/workspace/container-deps.d.ts +54 -0
- package/dist/workspace/container-deps.js +224 -0
- package/dist/workspace/container-fault.d.ts +38 -0
- package/dist/workspace/container-fault.js +73 -0
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/workspace/git-host.d.ts +79 -0
- package/dist/workspace/git-host.js +226 -0
- package/dist/workspace/git-identity.d.ts +57 -0
- package/dist/workspace/git-identity.js +147 -0
- package/dist/workspace/index.d.ts +43 -47
- package/dist/workspace/index.js +52 -112
- package/dist/workspace/install-job.d.ts +121 -0
- package/dist/workspace/install-job.js +866 -0
- package/dist/{computer → workspace}/install.d.ts +15 -19
- package/dist/{computer → workspace}/install.js +8 -11
- package/dist/workspace/object.d.ts +502 -0
- package/dist/workspace/object.js +1427 -0
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/workspace/shell.d.ts +45 -0
- package/dist/workspace/shell.js +80 -0
- package/dist/workspace/sync.d.ts +111 -0
- package/dist/workspace/sync.js +124 -0
- package/dist/workspace/wake.d.ts +19 -0
- package/dist/workspace/wake.js +10 -0
- package/package.json +28 -42
- package/dist/arc-agi/analysis.d.ts +0 -336
- package/dist/arc-agi/analysis.js +0 -829
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -25
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -42
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -27
- package/dist/arc-agi/main-agent.js +0 -65
- package/dist/arc-agi/recipe.d.ts +0 -51
- package/dist/arc-agi/recipe.js +0 -100
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -40
- package/dist/arc-agi/soul.js +0 -90
- package/dist/arc-agi/store.d.ts +0 -154
- package/dist/arc-agi/store.js +0 -216
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -576
- package/dist/arc-agi/types.d.ts +0 -212
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -137
- package/dist/computer/read.js +0 -203
- package/dist/recall/index.d.ts +0 -99
- package/dist/recall/index.js +0 -171
- package/dist/triage/index.d.ts +0 -355
- package/dist/triage/index.js +0 -302
package/dist/computer/index.js
CHANGED
|
@@ -1,1049 +1,108 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
else s |= 1;
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
fail(e);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
45
|
-
if (env.hasError) throw env.error;
|
|
46
|
-
}
|
|
47
|
-
return next();
|
|
48
|
-
};
|
|
49
|
-
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
50
|
-
var e = new Error(message);
|
|
51
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
-
});
|
|
53
|
-
import { tool } from "ai";
|
|
54
|
-
import { z } from "zod";
|
|
55
|
-
import { definePlugin, withAbort } from "@dynamicagents/core";
|
|
56
|
-
import { getWorkspace, shellQuote } from "@cloudflare/computer";
|
|
57
|
-
import { guardPath } from "./paths.js";
|
|
58
|
-
import { cancelledNote, humanBytes, packBlocks, renderGrepMatches, renderResult } from "./render.js";
|
|
59
|
-
import { collectVisible, listingNote, pathExists, readBounded, readWindow } from "./read.js";
|
|
60
|
-
import { execGate, execLostNote, writeGate } from "./gate.js";
|
|
61
|
-
/**
|
|
62
|
-
* `@dynamicagents/plugins/computer` — a Linux container whose filesystem outlives it.
|
|
63
|
-
*
|
|
64
|
-
* The filesystem **is** a Durable Object's SQLite, mounted into the container
|
|
65
|
-
* over FUSE by `computerd`. Commands see a normal `/workspace`; the Worker reads
|
|
66
|
-
* the same tree over RPC; and when the container is replaced the tree is pushed
|
|
67
|
-
* back into the new one.
|
|
68
|
-
*
|
|
69
|
-
* Not to be confused with `@dynamicagents/plugins/workspace`, which is a virtual
|
|
70
|
-
* filesystem with no processes and nothing to run. Install exactly one
|
|
71
|
-
* filesystem plugin — an agent holding two gives the model no way to know which
|
|
72
|
-
* one a path refers to.
|
|
73
|
-
*
|
|
74
|
-
* ## `node_modules` is **not** in the workspace
|
|
75
|
-
*
|
|
76
|
-
* The one thing to internalise before reading further. `computerd` excludes it
|
|
77
|
-
* from the sync, and the exclusion is right: pushing a real one (429 MB, 22,470
|
|
78
|
-
* files) into the object exceeds the Durable Object's 128 MB isolate memory
|
|
79
|
-
* limit, leaving the tree silently short — and the reconciliation that follows
|
|
80
|
-
* propagates the shortfall back into the container.
|
|
81
|
-
*
|
|
82
|
-
* So dependencies live in the container and die with it, while source and `.git`
|
|
83
|
-
* are durable. Two consequences run through everything below: an install has to
|
|
84
|
-
* be re-run on a cold container, and `sb_read` cannot see a path under
|
|
85
|
-
* `node_modules` even though a shell in the same container can.
|
|
86
|
-
*
|
|
87
|
-
* Requires the Workers **Paid** plan (containers) and a Durable Object binding
|
|
88
|
-
* whose class owns the workspace — see the README for the wrangler block.
|
|
89
|
-
*/
|
|
90
|
-
/**
|
|
91
|
-
* How a repository installs its dependencies — the mechanical half.
|
|
92
|
-
*
|
|
93
|
-
* Re-exported from the plugin's one entry point rather than given a subpath of
|
|
94
|
-
* its own: `verify:exports` treats each subpath as an isolated realm, and a host
|
|
95
|
-
* needs the resolver and the tools together anyway.
|
|
96
|
-
*/
|
|
97
|
-
export { DEFAULT_INSTALL_PLAN, installFingerprint, resolveInstallCommand } from "./install.js";
|
|
98
|
-
/**
|
|
99
|
-
* The helpers this plugin's own tests and hosts reach for, re-exported from the
|
|
100
|
-
* one entry point.
|
|
101
|
-
*
|
|
102
|
-
* They live in sibling modules — `paths.ts`, `render.ts`, `gate.ts` — because
|
|
103
|
-
* this file was 1,900 lines and the seams were obvious. The subpath is the unit a
|
|
104
|
-
* consumer imports, so what it exports must not move when the files behind it do:
|
|
105
|
-
* `verify:exports` treats `./computer` as one realm, and everything below stays
|
|
106
|
-
* inside `dist/computer/`.
|
|
107
|
-
*/
|
|
108
|
-
export { isContainerOnly, isGitInternal } from "./paths.js";
|
|
109
|
-
export { cancelledNote, packBlocks, renderGrepMatches, renderResult, truncateOutput } from "./render.js";
|
|
1
|
+
import { definePlugin, PluginSetupError } from "@dynamicagents/core";
|
|
2
|
+
import { openWorkspace, openWorkspaceFs } from "../workspace/open.js";
|
|
3
|
+
import { workspaceRoute } from "./proxy.js";
|
|
4
|
+
import { computerContext } from "./context.js";
|
|
5
|
+
import { execTools } from "./tools-exec.js";
|
|
6
|
+
import { fileTools } from "./tools-file.js";
|
|
7
|
+
import { grepTools } from "./tools-grep.js";
|
|
8
|
+
/**
|
|
9
|
+
* `@dynamicagents/plugins/computer` — an agent's tools over a container
|
|
10
|
+
* workspace: `bash`, `grep` and `edit`, and `computerWorkspace`, the agent's own
|
|
11
|
+
* `this.workspace`, which Think's `read`, `write`, `delete`, `find` and `list`
|
|
12
|
+
* go through.
|
|
13
|
+
*
|
|
14
|
+
* The workspace itself — the Durable Object, its container, its git, its
|
|
15
|
+
* install — is `@dynamicagents/plugins/workspace`. This plugin reaches it only
|
|
16
|
+
* through that subpath's client modules, never its object, so an agent that
|
|
17
|
+
* holds the tools carries no container backend and no isomorphic-git.
|
|
18
|
+
*
|
|
19
|
+
* The agent's own workspace has to be this one — `computerWorkspace` — or
|
|
20
|
+
* Think's `read` and `write` would work on a different tree from `bash`. The
|
|
21
|
+
* plugin refuses to start otherwise; see `./proxy.ts`.
|
|
22
|
+
*
|
|
23
|
+
* ## The dependency tree is not in the workspace
|
|
24
|
+
*
|
|
25
|
+
* The one thing to internalise before reading further. A package root's
|
|
26
|
+
* `node_modules` is a bind mount of the container's disk, so installs never
|
|
27
|
+
* sync, and a new container reinstalls — see `/workspace`'s
|
|
28
|
+
* `container-deps.ts`. The file tools read the workspace, so they refuse it;
|
|
29
|
+
* `bash` reaches it. `paths.ts` owns what a path may be and what walks drop.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* The helpers this plugin's own tests reach for, re-exported from the one
|
|
33
|
+
* entry point. `verify:exports` treats `./computer` as one realm, so what it
|
|
34
|
+
* exports must not move when the files behind it do.
|
|
35
|
+
*/
|
|
36
|
+
export { isGitInternal, SESSION_STATE_DIR, WALK_SKIPS } from "./paths.js";
|
|
37
|
+
export { packBlocks, renderGrepMatches, renderResult, syncPendingNote } from "./render.js";
|
|
110
38
|
export { execGate, needsDependencies, writeGate } from "./gate.js";
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
export { pathExists } from "./read.js";
|
|
124
|
-
/**
|
|
125
|
-
* This plugin's tool-family name, as a recipe's `toolFamilies` lists it.
|
|
126
|
-
*
|
|
127
|
-
* `"sandbox"` names the capability — a shell and a filesystem — rather than the
|
|
128
|
-
* package providing it, and it appears in recipes, souls and allowlists that
|
|
129
|
-
* have nothing to do with either. Renaming it would have `validateRecipe`
|
|
130
|
-
* silently drop the family from every recipe that still says `sandbox`: a
|
|
131
|
-
* subagent with no tools and no error explaining why.
|
|
132
|
-
*/
|
|
133
|
-
export const SANDBOX_FAMILY = "sandbox";
|
|
134
|
-
/**
|
|
135
|
-
* Where a parent puts the workspace name so its subagents reach the same one.
|
|
136
|
-
*
|
|
137
|
-
* A subagent cannot compute this itself. The name is derived from the verified
|
|
138
|
-
* caller and the repository, and core deliberately gives a subagent execution a
|
|
139
|
-
* `callerKey` thunk that **throws** — "a subagent execution has no caller
|
|
140
|
-
* identity". So a facet running a `code` subtask would fail at its first tool
|
|
141
|
-
* call, with the parent's checkout sitting in a workspace it cannot name.
|
|
142
|
-
*
|
|
143
|
-
* `resolveRuntime` runs on the parent, where the name resolves, and its return
|
|
144
|
-
* value reaches every tool family as `ToolFamilyContext.runtime`. That is the
|
|
145
|
-
* channel core built for exactly this, and it is emphatically *not* `params`,
|
|
146
|
-
* which are declared in the subtask type's schema and rendered to the delegating
|
|
147
|
-
* model — a workspace name there would be model-authored, and a model naming
|
|
148
|
-
* another caller's workspace would get that caller's files.
|
|
149
|
-
*/
|
|
150
|
-
export const WORKSPACE_RUNTIME_KEY = "workspaceName";
|
|
151
|
-
/**
|
|
152
|
-
* Read a parent-resolved workspace name off a subtask's runtime state.
|
|
153
|
-
*
|
|
154
|
-
* Returns `undefined` rather than throwing on anything unexpected: the main
|
|
155
|
-
* agent's tools have no runtime at all, and falling back to the configured thunk
|
|
156
|
-
* is right there.
|
|
157
|
-
*/
|
|
158
|
-
export function workspaceNameFromRuntime(runtime) {
|
|
159
|
-
const value = runtime?.[WORKSPACE_RUNTIME_KEY];
|
|
160
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* How much command output the model is allowed to see, in characters.
|
|
164
|
-
*
|
|
165
|
-
* One `npm install` prints more than a small context window holds, and the
|
|
166
|
-
* interesting part of a failing build is the first error and the last summary —
|
|
167
|
-
* never the middle. So output is truncated from the middle rather than the end,
|
|
168
|
-
* which is what a naive `slice` would do and would drop the exit summary that
|
|
169
|
-
* says what actually failed.
|
|
170
|
-
*/
|
|
171
|
-
const DEFAULT_MAX_OUTPUT_CHARS = 16_000;
|
|
172
|
-
/**
|
|
173
|
-
* How many directory entries one `sb_ls` returns.
|
|
174
|
-
*
|
|
175
|
-
* A bound on the *listing*, not on the rendered text — `maxOutputChars` still
|
|
176
|
-
* applies on top, and the order matters. Bounding only the text means `readdir`
|
|
177
|
-
* returns every entry and the isolate holds all of them before the ceiling
|
|
178
|
-
* discards the tail; a real `node_modules` is 22,470 files, and a misbehaving
|
|
179
|
-
* build is exactly when someone lists one. `readdir` takes a `limit`, so the
|
|
180
|
-
* bound is applied where the entries are read.
|
|
181
|
-
*/
|
|
182
|
-
const DEFAULT_MAX_ENTRIES = 1000;
|
|
183
|
-
/**
|
|
184
|
-
* How many matches one `sb_grep` returns.
|
|
185
|
-
*
|
|
186
|
-
* Bounded at the source like {@link DEFAULT_MAX_ENTRIES}, and for a sharper reason
|
|
187
|
-
* than a listing: without a `limit`, `fs.grep` reads *every* file under the path
|
|
188
|
-
* looking for more. `.git` is in the workspace, so an unbounded search of
|
|
189
|
-
* `/workspace` streams every loose object through the isolate before answering.
|
|
190
|
-
* The limit is what stops that walk early.
|
|
191
|
-
*/
|
|
192
|
-
const DEFAULT_MAX_MATCHES = 200;
|
|
193
|
-
/** A command that has not finished in this long is a hung command. */
|
|
194
|
-
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
195
|
-
/** Where checkouts live, in the container and in the workspace alike. */
|
|
196
|
-
const DEFAULT_CWD = "/workspace";
|
|
197
|
-
/**
|
|
198
|
-
* How often the install gate re-reads the install's status.
|
|
199
|
-
*
|
|
200
|
-
* An interval rather than a subscription because the status lives in another
|
|
201
|
-
* Durable Object with no event to wait on. Never slept past the gate itself —
|
|
202
|
-
* see `awaitInstall`.
|
|
203
|
-
*/
|
|
204
|
-
const INSTALL_POLL_MS = 3_000;
|
|
205
|
-
/**
|
|
206
|
-
* Open the workspace behind a Durable Object stub.
|
|
207
|
-
*
|
|
208
|
-
* The cast is unavoidable, and narrow enough to be worth isolating here rather
|
|
209
|
-
* than repeating. `getWorkspace` wants a handle whose `__getWorkspaceStub()`
|
|
210
|
-
* resolves to a `WorkspaceStub` — the concrete class, private fields and all.
|
|
211
|
-
* Across a Durable Object boundary Workers RPC hands back a structural
|
|
212
|
-
* `Stub<WorkspaceStub>`, which forwards every method faithfully but carries none
|
|
213
|
-
* of the class's private brand: runtime-compatible, type-incompatible. This is
|
|
214
|
-
* the only place in the plugin that gap is crossed.
|
|
215
|
-
*/
|
|
216
|
-
function openWorkspace(host) {
|
|
217
|
-
return getWorkspace(host);
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* The shell wrapper both variants share, minus the choice they differ on.
|
|
221
|
-
*
|
|
222
|
-
* One quoted argument, not string concatenation: the command is model-authored
|
|
223
|
-
* and routinely contains quotes of its own (`git commit -m "…"`), so anything
|
|
224
|
-
* less than `shellQuote` re-parses the model's quoting and mangles it.
|
|
225
|
-
*
|
|
226
|
-
* ## Why `-o pipefail`
|
|
227
|
-
*
|
|
228
|
-
* Without it a pipeline's exit status is its **last** stage's, so
|
|
229
|
-
* `npm run check | tail -100` reports `exit 0` for a gate that failed outright —
|
|
230
|
-
* and models pipe into `tail` constantly. A tool that reports success for a
|
|
231
|
-
* failed build is worse than one that reports nothing. It also costs a re-run
|
|
232
|
-
* every time: a model that cannot trust a piped exit code runs the whole thing
|
|
233
|
-
* again unpiped to get one.
|
|
234
|
-
*
|
|
235
|
-
* The trade is worth stating. `pipefail` surfaces SIGPIPE too, so
|
|
236
|
-
* `ls big-dir | head -1` reports 141 rather than 0 — noisy, but *visible*, where
|
|
237
|
-
* the alternative is a failing gate that looks clean. The tool description tells
|
|
238
|
-
* the model not to pipe into `head`/`tail` at all, since output is already
|
|
239
|
-
* truncated with both ends kept.
|
|
240
|
-
*
|
|
241
|
-
* Requires a shell that implements it — `bash`, `zsh`, `ksh`, not `sh`/dash. A
|
|
242
|
-
* host setting {@link ComputerConfig.shell} is choosing that shell explicitly.
|
|
243
|
-
*/
|
|
244
|
-
function wrapped(command, shell) {
|
|
245
|
-
return `${shell} -o pipefail -c ${shellQuote(command)}`;
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Run a command under {@link ComputerConfig.shell} with its two output streams
|
|
249
|
-
* left as they are, or hand it back untouched when no shell is configured.
|
|
250
|
-
*
|
|
251
|
-
* This is the variant for a caller that **reads the result in code**: `stdout` is
|
|
252
|
-
* a data channel it compares or parses, and `stderr` is a separate diagnostic.
|
|
253
|
-
* {@link computerExec} is that caller, on behalf of `@dynamicagents/plugins/repo`,
|
|
254
|
-
* which asks git questions like `symbolic-ref --short refs/remotes/origin/HEAD`
|
|
255
|
-
* and `rev-list --count` and needs the answer alone.
|
|
256
|
-
*
|
|
257
|
-
* Two functions rather than one with a flag, because the difference is a change
|
|
258
|
-
* to the *output contract* and a boolean hides it at the call site. Merging the
|
|
259
|
-
* streams here would give every `/repo` git command an empty `stderr` and a
|
|
260
|
-
* `stdout` that is a transcript rather than an answer — which fails silently,
|
|
261
|
-
* since the commands `/repo` parses are quiet ones and a single `warning:` is
|
|
262
|
-
* enough to skip the empty-branch guard.
|
|
263
|
-
*/
|
|
264
|
-
export function withShell(command, shell) {
|
|
265
|
-
return shell ? wrapped(command, shell) : command;
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Run a command under {@link ComputerConfig.shell} with its two output streams
|
|
269
|
-
* merged into one transcript, in the order they were written.
|
|
270
|
-
*
|
|
271
|
-
* This is the variant for a caller whose consumer is **a model reading output**.
|
|
272
|
-
* `sb_exec` is that caller. A project's check is a chain — `wrangler types &&
|
|
273
|
-
* prettier && eslint && tsc` — and *which tool spoke last* is how you know which
|
|
274
|
-
* one failed. A stdout block and a separate stderr block destroy that ordering,
|
|
275
|
-
* and a model that wants it back re-runs the whole gate as
|
|
276
|
-
* `npm run check > /tmp/out 2>&1; cat /tmp/out`. This is that workaround, done
|
|
277
|
-
* once, for free.
|
|
278
|
-
*
|
|
279
|
-
* The redirect binds to the wrapper process, so it applies to everything the
|
|
280
|
-
* command writes however deeply nested — and there is no inner brace group or
|
|
281
|
-
* subshell to mis-parse a command that already contains `&&`, quotes or redirects
|
|
282
|
-
* of its own.
|
|
283
|
-
*
|
|
284
|
-
* With no shell configured there is no wrapper process to redirect, so the
|
|
285
|
-
* command goes to the runtime untouched and the two streams arrive separate.
|
|
286
|
-
* That is not a gap: {@link renderResult} renders them as a labelled
|
|
287
|
-
* `--- stderr ---` block for exactly this case. The transcript is the better
|
|
288
|
-
* answer, not the only supported one.
|
|
289
|
-
*/
|
|
290
|
-
export function withShellTranscript(command, shell) {
|
|
291
|
-
return shell ? `${wrapped(command, shell)} 2>&1` : command;
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* Wait for a disposable resource without outliving `signal`.
|
|
295
|
-
*
|
|
296
|
-
* `withAbort` can abandon the wait but not the resource, and one that arrives
|
|
297
|
-
* after its caller has given up has nobody left to dispose it. So it is released
|
|
298
|
-
* on arrival instead — disposed by default, or handed to `release` where
|
|
299
|
-
* disposing alone would leave work running.
|
|
300
|
-
*/
|
|
301
|
-
function acquire(signal, opening, release = (resource) => resource[Symbol.dispose]()) {
|
|
302
|
-
return withAbort(signal, opening, () => {
|
|
303
|
-
void opening.then(release).catch(() => { });
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* How an exec handle that arrived too late is released. Disposing only detaches
|
|
308
|
-
* this side, so the process is killed first.
|
|
309
|
-
*/
|
|
310
|
-
async function killLate(handle) {
|
|
311
|
-
try {
|
|
312
|
-
await handle.kill("SIGTERM");
|
|
313
|
-
}
|
|
314
|
-
finally {
|
|
315
|
-
handle[Symbol.dispose]();
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
export function buildComputerTools(workspace, config, advisories) {
|
|
319
|
-
const cwd = config.cwd ?? DEFAULT_CWD;
|
|
320
|
-
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
321
|
-
const maxChars = config.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
322
|
-
const gateMs = config.installGateMs ?? 90_000;
|
|
323
|
-
const env = config.env;
|
|
324
|
-
/**
|
|
325
|
-
* Wait out anything transient, up to the gate, and report whatever is left.
|
|
326
|
-
*
|
|
327
|
-
* Polled rather than subscribed: the state lives in another Durable Object,
|
|
328
|
-
* there is no event to wait on, and the whole window is under two minutes.
|
|
329
|
-
*
|
|
330
|
-
* **Read for every command, including the ones that need no dependencies.**
|
|
331
|
-
* That is a change of shape, not a slip. The filter that spares `cat
|
|
332
|
-
* README.md` from queueing behind an `npm ci` lives in `execGate`, where it
|
|
333
|
-
* applies per advisory — because a dependency install and a full workspace are
|
|
334
|
-
* not relevant to the same commands, and applying one filter to both is what
|
|
335
|
-
* made a workspace at its ceiling silent for exactly the commands whose writes
|
|
336
|
-
* it was dropping. The cost is one same-colo RPC on commands that previously
|
|
337
|
-
* skipped it, against a class of silent data loss.
|
|
338
|
-
*/
|
|
339
|
-
const awaitAdvisories = async (command, signal) => {
|
|
340
|
-
if (!advisories)
|
|
341
|
-
return {};
|
|
342
|
-
// Fails **open**, and that belongs here rather than at the call site: this
|
|
343
|
-
// is a read of another Durable Object's state. An error must not take out a
|
|
344
|
-
// working shell — running the command is exactly what would have happened
|
|
345
|
-
// before the gate existed, and a spurious blockage would strand the
|
|
346
|
-
// subagent.
|
|
347
|
-
const read = async () => {
|
|
348
|
-
try {
|
|
349
|
-
signal?.throwIfAborted();
|
|
350
|
-
return await withAbort(signal, advisories());
|
|
351
|
-
}
|
|
352
|
-
catch (err) {
|
|
353
|
-
// Open on a failed read, never on a cancel: failing open here would go on
|
|
354
|
-
// to run the command the caller just gave up on.
|
|
355
|
-
if (signal?.aborted)
|
|
356
|
-
throw err;
|
|
357
|
-
return [];
|
|
358
|
-
}
|
|
359
|
-
};
|
|
360
|
-
// Clamped to whatever is left of the gate, not the bare interval. A fixed
|
|
361
|
-
// sleep makes `installGateMs` a floor rather than the ceiling it is
|
|
362
|
-
// documented to be: at a 100 ms gate the loop still slept the full three
|
|
363
|
-
// seconds before looking again, so a public timeout overshot by 30×.
|
|
364
|
-
const deadline = Date.now() + gateMs;
|
|
365
|
-
let current = await read();
|
|
366
|
-
// Keyed on the gate's own verdict rather than on a state name, so "what is
|
|
367
|
-
// worth waiting for" has one definition and it is the one that will decide
|
|
368
|
-
// the outcome.
|
|
369
|
-
while (execGate(current, command).block && Date.now() < deadline) {
|
|
370
|
-
const wait = Math.min(INSTALL_POLL_MS, deadline - Date.now());
|
|
371
|
-
// Throws on a cancel rather than returning the verdict so far: a gate that
|
|
372
|
-
// outlived its caller would otherwise go on to run the command it was holding.
|
|
373
|
-
await withAbort(signal, new Promise((resolve) => setTimeout(resolve, wait)));
|
|
374
|
-
current = await read();
|
|
375
|
-
}
|
|
376
|
-
return execGate(current, command);
|
|
377
|
-
};
|
|
378
|
-
/**
|
|
379
|
-
* Whether a write can succeed at all, asked once and not polled.
|
|
380
|
-
*
|
|
381
|
-
* Separate from {@link awaitAdvisories} because the question is different:
|
|
382
|
-
* there is no command to classify and nothing worth waiting for. An install in
|
|
383
|
-
* flight does not stop a file being written, and the one thing that does —
|
|
384
|
-
* a workspace that no longer accepts writes — never resolves on its own.
|
|
385
|
-
*
|
|
386
|
-
* Fails **open** for the same reason the exec gate does: a read of another
|
|
387
|
-
* Durable Object's state must not take out a working tool.
|
|
388
|
-
*/
|
|
389
|
-
const refuseWrite = async () => {
|
|
390
|
-
if (!advisories)
|
|
391
|
-
return undefined;
|
|
392
|
-
try {
|
|
393
|
-
return writeGate(await advisories());
|
|
394
|
-
}
|
|
395
|
-
catch {
|
|
396
|
-
return undefined;
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
/**
|
|
400
|
-
* Host-supplied environment, with the undefined entries dropped.
|
|
401
|
-
*
|
|
402
|
-
* `RuntimeExecOptions.env` is `Record<string, string>`, and a config thunk
|
|
403
|
-
* that reads straight off `env` will hand back `undefined` for anything
|
|
404
|
-
* unset — which would otherwise arrive in the container as the string
|
|
405
|
-
* "undefined".
|
|
406
|
-
*/
|
|
407
|
-
const definedEnv = () => {
|
|
408
|
-
if (!env)
|
|
409
|
-
return undefined;
|
|
410
|
-
const entries = Object.entries(env()).filter((entry) => entry[1] !== undefined);
|
|
411
|
-
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
412
|
-
};
|
|
413
|
-
/**
|
|
414
|
-
* Open the workspace, run one file operation in it, and own the failure.
|
|
415
|
-
*
|
|
416
|
-
* Five lines every file tool would otherwise repeat with only the verb changed
|
|
417
|
-
* — five chances for the seventh to be written without them. The catch is the
|
|
418
|
-
* point: a failed file operation is the model's to recover from, and it can
|
|
419
|
-
* only recover from what it is told, so this returns the sentence rather than
|
|
420
|
-
* throwing it.
|
|
421
|
-
*
|
|
422
|
-
* `sb_exec` deliberately does not use it. That one recognises `EEXEC_LOST`,
|
|
423
|
-
* logs, and carries an install warning through the failure path — a bespoke
|
|
424
|
-
* catch saying something this cannot.
|
|
425
|
-
*/
|
|
426
|
-
const inWorkspace = async (gerund, subject, body) => {
|
|
427
|
-
try {
|
|
428
|
-
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
429
|
-
try {
|
|
430
|
-
const ws = __addDisposableResource(env_1, await workspace(), false);
|
|
431
|
-
return await body(ws.fs);
|
|
432
|
-
}
|
|
433
|
-
catch (e_1) {
|
|
434
|
-
env_1.error = e_1;
|
|
435
|
-
env_1.hasError = true;
|
|
436
|
-
}
|
|
437
|
-
finally {
|
|
438
|
-
__disposeResources(env_1);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
catch (err) {
|
|
442
|
-
return `error ${gerund} ${subject}: ${String(err)}`;
|
|
443
|
-
}
|
|
444
|
-
};
|
|
39
|
+
export { computerWorkspace, isComputerWorkspace } from "./proxy.js";
|
|
40
|
+
/**
|
|
41
|
+
* @param workspace Opens the workspace for a command: container started, CA
|
|
42
|
+
* installed.
|
|
43
|
+
* @param fsWorkspace Opens it for the file tools, which touch nothing but the
|
|
44
|
+
* host's SQLite. Defaults to {@link workspace}, so a host with one way in
|
|
45
|
+
* behaves as before. See {@link openWorkspaceFs}.
|
|
46
|
+
* @param lockScope Names the workspace a write locks within — see
|
|
47
|
+
* {@link file://./file-lock.ts}. Defaults to this tool set alone.
|
|
48
|
+
*/
|
|
49
|
+
export function buildComputerTools(workspace, config, advisories, fsWorkspace = workspace, lockScope) {
|
|
50
|
+
const ctx = computerContext(workspace, config, advisories, fsWorkspace, lockScope);
|
|
445
51
|
return {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
*
|
|
450
|
-
* A description that leads with "output is truncated if it is large" and
|
|
451
|
-
* gives no exit code hands the model two independent reasons to re-run a
|
|
452
|
-
* command and capture the output "properly" — at 60 seconds a go on a real
|
|
453
|
-
* gate, whose few hundred characters were never near the ceiling anyway.
|
|
454
|
-
*
|
|
455
|
-
* Two of these sentences are only true when a shell is configured, so the
|
|
456
|
-
* description says whichever is. `withShellTranscript` is a no-op without
|
|
457
|
-
* {@link ComputerConfig.shell}: no `2>&1`, so the streams arrive separate
|
|
458
|
-
* and {@link renderResult} labels them; no `-o pipefail`, so `/bin/sh`
|
|
459
|
-
* reports a pipeline's *last* stage. Promising a transcript and
|
|
460
|
-
* first-failure semantics there would be the lie `wrapped` explains the
|
|
461
|
-
* cost of.
|
|
462
|
-
*/
|
|
463
|
-
description: "Run a shell command in the container and return its output. Use this for builds, tests, package installs, git, and anything else a terminal can do. " +
|
|
464
|
-
(config.shell
|
|
465
|
-
? "The result is the command's full transcript — stdout and stderr interleaved in the order they were written — followed by a line reporting the exit code, e.g. `--- exit 0 ---`. A command killed at the time limit says so on that line. " +
|
|
466
|
-
"You do not need to append `echo $?`, add `2>&1`, or redirect to a file to see any of this. " +
|
|
467
|
-
"Do not pipe into `head` or `tail` to shorten output: long output is already truncated from the middle, keeping the beginning and the end, and piping costs you the parts you wanted. Pipelines report the first failing stage, so a piped command reports its real failure rather than the pipe's — but `cmd | head` may report 141 when `head` closes the pipe early, which is not a failure of `cmd`. "
|
|
468
|
-
: "The result is the command's stdout, then any stderr under a `--- stderr ---` heading, then a line reporting the exit code, e.g. `--- exit 0 ---`. A command killed at the time limit says so on that line. " +
|
|
469
|
-
"You do not need to append `echo $?` to see the exit code. Add `2>&1` yourself if you need the two streams in the order they were written. " +
|
|
470
|
-
"Do not pipe into `head` or `tail` to shorten output: long output is already truncated from the middle, keeping the beginning and the end, and piping costs you the parts you wanted. A pipeline reports its **last** stage, so check the exit code of the command you care about rather than the pipe's. ") +
|
|
471
|
-
"Prefer targeted commands over ones that print everything.",
|
|
472
|
-
inputSchema: z.object({
|
|
473
|
-
command: z.string().describe("The shell command, e.g. 'npm test'"),
|
|
474
|
-
cwd: z
|
|
475
|
-
.string()
|
|
476
|
-
.optional()
|
|
477
|
-
.describe(`Working directory (default: ${cwd})`)
|
|
478
|
-
}),
|
|
479
|
-
execute: async ({ command, cwd: overrideCwd }, { abortSignal }) => {
|
|
480
|
-
/**
|
|
481
|
-
* One line per command, and it is the only view of where a task's wall
|
|
482
|
-
* clock actually goes.
|
|
483
|
-
*
|
|
484
|
-
* Everything on the Worker side of a container command is an `await`, so
|
|
485
|
-
* Workers Observability records the invocation at ~0% CPU and a long wall
|
|
486
|
-
* time and cannot say what ran. Diagnosing a 59-minute task on 2026-08-11
|
|
487
|
-
* meant inferring the shape of each command from the *gaps between AI
|
|
488
|
-
* Gateway calls*, because nothing logged the command itself. The three
|
|
489
|
-
* numbers below — how long the install gate held, how long the command
|
|
490
|
-
* took, what it exited with — would have answered it directly.
|
|
491
|
-
*
|
|
492
|
-
* Timed around the gate as well as the command, since a subagent blocked
|
|
493
|
-
* waiting for `npm ci` and one running a slow test suite are
|
|
494
|
-
* indistinguishable from the outside and want opposite fixes.
|
|
495
|
-
*/
|
|
496
|
-
const startedAtMs = Date.now();
|
|
497
|
-
/**
|
|
498
|
-
* What the model is told when this call stops before the command
|
|
499
|
-
* finishes. Said plainly, because the likeliest next move after a bare
|
|
500
|
-
* error is the same command again — and whether that is right depends on
|
|
501
|
-
* where the call stopped. A command that outran the limit once will again;
|
|
502
|
-
* one that never started because the workspace did not answer may not.
|
|
503
|
-
*
|
|
504
|
-
* `sb_exec` reads its call's signal rather than leaving the wait to core,
|
|
505
|
-
* and this is why: core's abandonment can say only that the command may
|
|
506
|
-
* still be running. Core's `TOOL_CALL_GRACE_MS` is the window this answer
|
|
507
|
-
* has to arrive in, and it covers sending the kill below.
|
|
508
|
-
*/
|
|
509
|
-
const stopped = (gateMs, started) => {
|
|
510
|
-
const timedOut = abortSignal?.reason?.name ===
|
|
511
|
-
"TimeoutError";
|
|
512
|
-
console.info("[computer] sb_exec stopped", {
|
|
513
|
-
command,
|
|
514
|
-
gateMs,
|
|
515
|
-
durationMs: Date.now() - startedAtMs - gateMs,
|
|
516
|
-
started,
|
|
517
|
-
reason: timedOut ? "time limit" : "cancelled"
|
|
518
|
-
});
|
|
519
|
-
if (!timedOut)
|
|
520
|
-
return "the command was stopped because this call was cancelled. Anything it changed before then is still changed.";
|
|
521
|
-
return started
|
|
522
|
-
? "the command was stopped: it ran past this call's time limit. Anything it changed before then is still changed. Try something narrower."
|
|
523
|
-
: "the command did not run: this call reached its time limit while the workspace was still getting ready, so nothing was changed. Try again; if it happens again, the workspace is not responding.";
|
|
524
|
-
};
|
|
525
|
-
// Only `sb_exec` waits on an install. The file tools read and write
|
|
526
|
-
// source, which is in the workspace and unaffected by an install in
|
|
527
|
-
// flight — blocking them would stop the subagent doing the reading it
|
|
528
|
-
// could usefully do while it waits. They do consult `writeGate`, which
|
|
529
|
-
// is a different question: not "is the tree ready" but "does a write
|
|
530
|
-
// survive at all".
|
|
531
|
-
let gate;
|
|
532
|
-
try {
|
|
533
|
-
gate = await awaitAdvisories(command, abortSignal);
|
|
534
|
-
}
|
|
535
|
-
catch (err) {
|
|
536
|
-
// Only a cancel gets out of the gate; a failed advisory read opens it.
|
|
537
|
-
if (abortSignal?.aborted)
|
|
538
|
-
return stopped(Date.now() - startedAtMs, false);
|
|
539
|
-
throw err;
|
|
540
|
-
}
|
|
541
|
-
const gateMsWaited = Date.now() - startedAtMs;
|
|
542
|
-
if (gate.block) {
|
|
543
|
-
console.info("[computer] sb_exec blocked by a workspace advisory", {
|
|
544
|
-
command,
|
|
545
|
-
gateMs: gateMsWaited
|
|
546
|
-
});
|
|
547
|
-
return gate.block;
|
|
548
|
-
}
|
|
549
|
-
// Prepended to whatever happens next, success or failure. The warning
|
|
550
|
-
// is context for the output, not a substitute for it — which is why it
|
|
551
|
-
// is carried through the catch as well.
|
|
552
|
-
const note = (body) => gate.warn ? `${gate.warn}\n\n${body}` : body;
|
|
553
|
-
// Set once the exec is sent. See `stopped`.
|
|
554
|
-
let started = false;
|
|
555
|
-
try {
|
|
556
|
-
const env_2 = { stack: [], error: void 0, hasError: false };
|
|
557
|
-
try {
|
|
558
|
-
// A workspace that stops answering would hold the call before any of the
|
|
559
|
-
// cancellation below is reached.
|
|
560
|
-
abortSignal?.throwIfAborted();
|
|
561
|
-
const ws = __addDisposableResource(env_2, await acquire(abortSignal, workspace()), false);
|
|
562
|
-
// Read once per command. This is the *host's* thunk, so calling it
|
|
563
|
-
// twice in the construction of one command's options is two chances to
|
|
564
|
-
// disagree — the check and the value would come from different reads.
|
|
565
|
-
const commandEnv = definedEnv();
|
|
566
|
-
const options = {
|
|
567
|
-
cwd: overrideCwd ?? cwd,
|
|
568
|
-
encoding: "utf8",
|
|
569
|
-
timeoutMs,
|
|
570
|
-
...(commandEnv ? { env: commandEnv } : {})
|
|
571
|
-
};
|
|
572
|
-
// Transcript, not two streams: this result goes to a model, which
|
|
573
|
-
// reads it as a terminal session rather than parsing it.
|
|
574
|
-
// Not started at all on a signal that has already fired: a process that
|
|
575
|
-
// is killed on the next line still ran long enough to change something.
|
|
576
|
-
abortSignal?.throwIfAborted();
|
|
577
|
-
// From here a stop cannot promise nothing ran: the exec may reach the
|
|
578
|
-
// container before its handle reaches this side.
|
|
579
|
-
started = true;
|
|
580
|
-
const handle = __addDisposableResource(env_2, await acquire(abortSignal, ws.runtime.exec(withShellTranscript(command, config.shell), options), killLate), false);
|
|
581
|
-
// The runtime takes no signal, so stopping the wait and stopping the
|
|
582
|
-
// process are two acts. Disposing the handle is neither — it releases
|
|
583
|
-
// this side's attachment and leaves the command running.
|
|
584
|
-
const result = await withAbort(abortSignal, handle.result(), () => handle.kill("SIGTERM"));
|
|
585
|
-
console.info("[computer] sb_exec", {
|
|
586
|
-
command,
|
|
587
|
-
exitCode: result.exitCode,
|
|
588
|
-
// Split so a slow command and a slow *wait* never look alike.
|
|
589
|
-
gateMs: gateMsWaited,
|
|
590
|
-
durationMs: Date.now() - startedAtMs - gateMsWaited,
|
|
591
|
-
// The ceiling this was measured against — a duration sitting on it is
|
|
592
|
-
// a timeout wearing a normal-looking number.
|
|
593
|
-
timeoutMs
|
|
594
|
-
});
|
|
595
|
-
return note(renderResult(result, maxChars));
|
|
596
|
-
}
|
|
597
|
-
catch (e_2) {
|
|
598
|
-
env_2.error = e_2;
|
|
599
|
-
env_2.hasError = true;
|
|
600
|
-
}
|
|
601
|
-
finally {
|
|
602
|
-
__disposeResources(env_2);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
catch (err) {
|
|
606
|
-
if (abortSignal?.aborted)
|
|
607
|
-
return note(stopped(gateMsWaited, started));
|
|
608
|
-
const lost = execLostNote(err);
|
|
609
|
-
console.warn("[computer] sb_exec failed", {
|
|
610
|
-
command,
|
|
611
|
-
gateMs: gateMsWaited,
|
|
612
|
-
durationMs: Date.now() - startedAtMs - gateMsWaited,
|
|
613
|
-
timeoutMs,
|
|
614
|
-
// Distinguished in the log for the same reason it is distinguished
|
|
615
|
-
// for the model: a container replacement and a broken command want
|
|
616
|
-
// different people looking at them.
|
|
617
|
-
...(lost ? { lost: true } : {}),
|
|
618
|
-
err: String(err)
|
|
619
|
-
});
|
|
620
|
-
// Returned, not thrown: a failed command is usually the model's to
|
|
621
|
-
// recover from, and it can only recover from what it is told.
|
|
622
|
-
return note(lost ?? `error running command: ${String(err)}`);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
}),
|
|
626
|
-
sb_read: tool({
|
|
627
|
-
description: "Read a file from the workspace. Returns the file's text, or a note if it does not exist. " +
|
|
628
|
-
"A large file comes back with its middle removed and a marker giving the `offset` that reaches the missing part. " +
|
|
629
|
-
"Pass `offset` (and optionally `length`) to read a specific byte window instead — the result states the window it returned and how many bytes follow, so you can page through a file. Byte offsets, not lines: to see the lines around a match, use sb_grep with `context`. " +
|
|
630
|
-
"Files under node_modules are not in the workspace — read those with sb_exec.",
|
|
631
|
-
inputSchema: z.object({
|
|
632
|
-
path: z
|
|
633
|
-
.string()
|
|
634
|
-
.describe("Absolute path, e.g. '/workspace/repo/src/a.ts'"),
|
|
635
|
-
offset: z
|
|
636
|
-
.number()
|
|
637
|
-
.int()
|
|
638
|
-
.min(0)
|
|
639
|
-
.optional()
|
|
640
|
-
.describe("Byte offset to start reading from"),
|
|
641
|
-
length: z
|
|
642
|
-
.number()
|
|
643
|
-
.int()
|
|
644
|
-
.min(1)
|
|
645
|
-
.optional()
|
|
646
|
-
.describe("Maximum bytes to return from `offset`")
|
|
647
|
-
}),
|
|
648
|
-
execute: async ({ path, offset, length }) => {
|
|
649
|
-
const refusal = guardPath(path, "sb_read");
|
|
650
|
-
if (refusal)
|
|
651
|
-
return refusal;
|
|
652
|
-
return inWorkspace("reading", path, async (fs) => {
|
|
653
|
-
// Either knob means the model chose a region; only an unqualified read
|
|
654
|
-
// gets the middle-out guess.
|
|
655
|
-
return offset === undefined && length === undefined
|
|
656
|
-
? await readBounded(fs, path, maxChars)
|
|
657
|
-
: await readWindow(fs, path, offset ?? 0, length ?? maxChars, maxChars);
|
|
658
|
-
});
|
|
659
|
-
}
|
|
660
|
-
}),
|
|
661
|
-
sb_write: tool({
|
|
662
|
-
description: "Create or overwrite a file in the workspace. Parent directories are created for you. For a small change to a large file prefer sb_edit, which does not require sending the whole file back.",
|
|
663
|
-
inputSchema: z.object({
|
|
664
|
-
path: z.string().describe("Absolute path"),
|
|
665
|
-
content: z
|
|
666
|
-
.string()
|
|
667
|
-
.describe("Full file content (overwrites any existing file)")
|
|
668
|
-
}),
|
|
669
|
-
execute: async ({ path, content }) => {
|
|
670
|
-
const refusal = guardPath(path, "sb_write");
|
|
671
|
-
if (refusal)
|
|
672
|
-
return refusal;
|
|
673
|
-
// Before the write, not after: the point is to not report a success that
|
|
674
|
-
// did not happen.
|
|
675
|
-
const lost = await refuseWrite();
|
|
676
|
-
if (lost)
|
|
677
|
-
return lost;
|
|
678
|
-
return inWorkspace("writing", path, async (fs) => {
|
|
679
|
-
const dir = path.slice(0, path.lastIndexOf("/"));
|
|
680
|
-
if (dir)
|
|
681
|
-
await fs.mkdir(dir, { recursive: true });
|
|
682
|
-
await fs.writeFile(path, content);
|
|
683
|
-
// Characters, not bytes. `String.length` counts UTF-16 code units — the
|
|
684
|
-
// distinction `readBounded` documents at length — so calling them bytes
|
|
685
|
-
// was simply wrong for anything outside ASCII. The exact byte count
|
|
686
|
-
// would cost a `TextEncoder` pass over the whole content for a number
|
|
687
|
-
// nobody does arithmetic with, and "characters" is already this module's
|
|
688
|
-
// word for the same count in `truncateOutput`'s omission marker.
|
|
689
|
-
const written = content.length;
|
|
690
|
-
return `wrote ${path} (${written} character${written === 1 ? "" : "s"})`;
|
|
691
|
-
});
|
|
692
|
-
}
|
|
693
|
-
}),
|
|
694
|
-
sb_edit: tool({
|
|
695
|
-
description: "Replace an exact string in a file. The string must be non-empty and must appear exactly once — if it appears zero times or more than once the edit is refused, so include enough surrounding context to make it unique.",
|
|
696
|
-
inputSchema: z.object({
|
|
697
|
-
path: z.string().describe("Absolute path"),
|
|
698
|
-
find: z
|
|
699
|
-
.string()
|
|
700
|
-
// Empty is refused at the schema rather than in the body, because
|
|
701
|
-
// `split("")` does not count empty-string occurrences: on a file of
|
|
702
|
-
// one character it reports none, on a longer one it reports one per
|
|
703
|
-
// character, and on an *empty* file it reports -1 — which slips past
|
|
704
|
-
// both guards below and writes `replace` into the file as if an edit
|
|
705
|
-
// had been found. A write tool must not write what nobody asked for.
|
|
706
|
-
.min(1)
|
|
707
|
-
.describe("Exact text to replace, unique within the file"),
|
|
708
|
-
replace: z.string().describe("Replacement text")
|
|
709
|
-
}),
|
|
710
|
-
execute: async ({ path, find, replace }) => {
|
|
711
|
-
const refusal = guardPath(path, "sb_edit");
|
|
712
|
-
if (refusal)
|
|
713
|
-
return refusal;
|
|
714
|
-
const lost = await refuseWrite();
|
|
715
|
-
if (lost)
|
|
716
|
-
return lost;
|
|
717
|
-
return inWorkspace("editing", path, async (fs) => {
|
|
718
|
-
const content = await fs.readFile(path, "utf8");
|
|
719
|
-
const occurrences = content.split(find).length - 1;
|
|
720
|
-
// Refusing an ambiguous edit is the whole value of this tool over
|
|
721
|
-
// sb_write: a silent first-match replace corrupts the file in a way
|
|
722
|
-
// that surfaces much later, usually as a confusing test failure.
|
|
723
|
-
if (occurrences === 0)
|
|
724
|
-
return `no match for that text in ${path}`;
|
|
725
|
-
if (occurrences > 1)
|
|
726
|
-
return `that text appears ${occurrences} times in ${path} — add surrounding context to make it unique`;
|
|
727
|
-
// A replacer function, not the string itself. `String.replace`
|
|
728
|
-
// interprets `$$`, `$&`, `` $` `` and `$'` in a *string* replacement
|
|
729
|
-
// even when the pattern is a plain string, so the text written is not
|
|
730
|
-
// the text the model sent: `echo $$` becomes `echo $`, and `$'` splices
|
|
731
|
-
// in everything before the match. Those two are not exotic — `$$`
|
|
732
|
-
// escapes a dollar in a Makefile and reads a PID in shell, and `$'…'`
|
|
733
|
-
// is bash ANSI-C quoting. A function's return value is used verbatim.
|
|
734
|
-
await fs.writeFile(path, content.replace(find, () => replace));
|
|
735
|
-
return `edited ${path}`;
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
}),
|
|
739
|
-
sb_ls: tool({
|
|
740
|
-
description: "List files in a workspace directory, or find files by name. Without `pattern` it lists one level: directories with a trailing slash, files with their size — check that before reading a large one, since sb_read truncates. " +
|
|
741
|
-
"`pattern` is a glob matched against paths relative to `path`, and searches the whole subtree: `*` stays within one path segment, `**/` crosses directories, `?` matches one character. So `*.ts` finds top-level TypeScript files and `**/*.ts` finds them at any depth. " +
|
|
742
|
-
"A cut listing reports the `offset` that continues it. node_modules is not in the workspace — list it with sb_exec.",
|
|
743
|
-
inputSchema: z.object({
|
|
744
|
-
path: z.string().describe("Absolute directory path"),
|
|
745
|
-
recursive: z
|
|
746
|
-
.boolean()
|
|
747
|
-
.optional()
|
|
748
|
-
.describe("List the whole subtree rather than one level"),
|
|
749
|
-
pattern: z
|
|
750
|
-
.string()
|
|
751
|
-
.optional()
|
|
752
|
-
.describe("Glob relative to path, e.g. '**/*.spec.ts'. Searches the subtree, so `recursive` is not needed with it."),
|
|
753
|
-
offset: z
|
|
754
|
-
.number()
|
|
755
|
-
.int()
|
|
756
|
-
.min(0)
|
|
757
|
-
.optional()
|
|
758
|
-
.describe("Entries to skip — use the offset a cut listing reports")
|
|
759
|
-
}),
|
|
760
|
-
execute: async ({ path, recursive, pattern, offset }) => {
|
|
761
|
-
const refusal = guardPath(path, "sb_ls");
|
|
762
|
-
if (refusal)
|
|
763
|
-
return refusal;
|
|
764
|
-
const from = offset ?? 0;
|
|
765
|
-
return inWorkspace("listing", path, async (fs) => {
|
|
766
|
-
if (recursive || pattern) {
|
|
767
|
-
// `find` rather than `ls`, which took no bound: a prefix scan
|
|
768
|
-
// returned every path in the subtree and the ceiling then threw most
|
|
769
|
-
// of them away — the same read-everything-then-discard shape the
|
|
770
|
-
// `readdir` limit below exists to avoid. Two differences follow from
|
|
771
|
-
// the swap, both improvements: directories appear (rendered like the
|
|
772
|
-
// arm below renders them), and order is the walk's — pre-order, by
|
|
773
|
-
// name — rather than one flat sort.
|
|
774
|
-
//
|
|
775
|
-
// Filtered rather than trusted, and this is the arm where it matters
|
|
776
|
-
// most: at a repo root `.git` is walked *first* and holds thousands
|
|
777
|
-
// of objects, so an unfiltered page is a page of `.git` and nothing
|
|
778
|
-
// else. Four rounds because a `find` retry re-walks dirents, which is
|
|
779
|
-
// cheap SQLite reads rather than file bytes.
|
|
780
|
-
const page = await collectVisible((at, limit) => fs.find(path, pattern, { limit, offset: at }), (e) => e.path, DEFAULT_MAX_ENTRIES, from, 4);
|
|
781
|
-
if (page.items.length === 0)
|
|
782
|
-
return page.crowded
|
|
783
|
-
? `(everything under ${path} from offset ${from} is inside .git — try a \`pattern\`, or a \`path\` inside the working tree)`
|
|
784
|
-
: pattern
|
|
785
|
-
? `(nothing under ${path} matches ${pattern})`
|
|
786
|
-
: `(${path} is empty)`;
|
|
787
|
-
const blocks = page.items
|
|
788
|
-
.slice(0, DEFAULT_MAX_ENTRIES)
|
|
789
|
-
.map((e) => [e.type === "dir" ? `${e.path}/` : e.path]);
|
|
790
|
-
const { body, shown } = packBlocks(blocks, maxChars);
|
|
791
|
-
return body + listingNote(page, shown, "entries", "`pattern`");
|
|
792
|
-
}
|
|
793
|
-
// One over the ceiling: enough to know the listing was cut without a
|
|
794
|
-
// second round trip to find out, and the extra entry is not shown.
|
|
795
|
-
// Unfiltered on purpose — `.git/` shows here exactly as `node_modules`
|
|
796
|
-
// does, because one line naming a directory that is really there is
|
|
797
|
-
// honest, and it is access rather than existence that is refused.
|
|
798
|
-
const entries = await fs.readdir(path, {
|
|
799
|
-
limit: DEFAULT_MAX_ENTRIES + 1,
|
|
800
|
-
offset: from
|
|
801
|
-
});
|
|
802
|
-
if (entries.length === 0)
|
|
803
|
-
return from > 0
|
|
804
|
-
? `(no entries in ${path} past offset ${from})`
|
|
805
|
-
: `(${path} is empty)`;
|
|
806
|
-
const blocks = entries
|
|
807
|
-
.slice(0, DEFAULT_MAX_ENTRIES)
|
|
808
|
-
.map((e) => [
|
|
809
|
-
e.isDirectory ? `${e.name}/` : `${e.name}\t${humanBytes(e.size)}`
|
|
810
|
-
]);
|
|
811
|
-
const { body, shown } = packBlocks(blocks, maxChars);
|
|
812
|
-
const more = entries.length > shown;
|
|
813
|
-
return (body +
|
|
814
|
-
(more
|
|
815
|
-
? `\n… showed ${shown} entries; there are more. Continue with \`offset: ${from + shown}\`, or narrow with \`pattern\`.`
|
|
816
|
-
: ""));
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
}),
|
|
820
|
-
/**
|
|
821
|
-
* Search, without the container.
|
|
822
|
-
*
|
|
823
|
-
* The tool this replaces is `sb_exec("grep -rn …")`, and the case for a native
|
|
824
|
-
* one is not that shelling out fails — it is where the search runs. `fs.grep`
|
|
825
|
-
* reads the Durable Object's SQLite, so it answers while the container is
|
|
826
|
-
* being replaced or an install is still running, which is exactly the window
|
|
827
|
-
* the install gate leaves a subagent with nothing to do. It is also why this
|
|
828
|
-
* tool is not gated: see {@link awaitInstall}.
|
|
829
|
-
*
|
|
830
|
-
* Two lesser reasons that still matter. The query arrives as a value rather
|
|
831
|
-
* than through `shellQuote` and a shell that would re-parse it. And the result
|
|
832
|
-
* is bounded by a `limit` at the source instead of being middle-truncated
|
|
833
|
-
* afterwards, which for a match list means losing whole files silently.
|
|
834
|
-
*/
|
|
835
|
-
sb_grep: tool({
|
|
836
|
-
description: "Search file contents across the workspace. Returns matching lines grouped by file, each with its line number. " +
|
|
837
|
-
"The query is matched literally — set `regex` to interpret it as a regular expression. " +
|
|
838
|
-
"Pass `include` to limit which files are searched, e.g. '**/*.ts' — without it every file under `path` is read, which is slower and rarely what you meant. " +
|
|
839
|
-
"A cut result reports the `offset` that continues it. Use `context` to see the lines around a match. " +
|
|
840
|
-
"node_modules is not in the workspace — search it with sb_exec.",
|
|
841
|
-
inputSchema: z.object({
|
|
842
|
-
query: z.string().describe("Text to find, e.g. 'buildComputerTools'"),
|
|
843
|
-
path: z
|
|
844
|
-
.string()
|
|
845
|
-
.optional()
|
|
846
|
-
.describe(`Absolute file or directory to search (default: ${cwd})`),
|
|
847
|
-
include: z
|
|
848
|
-
.string()
|
|
849
|
-
.optional()
|
|
850
|
-
.describe("Glob relative to path limiting which files are searched, e.g. '**/*.ts'"),
|
|
851
|
-
regex: z
|
|
852
|
-
.boolean()
|
|
853
|
-
.optional()
|
|
854
|
-
.describe("Interpret query as a regular expression"),
|
|
855
|
-
ignoreCase: z.boolean().optional().describe("Ignore letter case"),
|
|
856
|
-
context: z
|
|
857
|
-
.number()
|
|
858
|
-
.int()
|
|
859
|
-
.min(0)
|
|
860
|
-
.max(3)
|
|
861
|
-
.optional()
|
|
862
|
-
.describe("Lines of surrounding context to include with each match"),
|
|
863
|
-
offset: z
|
|
864
|
-
.number()
|
|
865
|
-
.int()
|
|
866
|
-
.min(0)
|
|
867
|
-
.optional()
|
|
868
|
-
.describe("Matches to skip — use the offset a cut result reports")
|
|
869
|
-
}),
|
|
870
|
-
execute: async ({ query, path, include, regex, ignoreCase, context, offset }) => {
|
|
871
|
-
const target = path ?? cwd;
|
|
872
|
-
const refusal = guardPath(target, "sb_grep");
|
|
873
|
-
if (refusal)
|
|
874
|
-
return refusal;
|
|
875
|
-
const from = offset ?? 0;
|
|
876
|
-
return inWorkspace("searching", target, async (fs) => {
|
|
877
|
-
// Two rounds, not four: a `grep` retry re-reads and re-scans every file
|
|
878
|
-
// it already looked at, where the `find` retry in `sb_ls` only re-walks
|
|
879
|
-
// dirents. `.git` is also far less likely to flood a page here — its
|
|
880
|
-
// bulk is compressed objects, which a text query does not match.
|
|
881
|
-
const page = await collectVisible((at, limit) => fs.grep(query, target, {
|
|
882
|
-
include,
|
|
883
|
-
regex,
|
|
884
|
-
ignoreCase,
|
|
885
|
-
context,
|
|
886
|
-
limit,
|
|
887
|
-
offset: at
|
|
888
|
-
}), (m) => m.path, DEFAULT_MAX_MATCHES, from, 2);
|
|
889
|
-
if (page.items.length === 0)
|
|
890
|
-
return page.crowded
|
|
891
|
-
? `every match for ${JSON.stringify(query)} from offset ${from} is inside .git, which is not searched. Add \`include\` (e.g. '**/*.ts') to search the working tree instead.`
|
|
892
|
-
: `no matches for ${JSON.stringify(query)} in ${target}${include ? ` (${include})` : ""}${from > 0 ? ` past offset ${from}` : ""}`;
|
|
893
|
-
const { body, shown, capped } = renderGrepMatches(page.items.slice(0, DEFAULT_MAX_MATCHES), maxChars);
|
|
894
|
-
return (body +
|
|
895
|
-
listingNote(page, shown, "matches", "`include`") +
|
|
896
|
-
(capped
|
|
897
|
-
? `\n(Some lines were shortened. Read one in full with \`sb_read\` and an \`offset\`.)`
|
|
898
|
-
: ""));
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
}),
|
|
902
|
-
sb_exists: tool({
|
|
903
|
-
description: "Check whether a path exists in the workspace.",
|
|
904
|
-
inputSchema: z.object({ path: z.string().describe("Absolute path") }),
|
|
905
|
-
execute: async ({ path }) => {
|
|
906
|
-
const refusal = guardPath(path, "sb_exists");
|
|
907
|
-
if (refusal)
|
|
908
|
-
return refusal;
|
|
909
|
-
return inWorkspace("checking", path, async (fs) => {
|
|
910
|
-
return (await pathExists(fs, path))
|
|
911
|
-
? `${path} exists`
|
|
912
|
-
: `${path} does not exist`;
|
|
913
|
-
});
|
|
914
|
-
}
|
|
915
|
-
})
|
|
52
|
+
...execTools(ctx),
|
|
53
|
+
...fileTools(ctx),
|
|
54
|
+
...grepTools(ctx)
|
|
916
55
|
};
|
|
917
56
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* it where the merge is visible:
|
|
944
|
-
*
|
|
945
|
-
* ```ts
|
|
946
|
-
* exec: (command, options) =>
|
|
947
|
-
* computerExec(config)(command, {
|
|
948
|
-
* ...options,
|
|
949
|
-
* env: { ...mine, ...options?.env }
|
|
950
|
-
* });
|
|
951
|
-
* ```
|
|
57
|
+
const CONTEXT = [
|
|
58
|
+
"You have a Linux container with a shell, a package manager and network access:",
|
|
59
|
+
"- `bash` runs any shell command — builds, tests, installs, git.",
|
|
60
|
+
"- `read` / `write` / `edit` / `delete` work on files; `edit` replaces an exact unique string and is the right tool for a small change.",
|
|
61
|
+
"- `grep` searches file contents; `list` lists a directory; `find` finds files by glob.",
|
|
62
|
+
// The reason to prefer them is the one thing the model cannot infer from a
|
|
63
|
+
// tool description: these read the durable workspace directly, so they are
|
|
64
|
+
// the only tools that still answer while the container is unavailable.
|
|
65
|
+
"Search with `grep` and `find` rather than running them through `bash`. They read the workspace directly, so they keep working while the container is restarting or an install is still running, and they come back with line numbers and a bounded result instead of a wall of text.",
|
|
66
|
+
"Command output is truncated from the middle when large, so run targeted commands and read specific files rather than printing everything.",
|
|
67
|
+
// Both halves of this matter and they pull in opposite directions, which
|
|
68
|
+
// is why they are stated together rather than left for the model to work
|
|
69
|
+
// out from a confusing result.
|
|
70
|
+
"The checkout is durable: it survives between tasks and is still there after the container restarts, so it may already contain work from an earlier task — check before assuming it is empty.",
|
|
71
|
+
"`node_modules` is not durable: it lives on the container's disk, so a new container reinstalls it, and the file tools cannot see inside it — use `bash` there. It is a mount point, so `rm -rf node_modules` fails; `npm ci` clears it itself.",
|
|
72
|
+
// Stated up front rather than left to a refusal, so the model does not spend
|
|
73
|
+
// a turn discovering it. The destination matters as much as the rule: a
|
|
74
|
+
// prohibition with nowhere to go gets worked around.
|
|
75
|
+
"`.git` is off limits to these tools, and searches and recursive listings skip it. It is git's internal state — reading it tells you less than the repository tools do, and writing it corrupts the checkout. Repository work goes through the repo tools (`repo_status`, `repo_diff`, `repo_commit`, `repo_push`); if a task needs git state you cannot get that way, say so in your result rather than reaching into `.git` yourself."
|
|
76
|
+
].join("\n");
|
|
77
|
+
/**
|
|
78
|
+
* The container plugin. Its tools reach the workspace through the agent's own
|
|
79
|
+
* `computerWorkspace` — the one Think's `read` and `write` use — so the two can
|
|
80
|
+
* never resolve different ones. `config` answers everything else: the shell,
|
|
81
|
+
* the limits, the environment.
|
|
952
82
|
*/
|
|
953
|
-
export function computerExec(config) {
|
|
954
|
-
return async (command, options) => {
|
|
955
|
-
const env_3 = { stack: [], error: void 0, hasError: false };
|
|
956
|
-
try {
|
|
957
|
-
// The caller forwards its execution's runtime state opaquely; only this side
|
|
958
|
-
// knows the key it might carry. That is what lets `/repo` reach a subagent's
|
|
959
|
-
// shared workspace without importing anything from here.
|
|
960
|
-
const name = workspaceNameFromRuntime(options?.runtime) ?? config.workspaceName();
|
|
961
|
-
const ws = __addDisposableResource(env_3, await openWorkspace(config.binding.get(config.binding.idFromName(name))), false);
|
|
962
|
-
const env = options?.env
|
|
963
|
-
? Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== undefined))
|
|
964
|
-
: undefined;
|
|
965
|
-
// Named rather than inlined, because the note below has to state it: a
|
|
966
|
-
// ceiling the model cannot see is one it cannot work within.
|
|
967
|
-
const timeoutMs = options?.timeout ?? config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
968
|
-
// `withShell`, never `withShellTranscript`: the caller reads this result in
|
|
969
|
-
// code. `/repo` compares `stdout` against a URL, tests it for emptiness to
|
|
970
|
-
// decide a tree is clean, and reads a sha out of it to push — all of which
|
|
971
|
-
// need `stdout` to carry the answer alone, with git's diagnostics on the
|
|
972
|
-
// channel git put them on.
|
|
973
|
-
const handle = __addDisposableResource(env_3, await ws.runtime.exec(withShell(command, config.shell), {
|
|
974
|
-
cwd: options?.cwd ?? config.cwd ?? DEFAULT_CWD,
|
|
975
|
-
encoding: "utf8",
|
|
976
|
-
timeoutMs,
|
|
977
|
-
...(env ? { env } : {})
|
|
978
|
-
}), false);
|
|
979
|
-
const result = await handle.result();
|
|
980
|
-
const killed = cancelledNote(result.status, result.exitCode, timeoutMs);
|
|
981
|
-
return {
|
|
982
|
-
// `/repo` branches on `success`, which this runtime does not report — it
|
|
983
|
-
// has `status` and `exitCode`. Derived from the exit code rather than the
|
|
984
|
-
// status, because a command that runs and fails is `completed` here.
|
|
985
|
-
success: result.exitCode === 0,
|
|
986
|
-
stdout: result.stdout,
|
|
987
|
-
// Appended rather than substituted: a command killed at the ceiling may
|
|
988
|
-
// well have written plenty first, and that output is the more useful half.
|
|
989
|
-
// This is the only field the note can travel on — `stdout` is a data
|
|
990
|
-
// channel the caller parses, and the four fields are the whole contract.
|
|
991
|
-
stderr: killed
|
|
992
|
-
? [result.stderr, killed].filter(Boolean).join("\n")
|
|
993
|
-
: result.stderr,
|
|
994
|
-
exitCode: result.exitCode
|
|
995
|
-
};
|
|
996
|
-
}
|
|
997
|
-
catch (e_3) {
|
|
998
|
-
env_3.error = e_3;
|
|
999
|
-
env_3.hasError = true;
|
|
1000
|
-
}
|
|
1001
|
-
finally {
|
|
1002
|
-
__disposeResources(env_3);
|
|
1003
|
-
}
|
|
1004
|
-
};
|
|
1005
|
-
}
|
|
1006
83
|
export function computer(config) {
|
|
1007
|
-
// Resolved per call, not memoized: `workspaceName` is a thunk precisely
|
|
1008
|
-
// because the name is not knowable at construction, and a stale stub would
|
|
1009
|
-
// silently route a second caller's commands into the first caller's files.
|
|
1010
|
-
const host = (runtime) => {
|
|
1011
|
-
const name = workspaceNameFromRuntime(runtime) ?? config.workspaceName();
|
|
1012
|
-
return config.binding.get(config.binding.idFromName(name));
|
|
1013
|
-
};
|
|
1014
|
-
const tools = (runtime) => buildComputerTools(() => openWorkspace(host(runtime)), config,
|
|
1015
|
-
// No try/catch here: `buildComputerTools` fails the gate open itself, so
|
|
1016
|
-
// wrapping again would only make it look like the guarantee lives in two
|
|
1017
|
-
// places.
|
|
1018
|
-
() => host(runtime).advisories());
|
|
1019
84
|
return definePlugin({
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
85
|
+
name: "computer",
|
|
86
|
+
tools: (ctx) => {
|
|
87
|
+
// Here, not in `execute`: the start check builds every plugin's tools,
|
|
88
|
+
// so a missing workspace fails the start rather than a turn.
|
|
89
|
+
const route = workspaceRoute(ctx.workspace());
|
|
90
|
+
if (!route)
|
|
91
|
+
throw new PluginSetupError(`plugin "computer" runs \`bash\` in a container, but ${ctx.agentName}'s ` +
|
|
92
|
+
"workspace is not that container's, so Think's `read` and `write` " +
|
|
93
|
+
"would work on a different tree. Set it on the agent class: " +
|
|
94
|
+
"`override workspace = computerWorkspace(config, () => this.pluginContext().runtime())`.");
|
|
95
|
+
// Resolved per call, not memoized: the name belongs to the turn — a
|
|
96
|
+
// sub-agent's comes from `runtime()`, see `/workspace`'s `WORKSPACE_RUNTIME_KEY` —
|
|
97
|
+
// and a stale stub would silently route a second caller's commands into
|
|
98
|
+
// the first caller's files.
|
|
99
|
+
const host = () => route.host(route.name());
|
|
100
|
+
return buildComputerTools(() => openWorkspace(host()), config,
|
|
101
|
+
// No try/catch here: `buildComputerTools` fails the gate open itself,
|
|
102
|
+
// so wrapping again would only make it look like the guarantee lives in
|
|
103
|
+
// two places.
|
|
104
|
+
() => host().advisories(), () => openWorkspaceFs(host()), route.name);
|
|
1027
105
|
},
|
|
1028
|
-
|
|
1029
|
-
"You have a Linux container with a shell, a package manager and network access:",
|
|
1030
|
-
"- `sb_exec` runs any shell command — builds, tests, installs, git.",
|
|
1031
|
-
"- `sb_read` / `sb_write` / `sb_edit` work on files; `sb_edit` replaces an exact unique string and is the right tool for a small change.",
|
|
1032
|
-
"- `sb_grep` searches file contents; `sb_ls` lists a directory, or finds files by glob with `pattern`; `sb_exists` checks a path.",
|
|
1033
|
-
// The reason to prefer them is the one thing the model cannot infer from a
|
|
1034
|
-
// tool description: these read the durable workspace directly, so they are
|
|
1035
|
-
// the only tools that still answer while the container is unavailable.
|
|
1036
|
-
"Search with `sb_grep` and `sb_ls` rather than running `grep` or `find` through `sb_exec`. They read the workspace directly, so they keep working while the container is restarting or dependencies are still installing, and they come back with line numbers and a bounded result instead of a wall of text.",
|
|
1037
|
-
"Command output is truncated from the middle when large, so run targeted commands and read specific files rather than printing everything.",
|
|
1038
|
-
// Both halves of this matter and they pull in opposite directions, which
|
|
1039
|
-
// is why they are stated together rather than left for the model to work
|
|
1040
|
-
// out from a confusing result.
|
|
1041
|
-
"The checkout is durable: it survives between tasks and is still there after the container restarts, so it may already contain work from an earlier task — check before assuming it is empty.",
|
|
1042
|
-
"`node_modules` is the exception. It lives only in the container, so it is rebuilt whenever the container restarts, and the file tools cannot see inside it — use `sb_exec` to read a dependency's source.",
|
|
1043
|
-
// Stated up front rather than left to a refusal, so the model does not spend
|
|
1044
|
-
// a turn discovering it. The destination matters as much as the rule: a
|
|
1045
|
-
// prohibition with nowhere to go gets worked around.
|
|
1046
|
-
"`.git` is off limits to these tools, and searches and recursive listings skip it. It is git's internal state — reading it tells you less than the repository tools do, and writing it corrupts the checkout. Repository work goes through the repo tools (`repo_status`, `repo_diff`, `repo_commit`, `repo_push`), which the main agent holds; if a task needs git state beyond what you can see in the working tree, say so in your result rather than reaching into `.git` yourself."
|
|
1047
|
-
].join("\n")
|
|
106
|
+
context: [{ provider: { get: async () => CONTEXT } }]
|
|
1048
107
|
});
|
|
1049
108
|
}
|