@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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,129 @@
1
+ // Deciding a run parked at an approval gate.
2
+ //
3
+ // The run record is the queue: the orchestrator polls it, so flipping a step
4
+ // from awaiting-approval to pending (approve) or failed (reject) is the whole
5
+ // decision. Nothing in memory has to survive a restart.
6
+ //
7
+ // Two doors lead here — the dashboard's approve button and the link in a
8
+ // notification email — and they must do exactly the same thing to the record,
9
+ // or a run approved from a phone would resume differently from one approved
10
+ // at a desk. So the decision lives in core and the routes only say who made
11
+ // it.
12
+
13
+ import { readRun, writeRun, type RunRecord } from "./store.ts";
14
+ import { platform } from "./platform.ts";
15
+
16
+ export interface ApprovalDecision {
17
+ decision: "approve" | "reject";
18
+ /** Guidance for the step being approved — rides the record into its prompt. */
19
+ note?: string;
20
+ /** Why a rejection, for the trace. Falls back to `note`, since a form has one box. */
21
+ reason?: string;
22
+ /** Decide only this step index; otherwise every step that is waiting. */
23
+ step?: number;
24
+ /** Who decided, as it reads after the verb: "by a human", "via emailed link". */
25
+ by: string;
26
+ }
27
+
28
+ /** An error the HTTP layer can map straight to a status — see errorResponse. */
29
+ class ApprovalError extends Error {
30
+ status: number;
31
+ constructor(message: string, status: number) {
32
+ super(message);
33
+ this.status = status;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Apply a decision to every step the run is waiting on, and line the run up
39
+ * again if a worker had parked it. Returns the record as written and which
40
+ * step indexes were decided.
41
+ *
42
+ * Throws with `status` 404 when the run does not exist and 409 when nothing
43
+ * is waiting — an emailed link is clicked twice, or after someone else
44
+ * already answered, and the second click must not be reported as success.
45
+ */
46
+ export async function decideApproval(
47
+ tenant: string,
48
+ workspace: string,
49
+ runId: string,
50
+ d: ApprovalDecision,
51
+ ): Promise<{ run: RunRecord; steps: number[] }> {
52
+ const run = readRun(tenant, workspace, runId);
53
+ if (!run) throw new ApprovalError("run not found", 404);
54
+
55
+ const waiting = run.steps
56
+ .map((s, i) => ({ s, i }))
57
+ .filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i));
58
+ if (waiting.length === 0) throw new ApprovalError("nothing is awaiting approval", 409);
59
+
60
+ const note = typeof d.note === "string" ? d.note.trim() : "";
61
+ const reason = (typeof d.reason === "string" ? d.reason.trim() : "") || note;
62
+ const now = new Date().toISOString();
63
+
64
+ for (const { s } of waiting) {
65
+ s.status = d.decision === "approve" ? "pending" : "failed";
66
+ // Record the decision itself, not just its effect on status. `pending`
67
+ // is also what a step looks like before anyone was asked, so without
68
+ // this a run that was approved and then interrupted comes back asking
69
+ // the same person the same question.
70
+ if (d.decision === "approve") {
71
+ s.approvedAt = now;
72
+ // An approval can carry guidance — it rides the step record into the
73
+ // prompt (see runner.ts), so "yes, but…" is one gesture, not a yes
74
+ // followed by a chase.
75
+ if (note) s.approvalNote = note.slice(0, 4000);
76
+ }
77
+ s.events.push({
78
+ t: now,
79
+ type: d.decision === "approve" ? "info" : "error",
80
+ text:
81
+ d.decision === "approve"
82
+ ? `approved ${d.by} — continuing${note ? ` (with guidance: ${note.slice(0, 200)})` : ""}`
83
+ : `rejected ${d.by}${reason ? `: ${reason.slice(0, 200)}` : ""}`,
84
+ });
85
+ }
86
+ writeRun(tenant, workspace, run);
87
+
88
+ // A parked run has no process polling for this decision — the worker
89
+ // that was driving it gave its slot back at the gate. Line it up again.
90
+ // Runs without the marker still have their starter polling the file, and
91
+ // enqueueing those too would put two drivers on one record.
92
+ if (run.parkedAt && run.steps.every((s) => s.status !== "awaiting-approval")) {
93
+ await platform.enqueueResume(tenant, workspace, run.id);
94
+ }
95
+
96
+ return { run, steps: waiting.map(({ i }) => i) };
97
+ }
98
+
99
+ /**
100
+ * Release a run parked on `wait: event` with what arrived. The payload is
101
+ * stored on the step — not as an approval note, which is a person's
102
+ * guidance and capped as such — and the release itself is an approval
103
+ * "by an external event", so the record reads the same way whichever door
104
+ * a park was opened through.
105
+ *
106
+ * Only steps waiting on an event are released: a run that is also asking a
107
+ * person a question keeps asking.
108
+ */
109
+ export async function deliverEvent(
110
+ tenant: string,
111
+ workspace: string,
112
+ runId: string,
113
+ payload: string,
114
+ ): Promise<{ run: RunRecord; steps: number[] }> {
115
+ const run = readRun(tenant, workspace, runId);
116
+ if (!run) throw new ApprovalError("run not found", 404);
117
+ const waiting = run.steps
118
+ .map((s, i) => ({ s, i }))
119
+ .filter(({ s }) => s.status === "awaiting-approval" && s.waitFor === "event");
120
+ if (waiting.length === 0) throw new ApprovalError("this run is not waiting for an event", 409);
121
+ for (const { s } of waiting) s.eventPayload = payload.slice(0, 20_000);
122
+ writeRun(tenant, workspace, run);
123
+ let decided: number[] = [];
124
+ for (const { i } of waiting) {
125
+ const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: "by an external event" });
126
+ decided = decided.concat(out.steps);
127
+ }
128
+ return { run: readRun(tenant, workspace, runId) ?? run, steps: decided };
129
+ }
package/src/arrange.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Rearranging a flow's steps into groups.
2
+ //
3
+ // Pure functions, extracted from the drag-and-drop board so they can be
4
+ // tested: when a drag produces an order you didn't expect, the first question
5
+ // is whether the rule is wrong or the target was — and that should be
6
+ // answerable without a browser.
7
+ //
8
+ // A `Groups` value is the arrangement: an array of groups, each holding
9
+ // indices into the flow's parsed steps. Steps sharing a group run in parallel.
10
+
11
+ export type Groups = number[][];
12
+
13
+ function removeStep(groups: Groups, step: number) {
14
+ const next = groups.map((g) => g.filter((i) => i !== step));
15
+ const emptiedAt = next.findIndex((g) => g.length === 0);
16
+ return { next: next.filter((g) => g.length > 0), emptiedAt };
17
+ }
18
+
19
+ /** Move `step` into the group that currently contains `anchor` — run in parallel. */
20
+ export function joinGroup(groups: Groups, step: number, anchor: number): Groups {
21
+ if (step === anchor) return groups;
22
+ const { next } = removeStep(groups, step);
23
+ const target = next.findIndex((g) => g.includes(anchor));
24
+ if (target === -1) return groups;
25
+ next[target] = [...next[target], step];
26
+ return next;
27
+ }
28
+
29
+ /**
30
+ * Move `step` out on its own at rail position `rail` — run sequentially.
31
+ * Rail 0 is above every group; rail N is below group N-1.
32
+ */
33
+ export function splitToRail(groups: Groups, step: number, rail: number): Groups {
34
+ const { next, emptiedAt } = removeStep(groups, step);
35
+ // Removing the step may have collapsed a group above the target, which
36
+ // shifts every rail below it up by one.
37
+ let at = rail;
38
+ if (emptiedAt !== -1 && emptiedAt < rail) at -= 1;
39
+ at = Math.max(0, Math.min(next.length, at));
40
+ next.splice(at, 0, [step]);
41
+ return next;
42
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,321 @@
1
+ // The model catalogue: what a gateway can actually run, cached.
2
+ //
3
+ // "Any model they want" is only an offer if picking a wrong one fails in one
4
+ // line at step start — not three minutes in, as an agent that narrates tool
5
+ // calls into prose because its model cannot make them. OpenRouter's models
6
+ // endpoint declares, per model, which parameters it supports (`tools` being
7
+ // the one that decides whether an agent loop is possible at all), plus
8
+ // context length and real prices. This module fetches that once, caches it
9
+ // on disk, and answers three questions from the cache:
10
+ //
11
+ // - can this model drive tools? → the run-start gate
12
+ // - what models exist, at what price? → `model:` autocomplete
13
+ // - what does a token actually cost? → metering for routed models
14
+ //
15
+ // Everything degrades to "don't know": a missing cache, an offline fetch or
16
+ // an unknown id yields null, and callers treat null as "no opinion" — the
17
+ // gate never blocks on ignorance, only on knowledge. A preset reference
18
+ // (`@preset/…`) is opaque by design: its settings live in the provider's
19
+ // dashboard, so the catalogue has no opinion about it either.
20
+
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import { dataRoot } from "./paths.ts";
24
+ import { resolveModel, resolveTier, MODEL_TIERS, EFFORT_LEVELS, type Effort, type Tier, type FlowInfo } from "./store.ts";
25
+ import type { FlowWarning } from "./flow-lint.ts";
26
+
27
+ export interface CatalogModel {
28
+ id: string;
29
+ name: string;
30
+ contextLength: number | null;
31
+ /** USD per input token, as the gateway quotes it (not per MTok). */
32
+ promptPrice: number | null;
33
+ /** USD per output token. */
34
+ completionPrice: number | null;
35
+ /** Can this model make tool calls? Null when the gateway doesn't say. */
36
+ tools: boolean | null;
37
+ /** Does it take a reasoning-depth parameter? (What `effort:` needs.) */
38
+ reasoning: boolean | null;
39
+ /** The effort levels this model accepts, when the gateway names them —
40
+ * OpenRouter's level names are ours (low…max, plus minimal/none below
41
+ * our scale), so no translation table sits between the two. */
42
+ efforts: string[] | null;
43
+ }
44
+
45
+ export interface Catalog {
46
+ /** The base_url this catalogue was fetched from. */
47
+ baseUrl: string;
48
+ fetchedAt: string;
49
+ models: CatalogModel[];
50
+ }
51
+
52
+ /** A day. Model lists move weekly, not hourly, and a stale price in an
53
+ * autocomplete hint is a smaller failure than a fetch on every keystroke. */
54
+ const TTL_MS = 24 * 60 * 60 * 1000;
55
+
56
+ const cacheFile = (baseUrl: string) =>
57
+ path.join(
58
+ dataRoot(),
59
+ "catalog",
60
+ // One file per endpoint, named legibly: catalog/openrouter.ai.json.
61
+ `${new URL(baseUrl).hostname.replace(/[^a-z0-9.-]/gi, "_")}.json`,
62
+ );
63
+
64
+ function parseModels(payload: unknown): CatalogModel[] {
65
+ const data = (payload as { data?: unknown[] })?.data;
66
+ if (!Array.isArray(data)) return [];
67
+ const out: CatalogModel[] = [];
68
+ for (const raw of data) {
69
+ if (!raw || typeof raw !== "object") continue;
70
+ const m = raw as Record<string, unknown>;
71
+ if (typeof m.id !== "string") continue;
72
+ const supported = Array.isArray(m.supported_parameters)
73
+ ? m.supported_parameters.map(String)
74
+ : null;
75
+ const pricing = (m.pricing ?? {}) as Record<string, unknown>;
76
+ const price = (k: string) => {
77
+ const n = Number(pricing[k]);
78
+ return Number.isFinite(n) ? n : null;
79
+ };
80
+ out.push({
81
+ id: m.id,
82
+ name: typeof m.name === "string" ? m.name : m.id,
83
+ contextLength: Number.isFinite(Number(m.context_length)) ? Number(m.context_length) : null,
84
+ promptPrice: price("prompt"),
85
+ completionPrice: price("completion"),
86
+ tools: supported ? supported.includes("tools") : null,
87
+ reasoning: supported ? supported.includes("reasoning") : null,
88
+ efforts: Array.isArray((m.reasoning as Record<string, unknown> | undefined)?.supported_efforts)
89
+ ? ((m.reasoning as Record<string, unknown>).supported_efforts as unknown[]).map(String)
90
+ : null,
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** The cache as it stands, however old — null only when nothing was ever
97
+ * fetched. Reading never touches the network. */
98
+ export function readCatalog(baseUrl: string): Catalog | null {
99
+ try {
100
+ const parsed = JSON.parse(fs.readFileSync(cacheFile(baseUrl), "utf8")) as Catalog;
101
+ return Array.isArray(parsed.models) ? parsed : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * The cache, refreshed over the network when it is older than the TTL.
109
+ * A failed fetch keeps the stale copy — offline is a reason to be out of
110
+ * date, not a reason to forget everything — and returns null only when
111
+ * there is nothing at all to fall back on.
112
+ */
113
+ export async function loadCatalog(baseUrl: string): Promise<Catalog | null> {
114
+ const cached = readCatalog(baseUrl);
115
+ if (cached && Date.now() - Date.parse(cached.fetchedAt) < TTL_MS) return cached;
116
+
117
+ try {
118
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/models`, {
119
+ signal: AbortSignal.timeout(10_000),
120
+ });
121
+ if (!res.ok) throw new Error(`${res.status}`);
122
+ const models = parseModels(await res.json());
123
+ if (!models.length) throw new Error("empty model list");
124
+ const catalog: Catalog = { baseUrl, fetchedAt: new Date().toISOString(), models };
125
+ fs.mkdirSync(path.dirname(cacheFile(baseUrl)), { recursive: true });
126
+ // Atomic like every other store write: a crashed refresh must not leave
127
+ // half a JSON file where the whole catalogue used to be.
128
+ const tmp = cacheFile(baseUrl) + ".tmp";
129
+ fs.writeFileSync(tmp, JSON.stringify(catalog));
130
+ fs.renameSync(tmp, cacheFile(baseUrl));
131
+ return catalog;
132
+ } catch {
133
+ return cached; // stale beats nothing; null when there is no nothing
134
+ }
135
+ }
136
+
137
+ /** A preset reference — settings live in the gateway's dashboard, opaque to
138
+ * us on purpose. The catalogue offers no opinion on one. */
139
+ export const isPresetRef = (model: string) => model.startsWith("@preset/");
140
+
141
+ export function findModel(catalog: Catalog | null, id: string): CatalogModel | null {
142
+ if (!catalog || isPresetRef(id)) return null;
143
+ return catalog.models.find((m) => m.id === id) ?? null;
144
+ }
145
+
146
+ export interface ModelVerdict {
147
+ ok: boolean;
148
+ /** One line, naming the cause and the nearest fix. Only set when !ok. */
149
+ reason?: string;
150
+ }
151
+
152
+ /**
153
+ * The run-start gate. Only ever fails on *knowledge*: an id the catalogue
154
+ * has, whose declared capabilities rule the step out. Unknown ids, preset
155
+ * refs, tier names that resolved elsewhere, and an absent catalogue all
156
+ * pass — the model call itself is the authority of last resort, and a gate
157
+ * that blocks on ignorance would make offline development impossible.
158
+ */
159
+ export function checkModel(
160
+ catalog: Catalog | null,
161
+ modelId: string,
162
+ needs: { tools: boolean },
163
+ ): ModelVerdict {
164
+ const m = findModel(catalog, modelId);
165
+ if (!m) return { ok: true };
166
+ if (needs.tools && m.tools === false) {
167
+ const alternative = nearestWithTools(catalog!, m);
168
+ return {
169
+ ok: false,
170
+ reason:
171
+ `model ${m.id} cannot call tools, and this agent declares some — the run would ` +
172
+ `narrate tool use as text instead of doing it.` +
173
+ (alternative ? ` Nearest model that can: ${alternative.id}.` : ""),
174
+ };
175
+ }
176
+ return { ok: true };
177
+ }
178
+
179
+ /** Same family first (everything before the slash), then anything with
180
+ * tools — "use the provider's sibling" is advice people actually take. */
181
+ function nearestWithTools(catalog: Catalog, from: CatalogModel): CatalogModel | null {
182
+ const vendor = from.id.split("/")[0];
183
+ const candidates = catalog.models.filter((m) => m.tools === true);
184
+ return candidates.find((m) => m.id.startsWith(vendor + "/")) ?? candidates[0] ?? null;
185
+ }
186
+
187
+ /** True when `effort:` will be silently ignored by this model — worth a
188
+ * trace line, never a failure: the step still runs, just without the depth
189
+ * the file asked for. */
190
+ export function effortIgnored(catalog: Catalog | null, modelId: string): boolean {
191
+ const m = findModel(catalog, modelId);
192
+ return m !== null && m.reasoning === false;
193
+ }
194
+
195
+ /**
196
+ * The effort that will actually reach this model, and the sentence that
197
+ * explains any difference. Three outcomes:
198
+ *
199
+ * - the model takes this level → unchanged, silent
200
+ * - it reasons, but not at this level → nearest supported level, said aloud
201
+ * - it does not reason at all → null, said aloud
202
+ *
203
+ * "Nearest" prefers the highest supported level at or below the request —
204
+ * someone who wrote `max` wanted the ceiling, and the ceiling here is
205
+ * `xhigh` — falling back to the lowest above it. Unknown models and absent
206
+ * catalogues pass the request through untouched: no knowledge, no opinion,
207
+ * same as the gate.
208
+ */
209
+ export function clampEffort(
210
+ catalog: Catalog | null,
211
+ modelId: string,
212
+ effort: Effort | null,
213
+ ): { effort: Effort | null; note: string | null } {
214
+ if (!effort) return { effort: null, note: null };
215
+ const m = findModel(catalog, modelId);
216
+ if (!m) return { effort, note: null };
217
+ if (m.reasoning === false && !m.efforts?.length) {
218
+ return {
219
+ effort: null,
220
+ note: `model ${m.id} takes no reasoning parameter — effort: ${effort} will have no effect`,
221
+ };
222
+ }
223
+ if (!m.efforts?.length) return { effort, note: null }; // reasons, levels unnamed
224
+ const supported = EFFORT_LEVELS.filter((l) => m.efforts!.includes(l));
225
+ if (!supported.length || supported.includes(effort)) return { effort, note: null };
226
+ const want = EFFORT_LEVELS.indexOf(effort);
227
+ const below = [...supported].reverse().find((l) => EFFORT_LEVELS.indexOf(l) < want);
228
+ const clamped = below ?? supported[0];
229
+ return {
230
+ effort: clamped,
231
+ note:
232
+ `model ${m.id} supports effort ${supported.join(", ")} — ` +
233
+ `effort: ${effort} runs as ${clamped}`,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * What one step actually cost through this gateway, or null when the
239
+ * catalogue can't say. The SDK's own costUsd is priced off Anthropic's
240
+ * table, which is wrong for every routed model — so when the catalogue
241
+ * knows better, its number wins.
242
+ */
243
+ export function catalogCost(
244
+ catalog: Catalog | null,
245
+ modelId: string,
246
+ usage: { inputTokens: number; outputTokens: number },
247
+ ): number | null {
248
+ const m = findModel(catalog, modelId);
249
+ if (!m || m.promptPrice == null || m.completionPrice == null) return null;
250
+ return usage.inputTokens * m.promptPrice + usage.outputTokens * m.completionPrice;
251
+ }
252
+
253
+ /** Catalogue entries as completion items: price per MTok, context, and the
254
+ * one capability that decides agent-worthiness. Tool-capable models first —
255
+ * on an agent platform they are almost always what the author wants. */
256
+ export function catalogCompletions(catalog: Catalog | null): { id: string; hint: string }[] {
257
+ if (!catalog) return [];
258
+ const perMTok = (v: number | null) => (v == null ? "?" : `$${(v * 1_000_000).toFixed(2)}`);
259
+ const ctx = (n: number | null) => (n == null ? "" : n >= 1000 ? ` · ${Math.round(n / 1000)}k ctx` : ` · ${n} ctx`);
260
+ return [...catalog.models]
261
+ .sort((a, b) => Number(b.tools === true) - Number(a.tools === true) || a.id.localeCompare(b.id))
262
+ .map((m) => ({
263
+ id: m.id,
264
+ hint: `${perMTok(m.promptPrice)}/${perMTok(m.completionPrice)} MTok${ctx(m.contextLength)}${m.tools === false ? " · no tools" : ""}`,
265
+ }));
266
+ }
267
+
268
+ // -------------------------------------------------------------- edit-time
269
+
270
+ /** What the lint needs to know about each agent: its own model, and whether
271
+ * it declares any tools at all. */
272
+ export interface AgentModelInfo {
273
+ name: string;
274
+ model: string;
275
+ usesTools: boolean;
276
+ }
277
+
278
+ /**
279
+ * The run-start gate's veto, raised while the file is still open. Same
280
+ * knowledge, earlier moment: a step whose resolved model the catalogue says
281
+ * cannot call tools — run by an agent that declares some — gets a warning in
282
+ * the editor instead of a failed step tomorrow morning. Advisory like every
283
+ * flow lint; the run-start gate stays the enforcement.
284
+ */
285
+ export function lintFlowModels(
286
+ flow: FlowInfo,
287
+ agents: AgentModelInfo[],
288
+ catalog: Catalog | null,
289
+ providerModels: Partial<Record<Tier, string>> = {},
290
+ ): FlowWarning[] {
291
+ if (!catalog) return [];
292
+ const warnings: FlowWarning[] = [];
293
+ const byName = new Map(agents.map((a) => [a.name, a]));
294
+
295
+ // The same remap the runner applies on the wire: a tier the provider
296
+ // block renames is checked as the gateway's id.
297
+ const wire = (value: string): string => {
298
+ const resolved = resolveModel(value);
299
+ const tier = (Object.entries(MODEL_TIERS) as [Tier, string][]).find(
300
+ ([, id]) => id === resolved,
301
+ )?.[0] ?? resolveTier(value);
302
+ return (tier && providerModels[tier]) || resolved;
303
+ };
304
+
305
+ flow.steps.forEach((step, i) => {
306
+ if (step.subflow) return; // the subflow's own lint covers its steps
307
+ const agent = byName.get(step.agent);
308
+ if (!agent?.usesTools) return;
309
+ const model = wire(step.model ?? flow.model ?? agent.model);
310
+ const verdict = checkModel(catalog, model, { tools: true });
311
+ if (!verdict.ok) {
312
+ warnings.push({
313
+ step: i,
314
+ line: step.line,
315
+ message: `"${step.agent}" would run on ${model}, which cannot call tools`,
316
+ detail: verdict.reason!,
317
+ });
318
+ }
319
+ });
320
+ return warnings;
321
+ }