@dynamicagents/plugins 0.16.1 → 0.17.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.
@@ -1,168 +1,162 @@
1
- var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
- if (value !== null && value !== void 0) {
3
- if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
- var dispose, inner;
5
- if (async) {
6
- if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
- dispose = value[Symbol.asyncDispose];
8
- }
9
- if (dispose === void 0) {
10
- if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
- dispose = value[Symbol.dispose];
12
- if (async) inner = dispose;
13
- }
14
- if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
- if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
- env.stack.push({ value: value, dispose: dispose, async: async });
17
- }
18
- else if (async) {
19
- env.stack.push({ async: true });
20
- }
21
- return value;
22
- };
23
- var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
- return function (env) {
25
- function fail(e) {
26
- env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
- env.hasError = true;
28
- }
29
- var r, s = 0;
30
- function next() {
31
- while (r = env.stack.pop()) {
32
- try {
33
- if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
- if (r.dispose) {
35
- var result = r.dispose.call(r.value);
36
- if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
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";
1
+ import { createExecTool } from "@cloudflare/computer/tools/ai-sdk";
55
2
  import { withAbort } from "@dynamicagents/core";
56
- import { renderResult } from "./render.js";
3
+ import { tool } from "ai";
57
4
  import { withShellTranscript } from "./shell.js";
58
- /** Running one command in the container. */
5
+ /** Running one command in the container, on Computer's own `exec` tool. */
59
6
  /**
60
- * Wait for a disposable resource without outliving `signal`.
7
+ * The one backend this workspace has, and the id the model never has to name.
61
8
  *
62
- * `withAbort` can abandon the wait but not the resource, and one that arrives
63
- * after its caller has given up has nobody left to dispose it. So it is released
64
- * on arrival instead — disposed by default, or handed to `release` where
65
- * disposing alone would leave work running.
9
+ * `createExecTool` refuses to build with no backend, and shows a `backend`
10
+ * parameter only when there is more than one. So naming exactly one is what
11
+ * keeps the model's call `{ command }` and nothing else.
66
12
  */
67
- function acquire(signal, opening, release = (resource) => resource[Symbol.dispose]()) {
68
- return withAbort(signal, opening, () => {
69
- void opening.then(release).catch(() => { });
70
- });
71
- }
13
+ const BACKEND = "container";
14
+ /** What the model is told the backend is, under Computer's own description. */
15
+ const BACKEND_DESCRIPTION = "A Linux container whose disk is the checkout, with a package manager and network access.";
72
16
  /**
73
- * How an exec handle that arrived too late is released. Disposing only detaches
74
- * this side, so the process is killed first.
17
+ * The container's shell, as Computer's `exec` tool.
18
+ *
19
+ * **The tool is upstream's, and deliberately not wrapped past the advisory
20
+ * gate.** Its schema, its description, its truncation and its result shape are
21
+ * `@cloudflare/computer`'s, so the model sees the same `exec` everywhere that
22
+ * package's tools are installed and this plugin has no second dialect to keep in
23
+ * step. What that costs, each of it accepted rather than overlooked:
24
+ *
25
+ * - **Truncation keeps the tail, not both ends.** Upstream cuts each stream to
26
+ * its last lines, so a failure printed early in a long build is gone from what
27
+ * the model reads. The exit code still reports it, and the model's recourse is
28
+ * a narrower command.
29
+ * - **The description promises a file this workspace does not write.** Upstream
30
+ * tells the model that truncated output is saved where `read` and `grep` can
31
+ * open it. That holds for a Workspace whose runtime saves it; this one reports
32
+ * no saved path, so the reply names none and the sentence is unfulfilled.
33
+ * Nothing can be set to turn it off: the sentence is built into the generated
34
+ * description.
35
+ * - **A command stopped at the time limit or cancelled reads as a plain
36
+ * failure.** Upstream's result has somewhere for an exit code and somewhere
37
+ * for an error, and nowhere for the difference between a command that outran
38
+ * its limit, one whose call was cancelled, and one that simply failed.
39
+ * {@link file://./client.ts CommandResult.status} still carries it for the log
40
+ * line below, which is the only place it is read.
41
+ *
42
+ * What stays this plugin's, because neither belongs to a tool:
43
+ *
44
+ * - **The advisory gate**, below: a command waits out a running install, is
45
+ * refused while something transient makes it pointless, and carries a note
46
+ * about anything permanent. {@link file://./gate.ts ExecGate} is the contract.
47
+ * - **How the container runs a command**, in
48
+ * {@link file://./sandbox.ts sandboxClient}: the launch-and-cancel handshake,
49
+ * the time limit, and the shell transcript. A configured shell merges the two
50
+ * streams onto one of them, so `stderr` arrives empty and the whole transcript
51
+ * is in `stdout` — see {@link file://./shell.ts withShellTranscript} for why a
52
+ * model reading output wants that, and why the plugin's context block rather
53
+ * than the tool description is where the model is told.
75
54
  */
76
- async function killLate(handle) {
77
- try {
78
- await handle.kill("SIGTERM");
79
- }
80
- finally {
81
- handle[Symbol.dispose]();
82
- }
83
- }
84
55
  export function execTools(ctx) {
85
56
  const { config, cwd, timeoutMs, maxChars, workspace, awaitAdvisories, definedEnv } = ctx;
86
- return {
87
- bash: tool({
88
- /**
89
- * States what the tool guarantees, rather than what it might withhold.
90
- *
91
- * A description that leads with "output is truncated if it is large" and
92
- * gives no exit code hands the model two independent reasons to re-run a
93
- * command and capture the output "properly" — at 60 seconds a go on a real
94
- * gate, whose few hundred characters were never near the ceiling anyway.
95
- *
96
- * Two of these sentences are only true when a shell is configured, so the
97
- * description says whichever is. `withShellTranscript` is a no-op without
98
- * {@link file://./index.ts ComputerToolsConfig.shell}: no `2>&1`, so the
99
- * streams arrive separate and {@link renderResult} labels them; no
100
- * `-o pipefail`, so `/bin/sh` reports a pipeline's *last* stage. Promising
101
- * a transcript and first-failure semantics there would be the lie
102
- * `wrapped` explains the cost of.
103
- */
104
- 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. " +
105
- (config.shell
106
- ? "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. " +
107
- "You do not need to append `echo $?`, add `2>&1`, or redirect to a file to see any of this. " +
108
- "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`. "
109
- : "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. " +
110
- "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. " +
111
- "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. ") +
112
- "Prefer targeted commands over ones that print everything.",
113
- inputSchema: z.object({
114
- command: z.string().describe("The shell command, e.g. 'npm test'"),
115
- cwd: z
116
- .string()
117
- .optional()
118
- .describe(`Working directory (default: ${cwd})`)
119
- }),
120
- execute: async ({ command, cwd: overrideCwd }, { abortSignal }) => {
57
+ /**
58
+ * Computer's tool over one call's workspace and one call's signal.
59
+ *
60
+ * Built per call rather than once, because the signal belongs to the call and
61
+ * upstream's runtime interface has nowhere to pass one: holding it in a
62
+ * variable this closure shared would let two commands in one isolate read
63
+ * each other's cancellation. The instance below, built with no signal, is
64
+ * used for its description and its schema alone and never runs a command.
65
+ */
66
+ const execToolFor = (signal) => createExecTool({
67
+ workspace: {
68
+ runtime: {
121
69
  /**
122
- * One line per command, and it is the only view of where a task's wall
123
- * clock actually goes.
70
+ * Computer's tool asks for a command and a working directory; the
71
+ * rest of what a container command needs is this plugin's, and is
72
+ * filled in here rather than left to the model.
124
73
  *
125
- * Everything on the Worker side of a container command is an `await`, so
126
- * Workers Observability records the invocation at ~0% CPU and a long wall
127
- * time and cannot say what ran. Without this line, diagnosing a 59-minute
128
- * task means inferring the shape of each command from the *gaps between
129
- * AI Gateway calls*. The three numbers below — how long the install gate
130
- * held, how long the command took, what it exited with — answer it
131
- * directly.
132
- *
133
- * Timed around the gate as well as the command, since an agent blocked
134
- * waiting for `npm ci` and one running a slow test suite are
135
- * indistinguishable from the outside and want opposite fixes.
74
+ * `output` is not forwarded: the sandbox has no way to cap a
75
+ * command's output at the source, so the ceiling is spent on this
76
+ * side, which is where it was spent before.
136
77
  */
78
+ exec: async (command, options) => {
79
+ // Abandoning the wait is not abandoning the workspace: one that
80
+ // arrives after this call gave up has nobody left to dispose it.
81
+ const client = await acquire(signal, workspace());
82
+ const releaseClient = releaseOnce("workspace", client);
83
+ /**
84
+ * The launch, once it exists, and **what owns the client from then
85
+ * on**.
86
+ *
87
+ * A command's kill runs through this client, so the client has to
88
+ * outlive the launch whatever happens to it: a cancel that landed
89
+ * mid-launch is cleaned up by {@link killLate}, which kills first
90
+ * and releases after, and a launch that fails instead brings no
91
+ * handle to kill and releases from here. Releasing in the catch
92
+ * below would disconnect the very kill that stops the abandoned
93
+ * command.
94
+ *
95
+ * Before the launch exists there is nothing else to release it, so
96
+ * the catch does.
97
+ */
98
+ let launch;
99
+ try {
100
+ // Read once per command, and inside the try: this is the host's
101
+ // thunk, so it may throw, and a throw out here would leave the
102
+ // client above held by nobody.
103
+ const env = definedEnv();
104
+ // The last moment before anything runs in the container.
105
+ // Upstream reads the signal only once it holds a handle, so
106
+ // without this a call cancelled while the workspace was opening
107
+ // still starts its command.
108
+ signal?.throwIfAborted();
109
+ launch = client.runtime.exec(withShellTranscript(command, config.shell), {
110
+ cwd: options.cwd ?? cwd,
111
+ encoding: "utf8",
112
+ timeoutMs,
113
+ ...(env ? { env } : {})
114
+ });
115
+ // A launch that fails has no handle for `killLate` to clean up,
116
+ // so its own failure is what releases the client. Attached here
117
+ // rather than in the catch, because an abandoned launch fails
118
+ // long after the catch has run.
119
+ void launch.catch(releaseClient);
120
+ const handle = await acquire(signal, launch,
121
+ // Disposing a late handle only detaches this side, so the
122
+ // command is killed first and the workspace released after.
123
+ // Returned, not discarded: `acquire` adopts it, so a kill that
124
+ // fails is handled rather than left unhandled.
125
+ (late) => killLate(late, releaseClient));
126
+ return held(handle, releaseClient, command);
127
+ }
128
+ catch (err) {
129
+ if (launch === undefined)
130
+ releaseClient();
131
+ throw err;
132
+ }
133
+ },
134
+ backends: () => [
135
+ {
136
+ id: BACKEND,
137
+ protocol: "command",
138
+ callable: false,
139
+ description: BACKEND_DESCRIPTION
140
+ }
141
+ ]
142
+ }
143
+ },
144
+ // Characters, spent as bytes: upstream's ceiling is in bytes and there is
145
+ // no character form of it. The two coincide until the output is not
146
+ // ASCII, where the model is shown a little less than the host asked for.
147
+ // `maxLines` is left to upstream's own default.
148
+ maxBytes: maxChars
149
+ });
150
+ /** For the description and the schema the model is shown, nothing else. */
151
+ const shape = execToolFor();
152
+ return {
153
+ exec: tool({
154
+ description: shape.description,
155
+ inputSchema: shape.inputSchema,
156
+ execute: async function* (input, options) {
157
+ const command = input.command;
137
158
  const startedAtMs = Date.now();
138
- /**
139
- * What the model is told when this call stops before the command
140
- * finishes. Said plainly, because the likeliest next move after a bare
141
- * error is the same command again — and whether that is right depends on
142
- * where the call stopped. A command that outran the limit once will again;
143
- * one that never started because the workspace did not answer may not.
144
- *
145
- * `bash` reads its call's signal rather than leaving the wait to Think,
146
- * and this is why: an abandoned wait can say only that the command may
147
- * still be running, and the kill below is what stops it.
148
- */
149
- const stopped = (gateMs, started) => {
150
- const timedOut = abortSignal?.reason?.name ===
151
- "TimeoutError";
152
- console.info("[computer] bash stopped", {
153
- command,
154
- gateMs,
155
- durationMs: Date.now() - startedAtMs - gateMs,
156
- started,
157
- reason: timedOut ? "time limit" : "cancelled"
158
- });
159
- if (!timedOut)
160
- return "the command was stopped because this call was cancelled. Anything it changed before then is still changed.";
161
- return started
162
- ? "the command was stopped: it ran past this call's time limit. Anything it changed before then is still changed. Try something narrower."
163
- : "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.";
164
- };
165
- // Only `bash` waits on an install. The file tools read and write
159
+ // Only the shell waits on an install. The file tools read and write
166
160
  // source, which is in the workspace and unaffected by an install in
167
161
  // flight — blocking them would stop the agent doing the reading it
168
162
  // could usefully do while it waits. They do consult `writeGate`, which
@@ -170,93 +164,228 @@ export function execTools(ctx) {
170
164
  // survive at all".
171
165
  let gate;
172
166
  try {
173
- gate = await awaitAdvisories(command, abortSignal);
167
+ gate = await awaitAdvisories(command, options.abortSignal);
174
168
  }
175
169
  catch (err) {
176
170
  // Only a cancel gets out of the gate; a failed advisory read opens it.
177
- if (abortSignal?.aborted)
178
- return stopped(Date.now() - startedAtMs, false);
171
+ if (options.abortSignal?.aborted) {
172
+ yield {
173
+ command,
174
+ cwd: input.cwd ?? null,
175
+ backend: BACKEND,
176
+ error: "the command was stopped because this call was cancelled before it ran."
177
+ };
178
+ return;
179
+ }
179
180
  throw err;
180
181
  }
181
- const gateMsWaited = Date.now() - startedAtMs;
182
- if (gate.block) {
183
- console.info("[computer] bash blocked by a workspace advisory", {
182
+ const gateMs = Date.now() - startedAtMs;
183
+ if (gate.block !== undefined) {
184
+ console.info("[computer] exec blocked by a workspace advisory", {
184
185
  command,
185
- gateMs: gateMsWaited
186
+ gateMs
186
187
  });
187
- return gate.block;
188
- }
189
- // Prepended to whatever happens next, success or failure. The warning
190
- // is context for the output, not a substitute for it — which is why it
191
- // is carried through the catch as well.
192
- const note = (body) => gate.warn ? `${gate.warn}\n\n${body}` : body;
193
- // Set once the exec is sent. See `stopped`.
194
- let started = false;
195
- try {
196
- const env_1 = { stack: [], error: void 0, hasError: false };
197
- try {
198
- // A workspace that stops answering would hold the call before any of the
199
- // cancellation below is reached.
200
- abortSignal?.throwIfAborted();
201
- const ws = __addDisposableResource(env_1, await acquire(abortSignal, workspace()), false);
202
- // Read once per command. This is the *host's* thunk, so calling it
203
- // twice in the construction of one command's options is two chances to
204
- // disagree — the check and the value would come from different reads.
205
- const commandEnv = definedEnv();
206
- const options = {
207
- cwd: overrideCwd ?? cwd,
208
- encoding: "utf8",
209
- timeoutMs,
210
- ...(commandEnv ? { env: commandEnv } : {})
211
- };
212
- // Transcript, not two streams: this result goes to a model, which
213
- // reads it as a terminal session rather than parsing it.
214
- // Not started at all on a signal that has already fired: a process that
215
- // is killed on the next line still ran long enough to change something.
216
- abortSignal?.throwIfAborted();
217
- // From here a stop cannot promise nothing ran: the exec may reach the
218
- // container before its handle reaches this side.
219
- started = true;
220
- const handle = __addDisposableResource(env_1, await acquire(abortSignal, ws.runtime.exec(withShellTranscript(command, config.shell), options), killLate), false);
221
- // The runtime takes no signal, so stopping the wait and stopping the
222
- // process are two acts. Disposing the handle is neither — it releases
223
- // this side's attachment and leaves the command running.
224
- const result = await withAbort(abortSignal, handle.result(), () => handle.kill("SIGTERM"));
225
- console.info("[computer] bash", {
226
- command,
227
- exitCode: result.exitCode,
228
- // Split so a slow command and a slow *wait* never look alike.
229
- gateMs: gateMsWaited,
230
- durationMs: Date.now() - startedAtMs - gateMsWaited,
231
- // The ceiling this was measured against — a duration sitting on it is
232
- // a timeout wearing a normal-looking number.
233
- timeoutMs
234
- });
235
- return note(renderResult(result, maxChars));
236
- }
237
- catch (e_1) {
238
- env_1.error = e_1;
239
- env_1.hasError = true;
240
- }
241
- finally {
242
- __disposeResources(env_1);
243
- }
188
+ yield {
189
+ command,
190
+ cwd: input.cwd ?? null,
191
+ backend: BACKEND,
192
+ error: gate.block
193
+ };
194
+ return;
244
195
  }
245
- catch (err) {
246
- if (abortSignal?.aborted)
247
- return note(stopped(gateMsWaited, started));
248
- console.warn("[computer] bash failed", {
196
+ /**
197
+ * The one check kept in front of upstream's tool, and the reason is not
198
+ * reporting.
199
+ *
200
+ * Computer's executor launches the command and only then reads the
201
+ * signal, so a call already given up on would start `rm -rf build` in
202
+ * the container and kill it a moment later. Nothing downstream can
203
+ * undo that, so it is refused here instead.
204
+ */
205
+ if (options.abortSignal?.aborted) {
206
+ yield {
249
207
  command,
250
- gateMs: gateMsWaited,
251
- durationMs: Date.now() - startedAtMs - gateMsWaited,
252
- timeoutMs,
253
- err: String(err)
254
- });
255
- // Returned, not thrown: a failed command is usually the model's to
256
- // recover from, and it can only recover from what it is told.
257
- return note(`error running command: ${String(err)}`);
208
+ cwd: input.cwd ?? null,
209
+ backend: BACKEND,
210
+ error: "the command did not run: this call was cancelled before it started."
211
+ };
212
+ return;
258
213
  }
214
+ // One line per command, and the only view of where a task's wall clock
215
+ // goes: everything on the Worker side of a container command is an
216
+ // `await`, so Workers Observability records the invocation at ~0% CPU
217
+ // and a long wall time and cannot say what ran. Split, since an agent
218
+ // blocked waiting for `npm ci` and one running a slow suite are
219
+ // indistinguishable from outside and want opposite fixes.
220
+ let last;
221
+ const upstream = execToolFor(options.abortSignal);
222
+ for await (const part of parts(upstream.execute, input, options)) {
223
+ last = part;
224
+ yield gate.warn === undefined ? part : noted(part, gate.warn);
225
+ }
226
+ console.info("[computer] exec", {
227
+ command,
228
+ gateMs,
229
+ durationMs: Date.now() - startedAtMs - gateMs,
230
+ timeoutMs,
231
+ ...summary(last)
232
+ });
259
233
  }
260
234
  })
261
235
  };
262
236
  }
237
+ /**
238
+ * One resource, let go exactly once however a call ends, and never by throwing.
239
+ *
240
+ * Several paths can reach the same client: the launch's own failure, the late
241
+ * handle's cleanup, and the handle wrapper's release when a result settles. A
242
+ * host whose disposal frees something must see exactly one of them. It must
243
+ * also not throw, since most of those callers are a `finally` and a disposal
244
+ * that threw there would replace the result or the error the call was carrying.
245
+ */
246
+ function releaseOnce(what, resource) {
247
+ let released = false;
248
+ return () => {
249
+ if (released)
250
+ return;
251
+ released = true;
252
+ try {
253
+ resource[Symbol.dispose]();
254
+ }
255
+ catch (err) {
256
+ console.warn(`[computer] releasing an exec ${what} failed`, {
257
+ err: String(err)
258
+ });
259
+ }
260
+ };
261
+ }
262
+ /**
263
+ * Wait for a disposable resource without outliving `signal`.
264
+ *
265
+ * {@link withAbort} can abandon the wait but not the resource, and one that
266
+ * arrives after its caller has given up has nobody left to release it. So it is
267
+ * released on arrival instead: disposed by default, or handed to `release` where
268
+ * disposing alone would leave work running.
269
+ */
270
+ function acquire(signal, opening, release = (resource) => resource[Symbol.dispose]()) {
271
+ return withAbort(signal, opening, () => {
272
+ void opening.then(release).catch(() => { });
273
+ });
274
+ }
275
+ /**
276
+ * How a command whose handle arrived too late is let go: killed, then its
277
+ * workspace released. Disposing only detaches this side, so without the kill
278
+ * the command runs on in the container after the call gave up on it.
279
+ */
280
+ async function killLate(handle, releaseClient) {
281
+ const releaseHandle = releaseOnce("handle", handle);
282
+ try {
283
+ await handle.kill("SIGTERM");
284
+ }
285
+ finally {
286
+ releaseHandle();
287
+ releaseClient();
288
+ }
289
+ }
290
+ /**
291
+ * The handle, holding its workspace open until the command settles.
292
+ *
293
+ * Nothing upstream disposes either the handle or the workspace it came from, so
294
+ * both are let go here: when `result()` has answered or the handle is disposed,
295
+ * and never on the kill in between. A `/sandbox` handle and client hold nothing
296
+ * and their disposal is a no-op, but the types a host implements say
297
+ * `Disposable`, so one that does hold something is released exactly once and
298
+ * not before the command's own result has been read.
299
+ *
300
+ * It is also the one place the difference between a command that was stopped and
301
+ * one that merely failed is still visible. Upstream's result has nowhere to put
302
+ * it, so it goes to the log rather than to the model.
303
+ */
304
+ function held(handle, releaseClient, command) {
305
+ /**
306
+ * The handle first and the client second, each exactly once. Upstream
307
+ * disposes neither, and a host's handle may own an attachment or a
308
+ * subscription the client knows nothing about, so both are let go here.
309
+ */
310
+ const releaseHandle = releaseOnce("handle", handle);
311
+ const release = () => {
312
+ releaseHandle();
313
+ releaseClient();
314
+ };
315
+ return {
316
+ async result() {
317
+ try {
318
+ const result = await handle.result();
319
+ if (result.status !== undefined && result.status !== "completed")
320
+ console.info("[computer] exec stopped", {
321
+ command,
322
+ status: result.status,
323
+ exitCode: result.exitCode
324
+ });
325
+ return result;
326
+ }
327
+ finally {
328
+ release();
329
+ }
330
+ },
331
+ /**
332
+ * A kill does not release the client, and that ordering is the point: the
333
+ * caller reads `result()` straight after killing, so releasing here would
334
+ * hand the rest of the command's own result a client somebody had already
335
+ * disposed.
336
+ */
337
+ async kill(signal) {
338
+ await handle.kill(signal);
339
+ },
340
+ [Symbol.dispose]() {
341
+ release();
342
+ }
343
+ };
344
+ }
345
+ /**
346
+ * Whatever the wrapped executor produces, as something to iterate.
347
+ *
348
+ * Upstream's is an async generator that yields a snapshot while the command
349
+ * runs, and the AI SDK's tool type also admits one value awaited once. Both are
350
+ * handled here rather than asserted, so an upstream that stops streaming keeps
351
+ * working and the gate above keeps wrapping it.
352
+ */
353
+ async function* parts(execute, input, options) {
354
+ if (execute === undefined)
355
+ throw new Error("@cloudflare/computer's exec tool has no executor");
356
+ const produced = await execute(input, options);
357
+ if (produced !== null &&
358
+ typeof produced === "object" &&
359
+ Symbol.asyncIterator in produced) {
360
+ yield* produced;
361
+ return;
362
+ }
363
+ yield produced;
364
+ }
365
+ /** What a finished `exec` tells the log, whichever shape it came back in. */
366
+ function summary(part) {
367
+ if (part === null || typeof part !== "object")
368
+ return {};
369
+ if ("error" in part)
370
+ return { error: String(part.error) };
371
+ return "exitCode" in part ? { exitCode: part.exitCode } : {};
372
+ }
373
+ /**
374
+ * The gate's note, prepended to what the command said, which is the contract
375
+ * {@link file://./gate.ts ExecGate} states for `warn`.
376
+ *
377
+ * It goes on `stdout`, or on `error` when the command did not produce either:
378
+ * the note is context for whatever happened, not a substitute for it, so it
379
+ * cannot be the only thing the model gets back.
380
+ */
381
+ function noted(part, warn) {
382
+ if (part === null || typeof part !== "object")
383
+ return part;
384
+ if ("stdout" in part && typeof part.stdout === "string") {
385
+ return { ...part, stdout: `${warn}\n\n${part.stdout}` };
386
+ }
387
+ if ("error" in part && typeof part.error === "string") {
388
+ return { ...part, error: `${warn}\n\n${part.error}` };
389
+ }
390
+ return part;
391
+ }