@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/render.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { truncateOutput } from "../workspace/format.js";
|
|
1
2
|
/**
|
|
2
3
|
* Turning a result into the text a model reads.
|
|
3
4
|
*
|
|
@@ -15,43 +16,9 @@
|
|
|
15
16
|
* A grep match carries the whole line, and a minified bundle is one line of
|
|
16
17
|
* megabytes — a single match in `dist/` would otherwise be the entire result. The
|
|
17
18
|
* head is kept because a match's file and line number are what the model acts on;
|
|
18
|
-
* it reads the region with `
|
|
19
|
+
* it reads the region with `read` next.
|
|
19
20
|
*/
|
|
20
21
|
const MAX_MATCH_LINE_CHARS = 200;
|
|
21
|
-
/**
|
|
22
|
-
* Middle-out truncation, so both the first error and the final summary survive.
|
|
23
|
-
*
|
|
24
|
-
* The guard is not defensive padding. Without it a `max` at or below the marker's
|
|
25
|
-
* own length makes `half` zero or negative, and `slice(-0)` is `slice(0)` — the
|
|
26
|
-
* *whole* string — so the function returns more than it was given: 500 characters
|
|
27
|
-
* in, 543 out at `max: 80`. A silent inversion of the one thing it does,
|
|
28
|
-
* reachable from a public config field.
|
|
29
|
-
*/
|
|
30
|
-
export function truncateOutput(text, max) {
|
|
31
|
-
if (text.length <= max)
|
|
32
|
-
return text;
|
|
33
|
-
const marker = (dropped) => `\n\n… [${dropped} characters omitted from the middle] …\n\n`;
|
|
34
|
-
// No budget for two halves plus the marker: keep the head, which is where the
|
|
35
|
-
// first error is, and say nothing clever.
|
|
36
|
-
const half = Math.floor((max - marker(text.length).length) / 2);
|
|
37
|
-
if (half < 1)
|
|
38
|
-
return text.slice(0, Math.max(0, max));
|
|
39
|
-
return (text.slice(0, half) + marker(text.length - half * 2) + text.slice(-half));
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* A byte count in the form a model can act on.
|
|
43
|
-
*
|
|
44
|
-
* A listing that says a file is 4.2 MB tells the model the read it is about to do
|
|
45
|
-
* comes back with a hole in the middle — the one thing it cannot infer from the
|
|
46
|
-
* truncated result itself.
|
|
47
|
-
*/
|
|
48
|
-
export function humanBytes(bytes) {
|
|
49
|
-
if (bytes < 1024)
|
|
50
|
-
return `${bytes} B`;
|
|
51
|
-
if (bytes < 1024 * 1024)
|
|
52
|
-
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
53
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
54
|
-
}
|
|
55
22
|
/** One matching line, bounded — see {@link MAX_MATCH_LINE_CHARS}. */
|
|
56
23
|
function capLine(text) {
|
|
57
24
|
// A CRLF file leaves the carriage return on every line, since the reader splits
|
|
@@ -135,13 +102,6 @@ export function renderGrepMatches(matches, maxChars) {
|
|
|
135
102
|
// Only a line that actually made it out is worth explaining.
|
|
136
103
|
return { body, shown, capped: hasLongLine.slice(0, shown).includes(true) };
|
|
137
104
|
}
|
|
138
|
-
/** Elapsed time in the shape a sentence wants. */
|
|
139
|
-
export function humanMs(ms) {
|
|
140
|
-
const s = Math.round(ms / 1000);
|
|
141
|
-
return s < 60
|
|
142
|
-
? `${s}s`
|
|
143
|
-
: `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
144
|
-
}
|
|
145
105
|
/**
|
|
146
106
|
* Render a completed command the way a terminal would, minus the noise.
|
|
147
107
|
*
|
|
@@ -177,31 +137,38 @@ export function renderResult(result, maxChars) {
|
|
|
177
137
|
const body = truncateOutput(parts.join("\n"), maxChars);
|
|
178
138
|
const state = result.status && result.status !== "completed" ? ` (${result.status})` : "";
|
|
179
139
|
const verdict = `--- exit ${result.exitCode}${state} ---`;
|
|
180
|
-
|
|
140
|
+
const pending = syncPendingNote(result.sync);
|
|
141
|
+
const transcript = body ? `${body}\n${verdict}` : `(no output)\n${verdict}`;
|
|
142
|
+
return pending ? `${transcript}\n${pending}` : transcript;
|
|
181
143
|
}
|
|
182
144
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
145
|
+
* The command ran; its changes have not landed in the workspace yet.
|
|
146
|
+
*
|
|
147
|
+
* Every command is bracketed by a sync, and the pull afterwards is what moves
|
|
148
|
+
* what it wrote from the container into the Durable Object. That pull can fail
|
|
149
|
+
* while the command itself succeeds — a transport that dropped, a container
|
|
150
|
+
* replaced underneath — and the runtime reports it in `sync` rather than in the
|
|
151
|
+
* exit code, because the command genuinely did run.
|
|
152
|
+
*
|
|
153
|
+
* Worth a sentence to the model because the *next* thing it does is usually read
|
|
154
|
+
* what it just wrote, and the file tools read the workspace rather than the
|
|
155
|
+
* container. Without this the file looks unchanged and the obvious conclusion is
|
|
156
|
+
* that the command did not work, which is the one conclusion that is wrong.
|
|
157
|
+
*
|
|
158
|
+
* Recovery is deliberately not the model's, and is not automatic either: the
|
|
159
|
+
* runtime schedules no retry of its own, so an outstanding pull lands when the
|
|
160
|
+
* host drives one or when a later command's own bracket carries it. What the
|
|
161
|
+
* model is told therefore stops short of "it will be there in a moment" — it is
|
|
162
|
+
* told the read may be stale, that re-running the command is the wrong fix
|
|
163
|
+
* because it would repeat the side effects, and to say so in its result if the
|
|
164
|
+
* workspace still disagrees with what it wrote.
|
|
199
165
|
*/
|
|
200
|
-
export function
|
|
201
|
-
if (status !== "
|
|
166
|
+
export function syncPendingNote(sync) {
|
|
167
|
+
if (sync?.status !== "pending")
|
|
202
168
|
return undefined;
|
|
203
|
-
return (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
169
|
+
return ("(The command finished, but what it wrote has not reached the workspace " +
|
|
170
|
+
"yet, so the file tools may still show the previous contents. Do not re-run " +
|
|
171
|
+
"the command to force it — that repeats whatever it already did. Read again " +
|
|
172
|
+
"in a moment, and if the workspace still disagrees with what the command " +
|
|
173
|
+
"wrote, say so in your result rather than working around it.)");
|
|
207
174
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
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";
|
|
55
|
+
import { withAbort } from "@dynamicagents/core";
|
|
56
|
+
import { renderResult } from "./render.js";
|
|
57
|
+
import { execLostNote } from "./gate.js";
|
|
58
|
+
import { withShellTranscript } from "../workspace/shell.js";
|
|
59
|
+
/** Running one command in the container. */
|
|
60
|
+
/**
|
|
61
|
+
* Wait for a disposable resource without outliving `signal`.
|
|
62
|
+
*
|
|
63
|
+
* `withAbort` can abandon the wait but not the resource, and one that arrives
|
|
64
|
+
* after its caller has given up has nobody left to dispose it. So it is released
|
|
65
|
+
* on arrival instead — disposed by default, or handed to `release` where
|
|
66
|
+
* disposing alone would leave work running.
|
|
67
|
+
*/
|
|
68
|
+
function acquire(signal, opening, release = (resource) => resource[Symbol.dispose]()) {
|
|
69
|
+
return withAbort(signal, opening, () => {
|
|
70
|
+
void opening.then(release).catch(() => { });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* How an exec handle that arrived too late is released. Disposing only detaches
|
|
75
|
+
* this side, so the process is killed first.
|
|
76
|
+
*/
|
|
77
|
+
async function killLate(handle) {
|
|
78
|
+
try {
|
|
79
|
+
await handle.kill("SIGTERM");
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
handle[Symbol.dispose]();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export function execTools(ctx) {
|
|
86
|
+
const { config, cwd, timeoutMs, maxChars, workspace, awaitAdvisories, definedEnv } = ctx;
|
|
87
|
+
return {
|
|
88
|
+
bash: tool({
|
|
89
|
+
/**
|
|
90
|
+
* States what the tool guarantees, rather than what it might withhold.
|
|
91
|
+
*
|
|
92
|
+
* A description that leads with "output is truncated if it is large" and
|
|
93
|
+
* gives no exit code hands the model two independent reasons to re-run a
|
|
94
|
+
* command and capture the output "properly" — at 60 seconds a go on a real
|
|
95
|
+
* gate, whose few hundred characters were never near the ceiling anyway.
|
|
96
|
+
*
|
|
97
|
+
* Two of these sentences are only true when a shell is configured, so the
|
|
98
|
+
* description says whichever is. `withShellTranscript` is a no-op without
|
|
99
|
+
* {@link file://./index.ts ComputerConfig.shell}: no `2>&1`, so the
|
|
100
|
+
* streams arrive separate and {@link renderResult} labels them; no
|
|
101
|
+
* `-o pipefail`, so `/bin/sh` reports a pipeline's *last* stage. Promising
|
|
102
|
+
* a transcript and first-failure semantics there would be the lie
|
|
103
|
+
* `wrapped` explains the cost of.
|
|
104
|
+
*/
|
|
105
|
+
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. " +
|
|
106
|
+
(config.shell
|
|
107
|
+
? "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. " +
|
|
108
|
+
"You do not need to append `echo $?`, add `2>&1`, or redirect to a file to see any of this. " +
|
|
109
|
+
"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`. "
|
|
110
|
+
: "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. " +
|
|
111
|
+
"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. " +
|
|
112
|
+
"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. ") +
|
|
113
|
+
"Prefer targeted commands over ones that print everything.",
|
|
114
|
+
inputSchema: z.object({
|
|
115
|
+
command: z.string().describe("The shell command, e.g. 'npm test'"),
|
|
116
|
+
cwd: z
|
|
117
|
+
.string()
|
|
118
|
+
.optional()
|
|
119
|
+
.describe(`Working directory (default: ${cwd})`)
|
|
120
|
+
}),
|
|
121
|
+
execute: async ({ command, cwd: overrideCwd }, { abortSignal }) => {
|
|
122
|
+
/**
|
|
123
|
+
* One line per command, and it is the only view of where a task's wall
|
|
124
|
+
* clock actually goes.
|
|
125
|
+
*
|
|
126
|
+
* Everything on the Worker side of a container command is an `await`, so
|
|
127
|
+
* Workers Observability records the invocation at ~0% CPU and a long wall
|
|
128
|
+
* time and cannot say what ran. Without this line, diagnosing a 59-minute
|
|
129
|
+
* task means inferring the shape of each command from the *gaps between
|
|
130
|
+
* AI Gateway calls*. The three numbers below — how long the install gate
|
|
131
|
+
* held, how long the command took, what it exited with — answer it
|
|
132
|
+
* directly.
|
|
133
|
+
*
|
|
134
|
+
* Timed around the gate as well as the command, since an agent blocked
|
|
135
|
+
* waiting for `npm ci` and one running a slow test suite are
|
|
136
|
+
* indistinguishable from the outside and want opposite fixes.
|
|
137
|
+
*/
|
|
138
|
+
const startedAtMs = Date.now();
|
|
139
|
+
/**
|
|
140
|
+
* What the model is told when this call stops before the command
|
|
141
|
+
* finishes. Said plainly, because the likeliest next move after a bare
|
|
142
|
+
* error is the same command again — and whether that is right depends on
|
|
143
|
+
* where the call stopped. A command that outran the limit once will again;
|
|
144
|
+
* one that never started because the workspace did not answer may not.
|
|
145
|
+
*
|
|
146
|
+
* `bash` reads its call's signal rather than leaving the wait to Think,
|
|
147
|
+
* and this is why: an abandoned wait can say only that the command may
|
|
148
|
+
* still be running, and the kill below is what stops it.
|
|
149
|
+
*/
|
|
150
|
+
const stopped = (gateMs, started) => {
|
|
151
|
+
const timedOut = abortSignal?.reason?.name ===
|
|
152
|
+
"TimeoutError";
|
|
153
|
+
console.info("[computer] bash stopped", {
|
|
154
|
+
command,
|
|
155
|
+
gateMs,
|
|
156
|
+
durationMs: Date.now() - startedAtMs - gateMs,
|
|
157
|
+
started,
|
|
158
|
+
reason: timedOut ? "time limit" : "cancelled"
|
|
159
|
+
});
|
|
160
|
+
if (!timedOut)
|
|
161
|
+
return "the command was stopped because this call was cancelled. Anything it changed before then is still changed.";
|
|
162
|
+
return started
|
|
163
|
+
? "the command was stopped: it ran past this call's time limit. Anything it changed before then is still changed. Try something narrower."
|
|
164
|
+
: "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.";
|
|
165
|
+
};
|
|
166
|
+
// Only `bash` waits on an install. The file tools read and write
|
|
167
|
+
// source, which is in the workspace and unaffected by an install in
|
|
168
|
+
// flight — blocking them would stop the agent doing the reading it
|
|
169
|
+
// could usefully do while it waits. They do consult `writeGate`, which
|
|
170
|
+
// is a different question: not "is the tree ready" but "does a write
|
|
171
|
+
// survive at all".
|
|
172
|
+
let gate;
|
|
173
|
+
try {
|
|
174
|
+
gate = await awaitAdvisories(command, abortSignal);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
// Only a cancel gets out of the gate; a failed advisory read opens it.
|
|
178
|
+
if (abortSignal?.aborted)
|
|
179
|
+
return stopped(Date.now() - startedAtMs, false);
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
const gateMsWaited = Date.now() - startedAtMs;
|
|
183
|
+
if (gate.block) {
|
|
184
|
+
console.info("[computer] bash blocked by a workspace advisory", {
|
|
185
|
+
command,
|
|
186
|
+
gateMs: gateMsWaited
|
|
187
|
+
});
|
|
188
|
+
return gate.block;
|
|
189
|
+
}
|
|
190
|
+
// Prepended to whatever happens next, success or failure. The warning
|
|
191
|
+
// is context for the output, not a substitute for it — which is why it
|
|
192
|
+
// is carried through the catch as well.
|
|
193
|
+
const note = (body) => gate.warn ? `${gate.warn}\n\n${body}` : body;
|
|
194
|
+
// Set once the exec is sent. See `stopped`.
|
|
195
|
+
let started = false;
|
|
196
|
+
try {
|
|
197
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
198
|
+
try {
|
|
199
|
+
// A workspace that stops answering would hold the call before any of the
|
|
200
|
+
// cancellation below is reached.
|
|
201
|
+
abortSignal?.throwIfAborted();
|
|
202
|
+
const ws = __addDisposableResource(env_1, await acquire(abortSignal, workspace()), false);
|
|
203
|
+
// Read once per command. This is the *host's* thunk, so calling it
|
|
204
|
+
// twice in the construction of one command's options is two chances to
|
|
205
|
+
// disagree — the check and the value would come from different reads.
|
|
206
|
+
const commandEnv = definedEnv();
|
|
207
|
+
const options = {
|
|
208
|
+
cwd: overrideCwd ?? cwd,
|
|
209
|
+
encoding: "utf8",
|
|
210
|
+
timeoutMs,
|
|
211
|
+
...(commandEnv ? { env: commandEnv } : {})
|
|
212
|
+
};
|
|
213
|
+
// Transcript, not two streams: this result goes to a model, which
|
|
214
|
+
// reads it as a terminal session rather than parsing it.
|
|
215
|
+
// Not started at all on a signal that has already fired: a process that
|
|
216
|
+
// is killed on the next line still ran long enough to change something.
|
|
217
|
+
abortSignal?.throwIfAborted();
|
|
218
|
+
// From here a stop cannot promise nothing ran: the exec may reach the
|
|
219
|
+
// container before its handle reaches this side.
|
|
220
|
+
started = true;
|
|
221
|
+
const handle = __addDisposableResource(env_1, await acquire(abortSignal, ws.runtime.exec(withShellTranscript(command, config.shell), options), killLate), false);
|
|
222
|
+
// The runtime takes no signal, so stopping the wait and stopping the
|
|
223
|
+
// process are two acts. Disposing the handle is neither — it releases
|
|
224
|
+
// this side's attachment and leaves the command running.
|
|
225
|
+
const result = await withAbort(abortSignal, handle.result(), () => handle.kill("SIGTERM"));
|
|
226
|
+
console.info("[computer] bash", {
|
|
227
|
+
command,
|
|
228
|
+
exitCode: result.exitCode,
|
|
229
|
+
// Split so a slow command and a slow *wait* never look alike.
|
|
230
|
+
gateMs: gateMsWaited,
|
|
231
|
+
durationMs: Date.now() - startedAtMs - gateMsWaited,
|
|
232
|
+
// The ceiling this was measured against — a duration sitting on it is
|
|
233
|
+
// a timeout wearing a normal-looking number.
|
|
234
|
+
timeoutMs
|
|
235
|
+
});
|
|
236
|
+
return note(renderResult(result, maxChars));
|
|
237
|
+
}
|
|
238
|
+
catch (e_1) {
|
|
239
|
+
env_1.error = e_1;
|
|
240
|
+
env_1.hasError = true;
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
__disposeResources(env_1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (abortSignal?.aborted)
|
|
248
|
+
return note(stopped(gateMsWaited, started));
|
|
249
|
+
const lost = execLostNote(err);
|
|
250
|
+
console.warn("[computer] bash failed", {
|
|
251
|
+
command,
|
|
252
|
+
gateMs: gateMsWaited,
|
|
253
|
+
durationMs: Date.now() - startedAtMs - gateMsWaited,
|
|
254
|
+
timeoutMs,
|
|
255
|
+
// Distinguished in the log for the same reason it is distinguished
|
|
256
|
+
// for the model: a container replacement and a broken command want
|
|
257
|
+
// different people looking at them.
|
|
258
|
+
...(lost ? { lost: true } : {}),
|
|
259
|
+
err: String(err)
|
|
260
|
+
});
|
|
261
|
+
// Returned, not thrown: a failed command is usually the model's to
|
|
262
|
+
// recover from, and it can only recover from what it is told.
|
|
263
|
+
return note(lost ?? `error running command: ${String(err)}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
import type { ComputerContext } from "./context.js";
|
|
3
|
+
/**
|
|
4
|
+
* Editing a file in place — the one file tool this plugin keeps. Think's
|
|
5
|
+
* `read`, `write` and `delete` reach the workspace through the agent's own
|
|
6
|
+
* `this.workspace`, which is `computerWorkspace` (`./proxy.ts`).
|
|
7
|
+
*
|
|
8
|
+
* Not Think's `edit`, because that one is two calls through the workspace — a
|
|
9
|
+
* `readFile`, then a `writeFile` — and a lock inside the workspace cannot span
|
|
10
|
+
* the pair. See `./file-lock.ts`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function fileTools(ctx: ComputerContext): ToolSet;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { guardPath } from "./paths.js";
|
|
4
|
+
import { withFileLock } from "./file-lock.js";
|
|
5
|
+
/**
|
|
6
|
+
* Editing a file in place — the one file tool this plugin keeps. Think's
|
|
7
|
+
* `read`, `write` and `delete` reach the workspace through the agent's own
|
|
8
|
+
* `this.workspace`, which is `computerWorkspace` (`./proxy.ts`).
|
|
9
|
+
*
|
|
10
|
+
* Not Think's `edit`, because that one is two calls through the workspace — a
|
|
11
|
+
* `readFile`, then a `writeFile` — and a lock inside the workspace cannot span
|
|
12
|
+
* the pair. See `./file-lock.ts`.
|
|
13
|
+
*/
|
|
14
|
+
export function fileTools(ctx) {
|
|
15
|
+
const { inWorkspace, refuseWrite, scope } = ctx;
|
|
16
|
+
return {
|
|
17
|
+
edit: tool({
|
|
18
|
+
description: "Replace an exact string in a file. old_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. To create a file, use write.",
|
|
19
|
+
inputSchema: z.object({
|
|
20
|
+
path: z.string().describe("Absolute path"),
|
|
21
|
+
old_string: z
|
|
22
|
+
.string()
|
|
23
|
+
// Empty is refused at the schema rather than in the body, because
|
|
24
|
+
// `split("")` does not count empty-string occurrences: on a file of
|
|
25
|
+
// one character it reports none, on a longer one it reports one per
|
|
26
|
+
// character, and on an *empty* file it reports -1 — which slips past
|
|
27
|
+
// both guards below and writes `new_string` into the file as if an
|
|
28
|
+
// edit had been found. A write tool must not write what nobody asked
|
|
29
|
+
// for.
|
|
30
|
+
.min(1)
|
|
31
|
+
.describe("Exact text to replace, unique within the file"),
|
|
32
|
+
new_string: z.string().describe("Replacement text")
|
|
33
|
+
}),
|
|
34
|
+
execute: async ({ path, old_string, new_string }) => {
|
|
35
|
+
const refusal = guardPath(path, "edit");
|
|
36
|
+
if (refusal)
|
|
37
|
+
return refusal;
|
|
38
|
+
const lost = await refuseWrite();
|
|
39
|
+
if (lost)
|
|
40
|
+
return lost;
|
|
41
|
+
return inWorkspace("editing", path, (fs) => withFileLock(scope(), path, async () => {
|
|
42
|
+
const content = await fs.readFile(path, "utf8");
|
|
43
|
+
const occurrences = content.split(old_string).length - 1;
|
|
44
|
+
// Refusing an ambiguous edit is the whole value of this tool over
|
|
45
|
+
// `write`: a silent first-match replace corrupts the file in a way
|
|
46
|
+
// that surfaces much later, usually as a confusing test failure.
|
|
47
|
+
if (occurrences === 0)
|
|
48
|
+
return `no match for that text in ${path}`;
|
|
49
|
+
if (occurrences > 1)
|
|
50
|
+
return `that text appears ${occurrences} times in ${path} — add surrounding context to make it unique`;
|
|
51
|
+
// A replacer function, not the string itself. `String.replace`
|
|
52
|
+
// interprets `$$`, `$&`, `` $` `` and `$'` in a *string* replacement
|
|
53
|
+
// even when the pattern is a plain string, so the text written is not
|
|
54
|
+
// the text the model sent: `echo $$` becomes `echo $`, and `$'` splices
|
|
55
|
+
// in everything before the match. Those two are not exotic — `$$`
|
|
56
|
+
// escapes a dollar in a Makefile and reads a PID in shell, and `$'…'`
|
|
57
|
+
// is bash ANSI-C quoting. A function's return value is used verbatim.
|
|
58
|
+
await fs.writeFile(path, content.replace(old_string, () => new_string));
|
|
59
|
+
return `edited ${path}`;
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { guardPath, WALK_SKIPS } from "./paths.js";
|
|
4
|
+
import { renderGrepMatches } from "./render.js";
|
|
5
|
+
/**
|
|
6
|
+
* Searching file contents, under Think's name so it replaces Think's `grep` —
|
|
7
|
+
* which lists every file under the root and reads each one through the
|
|
8
|
+
* workspace, one round trip per file. Think's `find` and `list` stay Think's:
|
|
9
|
+
* over `computerWorkspace` they walk with `.git` and `node_modules` pruned.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* How many matches one `grep` returns.
|
|
13
|
+
*
|
|
14
|
+
* Bounded at the source: without a `limit`, `fs.grep` reads *every* file under
|
|
15
|
+
* the path looking for more, so an unbounded search of `/workspace` answers only
|
|
16
|
+
* after streaming the whole tree through the isolate. The limit stops that walk
|
|
17
|
+
* early; `exclude` keeps it out of `.git` and the dependency tree entirely.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_MAX_MATCHES = 200;
|
|
20
|
+
/**
|
|
21
|
+
* The most `context` lines one `grep` shows around each match.
|
|
22
|
+
*
|
|
23
|
+
* Clamped rather than refused, since a refusal costs the model a step to retry.
|
|
24
|
+
* The bound is {@link file://./render.ts packBlocks}: it ships the first match's
|
|
25
|
+
* block whole, so a block has to stay well inside the output budget.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_CONTEXT_LINES = 10;
|
|
28
|
+
export function grepTools(ctx) {
|
|
29
|
+
const { cwd, maxChars, inWorkspace } = ctx;
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* Search, without the container.
|
|
33
|
+
*
|
|
34
|
+
* The alternative is `bash("grep -rn …")`, and the case for a native one is
|
|
35
|
+
* not that shelling out fails — it is where the search runs. `fs.grep` reads
|
|
36
|
+
* the Durable Object's SQLite, so it answers while the container is being
|
|
37
|
+
* replaced or an install is still running, which is exactly the window the
|
|
38
|
+
* install gate leaves an agent with nothing to do. It is also why this tool
|
|
39
|
+
* is not gated: see {@link file://./context.ts awaitAdvisories}.
|
|
40
|
+
*
|
|
41
|
+
* And not Think's `grep`, which globs every file under the root and reads
|
|
42
|
+
* each one through the workspace.
|
|
43
|
+
*
|
|
44
|
+
* Two lesser reasons that still matter. The query arrives as a value rather
|
|
45
|
+
* than through `shellQuote` and a shell that would re-parse it. And the result
|
|
46
|
+
* is bounded by a `limit` at the source instead of being middle-truncated
|
|
47
|
+
* afterwards, which for a match list means losing whole files silently.
|
|
48
|
+
*/
|
|
49
|
+
grep: tool({
|
|
50
|
+
description: "Search file contents across the workspace. Returns matching lines grouped by file, each with its line number. " +
|
|
51
|
+
"The query is matched literally — set `regex` to interpret it as a regular expression. " +
|
|
52
|
+
"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. " +
|
|
53
|
+
"A cut result reports the `offset` that continues it. Use `context` to see the lines around a match. " +
|
|
54
|
+
"Results from `.git` and `node_modules` are left out — search node_modules with bash.",
|
|
55
|
+
inputSchema: z.object({
|
|
56
|
+
query: z.string().describe("Text to find, e.g. 'buildComputerTools'"),
|
|
57
|
+
path: z
|
|
58
|
+
.string()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe(`Absolute file or directory to search (default: ${cwd})`),
|
|
61
|
+
include: z
|
|
62
|
+
.string()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Glob relative to path limiting which files are searched, e.g. '**/*.ts'"),
|
|
65
|
+
regex: z
|
|
66
|
+
.boolean()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe("Interpret query as a regular expression"),
|
|
69
|
+
ignoreCase: z.boolean().optional().describe("Ignore letter case"),
|
|
70
|
+
context: z
|
|
71
|
+
.number()
|
|
72
|
+
.int()
|
|
73
|
+
.min(0)
|
|
74
|
+
.optional()
|
|
75
|
+
.describe(`Lines of surrounding context to include with each match, at most ${MAX_CONTEXT_LINES}`),
|
|
76
|
+
offset: z
|
|
77
|
+
.number()
|
|
78
|
+
.int()
|
|
79
|
+
.min(0)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("Matches to skip — use the offset a cut result reports")
|
|
82
|
+
}),
|
|
83
|
+
execute: async ({ query, path, include, regex, ignoreCase, context, offset }) => {
|
|
84
|
+
const target = path ?? cwd;
|
|
85
|
+
const refusal = guardPath(target, "grep");
|
|
86
|
+
if (refusal)
|
|
87
|
+
return refusal;
|
|
88
|
+
const from = offset ?? 0;
|
|
89
|
+
const around = context === undefined
|
|
90
|
+
? undefined
|
|
91
|
+
: Math.min(context, MAX_CONTEXT_LINES);
|
|
92
|
+
return inWorkspace("searching", target, async (fs) => {
|
|
93
|
+
// Pruned in the store, not filtered here: an excluded directory is
|
|
94
|
+
// never descended into, so `node_modules` costs nothing to skip and
|
|
95
|
+
// `offset` counts only matches the model can actually be shown. A
|
|
96
|
+
// filter on this side could not do either — it pages blind, so a
|
|
97
|
+
// crowded page could hide every real match behind skipped ones.
|
|
98
|
+
const matches = await fs.grep(query, target, {
|
|
99
|
+
include,
|
|
100
|
+
regex,
|
|
101
|
+
ignoreCase,
|
|
102
|
+
context: around,
|
|
103
|
+
limit: DEFAULT_MAX_MATCHES + 1,
|
|
104
|
+
offset: from,
|
|
105
|
+
exclude: WALK_SKIPS.map((segment) => `**/${segment}`)
|
|
106
|
+
});
|
|
107
|
+
if (matches.length === 0)
|
|
108
|
+
return `no matches for ${JSON.stringify(query)} in ${target}${include ? ` (${include})` : ""}${from > 0 ? ` past offset ${from}` : ""}`;
|
|
109
|
+
const { body, shown, capped } = renderGrepMatches(matches.slice(0, DEFAULT_MAX_MATCHES), maxChars);
|
|
110
|
+
return (body +
|
|
111
|
+
// `shown` falls short of what came back either because the page was
|
|
112
|
+
// capped or because the render budget ran out; both continue here.
|
|
113
|
+
(shown < matches.length
|
|
114
|
+
? `\n\n… showed ${shown} matches; there are more. Continue with ` +
|
|
115
|
+
`\`offset: ${from + shown}\`, or narrow with \`include\` — ` +
|
|
116
|
+
`narrowing is cheaper, since an offset still walks everything ` +
|
|
117
|
+
`it skips.`
|
|
118
|
+
: "") +
|
|
119
|
+
(capped
|
|
120
|
+
? `\n(Some lines were shortened. Read one in full with \`read\`, passing its line number as \`offset\`.)`
|
|
121
|
+
: "") +
|
|
122
|
+
(around !== context
|
|
123
|
+
? `\n(\`context\` is at most ${MAX_CONTEXT_LINES}, so ${context} was clamped to ${MAX_CONTEXT_LINES}. Read a wider region with \`read\`.)`
|
|
124
|
+
: ""));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
};
|
|
129
|
+
}
|