@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.
- package/README.md +59 -277
- package/dist/adapters/audio/openai.d.ts +36 -0
- package/dist/adapters/audio/openai.js +136 -0
- package/dist/adapters/bullmq/lock.d.ts +26 -2
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/adapters/memory/connections.d.ts +10 -0
- package/dist/adapters/memory/connections.js +87 -0
- package/dist/adapters/memory/graph.d.ts +17 -0
- package/dist/adapters/memory/graph.js +267 -0
- package/dist/adapters/memory/index.d.ts +3 -0
- package/dist/adapters/memory/index.js +3 -0
- package/dist/adapters/memory/rate-limit.d.ts +14 -0
- package/dist/adapters/memory/rate-limit.js +28 -0
- package/dist/adapters/memory/runtime.js +0 -0
- package/dist/adapters/postgres/connections.d.ts +15 -0
- package/dist/adapters/postgres/connections.js +134 -0
- package/dist/adapters/postgres/graph.d.ts +24 -0
- package/dist/adapters/postgres/graph.js +325 -0
- package/dist/adapters/postgres/index.d.ts +2 -0
- package/dist/adapters/postgres/index.js +2 -0
- package/dist/adapters/postgres/migrations.js +198 -0
- package/dist/adapters/postgres/run-store.js +26 -2
- package/dist/adapters/postgres/schema.d.ts +61 -2
- package/dist/adapters/postgres/schema.js +95 -10
- package/dist/adapters/redis/index.d.ts +1 -0
- package/dist/adapters/redis/index.js +1 -0
- package/dist/adapters/redis/rate-limit.d.ts +46 -0
- package/dist/adapters/redis/rate-limit.js +55 -0
- package/dist/adapters/supabase/index.d.ts +15 -0
- package/dist/adapters/supabase/index.js +15 -0
- package/dist/adapters/supabase/rls.js +33 -0
- package/dist/agents/agent.d.ts +99 -1
- package/dist/agents/agent.js +215 -16
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +48 -0
- package/dist/agents/engine.js +370 -13
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -0
- package/dist/capabilities/index.d.ts +5 -1
- package/dist/capabilities/index.js +23 -0
- package/dist/capabilities/runtime.d.ts +8 -0
- package/dist/connections/cipher.d.ts +103 -0
- package/dist/connections/cipher.js +141 -0
- package/dist/connections/index.d.ts +156 -0
- package/dist/connections/index.js +72 -0
- package/dist/connections/oauth/client.d.ts +84 -0
- package/dist/connections/oauth/client.js +141 -0
- package/dist/connections/oauth/index.d.ts +166 -0
- package/dist/connections/oauth/index.js +226 -0
- package/dist/connections/oauth/service.d.ts +90 -0
- package/dist/connections/oauth/service.js +132 -0
- package/dist/connections/pause.d.ts +68 -0
- package/dist/connections/pause.js +97 -0
- package/dist/connections/resolver.d.ts +59 -0
- package/dist/connections/resolver.js +138 -0
- package/dist/connections/resume.d.ts +64 -0
- package/dist/connections/resume.js +99 -0
- package/dist/connections/vault-cipher.d.ts +95 -0
- package/dist/connections/vault-cipher.js +206 -0
- package/dist/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +26 -2
- package/dist/core/content-parts.js +1 -0
- package/dist/core/context.d.ts +25 -0
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +15 -0
- package/dist/core/events.d.ts +110 -2
- package/dist/core/events.js +33 -2
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/validation.js +19 -0
- package/dist/documents/index.d.ts +14 -0
- package/dist/documents/parsers/text.d.ts +16 -0
- package/dist/documents/parsers/text.js +54 -2
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +17 -0
- package/dist/entries/knowledge.js +16 -0
- package/dist/entries/mcp-server.d.ts +9 -0
- package/dist/entries/mcp-server.js +9 -0
- package/dist/entries/runtime.d.ts +10 -0
- package/dist/entries/runtime.js +8 -0
- package/dist/entries/testing.d.ts +28 -0
- package/dist/entries/testing.js +28 -0
- package/dist/graphql/resolvers.d.ts +7 -1
- package/dist/graphql/resolvers.js +20 -0
- package/dist/graphql/schema.d.ts +1 -1
- package/dist/graphql/schema.js +44 -0
- package/dist/guardrails/index.d.ts +115 -0
- package/dist/guardrails/index.js +108 -0
- package/dist/guardrails/moderation.d.ts +53 -0
- package/dist/guardrails/moderation.js +75 -0
- package/dist/guardrails/pii.d.ts +75 -0
- package/dist/guardrails/pii.js +193 -0
- package/dist/knowledge/communities.d.ts +166 -0
- package/dist/knowledge/communities.js +377 -0
- package/dist/knowledge/graph-global.d.ts +153 -0
- package/dist/knowledge/graph-global.js +246 -0
- package/dist/knowledge/graph-retrieval.d.ts +150 -0
- package/dist/knowledge/graph-retrieval.js +303 -0
- package/dist/knowledge/graph.d.ts +223 -0
- package/dist/knowledge/graph.js +369 -0
- package/dist/knowledge/index.d.ts +25 -0
- package/dist/knowledge/index.js +37 -1
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +98 -5
- package/dist/knowledge/retrieval.js +158 -28
- package/dist/mcp-server/index.d.ts +168 -0
- package/dist/mcp-server/index.js +175 -0
- package/dist/models/extraction.d.ts +73 -0
- package/dist/models/extraction.js +118 -0
- package/dist/models/index.d.ts +40 -1
- package/dist/models/index.js +22 -1
- package/dist/models/pricing.d.ts +2 -0
- package/dist/models/pricing.js +17 -1
- package/dist/models/provider-factory.js +9 -7
- package/dist/models/streaming.d.ts +111 -1
- package/dist/models/streaming.js +184 -3
- package/dist/persistence/index.d.ts +230 -0
- package/dist/persistence/index.js +2 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +12 -0
- package/dist/runtime/worker.d.ts +36 -1
- package/dist/runtime/worker.js +37 -4
- package/dist/security/checklist.js +55 -0
- package/dist/security/findings.js +18 -9
- package/dist/server/bin.d.ts +19 -0
- package/dist/server/bin.js +226 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +7 -4
- package/dist/server/doctor.d.ts +74 -0
- package/dist/server/doctor.js +280 -0
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +2 -0
- package/dist/skills/index.js +2 -0
- package/dist/telemetry/spans.js +17 -0
- package/dist/testing/conformance/artifact-exports.d.ts +20 -0
- package/dist/testing/conformance/artifact-exports.js +172 -0
- package/dist/testing/conformance/artifacts.d.ts +11 -0
- package/dist/testing/conformance/artifacts.js +316 -0
- package/dist/testing/conformance/audio.d.ts +38 -0
- package/dist/testing/conformance/audio.js +145 -0
- package/dist/testing/conformance/capability.d.ts +23 -0
- package/dist/testing/conformance/capability.js +27 -0
- package/dist/testing/conformance/checkpoint-store.d.ts +15 -0
- package/dist/testing/conformance/checkpoint-store.js +72 -0
- package/dist/testing/conformance/connections.d.ts +14 -0
- package/dist/testing/conformance/connections.js +171 -0
- package/dist/testing/conformance/conversation-store.d.ts +10 -0
- package/dist/testing/conformance/conversation-store.js +60 -0
- package/dist/testing/conformance/evaluation.d.ts +10 -0
- package/dist/testing/conformance/evaluation.js +295 -0
- package/dist/testing/conformance/files.d.ts +19 -0
- package/dist/testing/conformance/files.js +454 -0
- package/dist/testing/conformance/flows.d.ts +16 -0
- package/dist/testing/conformance/flows.js +193 -0
- package/dist/testing/conformance/graph.d.ts +22 -0
- package/dist/testing/conformance/graph.js +500 -0
- package/dist/testing/conformance/hitl.d.ts +25 -0
- package/dist/testing/conformance/hitl.js +523 -0
- package/dist/testing/conformance/index.d.ts +163 -0
- package/dist/testing/conformance/index.js +321 -0
- package/dist/testing/conformance/invariants.d.ts +23 -0
- package/dist/testing/conformance/invariants.js +80 -0
- package/dist/testing/conformance/knowledge.d.ts +41 -0
- package/dist/testing/conformance/knowledge.js +592 -0
- package/dist/testing/conformance/parents.d.ts +50 -0
- package/dist/testing/conformance/parents.js +39 -0
- package/dist/testing/conformance/rate-limit.d.ts +25 -0
- package/dist/testing/conformance/rate-limit.js +71 -0
- package/dist/testing/conformance/records.d.ts +36 -0
- package/dist/testing/conformance/records.js +400 -0
- package/dist/testing/conformance/rollups.d.ts +30 -0
- package/dist/testing/conformance/rollups.js +425 -0
- package/dist/testing/conformance/run-coordinator.d.ts +27 -0
- package/dist/testing/conformance/run-coordinator.js +150 -0
- package/dist/testing/conformance/run-event-log.d.ts +9 -0
- package/dist/testing/conformance/run-event-log.js +138 -0
- package/dist/testing/conformance/run-store.d.ts +12 -0
- package/dist/testing/conformance/run-store.js +294 -0
- package/dist/testing/conformance/session-state.d.ts +38 -0
- package/dist/testing/conformance/session-state.js +198 -0
- package/dist/testing/conformance/usage-limits.d.ts +10 -0
- package/dist/testing/conformance/usage-limits.js +309 -0
- package/dist/testing/conformance.d.ts +8 -0
- package/dist/testing/conformance.js +8 -0
- package/dist/testing/memory-backend.d.ts +39 -0
- package/dist/testing/memory-backend.js +41 -0
- package/dist/testing/pglite.d.ts +55 -0
- package/dist/testing/pglite.js +78 -0
- package/dist/testing/stub-model.d.ts +58 -0
- package/dist/testing/stub-model.js +71 -0
- package/dist/testing/supabase-storage-double.d.ts +35 -0
- package/dist/testing/supabase-storage-double.js +101 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +31 -1
- package/dist/toolkit/index.js +26 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -0
- package/dist/toolkit/ssrf.d.ts +139 -0
- package/dist/toolkit/ssrf.js +349 -0
- package/dist/toolkit/vendor.d.ts +91 -0
- package/dist/toolkit/vendor.js +153 -0
- package/dist/toolkit/web.d.ts +13 -0
- package/dist/toolkit/web.js +7 -1
- package/dist/tools/budget.d.ts +28 -0
- package/dist/tools/budget.js +35 -0
- package/dist/tools/credentials.d.ts +316 -0
- package/dist/tools/credentials.js +369 -0
- package/dist/tools/define.d.ts +45 -0
- package/dist/tools/define.js +26 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +35 -2
- package/dist/tools/index.js +11 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/fs.d.ts +24 -0
- package/dist/tools/library/fs.js +102 -0
- package/dist/tools/library/index.d.ts +37 -2
- package/dist/tools/library/index.js +47 -0
- package/dist/tools/library/shell.d.ts +45 -0
- package/dist/tools/library/shell.js +70 -0
- package/dist/tools/meta-tools.js +8 -0
- package/dist/tools/registry.d.ts +146 -0
- package/dist/tools/registry.js +250 -4
- package/dist/usage/index.d.ts +21 -0
- package/dist/usage/index.js +1 -0
- package/dist/usage/rate-limit.d.ts +122 -0
- package/dist/usage/rate-limit.js +131 -0
- package/dist/usage/recorder.js +3 -0
- package/package.json +33 -2
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
package/dist/usage/index.d.ts
CHANGED
|
@@ -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
|
package/dist/usage/index.js
CHANGED
|
@@ -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
|
package/dist/usage/recorder.js
CHANGED
|
@@ -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 {
|