@coreplane/switchboard 1.208.0 → 1.210.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/dist/assets/deploy/cloudflare-resident/worker.ts +37 -9
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +22 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +45 -16
- package/dist/assets/src/execution/binaryRead.ts +58 -0
- package/dist/cli.js +210 -33
- package/package.json +1 -1
|
@@ -95,6 +95,15 @@ import {
|
|
|
95
95
|
type ThreadDepsMechanism,
|
|
96
96
|
} from "../../src/execution/residentDepCache.js";
|
|
97
97
|
import { parseWorktreeCleanliness, worktreeCleanlinessScript } from "../../src/execution/residentCleanliness.js";
|
|
98
|
+
import {
|
|
99
|
+
base64ByteLength,
|
|
100
|
+
MAX_READ_BASE64_CHARS,
|
|
101
|
+
MAX_READ_BYTES,
|
|
102
|
+
readCommandFor,
|
|
103
|
+
readEncodingOf,
|
|
104
|
+
type Base64ReadAnswer,
|
|
105
|
+
type ReadEncoding,
|
|
106
|
+
} from "../../src/execution/binaryRead.js";
|
|
98
107
|
import {
|
|
99
108
|
capBytesFor,
|
|
100
109
|
capWrappedCommand,
|
|
@@ -5170,36 +5179,53 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5170
5179
|
}
|
|
5171
5180
|
|
|
5172
5181
|
/** POST /read: cat the file AS THE THREAD USER — the OS layer (not just
|
|
5173
|
-
* the prefix check) is what confines a symlink pointing outside.
|
|
5174
|
-
|
|
5175
|
-
|
|
5182
|
+
* the prefix check) is what confines a symlink pointing outside. A
|
|
5183
|
+
* `base64` read (src/execution/binaryRead.ts) runs `base64 -w0` instead,
|
|
5184
|
+
* under the binary cap; an overflow is the named refusal, never a slice
|
|
5185
|
+
* of the encoding. */
|
|
5186
|
+
async readThreadFile(
|
|
5187
|
+
threadKey: string,
|
|
5188
|
+
path: string,
|
|
5189
|
+
encoding: ReadEncoding = "utf8",
|
|
5190
|
+
): Promise<{ content: string; truncated: boolean } | Base64ReadAnswer | ThreadErr> {
|
|
5191
|
+
return this.withThreadBusy(threadKey, () => this.readThreadFileImpl(threadKey, path, encoding));
|
|
5176
5192
|
}
|
|
5177
5193
|
|
|
5178
5194
|
private async readThreadFileImpl(
|
|
5179
5195
|
threadKey: string,
|
|
5180
5196
|
path: string,
|
|
5181
|
-
|
|
5197
|
+
encoding: ReadEncoding,
|
|
5198
|
+
): Promise<{ content: string; truncated: boolean } | Base64ReadAnswer | ThreadErr> {
|
|
5182
5199
|
const pre = await this.threadPreflight(threadKey);
|
|
5183
5200
|
if ("error" in pre) return pre;
|
|
5184
5201
|
const resolved = confineThreadPath(pre.binding.worktreePath, path);
|
|
5185
5202
|
if (!resolved)
|
|
5186
5203
|
return { error: `path-escape: ${JSON.stringify(path)} does not stay inside the thread worktree`, status: 400 };
|
|
5204
|
+
const cap = encoding === "base64" ? MAX_READ_BASE64_CHARS : READ_CONTENT_CAP;
|
|
5187
5205
|
let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
|
|
5188
5206
|
try {
|
|
5189
5207
|
r = await this.threadRun(
|
|
5190
5208
|
pre.binding.user,
|
|
5191
5209
|
pre.binding.worktreePath,
|
|
5192
|
-
|
|
5210
|
+
readCommandFor(encoding, resolved),
|
|
5193
5211
|
DEFAULT_EXEC_TIMEOUT_MS,
|
|
5194
|
-
capBytesFor(
|
|
5212
|
+
capBytesFor(cap),
|
|
5195
5213
|
);
|
|
5196
5214
|
} catch (err) {
|
|
5197
5215
|
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
5198
5216
|
throw err;
|
|
5199
5217
|
}
|
|
5200
5218
|
if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
|
|
5201
|
-
const truncated = r.stdout.length >
|
|
5202
|
-
|
|
5219
|
+
const truncated = r.stdout.length > cap;
|
|
5220
|
+
if (encoding === "base64") {
|
|
5221
|
+
// Padding hides up to two bytes inside the cap's char count, so the
|
|
5222
|
+
// decoded size is checked too — the cap is bytes, not characters.
|
|
5223
|
+
const content = r.stdout.trimEnd();
|
|
5224
|
+
return truncated || base64ByteLength(content) > MAX_READ_BYTES
|
|
5225
|
+
? { encoding, tooLarge: true }
|
|
5226
|
+
: { encoding, content };
|
|
5227
|
+
}
|
|
5228
|
+
return { content: truncated ? r.stdout.slice(0, cap) : r.stdout, truncated };
|
|
5203
5229
|
}
|
|
5204
5230
|
|
|
5205
5231
|
/** POST /write: content travels via the SDK file API into the thread's
|
|
@@ -7186,7 +7212,9 @@ async function handleRead(env: Env, body: Record<string, unknown>): Promise<Resp
|
|
|
7186
7212
|
if (ctx instanceof Response) return ctx;
|
|
7187
7213
|
if (typeof body.path !== "string")
|
|
7188
7214
|
return json({ error: "path must be a string relative to the thread worktree" }, 400);
|
|
7189
|
-
const
|
|
7215
|
+
const encoding = readEncodingOf(body);
|
|
7216
|
+
if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
|
|
7217
|
+
const result = await ctx.stub.readThreadFile(ctx.threadKey, body.path, encoding);
|
|
7190
7218
|
if ("error" in result) return threadErrResponse(result);
|
|
7191
7219
|
return json(result);
|
|
7192
7220
|
}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
// first deploy; the SDK is young and its surface may shift.
|
|
19
19
|
import { getSandbox, Sandbox, type ExecOptions, type ExecResult } from "@cloudflare/sandbox";
|
|
20
20
|
import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashTimeout.js";
|
|
21
|
+
import {
|
|
22
|
+
base64ByteLength,
|
|
23
|
+
MAX_READ_BYTES,
|
|
24
|
+
readEncodingOf,
|
|
25
|
+
type Base64ReadAnswer,
|
|
26
|
+
} from "../../src/execution/binaryRead.js";
|
|
21
27
|
import {
|
|
22
28
|
EXEC_KEEPALIVE_INTERVAL_MS,
|
|
23
29
|
SANDBOX_SLEEP_AFTER,
|
|
@@ -265,7 +271,22 @@ export default {
|
|
|
265
271
|
);
|
|
266
272
|
}
|
|
267
273
|
case "/read": {
|
|
268
|
-
|
|
274
|
+
// `encoding: "base64"` is a binary read (src/execution/binaryRead.ts):
|
|
275
|
+
// the SDK encodes the bytes, and a file over the cap is refused by
|
|
276
|
+
// name inside a 200 — the client counts a non-2xx as a sick Worker.
|
|
277
|
+
const encoding = readEncodingOf(body);
|
|
278
|
+
if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
|
|
279
|
+
const path = abs(String(body.path ?? ""));
|
|
280
|
+
if (encoding === "base64") {
|
|
281
|
+
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path, { encoding: "base64" }));
|
|
282
|
+
const content = typeof file === "string" ? file : (file?.content ?? "");
|
|
283
|
+
const answer: Base64ReadAnswer =
|
|
284
|
+
base64ByteLength(content) > MAX_READ_BYTES
|
|
285
|
+
? { encoding: "base64", tooLarge: true }
|
|
286
|
+
: { encoding: "base64", content };
|
|
287
|
+
return json(answer);
|
|
288
|
+
}
|
|
289
|
+
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path));
|
|
269
290
|
return json({ content: typeof file === "string" ? file : (file?.content ?? "") });
|
|
270
291
|
}
|
|
271
292
|
case "/write": {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.210.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.210.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -18999,7 +18999,7 @@
|
|
|
18999
18999
|
},
|
|
19000
19000
|
"packages/switchboard": {
|
|
19001
19001
|
"name": "@coreplane/switchboard",
|
|
19002
|
-
"version": "1.
|
|
19002
|
+
"version": "1.210.0",
|
|
19003
19003
|
"license": "Apache-2.0",
|
|
19004
19004
|
"dependencies": {
|
|
19005
19005
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.210.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// Agent definitions. An agent is a system prompt + toolset + machine class +
|
|
1
|
+
// Agent definitions. An agent is a system prompt + toolset + machine class + wall-clock budget.
|
|
2
2
|
import type { Effort } from "../effort.js";
|
|
3
3
|
import type { CacheTtl } from "../providers/types.js";
|
|
4
4
|
import { BASH_TIMEOUT_MAX_MS } from "../execution/bashTimeout.js";
|
|
5
|
-
import { CONTRACT_HEADING, CONTRACT_SECTION_HEADINGS } from "../core/ship/contract.js";
|
|
5
|
+
import { CONTRACT_HEADING, CONTRACT_SECTION_HEADINGS, PR_TITLE_GUARD } from "../core/ship/contract.js";
|
|
6
6
|
// Which model runs it is resolved separately by the config layers, so any
|
|
7
7
|
// agent can run on any configured provider/model.
|
|
8
8
|
|
|
@@ -42,13 +42,37 @@ export function machineNeedsRepo(machine: MachineClass): boolean {
|
|
|
42
42
|
export const IDENTITIES = ["none", "read", "write"] as const;
|
|
43
43
|
export type Identity = (typeof IDENTITIES)[number];
|
|
44
44
|
|
|
45
|
+
/** The pace that marks a run as looping rather than working: a model turn
|
|
46
|
+
* every ten seconds, sustained for the whole wall clock. A busy run takes
|
|
47
|
+
* 20–40 s a turn (a model think plus a tool call), so a run that averages six
|
|
48
|
+
* a minute from start to end is re-issuing calls, not making progress — and
|
|
49
|
+
* its turn cap ends it before the wall clock would, with a write-up that
|
|
50
|
+
* says so (docs/reference/specs/run-loop.md item 1). */
|
|
51
|
+
export const RUNAWAY_TURNS_PER_MINUTE = 6;
|
|
52
|
+
|
|
53
|
+
/** The turn cap a wall clock implies: `maxMinutes × RUNAWAY_TURNS_PER_MINUTE`.
|
|
54
|
+
* Every preset that runs the loop derives its `maxTurns` from this, so the
|
|
55
|
+
* cap is never a number a good run reaches — the minutes are the budget. */
|
|
56
|
+
export function runawayTurnCap(maxMinutes: number): number {
|
|
57
|
+
return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A loop-running preset's budget as one fact: the wall clock, and the runaway
|
|
61
|
+
* guard derived from it. */
|
|
62
|
+
function loopBudget(maxMinutes: number): Pick<AgentDef, "maxMinutes" | "maxTurns"> {
|
|
63
|
+
return { maxMinutes, maxTurns: runawayTurnCap(maxMinutes) };
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
export interface AgentDef {
|
|
46
67
|
name: string;
|
|
47
68
|
description: string;
|
|
48
69
|
system: string;
|
|
49
70
|
/** key into TOOLSETS: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "none" */
|
|
50
71
|
toolset: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "none";
|
|
51
|
-
/**
|
|
72
|
+
/** The runaway guard, not a budget: `runawayTurnCap(maxMinutes)` for every
|
|
73
|
+
* preset that runs the loop (`loopBudget`). The wall clock below is the
|
|
74
|
+
* budget; a run that reaches this cap first was pacing like a loop, and its
|
|
75
|
+
* write-up says so. The proxy refuses model calls past it too. */
|
|
52
76
|
maxTurns: number;
|
|
53
77
|
maxTokens: number;
|
|
54
78
|
/** hard wall-clock budget for the tool loop; at the deadline the agent is
|
|
@@ -97,7 +121,7 @@ export interface AgentDef {
|
|
|
97
121
|
// and rendered against the head sha at render time, so a repush is a
|
|
98
122
|
// re-render by Switchboard — the agent only resubmits when the CONTENT (line
|
|
99
123
|
// numbers included) changed.
|
|
100
|
-
const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Content contract per field (each renders as its own section): prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. Keep each field concise, not padded.
|
|
124
|
+
const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI. Content contract per field (each renders as its own section): prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. Keep each field concise, not padded.
|
|
101
125
|
EVERY PR includes one that already exists when you push — opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW — carry forward what the existing body says that is still true (a dependency bump's release notes belong in whatWhy), add what you changed, and anchor the Tour at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
|
|
102
126
|
- **title**: the PR title — one line naming the change, specific enough to pick out of a PR list.
|
|
103
127
|
- **TL;DR** (\`tldr\`, rendered first): two sentences for a naive reader with zero context — what this PR does and why it matters.
|
|
@@ -146,6 +170,13 @@ const IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (nativ
|
|
|
146
170
|
const SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
|
|
147
171
|
const RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun — and no Docker.`;
|
|
148
172
|
|
|
173
|
+
// Both coding prompts carry this verbatim (docs/reference/specs/agent-coding.md
|
|
174
|
+
// item 10): the one way a run's screenshot reaches the person. Said once so
|
|
175
|
+
// the sandbox and resident variants cannot drift on it.
|
|
176
|
+
const SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording — it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
|
|
177
|
+
SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request — commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too — unless the request names one destination. Never attach a subset and link the rest.
|
|
178
|
+
Text stays in your message; do not attach what you can say.`;
|
|
179
|
+
|
|
149
180
|
const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
150
181
|
|
|
151
182
|
You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
|
|
@@ -173,6 +204,8 @@ ${UNIT_HANDOFF}
|
|
|
173
204
|
|
|
174
205
|
${PR_DESCRIPTION_TEMPLATE}
|
|
175
206
|
|
|
207
|
+
${SHOW_FILES}
|
|
208
|
+
|
|
176
209
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
177
210
|
|
|
178
211
|
If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
|
|
@@ -211,6 +244,8 @@ ${UNIT_HANDOFF}
|
|
|
211
244
|
|
|
212
245
|
${PR_DESCRIPTION_TEMPLATE}
|
|
213
246
|
|
|
247
|
+
${SHOW_FILES}
|
|
248
|
+
|
|
214
249
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
215
250
|
|
|
216
251
|
Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
|
|
@@ -401,9 +436,8 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
401
436
|
// and mints no credential of its own.
|
|
402
437
|
machine: "none",
|
|
403
438
|
identity: "none",
|
|
404
|
-
maxTurns: 8, // a repo read is 2-3 calls (repos → tree → file); an issue action 1-2; still fast
|
|
405
439
|
maxTokens: 16000,
|
|
406
|
-
|
|
440
|
+
...loopBudget(5),
|
|
407
441
|
},
|
|
408
442
|
coding: {
|
|
409
443
|
name: "coding",
|
|
@@ -411,9 +445,8 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
411
445
|
system: CODING_SYSTEM,
|
|
412
446
|
residentSystem: CODING_SYSTEM_RESIDENT,
|
|
413
447
|
toolset: "full",
|
|
414
|
-
maxTurns: 60, // scoping is capped at ~5 calls by the prompt; this is implementation room
|
|
415
448
|
maxTokens: 64000,
|
|
416
|
-
|
|
449
|
+
...loopBudget(45),
|
|
417
450
|
// Coding steps run long: a single model turn can take 5-6 minutes and
|
|
418
451
|
// installs/tests add more — a 5m cache entry would expire between
|
|
419
452
|
// requests, so the 2× write buys reads for the whole run.
|
|
@@ -431,9 +464,8 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
431
464
|
toolset: "readonly",
|
|
432
465
|
machine: "repo-resident",
|
|
433
466
|
identity: "read", // a read-scoped token and a read-only worktree: it cannot post or push from inside
|
|
434
|
-
maxTurns: 30, // backstop only; wall clock is the real budget (12 bound at ~4 min in practice)
|
|
435
467
|
maxTokens: 64000,
|
|
436
|
-
|
|
468
|
+
...loopBudget(25), // a safety net — typical reviews land in ~5 minutes
|
|
437
469
|
effort: "medium", // fast turns; one big-context pass does the deep work
|
|
438
470
|
},
|
|
439
471
|
ship: {
|
|
@@ -468,9 +500,8 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
468
500
|
toolset: "web",
|
|
469
501
|
machine: "none", // web I/O only; no workspace is provisioned
|
|
470
502
|
identity: "none",
|
|
471
|
-
maxTurns: 12,
|
|
472
503
|
maxTokens: 24000,
|
|
473
|
-
|
|
504
|
+
...loopBudget(8),
|
|
474
505
|
effort: "medium",
|
|
475
506
|
},
|
|
476
507
|
explore: {
|
|
@@ -483,9 +514,8 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
483
514
|
// review depends on: a two-hour job shares no container with anyone.
|
|
484
515
|
machine: "repo-cold",
|
|
485
516
|
identity: "read", // a read-scoped token: it can clone and read, never push — whatever the caller holds
|
|
486
|
-
maxTurns: 150, // a backstop for a two-hour loop of batched checks; the wall clock is the budget
|
|
487
517
|
maxTokens: 64000,
|
|
488
|
-
|
|
518
|
+
...loopBudget(120),
|
|
489
519
|
// A detached job polled across calls makes long steps: a 5m cache entry
|
|
490
520
|
// would expire between them, so the 2× write buys reads for the whole run.
|
|
491
521
|
cacheTtl: "1h",
|
|
@@ -501,9 +531,8 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
501
531
|
// dispatcher, the GitHub reads are REST in the bot process.
|
|
502
532
|
machine: "none",
|
|
503
533
|
identity: "none",
|
|
504
|
-
maxTurns: 40, // a spawn, then a poll per child every few minutes; the wall clock is the budget
|
|
505
534
|
maxTokens: 32000,
|
|
506
|
-
|
|
535
|
+
...loopBudget(120), // long enough to outlast a coding child; every child is capped by what remains of it
|
|
507
536
|
// No built-in effort: the deployment decides, as for coding.
|
|
508
537
|
},
|
|
509
538
|
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// A binary read on the Executor seam (docs/reference/specs/execution.md item 19):
|
|
2
|
+
// the whole file as bytes, for a tool that hands a workspace artifact — a
|
|
3
|
+
// screenshot, a PDF — to somewhere that needs the bytes, not a text view. Both
|
|
4
|
+
// remote executors ask their Worker's `/read` route for `encoding: "base64"`;
|
|
5
|
+
// the Worker answers `{ content, encoding: "base64" }`. This module is the
|
|
6
|
+
// contract both ends share: the caps, the request/answer shape, the resident's
|
|
7
|
+
// read command. It is bundled into the Workers too, so it stays free of Node
|
|
8
|
+
// imports.
|
|
9
|
+
|
|
10
|
+
/** The most bytes one `readBytes` hands over. A binary cannot be truncated
|
|
11
|
+
* the way text output is, so a larger file is refused by name, never trimmed.
|
|
12
|
+
* 10 MiB covers a full-page 2× screenshot or a PDF several times over while
|
|
13
|
+
* keeping one read well inside a Worker isolate's memory. */
|
|
14
|
+
export const MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
/** The base64 length a `MAX_READ_BYTES` file encodes to (four chars per three
|
|
17
|
+
* bytes, padded) — the Worker-side output cap for a base64 read. */
|
|
18
|
+
export const MAX_READ_BASE64_CHARS = Math.ceil(MAX_READ_BYTES / 3) * 4;
|
|
19
|
+
|
|
20
|
+
export type ReadEncoding = "utf8" | "base64";
|
|
21
|
+
|
|
22
|
+
/** The encoding a `/read` body asks for. Absent is the body every pre-binary
|
|
23
|
+
* client sends — a text read, exactly as before; `"base64"` asks for bytes;
|
|
24
|
+
* anything else is refused by name rather than silently read as text. */
|
|
25
|
+
export function readEncodingOf(body: Record<string, unknown>): ReadEncoding | { error: string } {
|
|
26
|
+
if (body.encoding === undefined) return "utf8";
|
|
27
|
+
if (body.encoding === "base64") return "base64";
|
|
28
|
+
return { error: `encoding must be "base64" or absent, got ${JSON.stringify(body.encoding)}` };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The command a resident runs for a read of an already-confined path: `cat`
|
|
32
|
+
* for text; `base64 -w0` for bytes — one unwrapped line, so the Durable
|
|
33
|
+
* Object's character cap slices a plain string and nothing else. */
|
|
34
|
+
export function readCommandFor(encoding: ReadEncoding, resolvedPath: string): string {
|
|
35
|
+
return encoding === "base64" ? `base64 -w0 -- ${resolvedPath}` : `cat -- ${resolvedPath}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** How many bytes a base64 string decodes to, padding discounted. */
|
|
39
|
+
export function base64ByteLength(b64: string): number {
|
|
40
|
+
const trimmed = b64.replace(/\s+/g, "");
|
|
41
|
+
if (trimmed.length === 0) return 0;
|
|
42
|
+
const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0;
|
|
43
|
+
return Math.floor((trimmed.length * 3) / 4) - padding;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A Worker's answer to a base64 read: the bytes, or the named refusal of a
|
|
47
|
+
* file over the cap — HTTP 200 either way. The clients classify a non-2xx or
|
|
48
|
+
* an in-body `error` as a sick Worker (fail-fast counts it); a large file is
|
|
49
|
+
* the model's mistake, not infrastructure, so it travels as a plain field. */
|
|
50
|
+
export type Base64ReadAnswer = { encoding: "base64"; content: string } | { encoding: "base64"; tooLarge: true };
|
|
51
|
+
|
|
52
|
+
/** One message for a file over the cap, for every implementation. `bytes` is
|
|
53
|
+
* the size when the reader could measure it; a resident sees only that its
|
|
54
|
+
* capped base64 stream overflowed. */
|
|
55
|
+
export function tooLargeMessage(path: string, bytes?: number): string {
|
|
56
|
+
const size = bytes === undefined ? "" : ` (${bytes} bytes)`;
|
|
57
|
+
return `${path}${size} is over the ${MAX_READ_BYTES}-byte cap of a binary read`;
|
|
58
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1560,6 +1560,12 @@ var init_contract = __esm({
|
|
|
1560
1560
|
function machineNeedsRepo(machine) {
|
|
1561
1561
|
return machine === "repo-cold" || machine === "repo-resident";
|
|
1562
1562
|
}
|
|
1563
|
+
function runawayTurnCap(maxMinutes) {
|
|
1564
|
+
return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
|
|
1565
|
+
}
|
|
1566
|
+
function loopBudget(maxMinutes) {
|
|
1567
|
+
return { maxMinutes, maxTurns: runawayTurnCap(maxMinutes) };
|
|
1568
|
+
}
|
|
1563
1569
|
function getAgent(name) {
|
|
1564
1570
|
const a = AGENTS[name];
|
|
1565
1571
|
if (!a) {
|
|
@@ -1567,7 +1573,7 @@ function getAgent(name) {
|
|
|
1567
1573
|
}
|
|
1568
1574
|
return a;
|
|
1569
1575
|
}
|
|
1570
|
-
var MACHINE_CLASSES, IDENTITIES, PR_DESCRIPTION_TEMPLATE, NEVER_MERGE, CONTRACT_HEADINGS_LIST, UNIT_CONTRACT, UNIT_HANDOFF, IMAGE_TOOLCHAIN, SANDBOX_TOOLCHAIN, RESIDENT_TOOLCHAIN, CODING_SYSTEM, CODING_SYSTEM_RESIDENT, REVIEW_VERDICT_INSTRUCTION, REVIEW_SPEC_CHECK, REVIEW_UNIT_CONTRACT, REVIEW_WHOLE_CHANGE, REVIEW_SYSTEM, REVIEW_SYSTEM_RESIDENT, RESEARCH_SYSTEM, GENERAL_SYSTEM, EXPLORE_SYSTEM, CONDUCTOR_SYSTEM, AGENTS;
|
|
1576
|
+
var MACHINE_CLASSES, IDENTITIES, RUNAWAY_TURNS_PER_MINUTE, PR_DESCRIPTION_TEMPLATE, NEVER_MERGE, CONTRACT_HEADINGS_LIST, UNIT_CONTRACT, UNIT_HANDOFF, IMAGE_TOOLCHAIN, SANDBOX_TOOLCHAIN, RESIDENT_TOOLCHAIN, SHOW_FILES, CODING_SYSTEM, CODING_SYSTEM_RESIDENT, REVIEW_VERDICT_INSTRUCTION, REVIEW_SPEC_CHECK, REVIEW_UNIT_CONTRACT, REVIEW_WHOLE_CHANGE, REVIEW_SYSTEM, REVIEW_SYSTEM_RESIDENT, RESEARCH_SYSTEM, GENERAL_SYSTEM, EXPLORE_SYSTEM, CONDUCTOR_SYSTEM, AGENTS;
|
|
1571
1577
|
var init_registry = __esm({
|
|
1572
1578
|
"../../src/agents/registry.ts"() {
|
|
1573
1579
|
"use strict";
|
|
@@ -1575,7 +1581,8 @@ var init_registry = __esm({
|
|
|
1575
1581
|
init_contract();
|
|
1576
1582
|
MACHINE_CLASSES = ["none", "blank", "repo-cold", "repo-resident"];
|
|
1577
1583
|
IDENTITIES = ["none", "read", "write"];
|
|
1578
|
-
|
|
1584
|
+
RUNAWAY_TURNS_PER_MINUTE = 6;
|
|
1585
|
+
PR_DESCRIPTION_TEMPLATE = `PR description \u2014 submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate \u2014 \`npm run check:pr-title -- "<title>"\` \u2014 and submit only a title it accepts; the same gate refuses the PR in CI. Content contract per field (each renders as its own section): prose is unwrapped \u2014 no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation \u2014 state exactly what you ran and the real result. Keep each field concise, not padded.
|
|
1579
1586
|
EVERY PR includes one that already exists when you push \u2014 opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW \u2014 carry forward what the existing body says that is still true (a dependency bump's release notes belong in whatWhy), add what you changed, and anchor the Tour at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
|
|
1580
1587
|
- **title**: the PR title \u2014 one line naming the change, specific enough to pick out of a PR list.
|
|
1581
1588
|
- **TL;DR** (\`tldr\`, rendered first): two sentences for a naive reader with zero context \u2014 what this PR does and why it matters.
|
|
@@ -1591,6 +1598,9 @@ EVERY PR includes one that already exists when you push \u2014 opened by a perso
|
|
|
1591
1598
|
IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (native modules build); ffmpeg (frames out of a video \u2014 \`ffmpeg -i in.mp4 -vf fps=1 f_%03d.png\` \u2014 and video out of frames or a recording); and a headless Chromium through Playwright \u2014 \`playwright screenshot <url> out.png\`, \`playwright pdf <url> out.pdf\`, or \`require('playwright')\` for a scripted page and \`recordVideo\``;
|
|
1592
1599
|
SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
|
|
1593
1600
|
RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun \u2014 and no Docker.`;
|
|
1601
|
+
SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording \u2014 it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
|
|
1602
|
+
SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request \u2014 commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too \u2014 unless the request names one destination. Never attach a subset and link the rest.
|
|
1603
|
+
Text stays in your message; do not attach what you can say.`;
|
|
1594
1604
|
CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
1595
1605
|
|
|
1596
1606
|
You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
|
|
@@ -1618,6 +1628,8 @@ ${UNIT_HANDOFF}
|
|
|
1618
1628
|
|
|
1619
1629
|
${PR_DESCRIPTION_TEMPLATE}
|
|
1620
1630
|
|
|
1631
|
+
${SHOW_FILES}
|
|
1632
|
+
|
|
1621
1633
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (\u25CB pending items), then update it whenever an item starts (\u2731) or finishes (\u2713). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item \u2713 only after it has actually happened \u2014 never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
1622
1634
|
|
|
1623
1635
|
If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
|
|
@@ -1650,6 +1662,8 @@ ${UNIT_HANDOFF}
|
|
|
1650
1662
|
|
|
1651
1663
|
${PR_DESCRIPTION_TEMPLATE}
|
|
1652
1664
|
|
|
1665
|
+
${SHOW_FILES}
|
|
1666
|
+
|
|
1653
1667
|
Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (\u25CB pending items), then update it whenever an item starts (\u2731) or finishes (\u2713). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item \u2713 only after it has actually happened \u2014 never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
|
|
1654
1668
|
|
|
1655
1669
|
Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
|
|
@@ -1766,10 +1780,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
|
|
|
1766
1780
|
// and mints no credential of its own.
|
|
1767
1781
|
machine: "none",
|
|
1768
1782
|
identity: "none",
|
|
1769
|
-
maxTurns: 8,
|
|
1770
|
-
// a repo read is 2-3 calls (repos → tree → file); an issue action 1-2; still fast
|
|
1771
1783
|
maxTokens: 16e3,
|
|
1772
|
-
|
|
1784
|
+
...loopBudget(5)
|
|
1773
1785
|
},
|
|
1774
1786
|
coding: {
|
|
1775
1787
|
name: "coding",
|
|
@@ -1777,10 +1789,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
|
|
|
1777
1789
|
system: CODING_SYSTEM,
|
|
1778
1790
|
residentSystem: CODING_SYSTEM_RESIDENT,
|
|
1779
1791
|
toolset: "full",
|
|
1780
|
-
maxTurns: 60,
|
|
1781
|
-
// scoping is capped at ~5 calls by the prompt; this is implementation room
|
|
1782
1792
|
maxTokens: 64e3,
|
|
1783
|
-
|
|
1793
|
+
...loopBudget(45),
|
|
1784
1794
|
// Coding steps run long: a single model turn can take 5-6 minutes and
|
|
1785
1795
|
// installs/tests add more — a 5m cache entry would expire between
|
|
1786
1796
|
// requests, so the 2× write buys reads for the whole run.
|
|
@@ -1800,11 +1810,9 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
|
|
|
1800
1810
|
machine: "repo-resident",
|
|
1801
1811
|
identity: "read",
|
|
1802
1812
|
// a read-scoped token and a read-only worktree: it cannot post or push from inside
|
|
1803
|
-
maxTurns: 30,
|
|
1804
|
-
// backstop only; wall clock is the real budget (12 bound at ~4 min in practice)
|
|
1805
1813
|
maxTokens: 64e3,
|
|
1806
|
-
|
|
1807
|
-
// safety net
|
|
1814
|
+
...loopBudget(25),
|
|
1815
|
+
// a safety net — typical reviews land in ~5 minutes
|
|
1808
1816
|
effort: "medium"
|
|
1809
1817
|
// fast turns; one big-context pass does the deep work
|
|
1810
1818
|
},
|
|
@@ -1838,9 +1846,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
|
|
|
1838
1846
|
machine: "none",
|
|
1839
1847
|
// web I/O only; no workspace is provisioned
|
|
1840
1848
|
identity: "none",
|
|
1841
|
-
maxTurns: 12,
|
|
1842
1849
|
maxTokens: 24e3,
|
|
1843
|
-
|
|
1850
|
+
...loopBudget(8),
|
|
1844
1851
|
effort: "medium"
|
|
1845
1852
|
},
|
|
1846
1853
|
explore: {
|
|
@@ -1853,10 +1860,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
|
|
|
1853
1860
|
machine: "repo-cold",
|
|
1854
1861
|
identity: "read",
|
|
1855
1862
|
// a read-scoped token: it can clone and read, never push — whatever the caller holds
|
|
1856
|
-
maxTurns: 150,
|
|
1857
|
-
// a backstop for a two-hour loop of batched checks; the wall clock is the budget
|
|
1858
1863
|
maxTokens: 64e3,
|
|
1859
|
-
|
|
1864
|
+
...loopBudget(120),
|
|
1860
1865
|
// A detached job polled across calls makes long steps: a 5m cache entry
|
|
1861
1866
|
// would expire between them, so the 2× write buys reads for the whole run.
|
|
1862
1867
|
cacheTtl: "1h"
|
|
@@ -1871,10 +1876,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
|
|
|
1871
1876
|
// dispatcher, the GitHub reads are REST in the bot process.
|
|
1872
1877
|
machine: "none",
|
|
1873
1878
|
identity: "none",
|
|
1874
|
-
maxTurns: 40,
|
|
1875
|
-
// a spawn, then a poll per child every few minutes; the wall clock is the budget
|
|
1876
1879
|
maxTokens: 32e3,
|
|
1877
|
-
|
|
1880
|
+
...loopBudget(120)
|
|
1878
1881
|
// long enough to outlast a coding child; every child is capped by what remains of it
|
|
1879
1882
|
// No built-in effort: the deployment decides, as for coding.
|
|
1880
1883
|
}
|
|
@@ -7853,9 +7856,23 @@ var init_host3 = __esm({
|
|
|
7853
7856
|
}
|
|
7854
7857
|
});
|
|
7855
7858
|
|
|
7859
|
+
// ../../src/execution/binaryRead.ts
|
|
7860
|
+
function tooLargeMessage(path, bytes) {
|
|
7861
|
+
const size = bytes === void 0 ? "" : ` (${bytes} bytes)`;
|
|
7862
|
+
return `${path}${size} is over the ${MAX_READ_BYTES}-byte cap of a binary read`;
|
|
7863
|
+
}
|
|
7864
|
+
var MAX_READ_BYTES, MAX_READ_BASE64_CHARS;
|
|
7865
|
+
var init_binaryRead = __esm({
|
|
7866
|
+
"../../src/execution/binaryRead.ts"() {
|
|
7867
|
+
"use strict";
|
|
7868
|
+
MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
7869
|
+
MAX_READ_BASE64_CHARS = Math.ceil(MAX_READ_BYTES / 3) * 4;
|
|
7870
|
+
}
|
|
7871
|
+
});
|
|
7872
|
+
|
|
7856
7873
|
// ../../src/execution/executor.ts
|
|
7857
7874
|
import { execFile } from "node:child_process";
|
|
7858
|
-
import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
|
|
7875
|
+
import { closeSync, existsSync as existsSync8, fstatSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
|
|
7859
7876
|
import { dirname as dirname6, resolve as resolve6 } from "node:path";
|
|
7860
7877
|
function execDeadline(timeoutMs, signal) {
|
|
7861
7878
|
const deadline = new AbortController();
|
|
@@ -7873,6 +7890,15 @@ function truncate(s) {
|
|
|
7873
7890
|
return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + `
|
|
7874
7891
|
...[truncated ${s.length - MAX_OUTPUT} chars]` : s;
|
|
7875
7892
|
}
|
|
7893
|
+
function decodeBase64Read(answer, at) {
|
|
7894
|
+
if (answer.encoding !== "base64") {
|
|
7895
|
+
throw new Error(
|
|
7896
|
+
`${at.where}: the Worker answered a text read to a request for bytes \u2014 it predates binary reads; redeploy it`
|
|
7897
|
+
);
|
|
7898
|
+
}
|
|
7899
|
+
if (answer.tooLarge === true) throw new Error(tooLargeMessage(at.path));
|
|
7900
|
+
return new Uint8Array(Buffer.from(typeof answer.content === "string" ? answer.content : "", "base64"));
|
|
7901
|
+
}
|
|
7876
7902
|
async function runLocalCommand(command2, cwd) {
|
|
7877
7903
|
const r = await runBash(command2, cwd);
|
|
7878
7904
|
const output = [r.stdout, r.stderr].filter(Boolean).join("\n--- stderr ---\n");
|
|
@@ -7906,6 +7932,7 @@ var init_executor = __esm({
|
|
|
7906
7932
|
"../../src/execution/executor.ts"() {
|
|
7907
7933
|
"use strict";
|
|
7908
7934
|
init_bashTimeout();
|
|
7935
|
+
init_binaryRead();
|
|
7909
7936
|
init_clock();
|
|
7910
7937
|
init_bashTimeout();
|
|
7911
7938
|
ExecInfraError = class extends Error {
|
|
@@ -7925,6 +7952,8 @@ var init_executor = __esm({
|
|
|
7925
7952
|
ExecHealthTracker = class {
|
|
7926
7953
|
constructor(inner) {
|
|
7927
7954
|
this.inner = inner;
|
|
7955
|
+
const innerReadBytes = inner.readBytes?.bind(inner);
|
|
7956
|
+
if (innerReadBytes) this.readBytes = (path, opts) => this.track(() => innerReadBytes(path, opts));
|
|
7928
7957
|
}
|
|
7929
7958
|
inner;
|
|
7930
7959
|
consecutiveInfraFailures = 0;
|
|
@@ -7932,6 +7961,9 @@ var init_executor = __esm({
|
|
|
7932
7961
|
* evidence the runner's abort diagnosis quotes instead of guessing a cause.
|
|
7933
7962
|
* Cleared by a successful op along with the count. */
|
|
7934
7963
|
lastInfraError;
|
|
7964
|
+
/** Present exactly when the inner executor reads bytes — a decorator that
|
|
7965
|
+
* always offered it would promise what the transport cannot do. */
|
|
7966
|
+
readBytes;
|
|
7935
7967
|
async track(op) {
|
|
7936
7968
|
try {
|
|
7937
7969
|
const out = await op();
|
|
@@ -7986,6 +8018,18 @@ ${parts}`);
|
|
|
7986
8018
|
async readFile(path) {
|
|
7987
8019
|
return truncate(readFileSync9(this.confine(path), "utf8"));
|
|
7988
8020
|
}
|
|
8021
|
+
/** The size is checked on the open handle and the bytes read from the same
|
|
8022
|
+
* handle, so a file that grows between the two calls cannot slip past the cap. */
|
|
8023
|
+
async readBytes(path) {
|
|
8024
|
+
const fd = openSync(this.confine(path), "r");
|
|
8025
|
+
try {
|
|
8026
|
+
const size = fstatSync(fd).size;
|
|
8027
|
+
if (size > MAX_READ_BYTES) throw new Error(tooLargeMessage(path, size));
|
|
8028
|
+
return new Uint8Array(readFileSync9(fd));
|
|
8029
|
+
} finally {
|
|
8030
|
+
closeSync(fd);
|
|
8031
|
+
}
|
|
8032
|
+
}
|
|
7989
8033
|
async writeFile(path, content) {
|
|
7990
8034
|
const abs = this.confine(path);
|
|
7991
8035
|
mkdirSync5(dirname6(abs), { recursive: true });
|
|
@@ -8480,6 +8524,13 @@ ${parts}`);
|
|
|
8480
8524
|
const r = await this.call("/read", { path }, void 0, void 0, opts?.span);
|
|
8481
8525
|
return truncate(String(r.content ?? ""));
|
|
8482
8526
|
}
|
|
8527
|
+
/** The same `/read` route asked for `encoding: "base64"` (src/execution/binaryRead.ts);
|
|
8528
|
+
* the Worker refuses an over-cap file by name, and one that predates
|
|
8529
|
+
* binary reads answers text, which `decodeBase64Read` names instead of decoding. */
|
|
8530
|
+
async readBytes(path, opts) {
|
|
8531
|
+
const r = await this.call("/read", { path, encoding: "base64" }, void 0, void 0, opts?.span);
|
|
8532
|
+
return decodeBase64Read(r, { where: "sandbox worker /read", path });
|
|
8533
|
+
}
|
|
8483
8534
|
async writeFile(path, content, opts) {
|
|
8484
8535
|
await this.call("/write", { path, content }, void 0, void 0, opts?.span);
|
|
8485
8536
|
return `Wrote ${path}`;
|
|
@@ -9049,6 +9100,26 @@ ${parts}`);
|
|
|
9049
9100
|
}
|
|
9050
9101
|
return truncate(String(data.content ?? ""));
|
|
9051
9102
|
}
|
|
9103
|
+
/** The same `/read` route asked for `encoding: "base64"` (src/execution/binaryRead.ts),
|
|
9104
|
+
* with the same re-attach on an evicted worktree; a missing file is the
|
|
9105
|
+
* route's 404 as for `readFile`, an over-cap file and a Worker that predates
|
|
9106
|
+
* binary reads are `decodeBase64Read`'s plain errors. */
|
|
9107
|
+
async readBytes(path, opts) {
|
|
9108
|
+
const { status: status3, data } = await this.opWithReattach(
|
|
9109
|
+
"/read",
|
|
9110
|
+
{ path, encoding: "base64" },
|
|
9111
|
+
void 0,
|
|
9112
|
+
void 0,
|
|
9113
|
+
opts?.span
|
|
9114
|
+
);
|
|
9115
|
+
if (status3 !== 200) {
|
|
9116
|
+
throw classifyError(new ExecInfraError(`resident /read: ${String(data.error ?? `HTTP ${status3}`)}`), {
|
|
9117
|
+
kind: "http",
|
|
9118
|
+
code: String(status3)
|
|
9119
|
+
});
|
|
9120
|
+
}
|
|
9121
|
+
return decodeBase64Read(data, { where: "resident /read", path });
|
|
9122
|
+
}
|
|
9052
9123
|
async writeFile(path, content, opts) {
|
|
9053
9124
|
const { status: status3, data } = await this.opWithReattach("/write", { path, content }, void 0, void 0, opts?.span);
|
|
9054
9125
|
if (status3 !== 200) {
|
|
@@ -14526,7 +14597,7 @@ ${evidence}`);
|
|
|
14526
14597
|
case "wrap_up":
|
|
14527
14598
|
return `Runs keep reaching the wrap-up warning (${p.runIds.length} runs; ${formatDuration(p.durationMs, "report")} spent winding down). Either the affected agent's \`maxMinutes\` (\`src/agents/registry.ts\`) is too tight for this shape of work, or the prompt should push batching (fewer, larger tool calls) \u2014 the evidence rows say which agent and how close to the deadline each run got.`;
|
|
14528
14599
|
case "budget_hit":
|
|
14529
|
-
return p.signature === "turns" ? `Runs
|
|
14600
|
+
return p.signature === "turns" ? `Runs outpace the runaway guard: the turn cap is \`RUNAWAY_TURNS_PER_MINUTE\` (${RUNAWAY_TURNS_PER_MINUTE} turns a minute) over the affected agent's wall clock, a pace a working run does not sustain \u2014 so a run that reaches it is looping, not working. Read the evidence rows for a retry loop (the same call re-issued turn after turn) and fix its cause where the agent reads before acting (the target repo's AGENTS.md, the resident command table), or have the prompt batch tool calls (several commands per \`bash\` call). The cap is derived from \`maxMinutes\` (\`src/agents/registry.ts\`), not a knob to turn.` : `Runs exhaust the TIME budget and are cut off mid-work. Raise \`maxMinutes\` for the affected agent (\`src/agents/registry.ts\`), or split the task shape that triggers it \u2014 a run that is forced to write up findings is a run whose work was wasted.`;
|
|
14530
14601
|
case "infra_failure":
|
|
14531
14602
|
if (p.signature === "sandbox_dead") {
|
|
14532
14603
|
return `The sandbox died mid-run in ${p.runIds.length} runs. Check container sizing first (the \`deploy/cloudflare-*/wrangler.template.jsonc\` comments: the 1 GiB \`basic\` tier died running vitest; thread sandboxes are \`standard-4\` \u2014 4 vCPU / 12 GiB / 20 GB, the platform's largest \u2014 and residents a custom type of the same size), then the memory footprint of the failing command, then the sandbox/resident Worker logs (\`deploy/bin/cf-logs\`) around the affected runs.`;
|
|
@@ -14559,6 +14630,7 @@ var init_frictionProposals = __esm({
|
|
|
14559
14630
|
"../../src/core/frictionProposals.ts"() {
|
|
14560
14631
|
"use strict";
|
|
14561
14632
|
init_runFriction();
|
|
14633
|
+
init_registry();
|
|
14562
14634
|
init_formatDuration();
|
|
14563
14635
|
DEFAULT_MIN_RUNS = 2;
|
|
14564
14636
|
MAX_EXAMPLES = 5;
|
|
@@ -23794,12 +23866,16 @@ var init_tracingExecutor = __esm({
|
|
|
23794
23866
|
if (innerRelease) this.release = (mode) => this.timed("exec.release", (s) => innerRelease(mode, { span: s }));
|
|
23795
23867
|
const innerMoveTo = inner.moveTo?.bind(inner);
|
|
23796
23868
|
if (innerMoveTo) this.moveTo = (sha) => this.timed("exec.move_to", (s) => innerMoveTo(sha, { span: s }));
|
|
23869
|
+
const innerReadBytes = inner.readBytes?.bind(inner);
|
|
23870
|
+
if (innerReadBytes)
|
|
23871
|
+
this.readBytes = (path) => this.timed("exec.read_bytes", (s) => innerReadBytes(path, { span: s }));
|
|
23797
23872
|
}
|
|
23798
23873
|
inner;
|
|
23799
23874
|
span;
|
|
23800
23875
|
backend;
|
|
23801
23876
|
release;
|
|
23802
23877
|
moveTo;
|
|
23878
|
+
readBytes;
|
|
23803
23879
|
/** Each op under its own `exec.*` span, handed to the inner executor as
|
|
23804
23880
|
* `opts.span` so its HTTP calls become `http.client` children (item 21). */
|
|
23805
23881
|
timed(name, fn, extra = {}) {
|
|
@@ -24799,6 +24875,60 @@ ${skill.body}${footer}`;
|
|
|
24799
24875
|
}
|
|
24800
24876
|
});
|
|
24801
24877
|
|
|
24878
|
+
// ../../src/tools/attach.ts
|
|
24879
|
+
function fileNameOf2(path) {
|
|
24880
|
+
const segments2 = path.split("/").filter(Boolean);
|
|
24881
|
+
return segments2[segments2.length - 1] ?? path;
|
|
24882
|
+
}
|
|
24883
|
+
var attachFileTool;
|
|
24884
|
+
var init_attach = __esm({
|
|
24885
|
+
"../../src/tools/attach.ts"() {
|
|
24886
|
+
"use strict";
|
|
24887
|
+
init_binaryRead();
|
|
24888
|
+
attachFileTool = {
|
|
24889
|
+
name: "attach_file",
|
|
24890
|
+
description: `Post a file from the workspace into the conversation so the person sees it inline \u2014 a screenshot (e.g. from \`playwright screenshot\`), a rendered PDF, a recording, a log. Use it whenever you produce an image worth showing: a link to a file is not a picture. Whole files only, up to ${MAX_READ_BYTES} bytes.`,
|
|
24891
|
+
inputSchema: {
|
|
24892
|
+
type: "object",
|
|
24893
|
+
properties: {
|
|
24894
|
+
path: { type: "string", description: "Relative path of the file in the workspace" },
|
|
24895
|
+
comment: {
|
|
24896
|
+
type: "string",
|
|
24897
|
+
description: "One line posted with the file saying what it shows (default: the file name)"
|
|
24898
|
+
}
|
|
24899
|
+
},
|
|
24900
|
+
required: ["path"]
|
|
24901
|
+
},
|
|
24902
|
+
async run(input, ctx) {
|
|
24903
|
+
const path = String(input.path ?? "").trim();
|
|
24904
|
+
if (!path) return "error: path is required";
|
|
24905
|
+
const name = fileNameOf2(path);
|
|
24906
|
+
const lead = typeof input.comment === "string" && input.comment.trim() ? input.comment.trim() : name;
|
|
24907
|
+
if (!ctx.attach) {
|
|
24908
|
+
return "attach_file is not available here: this conversation's channel takes no file uploads \u2014 link to the file instead";
|
|
24909
|
+
}
|
|
24910
|
+
const readBytes = ctx.executor.readBytes?.bind(ctx.executor);
|
|
24911
|
+
if (!readBytes) {
|
|
24912
|
+
return "attach_file is not available here: this workspace cannot hand files over \u2014 link to the file instead";
|
|
24913
|
+
}
|
|
24914
|
+
let bytes;
|
|
24915
|
+
try {
|
|
24916
|
+
bytes = await readBytes(path);
|
|
24917
|
+
} catch (err2) {
|
|
24918
|
+
return `error: could not read ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`;
|
|
24919
|
+
}
|
|
24920
|
+
if (bytes.byteLength === 0) return `error: ${path} is empty \u2014 nothing to attach`;
|
|
24921
|
+
try {
|
|
24922
|
+
await ctx.attach({ name, bytes, lead });
|
|
24923
|
+
} catch (err2) {
|
|
24924
|
+
return `error: the channel refused the upload of ${name}: ${err2 instanceof Error ? err2.message : String(err2)}`;
|
|
24925
|
+
}
|
|
24926
|
+
return `attached ${name} (${bytes.byteLength} bytes) to the conversation`;
|
|
24927
|
+
}
|
|
24928
|
+
};
|
|
24929
|
+
}
|
|
24930
|
+
});
|
|
24931
|
+
|
|
24802
24932
|
// ../../src/core/dispatch/awaitChildren.ts
|
|
24803
24933
|
function decideWait(inputs) {
|
|
24804
24934
|
const { children, now, budgetEndsAt, timeoutAt, stop, followUpPending } = inputs;
|
|
@@ -24907,6 +25037,7 @@ function watchedChild(io, on) {
|
|
|
24907
25037
|
}
|
|
24908
25038
|
};
|
|
24909
25039
|
if (io.attach) watched2.attach = (file) => io.attach(file);
|
|
25040
|
+
if (io.attachFile) watched2.attachFile = (file) => io.attachFile(file);
|
|
24910
25041
|
if (io.runFinished) watched2.runFinished = (receipt) => io.runFinished(receipt);
|
|
24911
25042
|
if (io.openThread) watched2.openThread = (lead) => io.openThread(lead);
|
|
24912
25043
|
return watched2;
|
|
@@ -25436,6 +25567,7 @@ var init_workspace = __esm({
|
|
|
25436
25567
|
init_web();
|
|
25437
25568
|
init_github();
|
|
25438
25569
|
init_skills();
|
|
25570
|
+
init_attach();
|
|
25439
25571
|
init_runs2();
|
|
25440
25572
|
bashTool = {
|
|
25441
25573
|
name: "bash",
|
|
@@ -25804,6 +25936,7 @@ ${raw.trim()}`;
|
|
|
25804
25936
|
bashTool,
|
|
25805
25937
|
readFileTool,
|
|
25806
25938
|
writeFileTool,
|
|
25939
|
+
attachFileTool,
|
|
25807
25940
|
updateStatusTool,
|
|
25808
25941
|
submitPrDescriptionTool,
|
|
25809
25942
|
submitHandoffTool,
|
|
@@ -26248,21 +26381,33 @@ async function runLoop(opts, now, note, emit, agentSpan) {
|
|
|
26248
26381
|
return await finishSandboxDead(complete2, opts, messages, system, diagnosis);
|
|
26249
26382
|
}
|
|
26250
26383
|
const wasTimeout = now() >= deadline;
|
|
26384
|
+
const elapsedMs = opts.agent.maxMinutes * 6e4 - (deadline - now());
|
|
26385
|
+
const pace = `${turn} model turn${turn === 1 ? "" : "s"} in ${elapsedMinutes(elapsedMs)}`;
|
|
26251
26386
|
note(
|
|
26252
26387
|
wasTimeout ? "time_budget_exhausted" : "turn_budget_exhausted",
|
|
26253
|
-
|
|
26388
|
+
wasTimeout ? "time budget exhausted \u2014 writing up findings so far" : `turn guard fired: ${pace}, a pace that looks like a loop \u2014 writing up findings so far`
|
|
26254
26389
|
);
|
|
26390
|
+
const writeUp = "Write your final answer now from what you have learned so far: report your findings/results to date, then state plainly which parts of the task you did not get to and what a follow-up (in this thread, to reuse this workspace) should focus on.";
|
|
26255
26391
|
const text = await runFinale(
|
|
26256
26392
|
complete2,
|
|
26257
26393
|
opts,
|
|
26258
26394
|
messages,
|
|
26259
26395
|
system,
|
|
26260
|
-
|
|
26396
|
+
wasTimeout ? `You have reached the time budget and can make no more tool calls. ${writeUp}` : `You have hit the run's turn guard \u2014 ${pace}, a pace that looks like a loop \u2014 and can make no more tool calls. ${writeUp}`
|
|
26261
26397
|
);
|
|
26262
|
-
|
|
26263
|
-
|
|
26398
|
+
if (wasTimeout) {
|
|
26399
|
+
return text ? `\u26A0\uFE0F _Hit the ${opts.agent.maxMinutes}-minute budget before finishing \u2014 findings so far:_
|
|
26400
|
+
|
|
26401
|
+
${text}` : `Stopped at the ${opts.agent.maxMinutes}-minute budget without finishing. Partial work may exist in the workspace \u2014 narrow the task and try again.`;
|
|
26402
|
+
}
|
|
26403
|
+
return text ? `\u26A0\uFE0F _Stopped after ${pace} \u2014 that pace looks like a loop; findings so far:_
|
|
26264
26404
|
|
|
26265
|
-
${text}` : `Stopped
|
|
26405
|
+
${text}` : `Stopped after ${pace} \u2014 that pace looks like a loop \u2014 without finishing. Partial work may exist in the workspace \u2014 look for a retry loop in the run's events before trying again.`;
|
|
26406
|
+
}
|
|
26407
|
+
function elapsedMinutes(ms) {
|
|
26408
|
+
const minutes = Math.round(ms / 6e4);
|
|
26409
|
+
if (minutes < 1) return "under a minute";
|
|
26410
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
26266
26411
|
}
|
|
26267
26412
|
async function runFinale(complete2, opts, messages, system, instruction) {
|
|
26268
26413
|
messages.push({ role: "user", content: [{ type: "text", text: instruction }] });
|
|
@@ -28884,9 +29029,11 @@ async function runLoop2(deps, ctx) {
|
|
|
28884
29029
|
let runFailed = false;
|
|
28885
29030
|
let runDiagnosis;
|
|
28886
29031
|
const releaseWorkspace = (span) => round2.release({ hardStopped: run2.control.requested === "hard", ...span ? { span } : {} });
|
|
29032
|
+
const attachFile = io.attachFile?.bind(io);
|
|
28887
29033
|
const toolContext = {
|
|
28888
29034
|
executor,
|
|
28889
29035
|
reportProgress,
|
|
29036
|
+
...attachFile ? { attach: attachFile } : {},
|
|
28890
29037
|
web: webCapability(),
|
|
28891
29038
|
skills: deps.skills,
|
|
28892
29039
|
github: githubCapabilityFor(deps, msg.userId),
|
|
@@ -33607,6 +33754,20 @@ var init_slack = __esm({
|
|
|
33607
33754
|
${file.text}`));
|
|
33608
33755
|
}
|
|
33609
33756
|
}
|
|
33757
|
+
/** A run's binary artifact in the thread — a screenshot renders inline, a
|
|
33758
|
+
* PDF as a preview — through the same `files.uploadV2` with the bytes as
|
|
33759
|
+
* the file. No fallback: a text reply cannot carry bytes, so a failed upload
|
|
33760
|
+
* propagates for the caller to report. */
|
|
33761
|
+
async attachFile(file) {
|
|
33762
|
+
await this.client.files.uploadV2({
|
|
33763
|
+
channel_id: this.ev.channel,
|
|
33764
|
+
thread_ts: this.ev.threadTs,
|
|
33765
|
+
filename: file.name,
|
|
33766
|
+
title: file.name,
|
|
33767
|
+
file: Buffer.from(file.bytes),
|
|
33768
|
+
initial_comment: mdToMrkdwn(file.lead)
|
|
33769
|
+
});
|
|
33770
|
+
}
|
|
33610
33771
|
async post(mrkdwn) {
|
|
33611
33772
|
for (const chunk of chunkText(mrkdwn, SLACK_MSG_LIMIT)) {
|
|
33612
33773
|
await this.client.chat.postMessage({
|
|
@@ -37004,7 +37165,7 @@ async function handleAdmitted(door, req, deps) {
|
|
|
37004
37165
|
grant2.publish({
|
|
37005
37166
|
type: "run_note",
|
|
37006
37167
|
kind: "turn_budget_exhausted",
|
|
37007
|
-
summary: `model proxy refused a call past the ${turn.maxTurns}-turn
|
|
37168
|
+
summary: `model proxy refused a call past the run's ${turn.maxTurns}-turn guard (${used})`,
|
|
37008
37169
|
at: deps.clock()
|
|
37009
37170
|
});
|
|
37010
37171
|
log(`[model-proxy] 403 turn_budget_exhausted run=${grant2.runId} turns=${turn.turns}/${turn.maxTurns}`);
|
|
@@ -37012,7 +37173,7 @@ async function handleAdmitted(door, req, deps) {
|
|
|
37012
37173
|
shape,
|
|
37013
37174
|
403,
|
|
37014
37175
|
"turn_budget_exhausted",
|
|
37015
|
-
`the run
|
|
37176
|
+
`the run is past its ${turn.maxTurns}-turn guard (${used})`
|
|
37016
37177
|
);
|
|
37017
37178
|
}
|
|
37018
37179
|
const payload = JSON.stringify(pinRequest(shape, body, grant2));
|
|
@@ -37658,6 +37819,7 @@ function watched(io, on) {
|
|
|
37658
37819
|
}
|
|
37659
37820
|
};
|
|
37660
37821
|
if (io.attach) out.attach = (file) => io.attach(file);
|
|
37822
|
+
if (io.attachFile) out.attachFile = (file) => io.attachFile(file);
|
|
37661
37823
|
if (io.runFinished) out.runFinished = (receipt) => io.runFinished(receipt);
|
|
37662
37824
|
if (io.openThread) out.openThread = (lead) => io.openThread(lead);
|
|
37663
37825
|
return out;
|
|
@@ -39374,8 +39536,9 @@ init_source();
|
|
|
39374
39536
|
init_invokedAsScript();
|
|
39375
39537
|
init_secrets2();
|
|
39376
39538
|
import { Console } from "node:console";
|
|
39377
|
-
import { existsSync as existsSync14 } from "node:fs";
|
|
39378
|
-
import {
|
|
39539
|
+
import { existsSync as existsSync14, mkdtempSync, writeFileSync as writeFileSync9 } from "node:fs";
|
|
39540
|
+
import { tmpdir } from "node:os";
|
|
39541
|
+
import { basename as basename3, join as join15 } from "node:path";
|
|
39379
39542
|
var CONFIG_PATH3 = process.env.SWITCHBOARD_CONFIG ?? installationPath(OPERATOR_ROOT, "config/config.yaml");
|
|
39380
39543
|
var DATA_DIR2 = installationPath(OPERATOR_ROOT, "data");
|
|
39381
39544
|
var CLI_CALLER = { kind: "cli", id: CLI_ACTOR.id, actor: CLI_ACTOR };
|
|
@@ -39527,14 +39690,16 @@ ${cliCatalogue(commands)}`, stderr: "" };
|
|
|
39527
39690
|
}
|
|
39528
39691
|
}
|
|
39529
39692
|
var ConsoleIO = class _ConsoleIO {
|
|
39530
|
-
constructor(out = process.stdout, threadKey = "cli", prefix = "") {
|
|
39693
|
+
constructor(out = process.stdout, threadKey = "cli", prefix = "", attachments = {}) {
|
|
39531
39694
|
this.out = out;
|
|
39532
39695
|
this.threadKey = threadKey;
|
|
39533
39696
|
this.prefix = prefix;
|
|
39697
|
+
this.attachments = attachments;
|
|
39534
39698
|
}
|
|
39535
39699
|
out;
|
|
39536
39700
|
threadKey;
|
|
39537
39701
|
prefix;
|
|
39702
|
+
attachments;
|
|
39538
39703
|
/** The receipt of the run this request started, once it finished — undefined
|
|
39539
39704
|
* before that, and forever when no run was started (a config reply such as
|
|
39540
39705
|
* `help`, a refusal before a run existed). */
|
|
@@ -39544,6 +39709,15 @@ var ConsoleIO = class _ConsoleIO {
|
|
|
39544
39709
|
async reply(text) {
|
|
39545
39710
|
this.out.write("\n" + this.prefix + text + "\n");
|
|
39546
39711
|
}
|
|
39712
|
+
/** The harness's file upload: the bytes written under the attachments dir,
|
|
39713
|
+
* the lead printed like a reply with the path a person can open. */
|
|
39714
|
+
async attachFile(file) {
|
|
39715
|
+
this.attachments.dir ??= mkdtempSync(join15(tmpdir(), "switchboard-attachments-"));
|
|
39716
|
+
const path = join15(this.attachments.dir, basename3(file.name));
|
|
39717
|
+
writeFileSync9(path, file.bytes);
|
|
39718
|
+
await this.reply(`${file.lead}
|
|
39719
|
+
\u{1F4CE} ${file.name} (${file.bytes.byteLength} bytes) \u2192 ${path}`);
|
|
39720
|
+
}
|
|
39547
39721
|
runFinished(receipt) {
|
|
39548
39722
|
this.finished = receipt;
|
|
39549
39723
|
}
|
|
@@ -39566,7 +39740,10 @@ var ConsoleIO = class _ConsoleIO {
|
|
|
39566
39740
|
const n2 = ++this.children;
|
|
39567
39741
|
await this.reply(lead);
|
|
39568
39742
|
const threadKey = `${this.threadKey}/child-${n2}`;
|
|
39569
|
-
return {
|
|
39743
|
+
return {
|
|
39744
|
+
thread: { threadKey },
|
|
39745
|
+
io: new _ConsoleIO(this.out, threadKey, `${this.prefix}[child-${n2}] `, this.attachments)
|
|
39746
|
+
};
|
|
39570
39747
|
}
|
|
39571
39748
|
};
|
|
39572
39749
|
function askExitCode(finished) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coreplane/switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.210.0",
|
|
4
4
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://openswitchboard.dev",
|