@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/step-exec.ts
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// The model loop, extracted to run in two places: the server process (the
|
|
2
|
+
// classic path) and the runner container's driver (the isolated path). One
|
|
3
|
+
// implementation, because the moment these fork, the sandboxed path becomes
|
|
4
|
+
// the less-tested one — backwards from its whole purpose.
|
|
5
|
+
//
|
|
6
|
+
// Everything here takes values, not stores: the caller resolves secrets,
|
|
7
|
+
// assembles the system prompt and builds MCP servers, then hands this the
|
|
8
|
+
// results. That is what lets the same function run somewhere the vault, the
|
|
9
|
+
// library and the account do not exist.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
14
|
+
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
15
|
+
import type { Effort } from "./store.ts";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { checkPaths, checkBash, isFilesystemTool } from "./confine.ts";
|
|
18
|
+
|
|
19
|
+
export interface ExecOutcome {
|
|
20
|
+
status: "completed" | "failed";
|
|
21
|
+
result: string | null;
|
|
22
|
+
/** The LAST text block, which is the model's answer. `result` is every
|
|
23
|
+
* block joined, and the first of those is usually narration before a tool
|
|
24
|
+
* call — so anything wanting "what did this conclude" must read this. */
|
|
25
|
+
conclusion: string | null;
|
|
26
|
+
/** The JSON an `output: json` step returned — parsed, so the next step
|
|
27
|
+
* gets the value and not a re-extraction of it from prose. Undefined for
|
|
28
|
+
* a step that declared no output shape. */
|
|
29
|
+
data?: unknown;
|
|
30
|
+
costUsd: number | null;
|
|
31
|
+
/** Token counts off the SDK's result message. costUsd is priced from
|
|
32
|
+
* Anthropic's table, which is wrong for a routed model — these are the
|
|
33
|
+
* raw numbers a caller with a gateway's own prices can reprice from. */
|
|
34
|
+
usage: { inputTokens: number; outputTokens: number } | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ExecOptions {
|
|
38
|
+
agentDir: string;
|
|
39
|
+
workspaceRoot: string;
|
|
40
|
+
libraryRoot: string;
|
|
41
|
+
prompt: string;
|
|
42
|
+
model: string;
|
|
43
|
+
/** How hard the model thinks before answering — orthogonal to which model
|
|
44
|
+
* it is. Null leaves it to the SDK's own default rather than guessing a
|
|
45
|
+
* level on the author's behalf. */
|
|
46
|
+
effort?: Effort | null;
|
|
47
|
+
systemPrompt: string;
|
|
48
|
+
/** Exact SDK tool names the agent may use. */
|
|
49
|
+
allowed: string[];
|
|
50
|
+
/** MCP server names the agent was granted — tools from these pass. */
|
|
51
|
+
mcpNames: string[];
|
|
52
|
+
mcpServers: Record<string, McpServerConfig>;
|
|
53
|
+
/** The child environment: process env + secrets + provider. */
|
|
54
|
+
env: Record<string, string | undefined>;
|
|
55
|
+
timeoutSec?: number;
|
|
56
|
+
/** The most this step may spend, in USD, before it is stopped mid-turn.
|
|
57
|
+
* The runner derives it from the flow's `budget:` and what the run has
|
|
58
|
+
* already spent; absent means no ceiling on this step. */
|
|
59
|
+
budgetUsd?: number | null;
|
|
60
|
+
/** Price per token for the model, when the catalogue knows it, so spend
|
|
61
|
+
* can be counted turn by turn instead of learned at the end. Without it
|
|
62
|
+
* the step prices its turns from the SDK's own running total. */
|
|
63
|
+
price?: { input: number; output: number } | null;
|
|
64
|
+
/** A check the step must pass to count as done: a shell command that must
|
|
65
|
+
* exit 0, or an eval-style assertion (`contains: x`, `not-contains: x`,
|
|
66
|
+
* `matches: re`, `file: path`, `judge: sentence`) — see checkVerify. */
|
|
67
|
+
verify?: string;
|
|
68
|
+
verifyEnv?: Record<string, string>;
|
|
69
|
+
/** `output: json` — the reply must carry one JSON value; extracting it is
|
|
70
|
+
* part of finishing the step, and failing to is failing the step. */
|
|
71
|
+
output?: "json";
|
|
72
|
+
/** false when the caller is already an isolation boundary (a run
|
|
73
|
+
* container): the SDK's bash sandbox is then redundant and would block
|
|
74
|
+
* declared network use. Default (undefined/true) keeps it on. */
|
|
75
|
+
sandboxBash?: boolean;
|
|
76
|
+
emit: (type: "text" | "tool" | "info" | "error", text: string, extra?: EventExtra) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The pairing fields on a tool event — see RunEvent in store.ts. */
|
|
80
|
+
export type EventExtra = { call?: string; ms?: number; err?: boolean };
|
|
81
|
+
|
|
82
|
+
/** Conservative per-token rates for a model the catalogue cannot price —
|
|
83
|
+
* Opus-class — so a ceiling still means something on an unknown gateway. */
|
|
84
|
+
export const FALLBACK_PRICE = { input: 15e-6, output: 75e-6 };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What one assistant turn cost. Cache traffic is counted as input at the
|
|
88
|
+
* input rate, which over-approximates on gateways with cheaper cache reads
|
|
89
|
+
* — on the money side, the honest direction to be wrong in. Pure.
|
|
90
|
+
*/
|
|
91
|
+
export function priceTurn(u: Record<string, number | undefined>, price: { input: number; output: number } | null): number {
|
|
92
|
+
const inTok = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
|
93
|
+
const outTok = u.output_tokens ?? 0;
|
|
94
|
+
const p = price ?? FALLBACK_PRICE;
|
|
95
|
+
return inTok * p.input + outTok * p.output;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Each step launched together gets an equal share of what is left of the
|
|
100
|
+
* run's budget: the shares sum to the remainder, so a group cannot end
|
|
101
|
+
* over the cap however many steps it fans out to. No budget, no ceiling.
|
|
102
|
+
*/
|
|
103
|
+
export function stepCeiling(budgetUsd: number | null | undefined, spentUsd: number, launching: number): number | null {
|
|
104
|
+
if (!budgetUsd || budgetUsd <= 0) return null;
|
|
105
|
+
return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function executeStep(opts: ExecOptions): Promise<ExecOutcome> {
|
|
109
|
+
const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
|
|
110
|
+
let status: "running" | "completed" | "failed" = "running";
|
|
111
|
+
let costUsd: number | null = null;
|
|
112
|
+
let usage: ExecOutcome["usage"] = null;
|
|
113
|
+
const texts: string[] = [];
|
|
114
|
+
|
|
115
|
+
fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
|
|
116
|
+
fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
|
|
117
|
+
|
|
118
|
+
const q = query({
|
|
119
|
+
prompt: opts.prompt,
|
|
120
|
+
options: {
|
|
121
|
+
cwd: agentDir,
|
|
122
|
+
model: opts.model,
|
|
123
|
+
// Omitted, not passed as undefined-with-a-default: an unset effort
|
|
124
|
+
// should mean "whatever this model does normally", which is not a
|
|
125
|
+
// level we can name — it moves as models ship.
|
|
126
|
+
...(opts.effort ? { effort: opts.effort } : {}),
|
|
127
|
+
systemPrompt: opts.systemPrompt,
|
|
128
|
+
// Restrict the toolset itself, not just approval: an agent that
|
|
129
|
+
// declares no tools gets none, instead of seeing the full Claude
|
|
130
|
+
// Code toolset and burning turns on denied calls.
|
|
131
|
+
tools: opts.allowed,
|
|
132
|
+
// Deliberately NOT allowedTools: listing them there auto-approves and
|
|
133
|
+
// skips canUseTool entirely, which is where confinement happens. The
|
|
134
|
+
// toolset is still restricted by `tools` above.
|
|
135
|
+
settingSources: [],
|
|
136
|
+
// Sandbox the shell — but only when the shell would otherwise run in a
|
|
137
|
+
// process worth protecting. In an isolated run the container IS the
|
|
138
|
+
// boundary (non-root, cap-dropped, host-less, icc-disabled network),
|
|
139
|
+
// so the SDK's own bash sandbox is redundant there and actively harms:
|
|
140
|
+
// it blocks outbound network, which a declared SSH or curl step
|
|
141
|
+
// legitimately needs. So the container relaxes it (opts.sandboxBash =
|
|
142
|
+
// false) and the container's walls do the containing; the in-process
|
|
143
|
+
// path (the CLI on someone's laptop, and the host executor) keeps it,
|
|
144
|
+
// because there a shell escape reaches the real machine.
|
|
145
|
+
sandbox: {
|
|
146
|
+
enabled: opts.sandboxBash !== false,
|
|
147
|
+
// Must stay false. Auto-allowing bash because it's sandboxed skips
|
|
148
|
+
// canUseTool entirely — and the OS sandbox blocks writes outside the
|
|
149
|
+
// tree but still permits reads, so `cat ../../secrets.json` walked
|
|
150
|
+
// straight out. Verified by probe: true leaks, false denies.
|
|
151
|
+
autoAllowBashIfSandboxed: false,
|
|
152
|
+
failIfUnavailable: false,
|
|
153
|
+
},
|
|
154
|
+
env: opts.env,
|
|
155
|
+
mcpServers: opts.mcpServers,
|
|
156
|
+
canUseTool: async (toolName: string, input: Record<string, unknown>) => {
|
|
157
|
+
// The toolset was already narrowed to what the agent declared, so
|
|
158
|
+
// anything outside it is a denial with a reason the model can act on.
|
|
159
|
+
const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
|
|
160
|
+
if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
|
|
161
|
+
return {
|
|
162
|
+
behavior: "deny" as const,
|
|
163
|
+
message: `Tool ${toolName} is not enabled for this agent.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const verdict =
|
|
167
|
+
toolName === "Bash"
|
|
168
|
+
? checkBash(String(input.command ?? ""))
|
|
169
|
+
: isFilesystemTool(toolName)
|
|
170
|
+
? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
|
|
171
|
+
: { ok: true as const };
|
|
172
|
+
if (!verdict.ok) {
|
|
173
|
+
emit("error", verdict.reason!);
|
|
174
|
+
return { behavior: "deny" as const, message: verdict.reason! };
|
|
175
|
+
}
|
|
176
|
+
return { behavior: "allow" as const, updatedInput: verdict.updatedInput ?? input };
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const deadline = opts.timeoutSec ? Date.now() + opts.timeoutSec * 1000 : null;
|
|
182
|
+
// The hard cap. Spend is counted as each assistant turn arrives — its
|
|
183
|
+
// usage is in the message — so the step can stop at the ceiling rather
|
|
184
|
+
// than learn afterwards that it crossed it. Cache traffic is counted as
|
|
185
|
+
// input at the input rate, which over-approximates: on the money side
|
|
186
|
+
// that is the honest direction to be wrong in.
|
|
187
|
+
const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
|
|
188
|
+
let spentUsd = 0;
|
|
189
|
+
|
|
190
|
+
// Open tool calls, by the provider's id, so the result can be paired with
|
|
191
|
+
// its call and the trace can say how long each tool ran.
|
|
192
|
+
const openCalls = new Map<string, { name: string; at: number }>();
|
|
193
|
+
|
|
194
|
+
for await (const message of q) {
|
|
195
|
+
if (deadline && Date.now() > deadline) {
|
|
196
|
+
emit("error", `step exceeded its ${opts.timeoutSec}s timeout`);
|
|
197
|
+
status = "failed";
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (message.type === "assistant") {
|
|
201
|
+
const u = (message.message as unknown as { usage?: Record<string, number | undefined> }).usage;
|
|
202
|
+
if (ceiling && u) {
|
|
203
|
+
spentUsd += priceTurn(u, opts.price ?? null);
|
|
204
|
+
if (spentUsd >= ceiling) {
|
|
205
|
+
emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (budget: in the flow file)`);
|
|
206
|
+
status = "failed";
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const block of message.message.content) {
|
|
211
|
+
if (block.type === "text" && block.text.trim()) {
|
|
212
|
+
texts.push(block.text);
|
|
213
|
+
emit("text", block.text);
|
|
214
|
+
} else if (block.type === "tool_use") {
|
|
215
|
+
openCalls.set(block.id, { name: block.name, at: Date.now() });
|
|
216
|
+
emit("tool", block.name, { call: block.id });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else if (message.type === "user") {
|
|
220
|
+
// Tool results ride back as user turns. The completion event closes
|
|
221
|
+
// the span the call opened; the runner's journal keeps both.
|
|
222
|
+
const content = message.message.content;
|
|
223
|
+
if (Array.isArray(content)) {
|
|
224
|
+
for (const block of content) {
|
|
225
|
+
if (block.type !== "tool_result") continue;
|
|
226
|
+
const open = openCalls.get(block.tool_use_id);
|
|
227
|
+
if (!open) continue;
|
|
228
|
+
openCalls.delete(block.tool_use_id);
|
|
229
|
+
emit("tool", open.name, {
|
|
230
|
+
call: block.tool_use_id,
|
|
231
|
+
ms: Date.now() - open.at,
|
|
232
|
+
...(block.is_error ? { err: true } : {}),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} else if (message.type === "result") {
|
|
237
|
+
status = message.subtype === "success" ? "completed" : "failed";
|
|
238
|
+
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
239
|
+
if ("usage" in message && message.usage) {
|
|
240
|
+
const u = message.usage as unknown as Record<string, number | undefined>;
|
|
241
|
+
usage = {
|
|
242
|
+
// Cache traffic is input the provider still bills (at its own
|
|
243
|
+
// rates); folding it into the input count over-approximates for
|
|
244
|
+
// gateways with cheaper cache reads, which errs on the honest side.
|
|
245
|
+
inputTokens:
|
|
246
|
+
(u.input_tokens ?? 0) +
|
|
247
|
+
(u.cache_creation_input_tokens ?? 0) +
|
|
248
|
+
(u.cache_read_input_tokens ?? 0),
|
|
249
|
+
outputTokens: u.output_tokens ?? 0,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// The SDK ends a healthy run with a `result` message. A stream that just
|
|
255
|
+
// stops — subprocess OOM-killed, crashed, or torn down — used to fall
|
|
256
|
+
// through as "completed", which reported a run that produced nothing as a
|
|
257
|
+
// success. Silence is not success.
|
|
258
|
+
if (status === "running") {
|
|
259
|
+
status = "failed";
|
|
260
|
+
emit(
|
|
261
|
+
"error",
|
|
262
|
+
"the model stream ended without a result — the model process likely died (out of memory?)",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
const result = texts.join("\n").trim() || null;
|
|
266
|
+
// A step's reply is not one message: the model narrates ("Now let me read
|
|
267
|
+
// the outputs…"), calls tools, and answers last. Joining those and reading
|
|
268
|
+
// the first line — which is what the run summary did — reports the plan
|
|
269
|
+
// and never the outcome. The final block is the answer; keep it so the
|
|
270
|
+
// summary has something true to read.
|
|
271
|
+
const conclusion = texts.at(-1)?.trim() || null;
|
|
272
|
+
|
|
273
|
+
// output: json — the declared shape is a contract, checked here where both
|
|
274
|
+
// executors run. A reply with no JSON in it is not "done with a caveat";
|
|
275
|
+
// it is the failure the next step would otherwise inherit as garbage.
|
|
276
|
+
let data: unknown = undefined;
|
|
277
|
+
if (status === "completed" && opts.output === "json") {
|
|
278
|
+
const extracted = extractJson(result);
|
|
279
|
+
if (extracted.ok) {
|
|
280
|
+
data = extracted.value;
|
|
281
|
+
emit("info", `output: json — ${describeJson(data)}`);
|
|
282
|
+
} else {
|
|
283
|
+
emit("error", `output: json — ${extracted.reason}`);
|
|
284
|
+
status = "failed";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// "Done" should mean a check passed, not that the model stopped talking.
|
|
289
|
+
if (status === "completed" && opts.verify) {
|
|
290
|
+
const verdict = await checkVerify(agentDir, opts.verify, {
|
|
291
|
+
env: opts.verifyEnv ?? {},
|
|
292
|
+
result,
|
|
293
|
+
conclusion,
|
|
294
|
+
data,
|
|
295
|
+
modelEnv: opts.env,
|
|
296
|
+
});
|
|
297
|
+
emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`);
|
|
298
|
+
if (verdict.detail.trim()) emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000));
|
|
299
|
+
if (!verdict.ok) status = "failed";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ------------------------------------------------------------ output: json
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The one JSON value a reply carries. A ```json fence wins — it is what the
|
|
309
|
+
* step was asked to write — and the LAST one at that, because a model that
|
|
310
|
+
* shows its working often quotes an earlier draft first. Without a fence,
|
|
311
|
+
* the reply's trailing `{…}` or `[…]` is tried, so a model that answered
|
|
312
|
+
* with bare JSON is not failed for good behaviour.
|
|
313
|
+
*/
|
|
314
|
+
export function extractJson(result: string | null): { ok: true; value: unknown } | { ok: false; reason: string } {
|
|
315
|
+
const text = (result ?? "").trim();
|
|
316
|
+
if (!text) return { ok: false, reason: "the reply was empty" };
|
|
317
|
+
const fences = [...text.matchAll(/```(?:json|JSON)?\s*\n([\s\S]*?)\n\s*```/g)].map((m) => m[1].trim());
|
|
318
|
+
const candidates = fences.length ? fences.reverse() : [];
|
|
319
|
+
if (!candidates.length) {
|
|
320
|
+
// The largest trailing bracketed span: walk back from the end to the
|
|
321
|
+
// last closing bracket, then forward to the matching opener.
|
|
322
|
+
const close = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]"));
|
|
323
|
+
if (close !== -1) {
|
|
324
|
+
const opener = text[close] === "}" ? "{" : "[";
|
|
325
|
+
for (let i = text.indexOf(opener); i !== -1 && i < close; i = text.indexOf(opener, i + 1)) {
|
|
326
|
+
candidates.push(text.slice(i, close + 1));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
let lastError = "no JSON value found in the reply";
|
|
331
|
+
for (const c of candidates) {
|
|
332
|
+
try {
|
|
333
|
+
return { ok: true, value: JSON.parse(c) };
|
|
334
|
+
} catch (err) {
|
|
335
|
+
lastError = `could not parse: ${err instanceof Error ? err.message : String(err)}`;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { ok: false, reason: lastError };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function describeJson(value: unknown): string {
|
|
342
|
+
if (Array.isArray(value)) return `an array of ${value.length}`;
|
|
343
|
+
if (value && typeof value === "object") return `an object with keys ${Object.keys(value).slice(0, 8).join(", ")}`;
|
|
344
|
+
return `a ${typeof value}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ----------------------------------------------------------------- verify:
|
|
348
|
+
|
|
349
|
+
/** The eval vocabulary a `verify:` may borrow. Anything else is a shell command. */
|
|
350
|
+
const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s*([\s\S]+)$/;
|
|
351
|
+
|
|
352
|
+
export interface VerifyVerdict {
|
|
353
|
+
ok: boolean;
|
|
354
|
+
/** One line for the trace: "exit 0", "found", "PASS"… */
|
|
355
|
+
headline: string;
|
|
356
|
+
detail: string;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Decide a `verify:`. Two dialects, one key: an eval assertion, when the
|
|
361
|
+
* value starts with one of the eval file's assertion words, or a shell
|
|
362
|
+
* command. The assertion form exists so that a flow and an eval share a
|
|
363
|
+
* vocabulary — "the output must mention the price" is the same sentence in
|
|
364
|
+
* both places — and so that the commonest checks need no shell at all.
|
|
365
|
+
*/
|
|
366
|
+
export async function checkVerify(
|
|
367
|
+
agentDir: string,
|
|
368
|
+
verify: string,
|
|
369
|
+
ctx: {
|
|
370
|
+
env: Record<string, string>;
|
|
371
|
+
result: string | null;
|
|
372
|
+
/** The final turn — what the step concluded. `contains:`, `not-contains:`
|
|
373
|
+
* and `matches:` test this, because it is also what the run's headline
|
|
374
|
+
* is read from: a reporter that narrated between tool calls ("Now let
|
|
375
|
+
* me write the report…") failed `matches: ^BAD\b` with a correct
|
|
376
|
+
* headline while the check read every turn joined. `judge:` still grades
|
|
377
|
+
* the whole result. */
|
|
378
|
+
conclusion?: string | null;
|
|
379
|
+
data?: unknown;
|
|
380
|
+
/** The step's own model environment, for `judge:` — it grades on the
|
|
381
|
+
* fast tier through the same credential the step rode. */
|
|
382
|
+
modelEnv?: Record<string, string | undefined>;
|
|
383
|
+
},
|
|
384
|
+
): Promise<VerifyVerdict> {
|
|
385
|
+
const m = verify.trim().match(VERIFY_ASSERTION);
|
|
386
|
+
if (!m) {
|
|
387
|
+
const { code, out } = await runVerify(agentDir, verify, ctx.env, ctx.data);
|
|
388
|
+
return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
|
|
389
|
+
}
|
|
390
|
+
const [, kind, rawValue] = m;
|
|
391
|
+
const value = rawValue.trim().replace(/^["']|["']$/g, "");
|
|
392
|
+
const output = (kind === "judge" ? ctx.result : (ctx.conclusion ?? ctx.result)) ?? "";
|
|
393
|
+
const hay = output.toLowerCase();
|
|
394
|
+
switch (kind) {
|
|
395
|
+
case "contains": {
|
|
396
|
+
const ok = hay.includes(value.toLowerCase());
|
|
397
|
+
return { ok, headline: ok ? "found" : `"${value}" not in the reply`, detail: "" };
|
|
398
|
+
}
|
|
399
|
+
case "not-contains": {
|
|
400
|
+
const ok = !hay.includes(value.toLowerCase());
|
|
401
|
+
return { ok, headline: ok ? "absent" : `"${value}" appeared in the reply`, detail: "" };
|
|
402
|
+
}
|
|
403
|
+
case "matches": {
|
|
404
|
+
try {
|
|
405
|
+
const ok = new RegExp(value, "i").test(output);
|
|
406
|
+
return { ok, headline: ok ? "matched" : "no match", detail: "" };
|
|
407
|
+
} catch {
|
|
408
|
+
return { ok: false, headline: "invalid regular expression", detail: "" };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
case "file": {
|
|
412
|
+
const target = path.resolve(agentDir, value);
|
|
413
|
+
if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
|
|
414
|
+
return { ok: false, headline: "path escapes the agent directory", detail: "" };
|
|
415
|
+
}
|
|
416
|
+
const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
|
|
417
|
+
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
418
|
+
}
|
|
419
|
+
case "judge": {
|
|
420
|
+
const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
|
|
421
|
+
const ok = /^\s*PASS\b/i.test(verdict);
|
|
422
|
+
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* A toolless, fast-tier grading call: does the reply satisfy the sentence?
|
|
430
|
+
* Same shape as the eval judge, and answered with one word first so the
|
|
431
|
+
* verdict is a prefix test rather than a reading.
|
|
432
|
+
*/
|
|
433
|
+
async function judgeReply(
|
|
434
|
+
rubric: string,
|
|
435
|
+
output: string,
|
|
436
|
+
env: Record<string, string | undefined>,
|
|
437
|
+
): Promise<string> {
|
|
438
|
+
const texts: string[] = [];
|
|
439
|
+
try {
|
|
440
|
+
const q = query({
|
|
441
|
+
prompt:
|
|
442
|
+
`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
443
|
+
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
444
|
+
`<reply>\n${output.slice(0, 40_000)}\n</reply>`,
|
|
445
|
+
options: {
|
|
446
|
+
model: "haiku",
|
|
447
|
+
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
448
|
+
tools: [],
|
|
449
|
+
settingSources: [],
|
|
450
|
+
env,
|
|
451
|
+
maxTurns: 1,
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
for await (const message of q) {
|
|
455
|
+
if (message.type === "assistant") {
|
|
456
|
+
for (const block of message.message.content) {
|
|
457
|
+
if (block.type === "text") texts.push(block.text);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch (err) {
|
|
462
|
+
return `FAIL — the judge could not run: ${err instanceof Error ? err.message : String(err)}`;
|
|
463
|
+
}
|
|
464
|
+
return texts.join("\n").trim();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Verification runs in the agent's directory with its secrets available, so
|
|
468
|
+
// a check can be as simple as `npm run build` or `test -s outputs/report.md`.
|
|
469
|
+
function runVerify(
|
|
470
|
+
agentDir: string,
|
|
471
|
+
command: string,
|
|
472
|
+
env: Record<string, string>,
|
|
473
|
+
data?: unknown,
|
|
474
|
+
): Promise<{ code: number | null; out: string }> {
|
|
475
|
+
return new Promise((resolve) => {
|
|
476
|
+
// No clock of the platform's: a verify like `npm run build` takes what
|
|
477
|
+
// it takes, and the step's own `timeout:` is the bound if the flow set one.
|
|
478
|
+
const child = spawn("bash", ["-lc", command], {
|
|
479
|
+
cwd: agentDir,
|
|
480
|
+
env: { ...process.env, ...env },
|
|
481
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
482
|
+
});
|
|
483
|
+
// An `output: json` step's data arrives on stdin, so a check can be
|
|
484
|
+
// `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
|
|
485
|
+
// the step actually returned rather than re-parsing its prose.
|
|
486
|
+
child.stdin.on("error", () => {});
|
|
487
|
+
if (data !== undefined) child.stdin.end(JSON.stringify(data));
|
|
488
|
+
else child.stdin.end();
|
|
489
|
+
let out = "";
|
|
490
|
+
const append = (c: Buffer) => {
|
|
491
|
+
if (out.length < 4000) out += c.toString();
|
|
492
|
+
};
|
|
493
|
+
child.stdout.on("data", append);
|
|
494
|
+
child.stderr.on("data", append);
|
|
495
|
+
child.on("error", (e) => resolve({ code: null, out: e.message }));
|
|
496
|
+
child.on("close", (code) => resolve({ code, out }));
|
|
497
|
+
});
|
|
498
|
+
}
|