@foldrun/core 2.4.0 → 2.6.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/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +26 -3
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +237 -13
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +27 -2
- package/dist/src/evals.js +62 -19
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +60 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +33 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +42 -0
- package/dist/src/matter.js +132 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -1
- package/dist/src/network.js +35 -8
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +9 -2
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +2 -2
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +25 -2
- package/dist/src/run-container.js +61 -6
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +471 -102
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +30 -1
- package/dist/src/scan.js +145 -14
- package/dist/src/starter.js +27 -2
- package/dist/src/step-exec.d.ts +39 -3
- package/dist/src/step-exec.js +118 -15
- package/dist/src/store.d.ts +144 -3
- package/dist/src/store.js +150 -7
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +27 -3
- package/src/deploy.ts +238 -12
- package/src/egress.ts +12 -2
- package/src/evals.ts +64 -19
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +83 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +33 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/judge-verdict.ts +52 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +145 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +35 -7
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +8 -2
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +2 -2
- package/src/providers.ts +7 -2
- package/src/run-container.ts +77 -8
- package/src/runner.ts +477 -79
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +153 -14
- package/src/starter.ts +27 -2
- package/src/step-exec.ts +168 -17
- package/src/store.ts +268 -9
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
package/dist/src/otel.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A run as an OpenTelemetry trace — OTLP/HTTP JSON, the body a collector's
|
|
2
|
+
// /v1/traces takes — so an account's agent runs sit in the tracing it
|
|
3
|
+
// already has (Grafana Tempo, Honeycomb, Datadog, Jaeger, Langfuse…) beside
|
|
4
|
+
// the services they act on. Built from buildTraceTree, the same tree the run
|
|
5
|
+
// page draws: run → step → model call → tool call.
|
|
6
|
+
//
|
|
7
|
+
// telemetry:
|
|
8
|
+
// otlp: https://otel.example.com/v1/traces
|
|
9
|
+
// headers:
|
|
10
|
+
// authorization: ${OTEL_TOKEN} # a vault secret, by name
|
|
11
|
+
//
|
|
12
|
+
// In AGENTS.md (the workspace's, else the account's — the block replaces
|
|
13
|
+
// whole, like notify:). Ids are derived from the run and span ids, so a run
|
|
14
|
+
// sent twice is the same trace, not two. No OpenTelemetry SDK: the wire
|
|
15
|
+
// format is small, and a dependency in every runner image is not.
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { buildTraceTree } from "./trace-tree.js";
|
|
18
|
+
export { parseTelemetry, telemetrySecretNames, telemetryHeaders } from "./otel-config.js";
|
|
19
|
+
const attr = (key, v) => {
|
|
20
|
+
if (v === null || v === undefined || v === "")
|
|
21
|
+
return [];
|
|
22
|
+
if (typeof v === "boolean")
|
|
23
|
+
return [{ key, value: { boolValue: v } }];
|
|
24
|
+
if (typeof v === "number")
|
|
25
|
+
return [{ key, value: Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v } }];
|
|
26
|
+
return [{ key, value: { stringValue: v } }];
|
|
27
|
+
};
|
|
28
|
+
const hex = (s, len) => crypto.createHash("sha256").update(s).digest("hex").slice(0, len);
|
|
29
|
+
const nanos = (msEpoch) => (BigInt(Math.round(msEpoch)) * BigInt(1_000_000)).toString();
|
|
30
|
+
/** OTLP span kinds: INTERNAL, and CLIENT for a call that leaves the platform. */
|
|
31
|
+
const SPAN_KIND = { internal: 1, client: 3 };
|
|
32
|
+
/** The ExportTraceServiceRequest for one run. */
|
|
33
|
+
export function runToOtlp(run, ctx, now = Date.now()) {
|
|
34
|
+
const tree = buildTraceTree(run, now);
|
|
35
|
+
const traceId = hex(`foldrun-trace\0${ctx.tenant}\0${ctx.workspace}\0${run.id}`, 32);
|
|
36
|
+
const spans = [];
|
|
37
|
+
const visit = (s, parent, inherited) => {
|
|
38
|
+
const spanId = hex(`${traceId}\0${s.id}`, 16);
|
|
39
|
+
const start = s.start ?? inherited.start;
|
|
40
|
+
const end = Math.max(start, s.end ?? s.start ?? now);
|
|
41
|
+
const step = s.step !== undefined ? run.steps[s.step] : undefined;
|
|
42
|
+
const agent = s.kind === "step" ? step?.agent : inherited.agent;
|
|
43
|
+
spans.push({
|
|
44
|
+
traceId,
|
|
45
|
+
spanId,
|
|
46
|
+
...(parent ? { parentSpanId: parent } : {}),
|
|
47
|
+
name: s.kind === "run" ? `run ${s.label}` : s.kind === "step" ? `step ${s.label}` : s.kind === "model" ? `chat ${s.model ?? s.label}` : s.kind === "tool" ? `tool ${s.label}` : s.label,
|
|
48
|
+
kind: s.kind === "model" ? SPAN_KIND.client : SPAN_KIND.internal,
|
|
49
|
+
startTimeUnixNano: nanos(start),
|
|
50
|
+
endTimeUnixNano: nanos(end),
|
|
51
|
+
attributes: [
|
|
52
|
+
...attr("foldrun.span.kind", s.kind),
|
|
53
|
+
...attr("foldrun.status", s.status),
|
|
54
|
+
...(s.kind === "step" ? [...attr("foldrun.agent", agent), ...attr("foldrun.owner", agent ? ctx.owners?.[agent] ?? null : null), ...attr("foldrun.step", s.step)] : []),
|
|
55
|
+
...attr("gen_ai.request.model", s.model),
|
|
56
|
+
...(s.tokens ? [...attr("gen_ai.usage.input_tokens", s.tokens.input), ...attr("gen_ai.usage.output_tokens", s.tokens.output), ...attr("gen_ai.usage.cache_read_tokens", s.tokens.cacheRead), ...attr("gen_ai.usage.cache_write_tokens", s.tokens.cacheWrite)] : []),
|
|
57
|
+
...attr("gen_ai.response.finish_reason", s.stop),
|
|
58
|
+
...(typeof s.costUsd === "number" ? attr("foldrun.cost_usd", s.costUsd) : []),
|
|
59
|
+
...(s.kind === "tool" ? [...attr("gen_ai.tool.name", s.label), ...attr("gen_ai.tool.call.id", s.call)] : []),
|
|
60
|
+
...attr("foldrun.subagent", s.subagent),
|
|
61
|
+
],
|
|
62
|
+
status: s.status === "error" ? { code: 2, ...(s.text ? { message: s.text.slice(0, 500) } : {}) } : { code: 0 },
|
|
63
|
+
});
|
|
64
|
+
for (const c of s.children)
|
|
65
|
+
visit(c, spanId, { start, agent });
|
|
66
|
+
};
|
|
67
|
+
visit(tree, null, { start: Date.parse(run.startedAt) || now });
|
|
68
|
+
return {
|
|
69
|
+
resourceSpans: [
|
|
70
|
+
{
|
|
71
|
+
resource: {
|
|
72
|
+
attributes: [
|
|
73
|
+
...attr("service.name", "foldrun"),
|
|
74
|
+
...attr("foldrun.account", ctx.tenant),
|
|
75
|
+
...attr("foldrun.workspace", ctx.workspace),
|
|
76
|
+
...attr("foldrun.flow", run.flow),
|
|
77
|
+
...attr("foldrun.run_id", run.id),
|
|
78
|
+
...attr("foldrun.started_by", run.startedBy ?? null),
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
scopeSpans: [{ scope: { name: "foldrun", ...(ctx.version ? { version: ctx.version } : {}) }, spans }],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type Outcome = "running" | "queued" | "waiting" | "good" | "done" | "quiet" | "blocked" | "bad" | "failed" | "stopped";
|
|
2
|
+
export declare const OUTCOMES: readonly Outcome[];
|
|
3
|
+
/** From the display status (store.runDisplayStatus) and the verdict (store.runVerdict). */
|
|
4
|
+
export declare function outcomeOf(status: string, verdict: string | null | undefined): Outcome;
|
|
5
|
+
/** Still going: not an outcome yet. */
|
|
6
|
+
export declare const isLive: (o: Outcome) => o is "queued" | "running" | "waiting";
|
|
7
|
+
/** Ran to the end without failing — whatever it found. */
|
|
8
|
+
export declare const isCompleted: (o: Outcome) => o is "bad" | "blocked" | "done" | "good" | "quiet";
|
|
9
|
+
/** Over and countable for a rate: completed or failed. A run a person stopped is left out. */
|
|
10
|
+
export declare const isFinished: (o: Outcome) => o is "bad" | "blocked" | "done" | "failed" | "good" | "quiet";
|
|
11
|
+
/** Went wrong or found something wrong: failed, BAD, BLOCKED. */
|
|
12
|
+
export declare const needsAttention: (o: Outcome) => o is "bad" | "blocked" | "failed";
|
|
13
|
+
/** A person has something to do: what needsAttention says, and a run
|
|
14
|
+
* waiting on an approval or an answer (Codex review). */
|
|
15
|
+
export declare const needsAction: (o: Outcome) => o is "bad" | "blocked" | "failed" | "waiting";
|
|
16
|
+
/** Completed and reported nothing wrong (GOOD, QUIET, or nothing said). */
|
|
17
|
+
export declare const isSuccess: (o: Outcome) => o is "done" | "good" | "quiet";
|
|
18
|
+
export interface Tally {
|
|
19
|
+
/** Every run counted, live ones included. */
|
|
20
|
+
runs: number;
|
|
21
|
+
/** Running, queued, or waiting on a person. */
|
|
22
|
+
live: number;
|
|
23
|
+
/** Waiting on an approval or an answer. */
|
|
24
|
+
waiting: number;
|
|
25
|
+
stopped: number;
|
|
26
|
+
/** Completed + failed: the runs a rate is taken over. */
|
|
27
|
+
finished: number;
|
|
28
|
+
/** Ran to the end without failing. */
|
|
29
|
+
completed: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
/** What the completed runs found. */
|
|
32
|
+
ok: number;
|
|
33
|
+
quiet: number;
|
|
34
|
+
blocked: number;
|
|
35
|
+
bad: number;
|
|
36
|
+
/** completed / finished, as a whole percent; null with nothing finished. */
|
|
37
|
+
completedPct: number | null;
|
|
38
|
+
}
|
|
39
|
+
/** The one count of a set of outcomes that every surface shows. */
|
|
40
|
+
export declare function tally(outcomes: Iterable<Outcome>): Tally;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A run's outcome, and the one way every surface counts outcomes (FOL-54).
|
|
2
|
+
//
|
|
3
|
+
// A run is read on three separate lines, never folded into one "success":
|
|
4
|
+
// · execution — did it run to the end without failing? (completed, failed,
|
|
5
|
+
// stopped by a person, or still live)
|
|
6
|
+
// · result — what the work concluded: GOOD, QUIET, BLOCKED, BAD, or
|
|
7
|
+
// nothing said (done)
|
|
8
|
+
// · action — does a person need to do something? (an approval or a
|
|
9
|
+
// question waiting, a failure, a BLOCKED, a BAD finding)
|
|
10
|
+
//
|
|
11
|
+
// Before this, five surfaces computed five different "success rates" (one
|
|
12
|
+
// counted BAD as success, one counted stopped runs as failures, …), so the
|
|
13
|
+
// same desk read four different percentages. Every page, the API and the
|
|
14
|
+
// CLI now read `tally()`. Pure: no node imports, so the browser can use it.
|
|
15
|
+
export const OUTCOMES = ["running", "queued", "waiting", "good", "done", "quiet", "blocked", "bad", "failed", "stopped"];
|
|
16
|
+
/** From the display status (store.runDisplayStatus) and the verdict (store.runVerdict). */
|
|
17
|
+
export function outcomeOf(status, verdict) {
|
|
18
|
+
if (status === "running")
|
|
19
|
+
return "running";
|
|
20
|
+
if (status === "queued")
|
|
21
|
+
return "queued";
|
|
22
|
+
if (status === "awaiting-approval")
|
|
23
|
+
return "waiting";
|
|
24
|
+
if (status === "stopped")
|
|
25
|
+
return "stopped";
|
|
26
|
+
if (status === "failed")
|
|
27
|
+
return "failed";
|
|
28
|
+
switch (verdict) {
|
|
29
|
+
case "GOOD": return "good";
|
|
30
|
+
case "QUIET": return "quiet";
|
|
31
|
+
case "BLOCKED": return "blocked";
|
|
32
|
+
case "BAD": return "bad";
|
|
33
|
+
default: return "done";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Still going: not an outcome yet. */
|
|
37
|
+
export const isLive = (o) => o === "running" || o === "queued" || o === "waiting";
|
|
38
|
+
/** Ran to the end without failing — whatever it found. */
|
|
39
|
+
export const isCompleted = (o) => o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad";
|
|
40
|
+
/** Over and countable for a rate: completed or failed. A run a person stopped is left out. */
|
|
41
|
+
export const isFinished = (o) => isCompleted(o) || o === "failed";
|
|
42
|
+
/** Went wrong or found something wrong: failed, BAD, BLOCKED. */
|
|
43
|
+
export const needsAttention = (o) => o === "failed" || o === "bad" || o === "blocked";
|
|
44
|
+
/** A person has something to do: what needsAttention says, and a run
|
|
45
|
+
* waiting on an approval or an answer (Codex review). */
|
|
46
|
+
export const needsAction = (o) => needsAttention(o) || o === "waiting";
|
|
47
|
+
/** Completed and reported nothing wrong (GOOD, QUIET, or nothing said). */
|
|
48
|
+
export const isSuccess = (o) => o === "good" || o === "done" || o === "quiet";
|
|
49
|
+
/** The one count of a set of outcomes that every surface shows. */
|
|
50
|
+
export function tally(outcomes) {
|
|
51
|
+
const t = { runs: 0, live: 0, waiting: 0, stopped: 0, finished: 0, completed: 0, failed: 0, ok: 0, quiet: 0, blocked: 0, bad: 0, completedPct: null };
|
|
52
|
+
for (const o of outcomes) {
|
|
53
|
+
t.runs++;
|
|
54
|
+
if (isLive(o))
|
|
55
|
+
t.live++;
|
|
56
|
+
if (o === "waiting")
|
|
57
|
+
t.waiting++;
|
|
58
|
+
if (o === "stopped")
|
|
59
|
+
t.stopped++;
|
|
60
|
+
if (isFinished(o))
|
|
61
|
+
t.finished++;
|
|
62
|
+
if (isCompleted(o))
|
|
63
|
+
t.completed++;
|
|
64
|
+
if (o === "failed")
|
|
65
|
+
t.failed++;
|
|
66
|
+
if (o === "good" || o === "done")
|
|
67
|
+
t.ok++;
|
|
68
|
+
if (o === "quiet")
|
|
69
|
+
t.quiet++;
|
|
70
|
+
if (o === "blocked")
|
|
71
|
+
t.blocked++;
|
|
72
|
+
if (o === "bad")
|
|
73
|
+
t.bad++;
|
|
74
|
+
}
|
|
75
|
+
t.completedPct = t.finished ? Math.round((100 * t.completed) / t.finished) : null;
|
|
76
|
+
return t;
|
|
77
|
+
}
|
package/dist/src/package.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// claim, the files are what will run.
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
|
-
import matter from "
|
|
28
|
+
import matter, { configMatter } from "./matter.js";
|
|
29
29
|
import { assertSafeName, isEditablePath, listWorkspaceFiles, listWorkspaces, listFlows, parseFlow, readWorkspaceFile, saveWorkspace, blankTemplateFiles, syncBundleFor, notifyWorkspaceChanged, workspaceDir, } from "./store.js";
|
|
30
30
|
import { recordRevision } from "./history.js";
|
|
31
31
|
import { libraryDir, libraryTools } from "./library.js";
|
|
@@ -522,7 +522,14 @@ function safeFront(content) {
|
|
|
522
522
|
}
|
|
523
523
|
}
|
|
524
524
|
function frontOf(content) {
|
|
525
|
-
|
|
525
|
+
// An agent's secrets: and tools: may sit in its guardrail: block.
|
|
526
|
+
let d;
|
|
527
|
+
try {
|
|
528
|
+
d = configMatter(content).data;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
d = {};
|
|
532
|
+
}
|
|
526
533
|
const apis = Array.isArray(d.apis) ? d.apis.map((a) => (a && typeof a === "object" ? String(a.name ?? "") : "")) : [];
|
|
527
534
|
const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
|
|
528
535
|
const scripts = list(d.scripts);
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type TriggerAdmission = {
|
|
|
14
14
|
admit: true;
|
|
15
15
|
} | {
|
|
16
16
|
admit: false;
|
|
17
|
-
reason: "duplicate" | "throttled" | "quarantined" | "paused" | "debounced";
|
|
17
|
+
reason: "duplicate" | "throttled" | "quarantined" | "paused" | "halted" | "debounced";
|
|
18
18
|
detail: string;
|
|
19
19
|
firesAt?: number;
|
|
20
20
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PromptLayerRecord } from "./tokens.ts";
|
|
2
|
+
export { promptLayersChanged, promptCacheNote, type PromptLayerRecord } from "./tokens.ts";
|
|
3
|
+
export interface AssembledPrompt {
|
|
4
|
+
/** The whole prompt, stable layers first. */
|
|
5
|
+
text: string;
|
|
6
|
+
layers: PromptLayerRecord[];
|
|
7
|
+
}
|
|
8
|
+
export declare function hashLayer(text: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* A collector agentContext fills in whatever order it computes its
|
|
11
|
+
* sections. `add` with the same name twice appends to that layer, so a
|
|
12
|
+
* section built in two places (the platform's rules) is one layer.
|
|
13
|
+
*/
|
|
14
|
+
export declare class PromptLayers {
|
|
15
|
+
private readonly order;
|
|
16
|
+
private readonly text;
|
|
17
|
+
private readonly dynamic;
|
|
18
|
+
add(name: string, text: string, opts?: {
|
|
19
|
+
dynamic?: boolean;
|
|
20
|
+
}): void;
|
|
21
|
+
assemble(): AssembledPrompt;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The key an OpenAI-format provider routes a prompt by (`prompt_cache_key`).
|
|
25
|
+
* One per agent: every step of an agent opens with the same prefix, and a
|
|
26
|
+
* key that names the account keeps one tenant's cache from being probed
|
|
27
|
+
* through another's requests. Hashed, since the names are a person's and
|
|
28
|
+
* the parameter is capped at 64 characters.
|
|
29
|
+
*/
|
|
30
|
+
export declare function promptCacheKey(tenant: string, workspace: string, agent: string): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The system prompt in layers, ordered for the model's prompt cache.
|
|
2
|
+
//
|
|
3
|
+
// Every provider caches a prompt by its prefix: a request whose opening
|
|
4
|
+
// bytes match one it has seen reads the model's work on them instead of
|
|
5
|
+
// paying for it again, and a byte that differs invalidates everything
|
|
6
|
+
// after it. agentContext used to join its sections in the order it
|
|
7
|
+
// computed them, which put the memory index, the state file list and the
|
|
8
|
+
// clock — the three things that change between runs — in the middle.
|
|
9
|
+
//
|
|
10
|
+
// This collector keeps the sections apart by how often they change and
|
|
11
|
+
// assembles them stable first, dynamic last: what Anthropic does for
|
|
12
|
+
// Claude Code ("static content first, dynamic content last"). The prompt
|
|
13
|
+
// still goes to the SDK as one string, cached as one block. The Agent
|
|
14
|
+
// SDK's SYSTEM_PROMPT_DYNAMIC_BOUNDARY would split it, but the CLI honours
|
|
15
|
+
// the marker only when it talks to api.anthropic.com directly — a step
|
|
16
|
+
// behind the egress proxy got one block anyway (live check, 20 Oct 2026;
|
|
17
|
+
// claude-code issue #102518) — and where it does split, it sends the
|
|
18
|
+
// stable half with the CLI's "global" cache scope, which is not a scope to
|
|
19
|
+
// put an account's own prose in. So no marker.
|
|
20
|
+
//
|
|
21
|
+
// Each layer is also hashed, and the hashes ride on the step record
|
|
22
|
+
// (`promptLayers`). A step whose cache writes are high can then say which
|
|
23
|
+
// layer changed since the agent's last step — "memory changed" — instead
|
|
24
|
+
// of leaving a person to diff two 40k-character prompts.
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
// The record's shape and the readers' comparison live in tokens.ts, which
|
|
27
|
+
// has no node imports and is what the dashboard's client bundle reads.
|
|
28
|
+
export { promptLayersChanged, promptCacheNote } from "./tokens.js";
|
|
29
|
+
const SEP = "\n\n";
|
|
30
|
+
export function hashLayer(text) {
|
|
31
|
+
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A collector agentContext fills in whatever order it computes its
|
|
35
|
+
* sections. `add` with the same name twice appends to that layer, so a
|
|
36
|
+
* section built in two places (the platform's rules) is one layer.
|
|
37
|
+
*/
|
|
38
|
+
export class PromptLayers {
|
|
39
|
+
order = [];
|
|
40
|
+
text = new Map();
|
|
41
|
+
dynamic = new Set();
|
|
42
|
+
add(name, text, opts = {}) {
|
|
43
|
+
if (!text)
|
|
44
|
+
return;
|
|
45
|
+
if (!this.text.has(name)) {
|
|
46
|
+
this.order.push(name);
|
|
47
|
+
this.text.set(name, []);
|
|
48
|
+
}
|
|
49
|
+
this.text.get(name).push(text);
|
|
50
|
+
if (opts.dynamic)
|
|
51
|
+
this.dynamic.add(name);
|
|
52
|
+
}
|
|
53
|
+
assemble() {
|
|
54
|
+
const parts = [];
|
|
55
|
+
const layers = [];
|
|
56
|
+
const take = (names, isDynamic) => {
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const joined = this.text.get(name).join(SEP);
|
|
59
|
+
parts.push(joined);
|
|
60
|
+
layers.push({ name, chars: joined.length, hash: hashLayer(joined), ...(isDynamic ? { stable: false } : {}) });
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
take(this.order.filter((n) => !this.dynamic.has(n)), false);
|
|
64
|
+
take(this.order.filter((n) => this.dynamic.has(n)), true);
|
|
65
|
+
return { text: parts.join(SEP), layers };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The key an OpenAI-format provider routes a prompt by (`prompt_cache_key`).
|
|
70
|
+
* One per agent: every step of an agent opens with the same prefix, and a
|
|
71
|
+
* key that names the account keeps one tenant's cache from being probed
|
|
72
|
+
* through another's requests. Hashed, since the names are a person's and
|
|
73
|
+
* the parameter is capped at 64 characters.
|
|
74
|
+
*/
|
|
75
|
+
export function promptCacheKey(tenant, workspace, agent) {
|
|
76
|
+
return `fr-${crypto.createHash("sha256").update(`${tenant}\u0000${workspace}\u0000${agent}`).digest("hex").slice(0, 32)}`;
|
|
77
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { accountModelBlock } from "./model-credential.js";
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
|
-
import
|
|
23
|
+
import { configMatter } from "./matter.js";
|
|
24
24
|
import { getSecret } from "./secrets.js";
|
|
25
25
|
import { noteSecretUse, healthKey } from "./secret-health.js";
|
|
26
26
|
import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider } from "./store.js";
|
|
@@ -168,7 +168,7 @@ function cheapestModel(spec) {
|
|
|
168
168
|
}
|
|
169
169
|
function frontmatterOf(file) {
|
|
170
170
|
try {
|
|
171
|
-
return (
|
|
171
|
+
return (configMatter(fs.readFileSync(file, "utf8")).data ?? {});
|
|
172
172
|
}
|
|
173
173
|
catch {
|
|
174
174
|
return {};
|
package/dist/src/providers.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ export interface ProviderPreset {
|
|
|
20
20
|
* Off means the translator drops thinking rather than send a parameter
|
|
21
21
|
* the endpoint would reject. */
|
|
22
22
|
reasoningEffort?: boolean;
|
|
23
|
+
/** Whether the endpoint takes `prompt_cache_key` — a hint that requests
|
|
24
|
+
* with the same key share a prompt prefix, so its cache can be routed
|
|
25
|
+
* to. Only set where the parameter is documented; an endpoint that does
|
|
26
|
+
* not know it may refuse the request. */
|
|
27
|
+
promptCacheKey?: boolean;
|
|
23
28
|
/** One line a person should read before relying on it. */
|
|
24
29
|
note?: string;
|
|
25
30
|
/** A tool loop was driven through this endpoint from this runtime. */
|
package/dist/src/providers.js
CHANGED
|
@@ -222,7 +222,7 @@ export const PROVIDERS = [
|
|
|
222
222
|
note: "base_url is your server (default :8000). Start it with --enable-auto-tool-choice and a --tool-call-parser or no tool loop closes." },
|
|
223
223
|
// ------------------------------------------- Chat-Completions, translated
|
|
224
224
|
{ name: "openai", title: "OpenAI", format: "openai", baseUrl: "https://api.openai.com/v1", auth: "bearer", verified: true,
|
|
225
|
-
maxTokensParam: "max_completion_tokens", reasoningEffort: true,
|
|
225
|
+
maxTokensParam: "max_completion_tokens", reasoningEffort: true, promptCacheKey: true,
|
|
226
226
|
note: "Reached over Chat Completions, which OpenAI keeps supporting; the Responses API is not spoken here, so a reasoning model's reasoning does not carry across tool calls.", search: "openai" },
|
|
227
227
|
{ name: "gemini", title: "Google Gemini", format: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", auth: "bearer",
|
|
228
228
|
note: "Google's OpenAI-compatible route. Unknown parameters are ignored silently; reasoning cannot be switched off on the newest models." },
|
|
@@ -230,7 +230,7 @@ export const PROVIDERS = [
|
|
|
230
230
|
maxTokensParam: "max_completion_tokens", reasoningEffort: true },
|
|
231
231
|
{ name: "groq", title: "Groq", format: "openai", baseUrl: "https://api.groq.com/openai/v1", auth: "bearer",
|
|
232
232
|
maxTokensParam: "max_completion_tokens", reasoningEffort: true },
|
|
233
|
-
{ name: "mistral", title: "Mistral", format: "openai", baseUrl: "https://api.mistral.ai/v1", auth: "bearer" },
|
|
233
|
+
{ name: "mistral", title: "Mistral", format: "openai", baseUrl: "https://api.mistral.ai/v1", auth: "bearer", promptCacheKey: true },
|
|
234
234
|
{ name: "together", title: "Together AI", format: "openai", baseUrl: "https://api.together.ai/v1", auth: "bearer" },
|
|
235
235
|
{ name: "cerebras", title: "Cerebras", format: "openai", baseUrl: "https://api.cerebras.ai/v1", auth: "bearer",
|
|
236
236
|
maxTokensParam: "max_completion_tokens", reasoningEffort: true },
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import type { PeerInfo } from "./operator.ts";
|
|
1
2
|
import { type HookRecord } from "./hooks.ts";
|
|
2
3
|
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
-
import { type ApiSpec, type Effort } from "./store.ts";
|
|
4
|
-
import type { EventExtra, ExecUsage } from "./step-exec.ts";
|
|
4
|
+
import { type ApiSpec, type Effort, type ModelCall } from "./store.ts";
|
|
5
|
+
import type { CallIo, EventExtra, ExecUsage } from "./step-exec.ts";
|
|
5
6
|
import type { TokenPrice } from "./catalog.ts";
|
|
6
7
|
import { type RuntimeSpec } from "./runtime.ts";
|
|
7
8
|
import { type NetworkLevel } from "./network.ts";
|
|
@@ -44,11 +45,16 @@ export interface ContainerStepInput {
|
|
|
44
45
|
budgetNote?: string;
|
|
45
46
|
price?: TokenPrice | null;
|
|
46
47
|
verify?: string;
|
|
48
|
+
/** The model `verify: judge:` grades with (the workspace's `judge:`). */
|
|
49
|
+
judgeModel?: string;
|
|
50
|
+
judgeEffort?: string;
|
|
47
51
|
/** `output: json` — checked inside, where the reply is. */
|
|
48
52
|
output?: "json";
|
|
49
53
|
/** `schema:` and `max_turns:`, resolved host-side — see ExecOptions. */
|
|
50
54
|
schema?: Record<string, unknown> | boolean;
|
|
51
55
|
maxTurns?: number;
|
|
56
|
+
/** `capture:` resolved host-side (FOL-54 step 4) — see ExecOptions. */
|
|
57
|
+
capture?: "metadata" | "full";
|
|
52
58
|
/** `limits:` resolved host-side, and the SDK-name → foldrun-name map the
|
|
53
59
|
* count reads (limits.ts) — enforced in the pod by the same hook. */
|
|
54
60
|
limits?: Record<string, number>;
|
|
@@ -67,6 +73,13 @@ export interface ContainerStepInput {
|
|
|
67
73
|
ask?: {
|
|
68
74
|
timeoutSec: number;
|
|
69
75
|
};
|
|
76
|
+
/** tools: [peers] — this step's agent and the other steps of its
|
|
77
|
+
* parallel group, gathered host-side at launch. The driver rebuilds the
|
|
78
|
+
* colleagues' tools in here over the lease's /peers door. */
|
|
79
|
+
peers?: {
|
|
80
|
+
self: string;
|
|
81
|
+
siblings: PeerInfo[];
|
|
82
|
+
};
|
|
70
83
|
/** A Chat-Completions provider: the driver starts the runtime's translator
|
|
71
84
|
* on loopback and points the SDK at it. Null or absent: the env's
|
|
72
85
|
* ANTHROPIC_BASE_URL is spoken to directly. */
|
|
@@ -275,6 +288,8 @@ export declare function runtimeCacheMount(tenant: string | undefined): {
|
|
|
275
288
|
source: string;
|
|
276
289
|
target: string;
|
|
277
290
|
} | null;
|
|
291
|
+
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
292
|
+
export declare const DRIVER = "// The runner container's entrypoint. Reads one step, executes it with the\n// same core the server uses, speaks JSONL on stdout, exits.\nimport fs from \"node:fs\";\nconst input = JSON.parse(fs.readFileSync(\"/opt/runner/job/input.json\", \"utf8\"));\n// Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,\n// on the sandbox's clock. A worker that does not know a mark skips the line.\nconst mark = (name) => process.stdout.write(JSON.stringify({ e: \"mark\", name, at: Date.now() }) + \"\\n\");\nmark(\"driver\");\nlet modelSpoke = false;\nconst emit = (type, text, extra) => {\n // The model's first text or tool call is where start-up ends and the\n // step's own work begins \u2014 runtime and tool lines before it are set-up.\n if (!modelSpoke && (type === \"text\" || type === \"tool\")) { modelSpoke = true; mark(\"model\"); }\n process.stdout.write(JSON.stringify({ e: \"event\", type, text, ...extra }) + \"\\n\");\n};\n// What this sandbox actually touched, read at the last moment from the\n// kernel's own books: cgroup v2 for CPU busy-time and peak memory,\n// /proc/net/dev for bytes on the wire. Every read is best-effort \u2014 gVisor\n// exposes some of these files and not others depending on version, and a\n// metric we cannot read is null, never guessed. Reservation is what is\n// billed; these exist so the reservation can be right-sized.\nfunction readActuals() {\n // fs from the module's own import \u2014 the driver is ESM, and a require()\n // here threw on every call, silently nulling every metric it guarded.\n const read = (p) => { try { return fs.readFileSync(p, \"utf8\"); } catch { return null; } };\n // Two cgroup dialects: v2 (cpu.stat, microseconds; memory.peak) on a\n // plain runtime, v1-style controllers under gVisor (cpuacct.usage,\n // NANOseconds; memory.max_usage_in_bytes) \u2014 probed on the real cluster,\n // not assumed. Whichever answers wins; both silent means null.\n const cpuStat = read(\"/sys/fs/cgroup/cpu.stat\");\n const usage = cpuStat?.match(/^usage_usec (\\d+)/m);\n const cpuacctNs = usage ? NaN : Number(read(\"/sys/fs/cgroup/cpuacct/cpuacct.usage\")?.trim());\n const peak =\n read(\"/sys/fs/cgroup/memory.peak\") ??\n read(\"/sys/fs/cgroup/memory/memory.max_usage_in_bytes\") ??\n read(\"/sys/fs/cgroup/memory/memory.usage_in_bytes\");\n let rx = 0, tx = 0, sawNet = false;\n const net = read(\"/proc/net/dev\");\n if (net) {\n for (const line of net.split(\"\\n\").slice(2)) {\n const m = line.trim().match(/^(\\S+):\\s+(\\d+)(?:\\s+\\d+){7}\\s+(\\d+)/);\n if (m && m[1] !== \"lo\") { rx += Number(m[2]); tx += Number(m[3]); sawNet = true; }\n }\n }\n return {\n busyCpuSecs: usage\n ? Number(usage[1]) / 1e6\n : Number.isFinite(cpuacctNs) && cpuacctNs > 0\n ? cpuacctNs / 1e9\n : null,\n peakMemBytes: peak ? Number(peak.trim()) || null : null,\n rxBytes: sawNet ? rx : null,\n txBytes: sawNet ? tx : null,\n };\n}\n\ntry {\n // Runtime state (venvs, npm prefixes) lands in the agent user's real home\n // \u2014 writable, inside the container, gone with it.\n process.env.FOLDRUN_DATA = \"/home/agent/.foldrun\";\n const { executeStep } = await import(\"@foldrun/core/step-exec\");\n const { buildApiTools } = await import(\"@foldrun/core/api-tools\");\n const { buildScriptTools } = await import(\"@foldrun/core/script-tools\");\n const { buildConsultTools } = await import(\"@foldrun/core/agent-tools\");\n const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import(\"@foldrun/core/context-tools\");\n const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import(\"@foldrun/core/operator\");\n const { startTranslator } = await import(\"@foldrun/core/translator\");\n const { prepareRuntime } = await import(\"@foldrun/core/runtime\");\n const { materializeFileSecrets } = await import(\"@foldrun/core/secret-files\");\n const { paidWebActions } = await import(\"@foldrun/core/browser-pod\");\n mark(\"imports\");\n\n const agentDir = \"/workspace/\" + input.agentRel;\n // HOME is where a bare relative path lands when anything resolves one\n // outside cwd \u2014 and root's /root is outside the workspace, so such a read\n // was refused by confinement with a path the agent never typed. Its own\n // directory is both the truthful answer and the safe one.\n // @file secrets crossed as content; materialise them to 0600 paths inside\n // the container (a host path from the other side would not exist here).\n const env = materializeFileSecrets(agentDir, { ...process.env, HOME: agentDir }).env;\n // Caches go to the sandbox user's own home, not to HOME: the Claude CLI's\n // logs, fontconfig, Mesa and gstreamer all follow XDG_CACHE_HOME, and under\n // agents/<name>/.cache they were copied back after every step \u2014 3,855 files\n // across 179 agent folders on the box by 17 Oct 2026, read by nothing.\n env.XDG_CACHE_HOME = \"/home/agent/.cache\";\n\n // The declared runtime, built in here \u2014 pip and npm have the network, which\n // the host side may not. It lands in .foldrun/runner/.runtimes, which the\n // executors mount from a per-tenant directory (run-container.ts#RUNTIME_CACHE):\n // the first step for a given fingerprint installs, every later one finds\n // .ready and skips. With no mount it still works, just fresh every time \u2014\n // which is what this used to be unconditionally. The log says which happened.\n const runtime = input.runtime\n ? prepareRuntime(\"runner\", input.runtime)\n : { interpreters: {}, env: {}, log: [], error: null };\n for (const line of runtime.log) emit(\"info\", \"runtime: \" + line);\n if (runtime.error) emit(\"error\", \"runtime: \" + runtime.error);\n // The step's own environment gets it too, not only the script tools: an\n // agent that runs \"python3 x.py\" or a shebang script through Bash should\n // meet the packages its runtime declared, not the image's bare python.\n Object.assign(env, runtime.env);\n\n // install: what the agent finds it needs mid-step. apt goes through the\n // root start-up's helper (install-helper.ts); pip and npm land in a\n // directory of this container's own, on the paths from the first turn.\n const { buildInstallTool, INSTALL_TOOL } = await import(\"@foldrun/core/install-tool\");\n const { stepInstallEnv, INSTALL_SOCKET } = await import(\"@foldrun/core/runtime\");\n const installDir = \"/home/agent/.foldrun/step\";\n const installing = (input.allowed ?? []).includes(INSTALL_TOOL);\n if (installing) Object.assign(env, stepInstallEnv(installDir, env));\n const install = installing\n ? buildInstallTool({\n dir: installDir,\n socket: env.FOLDRUN_INSTALL_SOCKET || INSTALL_SOCKET,\n cacheRoot: \"/home/agent/.foldrun/runner/.runtimes\",\n python: env.VIRTUAL_ENV ? env.VIRTUAL_ENV + \"/bin/python\" : \"python3\",\n env,\n emit,\n })\n : { server: null };\n\n // API and script tools, rebuilt in here from their specs. Secrets were\n // substituted into API headers before the input crossed the boundary, so\n // buildApiTools gets an already-resolved environment. verify: gets the\n // container's own environment, whole: it is the boundary, assembled on\n // the host from an allowlist, and the declared secrets are in it.\n // A long result is kept in a file only when the agent can read it back.\n // (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)\n const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === \"Read\" || t === \"Grep\") };\n // A declared secret named like a model variable crosses as\n // FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own\n // settings, and the agent's API and script tools and verify: get the value\n // back under the name it declared. Built here, after the runtime and any\n // mid-step install have added their paths to env.\n const toolEnv = { ...env };\n for (const [k, v] of Object.entries(env)) if (k.startsWith(\"FOLDRUN_DECLARED_\")) toolEnv[k.slice(\"FOLDRUN_DECLARED_\".length)] = v;\n const api = buildApiTools(\"\", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);\n const script = buildScriptTools(\n agentDir,\n input.scripts,\n toolEnv,\n \"/library/scripts\",\n runtime.interpreters,\n null,\n keepIn.readable,\n );\n // The translator, when the provider speaks Chat Completions: a loopback\n // server for the length of this step. Its env replaces the endpoint the\n // host assembled; the provider's real URL and key stay inside it.\n const translator = input.translator ? await startTranslator(input.translator) : null;\n if (translator) {\n Object.assign(env, translator.env);\n emit(\"info\", \"translator: listening on \" + translator.baseUrl + \" for \" + (input.translator.label ?? input.translator.upstreamBase));\n }\n const consult = buildConsultTools(input.consults ?? [], env, emit);\n // The platform's own two groups, rebuilt from values like everything else.\n const search = buildSearchTools(input.search ?? []);\n const history = input.history?.length ? buildHistoryTools(input.history) : { server: null };\n const desks = input.desks?.length ? buildDeskTools(input.desks) : { server: null };\n // A person in the loop, over the proxy this step's lease already names:\n // ask_person when tools: [ask], and the run's inbox after every tool call.\n const channel = httpChannel(env.FOLDRUN_EGRESS);\n const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };\n const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };\n const inbox = channel ? inboxReader(channel, emit) : undefined;\n\n mark(\"tools\");\n const outcome = await executeStep({\n agentDir,\n workspaceRoot: \"/workspace\",\n libraryRoot: \"/library\",\n prompt: input.prompt,\n model: input.model,\n effort: input.effort ?? null,\n systemPrompt: input.systemPrompt,\n allowed: input.allowed,\n mcpNames: input.mcpNames,\n mcpServers: {\n ...(api.server ? { foldrun_apis: api.server } : {}),\n ...(script.server ? { foldrun_scripts: script.server } : {}),\n ...(consult.server ? { foldrun_agents: consult.server } : {}),\n ...(search.server ? { foldrun_search: search.server } : {}),\n ...(history.server ? { foldrun_history: history.server } : {}),\n ...(desks.server ? { foldrun_desks: desks.server } : {}),\n ...(ask.server ? { foldrun_ask: ask.server } : {}),\n ...(peers.server ? { foldrun_peers: peers.server } : {}),\n ...(install.server ? { foldrun_install: install.server } : {}),\n ...input.mcpServers,\n },\n ...(inbox ? { inbox } : {}),\n ...(input.subagents?.length ? { subagents: input.subagents } : {}),\n env,\n timeoutSec: input.timeoutSec,\n budgetUsd: input.budgetUsd,\n budgetNote: input.budgetNote,\n price: input.price,\n verify: input.verify,\n verifyEnv: toolEnv,\n ...(typeof input.judgeModel === \"string\" ? { judgeModel: input.judgeModel } : {}),\n ...(typeof input.judgeEffort === \"string\" ? { judgeEffort: input.judgeEffort } : {}),\n output: input.output,\n schema: input.schema,\n maxTurns: input.maxTurns,\n ...(input.capture === \"metadata\" || input.capture === \"full\" ? { capture: input.capture } : {}),\n ...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),\n ...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),\n // Slim browsing: the platform put this step on the slim image to browse\n // through the account's pod, and named the file the web tool logs its\n // reconnects to. A lost pod stops the step; what it wrote decides what\n // the runner does next (browser-pod.ts).\n ...(env.FOLDRUN_BROWSER_POD_EVENTS ? { browserPod: { events: env.FOLDRUN_BROWSER_POD_EVENTS, methods: api.methods ?? {}, paidWeb: paidWebActions(env), ...(env.FOLDRUN_BROWSER_INIT ? { browseInit: env.FOLDRUN_BROWSER_INIT } : {}) } } : {}),\n // The container is the boundary; the SDK's own bash sandbox here would\n // only block declared network use (SSH, curl) for no added safety.\n sandboxBash: false,\n emit,\n });\n for (const line of api.drainLog()) emit(\"info\", \"api: \" + line);\n for (const line of script.drainLog()) emit(\"info\", \"script: \" + line);\n if (translator) {\n for (const line of translator.drainLog()) emit(\"info\", line.startsWith(\"translator\") ? line : \"translator: \" + line);\n await translator.close();\n }\n // A consult's spend belongs to the step that asked.\n const consultCost = consult.drainCost();\n if (consultCost > 0) outcome.costUsd = (outcome.costUsd ?? 0) + consultCost;\n if (consultCost > 0 && typeof outcome.turnsCostUsd === \"number\") outcome.turnsCostUsd += consultCost;\n process.stdout.write(JSON.stringify({ e: \"done\", ...outcome, res: readActuals() }) + \"\\n\");\n process.exit(0);\n} catch (err) {\n emit(\"error\", err instanceof Error ? err.message : String(err));\n // The failure path reports actuals too \u2014 a step that died eleven minutes\n // in still held its slice for eleven minutes, and the failed runs are\n // exactly the ones whose resource story gets read.\n let res = null;\n try { res = readActuals(); } catch { /* the reading must never mask the error */ }\n process.stdout.write(JSON.stringify({ e: \"done\", status: \"failed\", result: null, costUsd: null, res }) + \"\\n\");\n process.exit(0);\n}\n";
|
|
278
293
|
/** The uv release copied into the image. Pinned: a floating uv is a floating
|
|
279
294
|
* resolver, and the runtime cache is keyed on the declaration, not on it. */
|
|
280
295
|
export declare const UV_VERSION = "0.11.25";
|
|
@@ -374,6 +389,10 @@ export declare function parseDriverLine(line: string): {
|
|
|
374
389
|
subagent?: string;
|
|
375
390
|
check?: boolean;
|
|
376
391
|
hook?: HookRecord;
|
|
392
|
+
io?: CallIo;
|
|
393
|
+
turn?: ModelCall;
|
|
394
|
+
inCall?: string;
|
|
395
|
+
responding?: true;
|
|
377
396
|
} | ({
|
|
378
397
|
e: "done";
|
|
379
398
|
} & ContainerStepOutcome) | null;
|
|
@@ -401,6 +420,10 @@ export interface RunInContainerArgs {
|
|
|
401
420
|
* without it, "stop" could only mean "stop after this step", and a
|
|
402
421
|
* browser step has fifteen minutes left to spend. */
|
|
403
422
|
runId?: string;
|
|
423
|
+
/** Whether a person has stopped the run, read from the record. A step that
|
|
424
|
+
* waits for room before its sandbox exists asks this, since there is no
|
|
425
|
+
* sandbox yet for the stop to destroy. */
|
|
426
|
+
stopRequested?: () => boolean;
|
|
404
427
|
/** The reservation class — which limits this step's sandbox holds. */
|
|
405
428
|
size?: "small" | "large" | "heavy";
|
|
406
429
|
/** Re-attach to a sandbox a previous driver started, instead of creating
|
|
@@ -23,7 +23,7 @@ import os from "node:os";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import crypto from "node:crypto";
|
|
25
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
26
|
-
import { isPlatformPath } from "./store.js";
|
|
26
|
+
import { CAPTURE_CAP, isPlatformPath } from "./store.js";
|
|
27
27
|
import { divertedSummary, isDivertedPath, overlayTestWrites } from "./test-mode.js";
|
|
28
28
|
import { isFileValue, fileContent } from "./secrets.js";
|
|
29
29
|
import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKET } from "./runtime.js";
|
|
@@ -410,7 +410,8 @@ export function runtimeCacheMount(tenant) {
|
|
|
410
410
|
return { source, target: RUNTIME_CACHE };
|
|
411
411
|
}
|
|
412
412
|
// ---------- the runner image ----------
|
|
413
|
-
|
|
413
|
+
/** The runner container's entrypoint, as JS text: written into the image as driver.mjs, so it must be plain JS — a TypeScript cast here broke every step on v2026.10.21.5 (tests/run-container.test.ts checks it). */
|
|
414
|
+
export const DRIVER = `// The runner container's entrypoint. Reads one step, executes it with the
|
|
414
415
|
// same core the server uses, speaks JSONL on stdout, exits.
|
|
415
416
|
import fs from "node:fs";
|
|
416
417
|
const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
|
|
@@ -475,7 +476,7 @@ try {
|
|
|
475
476
|
const { buildScriptTools } = await import("@foldrun/core/script-tools");
|
|
476
477
|
const { buildConsultTools } = await import("@foldrun/core/agent-tools");
|
|
477
478
|
const { buildSearchTools, buildHistoryTools, buildDeskTools } = await import("@foldrun/core/context-tools");
|
|
478
|
-
const { httpChannel, buildAskTool, inboxReader } = await import("@foldrun/core/operator");
|
|
479
|
+
const { httpChannel, buildAskTool, buildPeerTools, inboxReader } = await import("@foldrun/core/operator");
|
|
479
480
|
const { startTranslator } = await import("@foldrun/core/translator");
|
|
480
481
|
const { prepareRuntime } = await import("@foldrun/core/runtime");
|
|
481
482
|
const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
|
|
@@ -539,11 +540,18 @@ try {
|
|
|
539
540
|
// A long result is kept in a file only when the agent can read it back.
|
|
540
541
|
// (tool-output.ts canReadBack, inlined: the driver imports core by subpath.)
|
|
541
542
|
const keepIn = { dir: agentDir, readable: () => (input.allowed ?? []).some((t) => t === "Read" || t === "Grep") };
|
|
542
|
-
|
|
543
|
+
// A declared secret named like a model variable crosses as
|
|
544
|
+
// FOLDRUN_DECLARED_<NAME> (runner.ts, FOL-19): the model keeps its own
|
|
545
|
+
// settings, and the agent's API and script tools and verify: get the value
|
|
546
|
+
// back under the name it declared. Built here, after the runtime and any
|
|
547
|
+
// mid-step install have added their paths to env.
|
|
548
|
+
const toolEnv = { ...env };
|
|
549
|
+
for (const [k, v] of Object.entries(env)) if (k.startsWith("FOLDRUN_DECLARED_")) toolEnv[k.slice("FOLDRUN_DECLARED_".length)] = v;
|
|
550
|
+
const api = buildApiTools("", input.apis, undefined, { env: toolEnv, missing: [] }, keepIn);
|
|
543
551
|
const script = buildScriptTools(
|
|
544
552
|
agentDir,
|
|
545
553
|
input.scripts,
|
|
546
|
-
|
|
554
|
+
toolEnv,
|
|
547
555
|
"/library/scripts",
|
|
548
556
|
runtime.interpreters,
|
|
549
557
|
null,
|
|
@@ -566,6 +574,7 @@ try {
|
|
|
566
574
|
// ask_person when tools: [ask], and the run's inbox after every tool call.
|
|
567
575
|
const channel = httpChannel(env.FOLDRUN_EGRESS);
|
|
568
576
|
const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
|
|
577
|
+
const peers = input.peers ? buildPeerTools({ channel, self: input.peers.self, siblings: input.peers.siblings, emit }) : { server: null };
|
|
569
578
|
const inbox = channel ? inboxReader(channel, emit) : undefined;
|
|
570
579
|
|
|
571
580
|
mark("tools");
|
|
@@ -587,6 +596,7 @@ try {
|
|
|
587
596
|
...(history.server ? { foldrun_history: history.server } : {}),
|
|
588
597
|
...(desks.server ? { foldrun_desks: desks.server } : {}),
|
|
589
598
|
...(ask.server ? { foldrun_ask: ask.server } : {}),
|
|
599
|
+
...(peers.server ? { foldrun_peers: peers.server } : {}),
|
|
590
600
|
...(install.server ? { foldrun_install: install.server } : {}),
|
|
591
601
|
...input.mcpServers,
|
|
592
602
|
},
|
|
@@ -598,10 +608,13 @@ try {
|
|
|
598
608
|
budgetNote: input.budgetNote,
|
|
599
609
|
price: input.price,
|
|
600
610
|
verify: input.verify,
|
|
601
|
-
verifyEnv:
|
|
611
|
+
verifyEnv: toolEnv,
|
|
612
|
+
...(typeof input.judgeModel === "string" ? { judgeModel: input.judgeModel } : {}),
|
|
613
|
+
...(typeof input.judgeEffort === "string" ? { judgeEffort: input.judgeEffort } : {}),
|
|
602
614
|
output: input.output,
|
|
603
615
|
schema: input.schema,
|
|
604
616
|
maxTurns: input.maxTurns,
|
|
617
|
+
...(input.capture === "metadata" || input.capture === "full" ? { capture: input.capture } : {}),
|
|
605
618
|
...(input.limits && Object.keys(input.limits).length ? { limits: input.limits, toolOwners: input.toolOwners ?? {} } : {}),
|
|
606
619
|
...(input.hookNames?.length ? { hookNames: input.hookNames, hookBase: input.hookBase ?? {} } : {}),
|
|
607
620
|
// Slim browsing: the platform put this step on the slim image to browse
|
|
@@ -1040,6 +1053,44 @@ export function buildFailureSummary(log, opts = {}) {
|
|
|
1040
1053
|
return `${head}\n--- the last ${tail} characters of the build log ---\n${log.slice(-tail)}`;
|
|
1041
1054
|
}
|
|
1042
1055
|
// ---------- running one step ----------
|
|
1056
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
1057
|
+
/** A tool call's content from the sandbox, as far as it is well-formed and
|
|
1058
|
+
* within the cap; anything else is dropped (FOL-54 step 4). */
|
|
1059
|
+
function callIoOf(v) {
|
|
1060
|
+
if (!v || typeof v !== "object")
|
|
1061
|
+
return null;
|
|
1062
|
+
const o = v;
|
|
1063
|
+
// The cap plus the slack the runner masks inside before it cuts.
|
|
1064
|
+
const text = (x) => (typeof x === "string" ? x.slice(0, CAPTURE_CAP + 4096) : undefined);
|
|
1065
|
+
const io = {
|
|
1066
|
+
...(text(o.input) !== undefined ? { input: text(o.input) } : {}),
|
|
1067
|
+
...(text(o.output) !== undefined ? { output: text(o.output) } : {}),
|
|
1068
|
+
...(typeof o.inputChars === "number" ? { inputChars: num(o.inputChars) } : {}),
|
|
1069
|
+
...(typeof o.outputChars === "number" ? { outputChars: num(o.outputChars) } : {}),
|
|
1070
|
+
...(o.truncated === true ? { truncated: true } : {}),
|
|
1071
|
+
};
|
|
1072
|
+
return io.input !== undefined || io.output !== undefined ? io : null;
|
|
1073
|
+
}
|
|
1074
|
+
/** One model call from the sandbox, well-formed or dropped. */
|
|
1075
|
+
function modelCallOf(v) {
|
|
1076
|
+
if (!v || typeof v !== "object")
|
|
1077
|
+
return null;
|
|
1078
|
+
const o = v;
|
|
1079
|
+
const t = (o.tokens ?? {});
|
|
1080
|
+
if (typeof o.id !== "string" || !o.id || o.id.length > 128 || typeof o.at !== "string" || Number.isNaN(Date.parse(o.at)))
|
|
1081
|
+
return null;
|
|
1082
|
+
return {
|
|
1083
|
+
id: o.id,
|
|
1084
|
+
n: Math.max(1, Math.floor(num(o.n))),
|
|
1085
|
+
at: o.at,
|
|
1086
|
+
ms: num(o.ms),
|
|
1087
|
+
...(typeof o.model === "string" ? { model: o.model.slice(0, 128) } : {}),
|
|
1088
|
+
...(typeof o.stop === "string" ? { stop: o.stop.slice(0, 64) } : {}),
|
|
1089
|
+
tokens: { input: num(t.input), output: num(t.output), cacheRead: num(t.cacheRead), cacheWrite: num(t.cacheWrite) },
|
|
1090
|
+
tools: Math.floor(num(o.tools)),
|
|
1091
|
+
...(typeof o.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(o.subagent) ? { subagent: o.subagent } : {}),
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1043
1094
|
export function parseDriverLine(line) {
|
|
1044
1095
|
const trimmed = line.trim();
|
|
1045
1096
|
if (!trimmed.startsWith("{"))
|
|
@@ -1059,6 +1110,10 @@ export function parseDriverLine(line) {
|
|
|
1059
1110
|
...(typeof parsed.subagent === "string" && /^[a-z0-9-]{1,64}$/.test(parsed.subagent) ? { subagent: parsed.subagent } : {}),
|
|
1060
1111
|
...(parsed.check === true ? { check: true } : {}),
|
|
1061
1112
|
...(isHookRecord(parsed.hook) ? { hook: parsed.hook } : {}),
|
|
1113
|
+
...(callIoOf(parsed.io) ? { io: callIoOf(parsed.io) } : {}),
|
|
1114
|
+
...(typeof parsed.inCall === "string" && parsed.inCall.length <= 128 ? { inCall: parsed.inCall } : {}),
|
|
1115
|
+
...(modelCallOf(parsed.turn) ? { turn: modelCallOf(parsed.turn) } : {}),
|
|
1116
|
+
...(parsed.responding === true ? { responding: true } : {}),
|
|
1062
1117
|
};
|
|
1063
1118
|
}
|
|
1064
1119
|
if (parsed.e === "done") {
|