@dynamicagents/plugins 0.11.1 → 0.12.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 +12 -13
- package/dist/arc-agi/analysis.d.ts +16 -30
- package/dist/arc-agi/analysis.js +20 -47
- package/dist/arc-agi/game-tools.d.ts +5 -6
- package/dist/arc-agi/index.d.ts +6 -8
- package/dist/arc-agi/index.js +3 -3
- package/dist/arc-agi/main-agent.d.ts +5 -8
- package/dist/arc-agi/main-agent.js +5 -8
- package/dist/arc-agi/recipe.d.ts +17 -20
- package/dist/arc-agi/recipe.js +17 -20
- package/dist/arc-agi/soul.d.ts +16 -27
- package/dist/arc-agi/soul.js +16 -27
- package/dist/arc-agi/store.d.ts +4 -5
- package/dist/arc-agi/store.js +4 -5
- package/dist/arc-agi/tools.d.ts +1 -1
- package/dist/arc-agi/tools.js +10 -12
- package/dist/arc-agi/types.d.ts +6 -7
- package/dist/claude-code/config.d.ts +88 -11
- package/dist/claude-code/config.js +11 -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 +7 -13
- package/dist/claude-code/egress.js +9 -10
- package/dist/claude-code/events.d.ts +47 -0
- package/dist/claude-code/events.js +96 -10
- package/dist/claude-code/index.d.ts +31 -13
- package/dist/claude-code/index.js +62 -15
- package/dist/claude-code/run.d.ts +135 -41
- package/dist/claude-code/run.js +366 -61
- package/dist/computer/advisory.d.ts +31 -18
- package/dist/computer/advisory.js +30 -5
- package/dist/computer/context.d.ts +23 -0
- package/dist/computer/context.js +225 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +39 -0
- package/dist/computer/gate.d.ts +8 -8
- package/dist/computer/gate.js +7 -7
- package/dist/computer/host/ca-trust.d.ts +88 -0
- package/dist/computer/host/ca-trust.js +303 -0
- package/dist/computer/host/container-deps.d.ts +54 -0
- package/dist/computer/host/container-deps.js +224 -0
- package/dist/computer/host/container-fault.d.ts +38 -0
- package/dist/computer/host/container-fault.js +58 -0
- package/dist/computer/host/git-host.d.ts +62 -0
- package/dist/computer/host/git-host.js +156 -0
- package/dist/computer/host/git-identity.d.ts +57 -0
- package/dist/computer/host/git-identity.js +147 -0
- package/dist/computer/host/install-job.d.ts +121 -0
- package/dist/computer/host/install-job.js +766 -0
- package/dist/computer/host/sync.d.ts +111 -0
- package/dist/computer/host/sync.js +124 -0
- package/dist/computer/host/wake.d.ts +19 -0
- package/dist/computer/host/wake.js +10 -0
- package/dist/computer/host/workspace.d.ts +424 -0
- package/dist/computer/host/workspace.js +1258 -0
- package/dist/computer/index.d.ts +70 -50
- package/dist/computer/index.js +79 -767
- package/dist/computer/install.d.ts +8 -12
- package/dist/computer/install.js +6 -9
- package/dist/computer/paths.d.ts +21 -16
- package/dist/computer/paths.js +40 -38
- package/dist/computer/read.d.ts +18 -22
- package/dist/computer/read.js +24 -27
- package/dist/computer/render.d.ts +28 -0
- package/dist/computer/render.js +34 -1
- package/dist/computer/shell.d.ts +45 -0
- package/dist/computer/shell.js +80 -0
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +269 -0
- package/dist/computer/tools-file.d.ts +4 -0
- package/dist/computer/tools-file.js +124 -0
- package/dist/computer/tools-find.d.ts +3 -0
- package/dist/computer/tools-find.js +216 -0
- package/dist/recall/index.d.ts +9 -3
- package/dist/recall/index.js +22 -12
- package/dist/repo/checkout.d.ts +34 -4
- package/dist/repo/checkout.js +36 -1
- package/dist/repo/context.d.ts +109 -0
- package/dist/repo/context.js +530 -0
- package/dist/repo/index.d.ts +6 -29
- package/dist/repo/index.js +17 -1027
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-forge.d.ts +4 -0
- package/dist/repo/tools-forge.js +179 -0
- package/dist/repo/tools-review.d.ts +3 -0
- package/dist/repo/tools-review.js +373 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +221 -0
- package/dist/triage/index.d.ts +6 -1
- package/dist/triage/index.js +32 -23
- package/dist/workspace/index.d.ts +3 -3
- package/dist/workspace/index.js +3 -3
- package/package.json +21 -15
|
@@ -0,0 +1,269 @@
|
|
|
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 "./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
|
+
sb_exec: 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 a subagent 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
|
+
* `sb_exec` reads its call's signal rather than leaving the wait to core,
|
|
147
|
+
* and this is why: core's abandonment can say only that the command may
|
|
148
|
+
* still be running. Core's `TOOL_CALL_GRACE_MS` is the window this answer
|
|
149
|
+
* has to arrive in, and it covers sending the kill below.
|
|
150
|
+
*/
|
|
151
|
+
const stopped = (gateMs, started) => {
|
|
152
|
+
const timedOut = abortSignal?.reason?.name ===
|
|
153
|
+
"TimeoutError";
|
|
154
|
+
console.info("[computer] sb_exec stopped", {
|
|
155
|
+
command,
|
|
156
|
+
gateMs,
|
|
157
|
+
durationMs: Date.now() - startedAtMs - gateMs,
|
|
158
|
+
started,
|
|
159
|
+
reason: timedOut ? "time limit" : "cancelled"
|
|
160
|
+
});
|
|
161
|
+
if (!timedOut)
|
|
162
|
+
return "the command was stopped because this call was cancelled. Anything it changed before then is still changed.";
|
|
163
|
+
return started
|
|
164
|
+
? "the command was stopped: it ran past this call's time limit. Anything it changed before then is still changed. Try something narrower."
|
|
165
|
+
: "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.";
|
|
166
|
+
};
|
|
167
|
+
// Only `sb_exec` waits on an install. The file tools read and write
|
|
168
|
+
// source, which is in the workspace and unaffected by an install in
|
|
169
|
+
// flight — blocking them would stop the subagent doing the reading it
|
|
170
|
+
// could usefully do while it waits. They do consult `writeGate`, which
|
|
171
|
+
// is a different question: not "is the tree ready" but "does a write
|
|
172
|
+
// survive at all".
|
|
173
|
+
let gate;
|
|
174
|
+
try {
|
|
175
|
+
gate = await awaitAdvisories(command, abortSignal);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
// Only a cancel gets out of the gate; a failed advisory read opens it.
|
|
179
|
+
if (abortSignal?.aborted)
|
|
180
|
+
return stopped(Date.now() - startedAtMs, false);
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
const gateMsWaited = Date.now() - startedAtMs;
|
|
184
|
+
if (gate.block) {
|
|
185
|
+
console.info("[computer] sb_exec blocked by a workspace advisory", {
|
|
186
|
+
command,
|
|
187
|
+
gateMs: gateMsWaited
|
|
188
|
+
});
|
|
189
|
+
return gate.block;
|
|
190
|
+
}
|
|
191
|
+
// Prepended to whatever happens next, success or failure. The warning
|
|
192
|
+
// is context for the output, not a substitute for it — which is why it
|
|
193
|
+
// is carried through the catch as well.
|
|
194
|
+
const note = (body) => gate.warn ? `${gate.warn}\n\n${body}` : body;
|
|
195
|
+
// Set once the exec is sent. See `stopped`.
|
|
196
|
+
let started = false;
|
|
197
|
+
try {
|
|
198
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
199
|
+
try {
|
|
200
|
+
// A workspace that stops answering would hold the call before any of the
|
|
201
|
+
// cancellation below is reached.
|
|
202
|
+
abortSignal?.throwIfAborted();
|
|
203
|
+
const ws = __addDisposableResource(env_1, await acquire(abortSignal, workspace()), false);
|
|
204
|
+
// Read once per command. This is the *host's* thunk, so calling it
|
|
205
|
+
// twice in the construction of one command's options is two chances to
|
|
206
|
+
// disagree — the check and the value would come from different reads.
|
|
207
|
+
const commandEnv = definedEnv();
|
|
208
|
+
const options = {
|
|
209
|
+
cwd: overrideCwd ?? cwd,
|
|
210
|
+
encoding: "utf8",
|
|
211
|
+
timeoutMs,
|
|
212
|
+
...(commandEnv ? { env: commandEnv } : {})
|
|
213
|
+
};
|
|
214
|
+
// Transcript, not two streams: this result goes to a model, which
|
|
215
|
+
// reads it as a terminal session rather than parsing it.
|
|
216
|
+
// Not started at all on a signal that has already fired: a process that
|
|
217
|
+
// is killed on the next line still ran long enough to change something.
|
|
218
|
+
abortSignal?.throwIfAborted();
|
|
219
|
+
// From here a stop cannot promise nothing ran: the exec may reach the
|
|
220
|
+
// container before its handle reaches this side.
|
|
221
|
+
started = true;
|
|
222
|
+
const handle = __addDisposableResource(env_1, await acquire(abortSignal, ws.runtime.exec(withShellTranscript(command, config.shell), options), killLate), false);
|
|
223
|
+
// The runtime takes no signal, so stopping the wait and stopping the
|
|
224
|
+
// process are two acts. Disposing the handle is neither — it releases
|
|
225
|
+
// this side's attachment and leaves the command running.
|
|
226
|
+
const result = await withAbort(abortSignal, handle.result(), () => handle.kill("SIGTERM"));
|
|
227
|
+
console.info("[computer] sb_exec", {
|
|
228
|
+
command,
|
|
229
|
+
exitCode: result.exitCode,
|
|
230
|
+
// Split so a slow command and a slow *wait* never look alike.
|
|
231
|
+
gateMs: gateMsWaited,
|
|
232
|
+
durationMs: Date.now() - startedAtMs - gateMsWaited,
|
|
233
|
+
// The ceiling this was measured against — a duration sitting on it is
|
|
234
|
+
// a timeout wearing a normal-looking number.
|
|
235
|
+
timeoutMs
|
|
236
|
+
});
|
|
237
|
+
return note(renderResult(result, maxChars));
|
|
238
|
+
}
|
|
239
|
+
catch (e_1) {
|
|
240
|
+
env_1.error = e_1;
|
|
241
|
+
env_1.hasError = true;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
__disposeResources(env_1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
if (abortSignal?.aborted)
|
|
249
|
+
return note(stopped(gateMsWaited, started));
|
|
250
|
+
const lost = execLostNote(err);
|
|
251
|
+
console.warn("[computer] sb_exec failed", {
|
|
252
|
+
command,
|
|
253
|
+
gateMs: gateMsWaited,
|
|
254
|
+
durationMs: Date.now() - startedAtMs - gateMsWaited,
|
|
255
|
+
timeoutMs,
|
|
256
|
+
// Distinguished in the log for the same reason it is distinguished
|
|
257
|
+
// for the model: a container replacement and a broken command want
|
|
258
|
+
// different people looking at them.
|
|
259
|
+
...(lost ? { lost: true } : {}),
|
|
260
|
+
err: String(err)
|
|
261
|
+
});
|
|
262
|
+
// Returned, not thrown: a failed command is usually the model's to
|
|
263
|
+
// recover from, and it can only recover from what it is told.
|
|
264
|
+
return note(lost ?? `error running command: ${String(err)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { guardPath } from "./paths.js";
|
|
4
|
+
import { withFileLock } from "./file-lock.js";
|
|
5
|
+
import { readBounded, readWindow } from "./read.js";
|
|
6
|
+
/** One file at a time: reading it, replacing it, editing it in place. */
|
|
7
|
+
export function fileTools(ctx) {
|
|
8
|
+
const { maxChars, inWorkspace, refuseWrite, scope } = ctx;
|
|
9
|
+
return {
|
|
10
|
+
sb_read: tool({
|
|
11
|
+
description: "Read a file from the workspace. Returns the file's text, or a note if it does not exist. " +
|
|
12
|
+
"A large file comes back with its middle removed and a marker giving the `offset` that reaches the missing part. " +
|
|
13
|
+
"Pass `offset` (and optionally `length`) to read a specific byte window instead — the result states the window it returned and how many bytes follow, so you can page through a file. Byte offsets, not lines: to see the lines around a match, use sb_grep with `context`. " +
|
|
14
|
+
"Files under node_modules are not in the workspace — read them with sb_exec.",
|
|
15
|
+
inputSchema: z.object({
|
|
16
|
+
path: z
|
|
17
|
+
.string()
|
|
18
|
+
.describe("Absolute path, e.g. '/workspace/repo/src/a.ts'"),
|
|
19
|
+
offset: z
|
|
20
|
+
.number()
|
|
21
|
+
.int()
|
|
22
|
+
.min(0)
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Byte offset to start reading from"),
|
|
25
|
+
length: z
|
|
26
|
+
.number()
|
|
27
|
+
.int()
|
|
28
|
+
.min(1)
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Maximum bytes to return from `offset`")
|
|
31
|
+
}),
|
|
32
|
+
execute: async ({ path, offset, length }) => {
|
|
33
|
+
const refusal = guardPath(path, "sb_read");
|
|
34
|
+
if (refusal)
|
|
35
|
+
return refusal;
|
|
36
|
+
return inWorkspace("reading", path, async (fs) => {
|
|
37
|
+
// Either knob means the model chose a region; only an unqualified read
|
|
38
|
+
// gets the middle-out guess.
|
|
39
|
+
return offset === undefined && length === undefined
|
|
40
|
+
? await readBounded(fs, path, maxChars)
|
|
41
|
+
: await readWindow(fs, path, offset ?? 0, length ?? maxChars, maxChars);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}),
|
|
45
|
+
sb_write: tool({
|
|
46
|
+
description: "Create or overwrite a file in the workspace. Parent directories are created for you. For a small change to a large file prefer sb_edit, which does not require sending the whole file back.",
|
|
47
|
+
inputSchema: z.object({
|
|
48
|
+
path: z.string().describe("Absolute path"),
|
|
49
|
+
content: z
|
|
50
|
+
.string()
|
|
51
|
+
.describe("Full file content (overwrites any existing file)")
|
|
52
|
+
}),
|
|
53
|
+
execute: async ({ path, content }) => {
|
|
54
|
+
const refusal = guardPath(path, "sb_write");
|
|
55
|
+
if (refusal)
|
|
56
|
+
return refusal;
|
|
57
|
+
// Before the write, not after: the point is to not report a success that
|
|
58
|
+
// did not happen.
|
|
59
|
+
const lost = await refuseWrite();
|
|
60
|
+
if (lost)
|
|
61
|
+
return lost;
|
|
62
|
+
return inWorkspace("writing", path, (fs) => withFileLock(scope(), path, async () => {
|
|
63
|
+
const dir = path.slice(0, path.lastIndexOf("/"));
|
|
64
|
+
if (dir)
|
|
65
|
+
await fs.mkdir(dir, { recursive: true });
|
|
66
|
+
await fs.writeFile(path, content);
|
|
67
|
+
// Characters, not bytes. `String.length` counts UTF-16 code units — the
|
|
68
|
+
// distinction `readBounded` documents at length — so calling them bytes
|
|
69
|
+
// was simply wrong for anything outside ASCII. The exact byte count
|
|
70
|
+
// would cost a `TextEncoder` pass over the whole content for a number
|
|
71
|
+
// nobody does arithmetic with, and "characters" is already this module's
|
|
72
|
+
// word for the same count in `truncateOutput`'s omission marker.
|
|
73
|
+
const written = content.length;
|
|
74
|
+
return `wrote ${path} (${written} character${written === 1 ? "" : "s"})`;
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
}),
|
|
78
|
+
sb_edit: tool({
|
|
79
|
+
description: "Replace an exact string in a file. The string must be non-empty and must appear exactly once — if it appears zero times or more than once the edit is refused, so include enough surrounding context to make it unique.",
|
|
80
|
+
inputSchema: z.object({
|
|
81
|
+
path: z.string().describe("Absolute path"),
|
|
82
|
+
find: z
|
|
83
|
+
.string()
|
|
84
|
+
// Empty is refused at the schema rather than in the body, because
|
|
85
|
+
// `split("")` does not count empty-string occurrences: on a file of
|
|
86
|
+
// one character it reports none, on a longer one it reports one per
|
|
87
|
+
// character, and on an *empty* file it reports -1 — which slips past
|
|
88
|
+
// both guards below and writes `replace` into the file as if an edit
|
|
89
|
+
// had been found. A write tool must not write what nobody asked for.
|
|
90
|
+
.min(1)
|
|
91
|
+
.describe("Exact text to replace, unique within the file"),
|
|
92
|
+
replace: z.string().describe("Replacement text")
|
|
93
|
+
}),
|
|
94
|
+
execute: async ({ path, find, replace }) => {
|
|
95
|
+
const refusal = guardPath(path, "sb_edit");
|
|
96
|
+
if (refusal)
|
|
97
|
+
return refusal;
|
|
98
|
+
const lost = await refuseWrite();
|
|
99
|
+
if (lost)
|
|
100
|
+
return lost;
|
|
101
|
+
return inWorkspace("editing", path, (fs) => withFileLock(scope(), path, async () => {
|
|
102
|
+
const content = await fs.readFile(path, "utf8");
|
|
103
|
+
const occurrences = content.split(find).length - 1;
|
|
104
|
+
// Refusing an ambiguous edit is the whole value of this tool over
|
|
105
|
+
// sb_write: a silent first-match replace corrupts the file in a way
|
|
106
|
+
// that surfaces much later, usually as a confusing test failure.
|
|
107
|
+
if (occurrences === 0)
|
|
108
|
+
return `no match for that text in ${path}`;
|
|
109
|
+
if (occurrences > 1)
|
|
110
|
+
return `that text appears ${occurrences} times in ${path} — add surrounding context to make it unique`;
|
|
111
|
+
// A replacer function, not the string itself. `String.replace`
|
|
112
|
+
// interprets `$$`, `$&`, `` $` `` and `$'` in a *string* replacement
|
|
113
|
+
// even when the pattern is a plain string, so the text written is not
|
|
114
|
+
// the text the model sent: `echo $$` becomes `echo $`, and `$'` splices
|
|
115
|
+
// in everything before the match. Those two are not exotic — `$$`
|
|
116
|
+
// escapes a dollar in a Makefile and reads a PID in shell, and `$'…'`
|
|
117
|
+
// is bash ANSI-C quoting. A function's return value is used verbatim.
|
|
118
|
+
await fs.writeFile(path, content.replace(find, () => replace));
|
|
119
|
+
return `edited ${path}`;
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { guardPath, isSkipped, skipNames, WALK_SKIPS } from "./paths.js";
|
|
4
|
+
import { humanBytes, packBlocks, renderGrepMatches } from "./render.js";
|
|
5
|
+
import { collectVisible, listingNote, pathExists } from "./read.js";
|
|
6
|
+
/** Finding things: what is in a directory, what a file contains, what exists. */
|
|
7
|
+
/**
|
|
8
|
+
* How many directory entries one `sb_ls` returns.
|
|
9
|
+
*
|
|
10
|
+
* A bound on the *listing*, not on the rendered text — `maxOutputChars` still
|
|
11
|
+
* applies on top, and the order matters. Bounding only the text means `readdir`
|
|
12
|
+
* returns every entry and the isolate holds all of them before the ceiling
|
|
13
|
+
* discards the tail; a dependency tree is 22,470 files, and a misbehaving build
|
|
14
|
+
* is exactly when someone lists one. `readdir` takes a `limit`, so the bound is
|
|
15
|
+
* applied where the entries are read.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_MAX_ENTRIES = 1000;
|
|
18
|
+
/**
|
|
19
|
+
* How many matches one `sb_grep` returns.
|
|
20
|
+
*
|
|
21
|
+
* Bounded at the source like {@link DEFAULT_MAX_ENTRIES}, and for a sharper reason
|
|
22
|
+
* than a listing: without a `limit`, `fs.grep` reads *every* file under the path
|
|
23
|
+
* looking for more. `.git` and the dependency tree are both in the workspace, so
|
|
24
|
+
* an unbounded search of `/workspace` streams every loose object and every
|
|
25
|
+
* vendored file through the isolate before answering. The limit is what stops
|
|
26
|
+
* that walk early, and the skip list is what keeps the matches worth reading.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_MAX_MATCHES = 200;
|
|
29
|
+
export function findTools(ctx) {
|
|
30
|
+
const { cwd, maxChars, inWorkspace } = ctx;
|
|
31
|
+
return {
|
|
32
|
+
sb_ls: tool({
|
|
33
|
+
description: "List files in a workspace directory, or find files by name. Without `pattern` it lists one level: directories with a trailing slash, files with their size — check that before reading a large one, since sb_read truncates. " +
|
|
34
|
+
"`pattern` is a glob matched against paths relative to `path`, and searches the whole subtree: `*` stays within one path segment, `**/` crosses directories, `?` matches one character. So `*.ts` finds top-level TypeScript files and `**/*.ts` finds them at any depth. " +
|
|
35
|
+
"A cut listing reports the `offset` that continues it. Subtree listings leave out `.git` and `node_modules`.",
|
|
36
|
+
inputSchema: z.object({
|
|
37
|
+
path: z.string().describe("Absolute directory path"),
|
|
38
|
+
recursive: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("List the whole subtree rather than one level"),
|
|
42
|
+
pattern: z
|
|
43
|
+
.string()
|
|
44
|
+
.optional()
|
|
45
|
+
.describe("Glob relative to path, e.g. '**/*.spec.ts'. Searches the subtree, so `recursive` is not needed with it."),
|
|
46
|
+
offset: z
|
|
47
|
+
.number()
|
|
48
|
+
.int()
|
|
49
|
+
.min(0)
|
|
50
|
+
.optional()
|
|
51
|
+
.describe("Entries to skip — use the offset a cut listing reports")
|
|
52
|
+
}),
|
|
53
|
+
execute: async ({ path, recursive, pattern, offset }) => {
|
|
54
|
+
const refusal = guardPath(path, "sb_ls");
|
|
55
|
+
if (refusal)
|
|
56
|
+
return refusal;
|
|
57
|
+
const from = offset ?? 0;
|
|
58
|
+
return inWorkspace("listing", path, async (fs) => {
|
|
59
|
+
if (recursive || pattern) {
|
|
60
|
+
// `find` rather than `ls`, which took no bound: a prefix scan
|
|
61
|
+
// returned every path in the subtree and the ceiling then threw most
|
|
62
|
+
// of them away — the same read-everything-then-discard shape the
|
|
63
|
+
// `readdir` limit below exists to avoid. Two differences follow from
|
|
64
|
+
// the swap, both improvements: directories appear (rendered like the
|
|
65
|
+
// arm below renders them), and order is the walk's — pre-order, by
|
|
66
|
+
// name — rather than one flat sort.
|
|
67
|
+
//
|
|
68
|
+
// `.git` and `node_modules` are pruned by the store, not filtered
|
|
69
|
+
// here: at a repo root either one outnumbers a page on its own.
|
|
70
|
+
const entries = await fs.find(path, pattern, {
|
|
71
|
+
limit: DEFAULT_MAX_ENTRIES + 1,
|
|
72
|
+
offset: from,
|
|
73
|
+
exclude: WALK_SKIPS.map((segment) => `**/${segment}`)
|
|
74
|
+
});
|
|
75
|
+
if (entries.length === 0)
|
|
76
|
+
return pattern
|
|
77
|
+
? `(nothing under ${path} matches ${pattern})`
|
|
78
|
+
: `(${path} is empty)`;
|
|
79
|
+
const blocks = entries
|
|
80
|
+
.slice(0, DEFAULT_MAX_ENTRIES)
|
|
81
|
+
.map((e) => [e.type === "dir" ? `${e.path}/` : e.path]);
|
|
82
|
+
const { body, shown } = packBlocks(blocks, maxChars);
|
|
83
|
+
const page = {
|
|
84
|
+
rawIndex: entries.map((_, i) => from + i),
|
|
85
|
+
rawEnd: from + entries.length,
|
|
86
|
+
exhausted: entries.length <= DEFAULT_MAX_ENTRIES,
|
|
87
|
+
crowded: false
|
|
88
|
+
};
|
|
89
|
+
return body + listingNote(page, shown, "entries", "`pattern`");
|
|
90
|
+
}
|
|
91
|
+
// One over the ceiling: enough to know the listing was cut without a
|
|
92
|
+
// second round trip to find out, and the extra entry is not shown.
|
|
93
|
+
// Unfiltered on purpose — one line naming a directory that is really
|
|
94
|
+
// there is honest, and costs a line rather than a page. It is the
|
|
95
|
+
// *subtree* walk above that cannot afford to descend into them.
|
|
96
|
+
const entries = await fs.readdir(path, {
|
|
97
|
+
limit: DEFAULT_MAX_ENTRIES + 1,
|
|
98
|
+
offset: from
|
|
99
|
+
});
|
|
100
|
+
if (entries.length === 0)
|
|
101
|
+
return from > 0
|
|
102
|
+
? `(no entries in ${path} past offset ${from})`
|
|
103
|
+
: `(${path} is empty)`;
|
|
104
|
+
const blocks = entries
|
|
105
|
+
.slice(0, DEFAULT_MAX_ENTRIES)
|
|
106
|
+
.map((e) => [
|
|
107
|
+
e.isDirectory ? `${e.name}/` : `${e.name}\t${humanBytes(e.size)}`
|
|
108
|
+
]);
|
|
109
|
+
const { body, shown } = packBlocks(blocks, maxChars);
|
|
110
|
+
const more = entries.length > shown;
|
|
111
|
+
return (body +
|
|
112
|
+
(more
|
|
113
|
+
? `\n… showed ${shown} entries; there are more. Continue with \`offset: ${from + shown}\`, or narrow with \`pattern\`.`
|
|
114
|
+
: ""));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
/**
|
|
119
|
+
* Search, without the container.
|
|
120
|
+
*
|
|
121
|
+
* The tool this replaces is `sb_exec("grep -rn …")`, and the case for a native
|
|
122
|
+
* one is not that shelling out fails — it is where the search runs. `fs.grep`
|
|
123
|
+
* reads the Durable Object's SQLite, so it answers while the container is
|
|
124
|
+
* being replaced or an install is still running, which is exactly the window
|
|
125
|
+
* the install gate leaves a subagent with nothing to do. It is also why this
|
|
126
|
+
* tool is not gated: see {@link file://./context.ts awaitAdvisories}.
|
|
127
|
+
*
|
|
128
|
+
* Two lesser reasons that still matter. The query arrives as a value rather
|
|
129
|
+
* than through `shellQuote` and a shell that would re-parse it. And the result
|
|
130
|
+
* is bounded by a `limit` at the source instead of being middle-truncated
|
|
131
|
+
* afterwards, which for a match list means losing whole files silently.
|
|
132
|
+
*/
|
|
133
|
+
sb_grep: tool({
|
|
134
|
+
description: "Search file contents across the workspace. Returns matching lines grouped by file, each with its line number. " +
|
|
135
|
+
"The query is matched literally — set `regex` to interpret it as a regular expression. " +
|
|
136
|
+
"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. " +
|
|
137
|
+
"A cut result reports the `offset` that continues it. Use `context` to see the lines around a match. " +
|
|
138
|
+
"Results from `.git` and `node_modules` are left out — search node_modules with sb_exec.",
|
|
139
|
+
inputSchema: z.object({
|
|
140
|
+
query: z.string().describe("Text to find, e.g. 'buildComputerTools'"),
|
|
141
|
+
path: z
|
|
142
|
+
.string()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe(`Absolute file or directory to search (default: ${cwd})`),
|
|
145
|
+
include: z
|
|
146
|
+
.string()
|
|
147
|
+
.optional()
|
|
148
|
+
.describe("Glob relative to path limiting which files are searched, e.g. '**/*.ts'"),
|
|
149
|
+
regex: z
|
|
150
|
+
.boolean()
|
|
151
|
+
.optional()
|
|
152
|
+
.describe("Interpret query as a regular expression"),
|
|
153
|
+
ignoreCase: z.boolean().optional().describe("Ignore letter case"),
|
|
154
|
+
context: z
|
|
155
|
+
.number()
|
|
156
|
+
.int()
|
|
157
|
+
.min(0)
|
|
158
|
+
.max(3)
|
|
159
|
+
.optional()
|
|
160
|
+
.describe("Lines of surrounding context to include with each match"),
|
|
161
|
+
offset: z
|
|
162
|
+
.number()
|
|
163
|
+
.int()
|
|
164
|
+
.min(0)
|
|
165
|
+
.optional()
|
|
166
|
+
.describe("Matches to skip — use the offset a cut result reports")
|
|
167
|
+
}),
|
|
168
|
+
execute: async ({ query, path, include, regex, ignoreCase, context, offset }) => {
|
|
169
|
+
const target = path ?? cwd;
|
|
170
|
+
const refusal = guardPath(target, "sb_grep");
|
|
171
|
+
if (refusal)
|
|
172
|
+
return refusal;
|
|
173
|
+
const from = offset ?? 0;
|
|
174
|
+
return inWorkspace("searching", target, async (fs) => {
|
|
175
|
+
// Two rounds, because a `grep` retry re-reads and re-scans every file
|
|
176
|
+
// it already looked at. `.git` rarely floods a page here — its bulk is
|
|
177
|
+
// compressed objects, which a text query does not match — where a
|
|
178
|
+
// `node_modules` left in the workspace is source and matches.
|
|
179
|
+
const skips = WALK_SKIPS;
|
|
180
|
+
const page = await collectVisible((at, limit) => fs.grep(query, target, {
|
|
181
|
+
include,
|
|
182
|
+
regex,
|
|
183
|
+
ignoreCase,
|
|
184
|
+
context,
|
|
185
|
+
limit,
|
|
186
|
+
offset: at
|
|
187
|
+
}), (m) => m.path, (p) => isSkipped(skips, p), DEFAULT_MAX_MATCHES, from, 2);
|
|
188
|
+
if (page.items.length === 0)
|
|
189
|
+
return page.crowded
|
|
190
|
+
? `every match for ${JSON.stringify(query)} from offset ${from} is inside ${skipNames(skips)}, which ${skips.length > 1 ? "are" : "is"} not searched. Add \`include\` (e.g. '**/*.ts') to search the working tree instead, or use sb_exec to search \`node_modules\`.`
|
|
191
|
+
: `no matches for ${JSON.stringify(query)} in ${target}${include ? ` (${include})` : ""}${from > 0 ? ` past offset ${from}` : ""}`;
|
|
192
|
+
const { body, shown, capped } = renderGrepMatches(page.items.slice(0, DEFAULT_MAX_MATCHES), maxChars);
|
|
193
|
+
return (body +
|
|
194
|
+
listingNote(page, shown, "matches", "`include`") +
|
|
195
|
+
(capped
|
|
196
|
+
? `\n(Some lines were shortened. Read one in full with \`sb_read\` and an \`offset\`.)`
|
|
197
|
+
: ""));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}),
|
|
201
|
+
sb_exists: tool({
|
|
202
|
+
description: "Check whether a path exists in the workspace.",
|
|
203
|
+
inputSchema: z.object({ path: z.string().describe("Absolute path") }),
|
|
204
|
+
execute: async ({ path }) => {
|
|
205
|
+
const refusal = guardPath(path, "sb_exists");
|
|
206
|
+
if (refusal)
|
|
207
|
+
return refusal;
|
|
208
|
+
return inWorkspace("checking", path, async (fs) => {
|
|
209
|
+
return (await pathExists(fs, path))
|
|
210
|
+
? `${path} exists`
|
|
211
|
+
: `${path} does not exist`;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
};
|
|
216
|
+
}
|
package/dist/recall/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { SessionMessage } from "agents/experimental/memory/session";
|
|
2
1
|
import type { AgentPlugin } from "@dynamicagents/core";
|
|
2
|
+
import type { SessionMessage } from "@dynamicagents/core/agent";
|
|
3
3
|
/**
|
|
4
4
|
* The subset of the Vectorize binding recall drives — structurally satisfied by
|
|
5
5
|
* `Vectorize`/`VectorizeIndex`, so nothing here names the binding type and a
|
|
@@ -57,12 +57,13 @@ export declare function recallSearch(index: RecallIndex, namespace: string, quer
|
|
|
57
57
|
*
|
|
58
58
|
* // plugins.ts — the bindings, and the values spread
|
|
59
59
|
* recall({ ai: host.env.AI, index: host.env.VECTORIZE,
|
|
60
|
-
* namespace: host.callerKey, aiGatewayId: host.aiGatewayId,
|
|
60
|
+
* namespace: host.callerKey, aiGatewayId: host.aiGatewayId,
|
|
61
|
+
* agentName: host.agentName, ...RECALL })
|
|
61
62
|
* ```
|
|
62
63
|
*
|
|
63
64
|
* Field-by-field re-typing is not just verbose — it silently drops any option
|
|
64
65
|
* added later, so a new default lands everywhere except the deployments that
|
|
65
|
-
* enumerated the
|
|
66
|
+
* enumerated the fields by hand.
|
|
66
67
|
*/
|
|
67
68
|
export interface RecallTuning {
|
|
68
69
|
/**
|
|
@@ -72,6 +73,11 @@ export interface RecallTuning {
|
|
|
72
73
|
* override is dropped in favour of core's default.
|
|
73
74
|
*/
|
|
74
75
|
aiGatewayId?: string;
|
|
76
|
+
/**
|
|
77
|
+
* The `agent` key on embedding calls' AI Gateway log rows. Pass the host's
|
|
78
|
+
* `PluginHost.agentName`, for the reason given on {@link aiGatewayId}.
|
|
79
|
+
*/
|
|
80
|
+
agentName?: string;
|
|
75
81
|
/** Workers-AI embedding model. Must match the index's dimension. */
|
|
76
82
|
embeddingModelId?: string;
|
|
77
83
|
/** Matches returned by a search. Defaults to 5. */
|