@foldrun/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -10,9 +10,10 @@
10
10
  // Resolution is nearest-wins, the same rule OKF uses for knowledge: an agent's
11
11
  // own file beats its workspace's, which beats the account's. So a team can set
12
12
  // a house style once and one agent can still override it.
13
+ import { platform } from "./platform.js";
13
14
  import fs from "node:fs";
14
15
  import path from "node:path";
15
- import { dataRoot, singleWorkspace } from "./paths.js";
16
+ import { dataRoot, singleWorkspace, singleAccountRoot, MAX_EDITABLE_FILE } from "./paths.js";
16
17
  import matter from "gray-matter";
17
18
  import { KINDS } from "./kinds.js";
18
19
  import { recordRevision, registerTreeReader } from "./history.js";
@@ -27,7 +28,9 @@ export function libraryDir(tenant, kind) {
27
28
  // library is still possible on a laptop without inventing a home directory.
28
29
  const single = singleWorkspace();
29
30
  if (single) {
30
- const base = path.join(path.resolve(single), "..", "library");
31
+ // The account scope, which is the workspace's parent only when the
32
+ // workspace is a flat one — see singleAccountRoot.
33
+ const base = path.join(singleAccountRoot(), "library");
31
34
  return kind ? path.join(base, kind) : base;
32
35
  }
33
36
  assertSafeName(tenant, "tenant");
@@ -50,6 +53,27 @@ function assertLibraryPath(kind, rel) {
50
53
  }
51
54
  return norm;
52
55
  }
56
+ /** Every file inside a folder entry, as paths relative to the kind's
57
+ * directory. Depth-first so a nested `scripts/` comes with it. */
58
+ function folderFiles(dir, prefix) {
59
+ const out = [];
60
+ const walk = (at, rel) => {
61
+ for (const e of fs.readdirSync(at, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
62
+ const here = rel ? `${rel}/${e.name}` : e.name;
63
+ if (e.isDirectory())
64
+ walk(path.join(at, e.name), here);
65
+ else
66
+ out.push(`${prefix}/${here}`);
67
+ }
68
+ };
69
+ try {
70
+ walk(dir, "");
71
+ }
72
+ catch {
73
+ return [`${prefix}/tool.md`];
74
+ }
75
+ return out;
76
+ }
53
77
  export function listLibrary(tenant, kind) {
54
78
  const dir = libraryDir(tenant, kind);
55
79
  if (!fs.existsSync(dir))
@@ -77,6 +101,7 @@ export function listLibrary(tenant, kind) {
77
101
  out.push({
78
102
  ...describe(manifest, entry.name),
79
103
  path: `${entry.name}/tool.md`,
104
+ files: folderFiles(full, entry.name),
80
105
  ...toolShape(manifest, entry.name),
81
106
  updatedAt: fs.statSync(manifest).mtime.toISOString(),
82
107
  });
@@ -103,6 +128,7 @@ export function listLibrary(tenant, kind) {
103
128
  ...describe(skillMd, entry),
104
129
  path: `${entry}/SKILL.md`,
105
130
  hasScripts: fs.existsSync(path.join(full, "scripts")),
131
+ files: folderFiles(full, entry),
106
132
  updatedAt: fs.statSync(skillMd).mtime.toISOString(),
107
133
  });
108
134
  }
@@ -166,7 +192,9 @@ export function libraryFileExists(tenant, kind, rel) {
166
192
  }
167
193
  }
168
194
  export function writeLibraryFile(tenant, kind, rel, content, meta = {}) {
169
- if (content.length > 256 * 1024)
195
+ // 512 KB: a folder tool's program is one file, and the gallery browser's is
196
+ // past 256 KB. Still a file a person edits, not a store for data.
197
+ if (content.length > MAX_EDITABLE_FILE)
170
198
  throw new Error("file too large");
171
199
  const norm = assertLibraryPath(kind, rel);
172
200
  if (norm.endsWith(".md"))
@@ -213,7 +241,18 @@ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
213
241
  // ---------- consumed by the runner ----------
214
242
  /** Library tool definitions, keyed by name — http or script, both grantable by name in `tools:`. */
215
243
  export function libraryTools(tenant) {
216
- return readToolDir(libraryDir(tenant, "tools"), "account");
244
+ // The platform's gallery first, the account's own copies over it: a tool
245
+ // the platform ships is granted by name in every account without anyone
246
+ // installing it, and an account that installed (copied) one keeps its
247
+ // copy — that is what "install to customise" means. Both read at account
248
+ // scope, so a folder tool's `run:` resolves to /library/tools/<name>/…
249
+ // in the sandbox either way; the staging step lays the gallery down
250
+ // first and the account library over it for the same reason.
251
+ const gallery = platform.galleryDir();
252
+ return {
253
+ ...(gallery ? readToolDir(path.join(gallery, "tools"), "account") : {}),
254
+ ...readToolDir(libraryDir(tenant, "tools"), "account"),
255
+ };
217
256
  }
218
257
  /** Shared memory index — derived from the files, like every other scope. */
219
258
  export function libraryMemoryIndex(tenant) {
@@ -0,0 +1,84 @@
1
+ export type Limits = Record<string, number>;
2
+ /** The key that caps every call in the step. */
3
+ export declare const TOTAL_KEY = "calls";
4
+ /** One spelling for comparing: `desk-email` and `desk_email` are the same
5
+ * tool (a script tool's SDK name has its dashes made underscores). */
6
+ export declare function normKey(key: string): string;
7
+ /**
8
+ * A `limits:` block as written: the values that can be read, and a sentence
9
+ * for each that cannot. Shape only — whether a key names a tool this agent
10
+ * has is `limitKeyProblems`, which needs the grants.
11
+ */
12
+ export declare function readLimits(raw: unknown, where?: string): {
13
+ limits: Limits;
14
+ problems: string[];
15
+ };
16
+ /**
17
+ * The limits a step runs under, nearest last: account, workspace, agent,
18
+ * step. A key set nearer replaces the same key further out; the others
19
+ * stand. Unreadable values are dropped here — `check` and the deploy gate
20
+ * say so where they were written.
21
+ */
22
+ export declare function cascadeLimits(levels: unknown[]): Limits;
23
+ /** SDK tool name → the foldrun name it is granted as, for the tools whose
24
+ * SDK name does not say (an openapi tool's operations, a script whose name
25
+ * was made safe). `apiOwners` is api-tools' own record of which API each
26
+ * tool was built for — not guessed from the name, which cannot tell `crm`'s
27
+ * operation `admin_list` from `crm_admin`'s `list`. Built host-side; plain
28
+ * JSON so it crosses into a pod. */
29
+ export declare function toolOwners(apiOwners: Record<string, string>, scripts: {
30
+ name: string;
31
+ }[]): Record<string, string>;
32
+ /**
33
+ * Every key one call counts under, `calls` included. Pure: the SDK's tool
34
+ * name, its input, and the owners map.
35
+ */
36
+ export declare function limitKeysFor(toolName: string, input: Record<string, unknown> | undefined, owners?: Record<string, string>): string[];
37
+ export interface LimitRefusal {
38
+ key: string;
39
+ used: number;
40
+ limit: number;
41
+ message: string;
42
+ }
43
+ /**
44
+ * One step's counts. `take` checks every key the call counts under and
45
+ * either refuses — counting nothing — or counts it under all of them.
46
+ */
47
+ export declare class CallCounter {
48
+ private readonly limits;
49
+ private readonly used;
50
+ refused: number;
51
+ constructor(limits: Limits | undefined | null);
52
+ get active(): boolean;
53
+ take(keys: string[]): LimitRefusal | null;
54
+ /** Give back a call that was counted and then refused by a later check. */
55
+ refund(keys: string[]): void;
56
+ count(key: string): number;
57
+ /** `limits: web.search 40/40, crm 3/20` — null when no limits are set. */
58
+ summary(): string | null;
59
+ }
60
+ /**
61
+ * Keys an agent's `limits:` names that it cannot use. `known` is every tool
62
+ * name in reach (built-ins, the workspace's and library's tools, this
63
+ * agent's APIs, scripts and MCP servers); `granted` is what this agent's
64
+ * `tools:` (and `apis:`, `scripts:`, `mcpServers:`) gives it. A key that
65
+ * names nothing is an error; a real tool this agent does not hold is a
66
+ * warning — the limit is harmless, and probably a leftover.
67
+ */
68
+ export declare function limitKeyProblems(limits: Limits, ctx: {
69
+ known: Iterable<string>;
70
+ granted: Iterable<string>;
71
+ }): {
72
+ level: "error" | "warn";
73
+ message: string;
74
+ }[];
75
+ /**
76
+ * A flow step's `limits:` option, written on one line:
77
+ * `limits: {web.search: 10, calls: 50}` (the braces are optional). Read by
78
+ * hand rather than as YAML, because `web.search` is a plain key here and one
79
+ * line is all a step option has.
80
+ */
81
+ export declare function parseStepLimits(text: string): {
82
+ limits: Limits;
83
+ problems: string[];
84
+ };
@@ -0,0 +1,287 @@
1
+ // Per-step call limits: how many times a step may call a tool before the
2
+ // next call is refused.
3
+ //
4
+ // limits:
5
+ // web.search: 40 # one action of the web tool
6
+ // web: 150 # every web action together
7
+ // crm: 20 # an API, script or MCP tool, by the name it is granted as
8
+ // calls: 300 # every tool call in the step, whatever the tool
9
+ //
10
+ // Counted per step and enforced in step-exec.ts's PreToolUse hook — the one
11
+ // place every call passes, the step's own and its sub-agents', on the
12
+ // in-process path and inside a run container alike. A call past a limit is
13
+ // refused before it runs: the tool never sees it, so a paid search API is
14
+ // never asked and the cost is capped exactly. The refusal counts toward
15
+ // nothing. A retry of the step starts from zero; so does each fan-out
16
+ // instance, which is its own step.
17
+ //
18
+ // Nearest wins per key: the account's AGENTS.md, then the workspace's, then
19
+ // the agent's own `limits:`, then a flow step's `limits:` option.
20
+ import { WEB_ACTIONS } from "./providers.js";
21
+ import { DELEGATE_TOOLS } from "./subagents.js";
22
+ /** The key that caps every call in the step. */
23
+ export const TOTAL_KEY = "calls";
24
+ /** SDK built-ins, counted under the foldrun group that grants them — and
25
+ * under their own SDK name, so `Bash: 5` works for a Claude Code file. */
26
+ const BUILTIN_GROUP = {
27
+ Read: "read", Glob: "read", Grep: "read",
28
+ Write: "write", Edit: "write", MultiEdit: "write", NotebookEdit: "write",
29
+ Bash: "code",
30
+ };
31
+ /** Retired spellings an author may still write as a key. */
32
+ const KEY_ALIASES = { files: "write", bash: "code" };
33
+ /** The platform's own in-process servers and the name each is granted as. */
34
+ const PLATFORM_SERVERS = {
35
+ foldrun_search: "search",
36
+ foldrun_history: "history",
37
+ foldrun_desks: "desks",
38
+ foldrun_ask: "ask",
39
+ };
40
+ /** One spelling for comparing: `desk-email` and `desk_email` are the same
41
+ * tool (a script tool's SDK name has its dashes made underscores). */
42
+ export function normKey(key) {
43
+ const k = key.trim();
44
+ return (KEY_ALIASES[k] ?? k).replace(/-/g, "_");
45
+ }
46
+ const KEY_RE = /^[A-Za-z0-9_-]+(\.[a-z]+)?$/;
47
+ /**
48
+ * A `limits:` block as written: the values that can be read, and a sentence
49
+ * for each that cannot. Shape only — whether a key names a tool this agent
50
+ * has is `limitKeyProblems`, which needs the grants.
51
+ */
52
+ export function readLimits(raw, where = "limits") {
53
+ const limits = {};
54
+ const problems = [];
55
+ if (raw === undefined || raw === null)
56
+ return { limits, problems };
57
+ if (typeof raw !== "object" || Array.isArray(raw)) {
58
+ problems.push(`${where}: is a block of tool: count, e.g. \`limits: {web.search: 40, calls: 300}\`.`);
59
+ return { limits, problems };
60
+ }
61
+ // A YAML block written `web: {search: 40}` arrives nested; read it as the
62
+ // dotted keys it means rather than refusing a natural spelling.
63
+ const flat = [];
64
+ for (const [k, v] of Object.entries(raw)) {
65
+ if (v && typeof v === "object" && !Array.isArray(v)) {
66
+ for (const [sub, n] of Object.entries(v))
67
+ flat.push([`${k}.${sub}`, n]);
68
+ }
69
+ else
70
+ flat.push([k, v]);
71
+ }
72
+ for (const [key, value] of flat) {
73
+ const k = key.trim();
74
+ if (!KEY_RE.test(k)) {
75
+ problems.push(`${where}: "${k}" is not a key — a tool's name, web.<action>, or calls.`);
76
+ continue;
77
+ }
78
+ if (k.includes(".")) {
79
+ const [head, action] = k.split(".");
80
+ if (head !== "web" || !WEB_ACTIONS.includes(action)) {
81
+ problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}.`);
82
+ continue;
83
+ }
84
+ }
85
+ const n = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
86
+ if (!Number.isInteger(n) || n < 1) {
87
+ problems.push(`${where}: ${k}: ${String(value)} — a whole number of calls, 1 or more. To withhold a tool, leave it out of tools:.`);
88
+ continue;
89
+ }
90
+ limits[k] = n;
91
+ }
92
+ return { limits, problems };
93
+ }
94
+ /**
95
+ * The limits a step runs under, nearest last: account, workspace, agent,
96
+ * step. A key set nearer replaces the same key further out; the others
97
+ * stand. Unreadable values are dropped here — `check` and the deploy gate
98
+ * say so where they were written.
99
+ */
100
+ export function cascadeLimits(levels) {
101
+ const out = {};
102
+ const at = new Map(); // normalised → the key as last written
103
+ for (const level of levels) {
104
+ for (const [k, n] of Object.entries(readLimits(level).limits)) {
105
+ const nk = normKey(k);
106
+ const prev = at.get(nk);
107
+ if (prev !== undefined)
108
+ delete out[prev];
109
+ out[k] = n;
110
+ at.set(nk, k);
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ /** SDK tool name → the foldrun name it is granted as, for the tools whose
116
+ * SDK name does not say (an openapi tool's operations, a script whose name
117
+ * was made safe). `apiOwners` is api-tools' own record of which API each
118
+ * tool was built for — not guessed from the name, which cannot tell `crm`'s
119
+ * operation `admin_list` from `crm_admin`'s `list`. Built host-side; plain
120
+ * JSON so it crosses into a pod. */
121
+ export function toolOwners(apiOwners, scripts) {
122
+ const out = { ...apiOwners };
123
+ for (const s of scripts)
124
+ out[`mcp__foldrun_scripts__${s.name}`] = s.name;
125
+ return out;
126
+ }
127
+ /**
128
+ * Every key one call counts under, `calls` included. Pure: the SDK's tool
129
+ * name, its input, and the owners map.
130
+ */
131
+ export function limitKeysFor(toolName, input, owners = {}) {
132
+ const keys = [TOTAL_KEY];
133
+ const webAction = (action) => {
134
+ keys.push("web");
135
+ const a = typeof action === "string" ? action.trim().toLowerCase() : "";
136
+ if (a)
137
+ keys.push(`web.${a}`);
138
+ };
139
+ // A model provider's own search or fetch, standing in for the web tool's.
140
+ if (toolName === "WebSearch") {
141
+ webAction("search");
142
+ return keys;
143
+ }
144
+ if (toolName === "WebFetch") {
145
+ webAction("fetch");
146
+ return keys;
147
+ }
148
+ if (DELEGATE_TOOLS.includes(toolName))
149
+ return keys;
150
+ const owner = owners[toolName];
151
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
152
+ const name = owner
153
+ ?? (mcp
154
+ ? mcp[1] === "foldrun_scripts" ? mcp[2]
155
+ : mcp[1] === "foldrun_apis" ? mcp[2].replace(/^call_/, "")
156
+ : mcp[1] === "foldrun_agents" ? mcp[2].replace(/^consult_/, "")
157
+ : PLATFORM_SERVERS[mcp[1]] ?? mcp[1]
158
+ : null);
159
+ if (name !== null) {
160
+ if (normKey(name) === "web")
161
+ webAction(input?.action);
162
+ else
163
+ keys.push(name);
164
+ return keys;
165
+ }
166
+ const group = BUILTIN_GROUP[toolName];
167
+ if (group)
168
+ keys.push(group);
169
+ keys.push(toolName);
170
+ return keys;
171
+ }
172
+ /**
173
+ * One step's counts. `take` checks every key the call counts under and
174
+ * either refuses — counting nothing — or counts it under all of them.
175
+ */
176
+ export class CallCounter {
177
+ limits = new Map();
178
+ used = new Map();
179
+ refused = 0;
180
+ constructor(limits) {
181
+ for (const [k, n] of Object.entries(limits ?? {}))
182
+ this.limits.set(normKey(k), { key: k, limit: n });
183
+ }
184
+ get active() {
185
+ return this.limits.size > 0;
186
+ }
187
+ take(keys) {
188
+ const norm = [...new Set(keys.map(normKey))];
189
+ for (const k of norm) {
190
+ const l = this.limits.get(k);
191
+ if (!l)
192
+ continue;
193
+ const used = this.used.get(k) ?? 0;
194
+ if (used >= l.limit) {
195
+ this.refused++;
196
+ return {
197
+ key: l.key, used, limit: l.limit,
198
+ message: `limit reached: ${l.key} ${used} of ${l.limit} in this step — work with what you have, or say what more you would need`,
199
+ };
200
+ }
201
+ }
202
+ for (const k of norm)
203
+ this.used.set(k, (this.used.get(k) ?? 0) + 1);
204
+ return null;
205
+ }
206
+ /** Give back a call that was counted and then refused by a later check. */
207
+ refund(keys) {
208
+ for (const k of new Set(keys.map(normKey))) {
209
+ const n = this.used.get(k) ?? 0;
210
+ if (n > 0)
211
+ this.used.set(k, n - 1);
212
+ }
213
+ }
214
+ count(key) {
215
+ return this.used.get(normKey(key)) ?? 0;
216
+ }
217
+ /** `limits: web.search 40/40, crm 3/20` — null when no limits are set. */
218
+ summary() {
219
+ if (!this.active)
220
+ return null;
221
+ const parts = [...this.limits.entries()].map(([k, l]) => `${l.key} ${this.used.get(k) ?? 0}/${l.limit}`);
222
+ return `limits: ${parts.join(", ")}${this.refused ? ` — ${this.refused} call${this.refused === 1 ? "" : "s"} refused` : ""}`;
223
+ }
224
+ }
225
+ /**
226
+ * Keys an agent's `limits:` names that it cannot use. `known` is every tool
227
+ * name in reach (built-ins, the workspace's and library's tools, this
228
+ * agent's APIs, scripts and MCP servers); `granted` is what this agent's
229
+ * `tools:` (and `apis:`, `scripts:`, `mcpServers:`) gives it. A key that
230
+ * names nothing is an error; a real tool this agent does not hold is a
231
+ * warning — the limit is harmless, and probably a leftover.
232
+ */
233
+ export function limitKeyProblems(limits, ctx) {
234
+ const out = [];
235
+ const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
236
+ const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
237
+ const granted = new Set([...ctx.granted].flatMap((g) => {
238
+ const n = normKey(g);
239
+ // A group grants its members' keys, and `write` holds Read too.
240
+ if (n === "write")
241
+ return [n, "read", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep"];
242
+ if (n === "read")
243
+ return [n, "Read", "Glob", "Grep"];
244
+ if (n === "code")
245
+ return [n, "Bash"];
246
+ if (BUILTIN_GROUP[g])
247
+ return [n, BUILTIN_GROUP[g]];
248
+ return [n];
249
+ }).map(normKey));
250
+ for (const key of Object.keys(limits)) {
251
+ if (key === TOTAL_KEY)
252
+ continue;
253
+ const base = key.startsWith("web.") ? "web" : key;
254
+ const n = normKey(base);
255
+ if (!known.has(n)) {
256
+ const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
257
+ out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
258
+ }
259
+ else if (!granted.has(n)) {
260
+ out.push({ level: "warn", message: `limits: ${key} — this agent is not granted ${base}, so the limit never applies` });
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+ /**
266
+ * A flow step's `limits:` option, written on one line:
267
+ * `limits: {web.search: 10, calls: 50}` (the braces are optional). Read by
268
+ * hand rather than as YAML, because `web.search` is a plain key here and one
269
+ * line is all a step option has.
270
+ */
271
+ export function parseStepLimits(text) {
272
+ const body = text.trim().replace(/^\{/, "").replace(/\}$/, "").trim();
273
+ if (!body)
274
+ return { limits: {}, problems: ["a block of tool: count on one line, e.g. {web.search: 10, calls: 50}"] };
275
+ const raw = {};
276
+ const problems = [];
277
+ for (const part of body.split(",")) {
278
+ const m = /^\s*([^:\s]+)\s*:\s*(\S+)\s*$/.exec(part);
279
+ if (!m) {
280
+ problems.push(`"${part.trim()}" is not tool: count`);
281
+ continue;
282
+ }
283
+ raw[m[1]] = m[2];
284
+ }
285
+ const read = readLimits(raw, "limits");
286
+ return { limits: read.limits, problems: [...problems, ...read.problems.map((p) => p.replace(/^limits: /, ""))] };
287
+ }
@@ -0,0 +1,47 @@
1
+ export interface RegionChoice {
2
+ /** ISO 3166-1 alpha-2, upper-case: AU, IR, DE. Null when nobody said and
3
+ * the language tag carries none. */
4
+ region: string | null;
5
+ from: "agent" | "flow" | "workspace" | "account" | "env" | "language" | "none";
6
+ lines: string[];
7
+ }
8
+ export declare function normalizeRegion(raw: unknown): string | null;
9
+ export declare function regionProblem(raw: unknown): string | null;
10
+ export declare function resolveRegion(levels: {
11
+ level: RegionChoice["from"];
12
+ value: unknown;
13
+ }[], languageTag: string, env?: Record<string, string | undefined>): RegionChoice;
14
+ export type Units = "metric" | "imperial";
15
+ export interface LocaleFacts {
16
+ /** The BCP-47 tag Intl was asked with, e.g. fa-IR. */
17
+ tag: string;
18
+ language: string;
19
+ region: string | null;
20
+ currency: string | null;
21
+ units: Units;
22
+ /** The calendar in use — Intl's first for the locale, or the override. */
23
+ calendar: string;
24
+ /** Other calendars the locale commonly uses, e.g. gregory beside persian. */
25
+ calendars: string[];
26
+ /** Today in that calendar, formatted for the locale. Null when it is
27
+ * gregory and would only repeat FOLDRUN_DATE. */
28
+ dateLocal: string | null;
29
+ /** 1 = Monday … 7 = Sunday, ISO. */
30
+ weekStart: number | null;
31
+ weekend: number[];
32
+ direction: "ltr" | "rtl";
33
+ hourCycle: "h12" | "h23" | null;
34
+ /** A sample date, so the model sees the order without being told. */
35
+ dateSample: string;
36
+ }
37
+ export declare const dayName: (n: number) => string;
38
+ export declare function localeProblems(front: Record<string, unknown>): string[];
39
+ export declare function deriveLocale(language: string, region: string | null, overrides?: {
40
+ currency?: unknown;
41
+ calendar?: unknown;
42
+ units?: unknown;
43
+ }, timeZone?: string, now?: Date): LocaleFacts;
44
+ /** The sentence block the prompt carries. Only what is true for this
45
+ * locale; nothing hedged, nothing the model has to guess at. */
46
+ export declare function localeProse(f: LocaleFacts, languageName: string, regionName: string | null): string;
47
+ export declare function regionName(code: string | null): string | null;