@retinue/agentkit 0.2.0 → 0.3.1
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 +1 -1
- 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/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 +78 -1
- package/dist/agents/agent.js +118 -5
- package/dist/agents/define.js +24 -4
- package/dist/agents/engine.d.ts +20 -0
- package/dist/agents/engine.js +177 -6
- package/dist/audio/index.d.ts +151 -0
- package/dist/audio/index.js +183 -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/content-parts.d.ts +18 -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 +44 -2
- package/dist/core/events.js +31 -2
- package/dist/core/validation.js +19 -0
- package/dist/entries/connections.d.ts +10 -0
- package/dist/entries/connections.js +10 -0
- package/dist/entries/knowledge.d.ts +8 -0
- package/dist/entries/knowledge.js +8 -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 +3 -1
- package/dist/graphql/resolvers.js +14 -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 +24 -0
- package/dist/knowledge/index.js +36 -1
- package/dist/knowledge/retrieval.d.ts +26 -1
- package/dist/knowledge/retrieval.js +77 -1
- 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 +89 -0
- package/dist/models/streaming.js +179 -2
- 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 +46 -0
- package/dist/server/bin.d.ts +72 -0
- package/dist/server/bin.js +304 -0
- package/dist/server/boot.d.ts +20 -2
- package/dist/server/boot.js +29 -3
- package/dist/server/cli-worker.js +6 -3
- package/dist/server/cli.d.ts +13 -0
- package/dist/server/cli.js +10 -5
- package/dist/server/config.d.ts +11 -1
- package/dist/server/config.js +18 -0
- package/dist/server/doctor.d.ts +85 -0
- package/dist/server/doctor.js +280 -0
- package/dist/server/pool.d.ts +47 -0
- package/dist/server/pool.js +73 -0
- package/dist/skills/context.d.ts +61 -0
- package/dist/skills/context.js +107 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +5 -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/http.d.ts +27 -0
- package/dist/toolkit/http.js +48 -0
- package/dist/toolkit/index.d.ts +27 -1
- package/dist/toolkit/index.js +24 -1
- package/dist/toolkit/mime.d.ts +97 -0
- package/dist/toolkit/mime.js +233 -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/tools/credentials.d.ts +265 -6
- package/dist/tools/credentials.js +318 -3
- package/dist/tools/define.d.ts +14 -0
- package/dist/tools/define.js +3 -0
- package/dist/tools/index.d.ts +21 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/library/audio.d.ts +63 -0
- package/dist/tools/library/audio.js +148 -0
- package/dist/tools/library/index.d.ts +8 -0
- package/dist/tools/library/index.js +7 -0
- package/dist/tools/registry.d.ts +33 -0
- package/dist/tools/registry.js +72 -2
- 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 +29 -2
package/dist/tools/define.d.ts
CHANGED
|
@@ -16,6 +16,20 @@ export type ToolSpec<I = unknown, O = unknown> = {
|
|
|
16
16
|
readonly inputSchema?: unknown;
|
|
17
17
|
readonly outputSchema?: unknown;
|
|
18
18
|
readonly requiresIdempotencyKey?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* The provider scopes this tool needs — #260's field, made settable by #234.
|
|
21
|
+
*
|
|
22
|
+
* It was declared on `ToolDescriptor` and had **no way to be set**: `ToolSpec` did not carry it, so nothing
|
|
23
|
+
* in the repository could populate it and nothing read it. A field that cannot be written is a field that
|
|
24
|
+
* documents an intention rather than expressing one, which is the defect class `check:reachability` exists
|
|
25
|
+
* for — it was missed because the check asks whether declared fields are *read*, and an unwritable field is
|
|
26
|
+
* never read either.
|
|
27
|
+
*
|
|
28
|
+
* Google is the first vendor where it matters: `gmail.send` and `gmail.readonly` are different consents, and
|
|
29
|
+
* asking for the wrong one is the difference between a security team approving an integration and refusing
|
|
30
|
+
* it.
|
|
31
|
+
*/
|
|
32
|
+
readonly requiredScopes?: readonly string[];
|
|
19
33
|
execute(input: I, context: ExecutionContext): Promise<O> | O;
|
|
20
34
|
};
|
|
21
35
|
/** Build a `Tool` from a plain spec: `execute` returns data (or throws); the envelope is handled here. */
|
package/dist/tools/define.js
CHANGED
|
@@ -22,6 +22,9 @@ export const defineTool = (spec) => {
|
|
|
22
22
|
effect,
|
|
23
23
|
approvalPolicy: spec.approvalPolicy ?? (effect === "external-write" || effect === "destructive" ? "always" : "never"),
|
|
24
24
|
requiresIdempotencyKey: spec.requiresIdempotencyKey ?? (effect === "external-write" || effect === "destructive"),
|
|
25
|
+
// Omitted rather than defaulted to `[]`: absent means "this vendor has no scopes", which is true of
|
|
26
|
+
// every wave-1 tool, and an empty array would read as "needs none" — a different claim.
|
|
27
|
+
...(spec.requiredScopes === undefined ? {} : { requiredScopes: spec.requiredScopes }),
|
|
25
28
|
};
|
|
26
29
|
return {
|
|
27
30
|
descriptor,
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -12,6 +12,13 @@ import type { IdempotencyKey } from "../idempotency/index.js";
|
|
|
12
12
|
/**
|
|
13
13
|
* Effect classification. This drives the approval policy, so an unknown effect is
|
|
14
14
|
* never treated as `read` — see `../mcp` for how imported tools are classified.
|
|
15
|
+
*
|
|
16
|
+
* **Adding a value is more expensive than it looks — #228.** `external-write` and `destructive` appear in a
|
|
17
|
+
* disjunction three times: twice in `define.ts` (deriving `approvalPolicy` and `requiresIdempotencyKey`) and
|
|
18
|
+
* once in `registry.ts`'s `requiresKey`. A new value has to be added to all three, and missing one gives the
|
|
19
|
+
* new effect **`approvalPolicy: "never"`** by default — silently ungating the very tools it was added to
|
|
20
|
+
* protect. #228 asked for a fifth value for public publishing and declined for this reason; the gate there is
|
|
21
|
+
* an exact list in `docs/23-tool-catalogue.md`, enforced by `check:effects`.
|
|
15
22
|
*/
|
|
16
23
|
export declare const TOOL_EFFECTS: readonly ["read", "internal-write", "external-write", "destructive"];
|
|
17
24
|
export type ToolEffect = (typeof TOOL_EFFECTS)[number];
|
|
@@ -28,6 +35,20 @@ export type ToolDescriptor = {
|
|
|
28
35
|
readonly approvalPolicy: ApprovalPolicy;
|
|
29
36
|
/** External and destructive tools must supply an idempotency key. */
|
|
30
37
|
readonly requiresIdempotencyKey: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The vendor scopes this tool needs — REQ-063 (#259), task #260 AC-3.
|
|
40
|
+
*
|
|
41
|
+
* Per **tool**, not per toolkit, because that is the granularity the question is asked at: a deployment
|
|
42
|
+
* enabling three of Gmail's eight tools should be sent through a consent screen for three tools' worth of
|
|
43
|
+
* scopes, not eight. Google's restricted scopes are the case that makes this matter — asking for
|
|
44
|
+
* `gmail.modify` when only `gmail.readonly` is needed is the difference between a consent a security team
|
|
45
|
+
* approves and one they refuse.
|
|
46
|
+
*
|
|
47
|
+
* Absent means "no scope is required or the vendor has no scopes", which is true of every wave 1 tool.
|
|
48
|
+
* Declared here rather than only in prose so a connection can be checked against what the enabled tools
|
|
49
|
+
* actually need **before** a tenant is sent through consent, rather than after a 403.
|
|
50
|
+
*/
|
|
51
|
+
readonly requiredScopes?: readonly string[];
|
|
31
52
|
/**
|
|
32
53
|
* For a delegating tool (#113): the deterministic function this capability wraps.
|
|
33
54
|
*
|
package/dist/tools/index.js
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
/**
|
|
9
9
|
* Effect classification. This drives the approval policy, so an unknown effect is
|
|
10
10
|
* never treated as `read` — see `../mcp` for how imported tools are classified.
|
|
11
|
+
*
|
|
12
|
+
* **Adding a value is more expensive than it looks — #228.** `external-write` and `destructive` appear in a
|
|
13
|
+
* disjunction three times: twice in `define.ts` (deriving `approvalPolicy` and `requiresIdempotencyKey`) and
|
|
14
|
+
* once in `registry.ts`'s `requiresKey`. A new value has to be added to all three, and missing one gives the
|
|
15
|
+
* new effect **`approvalPolicy: "never"`** by default — silently ungating the very tools it was added to
|
|
16
|
+
* protect. #228 asked for a fifth value for public publishing and declined for this reason; the gate there is
|
|
17
|
+
* an exact list in `docs/23-tool-catalogue.md`, enforced by `check:effects`.
|
|
11
18
|
*/
|
|
12
19
|
export const TOOL_EFFECTS = [
|
|
13
20
|
"read",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transcribe` and `speech_generate` — REQ-062 (#257), task #258, AC-9.
|
|
3
|
+
*
|
|
4
|
+
* ## These are library tools, not a `tools-media` package — a deviation from `docs/23`
|
|
5
|
+
*
|
|
6
|
+
* The catalogue assigns both to a `tools-media` sibling package. That assignment predates the provider-port
|
|
7
|
+
* pattern being settled, and following it now would put these in the wrong place for a reason worth recording.
|
|
8
|
+
*
|
|
9
|
+
* A sibling package exists for a **vendor**: `tools-github` wraps GitHub's API, and a change to that API is a
|
|
10
|
+
* patch to one small package rather than a platform release. Neither of these tools wraps a vendor. They take a
|
|
11
|
+
* `TranscriptionProvider` and a `SpeechProvider` — ports — exactly as `web_search` takes a `SearchProvider`
|
|
12
|
+
* and lives right here in the library for that reason.
|
|
13
|
+
*
|
|
14
|
+
* Putting them in a package would mean the package had no vendor in it: Whisper, Deepgram and a self-hosted
|
|
15
|
+
* `whisper.cpp` are values of a parameter, and a `tools-media` that shipped no vendor code would be a folder
|
|
16
|
+
* whose only content is two thin wrappers over runtime ports. `docs/23`'s row is updated rather than obeyed.
|
|
17
|
+
*
|
|
18
|
+
* The other two tools that row names — `image_generate` and `video_generate` — are out of REQ-062's scope and
|
|
19
|
+
* unaffected by this. If they arrive as vendor integrations, a `tools-media` package is the right home for
|
|
20
|
+
* *them*.
|
|
21
|
+
*
|
|
22
|
+
* ## The effects, and why `speech_generate` is not an external write
|
|
23
|
+
*
|
|
24
|
+
* `transcribe` is a `read`: it looks at a recording and returns text, changes nothing, and notifies nobody.
|
|
25
|
+
*
|
|
26
|
+
* `speech_generate` is an `internal-write` because it **creates a file the tenant owns**. Nothing leaves the
|
|
27
|
+
* deployment and nobody else sees it, which is what separates `internal-write` from `external-write` — the
|
|
28
|
+
* distinction #228 settled. It costs money, which is why it is not a `read`, and the cost is bounded by the
|
|
29
|
+
* character ceiling rather than by an approval.
|
|
30
|
+
*/
|
|
31
|
+
import { type AudioBounds } from "../../audio/index.js";
|
|
32
|
+
import type { SpeechProvider, TranscriptionProvider } from "../../audio/index.js";
|
|
33
|
+
import { type DelegatingToolDeps } from "../delegating.js";
|
|
34
|
+
import type { Tool } from "../index.js";
|
|
35
|
+
/** What the tool needs in order to read an uploaded file and write a generated one. */
|
|
36
|
+
export type AudioToolDeps = {
|
|
37
|
+
/**
|
|
38
|
+
* Reads an uploaded file's bytes **through the authorized path**.
|
|
39
|
+
*
|
|
40
|
+
* A function rather than a store, because the tool must not be able to reach a file the principal cannot:
|
|
41
|
+
* the host supplies a reader already scoped to the execution context, which is the same arrangement the
|
|
42
|
+
* attachment bridge uses.
|
|
43
|
+
*/
|
|
44
|
+
readonly readFile: (fileId: string) => Promise<{
|
|
45
|
+
readonly bytes: Uint8Array;
|
|
46
|
+
readonly mediaType: string;
|
|
47
|
+
readonly filename?: string;
|
|
48
|
+
readonly byteSize: number;
|
|
49
|
+
}>;
|
|
50
|
+
/** Stores generated audio and returns what a client needs to play it. */
|
|
51
|
+
readonly writeAudio: (input: {
|
|
52
|
+
readonly bytes: Uint8Array;
|
|
53
|
+
readonly mediaType: string;
|
|
54
|
+
readonly filename: string;
|
|
55
|
+
}) => Promise<{
|
|
56
|
+
readonly fileId: string;
|
|
57
|
+
readonly url?: string;
|
|
58
|
+
}>;
|
|
59
|
+
readonly bounds?: AudioBounds;
|
|
60
|
+
};
|
|
61
|
+
export declare const createTranscribeTool: (deps: DelegatingToolDeps, provider: TranscriptionProvider, audio: AudioToolDeps) => Tool;
|
|
62
|
+
export declare const createSpeechGenerateTool: (deps: DelegatingToolDeps, provider: SpeechProvider, audio: AudioToolDeps) => Tool;
|
|
63
|
+
//# sourceMappingURL=audio.d.ts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transcribe` and `speech_generate` — REQ-062 (#257), task #258, AC-9.
|
|
3
|
+
*
|
|
4
|
+
* ## These are library tools, not a `tools-media` package — a deviation from `docs/23`
|
|
5
|
+
*
|
|
6
|
+
* The catalogue assigns both to a `tools-media` sibling package. That assignment predates the provider-port
|
|
7
|
+
* pattern being settled, and following it now would put these in the wrong place for a reason worth recording.
|
|
8
|
+
*
|
|
9
|
+
* A sibling package exists for a **vendor**: `tools-github` wraps GitHub's API, and a change to that API is a
|
|
10
|
+
* patch to one small package rather than a platform release. Neither of these tools wraps a vendor. They take a
|
|
11
|
+
* `TranscriptionProvider` and a `SpeechProvider` — ports — exactly as `web_search` takes a `SearchProvider`
|
|
12
|
+
* and lives right here in the library for that reason.
|
|
13
|
+
*
|
|
14
|
+
* Putting them in a package would mean the package had no vendor in it: Whisper, Deepgram and a self-hosted
|
|
15
|
+
* `whisper.cpp` are values of a parameter, and a `tools-media` that shipped no vendor code would be a folder
|
|
16
|
+
* whose only content is two thin wrappers over runtime ports. `docs/23`'s row is updated rather than obeyed.
|
|
17
|
+
*
|
|
18
|
+
* The other two tools that row names — `image_generate` and `video_generate` — are out of REQ-062's scope and
|
|
19
|
+
* unaffected by this. If they arrive as vendor integrations, a `tools-media` package is the right home for
|
|
20
|
+
* *them*.
|
|
21
|
+
*
|
|
22
|
+
* ## The effects, and why `speech_generate` is not an external write
|
|
23
|
+
*
|
|
24
|
+
* `transcribe` is a `read`: it looks at a recording and returns text, changes nothing, and notifies nobody.
|
|
25
|
+
*
|
|
26
|
+
* `speech_generate` is an `internal-write` because it **creates a file the tenant owns**. Nothing leaves the
|
|
27
|
+
* deployment and nobody else sees it, which is what separates `internal-write` from `external-write` — the
|
|
28
|
+
* distinction #228 settled. It costs money, which is why it is not a `read`, and the cost is bounded by the
|
|
29
|
+
* character ceiling rather than by an approval.
|
|
30
|
+
*/
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { AgentPlatformError } from "../../core/errors.js";
|
|
33
|
+
import { assertWithinBounds, MAX_SPEECH_CHARS } from "../../audio/index.js";
|
|
34
|
+
import { defineDelegatingTool } from "../delegating.js";
|
|
35
|
+
const transcribeSchema = z.object({
|
|
36
|
+
/**
|
|
37
|
+
* A file id, never bytes and never a URL.
|
|
38
|
+
*
|
|
39
|
+
* Bytes in a tool argument would mean a base64 recording inside the model's context — expensive, and it would
|
|
40
|
+
* arrive having bypassed the upload bounds entirely. A URL would make this a fetch tool with an SSRF surface.
|
|
41
|
+
* An id goes through the mediated read path, so authorization applies and the bounds were checked at upload.
|
|
42
|
+
*/
|
|
43
|
+
fileId: z.string().min(1).describe("The id of an uploaded audio file."),
|
|
44
|
+
languageHint: z
|
|
45
|
+
.string()
|
|
46
|
+
.min(2)
|
|
47
|
+
.max(8)
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("A BCP-47 language tag, if you already know it. Leave it out to let the provider detect."),
|
|
50
|
+
});
|
|
51
|
+
const speechSchema = z.object({
|
|
52
|
+
text: z.string().min(1).max(MAX_SPEECH_CHARS).describe("What to say. Plain text."),
|
|
53
|
+
voice: z.string().min(1).max(64).optional().describe("The provider's voice name, if the deployment offers a choice."),
|
|
54
|
+
format: z.enum(["mp3", "wav", "opus", "flac"]).optional(),
|
|
55
|
+
});
|
|
56
|
+
export const createTranscribeTool = (deps, provider, audio) => defineDelegatingTool(deps, {
|
|
57
|
+
name: "transcribe",
|
|
58
|
+
label: "Transcribe a recording",
|
|
59
|
+
description: "Turn an uploaded audio file into text. Give it the file's id. Returns the transcript, the detected " +
|
|
60
|
+
"language and the duration. If `truncated` is true the recording was longer than the transcript limit " +
|
|
61
|
+
"and you are reading only the beginning — say so rather than summarising it as the whole thing.",
|
|
62
|
+
category: "media",
|
|
63
|
+
effect: "read",
|
|
64
|
+
inputSchema: transcribeSchema,
|
|
65
|
+
delegatesTo: "audio.TranscriptionProvider",
|
|
66
|
+
delegate: async (input) => {
|
|
67
|
+
const file = await audio.readFile(input.fileId);
|
|
68
|
+
/**
|
|
69
|
+
* Bounds checked again here, and that is not redundant.
|
|
70
|
+
*
|
|
71
|
+
* The upload path checks what a client *declared*; this checks what was actually stored. A file that grew
|
|
72
|
+
* past the ceiling, or one uploaded before a deployment tightened its bounds, would otherwise reach the
|
|
73
|
+
* provider — and the provider's refusal costs the round trip and names the request rather than the size.
|
|
74
|
+
*/
|
|
75
|
+
try {
|
|
76
|
+
assertWithinBounds({ byteSize: file.byteSize, mediaType: file.mediaType }, audio.bounds);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new AgentPlatformError({
|
|
80
|
+
code: "invalid_input",
|
|
81
|
+
message: error instanceof Error ? error.message : String(error),
|
|
82
|
+
retryable: false,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const result = await provider.transcribe({
|
|
86
|
+
audio: file.bytes,
|
|
87
|
+
mediaType: file.mediaType,
|
|
88
|
+
...(file.filename === undefined ? {} : { filename: file.filename }),
|
|
89
|
+
...(input.languageHint === undefined ? {} : { languageHint: input.languageHint }),
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
text: result.text,
|
|
93
|
+
truncated: result.truncated,
|
|
94
|
+
...(result.durationSeconds === undefined ? {} : { durationSeconds: result.durationSeconds }),
|
|
95
|
+
...(result.language === undefined ? {} : { language: result.language }),
|
|
96
|
+
// Reported so a summary of what happened can name the provider rather than implying the platform did it.
|
|
97
|
+
provider: provider.id,
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
export const createSpeechGenerateTool = (deps, provider, audio) => defineDelegatingTool(deps, {
|
|
102
|
+
name: "speech_generate",
|
|
103
|
+
label: "Say something aloud",
|
|
104
|
+
description: "Turn text into an audio file the user can play. Returns a file id. This costs money per character, so " +
|
|
105
|
+
"say what needs saying rather than reading a whole document aloud. Requires approval by policy.",
|
|
106
|
+
category: "media",
|
|
107
|
+
// Creates a file the tenant owns; nothing leaves the deployment. See the header on why not external-write.
|
|
108
|
+
effect: "internal-write",
|
|
109
|
+
/**
|
|
110
|
+
* `policy`, set explicitly — and the explicitness is the point.
|
|
111
|
+
*
|
|
112
|
+
* `internal-write` *derives* `never`, which is right for the writes that motivated that default: a note
|
|
113
|
+
* saved to the tenant's own store costs nothing and asking about it would be noise. This one costs money
|
|
114
|
+
* per character, so a deployment should be able to decide whether an agent may spend it unattended.
|
|
115
|
+
*
|
|
116
|
+
* `policy` rather than `always` because the amount is small and bounded by the character ceiling — a
|
|
117
|
+
* mandatory click on every sentence would make the tool unusable, and `docs/23` specifies `policy` for
|
|
118
|
+
* exactly this reason.
|
|
119
|
+
*/
|
|
120
|
+
approvalPolicy: "policy",
|
|
121
|
+
inputSchema: speechSchema,
|
|
122
|
+
delegatesTo: "audio.SpeechProvider",
|
|
123
|
+
delegate: async (input) => {
|
|
124
|
+
const spoken = await provider.speak({
|
|
125
|
+
text: input.text,
|
|
126
|
+
...(input.voice === undefined ? {} : { voice: input.voice }),
|
|
127
|
+
...(input.format === undefined ? {} : { format: input.format }),
|
|
128
|
+
});
|
|
129
|
+
const extension = { "audio/mpeg": "mp3", "audio/wav": "wav", "audio/ogg": "opus", "audio/flac": "flac" }[spoken.mediaType];
|
|
130
|
+
const stored = await audio.writeAudio({
|
|
131
|
+
bytes: spoken.audio,
|
|
132
|
+
mediaType: spoken.mediaType,
|
|
133
|
+
// Named from the text so a list of generated files is readable, and bounded so a long prompt does not
|
|
134
|
+
// become a 4,000-character filename.
|
|
135
|
+
filename: `speech-${input.text.slice(0, 40).replace(/[^\w -]/g, "").trim().replace(/\s+/g, "-") || "audio"}.${extension ?? "bin"}`,
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
fileId: stored.fileId,
|
|
139
|
+
...(stored.url === undefined ? {} : { url: stored.url }),
|
|
140
|
+
mediaType: spoken.mediaType,
|
|
141
|
+
byteSize: spoken.audio.byteLength,
|
|
142
|
+
...(spoken.durationSeconds === undefined ? {} : { durationSeconds: spoken.durationSeconds }),
|
|
143
|
+
characters: input.text.length,
|
|
144
|
+
provider: provider.id,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
//# sourceMappingURL=audio.js.map
|
|
@@ -121,4 +121,12 @@ export { createHttpRequestTool, createHttpWriteTool } from "./http.js";
|
|
|
121
121
|
export { createSearchKnowledgeTool } from "./knowledge.js";
|
|
122
122
|
export type { KnowledgeRetriever } from "./knowledge.js";
|
|
123
123
|
export { createFetchJsonTool, createFetchUrlTool, createWebSearchTool } from "./web.js";
|
|
124
|
+
/**
|
|
125
|
+
* Audio — REQ-062 (#257), task #258.
|
|
126
|
+
*
|
|
127
|
+
* Library tools rather than a `tools-media` package, and the reason is argued in `audio.ts`: they take ports,
|
|
128
|
+
* not a vendor, exactly as `createWebSearchTool` does.
|
|
129
|
+
*/
|
|
130
|
+
export { createSpeechGenerateTool, createTranscribeTool } from "./audio.js";
|
|
131
|
+
export type { AudioToolDeps } from "./audio.js";
|
|
124
132
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -171,4 +171,11 @@ export { createParseCsvTool, createQueryJsonTool, createSqlQueryTool, createSqlS
|
|
|
171
171
|
export { createHttpRequestTool, createHttpWriteTool } from "./http.js";
|
|
172
172
|
export { createSearchKnowledgeTool } from "./knowledge.js";
|
|
173
173
|
export { createFetchJsonTool, createFetchUrlTool, createWebSearchTool } from "./web.js";
|
|
174
|
+
/**
|
|
175
|
+
* Audio — REQ-062 (#257), task #258.
|
|
176
|
+
*
|
|
177
|
+
* Library tools rather than a `tools-media` package, and the reason is argued in `audio.ts`: they take ports,
|
|
178
|
+
* not a vendor, exactly as `createWebSearchTool` does.
|
|
179
|
+
*/
|
|
180
|
+
export { createSpeechGenerateTool, createTranscribeTool } from "./audio.js";
|
|
174
181
|
//# sourceMappingURL=index.js.map
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -120,6 +120,26 @@ export type ToolCatalog = {
|
|
|
120
120
|
*/
|
|
121
121
|
readonly toolset?: TenantToolset;
|
|
122
122
|
};
|
|
123
|
+
/**
|
|
124
|
+
* Loads a skill body by name — the execution half of `load_skill`.
|
|
125
|
+
*
|
|
126
|
+
* Kept structural (no `skills` import) so `tools` does not depend on `skills`. Returns `null` for a name the
|
|
127
|
+
* tenant does not have, so an unknown skill is a message rather than a throw.
|
|
128
|
+
*/
|
|
129
|
+
export interface SkillBodyLoader {
|
|
130
|
+
load(context: ExecutionContext, name: string): Promise<{
|
|
131
|
+
readonly name: string;
|
|
132
|
+
readonly version: number;
|
|
133
|
+
readonly instructions: string;
|
|
134
|
+
} | null>;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* How many distinct skills one run may pull into context.
|
|
138
|
+
*
|
|
139
|
+
* Mirrors `SKILL_LIMITS.maxLoadedPerRun`, duplicated rather than imported for the same layering reason as
|
|
140
|
+
* `SkillBodyLoader`. A test asserts the two agree, so the copy cannot drift silently.
|
|
141
|
+
*/
|
|
142
|
+
export declare const MAX_SKILLS_LOADED_PER_RUN = 5;
|
|
123
143
|
/** Structural approval check (satisfied by the HITL `ApprovalGate`) — kept structural to avoid a
|
|
124
144
|
* tools→hitl dependency. Returns false when the tool needs approval and the call carries neither a
|
|
125
145
|
* standing grant nor a valid one-time approval. */
|
|
@@ -208,6 +228,19 @@ export type ToolRegistryConfig = {
|
|
|
208
228
|
* Wiring is the toggle, the same rule the tool library already follows for `web_search`.
|
|
209
229
|
*/
|
|
210
230
|
readonly search?: ToolSearch;
|
|
231
|
+
/**
|
|
232
|
+
* Loads a named skill's body, when the deployment has skills — task #244.
|
|
233
|
+
*
|
|
234
|
+
* Structural rather than importing `SkillResolver`, for the reason `ApprovalCheck` is structural: `tools` must
|
|
235
|
+
* not depend on `skills`. `createSkillBodyLoader` in `skills/` adapts a resolver to this shape.
|
|
236
|
+
*
|
|
237
|
+
* Wiring is the toggle. `load_skill` has been in `META_TOOLS` since the registry was written with **nothing
|
|
238
|
+
* implementing it** — the third instance of that exact pattern after `execute_tool` and `learn_tools` (#210).
|
|
239
|
+
* A model handed the descriptor would call it, get "Unknown tool load_skill", and a transcript reader would
|
|
240
|
+
* see a broken platform. So the descriptor is now advertised only when this is wired, which is the rule
|
|
241
|
+
* `find_tools` already follows.
|
|
242
|
+
*/
|
|
243
|
+
readonly skills?: SkillBodyLoader;
|
|
211
244
|
/** A tenant's category switches, applied *before* authorization filtering — AC-4. */
|
|
212
245
|
readonly toolsets?: ToolsetResolver;
|
|
213
246
|
/**
|
package/dist/tools/registry.js
CHANGED
|
@@ -83,6 +83,13 @@ export const categoryEnabled = (toolset, category) => {
|
|
|
83
83
|
return false;
|
|
84
84
|
return !(toolset.disabledCategories ?? []).includes(category);
|
|
85
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;
|
|
86
93
|
export const createToolRegistry = (config) => {
|
|
87
94
|
const maxInline = config.maxInlineOutputBytes ?? 8 * 1024;
|
|
88
95
|
const validator = config.validator ?? zodishValidator;
|
|
@@ -158,7 +165,19 @@ export const createToolRegistry = (config) => {
|
|
|
158
165
|
const wanted = toolset === undefined
|
|
159
166
|
? all.filter((t) => !duplicated.has(t.descriptor.name))
|
|
160
167
|
: all.filter((t) => !duplicated.has(t.descriptor.name) && categoryEnabled(toolset, t.descriptor.category));
|
|
161
|
-
|
|
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));
|
|
162
181
|
const descriptors = usable.map((t) => t.descriptor);
|
|
163
182
|
const permitted = new Set((await config.authorization.filterTools(context, descriptors)).map((d) => d.name));
|
|
164
183
|
return usable.filter((t) => permitted.has(t.descriptor.name));
|
|
@@ -178,6 +197,15 @@ export const createToolRegistry = (config) => {
|
|
|
178
197
|
const tools = (await authorizedTools(context)).map((t) => t.descriptor);
|
|
179
198
|
return config.search.search({ query: input.query, tools, limit: input.limit ?? 10 });
|
|
180
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();
|
|
181
209
|
const api = {
|
|
182
210
|
async catalog(context, policy) {
|
|
183
211
|
const excluded = new Set(policy.excluded);
|
|
@@ -199,7 +227,10 @@ export const createToolRegistry = (config) => {
|
|
|
199
227
|
* The alternative — a permanent descriptor that fails at execution — costs the model a call to discover
|
|
200
228
|
* and reads in a transcript exactly like a broken platform.
|
|
201
229
|
*/
|
|
202
|
-
const meta = META_TOOL_DESCRIPTOR_LIST.filter((d) => d.name !== "find_tools" || config.search !== undefined)
|
|
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);
|
|
203
234
|
const toolset = config.toolsets === undefined ? undefined : await config.toolsets.resolve(context);
|
|
204
235
|
if (config.catalogBudget === undefined)
|
|
205
236
|
return {
|
|
@@ -286,6 +317,45 @@ export const createToolRegistry = (config) => {
|
|
|
286
317
|
* tool, and called it wrongly or not at all. Search without schemas is a dead end, and the descriptor for
|
|
287
318
|
* `learn_tools` had been in `META_TOOLS` since the registry was written with nothing implementing it.
|
|
288
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
|
+
}
|
|
289
359
|
if (input.name === "learn_tools") {
|
|
290
360
|
const asked = (input.input ?? {});
|
|
291
361
|
const names = Array.isArray(asked.names) ? asked.names.filter((n) => typeof n === "string") : [];
|
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
|