@retinue/agentkit 0.1.0 → 0.3.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 (246) hide show
  1. package/README.md +59 -277
  2. package/dist/adapters/audio/openai.d.ts +36 -0
  3. package/dist/adapters/audio/openai.js +136 -0
  4. package/dist/adapters/bullmq/lock.d.ts +26 -2
  5. package/dist/adapters/embeddings/openai.d.ts +45 -0
  6. package/dist/adapters/embeddings/openai.js +109 -0
  7. package/dist/adapters/memory/connections.d.ts +10 -0
  8. package/dist/adapters/memory/connections.js +87 -0
  9. package/dist/adapters/memory/graph.d.ts +17 -0
  10. package/dist/adapters/memory/graph.js +267 -0
  11. package/dist/adapters/memory/index.d.ts +3 -0
  12. package/dist/adapters/memory/index.js +3 -0
  13. package/dist/adapters/memory/rate-limit.d.ts +14 -0
  14. package/dist/adapters/memory/rate-limit.js +28 -0
  15. package/dist/adapters/memory/runtime.js +0 -0
  16. package/dist/adapters/postgres/connections.d.ts +15 -0
  17. package/dist/adapters/postgres/connections.js +134 -0
  18. package/dist/adapters/postgres/graph.d.ts +24 -0
  19. package/dist/adapters/postgres/graph.js +325 -0
  20. package/dist/adapters/postgres/index.d.ts +2 -0
  21. package/dist/adapters/postgres/index.js +2 -0
  22. package/dist/adapters/postgres/migrations.js +198 -0
  23. package/dist/adapters/postgres/run-store.js +26 -2
  24. package/dist/adapters/postgres/schema.d.ts +61 -2
  25. package/dist/adapters/postgres/schema.js +95 -10
  26. package/dist/adapters/redis/index.d.ts +1 -0
  27. package/dist/adapters/redis/index.js +1 -0
  28. package/dist/adapters/redis/rate-limit.d.ts +46 -0
  29. package/dist/adapters/redis/rate-limit.js +55 -0
  30. package/dist/adapters/supabase/index.d.ts +15 -0
  31. package/dist/adapters/supabase/index.js +15 -0
  32. package/dist/adapters/supabase/rls.js +33 -0
  33. package/dist/agents/agent.d.ts +99 -1
  34. package/dist/agents/agent.js +215 -16
  35. package/dist/agents/define.js +24 -4
  36. package/dist/agents/engine.d.ts +48 -0
  37. package/dist/agents/engine.js +370 -13
  38. package/dist/audio/index.d.ts +151 -0
  39. package/dist/audio/index.js +183 -0
  40. package/dist/capabilities/index.d.ts +5 -1
  41. package/dist/capabilities/index.js +23 -0
  42. package/dist/capabilities/runtime.d.ts +8 -0
  43. package/dist/connections/cipher.d.ts +103 -0
  44. package/dist/connections/cipher.js +141 -0
  45. package/dist/connections/index.d.ts +156 -0
  46. package/dist/connections/index.js +72 -0
  47. package/dist/connections/oauth/client.d.ts +84 -0
  48. package/dist/connections/oauth/client.js +141 -0
  49. package/dist/connections/oauth/index.d.ts +166 -0
  50. package/dist/connections/oauth/index.js +226 -0
  51. package/dist/connections/oauth/service.d.ts +90 -0
  52. package/dist/connections/oauth/service.js +132 -0
  53. package/dist/connections/pause.d.ts +68 -0
  54. package/dist/connections/pause.js +97 -0
  55. package/dist/connections/resolver.d.ts +59 -0
  56. package/dist/connections/resolver.js +138 -0
  57. package/dist/connections/resume.d.ts +64 -0
  58. package/dist/connections/resume.js +99 -0
  59. package/dist/connections/vault-cipher.d.ts +95 -0
  60. package/dist/connections/vault-cipher.js +206 -0
  61. package/dist/core/budget.d.ts +55 -0
  62. package/dist/core/budget.js +56 -0
  63. package/dist/core/content-parts.d.ts +26 -2
  64. package/dist/core/content-parts.js +1 -0
  65. package/dist/core/context.d.ts +25 -0
  66. package/dist/core/errors.d.ts +1 -1
  67. package/dist/core/errors.js +15 -0
  68. package/dist/core/events.d.ts +110 -2
  69. package/dist/core/events.js +33 -2
  70. package/dist/core/index.d.ts +1 -0
  71. package/dist/core/index.js +1 -0
  72. package/dist/core/validation.js +19 -0
  73. package/dist/documents/index.d.ts +14 -0
  74. package/dist/documents/parsers/text.d.ts +16 -0
  75. package/dist/documents/parsers/text.js +54 -2
  76. package/dist/entries/connections.d.ts +10 -0
  77. package/dist/entries/connections.js +10 -0
  78. package/dist/entries/guardrails.d.ts +14 -0
  79. package/dist/entries/guardrails.js +14 -0
  80. package/dist/entries/knowledge.d.ts +17 -0
  81. package/dist/entries/knowledge.js +16 -0
  82. package/dist/entries/mcp-server.d.ts +9 -0
  83. package/dist/entries/mcp-server.js +9 -0
  84. package/dist/entries/runtime.d.ts +10 -0
  85. package/dist/entries/runtime.js +8 -0
  86. package/dist/entries/testing.d.ts +28 -0
  87. package/dist/entries/testing.js +28 -0
  88. package/dist/graphql/resolvers.d.ts +7 -1
  89. package/dist/graphql/resolvers.js +20 -0
  90. package/dist/graphql/schema.d.ts +1 -1
  91. package/dist/graphql/schema.js +44 -0
  92. package/dist/guardrails/index.d.ts +115 -0
  93. package/dist/guardrails/index.js +108 -0
  94. package/dist/guardrails/moderation.d.ts +53 -0
  95. package/dist/guardrails/moderation.js +75 -0
  96. package/dist/guardrails/pii.d.ts +75 -0
  97. package/dist/guardrails/pii.js +193 -0
  98. package/dist/knowledge/communities.d.ts +166 -0
  99. package/dist/knowledge/communities.js +377 -0
  100. package/dist/knowledge/graph-global.d.ts +153 -0
  101. package/dist/knowledge/graph-global.js +246 -0
  102. package/dist/knowledge/graph-retrieval.d.ts +150 -0
  103. package/dist/knowledge/graph-retrieval.js +303 -0
  104. package/dist/knowledge/graph.d.ts +223 -0
  105. package/dist/knowledge/graph.js +369 -0
  106. package/dist/knowledge/index.d.ts +25 -0
  107. package/dist/knowledge/index.js +37 -1
  108. package/dist/knowledge/navigate.d.ts +89 -0
  109. package/dist/knowledge/navigate.js +107 -0
  110. package/dist/knowledge/retrieval.d.ts +98 -5
  111. package/dist/knowledge/retrieval.js +158 -28
  112. package/dist/mcp-server/index.d.ts +168 -0
  113. package/dist/mcp-server/index.js +175 -0
  114. package/dist/models/extraction.d.ts +73 -0
  115. package/dist/models/extraction.js +118 -0
  116. package/dist/models/index.d.ts +40 -1
  117. package/dist/models/index.js +22 -1
  118. package/dist/models/pricing.d.ts +2 -0
  119. package/dist/models/pricing.js +17 -1
  120. package/dist/models/provider-factory.js +9 -7
  121. package/dist/models/streaming.d.ts +111 -1
  122. package/dist/models/streaming.js +184 -3
  123. package/dist/persistence/index.d.ts +230 -0
  124. package/dist/persistence/index.js +2 -0
  125. package/dist/runtime/index.d.ts +1 -1
  126. package/dist/runtime/index.js +12 -0
  127. package/dist/runtime/worker.d.ts +36 -1
  128. package/dist/runtime/worker.js +37 -4
  129. package/dist/security/checklist.js +55 -0
  130. package/dist/security/findings.js +18 -9
  131. package/dist/server/bin.d.ts +19 -0
  132. package/dist/server/bin.js +226 -0
  133. package/dist/server/boot.d.ts +20 -2
  134. package/dist/server/boot.js +29 -3
  135. package/dist/server/cli.d.ts +13 -0
  136. package/dist/server/cli.js +7 -4
  137. package/dist/server/doctor.d.ts +74 -0
  138. package/dist/server/doctor.js +280 -0
  139. package/dist/skills/catalogue.d.ts +49 -0
  140. package/dist/skills/catalogue.js +61 -0
  141. package/dist/skills/context.d.ts +61 -0
  142. package/dist/skills/context.js +107 -0
  143. package/dist/skills/index.d.ts +2 -0
  144. package/dist/skills/index.js +2 -0
  145. package/dist/telemetry/spans.js +17 -0
  146. package/dist/testing/conformance/artifact-exports.d.ts +20 -0
  147. package/dist/testing/conformance/artifact-exports.js +172 -0
  148. package/dist/testing/conformance/artifacts.d.ts +11 -0
  149. package/dist/testing/conformance/artifacts.js +316 -0
  150. package/dist/testing/conformance/audio.d.ts +38 -0
  151. package/dist/testing/conformance/audio.js +145 -0
  152. package/dist/testing/conformance/capability.d.ts +23 -0
  153. package/dist/testing/conformance/capability.js +27 -0
  154. package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
  155. package/dist/testing/conformance/checkpoint-store.js +72 -0
  156. package/dist/testing/conformance/connections.d.ts +14 -0
  157. package/dist/testing/conformance/connections.js +171 -0
  158. package/dist/testing/conformance/conversation-store.d.ts +10 -0
  159. package/dist/testing/conformance/conversation-store.js +60 -0
  160. package/dist/testing/conformance/evaluation.d.ts +10 -0
  161. package/dist/testing/conformance/evaluation.js +295 -0
  162. package/dist/testing/conformance/files.d.ts +19 -0
  163. package/dist/testing/conformance/files.js +454 -0
  164. package/dist/testing/conformance/flows.d.ts +16 -0
  165. package/dist/testing/conformance/flows.js +193 -0
  166. package/dist/testing/conformance/graph.d.ts +22 -0
  167. package/dist/testing/conformance/graph.js +500 -0
  168. package/dist/testing/conformance/hitl.d.ts +25 -0
  169. package/dist/testing/conformance/hitl.js +523 -0
  170. package/dist/testing/conformance/index.d.ts +163 -0
  171. package/dist/testing/conformance/index.js +321 -0
  172. package/dist/testing/conformance/invariants.d.ts +23 -0
  173. package/dist/testing/conformance/invariants.js +80 -0
  174. package/dist/testing/conformance/knowledge.d.ts +41 -0
  175. package/dist/testing/conformance/knowledge.js +592 -0
  176. package/dist/testing/conformance/parents.d.ts +50 -0
  177. package/dist/testing/conformance/parents.js +39 -0
  178. package/dist/testing/conformance/rate-limit.d.ts +25 -0
  179. package/dist/testing/conformance/rate-limit.js +71 -0
  180. package/dist/testing/conformance/records.d.ts +36 -0
  181. package/dist/testing/conformance/records.js +400 -0
  182. package/dist/testing/conformance/rollups.d.ts +30 -0
  183. package/dist/testing/conformance/rollups.js +425 -0
  184. package/dist/testing/conformance/run-coordinator.d.ts +27 -0
  185. package/dist/testing/conformance/run-coordinator.js +150 -0
  186. package/dist/testing/conformance/run-event-log.d.ts +9 -0
  187. package/dist/testing/conformance/run-event-log.js +138 -0
  188. package/dist/testing/conformance/run-store.d.ts +12 -0
  189. package/dist/testing/conformance/run-store.js +294 -0
  190. package/dist/testing/conformance/session-state.d.ts +38 -0
  191. package/dist/testing/conformance/session-state.js +198 -0
  192. package/dist/testing/conformance/usage-limits.d.ts +10 -0
  193. package/dist/testing/conformance/usage-limits.js +309 -0
  194. package/dist/testing/conformance.d.ts +8 -0
  195. package/dist/testing/conformance.js +8 -0
  196. package/dist/testing/memory-backend.d.ts +39 -0
  197. package/dist/testing/memory-backend.js +41 -0
  198. package/dist/testing/pglite.d.ts +55 -0
  199. package/dist/testing/pglite.js +78 -0
  200. package/dist/testing/stub-model.d.ts +58 -0
  201. package/dist/testing/stub-model.js +71 -0
  202. package/dist/testing/supabase-storage-double.d.ts +35 -0
  203. package/dist/testing/supabase-storage-double.js +101 -0
  204. package/dist/toolkit/files.d.ts +125 -0
  205. package/dist/toolkit/files.js +320 -0
  206. package/dist/toolkit/http.d.ts +27 -0
  207. package/dist/toolkit/http.js +48 -0
  208. package/dist/toolkit/index.d.ts +31 -1
  209. package/dist/toolkit/index.js +26 -1
  210. package/dist/toolkit/mime.d.ts +97 -0
  211. package/dist/toolkit/mime.js +233 -0
  212. package/dist/toolkit/sandbox.d.ts +119 -0
  213. package/dist/toolkit/sandbox.js +239 -0
  214. package/dist/toolkit/ssrf.d.ts +139 -0
  215. package/dist/toolkit/ssrf.js +349 -0
  216. package/dist/toolkit/vendor.d.ts +91 -0
  217. package/dist/toolkit/vendor.js +153 -0
  218. package/dist/toolkit/web.d.ts +13 -0
  219. package/dist/toolkit/web.js +7 -1
  220. package/dist/tools/budget.d.ts +28 -0
  221. package/dist/tools/budget.js +35 -0
  222. package/dist/tools/credentials.d.ts +316 -0
  223. package/dist/tools/credentials.js +369 -0
  224. package/dist/tools/define.d.ts +45 -0
  225. package/dist/tools/define.js +26 -0
  226. package/dist/tools/find.d.ts +109 -0
  227. package/dist/tools/find.js +210 -0
  228. package/dist/tools/index.d.ts +35 -2
  229. package/dist/tools/index.js +11 -0
  230. package/dist/tools/library/audio.d.ts +63 -0
  231. package/dist/tools/library/audio.js +148 -0
  232. package/dist/tools/library/fs.d.ts +24 -0
  233. package/dist/tools/library/fs.js +102 -0
  234. package/dist/tools/library/index.d.ts +37 -2
  235. package/dist/tools/library/index.js +47 -0
  236. package/dist/tools/library/shell.d.ts +45 -0
  237. package/dist/tools/library/shell.js +70 -0
  238. package/dist/tools/meta-tools.js +8 -0
  239. package/dist/tools/registry.d.ts +146 -0
  240. package/dist/tools/registry.js +250 -4
  241. package/dist/usage/index.d.ts +21 -0
  242. package/dist/usage/index.js +1 -0
  243. package/dist/usage/rate-limit.d.ts +122 -0
  244. package/dist/usage/rate-limit.js +131 -0
  245. package/dist/usage/recorder.js +3 -0
  246. package/package.json +33 -2
@@ -18,6 +18,8 @@
18
18
  import { assertToolAuthorized } from "../authorization/index.js";
19
19
  import { deriveIdempotencyKey } from "../idempotency/index.js";
20
20
  import { META_TOOL_DESCRIPTOR_LIST } from "./meta-tools.js";
21
+ import { applyTokenBudget } from "../core/budget.js";
22
+ import { entryTokens } from "./budget.js";
21
23
  /** Default validator: run a zod-like schema's `safeParse`; pass through anything else (tool self-validates). */
22
24
  export const zodishValidator = {
23
25
  validate(schema, value) {
@@ -39,6 +41,26 @@ const compact = (d) => ({
39
41
  effect: d.effect,
40
42
  });
41
43
  const invalidInput = (message) => ({ code: "invalid_input", message, retryable: false });
44
+ /** The registry's own meta-tools, which a model may not target through `execute_tool`. */
45
+ const META_ONLY = new Set(["execute_tool", "find_tools", "learn_tools", "read_tool_output"]);
46
+ export const unwrapExecuteTool = (request) => {
47
+ if (request.name !== "execute_tool")
48
+ return request;
49
+ const asked = (request.input ?? {});
50
+ if (typeof asked.name !== "string" || asked.name.trim() === "")
51
+ return { error: invalidInput("execute_tool needs the name of the tool to run.") };
52
+ if (META_ONLY.has(asked.name))
53
+ return { error: invalidInput(`execute_tool cannot call ${asked.name}; call it directly.`) };
54
+ return {
55
+ name: asked.name,
56
+ input: asked.input,
57
+ ...(typeof asked.idempotencyKey === "string" ? { idempotencyKey: asked.idempotencyKey } : {}),
58
+ // The *outer* call's identity is kept: the tool call the model made is the one the run event log records,
59
+ // and rewriting it here would make a transcript disagree with the model's own history.
60
+ ...(request.toolCallId === undefined ? {} : { toolCallId: request.toolCallId }),
61
+ ...(request.approval === undefined ? {} : { approval: request.approval }),
62
+ };
63
+ };
42
64
  /** A wiring problem, not a caller problem: retrying the identical call cannot help. */
43
65
  const capabilityUnavailable = (message) => ({
44
66
  code: "capability_unavailable",
@@ -46,6 +68,28 @@ const capabilityUnavailable = (message) => ({
46
68
  retryable: false,
47
69
  });
48
70
  const requiresKey = (effect, requires) => requires || effect === "external-write" || effect === "destructive";
71
+ /**
72
+ * Categories a tenant may not switch off.
73
+ *
74
+ * `meta` is the model's route back to everything else. A tenant that disabled it would have an agent that cannot
75
+ * learn a schema or search the catalogue — which is not a smaller toolset, it is a broken one.
76
+ */
77
+ export const UNDISABLEABLE_CATEGORIES = ["meta"];
78
+ /** Whether a category survives a tenant's toolset. Exported because the filtering is worth testing directly. */
79
+ export const categoryEnabled = (toolset, category) => {
80
+ if (UNDISABLEABLE_CATEGORIES.includes(category))
81
+ return true;
82
+ if (toolset.enabledCategories !== undefined && !toolset.enabledCategories.includes(category))
83
+ return false;
84
+ return !(toolset.disabledCategories ?? []).includes(category);
85
+ };
86
+ /**
87
+ * How many distinct skills one run may pull into context.
88
+ *
89
+ * Mirrors `SKILL_LIMITS.maxLoadedPerRun`, duplicated rather than imported for the same layering reason as
90
+ * `SkillBodyLoader`. A test asserts the two agree, so the copy cannot drift silently.
91
+ */
92
+ export const MAX_SKILLS_LOADED_PER_RUN = 5;
49
93
  export const createToolRegistry = (config) => {
50
94
  const maxInline = config.maxInlineOutputBytes ?? 8 * 1024;
51
95
  const validator = config.validator ?? zodishValidator;
@@ -110,13 +154,59 @@ export const createToolRegistry = (config) => {
110
154
  });
111
155
  }
112
156
  }
113
- const usable = all.filter((t) => !duplicated.has(t.descriptor.name));
157
+ /**
158
+ * The tenant's toolset, applied **before** authorization — AC-4.
159
+ *
160
+ * Order matters and this is the order the AC asks for. A tool a tenant switched off is not a tool the
161
+ * principal is unauthorized for: it does not exist for that tenant, so it must not reach the authorization
162
+ * policy, must not appear in a policy's audit of what it filtered, and must not be findable.
163
+ */
164
+ const toolset = config.toolsets === undefined ? undefined : await config.toolsets.resolve(context);
165
+ const wanted = toolset === undefined
166
+ ? all.filter((t) => !duplicated.has(t.descriptor.name))
167
+ : all.filter((t) => !duplicated.has(t.descriptor.name) && categoryEnabled(toolset, t.descriptor.category));
168
+ /**
169
+ * The running agent's exclusions — task #244.
170
+ *
171
+ * Here, alongside the tenant toolset and before authorization, for the same reason: an excluded tool does not
172
+ * exist for this agent, so it must not reach the authorization policy, must not appear in what a policy
173
+ * audits, and must not be findable by `find_tools`.
174
+ *
175
+ * `excluded` only. `preloaded` and `categories` decide which tools carry full schemas up front, which is a
176
+ * question about the *catalogue* and is answered in `catalog()`; they are not a permission and must not
177
+ * remove anything here.
178
+ */
179
+ const excludedByAgent = new Set(context.agentToolPolicy?.excluded ?? []);
180
+ const usable = excludedByAgent.size === 0 ? wanted : wanted.filter((t) => !excludedByAgent.has(t.descriptor.name));
114
181
  const descriptors = usable.map((t) => t.descriptor);
115
182
  const permitted = new Set((await config.authorization.filterTools(context, descriptors)).map((d) => d.name));
116
183
  return usable.filter((t) => permitted.has(t.descriptor.name));
117
184
  };
118
185
  const findAuthorized = async (context, name) => (await authorizedTools(context)).find((t) => t.descriptor.name === name) ?? null;
119
- return {
186
+ /**
187
+ * One implementation, reached two ways: `registry.find` for a host, and `execute("find_tools")` for a model.
188
+ *
189
+ * `find_tools` is not authorized as a tool in its own right, and that is deliberate: like every other
190
+ * meta-tool it is part of the interface rather than a capability a role grants. What *is* authorized is
191
+ * everything it can return — the corpus is the caller's own authorized tool list — so the worst a principal
192
+ * with no tools can learn from it is that they have none.
193
+ */
194
+ const runFind = async (context, input) => {
195
+ if (config.search === undefined)
196
+ return { hits: [], modes: [] };
197
+ const tools = (await authorizedTools(context)).map((t) => t.descriptor);
198
+ return config.search.search({ query: input.query, tools, limit: input.limit ?? 10 });
199
+ };
200
+ /**
201
+ * Skills already pulled into context, per run — the ceiling for `load_skill`.
202
+ *
203
+ * Per registry instance and keyed by run id. In a multi-process deployment a run is claimed by one worker at a
204
+ * time (the lease), so a per-process tally is the right scope; it is a *context* ceiling, and context is
205
+ * per-turn anyway. A run that migrates to another worker after a crash starts its tally again, which is the
206
+ * correct direction: the new attempt's context is empty.
207
+ */
208
+ const skillsLoadedPerRun = new Map();
209
+ const api = {
120
210
  async catalog(context, policy) {
121
211
  const excluded = new Set(policy.excluded);
122
212
  const preloadNames = new Set(policy.preloaded);
@@ -131,14 +221,169 @@ export const createToolRegistry = (config) => {
131
221
  else
132
222
  discoverable.push(compact(d));
133
223
  }
134
- return { preloaded, discoverable, meta: META_TOOL_DESCRIPTOR_LIST.map(compact) };
224
+ /**
225
+ * `find_tools` is advertised only when a search is wired.
226
+ *
227
+ * The alternative — a permanent descriptor that fails at execution — costs the model a call to discover
228
+ * and reads in a transcript exactly like a broken platform.
229
+ */
230
+ const meta = META_TOOL_DESCRIPTOR_LIST.filter((d) => (d.name !== "find_tools" || config.search !== undefined) &&
231
+ // Same rule, same reason: a descriptor that fails at execution costs the model a call to discover and
232
+ // reads in a transcript exactly like a broken platform — #244.
233
+ (d.name !== "load_skill" || config.skills !== undefined)).map(compact);
234
+ const toolset = config.toolsets === undefined ? undefined : await config.toolsets.resolve(context);
235
+ if (config.catalogBudget === undefined)
236
+ return {
237
+ preloaded,
238
+ discoverable,
239
+ meta,
240
+ ...(toolset === undefined ? {} : { toolset }),
241
+ };
242
+ // Preloaded entries and the meta-tools are charged against the budget but never dropped: they are the
243
+ // host's own instruction and the model's route back to what was withheld.
244
+ const fixed = preloaded.reduce((total, d) => total + entryTokens(compact(d)), 0) +
245
+ meta.reduce((total, entry) => total + entryTokens(entry), 0);
246
+ const outcome = applyTokenBudget({
247
+ items: discoverable,
248
+ budget: { maxTokens: Math.max(0, config.catalogBudget.maxTokens - fixed) },
249
+ tokensOf: entryTokens,
250
+ nameOf: (entry) => entry.name,
251
+ });
252
+ return {
253
+ preloaded,
254
+ discoverable: outcome.resident,
255
+ meta,
256
+ ...(toolset === undefined ? {} : { toolset }),
257
+ ...(outcome.dropped.length === 0 && !outcome.overBudget
258
+ ? {}
259
+ : {
260
+ truncation: {
261
+ budgetTokens: config.catalogBudget.maxTokens,
262
+ residentTokens: outcome.residentTokens + fixed,
263
+ dropped: outcome.dropped,
264
+ findable: config.search !== undefined,
265
+ overBudget: outcome.residentTokens + fixed > config.catalogBudget.maxTokens,
266
+ },
267
+ }),
268
+ };
269
+ },
270
+ find: runFind,
271
+ async listAuthorized(context) {
272
+ return (await authorizedTools(context)).map((t) => t.descriptor);
135
273
  },
136
274
  async learn(context, names) {
137
275
  // Only authorized tools are returned — an unauthorized name is silently unlearnable.
138
276
  const wanted = new Set(names);
139
277
  return (await authorizedTools(context)).map((t) => t.descriptor).filter((d) => wanted.has(d.name));
140
278
  },
141
- async execute(context, input) {
279
+ async execute(context, outer) {
280
+ /**
281
+ * `execute_tool`, unwrapped before anything else — task #210.
282
+ *
283
+ * Without this, `find_tools` finds a tool the model **cannot call**: a truncated tool list means the name
284
+ * it just learned is not in its own tool set, so search would return an answer and leave the model stuck.
285
+ * That is the difference between a deferral and an amputation, and it was missing — `execute_tool` has been
286
+ * in `META_TOOLS` since the registry was written and nothing implemented it.
287
+ *
288
+ * Unwrapping rather than dispatching: the inner call then goes through *every* check below — authorization,
289
+ * the toolset, the approval gate, validation, idempotency, the shadow recorder — because it is the same
290
+ * code path. A separate branch that called the tool directly would be a way around all of them, reachable
291
+ * by name from a model.
292
+ */
293
+ if (outer.name === "execute_tool") {
294
+ const unwrapped = unwrapExecuteTool(outer);
295
+ if ("error" in unwrapped)
296
+ return { ok: false, error: unwrapped.error };
297
+ /**
298
+ * Re-entered through the public surface, and tagged once on the way out.
299
+ *
300
+ * Re-entering rather than falling through with a rewritten argument: the inner call then passes every
301
+ * check below exactly as a direct call would — authorization, the tenant's toolset, the approval gate,
302
+ * validation, idempotency, the shadow recorder — because it *is* a direct call. A fall-through would work
303
+ * today and become a bypass the first time somebody adds a check above this line.
304
+ *
305
+ * `ranToolName` is attached here, in the one place the indirection is known. The audit trail's question
306
+ * is "what was done", and `execute_tool` is not an answer to it.
307
+ */
308
+ const inner = await api.execute(context, unwrapped);
309
+ return { ...inner, ranToolName: unwrapped.name };
310
+ }
311
+ const input = outer;
312
+ /**
313
+ * `learn_tools`, handled here — task #210, and the leg that was missing.
314
+ *
315
+ * `find_tools` returns names and descriptions. A model that then calls the tool through `execute_tool` has
316
+ * to guess its arguments, and in the 200-tool measurement it did exactly that: searched, found the right
317
+ * tool, and called it wrongly or not at all. Search without schemas is a dead end, and the descriptor for
318
+ * `learn_tools` had been in `META_TOOLS` since the registry was written with nothing implementing it.
319
+ */
320
+ /**
321
+ * `load_skill` — task #244, and the third meta-tool that was advertised with nothing behind it.
322
+ *
323
+ * Handled in the registry for the reason `find_tools` is: the model reaches it as a tool, so it has to go
324
+ * through the same surface. A per-run ceiling bounds what one run may pull into context — a model that
325
+ * loads every skill it can see has undone the whole point of a catalogue plus on-demand bodies.
326
+ *
327
+ * **Only when a loader is wired**, and unlike `find_tools` this branch does not refuse when it is not — it
328
+ * falls through to provider dispatch. `find_tools` has to be the registry's (its corpus *is* the registry's
329
+ * authorized list), but `load_skill` can perfectly well be a provider tool, and the reference host had one
330
+ * before this existed. Intercepting the name unconditionally broke it: a host with a working `load_skill`
331
+ * got `capability_unavailable` because *this* registry had no resolver. The built-in is a default, not a
332
+ * claim on the name.
333
+ */
334
+ if (input.name === "load_skill" && config.skills !== undefined) {
335
+ const asked = (input.input ?? {});
336
+ if (typeof asked.name !== "string" || asked.name.trim() === "")
337
+ return { ok: false, error: invalidInput("load_skill needs `name`: the skill whose instructions you want.") };
338
+ const runKey = context.runId ?? "no-run";
339
+ const loaded = skillsLoadedPerRun.get(runKey) ?? new Set();
340
+ // Counted per distinct name, so re-loading one already in context is free rather than spending the
341
+ // ceiling twice on the same content.
342
+ if (!loaded.has(asked.name) && loaded.size >= MAX_SKILLS_LOADED_PER_RUN)
343
+ return {
344
+ ok: false,
345
+ error: capabilityUnavailable(`this run has already loaded ${MAX_SKILLS_LOADED_PER_RUN} skills, which is the ceiling. ` +
346
+ `Already loaded: ${[...loaded].join(", ")}.`),
347
+ };
348
+ const skill = await config.skills.load(context, asked.name);
349
+ if (skill === null)
350
+ return {
351
+ ok: false,
352
+ error: invalidInput(`No skill named "${asked.name}" is available to this tenant. The skills you can load are listed in ` +
353
+ "your context."),
354
+ };
355
+ loaded.add(skill.name);
356
+ skillsLoadedPerRun.set(runKey, loaded);
357
+ return { ok: true, data: { name: skill.name, version: skill.version, instructions: skill.instructions } };
358
+ }
359
+ if (input.name === "learn_tools") {
360
+ const asked = (input.input ?? {});
361
+ const names = Array.isArray(asked.names) ? asked.names.filter((n) => typeof n === "string") : [];
362
+ if (names.length === 0)
363
+ return { ok: false, error: invalidInput("learn_tools needs `names`: the tools whose schemas you want.") };
364
+ // Authorized only, like discovery and like search — an unauthorized name is silently unlearnable.
365
+ return { ok: true, data: { tools: await api.learn(context, names) } };
366
+ }
367
+ /**
368
+ * `find_tools`, handled here rather than by a provider — AC-1.
369
+ *
370
+ * It has to be the registry: the corpus *is* the registry's authorized tool list, and a provider-supplied
371
+ * search tool would either need the registry passed into it (a construction cycle) or its own idea of what
372
+ * exists, which is the second implementation AC-2 forbids.
373
+ */
374
+ if (input.name === "find_tools") {
375
+ if (config.search === undefined)
376
+ return {
377
+ ok: false,
378
+ error: capabilityUnavailable("find_tools is not available: no tool search is configured (see ToolRegistryConfig.search)."),
379
+ };
380
+ const asked = (input.input ?? {});
381
+ if (typeof asked.query !== "string" || asked.query.trim() === "")
382
+ return { ok: false, error: invalidInput("find_tools needs a query describing what you are trying to do.") };
383
+ const limit = typeof asked.limit === "number" && asked.limit > 0 ? Math.min(Math.floor(asked.limit), 25) : 10;
384
+ const outcome = await runFind(context, { query: asked.query, limit });
385
+ return { ok: true, data: outcome };
386
+ }
142
387
  const tool = await findAuthorized(context, input.name);
143
388
  // Not found OR not authorized → both reject; execution is never a way around discovery filtering.
144
389
  if (!tool) {
@@ -272,6 +517,7 @@ export const createToolRegistry = (config) => {
272
517
  return { ok: true, data: value };
273
518
  },
274
519
  };
520
+ return api;
275
521
  /** Spill an oversize success payload to blob storage and reference it. */
276
522
  async function maybeSpill(context, result) {
277
523
  if (!result.ok || result.spilledOutputRef !== undefined)
@@ -32,6 +32,14 @@ export type UsageEvent = {
32
32
  readonly inputTokens: number;
33
33
  readonly outputTokens: number;
34
34
  readonly cachedInputTokens: number;
35
+ /**
36
+ * Tokens written into a prompt cache — task #247.
37
+ *
38
+ * Optional, and absent means "not reported" rather than "none": rows written before this existed and a turn
39
+ * whose provider reports no breakdown are different facts, and a zero would merge them. The same rule
40
+ * `imageCount` follows.
41
+ */
42
+ readonly cacheWriteTokens?: number;
35
43
  readonly reasoningTokens?: number;
36
44
  /**
37
45
  * Non-text input this turn carried — #185 AC-4.
@@ -70,6 +78,18 @@ export type CostEstimate = {
70
78
  readonly modelId: string;
71
79
  readonly inputTokens: number;
72
80
  readonly maxOutputTokens: number;
81
+ /**
82
+ * How much of `inputTokens` the caller expects to be served from cache — task #247.
83
+ *
84
+ * Optional, and its absence means "assume none", which is what this did unconditionally before. That default
85
+ * is deliberately the *conservative* one for a spend limit: over-reserving refuses a turn slightly early,
86
+ * under-reserving admits a turn that then exceeds the ceiling, and only the second one costs money.
87
+ *
88
+ * A seam rather than a prediction. The platform cannot know a hit rate before the call, and inventing one
89
+ * would make every reservation wrong in a new way; a caller that has just observed 97% on the previous turn of
90
+ * the same conversation genuinely does know something, and this is where it says so.
91
+ */
92
+ readonly cachedInputTokens?: number;
73
93
  };
74
94
  export type Reservation = {
75
95
  readonly id: string;
@@ -101,5 +121,6 @@ export interface PricingResolver {
101
121
  }
102
122
  export * from "./recorder.js";
103
123
  export * from "./quota.js";
124
+ export * from "./rate-limit.js";
104
125
  export * from "./rollups.js";
105
126
  //# sourceMappingURL=index.d.ts.map
@@ -16,5 +16,6 @@
16
16
  export const usageDedupeKey = (event) => (event.stepId === undefined ? event.id : `${event.runId}:${event.stepId}`);
17
17
  export * from "./recorder.js";
18
18
  export * from "./quota.js";
19
+ export * from "./rate-limit.js";
19
20
  export * from "./rollups.js";
20
21
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Per-tenant rate limiting at run admission — REQ-058 (#246), task #248.
3
+ *
4
+ * Cost quotas already gate admission and work well: reservations, `UsageCeiling`, refusal before a provider call
5
+ * (`quota.ts`). **Rate is a different axis and nothing bounded it.** A thousand runs a second, each costing a
6
+ * fraction of a cent, passes every check that existed — `grep -ril "rate limit"` found the phrase only in
7
+ * `runtime/retry.ts`, handling a *provider's* 429, and in the load-test scenario.
8
+ *
9
+ * The two fail differently, which is why this is not a dimension of the quota guard:
10
+ *
11
+ * - A cost ceiling stops a tenant **spending too much over a period**. It is answered from a rollup.
12
+ * - A rate limit stops a tenant **consuming a deployment's capacity right now**. It is answered from a counter
13
+ * that has to be correct across every process in the fleet.
14
+ *
15
+ * ## Why this lives in `usage/`
16
+ *
17
+ * It is not usage, and it sits here anyway: it is an *admission guard*, it is called on the same line as the
18
+ * quota guard, a deployment configures both in the same place, and putting it in its own module would add a
19
+ * published subpath for one port. The alternative was worse than the mild misfiling.
20
+ *
21
+ * ## Fixed window, and what that costs
22
+ *
23
+ * The window is identified by its **start, truncated to the period** — the same decision `bucketStartFor` makes
24
+ * for rollups, for the same reason: two processes asking "which window does T belong to" must agree, and they do
25
+ * because truncation is a pure function of T rather than a range someone chooses. It also means the Redis key is
26
+ * deterministic, so a key can never be created without an expiry and strand a tenant.
27
+ *
28
+ * The cost is the boundary burst: a tenant may send `max` at the end of one window and `max` at the start of the
29
+ * next, so the true worst case over a sliding window is 2×`max`. A sliding-log implementation would fix that and
30
+ * costs a sorted set per tenant with a member per request. Not worth it: the point is to stop a runaway client
31
+ * saturating a fleet, and 2× the intended rate for one window boundary does not.
32
+ *
33
+ * ## Two axes deliberately not implemented — AC-1
34
+ *
35
+ * **Concurrent runs per tenant.** This is a real gap: `startOrEnqueueRun` serialises runs *within* a
36
+ * conversation, and `serialization.ts` says outright that a conversation-less run's concurrency is "bounded
37
+ * where it should be: the worker's own limits, and quotas" — which is a per-process setting and a spend limit,
38
+ * neither of which stops one tenant occupying every slot in the fleet. It is left out because a *correct*
39
+ * implementation must be crash-safe, and a counter incremented at admission and decremented at completion leaks
40
+ * a permanent unit every time a worker dies mid-run. The right home is the existing run **lease**, which already
41
+ * has a TTL and a heartbeat — so this belongs with the lease rather than beside it, and doing it here would mean
42
+ * shipping the leaky version first.
43
+ *
44
+ * **Tool executions per run per interval.** `ExecutionLimits.maxToolCalls` already bounds the *count* and
45
+ * `wallClockTimeoutMs` bounds a tight loop, so a rate would need a clock threaded through the tool path to
46
+ * constrain something already constrained twice.
47
+ */
48
+ import type { ExecutionContext } from "../core/context.js";
49
+ import type { TenantId } from "../core/ids.js";
50
+ /**
51
+ * How many admissions a tenant gets, and over how long.
52
+ *
53
+ * `max: 0` and an absent policy both mean **unlimited** — see `createRateLimitGuard`. A deployment upgrading
54
+ * into this feature must not find its runs refused because it has not configured a limit yet.
55
+ */
56
+ export type RateLimitPolicy = {
57
+ readonly max: number;
58
+ readonly windowSeconds: number;
59
+ };
60
+ export type RateLimitDecision =
61
+ /** `remaining` is after this admission, so zero means the next one refuses. */
62
+ {
63
+ readonly admitted: true;
64
+ readonly remaining: number;
65
+ readonly resetsAt: string;
66
+ } | {
67
+ readonly admitted: false;
68
+ readonly limit: number;
69
+ readonly windowSeconds: number;
70
+ readonly used: number;
71
+ readonly resetsAt: string;
72
+ readonly retryAfterMs: number;
73
+ readonly message: string;
74
+ };
75
+ /**
76
+ * The counter, as a port.
77
+ *
78
+ * `consume` must be **atomic** and must set the expiry in the same operation. An `INCR` followed by a separate
79
+ * `EXPIRE` is the obvious implementation and it is wrong: a process dying between the two leaves a key with no
80
+ * TTL, and that tenant is refused for ever. The Redis adapter uses one script.
81
+ */
82
+ export interface RateLimitStore {
83
+ consume(input: {
84
+ readonly tenantId: TenantId;
85
+ /** The truncated window start, in epoch milliseconds — the key's identity. */
86
+ readonly windowStartMs: number;
87
+ readonly windowSeconds: number;
88
+ }): Promise<number>;
89
+ }
90
+ /** The window a moment belongs to. Pure, so every process agrees without coordinating. */
91
+ export declare const windowStartMs: (atMs: number, windowSeconds: number) => number;
92
+ /**
93
+ * Told when a tenant is refused.
94
+ *
95
+ * A sink of its own rather than a `RunEvent`, and the reason is the one `QuotaObserver` already gives: a
96
+ * `RunEvent` carries a `runId`, and this fires **before a run exists** — which is the whole point of refusing at
97
+ * admission. Squeezing it into the run stream would mean inventing a run id for an event about not starting one.
98
+ */
99
+ export interface RateLimitObserver {
100
+ onRefusal(context: ExecutionContext, refusal: Extract<RateLimitDecision, {
101
+ admitted: false;
102
+ }>): Promise<void> | void;
103
+ }
104
+ export type RateLimitGuardDeps = {
105
+ readonly store: RateLimitStore;
106
+ /**
107
+ * The policy for this tenant, or undefined for unlimited.
108
+ *
109
+ * A function rather than a value, for the reason `QuotaGuardDeps` gives: limits are per tenant and change
110
+ * without a redeploy, and a value captured at construction would be the limits of whoever booted the process.
111
+ */
112
+ readonly policyFor: (context: ExecutionContext) => Promise<RateLimitPolicy | undefined> | RateLimitPolicy | undefined;
113
+ readonly observer?: RateLimitObserver;
114
+ readonly now?: () => number;
115
+ };
116
+ export declare const createRateLimitGuard: (deps: RateLimitGuardDeps) => {
117
+ admit(context: ExecutionContext): Promise<RateLimitDecision>;
118
+ /** Throws the refusal, for a caller that would rather not branch. Same decision, different ergonomics. */
119
+ assertAdmitted(context: ExecutionContext): Promise<RateLimitDecision>;
120
+ };
121
+ export type RateLimitGuard = ReturnType<typeof createRateLimitGuard>;
122
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Per-tenant rate limiting at run admission — REQ-058 (#246), task #248.
3
+ *
4
+ * Cost quotas already gate admission and work well: reservations, `UsageCeiling`, refusal before a provider call
5
+ * (`quota.ts`). **Rate is a different axis and nothing bounded it.** A thousand runs a second, each costing a
6
+ * fraction of a cent, passes every check that existed — `grep -ril "rate limit"` found the phrase only in
7
+ * `runtime/retry.ts`, handling a *provider's* 429, and in the load-test scenario.
8
+ *
9
+ * The two fail differently, which is why this is not a dimension of the quota guard:
10
+ *
11
+ * - A cost ceiling stops a tenant **spending too much over a period**. It is answered from a rollup.
12
+ * - A rate limit stops a tenant **consuming a deployment's capacity right now**. It is answered from a counter
13
+ * that has to be correct across every process in the fleet.
14
+ *
15
+ * ## Why this lives in `usage/`
16
+ *
17
+ * It is not usage, and it sits here anyway: it is an *admission guard*, it is called on the same line as the
18
+ * quota guard, a deployment configures both in the same place, and putting it in its own module would add a
19
+ * published subpath for one port. The alternative was worse than the mild misfiling.
20
+ *
21
+ * ## Fixed window, and what that costs
22
+ *
23
+ * The window is identified by its **start, truncated to the period** — the same decision `bucketStartFor` makes
24
+ * for rollups, for the same reason: two processes asking "which window does T belong to" must agree, and they do
25
+ * because truncation is a pure function of T rather than a range someone chooses. It also means the Redis key is
26
+ * deterministic, so a key can never be created without an expiry and strand a tenant.
27
+ *
28
+ * The cost is the boundary burst: a tenant may send `max` at the end of one window and `max` at the start of the
29
+ * next, so the true worst case over a sliding window is 2×`max`. A sliding-log implementation would fix that and
30
+ * costs a sorted set per tenant with a member per request. Not worth it: the point is to stop a runaway client
31
+ * saturating a fleet, and 2× the intended rate for one window boundary does not.
32
+ *
33
+ * ## Two axes deliberately not implemented — AC-1
34
+ *
35
+ * **Concurrent runs per tenant.** This is a real gap: `startOrEnqueueRun` serialises runs *within* a
36
+ * conversation, and `serialization.ts` says outright that a conversation-less run's concurrency is "bounded
37
+ * where it should be: the worker's own limits, and quotas" — which is a per-process setting and a spend limit,
38
+ * neither of which stops one tenant occupying every slot in the fleet. It is left out because a *correct*
39
+ * implementation must be crash-safe, and a counter incremented at admission and decremented at completion leaks
40
+ * a permanent unit every time a worker dies mid-run. The right home is the existing run **lease**, which already
41
+ * has a TTL and a heartbeat — so this belongs with the lease rather than beside it, and doing it here would mean
42
+ * shipping the leaky version first.
43
+ *
44
+ * **Tool executions per run per interval.** `ExecutionLimits.maxToolCalls` already bounds the *count* and
45
+ * `wallClockTimeoutMs` bounds a tight loop, so a rate would need a clock threaded through the tool path to
46
+ * constrain something already constrained twice.
47
+ */
48
+ import { AgentPlatformError } from "../core/errors.js";
49
+ /** The window a moment belongs to. Pure, so every process agrees without coordinating. */
50
+ export const windowStartMs = (atMs, windowSeconds) => {
51
+ if (!Number.isFinite(atMs))
52
+ throw new AgentPlatformError({ code: "invalid_input", message: "rate limit: `at` must be a finite number of milliseconds", retryable: false });
53
+ if (!Number.isInteger(windowSeconds) || windowSeconds <= 0)
54
+ throw new AgentPlatformError({
55
+ code: "invalid_input",
56
+ message: `rate limit: windowSeconds must be a positive integer, got ${windowSeconds}`,
57
+ retryable: false,
58
+ });
59
+ const span = windowSeconds * 1_000;
60
+ return Math.floor(atMs / span) * span;
61
+ };
62
+ export const createRateLimitGuard = (deps) => {
63
+ const now = deps.now ?? Date.now;
64
+ return {
65
+ async admit(context) {
66
+ const policy = await deps.policyFor(context);
67
+ /**
68
+ * Unlimited, and this is the branch that matters most on the day this ships — AC-7.
69
+ *
70
+ * No policy, or a `max` of zero, means unlimited rather than deny-everything. A deployment that upgrades
71
+ * into this feature and has configured nothing must keep working; the alternative is an outage caused by
72
+ * adding a safety feature, which is how safety features get removed.
73
+ */
74
+ if (policy === undefined || policy.max <= 0)
75
+ return { admitted: true, remaining: Number.POSITIVE_INFINITY, resetsAt: new Date(now()).toISOString() };
76
+ const at = now();
77
+ const start = windowStartMs(at, policy.windowSeconds);
78
+ const resetsAtMs = start + policy.windowSeconds * 1_000;
79
+ const resetsAt = new Date(resetsAtMs).toISOString();
80
+ const used = await deps.store.consume({
81
+ tenantId: context.tenantId,
82
+ windowStartMs: start,
83
+ windowSeconds: policy.windowSeconds,
84
+ });
85
+ if (used <= policy.max)
86
+ return { admitted: true, remaining: policy.max - used, resetsAt };
87
+ const refusal = {
88
+ admitted: false,
89
+ limit: policy.max,
90
+ windowSeconds: policy.windowSeconds,
91
+ used,
92
+ resetsAt,
93
+ // At least a millisecond: a window that has just closed must not tell a client to retry in zero, which
94
+ // reads as "immediately" and puts it straight back into the same refusal.
95
+ retryAfterMs: Math.max(1, resetsAtMs - at),
96
+ message: `This workspace has used ${used} of ${policy.max} runs allowed per ${policy.windowSeconds}s. ` +
97
+ `It resets at ${resetsAt}.`,
98
+ };
99
+ await deps.observer?.onRefusal(context, refusal);
100
+ return refusal;
101
+ },
102
+ /** Throws the refusal, for a caller that would rather not branch. Same decision, different ergonomics. */
103
+ async assertAdmitted(context) {
104
+ const decision = await this.admit(context);
105
+ if (!decision.admitted)
106
+ throw new AgentPlatformError({
107
+ code: "admission_rate_limited",
108
+ message: decision.message,
109
+ /**
110
+ * Retryable, and `retryAfterMs` is what makes that honest.
111
+ *
112
+ * The window resets, so a client that treated this as permanent would give up on a workspace that is
113
+ * fine in a second. What must **not** happen is the engine's retry loop absorbing it — and it cannot:
114
+ * this is thrown at admission, before a run exists, so `decideRetry` never sees it. The distinct error
115
+ * code is what keeps it that way if the call site ever moves.
116
+ */
117
+ retryable: true,
118
+ retryAfterMs: decision.retryAfterMs,
119
+ details: {
120
+ retryAfter: decision.resetsAt,
121
+ retryAfterMs: decision.retryAfterMs,
122
+ limit: decision.limit,
123
+ used: decision.used,
124
+ windowSeconds: decision.windowSeconds,
125
+ },
126
+ });
127
+ return decision;
128
+ },
129
+ };
130
+ };
131
+ //# sourceMappingURL=rate-limit.js.map
@@ -49,6 +49,9 @@ export const createUsageRecorder = (config) => {
49
49
  return computeModelCostMinorUnits(pricing, {
50
50
  inputTokens: estimate.inputTokens,
51
51
  outputTokens: estimate.maxOutputTokens,
52
+ // Cache-aware when the caller said so — task #247. Absent means assume none, which over-reserves; that is
53
+ // the safe direction for a spend limit and is what this always did.
54
+ ...(estimate.cachedInputTokens === undefined ? {} : { cachedInputTokens: estimate.cachedInputTokens }),
52
55
  });
53
56
  };
54
57
  return {