@dynamicagents/plugins 0.16.0 → 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/repo-github/checkout.d.ts +16 -0
- package/dist/repo-github/checkout.js +16 -7
- package/dist/repo-github/context.d.ts +11 -0
- package/dist/repo-github/context.js +1 -0
- package/dist/repo-github/skills.d.ts +167 -0
- package/dist/repo-github/skills.js +414 -0
- package/dist/repo-github/tools-clone.js +25 -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
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ plugin to the tools an install names.
|
|
|
68
68
|
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
69
69
|
| [`/browser`](src/browser/) | Read web pages via Browser Rendering Quick Actions | `BROWSER` (paid plan) |
|
|
70
70
|
| [`/claude-code`](src/claude-code/) | Sub-agents whose model is a Claude Code session in a sandbox | one or more `claude setup-token` credentials |
|
|
71
|
-
| [`/computer`](src/computer/) | An agent's tools over a sandbox — `
|
|
71
|
+
| [`/computer`](src/computer/) | An agent's tools over a sandbox — `exec`, `grep`, `edit` — and the workspace Think's file tools use | a `/sandbox` object |
|
|
72
72
|
| [`/repo-github`](src/repo-github/) | Clone, commit, push a branch, open a pull request on GitHub — over any container | `GITHUB_TOKEN` |
|
|
73
73
|
| [`/sandbox`](src/sandbox/) | A container whose disk is the checkout, driven from a Durable Object through `agents`' `ContainerHarness` | `@cloudflare/sandbox`, an image running its daemon |
|
|
74
74
|
| [`/scratch`](src/scratch/) | A throwaway git repository with no remote, for work that needs a container but no checkout | — |
|
|
@@ -48,10 +48,15 @@ export declare function toolResultCard(event: ToolResultEvent): Card;
|
|
|
48
48
|
export declare function thinkingCard(durationMs: number | undefined, messageId: string | undefined): Card;
|
|
49
49
|
/** A session starting — every exec, a resume or a follow-up included. */
|
|
50
50
|
export declare function initCard(event: InitEvent): Card;
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* A session's end, with what its `result` line says about it. A session the API
|
|
53
|
+
* failed reports `success` as its subtype, so the card names the API instead.
|
|
54
|
+
*/
|
|
52
55
|
export declare function resultCard(result: ClaudeCodeResult): Card;
|
|
53
56
|
/** A session its run gave up on before reading its end, and why. */
|
|
54
57
|
export declare function lostCard(why: string): Card;
|
|
55
58
|
/** A session's container lost mid-turn, and the session resuming in a new one. */
|
|
56
59
|
export declare function containerLostCard(reason: string): Card;
|
|
60
|
+
/** A session the API failed, resuming after `waitMs`. */
|
|
61
|
+
export declare function apiErrorCard(error: string, waitMs: number): Card;
|
|
57
62
|
export {};
|
|
@@ -241,11 +241,19 @@ export function initCard(event) {
|
|
|
241
241
|
].filter(Boolean);
|
|
242
242
|
return { text: parts.join(" · "), detail: { title: "Session" } };
|
|
243
243
|
}
|
|
244
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* A session's end, with what its `result` line says about it. A session the API
|
|
246
|
+
* failed reports `success` as its subtype, so the card names the API instead.
|
|
247
|
+
*/
|
|
245
248
|
export function resultCard(result) {
|
|
246
249
|
const failed = result.isError || result.subtype !== "success";
|
|
250
|
+
const why = result.subtype !== "success"
|
|
251
|
+
? result.subtype
|
|
252
|
+
: result.apiErrorStatus === null
|
|
253
|
+
? "API error"
|
|
254
|
+
: `API ${result.apiErrorStatus}`;
|
|
247
255
|
const parts = [
|
|
248
|
-
failed ? `Session failed (${
|
|
256
|
+
failed ? `Session failed (${why})` : "Session finished",
|
|
249
257
|
result.numTurns
|
|
250
258
|
? `${result.numTurns} turn${result.numTurns === 1 ? "" : "s"}`
|
|
251
259
|
: undefined,
|
|
@@ -283,6 +291,17 @@ export function containerLostCard(reason) {
|
|
|
283
291
|
}
|
|
284
292
|
};
|
|
285
293
|
}
|
|
294
|
+
/** A session the API failed, resuming after `waitMs`. */
|
|
295
|
+
export function apiErrorCard(error, waitMs) {
|
|
296
|
+
return {
|
|
297
|
+
text: `Model call failed — the session resumes in ${duration(waitMs)}`,
|
|
298
|
+
detail: {
|
|
299
|
+
title: "Session",
|
|
300
|
+
status: "error",
|
|
301
|
+
sections: [section("Why", error, "text")]
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
286
305
|
function duration(ms) {
|
|
287
306
|
const s = Math.round(ms / 1000);
|
|
288
307
|
if (s < 60)
|
|
@@ -64,6 +64,8 @@ export interface SessionOutcome {
|
|
|
64
64
|
* `container_lost`, and this submits the next attempt to the same harness
|
|
65
65
|
* session, whose CLI conversation the harness kept: the session is told what
|
|
66
66
|
* happened, not briefed again.
|
|
67
|
+
* - **So does an API error the CLI gave up on** — see {@link transientApiError}
|
|
68
|
+
* — after a backoff.
|
|
67
69
|
*/
|
|
68
70
|
/** The sandbox RPCs a run uses. */
|
|
69
71
|
export interface SandboxRuns {
|
|
@@ -156,4 +158,6 @@ export interface ClaudeCodeModelOptions {
|
|
|
156
158
|
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
157
159
|
/** What a session is told when it resumes in a new container. */
|
|
158
160
|
export declare const CONTAINER_LOST_PROMPT: string;
|
|
161
|
+
/** What a session is told when it resumes after an API error. */
|
|
162
|
+
export declare const API_ERROR_PROMPT: string;
|
|
159
163
|
export declare function claudeCodeModel(options: ClaudeCodeModelOptions): LanguageModelV3;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isPlatformTransientError } from "agents";
|
|
2
2
|
import { SANDBOX_CONTRACT } from "../sandbox/constants.js";
|
|
3
3
|
import { withRunHeader } from "../sandbox/header.js";
|
|
4
|
-
import { containerLostCard, lostCard } from "./cards.js";
|
|
4
|
+
import { apiErrorCard, containerLostCard, lostCard } from "./cards.js";
|
|
5
5
|
import { decodeNote, decodeRunEnd, decodeSessionId } from "./harness-wire.js";
|
|
6
6
|
import { lastUserText, reportingModel } from "./language-model.js";
|
|
7
7
|
/**
|
|
@@ -22,6 +22,17 @@ export const CONTAINER_LOST_PROMPT = "The container this session was running in
|
|
|
22
22
|
const LOST_RESUBMITS = 3;
|
|
23
23
|
/** Why an operation settled that a new attempt may get past. */
|
|
24
24
|
const LOST_REASONS = new Set(["container_lost", "container_unavailable"]);
|
|
25
|
+
/** What a session is told when it resumes after an API error. */
|
|
26
|
+
export const API_ERROR_PROMPT = "Your last response was cut off by an API error, and the session has " +
|
|
27
|
+
"resumed. What you completed before it is kept, but a tool call it " +
|
|
28
|
+
"interrupted never ran. Carry on with the task from your last completed step.";
|
|
29
|
+
/**
|
|
30
|
+
* Resumes after an API error. One that recurs on every attempt would otherwise
|
|
31
|
+
* be resumed, a full-context call each time, until the session's limit.
|
|
32
|
+
*/
|
|
33
|
+
const API_ERROR_RESUMES = 5;
|
|
34
|
+
/** The first resume's wait, doubled for each after it. Every wait follows the CLI's own retries. */
|
|
35
|
+
const API_ERROR_BACKOFF_MS = 15_000;
|
|
25
36
|
/** Polls in a row a note may fail to file before it is given up on. */
|
|
26
37
|
const NOTE_TRIES = 3;
|
|
27
38
|
/** The sandbox's own ceiling on one poll's wait. */
|
|
@@ -122,6 +133,12 @@ export function claudeCodeModel(options) {
|
|
|
122
133
|
await file(`${operationId}:lost`, card.text, card.detail);
|
|
123
134
|
throw reason;
|
|
124
135
|
};
|
|
136
|
+
/** Stop the session, unless it is still within its limit. */
|
|
137
|
+
const withinLimit = async (operationId, startedAt) => {
|
|
138
|
+
if (Date.now() - startedAt <= timeoutMs)
|
|
139
|
+
return;
|
|
140
|
+
await stop(operationId, new Error(`the session ran past its limit of ${Math.round(timeoutMs / 60_000)} minutes`));
|
|
141
|
+
};
|
|
125
142
|
/**
|
|
126
143
|
* One step of the run, to its end: the brief's session, or the follow-up.
|
|
127
144
|
* Both count against one limit, from the session's first submission.
|
|
@@ -161,11 +178,21 @@ export function claudeCodeModel(options) {
|
|
|
161
178
|
}
|
|
162
179
|
for (;;) {
|
|
163
180
|
const operationId = `${runId}:${step}:a${state.attempt}`;
|
|
181
|
+
if (state.retryAt !== undefined && state.retryAt > Date.now()) {
|
|
182
|
+
await untilAborted(sleep(state.retryAt - Date.now()), signal).catch(() => undefined);
|
|
183
|
+
if (signal?.aborted)
|
|
184
|
+
await stop(operationId, signal.reason ?? new Error("the session was stopped"));
|
|
185
|
+
}
|
|
186
|
+
// Before the submission, which starts a model call: a turn recovered
|
|
187
|
+
// past its limit would otherwise start one only for the poll to stop it.
|
|
188
|
+
await withinLimit(operationId, state.startedAt);
|
|
164
189
|
// A resubmission that reached the CLI is told what happened, in the
|
|
165
190
|
// conversation it already had; one that never did is the step again.
|
|
166
191
|
const { fork: _fork, ...resumed } = header;
|
|
167
192
|
const text = state.attempt > 0 && state.sessionId
|
|
168
|
-
? withRunHeader(resumed,
|
|
193
|
+
? withRunHeader(resumed, state.resumeWith === "api-error"
|
|
194
|
+
? API_ERROR_PROMPT
|
|
195
|
+
: CONTAINER_LOST_PROMPT)
|
|
169
196
|
: withRunHeader(header, prompt);
|
|
170
197
|
try {
|
|
171
198
|
await rpc((sandbox) => sandbox.submit(runId, text, operationId));
|
|
@@ -182,9 +209,7 @@ export function claudeCodeModel(options) {
|
|
|
182
209
|
while (!result) {
|
|
183
210
|
if (signal?.aborted)
|
|
184
211
|
await stop(operationId, signal.reason ?? new Error("the session was stopped"));
|
|
185
|
-
|
|
186
|
-
await stop(operationId, new Error(`the session ran past its limit of ${Math.round(timeoutMs / 60_000)} minutes`));
|
|
187
|
-
}
|
|
212
|
+
await withinLimit(operationId, state.startedAt);
|
|
188
213
|
let polled;
|
|
189
214
|
try {
|
|
190
215
|
polled = await rpc((sandbox) => sandbox.poll(runId, operationId, POLL_WAIT_MS, state.from));
|
|
@@ -232,6 +257,26 @@ export function claudeCodeModel(options) {
|
|
|
232
257
|
if (!end?.result) {
|
|
233
258
|
throw new Error(`claude-code: the sandbox settled the session with no end it could read: ${String(result.text).slice(0, 200)}`);
|
|
234
259
|
}
|
|
260
|
+
const apiErrors = state.apiErrors ?? 0;
|
|
261
|
+
const waitMs = API_ERROR_BACKOFF_MS * 2 ** apiErrors;
|
|
262
|
+
const retryAt = Date.now() + waitMs;
|
|
263
|
+
if (transientApiError(end.result) &&
|
|
264
|
+
apiErrors < API_ERROR_RESUMES &&
|
|
265
|
+
retryAt - state.startedAt < timeoutMs &&
|
|
266
|
+
!signal?.aborted) {
|
|
267
|
+
console.warn("[claude-code] the session ended on an API error — resuming", { runId, operationId, error: end.result.text, waitMs });
|
|
268
|
+
const card = apiErrorCard(end.result.text, waitMs);
|
|
269
|
+
await file(`${operationId}:api-error`, card.text, card.detail);
|
|
270
|
+
await save({
|
|
271
|
+
...state,
|
|
272
|
+
attempt: state.attempt + 1,
|
|
273
|
+
from: 0,
|
|
274
|
+
apiErrors: apiErrors + 1,
|
|
275
|
+
resumeWith: "api-error",
|
|
276
|
+
retryAt
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
235
280
|
// No code is a process a signal ended.
|
|
236
281
|
return sessionEnd(end, end.exitCode ?? -1, state.sessionId);
|
|
237
282
|
}
|
|
@@ -241,7 +286,8 @@ export function claudeCodeModel(options) {
|
|
|
241
286
|
return sessionEnd(end, end.exitCode ?? 1, state.sessionId);
|
|
242
287
|
if (signal?.aborted)
|
|
243
288
|
await stop(operationId, signal.reason ?? new Error("the session was stopped"));
|
|
244
|
-
|
|
289
|
+
const lost = state.lost ?? 0;
|
|
290
|
+
if (!LOST_REASONS.has(reason) || lost >= LOST_RESUBMITS) {
|
|
245
291
|
const card = lostCard(reason || "the sandbox gave no reason");
|
|
246
292
|
await file(`${operationId}:lost`, card.text, card.detail);
|
|
247
293
|
throw new Error(`claude-code: the sandbox ended the session: ${reason || "no reason given"}`);
|
|
@@ -253,7 +299,13 @@ export function claudeCodeModel(options) {
|
|
|
253
299
|
});
|
|
254
300
|
const card = containerLostCard(reason);
|
|
255
301
|
await file(`${operationId}:container-lost`, card.text, card.detail);
|
|
256
|
-
await save({
|
|
302
|
+
await save({
|
|
303
|
+
...state,
|
|
304
|
+
attempt: state.attempt + 1,
|
|
305
|
+
from: 0,
|
|
306
|
+
lost: lost + 1,
|
|
307
|
+
resumeWith: "container-lost"
|
|
308
|
+
});
|
|
257
309
|
}
|
|
258
310
|
};
|
|
259
311
|
/** File one polled message's note, or read the session id it carries. */
|
|
@@ -335,6 +387,21 @@ export function claudeCodeModel(options) {
|
|
|
335
387
|
};
|
|
336
388
|
return reportingModel(options.model ?? "claude-code", run);
|
|
337
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Whether a step's end is an API error another attempt may get past.
|
|
392
|
+
*
|
|
393
|
+
* The CLI retries a failed call itself, but never one cut off after a block of
|
|
394
|
+
* the response was complete, since a resend could run a tool call twice; its
|
|
395
|
+
* documented recovery is to resume the session. No status is the transport — a
|
|
396
|
+
* dropped or stalled stream, a body that would not decompress — and a 5xx or
|
|
397
|
+
* 529 the API's own. A 4xx is the request's, 429 included: the egress has
|
|
398
|
+
* already moved the credential pool on (`./egress.ts`).
|
|
399
|
+
*/
|
|
400
|
+
function transientApiError(result) {
|
|
401
|
+
if (!result.isError || !result.text.startsWith("API Error"))
|
|
402
|
+
return false;
|
|
403
|
+
return result.apiErrorStatus === null || result.apiErrorStatus >= 500;
|
|
404
|
+
}
|
|
338
405
|
function sessionEnd(end, exitCode, seen) {
|
|
339
406
|
const sessionId = end.sessionId ?? seen;
|
|
340
407
|
return {
|
|
@@ -27,8 +27,11 @@ export interface FoundEntry {
|
|
|
27
27
|
type: "file" | "dir";
|
|
28
28
|
}
|
|
29
29
|
export interface StatResult {
|
|
30
|
+
name: string;
|
|
30
31
|
size: number;
|
|
31
32
|
mtime: number;
|
|
33
|
+
/** The file's Linux mode, as the container reports it. */
|
|
34
|
+
mode: number;
|
|
32
35
|
isFile: boolean;
|
|
33
36
|
isDirectory: boolean;
|
|
34
37
|
isSymbolicLink: boolean;
|
|
@@ -39,6 +42,7 @@ export interface DirEntry {
|
|
|
39
42
|
mtime?: number;
|
|
40
43
|
isFile: boolean;
|
|
41
44
|
isDirectory: boolean;
|
|
45
|
+
isSymbolicLink?: boolean;
|
|
42
46
|
}
|
|
43
47
|
export interface ComputerFs {
|
|
44
48
|
readFile(path: string): Promise<ReadableStream<Uint8Array>>;
|
|
@@ -54,7 +58,11 @@ export interface ComputerFs {
|
|
|
54
58
|
exclude?: string[];
|
|
55
59
|
}): Promise<FoundEntry[]>;
|
|
56
60
|
grep(pattern: string, path: string, options?: GrepOptions): Promise<GrepMatch[]>;
|
|
57
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Bytes as well as text: Think writes evicted media through this, and the
|
|
63
|
+
* `read` tool hands the model back an image it can look at again.
|
|
64
|
+
*/
|
|
65
|
+
writeFile(path: string, content: string | Uint8Array): Promise<void>;
|
|
58
66
|
mkdir(path: string, options?: {
|
|
59
67
|
recursive?: boolean;
|
|
60
68
|
}): Promise<void>;
|
package/dist/computer/context.js
CHANGED
|
@@ -180,7 +180,7 @@ export function computerContext(workspace, config, advisories, lockScope) {
|
|
|
180
180
|
* only recover from what it is told, so this returns the sentence rather than
|
|
181
181
|
* throwing it.
|
|
182
182
|
*
|
|
183
|
-
* `
|
|
183
|
+
* `exec` deliberately does not use it: it logs, and carries an install
|
|
184
184
|
* warning through the failure path — a bespoke catch saying something this
|
|
185
185
|
* cannot.
|
|
186
186
|
*/
|
package/dist/computer/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { SandboxAdvisory } from "../sandbox/advisory.js";
|
|
|
4
4
|
import type { ComputerClient } from "./client.js";
|
|
5
5
|
/**
|
|
6
6
|
* `@dynamicagents/plugins/computer` — an agent's tools over a `/sandbox`
|
|
7
|
-
* container: `
|
|
7
|
+
* container: `exec`, `grep` and `edit`, and `computerWorkspace`, the agent's own
|
|
8
8
|
* `this.workspace`, which Think's `read`, `write`, `delete`, `find` and `list`
|
|
9
9
|
* go through.
|
|
10
10
|
*
|
|
@@ -14,7 +14,7 @@ import type { ComputerClient } from "./client.js";
|
|
|
14
14
|
* backend.
|
|
15
15
|
*
|
|
16
16
|
* The agent's own workspace has to be the sandbox's — `computerWorkspace` — or
|
|
17
|
-
* Think's `read` and `write` would work on a different tree from `
|
|
17
|
+
* Think's `read` and `write` would work on a different tree from `exec`. The
|
|
18
18
|
* plugin refuses to start otherwise; see `./proxy.ts`. `paths.ts` owns what a
|
|
19
19
|
* path may be and what walks drop.
|
|
20
20
|
*/
|
|
@@ -24,7 +24,7 @@ import type { ComputerClient } from "./client.js";
|
|
|
24
24
|
* exports must not move when the files behind it do.
|
|
25
25
|
*/
|
|
26
26
|
export { isGitInternal, WALK_SKIPS } from "./paths.js";
|
|
27
|
-
export { packBlocks, renderGrepMatches
|
|
27
|
+
export { packBlocks, renderGrepMatches } from "./render.js";
|
|
28
28
|
export { execGate, needsDependencies, writeGate, type ExecGate } from "./gate.js";
|
|
29
29
|
export { sandboxClient, computerWorkspace, type SandboxComputerConfig, type SandboxComputerHost } from "./sandbox.js";
|
|
30
30
|
export type { ComputerClient } from "./client.js";
|
|
@@ -60,10 +60,14 @@ export interface ComputerToolsConfig {
|
|
|
60
60
|
* characters far better than they track UTF-8 bytes. So the name moved to meet
|
|
61
61
|
* the implementation rather than the other way round.
|
|
62
62
|
*
|
|
63
|
+
* `exec` is the exception, and spends this as **bytes**: the ceiling there is
|
|
64
|
+
* Computer's own tool's, which takes no character form. The two coincide
|
|
65
|
+
* until the output is not ASCII, where the model is shown a little less than
|
|
66
|
+
* this asked for.
|
|
63
67
|
*/
|
|
64
68
|
maxOutputChars?: number;
|
|
65
69
|
/**
|
|
66
|
-
* How long `
|
|
70
|
+
* How long `exec` waits for a running dependency install before giving the
|
|
67
71
|
* turn back. Defaults to 90 seconds.
|
|
68
72
|
*
|
|
69
73
|
* Bounded rather than open-ended because the wait happens inside a turn, and
|
|
@@ -78,8 +82,8 @@ export interface ComputerToolsConfig {
|
|
|
78
82
|
*
|
|
79
83
|
* ## Put no secret here
|
|
80
84
|
*
|
|
81
|
-
* *Every* command gets this environment, and `
|
|
82
|
-
* the model: `
|
|
85
|
+
* *Every* command gets this environment, and `exec`'s command is written by
|
|
86
|
+
* the model: `exec("printenv")` prints all of it, and so does a
|
|
83
87
|
* `postinstall` script in a repository the agent was asked to clone, which
|
|
84
88
|
* nobody vetted.
|
|
85
89
|
*
|
package/dist/computer/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { fileTools } from "./tools-file.js";
|
|
|
6
6
|
import { grepTools } from "./tools-grep.js";
|
|
7
7
|
/**
|
|
8
8
|
* `@dynamicagents/plugins/computer` — an agent's tools over a `/sandbox`
|
|
9
|
-
* container: `
|
|
9
|
+
* container: `exec`, `grep` and `edit`, and `computerWorkspace`, the agent's own
|
|
10
10
|
* `this.workspace`, which Think's `read`, `write`, `delete`, `find` and `list`
|
|
11
11
|
* go through.
|
|
12
12
|
*
|
|
@@ -16,7 +16,7 @@ import { grepTools } from "./tools-grep.js";
|
|
|
16
16
|
* backend.
|
|
17
17
|
*
|
|
18
18
|
* The agent's own workspace has to be the sandbox's — `computerWorkspace` — or
|
|
19
|
-
* Think's `read` and `write` would work on a different tree from `
|
|
19
|
+
* Think's `read` and `write` would work on a different tree from `exec`. The
|
|
20
20
|
* plugin refuses to start otherwise; see `./proxy.ts`. `paths.ts` owns what a
|
|
21
21
|
* path may be and what walks drop.
|
|
22
22
|
*/
|
|
@@ -26,7 +26,7 @@ import { grepTools } from "./tools-grep.js";
|
|
|
26
26
|
* exports must not move when the files behind it do.
|
|
27
27
|
*/
|
|
28
28
|
export { isGitInternal, WALK_SKIPS } from "./paths.js";
|
|
29
|
-
export { packBlocks, renderGrepMatches
|
|
29
|
+
export { packBlocks, renderGrepMatches } from "./render.js";
|
|
30
30
|
export { execGate, needsDependencies, writeGate } from "./gate.js";
|
|
31
31
|
export { sandboxClient, computerWorkspace } from "./sandbox.js";
|
|
32
32
|
/**
|
|
@@ -42,18 +42,35 @@ export function buildComputerTools(workspace, config, advisories, lockScope) {
|
|
|
42
42
|
...grepTools(ctx)
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
/**
|
|
46
|
+
* What the model is told about the container, once per turn.
|
|
47
|
+
*
|
|
48
|
+
* A function of the config rather than a constant, because two of the
|
|
49
|
+
* guarantees here are the configured shell's: without one, a pipeline reports
|
|
50
|
+
* its last stage and a promise of first-failure reporting would be false.
|
|
51
|
+
*/
|
|
52
|
+
function contextFor(config) {
|
|
53
|
+
return [
|
|
54
|
+
"You have a Linux container with a shell, a package manager and network access:",
|
|
55
|
+
"- `exec` runs any shell command — builds, tests, installs, git.",
|
|
56
|
+
"- `read` / `write` / `edit` / `delete` work on files; `edit` replaces an exact unique string and is the right tool for a small change.",
|
|
57
|
+
"- `grep` searches file contents; `ls` lists a directory; `find` finds files by glob.",
|
|
58
|
+
"Search with `grep` and `find` rather than running them through `exec`: they come back with line numbers and a bounded result instead of a wall of text.",
|
|
59
|
+
"A command's output is cut to its last lines when it is large, so an error printed early in a long run is gone from what you read: run targeted commands, and read specific files rather than printing everything.",
|
|
60
|
+
// Two of these are the configured shell's, not this plugin's: `-o pipefail`
|
|
61
|
+
// is what makes a pipeline report its first failing stage, and without a
|
|
62
|
+
// shell the wrapper that would carry it does not exist. Promising it anyway
|
|
63
|
+
// is the failure `./shell.ts` documents at length — a model that cannot
|
|
64
|
+
// trust a piped exit code re-runs the whole gate to get one.
|
|
65
|
+
config.shell
|
|
66
|
+
? "Do not pipe into `head` or `tail` to shorten output: it is already cut, and piping costs you the end you wanted. A pipeline reports its first failing stage, so a piped command reports its real failure — but `cmd | head` may report 141 when `head` closes the pipe early, which is not a failure of `cmd`."
|
|
67
|
+
: "Do not pipe into `head` or `tail` to shorten output: it is already cut, and piping costs you the end you wanted. A pipeline reports only its **last** stage, so `npm run check | cat` reports the `cat` and says nothing about the check: read the exit code of the command you care about rather than the pipe's.",
|
|
68
|
+
"The checkout is the container's own disk, and it is kept between tasks: a stopped container starts again from it, so it may already contain work from an earlier task — check before assuming it is empty.",
|
|
69
|
+
"`/workspace` holds one directory per checkout, plus directories of your own that are not part of any repository: `attachments/` keeps media taken out of the conversation, `fetched/` keeps fetched pages, `.agents/` keeps projected skills. A message that says a file was preserved at one of those paths means you can read it back. Searches from `/workspace` skip them; repository work happens inside a checkout.",
|
|
70
|
+
"`node_modules` is on that disk beside the checkout and is kept with it. The file tools leave it out — use `exec` there.",
|
|
71
|
+
"`.git` is off limits to these tools, and searches and recursive listings skip it. It is git's internal state — reading it tells you less than the repository tools do, and writing it corrupts the checkout. Repository work goes through the repo tools (`repo_status`, `repo_diff`, `repo_commit`, `repo_push`); if a task needs git state you cannot get that way, say so in your result rather than reaching into `.git` yourself."
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
57
74
|
/**
|
|
58
75
|
* The container plugin over a `/sandbox` object. Its tools reach the sandbox
|
|
59
76
|
* through the agent's own `computerWorkspace` — the one Think's `read` and
|
|
@@ -68,7 +85,7 @@ export function computer(config) {
|
|
|
68
85
|
// so a missing workspace fails the start rather than a turn.
|
|
69
86
|
const route = workspaceRoute(ctx.workspace());
|
|
70
87
|
if (!route)
|
|
71
|
-
throw new PluginSetupError(`plugin "computer" runs \`
|
|
88
|
+
throw new PluginSetupError(`plugin "computer" runs \`exec\` in a container, but ${ctx.agentName}'s ` +
|
|
72
89
|
"workspace is not that container's, so Think's `read` and `write` " +
|
|
73
90
|
"would work on a different tree. Set it on the agent class: " +
|
|
74
91
|
"`override workspace = computerWorkspace(config, () => this.pluginContext().runtime())`.");
|
|
@@ -82,6 +99,6 @@ export function computer(config) {
|
|
|
82
99
|
// two places.
|
|
83
100
|
() => route.advisories(route.name()), route.name);
|
|
84
101
|
},
|
|
85
|
-
context: [{ provider: { get: async () =>
|
|
102
|
+
context: [{ provider: { get: async () => contextFor(config) } }]
|
|
86
103
|
});
|
|
87
104
|
}
|
package/dist/computer/paths.d.ts
CHANGED
|
@@ -21,11 +21,35 @@
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function isGitInternal(path: string): boolean;
|
|
23
23
|
/**
|
|
24
|
-
* What every walk steps over, as directory names. Both the workspace's `
|
|
24
|
+
* What every walk steps over, as directory names. Both the workspace's `find`
|
|
25
25
|
* and the `grep` tool hand these on as `**\/<name>` exclusions, which prunes
|
|
26
26
|
* them: no directory named here is descended into, so none costs a read.
|
|
27
27
|
*/
|
|
28
28
|
export declare const WALK_SKIPS: readonly [".git", "node_modules"];
|
|
29
|
+
/**
|
|
30
|
+
* The directories Think writes on its own account, directly under the
|
|
31
|
+
* workspace root: evicted media under `attachments/evicted/`, a fetch tool's
|
|
32
|
+
* spilled responses under `fetched/`, projected skills under `.agents/skills`.
|
|
33
|
+
* Think builds those paths from a root of its own and offers no way to move
|
|
34
|
+
* them, so a walk of the root reaches them.
|
|
35
|
+
*
|
|
36
|
+
* Pruned from a walk of the root and from nowhere else, which is what
|
|
37
|
+
* {@link walkSkips} spells: these are *names at the root*, not names anywhere,
|
|
38
|
+
* so a checkout with its own `attachments/` is still searched. A direct read is
|
|
39
|
+
* not refused either, because an eviction marker hands the model one of these
|
|
40
|
+
* paths and expects it to be readable.
|
|
41
|
+
*/
|
|
42
|
+
export declare const AGENT_OWNED: readonly ["attachments", "fetched", ".agents"];
|
|
43
|
+
/**
|
|
44
|
+
* The exclusions one walk hands on, for a walk rooted at `directory` inside a
|
|
45
|
+
* workspace whose root is `root`.
|
|
46
|
+
*
|
|
47
|
+
* Every pattern is matched against the walked directory's *relative* path, so a
|
|
48
|
+
* root-anchored absolute pattern would match nothing. {@link AGENT_OWNED} is
|
|
49
|
+
* therefore relative to `directory` and only added when the walk starts at the
|
|
50
|
+
* root; {@link WALK_SKIPS} is a name at any depth.
|
|
51
|
+
*/
|
|
52
|
+
export declare function walkSkips(directory: string, root: string): string[];
|
|
29
53
|
/**
|
|
30
54
|
* The path check every file tool and every workspace call makes, before it
|
|
31
55
|
* opens anything.
|
|
@@ -40,7 +64,7 @@ export declare const WALK_SKIPS: readonly [".git", "node_modules"];
|
|
|
40
64
|
* actually happens is a model reaching into `.git/HEAD` to fix a merge.
|
|
41
65
|
*
|
|
42
66
|
* It is not containment, and this is the only place worth saying so. An agent
|
|
43
|
-
* that can write files here almost always holds `
|
|
67
|
+
* that can write files here almost always holds `exec` too, which reads and
|
|
44
68
|
* writes `.git` directly.
|
|
45
69
|
*
|
|
46
70
|
* Symlinks are not resolved, for the same reason: a tracked
|
package/dist/computer/paths.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Everything here is a string comparison: no filesystem, no `await`, which is
|
|
11
11
|
* what lets {@link guardPath} run before a tool opens the workspace at all.
|
|
12
12
|
*/
|
|
13
|
+
import { normalizeAbsolute } from "../sandbox/header.js";
|
|
13
14
|
/** Whether any segment of `path` is exactly `segment`. */
|
|
14
15
|
function hasSegment(path, segment) {
|
|
15
16
|
return path.split("/").includes(segment);
|
|
@@ -27,15 +28,60 @@ export function isGitInternal(path) {
|
|
|
27
28
|
return hasSegment(path, ".git");
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
|
-
* What every walk steps over, as directory names. Both the workspace's `
|
|
31
|
+
* What every walk steps over, as directory names. Both the workspace's `find`
|
|
31
32
|
* and the `grep` tool hand these on as `**\/<name>` exclusions, which prunes
|
|
32
33
|
* them: no directory named here is descended into, so none costs a read.
|
|
33
34
|
*/
|
|
34
35
|
export const WALK_SKIPS = [".git", "node_modules"];
|
|
36
|
+
/**
|
|
37
|
+
* The directories Think writes on its own account, directly under the
|
|
38
|
+
* workspace root: evicted media under `attachments/evicted/`, a fetch tool's
|
|
39
|
+
* spilled responses under `fetched/`, projected skills under `.agents/skills`.
|
|
40
|
+
* Think builds those paths from a root of its own and offers no way to move
|
|
41
|
+
* them, so a walk of the root reaches them.
|
|
42
|
+
*
|
|
43
|
+
* Pruned from a walk of the root and from nowhere else, which is what
|
|
44
|
+
* {@link walkSkips} spells: these are *names at the root*, not names anywhere,
|
|
45
|
+
* so a checkout with its own `attachments/` is still searched. A direct read is
|
|
46
|
+
* not refused either, because an eviction marker hands the model one of these
|
|
47
|
+
* paths and expects it to be readable.
|
|
48
|
+
*/
|
|
49
|
+
export const AGENT_OWNED = ["attachments", "fetched", ".agents"];
|
|
50
|
+
/**
|
|
51
|
+
* The exclusions one walk hands on, for a walk rooted at `directory` inside a
|
|
52
|
+
* workspace whose root is `root`.
|
|
53
|
+
*
|
|
54
|
+
* Every pattern is matched against the walked directory's *relative* path, so a
|
|
55
|
+
* root-anchored absolute pattern would match nothing. {@link AGENT_OWNED} is
|
|
56
|
+
* therefore relative to `directory` and only added when the walk starts at the
|
|
57
|
+
* root; {@link WALK_SKIPS} is a name at any depth.
|
|
58
|
+
*/
|
|
59
|
+
export function walkSkips(directory, root) {
|
|
60
|
+
const anywhere = WALK_SKIPS.map((segment) => `**/${segment}`);
|
|
61
|
+
return atRoot(directory, root)
|
|
62
|
+
? [...anywhere, ...AGENT_OWNED.map((name) => `${name}/**`)]
|
|
63
|
+
: anywhere;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether a walk starts at the workspace root, by what the two paths mean
|
|
67
|
+
* rather than by how they are spelled.
|
|
68
|
+
*
|
|
69
|
+
* The directory is the model's to spell, and `/workspace/`, `/workspace/.` and
|
|
70
|
+
* `/workspace/repo/..` are all spellings it produces. Every one of them is this
|
|
71
|
+
* root to the sandbox, which normalises before it confines, so a comparison
|
|
72
|
+
* here that read them literally would answer no to a walk that then went ahead
|
|
73
|
+
* from the root and came back with the agent's own directories in it. The
|
|
74
|
+
* normaliser is {@link file://../sandbox/header.ts normalizeAbsolute}, shared
|
|
75
|
+
* with that confinement so the two cannot disagree.
|
|
76
|
+
*/
|
|
77
|
+
function atRoot(directory, root) {
|
|
78
|
+
const resolved = normalizeAbsolute(directory);
|
|
79
|
+
return resolved !== undefined && resolved === normalizeAbsolute(root);
|
|
80
|
+
}
|
|
35
81
|
/** The sentence a `node_modules` path gets. */
|
|
36
82
|
function dependencyTreeNote(path, verb) {
|
|
37
83
|
return (`${path} is inside node_modules, which ${verb} leaves out: it is installed ` +
|
|
38
|
-
`output, not source. Use
|
|
84
|
+
`output, not source. Use exec there — \`cat\`, \`ls\`, \`rg\`.`);
|
|
39
85
|
}
|
|
40
86
|
/**
|
|
41
87
|
* The sentence a `.git` path gets.
|
|
@@ -43,9 +89,9 @@ function dependencyTreeNote(path, verb) {
|
|
|
43
89
|
* Names a route deliberately: a refusal with no destination is worse than no
|
|
44
90
|
* refusal, because the model retries and then works around it.
|
|
45
91
|
*
|
|
46
|
-
* What it must never name is `
|
|
92
|
+
* What it must never name is `exec`. That hands back the exact capability
|
|
47
93
|
* being withheld, in the one place the model is already looking for a way around
|
|
48
|
-
* it, with the tool's own authority behind it. `
|
|
94
|
+
* it, with the tool's own authority behind it. `exec` is unguarded because a
|
|
49
95
|
* shell takes an opaque command string and pattern-matching git out of one is
|
|
50
96
|
* neither reliable nor this guard's job — a fact about the implementation, not a
|
|
51
97
|
* route to advertise.
|
|
@@ -72,7 +118,7 @@ function gitInternalNote(path, verb) {
|
|
|
72
118
|
* actually happens is a model reaching into `.git/HEAD` to fix a merge.
|
|
73
119
|
*
|
|
74
120
|
* It is not containment, and this is the only place worth saying so. An agent
|
|
75
|
-
* that can write files here almost always holds `
|
|
121
|
+
* that can write files here almost always holds `exec` too, which reads and
|
|
76
122
|
* writes `.git` directly.
|
|
77
123
|
*
|
|
78
124
|
* Symlinks are not resolved, for the same reason: a tracked
|
package/dist/computer/proxy.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ThinkWorkspace } from "@cloudflare/think";
|
|
2
2
|
import type { SandboxAdvisory } from "../sandbox/advisory.js";
|
|
3
3
|
import type { ComputerClient } from "./client.js";
|
|
4
4
|
/**
|
|
@@ -6,7 +6,7 @@ import type { ComputerClient } from "./client.js";
|
|
|
6
6
|
*
|
|
7
7
|
* Think's `read`, `write`, `edit` and `delete` run against whatever an agent's
|
|
8
8
|
* workspace is, and the computer tools replace only the ones they name. So an
|
|
9
|
-
* agent with a container and Think's default workspace would run `
|
|
9
|
+
* agent with a container and Think's default workspace would run `exec` in the
|
|
10
10
|
* container and `write` in its own SQLite, and nothing would say so. An agent
|
|
11
11
|
* that installs the tools sets its workspace to `computerWorkspace`
|
|
12
12
|
* (`./sandbox.ts`), and the plugin refuses to start without it — and reaches
|
|
@@ -28,4 +28,11 @@ export interface WorkspaceRoute {
|
|
|
28
28
|
/** The route of a workspace {@link routedWorkspace} built, or `undefined`. */
|
|
29
29
|
export declare function workspaceRoute(workspace: unknown): WorkspaceRoute | undefined;
|
|
30
30
|
/** Think's file tools over `route`, carrying it for the computer tools to find. */
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* `root` is the sandbox's served root and never a working directory. The
|
|
33
|
+
* directories Think writes its own files into are fixed under that root, so
|
|
34
|
+
* anchoring their exclusions anywhere else both hides a checkout's own
|
|
35
|
+
* `attachments/` and leaves Think's in a search of the root: see
|
|
36
|
+
* {@link file://./paths.ts walkSkips}.
|
|
37
|
+
*/
|
|
38
|
+
export declare function routedWorkspace(route: WorkspaceRoute, root: string): ThinkWorkspace;
|