@dynamicagents/plugins 0.16.1 → 0.18.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 +3 -3
- package/dist/browser/index.d.ts +13 -16
- package/dist/browser/index.js +88 -26
- 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/sandbox-egress.d.ts +5 -1
- package/dist/repo-github/sandbox-egress.js +9 -3
- 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 +12 -8
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ export class Coder extends StepAgent<Env> {
|
|
|
38
38
|
|
|
39
39
|
override getPlugins() {
|
|
40
40
|
return [
|
|
41
|
-
browser({ binding: this.env.BROWSER }),
|
|
41
|
+
browser({ binding: this.env.BROWSER, loader: this.env.LOADER }),
|
|
42
42
|
computer({ shell: "bash" }),
|
|
43
43
|
repo(repoConfig(this.env))
|
|
44
44
|
];
|
|
@@ -66,9 +66,9 @@ plugin to the tools an install names.
|
|
|
66
66
|
|
|
67
67
|
| Subpath | What it adds | Needs |
|
|
68
68
|
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
69
|
-
| [`/browser`](src/browser/) |
|
|
69
|
+
| [`/browser`](src/browser/) | Drive a persistent browser by writing code, over Browser Run | `BROWSER` (paid plan), `LOADER` |
|
|
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 | — |
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -1,28 +1,25 @@
|
|
|
1
1
|
import type { AgentPlugin } from "@dynamicagents/core";
|
|
2
|
-
import type {
|
|
3
|
-
import type { QuickActionBinding, QuickActionCommonOptions } from "agents/browser";
|
|
2
|
+
import type { BrowserBinding, BrowserRunOptions } from "agents/browser";
|
|
4
3
|
export interface BrowserConfig {
|
|
5
4
|
/**
|
|
6
5
|
* The `BROWSER` binding. Browser Rendering is a **paid-plan** feature, and
|
|
7
6
|
* `wrangler dev` needs `remote: true` to reach it.
|
|
8
7
|
*/
|
|
9
|
-
binding:
|
|
8
|
+
binding: BrowserBinding;
|
|
10
9
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* The `LOADER` binding, declared as `"worker_loaders": [{ "binding":
|
|
11
|
+
* "LOADER" }]`. Each call gets a fresh Dynamic Worker to run the model's
|
|
12
|
+
* code in, with no network access of its own.
|
|
14
13
|
*/
|
|
15
|
-
|
|
14
|
+
loader: WorkerLoader;
|
|
15
|
+
/** How long one call may take, in milliseconds. The SDK defaults to 60,000. */
|
|
16
|
+
timeoutMs?: number;
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* Browser Run launch options: how long an idle browser lives, hostname
|
|
19
|
+
* guardrails, session recording, internal hostnames. Fixed when the browser
|
|
20
|
+
* starts rather than per call, and host-supplied, so the model never sees or
|
|
21
|
+
* chooses any of it.
|
|
19
22
|
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Browser Rendering options merged into every request — cookies, auth headers,
|
|
23
|
-
* viewport. Host-supplied and never exposed to the model, which only ever
|
|
24
|
-
* chooses the page and the action-specific fields.
|
|
25
|
-
*/
|
|
26
|
-
options?: QuickActionCommonOptions;
|
|
23
|
+
provider?: BrowserRunOptions;
|
|
27
24
|
}
|
|
28
25
|
export declare function browser(config: BrowserConfig): AgentPlugin;
|
package/dist/browser/index.js
CHANGED
|
@@ -1,42 +1,104 @@
|
|
|
1
1
|
import { definePlugin } from "@dynamicagents/core";
|
|
2
|
-
import {
|
|
2
|
+
import { Browser, DurableBrowserSessionStore, browserRun } from "agents/browser";
|
|
3
|
+
import { browserTool } from "agents/browser/ai-sdk";
|
|
3
4
|
/**
|
|
4
|
-
* `@dynamicagents/plugins/browser` —
|
|
5
|
-
*
|
|
5
|
+
* `@dynamicagents/plugins/browser` — drive a real browser, via Cloudflare
|
|
6
|
+
* Browser Run.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* One tool, `browser`: the model writes JavaScript that drives a persistent
|
|
9
|
+
* browser over CDP, and that code runs in a Dynamic Worker with no network of
|
|
10
|
+
* its own. The browser outlives each call, so tabs, cookies and logins carry
|
|
11
|
+
* over between them, and the model never starts, closes or resets it.
|
|
11
12
|
*
|
|
12
|
-
* The binding
|
|
13
|
+
* The binding and the loader are closed over at instantiation, so neither is
|
|
14
|
+
* ever model input.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The browser's name in its own store. Two `Browser` objects on one Durable
|
|
18
|
+
* Object need different names, so this says which one is the plugin's rather
|
|
19
|
+
* than taking the SDK's default and colliding with a host that also built one.
|
|
20
|
+
*/
|
|
21
|
+
const BROWSER_NAME = "browser";
|
|
22
|
+
/**
|
|
23
|
+
* What the model is told about the domain. Deliberately *not* the `browser.*`
|
|
24
|
+
* function reference: `browserTool` puts that in the tool's own description and
|
|
25
|
+
* keeps it in step with the SDK, and a copy here would be a second home for
|
|
26
|
+
* someone else's text. What is left is what this plugin knows and the SDK
|
|
27
|
+
* cannot: when reaching for a browser is the right move at all.
|
|
13
28
|
*/
|
|
14
|
-
/** How many characters of a page result the model is allowed to see. */
|
|
15
|
-
const DEFAULT_MAX_CHARS = 20_000;
|
|
16
29
|
const CONTEXT = [
|
|
17
|
-
"You can
|
|
18
|
-
"-
|
|
19
|
-
"-
|
|
20
|
-
"-
|
|
21
|
-
"-
|
|
22
|
-
"
|
|
30
|
+
"You can drive a real browser by writing JavaScript, with the `browser` tool. The tool's own description carries the API; what matters in choosing it:",
|
|
31
|
+
"- The browser is **persistent**. A later call lands on the same page, logged in, with the same cookies. Navigate in one call and read in the next rather than rebuilding state each time.",
|
|
32
|
+
"- Because it is persistent, read the page before acting on it. A call that submits a form or clicks through a checkout may be a repeat of one that already succeeded, and the page in front of you is the evidence of which.",
|
|
33
|
+
"- Prefer one call that does several steps over several calls that each do one: every call pays a round trip to the browser, and the page is still there either way.",
|
|
34
|
+
"- Return the part of the page you need rather than its whole text or markup. The tool's rules say where the ceiling is.",
|
|
35
|
+
"- A plain HTTP fetch is cheaper than a browser. Reach for this when a page needs rendering, a session, or interaction."
|
|
23
36
|
].join("\n");
|
|
24
37
|
export function browser(config) {
|
|
25
|
-
const { binding,
|
|
38
|
+
const { binding, loader, timeoutMs, provider } = config;
|
|
39
|
+
/**
|
|
40
|
+
* One `Browser` per installing object, and the lock is why.
|
|
41
|
+
*
|
|
42
|
+
* `browserTool` opens every run as a locking session, so runs in one scope
|
|
43
|
+
* wait for each other rather than driving one tab at once. But the SDK keeps
|
|
44
|
+
* its lock queues in a `WeakMap` keyed by the **`Browser` object itself**, so
|
|
45
|
+
* two instances over the same stored session hold two unrelated locks and
|
|
46
|
+
* serialise nothing. `getTools()` runs per turn, so a fresh `Browser` each
|
|
47
|
+
* time would leave overlapping turns in one object free to navigate each
|
|
48
|
+
* other's page.
|
|
49
|
+
*
|
|
50
|
+
* Keyed on the installing object's storage, which is the same granularity the
|
|
51
|
+
* SDK's locks have ("per isolate, which is per Durable Object"), and weak so
|
|
52
|
+
* an evicted object's browser handle goes with it. A `WeakMap` rather than one
|
|
53
|
+
* slot because nothing stops a host from hoisting this plugin to module scope
|
|
54
|
+
* and sharing it between objects in one isolate.
|
|
55
|
+
*/
|
|
56
|
+
const browsers = new WeakMap();
|
|
57
|
+
const browserFor = (storage) => {
|
|
58
|
+
const known = browsers.get(storage);
|
|
59
|
+
if (known !== undefined)
|
|
60
|
+
return known;
|
|
61
|
+
const made = new Browser({
|
|
62
|
+
name: BROWSER_NAME,
|
|
63
|
+
provider: browserRun(binding, provider),
|
|
64
|
+
// **The store is what keeps this out of the host's Lifecycle.** A
|
|
65
|
+
// `Browser` with no store defaults to one over the host object's storage
|
|
66
|
+
// and requires `Lifecycle.use()` to install it, which a plugin has no way
|
|
67
|
+
// to ask for: the contract offers tools, actions, context and `requires`
|
|
68
|
+
// and nothing else. Passing the store explicitly is the SDK's own answer,
|
|
69
|
+
// and it is what lets this capability stay whole inside the plugin.
|
|
70
|
+
store: new DurableBrowserSessionStore(storage)
|
|
71
|
+
});
|
|
72
|
+
browsers.set(storage, made);
|
|
73
|
+
return made;
|
|
74
|
+
};
|
|
26
75
|
return definePlugin({
|
|
27
76
|
name: "browser",
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
77
|
+
// **No `scope`.** A scope separates callers sharing one browser, and there
|
|
78
|
+
// is only ever one here: the record lives in the installing object's own
|
|
79
|
+
// storage, a parent object is keyed by its caller, and a sub-agent runs in
|
|
80
|
+
// its own facet with storage of its own. Naming a scope would imply an
|
|
81
|
+
// isolation the store already provides, and the default one still locks.
|
|
82
|
+
//
|
|
83
|
+
// **Nothing truncates here**, and the tool is handed over as the SDK built
|
|
84
|
+
// it. `browserTool` bounds its own output in two places: `execute` cuts the
|
|
85
|
+
// result and the error *structurally*, leaving valid JSON of the original
|
|
86
|
+
// shape with the largest values cut first and markers where content went,
|
|
87
|
+
// and `toModelOutput` then redacts base64 payloads, renders a screenshot as
|
|
88
|
+
// a note, and bounds the logs the same way. Wrapping either to add a flat
|
|
89
|
+
// ceiling of our own would collapse that JSON into a string, which is
|
|
90
|
+
// strictly worse than the bound it would be enforcing.
|
|
91
|
+
tools: (ctx) => ({
|
|
92
|
+
browser: browserTool({
|
|
93
|
+
browser: browserFor(ctx.storage),
|
|
94
|
+
loader,
|
|
95
|
+
...(timeoutMs === undefined ? {} : { timeoutMs })
|
|
96
|
+
})
|
|
35
97
|
}),
|
|
36
98
|
context: [{ provider: { get: async () => CONTEXT } }],
|
|
37
|
-
// A plugin cannot add its own wrangler binding, so declaring
|
|
99
|
+
// A plugin cannot add its own wrangler binding, so declaring them turns a
|
|
38
100
|
// missing one into a startup error naming this plugin, rather than a failed
|
|
39
101
|
// tool call inside a request someone is waiting on.
|
|
40
|
-
requires: { bindings: ["BROWSER"] }
|
|
102
|
+
requires: { bindings: ["BROWSER", "LOADER"] }
|
|
41
103
|
});
|
|
42
104
|
}
|
|
@@ -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
|
}
|