@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.
- package/README.md +1 -1
- package/dist/claude-code/cards.d.ts +6 -1
- package/dist/claude-code/cards.js +21 -2
- package/dist/claude-code/model.d.ts +4 -0
- package/dist/claude-code/model.js +74 -7
- package/dist/claude-code/verified.d.ts +1 -1
- package/dist/claude-code/verified.js +1 -1
- package/dist/computer/client.d.ts +9 -1
- package/dist/computer/context.js +1 -1
- package/dist/computer/index.d.ts +10 -6
- package/dist/computer/index.js +34 -17
- package/dist/computer/paths.d.ts +26 -2
- package/dist/computer/paths.js +51 -5
- package/dist/computer/proxy.d.ts +10 -3
- package/dist/computer/proxy.js +77 -87
- package/dist/computer/render.d.ts +6 -35
- package/dist/computer/render.js +5 -43
- package/dist/computer/sandbox.d.ts +4 -3
- package/dist/computer/sandbox.js +9 -2
- package/dist/computer/shell.d.ts +8 -7
- package/dist/computer/shell.js +8 -7
- package/dist/computer/tools-exec.d.ts +39 -0
- package/dist/computer/tools-exec.js +357 -228
- package/dist/computer/tools-grep.js +10 -4
- package/dist/sandbox/header.d.ts +13 -0
- package/dist/sandbox/header.js +19 -4
- package/dist/sandbox/install-plan.d.ts +1 -1
- package/dist/sandbox/install-plan.js +2 -4
- package/dist/sandbox/object.d.ts +1 -0
- package/dist/sandbox/object.js +21 -13
- package/package.json +9 -7
|
@@ -1,168 +1,162 @@
|
|
|
1
|
-
|
|
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 {
|
|
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
|
-
*
|
|
7
|
+
* The one backend this workspace has, and the id the model never has to name.
|
|
61
8
|
*
|
|
62
|
-
* `
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
*
|
|
123
|
-
*
|
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
-
|
|
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
|
|
182
|
-
if (gate.block) {
|
|
183
|
-
console.info("[computer]
|
|
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
|
|
186
|
+
gateMs
|
|
186
187
|
});
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
}
|