@foldrun/core 0.4.0 → 0.5.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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +6 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +403 -42
- package/dist/src/runner.d.ts +142 -9
- package/dist/src/runner.js +1027 -122
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +3 -2
- package/dist/src/script-tools.js +35 -3
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +61 -2
- package/dist/src/step-exec.js +414 -36
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +218 -37
- package/dist/src/store.js +347 -60
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +313 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +11 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +439 -43
- package/src/runner.ts +1050 -122
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +38 -5
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +429 -37
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +500 -68
- package/src/subagents.ts +189 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Slim browsing: a step whose only reason for the full runner image is that
|
|
2
|
+
// it browses may run on the slim image and drive the account's browser pod
|
|
3
|
+
// over Playwright's protocol. The pod can go away mid-step — a node drained,
|
|
4
|
+
// the idle reaper, an OOM — and a slim step has no browser of its own to fall
|
|
5
|
+
// back on. So:
|
|
6
|
+
//
|
|
7
|
+
// 1. The web tool reconnects first: a few tries, short backoff (the tool's
|
|
8
|
+
// run.mjs; each try is an event in FOLDRUN_BROWSER_POD_EVENTS).
|
|
9
|
+
// 2. Still gone: the step stops, and what it has done so far decides.
|
|
10
|
+
// Only reads — re-run the whole step from the start on the full image,
|
|
11
|
+
// and say so on the run ("browser pod lost; re-ran on full").
|
|
12
|
+
// 3. Anything written — fail the step: "browser pod died after the step had
|
|
13
|
+
// written <what>". Doing it again could send, post or charge twice, so
|
|
14
|
+
// retry:, on-fail: or a person decides.
|
|
15
|
+
//
|
|
16
|
+
// Read or write is decided from the calls the step made, as the model asked
|
|
17
|
+
// for them (step-exec.ts keeps the ledger). When a call could be either it
|
|
18
|
+
// counts as a write: the cost of being wrong that way is a failed step a
|
|
19
|
+
// retry can redo; the other way it is a duplicate email.
|
|
20
|
+
//
|
|
21
|
+
// Steps that act outward (a tool marked `outward: true`) never run slim; nor
|
|
22
|
+
// does anything the step start can already see needs a browser in the step
|
|
23
|
+
// (lightpanda, obscura, live sessions, extensions) — slimBrowseBlocker.
|
|
24
|
+
|
|
25
|
+
/** Set by the platform on a step it put on the slim image to browse
|
|
26
|
+
* through the pod. The web tool then never launches a browser of its own. */
|
|
27
|
+
export const POD_ONLY_ENV = "FOLDRUN_BROWSER_POD_ONLY";
|
|
28
|
+
/** Where the web tool appends one JSON line per reconnect, and the line
|
|
29
|
+
* that says the pod is lost or the call needs the full image. */
|
|
30
|
+
export const POD_EVENTS_ENV = "FOLDRUN_BROWSER_POD_EVENTS";
|
|
31
|
+
/** The file, inside the step's sandbox. */
|
|
32
|
+
export const POD_EVENTS_FILE = "/tmp/foldrun-browser-pod.jsonl";
|
|
33
|
+
/** The note the run carries when the fallback was taken. Worded once. */
|
|
34
|
+
export const RERAN_ON_FULL = "browser pod lost; re-ran on full";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Browse actions that can change something beyond the page in front of the
|
|
38
|
+
* model: anything that types, picks, submits, signs in, uploads, runs script
|
|
39
|
+
* or pays. A click is here because a click can submit and nothing before
|
|
40
|
+
* the click can tell. The web tool's run.mjs carries a copy (it decides
|
|
41
|
+
* whether a dropped call may be repeated); the gallery test holds the two
|
|
42
|
+
* equal.
|
|
43
|
+
*/
|
|
44
|
+
export const BROWSE_WRITE_ACTIONS = [
|
|
45
|
+
"click", "dblclick", "rightclick", "tap", "swipe", "mouse", "drag",
|
|
46
|
+
"fill", "type", "insert", "paste", "press", "keyboard", "keydown", "keyup", "clear",
|
|
47
|
+
"select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
/** Call arguments of web action=browse that are writes whatever the actions:
|
|
51
|
+
* page script, a paid captcha solver, a confirmation of a credential step. */
|
|
52
|
+
const BROWSE_WRITE_ARGS = ["js", "captcha", "captcha_solver", "confirm"];
|
|
53
|
+
|
|
54
|
+
/** Web actions that only read — when foldrun's own answers them. A paid
|
|
55
|
+
* provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
|
|
56
|
+
* it twice is paying twice. `monitor` keeps state, so it always writes. */
|
|
57
|
+
const WEB_READ_ACTIONS = new Set(["search", "fetch", "browse", "crawl", "map", "extract", "answer"]);
|
|
58
|
+
|
|
59
|
+
/** Built-ins that read and change nothing. */
|
|
60
|
+
const READ_BUILTINS = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch", "TodoWrite", "LS", "NotebookRead"]);
|
|
61
|
+
/** The platform's own tool groups that only read. Consults are a toolless
|
|
62
|
+
* answer; a delegation's own calls come through on their own. */
|
|
63
|
+
const READ_SERVERS = new Set(["foldrun_search", "foldrun_history", "foldrun_desks", "foldrun_agents"]);
|
|
64
|
+
const DELEGATE = new Set(["Agent", "Task"]);
|
|
65
|
+
|
|
66
|
+
export type CallKind = { write: false } | { write: true; what: string };
|
|
67
|
+
|
|
68
|
+
export interface ClassifyContext {
|
|
69
|
+
/** SDK tool name → HTTP method, for an API's typed operations (api-tools
|
|
70
|
+
* builds the map; a generic call_<api> carries its method in the input). */
|
|
71
|
+
methods?: Record<string, string>;
|
|
72
|
+
/** Web actions a paid provider answers this step — from the env's
|
|
73
|
+
* FOLDRUN_WEB_<ACTION>_VIA. */
|
|
74
|
+
paidWeb?: Record<string, string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The browse actions a call asked for, nested `if` branches included. */
|
|
78
|
+
export function browseActionNames(actions: unknown): string[] {
|
|
79
|
+
let steps: unknown = actions;
|
|
80
|
+
if (typeof steps === "string") {
|
|
81
|
+
try { steps = JSON.parse(steps); } catch { return ["(unreadable actions)"]; }
|
|
82
|
+
}
|
|
83
|
+
const out: string[] = [];
|
|
84
|
+
const walk = (v: unknown, depth: number) => {
|
|
85
|
+
if (depth > 8 || v === null || typeof v !== "object") return;
|
|
86
|
+
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
|
|
87
|
+
for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
|
|
88
|
+
out.push(k);
|
|
89
|
+
walk(x, depth + 1);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
walk(steps, 0);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What a browse call's arguments would write, or null when it only reads. */
|
|
97
|
+
export function browseWrites(input: Record<string, unknown>): string | null {
|
|
98
|
+
const names = browseActionNames(input.actions);
|
|
99
|
+
if (names.includes("(unreadable actions)")) return "browse actions it could not read";
|
|
100
|
+
const writes = [...new Set(names.filter((n) => (BROWSE_WRITE_ACTIONS as readonly string[]).includes(n)))];
|
|
101
|
+
if (writes.length) return `browse ${writes.join("/")}`;
|
|
102
|
+
const arg = BROWSE_WRITE_ARGS.find((a) => input[a] !== undefined && input[a] !== null && input[a] !== "" && input[a] !== false);
|
|
103
|
+
if (arg) return `browse ${arg}=`;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether one tool call can have changed anything outside the model's head,
|
|
109
|
+
* and if so, a few words saying what. Pure.
|
|
110
|
+
*/
|
|
111
|
+
export function classifyCall(tool: string, input: Record<string, unknown> | undefined, ctx: ClassifyContext = {}): CallKind {
|
|
112
|
+
const args = input ?? {};
|
|
113
|
+
const write = (what: string): CallKind => ({ write: true, what });
|
|
114
|
+
if (READ_BUILTINS.has(tool) || DELEGATE.has(tool)) return { write: false };
|
|
115
|
+
if (tool === "Write" || tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") {
|
|
116
|
+
const raw = String(args.file_path ?? args.notebook_path ?? args.path ?? "");
|
|
117
|
+
const file = raw.startsWith("/workspace/") ? raw.slice("/workspace/".length) : raw.split("/").slice(-3).join("/");
|
|
118
|
+
return write(file ? `${file} (${tool})` : `a file (${tool})`);
|
|
119
|
+
}
|
|
120
|
+
if (tool === "Bash") return write("a shell command");
|
|
121
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
122
|
+
if (!mcp) return write(tool); // unknown: a write until shown otherwise
|
|
123
|
+
const [, server, name] = mcp;
|
|
124
|
+
if (READ_SERVERS.has(server)) return { write: false };
|
|
125
|
+
if (server === "foldrun_ask") return write("a question to a person");
|
|
126
|
+
if (server === "foldrun_scripts") {
|
|
127
|
+
if (name !== "web") return write(`script ${name}`);
|
|
128
|
+
const action = String(args.action ?? "").trim().toLowerCase();
|
|
129
|
+
if (!WEB_READ_ACTIONS.has(action)) return write(action === "monitor" ? "web monitor state" : `web ${action || "call"}`);
|
|
130
|
+
const paid = ctx.paidWeb?.[action];
|
|
131
|
+
if (paid) return write(`a paid ${paid} ${action}`);
|
|
132
|
+
if (action === "browse") {
|
|
133
|
+
const w = browseWrites(args);
|
|
134
|
+
if (w) return write(w);
|
|
135
|
+
}
|
|
136
|
+
return { write: false };
|
|
137
|
+
}
|
|
138
|
+
if (server === "foldrun_apis") {
|
|
139
|
+
const method = String(
|
|
140
|
+
name.startsWith("call_") ? (args.method ?? "GET") : (ctx.methods?.[tool] ?? ""),
|
|
141
|
+
).toUpperCase();
|
|
142
|
+
const api = name.replace(/^call_/, "");
|
|
143
|
+
if (method === "GET" || method === "HEAD") return { write: false };
|
|
144
|
+
return write(`${api} ${method || "call"}`);
|
|
145
|
+
}
|
|
146
|
+
// Any other MCP server: its tools could do anything.
|
|
147
|
+
return write(`${server} ${name}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Paid web actions, from a step's env (FOLDRUN_WEB_<ACTION>_VIA=provider). */
|
|
151
|
+
export function paidWebActions(env: Record<string, string | undefined>): Record<string, string> {
|
|
152
|
+
const out: Record<string, string> = {};
|
|
153
|
+
for (const [k, v] of Object.entries(env)) {
|
|
154
|
+
const m = /^FOLDRUN_WEB_([A-Z]+)_VIA$/.exec(k);
|
|
155
|
+
if (m && v) out[m[1].toLowerCase()] = v;
|
|
156
|
+
}
|
|
157
|
+
if (env.FOLDRUN_BROWSER_VENDOR) out.browse = env.FOLDRUN_BROWSER_VENDOR;
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One line of the web tool's pod log. */
|
|
162
|
+
export interface PodEvent {
|
|
163
|
+
kind: "reconnect" | "lost" | "needs-full";
|
|
164
|
+
/** reconnect: which try, of how many, and whether it got through. */
|
|
165
|
+
attempt?: number;
|
|
166
|
+
of?: number;
|
|
167
|
+
ok?: boolean;
|
|
168
|
+
/** lost: whether the call ran on the pod at all before it was lost — a
|
|
169
|
+
* call that never connected changed nothing, whatever it asked for. */
|
|
170
|
+
ran?: boolean;
|
|
171
|
+
error?: string;
|
|
172
|
+
why?: string;
|
|
173
|
+
/** When the web tool wrote the line (ISO). */
|
|
174
|
+
at?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** ", at 10:15:15Z" from a pod log line's `at`; empty when it has none. */
|
|
178
|
+
export function podAt(at: unknown): string {
|
|
179
|
+
if (typeof at !== "string") return "";
|
|
180
|
+
const t = Date.parse(at);
|
|
181
|
+
return Number.isNaN(t) ? "" : `, at ${new Date(t).toISOString().slice(11, 19)}Z`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Lines of the pod log from byte `offset` on, and where the next read
|
|
185
|
+
* starts. A torn last line waits for the next read. */
|
|
186
|
+
export function readPodEvents(text: string, offset: number): { events: PodEvent[]; next: number } {
|
|
187
|
+
const chunk = text.slice(offset);
|
|
188
|
+
const end = chunk.lastIndexOf("\n");
|
|
189
|
+
if (end < 0) return { events: [], next: offset };
|
|
190
|
+
const events: PodEvent[] = [];
|
|
191
|
+
for (const line of chunk.slice(0, end).split("\n")) {
|
|
192
|
+
try {
|
|
193
|
+
const e = JSON.parse(line) as PodEvent;
|
|
194
|
+
if (e && (e.kind === "reconnect" || e.kind === "lost" || e.kind === "needs-full")) events.push(e);
|
|
195
|
+
} catch {
|
|
196
|
+
// not ours
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { events, next: offset + end + 1 };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** What crosses back from a step that browsed through the pod. */
|
|
203
|
+
export interface BrowserPodOutcome {
|
|
204
|
+
/** Reconnect tries the web tool made, and how many got through: the pod
|
|
205
|
+
* answered and the call went on, rather than dropping again at once. */
|
|
206
|
+
reconnects: number;
|
|
207
|
+
reconnected: number;
|
|
208
|
+
/** Reconnects that reached the pod, after which the call dropped again at
|
|
209
|
+
* once — a pod still answering while it shut down. Never "got through". */
|
|
210
|
+
closedAgain?: number;
|
|
211
|
+
/** Set when the step stopped because the pod was gone (or a call needed
|
|
212
|
+
* a browser in the step, which slim does not have). */
|
|
213
|
+
lost?: { cause: "lost" | "needs-full"; detail: string };
|
|
214
|
+
/** What the step had written by then, worded — empty means reads only. */
|
|
215
|
+
writes?: string[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** "a.md (Write), crm POST and 2 more". */
|
|
219
|
+
export function describeWrites(writes: string[]): string {
|
|
220
|
+
const uniq = [...new Set(writes)];
|
|
221
|
+
if (uniq.length > 3) return `${uniq.slice(0, 3).join(", ")} and ${uniq.length - 3} more`;
|
|
222
|
+
if (uniq.length <= 1) return uniq[0] ?? "something";
|
|
223
|
+
return `${uniq.slice(0, -1).join(", ")} and ${uniq.at(-1)}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The decision, once the pod is gone: re-run on full, or fail and say why. */
|
|
227
|
+
export function podLossDecision(pod: BrowserPodOutcome): { rerun: true; note: string } | { rerun: false; message: string } {
|
|
228
|
+
const writes = pod.writes ?? [];
|
|
229
|
+
const cause = pod.lost?.cause === "needs-full" ? `a call needed a browser in the step (${pod.lost.detail})` : `browser pod lost (${pod.lost?.detail ?? "connection closed"})`;
|
|
230
|
+
if (!writes.length) return { rerun: true, note: `${RERAN_ON_FULL} — ${cause}; the step had only read, so it ran again from the start on the full image` };
|
|
231
|
+
const head = pod.lost?.cause === "needs-full"
|
|
232
|
+
? `a call needed a browser in the step after the step had written ${describeWrites(writes)}`
|
|
233
|
+
: `browser pod died after the step had written ${describeWrites(writes)}`;
|
|
234
|
+
return { rerun: false, message: `${head} — not re-run, since doing it again could repeat that; retry:, on-fail: or a person decides (${pod.lost?.detail ?? "connection closed"})` };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Why a browsing step must have a browser in its own sandbox, from what the
|
|
239
|
+
* step start can see (the agent's `web.browse` block, as env), or null when
|
|
240
|
+
* the account's pod can serve every call it could make by default. A call
|
|
241
|
+
* that picks an in-step engine itself is caught at the call (needs-full).
|
|
242
|
+
*/
|
|
243
|
+
export function slimBrowseBlocker(env: Record<string, string | undefined>): string | null {
|
|
244
|
+
if (env.FOLDRUN_BROWSER_VENDOR) return `web.browse names ${env.FOLDRUN_BROWSER_VENDOR}`;
|
|
245
|
+
if (env.FOLDRUN_BROWSER_LIVE === "1") return "web.browse live: sessions run in the step";
|
|
246
|
+
const engine = (env.FOLDRUN_BROWSER_ENGINE ?? "").trim().toLowerCase();
|
|
247
|
+
if (engine === "lightpanda" || engine === "obscura") return `engine ${engine} runs in the step`;
|
|
248
|
+
if (env.FOLDRUN_BROWSER_EXTENSIONS) return "extensions load into a browser in the step";
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The outcome as it crosses back from the sandbox, checked field by field
|
|
253
|
+
* (the done line is the pod's word); null when absent or not this shape. */
|
|
254
|
+
export function parseBrowserPod(v: unknown): BrowserPodOutcome | null {
|
|
255
|
+
if (!v || typeof v !== "object") return null;
|
|
256
|
+
const o = v as Record<string, unknown>;
|
|
257
|
+
const n = (x: unknown) => (typeof x === "number" && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
|
|
258
|
+
const out: BrowserPodOutcome = { reconnects: n(o.reconnects), reconnected: n(o.reconnected) };
|
|
259
|
+
if (n(o.closedAgain)) out.closedAgain = n(o.closedAgain);
|
|
260
|
+
const lost = o.lost as Record<string, unknown> | undefined;
|
|
261
|
+
if (lost && typeof lost === "object" && (lost.cause === "lost" || lost.cause === "needs-full")) {
|
|
262
|
+
out.lost = { cause: lost.cause, detail: String(lost.detail ?? "").slice(0, 300) };
|
|
263
|
+
out.writes = Array.isArray(o.writes) ? o.writes.slice(0, 50).map((w) => String(w).slice(0, 120)) : [];
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** One line for a person: "2 reconnects (2 got through)", "browser pod
|
|
269
|
+
* lost; re-ran on full", or the failure. What the run page, `foldrun
|
|
270
|
+
* report` and an attempt row say. A pod that was lost in the end is never
|
|
271
|
+
* said to have let a reconnect through — "1 reconnect (1 got through); pod
|
|
272
|
+
* lost" read live as a contradiction — and a reconnect that reached the pod
|
|
273
|
+
* only for the call to drop again says that. */
|
|
274
|
+
export function browserPodLine(p: BrowserPodOutcome & { fallback?: string; failure?: string }): string {
|
|
275
|
+
const count = `${p.reconnects} reconnect${p.reconnects === 1 ? "" : "s"}`;
|
|
276
|
+
const tries = !p.reconnects
|
|
277
|
+
? "no reconnects"
|
|
278
|
+
: !p.lost
|
|
279
|
+
? `${count} (${p.reconnected} got through)`
|
|
280
|
+
: p.closedAgain
|
|
281
|
+
? `${count}, ${p.reconnects === 1 ? "which" : "the last"} reached the pod but it closed again`
|
|
282
|
+
: count;
|
|
283
|
+
if (p.failure) return `${tries}; ${p.failure}`;
|
|
284
|
+
if (p.fallback) return `${tries}; ${p.fallback}${p.lost ? ` (${p.lost.detail})` : ""}`;
|
|
285
|
+
if (p.lost) return `${tries}; ${p.lost.cause === "needs-full" ? "needed a browser in the step" : "pod lost"}: ${p.lost.detail}`;
|
|
286
|
+
return tries;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** What podTriesLine reads of a StepAttempt (store.ts) — structural, so
|
|
290
|
+
* this module stays free of the store's imports for the CLI and the web. */
|
|
291
|
+
export interface TryRow {
|
|
292
|
+
n: number;
|
|
293
|
+
status: string;
|
|
294
|
+
image?: string;
|
|
295
|
+
costUsd: number | null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A step's tries in one line when the browser pod was lost under one of
|
|
299
|
+
* them — "slim · lost · $0.0040 → full · completed · $0.0060" — so the run
|
|
300
|
+
* page and `foldrun report` show the slim go and the full re-run apart.
|
|
301
|
+
* `#n` leads each try when the step had more than one attempt. Null when
|
|
302
|
+
* no try was lost. */
|
|
303
|
+
export function podTriesLine(tries: readonly TryRow[] | null | undefined): string | null {
|
|
304
|
+
if (!tries?.some((t) => t.status === "lost")) return null;
|
|
305
|
+
const many = new Set(tries.map((t) => t.n)).size > 1;
|
|
306
|
+
return tries
|
|
307
|
+
.map((t) => [
|
|
308
|
+
`${many ? `#${t.n} ` : ""}${t.image ?? "?"}`,
|
|
309
|
+
t.status,
|
|
310
|
+
typeof t.costUsd === "number" ? `$${t.costUsd.toFixed(4)}` : null,
|
|
311
|
+
].filter(Boolean).join(" · "))
|
|
312
|
+
.join(" → ");
|
|
313
|
+
}
|
package/src/catalog.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { dataRoot } from "./paths.ts";
|
|
|
24
24
|
import { resolveModel, resolveTier, MODEL_TIERS, EFFORT_LEVELS, type Effort, type Tier, type FlowInfo } from "./store.ts";
|
|
25
25
|
import type { FlowWarning } from "./flow-lint.ts";
|
|
26
26
|
import { trimSlashes } from "./paths.ts";
|
|
27
|
+
import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
27
28
|
|
|
28
29
|
export interface CatalogModel {
|
|
29
30
|
id: string;
|
|
@@ -116,7 +117,7 @@ export async function loadCatalog(baseUrl: string): Promise<Catalog | null> {
|
|
|
116
117
|
if (cached && Date.now() - Date.parse(cached.fetchedAt) < TTL_MS) return cached;
|
|
117
118
|
|
|
118
119
|
try {
|
|
119
|
-
const res = await
|
|
120
|
+
const res = await fetchUntrusted(`${trimSlashes(baseUrl)}/v1/models`, {
|
|
120
121
|
signal: AbortSignal.timeout(10_000),
|
|
121
122
|
});
|
|
122
123
|
if (!res.ok) throw new Error(`${res.status}`);
|
package/src/clock.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The clock an agent works to.
|
|
2
|
+
//
|
|
3
|
+
// A step used to run on whatever the pod's clock said, which is UTC, while
|
|
4
|
+
// the flow that fired it was scheduled in Australia/Sydney. A run at 08:00
|
|
5
|
+
// Sydney on the 16th wrote files stamped the 15th and the next step, looking
|
|
6
|
+
// for today's files, found none. The schedule and the work disagreed about
|
|
7
|
+
// what day it was.
|
|
8
|
+
//
|
|
9
|
+
// So the calendar is resolvable at every level, nearest wins:
|
|
10
|
+
//
|
|
11
|
+
// agent frontmatter → flow frontmatter → workspace AGENTS.md →
|
|
12
|
+
// account AGENTS.md → FOLDRUN_TIMEZONE → UTC
|
|
13
|
+
//
|
|
14
|
+
// Leaving `timezone:` out inherits the next level up; setting it anywhere
|
|
15
|
+
// wins for that level and everything under it. A value nothing can read
|
|
16
|
+
// never fails a step — it falls through to the next level and says so once.
|
|
17
|
+
|
|
18
|
+
/** A zone the runtime can actually use: the name Intl understands, and the
|
|
19
|
+
* value to put in `TZ` for the shell and Node inside the sandbox. They are
|
|
20
|
+
* the same for an IANA name and differ for a fixed offset. */
|
|
21
|
+
export interface Zone {
|
|
22
|
+
/** What `Intl.DateTimeFormat({ timeZone })` is given. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** What `TZ` is set to. */
|
|
25
|
+
tz: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `UTC+10`, `GMT-3:30`, `+10:00`, `-0530`, or a bare `UTC`/`GMT`. */
|
|
29
|
+
const OFFSET_RE = /^(?:UTC|GMT)?\s*(?:([+-])\s*(\d{1,2})(?::?(\d{2}))?)?$/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a written timezone into something usable, or null if nothing can be.
|
|
33
|
+
*
|
|
34
|
+
* Two shapes are accepted, because both are what people type: an IANA name
|
|
35
|
+
* (`Australia/Sydney`) and a plain fixed offset (`UTC+10`, `+10:00`,
|
|
36
|
+
* `-05:30`, `GMT+5`, or `UTC` on its own).
|
|
37
|
+
*/
|
|
38
|
+
export function normalizeZone(raw: unknown): Zone | null {
|
|
39
|
+
const text = typeof raw === "string" ? raw.trim() : "";
|
|
40
|
+
if (!text) return null;
|
|
41
|
+
|
|
42
|
+
const offset = text.match(OFFSET_RE);
|
|
43
|
+
if (offset) {
|
|
44
|
+
const [, sign, hh, mm] = offset;
|
|
45
|
+
// A bare UTC/GMT, or the empty match — no offset at all.
|
|
46
|
+
if (!sign) return { name: "UTC", tz: "UTC" };
|
|
47
|
+
const hours = Number(hh);
|
|
48
|
+
const minutes = Number(mm ?? "0");
|
|
49
|
+
// The real range of civil offsets. `UTC+99` is a typo, not a zone.
|
|
50
|
+
if (hours > 14 || minutes > 59) return null;
|
|
51
|
+
if (hours === 14 && minutes > 0) return null;
|
|
52
|
+
const total = hours * 60 + minutes;
|
|
53
|
+
if (total === 0) return { name: "UTC", tz: "UTC" };
|
|
54
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
55
|
+
const name = `${sign}${pad(hours)}:${pad(minutes)}`; // Intl reads this form
|
|
56
|
+
if (minutes === 0) {
|
|
57
|
+
// Etc/GMT zones have their sign INVERTED — POSIX counts degrees west of
|
|
58
|
+
// Greenwich as positive, so Etc/GMT-10 is ten hours AHEAD of UTC. It is
|
|
59
|
+
// worth the confusion: `Etc/GMT-10` is understood by ICU (so Node in
|
|
60
|
+
// the sandbox honours it) and by the shell's date, which the offset
|
|
61
|
+
// form `+10:00` is not.
|
|
62
|
+
return { name, tz: `Etc/GMT${sign === "+" ? "-" : "+"}${hours}` };
|
|
63
|
+
}
|
|
64
|
+
// No Etc zone exists for a half-hour offset, so `TZ` gets the POSIX
|
|
65
|
+
// spec — which the shell reads, and whose sign is inverted for the same
|
|
66
|
+
// reason. FOLDRUN_DATE is computed from `name` either way, so the date
|
|
67
|
+
// an agent is told is right whatever TZ a program makes of it.
|
|
68
|
+
const posix = `${sign === "+" ? "-" : "+"}${pad(hours)}:${pad(minutes)}`;
|
|
69
|
+
return { name, tz: `<${sign}${pad(hours)}${pad(minutes)}>${posix}` };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Not an offset: an IANA name, if the platform knows it.
|
|
73
|
+
try {
|
|
74
|
+
new Intl.DateTimeFormat("en-CA", { timeZone: text });
|
|
75
|
+
return { name: text, tz: text };
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Is this a value `timezone:` may carry? Used by the lint, so `foldrun
|
|
82
|
+
* check` refuses `Sydney/Australia` and `UTC+99` before a run does. */
|
|
83
|
+
export function isTimezone(raw: unknown): boolean {
|
|
84
|
+
return normalizeZone(raw) !== null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The lint's sentence for a `timezone:` nobody can read. */
|
|
88
|
+
export function timezoneProblem(raw: unknown): string | null {
|
|
89
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
90
|
+
if (isTimezone(raw)) return null;
|
|
91
|
+
return (
|
|
92
|
+
`timezone: ${String(raw)} — not a zone; use an IANA name (Australia/Sydney) ` +
|
|
93
|
+
`or a fixed offset (UTC+10, +10:00, -05:30)`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One level of the cascade: where the value was written, and what it said. */
|
|
98
|
+
export interface ZoneLevel {
|
|
99
|
+
/** The word the trace uses: "agent", "flow", "workspace", "account". */
|
|
100
|
+
level: string;
|
|
101
|
+
value: unknown;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ClockChoice {
|
|
105
|
+
/** The zone, ready for Intl. */
|
|
106
|
+
timezone: string;
|
|
107
|
+
/** The value for `TZ`. */
|
|
108
|
+
tz: string;
|
|
109
|
+
/** Which level set it — or "FOLDRUN_TIMEZONE", or "default". */
|
|
110
|
+
source: string;
|
|
111
|
+
/** One sentence per unusable value found on the way down. Reported as a
|
|
112
|
+
* step event; never thrown, because a mistyped zone must not stop work. */
|
|
113
|
+
problems: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Walk the levels, nearest first, and take the first zone that works.
|
|
118
|
+
*
|
|
119
|
+
* `FOLDRUN_TIMEZONE` is appended by the caller's level list or read here as
|
|
120
|
+
* the last written level; UTC is what remains when nothing said anything —
|
|
121
|
+
* the clock the pods actually run on.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveClock(levels: ZoneLevel[], env = process.env.FOLDRUN_TIMEZONE): ClockChoice {
|
|
124
|
+
const problems: string[] = [];
|
|
125
|
+
const bad: string[] = [];
|
|
126
|
+
const all = [...levels, { level: "FOLDRUN_TIMEZONE", value: env }];
|
|
127
|
+
for (const { level, value } of all) {
|
|
128
|
+
const written = typeof value === "string" ? value.trim() : "";
|
|
129
|
+
if (!written) continue;
|
|
130
|
+
const zone = normalizeZone(written);
|
|
131
|
+
if (zone) {
|
|
132
|
+
for (const b of bad) problems.push(`${b} — using ${zone.name} (${level}) instead`);
|
|
133
|
+
return { timezone: zone.name, tz: zone.tz, source: level, problems };
|
|
134
|
+
}
|
|
135
|
+
bad.push(`timezone: "${written}" (${level}) is not a zone this runtime knows`);
|
|
136
|
+
}
|
|
137
|
+
for (const b of bad) problems.push(`${b} — using UTC instead`);
|
|
138
|
+
return { timezone: "UTC", tz: "UTC", source: "default", problems };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** YYYY-MM-DD in a timezone — the date the step believes it is. */
|
|
142
|
+
export function localDate(timeZone: string, now = new Date()): string {
|
|
143
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
144
|
+
timeZone,
|
|
145
|
+
year: "numeric",
|
|
146
|
+
month: "2-digit",
|
|
147
|
+
day: "2-digit",
|
|
148
|
+
}).format(now);
|
|
149
|
+
}
|
package/src/completions.ts
CHANGED
|
@@ -68,11 +68,14 @@ const EFFORTS: Completion[] = [
|
|
|
68
68
|
// survive vendor renames — then the exact SDK names, accepted so a Claude Code
|
|
69
69
|
// subagent runs here unchanged.
|
|
70
70
|
const TOOL_GROUPS: Completion[] = [
|
|
71
|
+
// `files`, `bash` and `fetch` are retired (tool-names.ts) and not offered.
|
|
72
|
+
// `web` is our gallery tool, offered here because it is the one way onto
|
|
73
|
+
// the web.
|
|
71
74
|
{ label: "read", hint: "Read, Glob, Grep — may look, never write" },
|
|
72
|
-
{ label: "
|
|
73
|
-
{ label: "
|
|
74
|
-
{ label: "
|
|
75
|
-
{ label: "
|
|
75
|
+
{ label: "write", hint: "Read, Write, Edit, Glob, Grep" },
|
|
76
|
+
{ label: "code", hint: "Bash — runs anything in the sandbox" },
|
|
77
|
+
{ label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
|
|
78
|
+
{ label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
|
|
76
79
|
];
|
|
77
80
|
|
|
78
81
|
const SDK_TOOL_NAMES: Completion[] = [
|
|
@@ -82,8 +85,6 @@ const SDK_TOOL_NAMES: Completion[] = [
|
|
|
82
85
|
{ label: "Glob", hint: "exact SDK name" },
|
|
83
86
|
{ label: "Grep", hint: "exact SDK name" },
|
|
84
87
|
{ label: "Bash", hint: "exact SDK name" },
|
|
85
|
-
{ label: "WebSearch", hint: "exact SDK name" },
|
|
86
|
-
{ label: "WebFetch", hint: "exact SDK name" },
|
|
87
88
|
];
|
|
88
89
|
|
|
89
90
|
const METHODS: Completion[] = ["GET", "POST", "PUT", "PATCH", "DELETE"].map((m) => ({ label: m }));
|
|
@@ -134,14 +135,17 @@ const FIELDS: Record<string, Completion[]> = {
|
|
|
134
135
|
{ label: "description", insert: "description: ", hint: "required — what it does, and when" },
|
|
135
136
|
{ label: "model", insert: "model: default" },
|
|
136
137
|
{ label: "effort", insert: "effort: high", hint: "how hard it thinks" },
|
|
137
|
-
{ label: "tools", insert: "tools:\n -
|
|
138
|
+
{ label: "tools", insert: "tools:\n - write", hint: "what the runtime gives it" },
|
|
138
139
|
{ label: "secrets", insert: "secrets:\n - ", hint: "credentials it may spend" },
|
|
139
140
|
{ label: "skills", insert: "skills:\n - ", hint: "allowlist; omit to inherit all" },
|
|
140
141
|
{ label: "scripts", insert: "scripts:\n - name: \n run: \n description: " },
|
|
141
142
|
{ label: "runtime", insert: "runtime:\n python: \"3\"\n packages: []" },
|
|
142
143
|
{ label: "mcpServers", insert: "mcpServers:\n " },
|
|
144
|
+
{ label: "ask", insert: "ask:\n timeout: 30m", hint: "tools: [ask] — how long ask_person waits for a person (max 24h)" },
|
|
143
145
|
{ label: "permissionMode", insert: "permissionMode: plan", hint: "plan = read-only" },
|
|
144
146
|
{ label: "disallowedTools", insert: "disallowedTools:\n - " },
|
|
147
|
+
{ label: "subagents", insert: "subagents:\n - ", hint: "colleagues it may delegate a job to — own context, own tools, no wider than its own" },
|
|
148
|
+
{ label: "limits", insert: "limits:\n web.search: 40\n calls: 300", hint: "per step: a call past a limit is refused, never run" },
|
|
145
149
|
{
|
|
146
150
|
label: "provider",
|
|
147
151
|
insert: "provider:\n name: \n token: ${PROVIDER_TOKEN}",
|
|
@@ -201,6 +205,8 @@ const FIELDS: Record<string, Completion[]> = {
|
|
|
201
205
|
{ label: "trigger", insert: "trigger: manual" },
|
|
202
206
|
{ label: "schedule", insert: "schedule: \"0 6 * * MON\"" },
|
|
203
207
|
{ label: "timezone", insert: "timezone: Australia/Sydney" },
|
|
208
|
+
{ label: "language", insert: "language: en-AU" },
|
|
209
|
+
{ label: "region", insert: "region: au", hint: "currency, units, calendar, weekend and text direction follow" },
|
|
204
210
|
{ label: "model", insert: "model: fast", hint: "every step, unless the step says" },
|
|
205
211
|
{ label: "effort", insert: "effort: high", hint: "every step, unless the step says" },
|
|
206
212
|
],
|
|
@@ -544,7 +550,7 @@ export function completionsAt(
|
|
|
544
550
|
each: [
|
|
545
551
|
{ label: "lines", hint: "one instance per line of the previous result" },
|
|
546
552
|
{ label: "items", hint: "one per element of the previous output: json array" },
|
|
547
|
-
{ label: "rows of
|
|
553
|
+
{ label: "rows of workspace/storage/x.csv", hint: "one per CSV row" },
|
|
548
554
|
],
|
|
549
555
|
};
|
|
550
556
|
const items = byKey[key];
|
|
@@ -569,6 +575,9 @@ export function completionsAt(
|
|
|
569
575
|
],
|
|
570
576
|
secrets: vocab.secrets.map((s) => ({ label: s, hint: "secret" })),
|
|
571
577
|
skills: vocab.skills.map((s) => ({ label: s, hint: "skill" })),
|
|
578
|
+
// Colleagues: a consult (agents:) or a delegate (subagents:).
|
|
579
|
+
agents: vocab.agents.map((a) => ({ label: a, hint: "consult — answers, no tools" })),
|
|
580
|
+
subagents: vocab.agents.map((a) => ({ label: a, hint: "delegate — own context and tools" })),
|
|
572
581
|
disallowedTools: [...TOOL_GROUPS, { label: "Bash" }, { label: "Write" }],
|
|
573
582
|
when: [{ label: "schema" }, { label: "urgent" }],
|
|
574
583
|
expect: ASSERTIONS.map((a) => ({ ...a, insert: `${a.label}: ` })),
|