@retinue/agentkit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -277
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/agents/agent.d.ts +22 -1
- package/dist/agents/agent.js +97 -11
- package/dist/agents/engine.d.ts +28 -0
- package/dist/agents/engine.js +194 -8
- 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/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +8 -0
- package/dist/core/events.d.ts +68 -2
- package/dist/core/events.js +2 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -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/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +9 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/graphql/resolvers.d.ts +4 -0
- package/dist/graphql/resolvers.js +6 -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/index.d.ts +1 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +73 -5
- package/dist/knowledge/retrieval.js +82 -28
- package/dist/models/streaming.d.ts +22 -1
- package/dist/models/streaming.js +5 -1
- package/dist/security/checklist.js +9 -0
- package/dist/security/findings.js +18 -9
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +12 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/index.d.ts +4 -0
- package/dist/toolkit/index.js +2 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -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 +57 -0
- package/dist/tools/credentials.js +54 -0
- package/dist/tools/define.d.ts +31 -0
- package/dist/tools/define.js +23 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +14 -2
- package/dist/tools/index.js +4 -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 +29 -2
- package/dist/tools/library/index.js +40 -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 +113 -0
- package/dist/tools/registry.js +180 -4
- package/package.json +5 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a command somewhere it cannot hurt you — REQ-047 (#206), task #215.
|
|
3
|
+
*
|
|
4
|
+
* `shell_exec` is the only tool in this package whose blast radius is not described by its schema. Every other
|
|
5
|
+
* tool can do one thing to one kind of object; this one can do anything the process can do. And its trigger is
|
|
6
|
+
* natural language — including language the model merely *read*, in a document, in an issue body, in a Slack
|
|
7
|
+
* message. Without isolation, a shell tool is a remote code execution endpoint reachable by anyone who can get
|
|
8
|
+
* text in front of the agent.
|
|
9
|
+
*
|
|
10
|
+
* So the sandbox is not a hardening step applied afterwards. It is the thing that makes the tool defensible, and
|
|
11
|
+
* the tool does not exist without one wired.
|
|
12
|
+
*
|
|
13
|
+
* ## What the contract guarantees
|
|
14
|
+
*
|
|
15
|
+
* | Guarantee | Why it is not optional |
|
|
16
|
+
* |---|---|
|
|
17
|
+
* | No network | A command that can reach the network can exfiltrate anything it can read, and the egress policy does not apply inside a container |
|
|
18
|
+
* | Read-only root, one writable scratch mount | A command that can write to the image can install a persistent foothold |
|
|
19
|
+
* | Memory cap | An unbounded allocation takes the host down with it, and that is a denial of service anybody can trigger by asking |
|
|
20
|
+
* | Wall-clock timeout | `sleep 999` must end as a *timeout*, not as an empty success |
|
|
21
|
+
* | No TTY | An interactive prompt would hang forever waiting for a person who is not there |
|
|
22
|
+
* | Output cap, with the truncation reported | Silent truncation makes a model believe it saw the whole answer |
|
|
23
|
+
* | The exit code in the envelope | Inferring success from output text is guessing; a non-zero exit is a fact |
|
|
24
|
+
*
|
|
25
|
+
* ## Gating is by effect, never by reading the command
|
|
26
|
+
*
|
|
27
|
+
* `shell_exec` is classified `destructive` and routed through the approval gate. It is tempting to inspect the
|
|
28
|
+
* command instead — refuse `rm -rf`, allow `ls` — and that is a losing game: `find . -delete`, `>file`, `dd`,
|
|
29
|
+
* `python -c`, a base64 pipeline. Any list of dangerous shapes is a list somebody gets around, and worse, it
|
|
30
|
+
* *feels* like protection. A classification cannot be evaded by rephrasing.
|
|
31
|
+
*/
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { AgentPlatformError } from "../core/errors.js";
|
|
34
|
+
/** Bytes of stdout and of stderr returned. Beyond this the output is truncated and says so. */
|
|
35
|
+
export const MAX_OUTPUT_BYTES = 64_000;
|
|
36
|
+
/** Wall clock. A model waiting on a hung command is a run holding a worker slot. */
|
|
37
|
+
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
38
|
+
export const DEFAULT_MEMORY_MB = 256;
|
|
39
|
+
const cap = (text, limit) => Buffer.byteLength(text, "utf8") <= limit
|
|
40
|
+
? { text, truncated: false }
|
|
41
|
+
: { text: Buffer.from(text, "utf8").subarray(0, limit).toString("utf8"), truncated: true };
|
|
42
|
+
/**
|
|
43
|
+
* Spawn a process and bound it, shared by both adapters.
|
|
44
|
+
*
|
|
45
|
+
* The output cap is applied **while reading**, not afterwards: a command that prints a gigabyte would otherwise
|
|
46
|
+
* be buffered in full before any limit could run, which is the same mistake `readBounded` exists to avoid in the
|
|
47
|
+
* HTTP client.
|
|
48
|
+
*
|
|
49
|
+
* ## Two decisions the first version got wrong, and CI found
|
|
50
|
+
*
|
|
51
|
+
* **The whole process group is killed, not the child.** `sh -c "sleep 999"` may exec or may fork, and when it
|
|
52
|
+
* forks the grandchild survives a `SIGKILL` aimed at its parent — so a timed-out command left a process running
|
|
53
|
+
* on the host. `detached: true` makes the child a group leader; `process.kill(-pid)` takes the group with it.
|
|
54
|
+
*
|
|
55
|
+
* **Resolution is on `exit`, not `close`.** `close` waits for every stdio stream to end, and an orphaned
|
|
56
|
+
* grandchild *holds the pipe open* — so `sh -c "sleep 5 | cat"` never resolved at all. The timeout fired, the
|
|
57
|
+
* process was killed, and the promise hung until the test runner gave up thirty seconds later. Reproduced
|
|
58
|
+
* locally in one line once CI had pointed at it.
|
|
59
|
+
*/
|
|
60
|
+
const boundedSpawn = async (file, args, input) => {
|
|
61
|
+
const startedAt = Date.now();
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
// A group leader, so a fork inside the shell can be killed with its parent.
|
|
64
|
+
const child = spawn(file, [...args], { stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
65
|
+
let out = "";
|
|
66
|
+
let err = "";
|
|
67
|
+
let truncated = false;
|
|
68
|
+
let reason;
|
|
69
|
+
const collect = (stream, append) => {
|
|
70
|
+
stream.on("data", (chunk) => {
|
|
71
|
+
const capped = cap(chunk.toString("utf8"), MAX_OUTPUT_BYTES);
|
|
72
|
+
if (capped.truncated)
|
|
73
|
+
truncated = true;
|
|
74
|
+
append(capped.text);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
collect(child.stdout, (chunk) => {
|
|
78
|
+
const capped = cap(out + chunk, MAX_OUTPUT_BYTES);
|
|
79
|
+
out = capped.text;
|
|
80
|
+
if (capped.truncated)
|
|
81
|
+
truncated = true;
|
|
82
|
+
});
|
|
83
|
+
collect(child.stderr, (chunk) => {
|
|
84
|
+
const capped = cap(err + chunk, MAX_OUTPUT_BYTES);
|
|
85
|
+
err = capped.text;
|
|
86
|
+
if (capped.truncated)
|
|
87
|
+
truncated = true;
|
|
88
|
+
});
|
|
89
|
+
/**
|
|
90
|
+
* The group, not the process.
|
|
91
|
+
*
|
|
92
|
+
* `-pid` addresses the group `detached` created. It throws if the group is already gone — a command that
|
|
93
|
+
* finished between the timer firing and this line — so the fallback is the plain kill, and neither is
|
|
94
|
+
* allowed to reject the promise.
|
|
95
|
+
*/
|
|
96
|
+
const killGroup = () => {
|
|
97
|
+
try {
|
|
98
|
+
if (child.pid !== undefined)
|
|
99
|
+
process.kill(-child.pid, "SIGKILL");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
child.kill("SIGKILL");
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
reason = "timeout";
|
|
107
|
+
input.onKill?.();
|
|
108
|
+
killGroup();
|
|
109
|
+
}, input.timeoutMs);
|
|
110
|
+
// `exit` and `close` can both fire, and after a group kill only one of them may. Whichever arrives first
|
|
111
|
+
// answers, and the guard is what makes listening to both safe rather than a double resolution.
|
|
112
|
+
let settled = false;
|
|
113
|
+
const settle = (result) => {
|
|
114
|
+
if (settled)
|
|
115
|
+
return;
|
|
116
|
+
settled = true;
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
resolve(result);
|
|
119
|
+
};
|
|
120
|
+
child.on("error", (error) => {
|
|
121
|
+
settle({
|
|
122
|
+
ok: false,
|
|
123
|
+
exitCode: null,
|
|
124
|
+
stdout: out,
|
|
125
|
+
stderr: `${err}${error.message}`,
|
|
126
|
+
truncated,
|
|
127
|
+
reason: "spawn-failed",
|
|
128
|
+
durationMs: Date.now() - startedAt,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const finish = (code) => settle({
|
|
132
|
+
// A timeout is not a completed command, whatever it printed before it died.
|
|
133
|
+
ok: reason === undefined,
|
|
134
|
+
exitCode: code,
|
|
135
|
+
stdout: out,
|
|
136
|
+
stderr: err,
|
|
137
|
+
truncated,
|
|
138
|
+
...(reason === undefined ? {} : { reason }),
|
|
139
|
+
durationMs: Date.now() - startedAt,
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* `exit` fires when the process dies; `close` waits for its stdio to end.
|
|
143
|
+
*
|
|
144
|
+
* Preferring `exit` is the fix: a grandchild holding the pipe open means `close` may never come, and the
|
|
145
|
+
* output collected so far is still the output. `close` stays wired for the ordinary case, where it arrives
|
|
146
|
+
* a moment later with the last of the buffer flushed.
|
|
147
|
+
*/
|
|
148
|
+
child.on("exit", (code) => finish(code));
|
|
149
|
+
child.on("close", (code) => finish(code));
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The argv, built separately so it can be asserted on.
|
|
154
|
+
*
|
|
155
|
+
* Every security property of this adapter *is* this array. A test that ran a command and checked its output would
|
|
156
|
+
* pass just as well with `--network=none` missing, so the argv is what the tests read — and the flags come before
|
|
157
|
+
* `extraArgs`, so a deployment adding options cannot quietly drop one.
|
|
158
|
+
*/
|
|
159
|
+
export const dockerArgs = (config, request) => [
|
|
160
|
+
"run",
|
|
161
|
+
"--rm",
|
|
162
|
+
// No network at all. Not a restricted network: a command that can resolve a name can exfiltrate through it.
|
|
163
|
+
"--network=none",
|
|
164
|
+
// The image is immutable; only the scratch mount is writable, and it is a tmpfs that dies with the container.
|
|
165
|
+
"--read-only",
|
|
166
|
+
"--tmpfs=/scratch:rw,size=16m,mode=1777",
|
|
167
|
+
`--memory=${config.memoryMb ?? DEFAULT_MEMORY_MB}m`,
|
|
168
|
+
// Without this a memory-capped container can still be pushed into swap and take the host's disk with it.
|
|
169
|
+
`--memory-swap=${config.memoryMb ?? DEFAULT_MEMORY_MB}m`,
|
|
170
|
+
"--pids-limit=128",
|
|
171
|
+
"--cap-drop=ALL",
|
|
172
|
+
"--security-opt=no-new-privileges",
|
|
173
|
+
// Not root inside the container either. `--read-only` makes most of that moot; this makes the rest of it moot.
|
|
174
|
+
"--user=65534:65534",
|
|
175
|
+
"--workdir=/scratch",
|
|
176
|
+
// No TTY, and stdin closed: an interactive prompt has nobody to answer it and would hold the slot until the
|
|
177
|
+
// timeout, reported as a timeout, which is a confusing way to learn that a command asked a question.
|
|
178
|
+
...(config.extraArgs ?? []),
|
|
179
|
+
config.image,
|
|
180
|
+
config.shell ?? "sh",
|
|
181
|
+
"-c",
|
|
182
|
+
request.command,
|
|
183
|
+
];
|
|
184
|
+
/**
|
|
185
|
+
* The real adapter: one container per command, destroyed after.
|
|
186
|
+
*
|
|
187
|
+
* Not a pool. A reused container is a container a previous command could have left something in, and the whole
|
|
188
|
+
* proposition here is that a command cannot affect anything outside itself.
|
|
189
|
+
*/
|
|
190
|
+
export const createDockerSandbox = (config) => ({
|
|
191
|
+
id: `docker:${config.image}`,
|
|
192
|
+
async run(request) {
|
|
193
|
+
const timeoutMs = request.timeoutMs ?? config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
194
|
+
const args = dockerArgs({ ...config, ...(request.memoryMb === undefined ? {} : { memoryMb: request.memoryMb }) }, request);
|
|
195
|
+
const result = await boundedSpawn(config.docker ?? "docker", args, { timeoutMs });
|
|
196
|
+
/**
|
|
197
|
+
* 137 is SIGKILL, and inside a memory-capped container that is almost always the OOM killer.
|
|
198
|
+
*
|
|
199
|
+
* Reported as `memory` rather than left as a bare exit code, because "exit 137" is a number a reader has to
|
|
200
|
+
* look up and "the command ran out of memory" is the finding. Not claimed when we did the killing ourselves:
|
|
201
|
+
* a timeout is also a SIGKILL, and it is already named.
|
|
202
|
+
*/
|
|
203
|
+
return result.exitCode === 137 && result.reason === undefined
|
|
204
|
+
? { ...result, ok: false, reason: "memory" }
|
|
205
|
+
: result;
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
/**
|
|
209
|
+
* The development adapter, which **refuses to exist** unless a deployment says so in words.
|
|
210
|
+
*
|
|
211
|
+
* The refusal is at construction rather than at the call, so a misconfiguration is a boot failure rather than a
|
|
212
|
+
* surprise the first time somebody asks the agent to run something. And the message says what to do instead,
|
|
213
|
+
* because "not allowed" without a next step is how a flag gets set to make an error go away.
|
|
214
|
+
*
|
|
215
|
+
* It provides the timeout and the output cap. It provides **none** of the isolation: no network isolation, no
|
|
216
|
+
* memory cap, no read-only filesystem, no dropped capabilities. The contract's table describes what a sandbox
|
|
217
|
+
* guarantees; this adapter meets one row of it, and saying so is the point.
|
|
218
|
+
*/
|
|
219
|
+
export const createLocalSandbox = (config) => {
|
|
220
|
+
if (config.allowUnsafeLocalExecution !== true) {
|
|
221
|
+
throw new AgentPlatformError({
|
|
222
|
+
code: "invalid_input",
|
|
223
|
+
message: "The local sandbox runs commands on this host with no isolation: no network isolation, no memory cap, " +
|
|
224
|
+
"no read-only filesystem. It is for development only, and it requires " +
|
|
225
|
+
"`allowUnsafeLocalExecution: true` in so many words. For anything else, use createDockerSandbox — or " +
|
|
226
|
+
"leave shell_exec unwired, which is the default and is a legitimate answer.",
|
|
227
|
+
retryable: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
id: "local:unsafe",
|
|
232
|
+
async run(request) {
|
|
233
|
+
return boundedSpawn(config.shell ?? "sh", ["-c", request.command], {
|
|
234
|
+
timeoutMs: request.timeoutMs ?? config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
//# sourceMappingURL=sandbox.js.map
|
package/dist/toolkit/web.d.ts
CHANGED
|
@@ -87,6 +87,19 @@ export type SearchProvider = {
|
|
|
87
87
|
/** Build the request URL for a query. The key belongs in `headers`, not here, wherever the provider allows it. */
|
|
88
88
|
readonly endpoint: (query: string, limit: number) => string;
|
|
89
89
|
readonly headers?: Readonly<Record<string, string>>;
|
|
90
|
+
/**
|
|
91
|
+
* `POST`, for the providers that require it.
|
|
92
|
+
*
|
|
93
|
+
* Added when the first real adapters were written (#214): this contract was GET-only, and three of the four
|
|
94
|
+
* most-used providers — Tavily, Serper, Exa — are POST with a JSON body. A GET-only seam would have limited the
|
|
95
|
+
* catalogue to Brave and self-hosted SearXNG, which is not a "one contract, several providers" rule so much as
|
|
96
|
+
* a rule with two providers.
|
|
97
|
+
*
|
|
98
|
+
* Optional, so every existing GET provider is unchanged.
|
|
99
|
+
*/
|
|
100
|
+
readonly method?: "GET" | "POST";
|
|
101
|
+
/** The request body for a `POST` provider. Ignored for `GET`. */
|
|
102
|
+
readonly body?: (query: string, limit: number) => unknown;
|
|
90
103
|
/** Read the provider's JSON into hits. Returning `[]` means "searched, found nothing". */
|
|
91
104
|
readonly parse: (payload: unknown) => readonly SearchHit[];
|
|
92
105
|
};
|
package/dist/toolkit/web.js
CHANGED
|
@@ -97,10 +97,16 @@ export const createWebSearch = (config = {}) => {
|
|
|
97
97
|
"directly.",
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
+
const method = provider.method ?? "GET";
|
|
100
101
|
const outcome = await client.request({
|
|
101
102
|
url: provider.endpoint(query, limit),
|
|
102
|
-
headers: provider.headers,
|
|
103
|
+
...(provider.headers === undefined ? {} : { headers: provider.headers }),
|
|
104
|
+
...(method === "POST"
|
|
105
|
+
? { method, body: JSON.stringify(provider.body?.(query, limit) ?? { query }) }
|
|
106
|
+
: {}),
|
|
103
107
|
accept: "application/json",
|
|
108
|
+
// Parsed by the provider's own `parse` and never shown to the model verbatim, so the untrusted-content
|
|
109
|
+
// envelope would only corrupt the JSON. The *hits* are fenced downstream, where they are read as prose.
|
|
104
110
|
fence: false,
|
|
105
111
|
});
|
|
106
112
|
if (!outcome.ok) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool costs a catalogue — REQ-045 (#204), task #210, AC-3.
|
|
3
|
+
*
|
|
4
|
+
* The budget itself is in `core/budget.ts`, shared with the skill catalogue. What is here is the part that knows
|
|
5
|
+
* about tools: how many tokens one entry is, which is the only thing the generic algorithm cannot know.
|
|
6
|
+
*/
|
|
7
|
+
import type { ToolCatalogEntry, ToolDescriptor } from "./index.js";
|
|
8
|
+
/**
|
|
9
|
+
* Deliberately **not** re-exported here.
|
|
10
|
+
*
|
|
11
|
+
* `applyTokenBudget`, `TokenBudget` and `BudgetOutcome` live in `core/` and reach consumers through
|
|
12
|
+
* `@retinue/agentkit/runtime`. Re-exporting them from `./tools` as well gave one name two homes, which
|
|
13
|
+
* `public-surface.test.ts` fails on — and it is right to: a consumer reading two import paths for one function
|
|
14
|
+
* has to guess which is canonical, and the two will not stay in step.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* What a compact catalogue entry costs.
|
|
18
|
+
*
|
|
19
|
+
* The fields a model actually reads, plus a small per-entry allowance for the JSON or Markdown scaffolding the
|
|
20
|
+
* assembler wraps them in — measured at ~35 tokens per entry in #221's harness, which is what this reproduces.
|
|
21
|
+
* An estimate rather than a tokenizer call: the point is a stable ceiling, and a budget that shifted with the
|
|
22
|
+
* model's tokenizer would make one deployment's 4,000 tokens another's 4,600.
|
|
23
|
+
*/
|
|
24
|
+
export declare const ENTRY_OVERHEAD_TOKENS = 6;
|
|
25
|
+
export declare const entryTokens: (entry: ToolCatalogEntry) => number;
|
|
26
|
+
/** A preloaded tool carries its schemas, which is usually most of its cost. */
|
|
27
|
+
export declare const descriptorTokens: (descriptor: ToolDescriptor) => number;
|
|
28
|
+
//# sourceMappingURL=budget.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool costs a catalogue — REQ-045 (#204), task #210, AC-3.
|
|
3
|
+
*
|
|
4
|
+
* The budget itself is in `core/budget.ts`, shared with the skill catalogue. What is here is the part that knows
|
|
5
|
+
* about tools: how many tokens one entry is, which is the only thing the generic algorithm cannot know.
|
|
6
|
+
*/
|
|
7
|
+
import { estimateTokens } from "../core/tokens.js";
|
|
8
|
+
/**
|
|
9
|
+
* Deliberately **not** re-exported here.
|
|
10
|
+
*
|
|
11
|
+
* `applyTokenBudget`, `TokenBudget` and `BudgetOutcome` live in `core/` and reach consumers through
|
|
12
|
+
* `@retinue/agentkit/runtime`. Re-exporting them from `./tools` as well gave one name two homes, which
|
|
13
|
+
* `public-surface.test.ts` fails on — and it is right to: a consumer reading two import paths for one function
|
|
14
|
+
* has to guess which is canonical, and the two will not stay in step.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* What a compact catalogue entry costs.
|
|
18
|
+
*
|
|
19
|
+
* The fields a model actually reads, plus a small per-entry allowance for the JSON or Markdown scaffolding the
|
|
20
|
+
* assembler wraps them in — measured at ~35 tokens per entry in #221's harness, which is what this reproduces.
|
|
21
|
+
* An estimate rather than a tokenizer call: the point is a stable ceiling, and a budget that shifted with the
|
|
22
|
+
* model's tokenizer would make one deployment's 4,000 tokens another's 4,600.
|
|
23
|
+
*/
|
|
24
|
+
export const ENTRY_OVERHEAD_TOKENS = 6;
|
|
25
|
+
export const entryTokens = (entry) => estimateTokens(`${entry.name} ${entry.label} ${entry.category} ${entry.effect} ${entry.description}`) +
|
|
26
|
+
ENTRY_OVERHEAD_TOKENS;
|
|
27
|
+
/** A preloaded tool carries its schemas, which is usually most of its cost. */
|
|
28
|
+
export const descriptorTokens = (descriptor) => entryTokens({
|
|
29
|
+
name: descriptor.name,
|
|
30
|
+
label: descriptor.label,
|
|
31
|
+
description: descriptor.description,
|
|
32
|
+
category: descriptor.category,
|
|
33
|
+
effect: descriptor.effect,
|
|
34
|
+
}) + estimateTokens(JSON.stringify(descriptor.inputSchema ?? {}));
|
|
35
|
+
//# sourceMappingURL=budget.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5.
|
|
3
|
+
*
|
|
4
|
+
* A toolkit needs a token. The question is who holds it and when it is read, and getting that wrong is the
|
|
5
|
+
* decision that makes a multi-tenant deployment a rewrite of every package rather than a configuration change.
|
|
6
|
+
*
|
|
7
|
+
* ## Why a tool must not read the environment
|
|
8
|
+
*
|
|
9
|
+
* `process.env.GITHUB_TOKEN` inside a tool works perfectly for exactly one tenant. It is also the shape that
|
|
10
|
+
* twenty more toolkits will copy, because the first one did — and each of them then has to be rewritten when a
|
|
11
|
+
* second customer arrives, with their own token, for the same tool.
|
|
12
|
+
*
|
|
13
|
+
* So a tool takes a **reference** and the host resolves it. What the reference means — a row in a credential
|
|
14
|
+
* table, a secret manager path, an env var name in a single-tenant deployment — is the host's business and
|
|
15
|
+
* changes nothing about the tool.
|
|
16
|
+
*
|
|
17
|
+
* ## Why resolution happens at the point of use
|
|
18
|
+
*
|
|
19
|
+
* Not at construction. A credential read once at startup is a credential that survives its own rotation: the
|
|
20
|
+
* secret changes, every request keeps sending the old one, and the failure looks like the vendor rejecting a
|
|
21
|
+
* token that "has not changed". Resolving per call costs a lookup — cacheable by the host, which knows its own
|
|
22
|
+
* rotation window — and means a rotated secret takes effect without a restart.
|
|
23
|
+
*
|
|
24
|
+
* The corollary: **a resolved secret is never stored on the tool.** It lives in the local scope of one call.
|
|
25
|
+
*
|
|
26
|
+
* ## Why a miss throws
|
|
27
|
+
*
|
|
28
|
+
* Returning an empty string would send an unauthenticated request and surface as a vendor 401 several layers
|
|
29
|
+
* away, where the actual problem — nobody wired the credential — is invisible. A typed failure names the ref.
|
|
30
|
+
*/
|
|
31
|
+
import type { ExecutionContext } from "../core/context.js";
|
|
32
|
+
import type { PlatformError } from "../core/errors.js";
|
|
33
|
+
/** An opaque handle. Its meaning belongs to the resolver, and no tool interprets it. */
|
|
34
|
+
export type CredentialRef = string;
|
|
35
|
+
export interface CredentialResolver {
|
|
36
|
+
/**
|
|
37
|
+
* The secret behind a reference, for this caller.
|
|
38
|
+
*
|
|
39
|
+
* Takes the context because a reference is resolved *per tenant*: two tenants using the same toolkit name the
|
|
40
|
+
* same credential and mean different secrets, and a resolver that ignored the caller would hand one tenant
|
|
41
|
+
* another's token.
|
|
42
|
+
*/
|
|
43
|
+
resolve(input: {
|
|
44
|
+
readonly ref: CredentialRef;
|
|
45
|
+
readonly context: ExecutionContext;
|
|
46
|
+
}): Promise<string>;
|
|
47
|
+
}
|
|
48
|
+
export declare const credentialMissing: (ref: CredentialRef, detail?: string) => PlatformError;
|
|
49
|
+
/**
|
|
50
|
+
* A resolver over a plain map, for a single-tenant deployment and for tests.
|
|
51
|
+
*
|
|
52
|
+
* Shipped because the alternative is every host writing the same six lines, and the sixth one writing it with a
|
|
53
|
+
* fallback to `process.env` — which is the thing this module exists to prevent. Explicitly *not* environment
|
|
54
|
+
* backed: a host that wants that passes `{ github: process.env.GITHUB_TOKEN ?? "" }` and can see it doing so.
|
|
55
|
+
*/
|
|
56
|
+
export declare const createStaticCredentialResolver: (secrets: Readonly<Record<string, string>>) => CredentialResolver;
|
|
57
|
+
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials, referenced rather than held — REQ-047 (#206), task #214, AC-5.
|
|
3
|
+
*
|
|
4
|
+
* A toolkit needs a token. The question is who holds it and when it is read, and getting that wrong is the
|
|
5
|
+
* decision that makes a multi-tenant deployment a rewrite of every package rather than a configuration change.
|
|
6
|
+
*
|
|
7
|
+
* ## Why a tool must not read the environment
|
|
8
|
+
*
|
|
9
|
+
* `process.env.GITHUB_TOKEN` inside a tool works perfectly for exactly one tenant. It is also the shape that
|
|
10
|
+
* twenty more toolkits will copy, because the first one did — and each of them then has to be rewritten when a
|
|
11
|
+
* second customer arrives, with their own token, for the same tool.
|
|
12
|
+
*
|
|
13
|
+
* So a tool takes a **reference** and the host resolves it. What the reference means — a row in a credential
|
|
14
|
+
* table, a secret manager path, an env var name in a single-tenant deployment — is the host's business and
|
|
15
|
+
* changes nothing about the tool.
|
|
16
|
+
*
|
|
17
|
+
* ## Why resolution happens at the point of use
|
|
18
|
+
*
|
|
19
|
+
* Not at construction. A credential read once at startup is a credential that survives its own rotation: the
|
|
20
|
+
* secret changes, every request keeps sending the old one, and the failure looks like the vendor rejecting a
|
|
21
|
+
* token that "has not changed". Resolving per call costs a lookup — cacheable by the host, which knows its own
|
|
22
|
+
* rotation window — and means a rotated secret takes effect without a restart.
|
|
23
|
+
*
|
|
24
|
+
* The corollary: **a resolved secret is never stored on the tool.** It lives in the local scope of one call.
|
|
25
|
+
*
|
|
26
|
+
* ## Why a miss throws
|
|
27
|
+
*
|
|
28
|
+
* Returning an empty string would send an unauthenticated request and surface as a vendor 401 several layers
|
|
29
|
+
* away, where the actual problem — nobody wired the credential — is invisible. A typed failure names the ref.
|
|
30
|
+
*/
|
|
31
|
+
export const credentialMissing = (ref, detail) => ({
|
|
32
|
+
code: "capability_unavailable",
|
|
33
|
+
message: `No credential is wired for reference "${ref}"` +
|
|
34
|
+
(detail === undefined ? "" : `: ${detail}`) +
|
|
35
|
+
". A tool cannot read one from the environment — supply a CredentialResolver to the host.",
|
|
36
|
+
// Retrying an unwired credential cannot help; something has to be configured.
|
|
37
|
+
retryable: false,
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* A resolver over a plain map, for a single-tenant deployment and for tests.
|
|
41
|
+
*
|
|
42
|
+
* Shipped because the alternative is every host writing the same six lines, and the sixth one writing it with a
|
|
43
|
+
* fallback to `process.env` — which is the thing this module exists to prevent. Explicitly *not* environment
|
|
44
|
+
* backed: a host that wants that passes `{ github: process.env.GITHUB_TOKEN ?? "" }` and can see it doing so.
|
|
45
|
+
*/
|
|
46
|
+
export const createStaticCredentialResolver = (secrets) => ({
|
|
47
|
+
async resolve({ ref }) {
|
|
48
|
+
const secret = secrets[ref];
|
|
49
|
+
if (secret === undefined || secret === "")
|
|
50
|
+
throw credentialMissing(ref, "not present in the static map");
|
|
51
|
+
return secret;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
//# sourceMappingURL=credentials.js.map
|
package/dist/tools/define.d.ts
CHANGED
|
@@ -22,4 +22,35 @@ export type ToolSpec<I = unknown, O = unknown> = {
|
|
|
22
22
|
export declare const defineTool: <I = unknown, O = unknown>(spec: ToolSpec<I, O>) => Tool<O>;
|
|
23
23
|
/** A `ToolProvider` serving a fixed set of tools. For dynamic per-tenant tools, implement the port. */
|
|
24
24
|
export declare const toolProvider: (id: string, tools: readonly Tool[]) => ToolProvider;
|
|
25
|
+
/**
|
|
26
|
+
* The spec a confirmation wrapper accepts — the three classification fields removed.
|
|
27
|
+
*
|
|
28
|
+
* Removed rather than defaulted, so passing one is a **compile error** instead of a silent override. "Visible in
|
|
29
|
+
* review" was the weaker version of this requirement: a reviewer reading `confirms({ …, approvalPolicy: "never" })`
|
|
30
|
+
* has to notice a contradiction, and the type system can refuse it instead.
|
|
31
|
+
*/
|
|
32
|
+
export type GatedToolSpec<I, O> = Omit<ToolSpec<I, O>, "effect" | "approvalPolicy" | "requiresIdempotencyKey">;
|
|
33
|
+
/**
|
|
34
|
+
* A tool that writes somewhere outside this system, and therefore waits for a person.
|
|
35
|
+
*
|
|
36
|
+
* `effect`, `approvalPolicy` and `requiresIdempotencyKey` are one decision, and this is the word for it. Setting
|
|
37
|
+
* them individually is three chances to get it wrong, and the failure is silent: `effect: "read"` on a tool that
|
|
38
|
+
* posts a message skips the approval gate, carries no idempotency key, and nothing in the build objects, because
|
|
39
|
+
* `read` is a valid value and the compiler cannot know what the function does.
|
|
40
|
+
*
|
|
41
|
+
* Across one package that is a code-review problem. Across the twenty-one toolkit packages `docs/23` plans, some
|
|
42
|
+
* contributed, it is a systemic one — so the safe thing is now shorter to write than the unsafe thing.
|
|
43
|
+
*
|
|
44
|
+
* Use `defineTool` directly for a read, or for the rare combination this does not express.
|
|
45
|
+
*/
|
|
46
|
+
export declare const confirms: <I = unknown, O = unknown>(spec: GatedToolSpec<I, O>) => Tool<O>;
|
|
47
|
+
/**
|
|
48
|
+
* A tool whose effect cannot be undone — a delete, a merge, a send that cannot be recalled.
|
|
49
|
+
*
|
|
50
|
+
* Distinct from `confirms` because the *classification* differs and downstream policy reads it: a shadow run
|
|
51
|
+
* suppresses `destructive` and `external-write` alike, but an operator reviewing what an agent may do wants to
|
|
52
|
+
* know which of the two a tool is. Collapsing them would lose that, and the answer to "what can this agent
|
|
53
|
+
* irreversibly do" would stop being answerable from the catalogue.
|
|
54
|
+
*/
|
|
55
|
+
export declare const destroys: <I = unknown, O = unknown>(spec: GatedToolSpec<I, O>) => Tool<O>;
|
|
25
56
|
//# sourceMappingURL=define.d.ts.map
|
package/dist/tools/define.js
CHANGED
|
@@ -42,4 +42,27 @@ export const toolProvider = (id, tools) => ({
|
|
|
42
42
|
return tools;
|
|
43
43
|
},
|
|
44
44
|
});
|
|
45
|
+
/**
|
|
46
|
+
* A tool that writes somewhere outside this system, and therefore waits for a person.
|
|
47
|
+
*
|
|
48
|
+
* `effect`, `approvalPolicy` and `requiresIdempotencyKey` are one decision, and this is the word for it. Setting
|
|
49
|
+
* them individually is three chances to get it wrong, and the failure is silent: `effect: "read"` on a tool that
|
|
50
|
+
* posts a message skips the approval gate, carries no idempotency key, and nothing in the build objects, because
|
|
51
|
+
* `read` is a valid value and the compiler cannot know what the function does.
|
|
52
|
+
*
|
|
53
|
+
* Across one package that is a code-review problem. Across the twenty-one toolkit packages `docs/23` plans, some
|
|
54
|
+
* contributed, it is a systemic one — so the safe thing is now shorter to write than the unsafe thing.
|
|
55
|
+
*
|
|
56
|
+
* Use `defineTool` directly for a read, or for the rare combination this does not express.
|
|
57
|
+
*/
|
|
58
|
+
export const confirms = (spec) => defineTool({ ...spec, effect: "external-write", approvalPolicy: "always", requiresIdempotencyKey: true });
|
|
59
|
+
/**
|
|
60
|
+
* A tool whose effect cannot be undone — a delete, a merge, a send that cannot be recalled.
|
|
61
|
+
*
|
|
62
|
+
* Distinct from `confirms` because the *classification* differs and downstream policy reads it: a shadow run
|
|
63
|
+
* suppresses `destructive` and `external-write` alike, but an operator reviewing what an agent may do wants to
|
|
64
|
+
* know which of the two a tool is. Collapsing them would lose that, and the answer to "what can this agent
|
|
65
|
+
* irreversibly do" would stop being answerable from the catalogue.
|
|
66
|
+
*/
|
|
67
|
+
export const destroys = (spec) => defineTool({ ...spec, effect: "destructive", approvalPolicy: "always", requiresIdempotencyKey: true });
|
|
45
68
|
//# sourceMappingURL=define.js.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `find_tools` — REQ-045 (#204), task #210, AC-1 and AC-2.
|
|
3
|
+
*
|
|
4
|
+
* Search over tool descriptors, so a catalogue can be *findable* rather than resident. The two-tier loading
|
|
5
|
+
* already keeps schemas out of context; this keeps the compact entries out too, which is what makes a budget
|
|
6
|
+
* (AC-3) something other than a way to hide tools from the model permanently.
|
|
7
|
+
*
|
|
8
|
+
* ## What is reused, and the one thing that is not
|
|
9
|
+
*
|
|
10
|
+
* **Fusion is `fuseByRank`** — the same reciprocal rank fusion, the same `K = 60`, the same normalisation and
|
|
11
|
+
* the same relevance floor as knowledge retrieval. There is one implementation and both callers use it.
|
|
12
|
+
*
|
|
13
|
+
* **Embeddings are the same port.** `EmbeddingProvider`, injected. No second embedding path, no second client,
|
|
14
|
+
* no second cache format.
|
|
15
|
+
*
|
|
16
|
+
* **The keyword signal is not `KeywordIndex`, and that is a finding rather than a shortcut.** `KeywordIndex` is
|
|
17
|
+
* a Postgres full-text index over `knowledge_chunks`; tools are not rows in that table and putting them there
|
|
18
|
+
* would mean a write path, a migration, and an index to keep in sync with a registry that is assembled per
|
|
19
|
+
* request from providers. A corpus of a few hundred short documents that already lives in memory does not need
|
|
20
|
+
* an index — it needs a scan, and the scan is deterministic and free. AC-2's "no second implementation" is about
|
|
21
|
+
* not having two rankers; this is one ranker over a corpus the other one cannot hold.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the semantic signal is optional
|
|
24
|
+
*
|
|
25
|
+
* Without an `EmbeddingProvider` this is keyword-only, and it says so in the result rather than pretending. Most
|
|
26
|
+
* deployments will not wire embeddings *for tools*: #221 measured selection accuracy as flat from 20 to 200
|
|
27
|
+
* tools, so the case for `find_tools` is context cost, not accuracy — and a deployment that pays for embeddings
|
|
28
|
+
* to solve a problem it does not have is exactly the cost this repository keeps refusing to impose by default.
|
|
29
|
+
*/
|
|
30
|
+
import type { EmbeddingProvider } from "../knowledge/index.js";
|
|
31
|
+
import type { ToolCatalogEntry, ToolDescriptor } from "./index.js";
|
|
32
|
+
/** Which signals found a tool. Reported, because "keyword only" is a different confidence from "both agreed". */
|
|
33
|
+
export type ToolSearchSignal = "semantic" | "keyword";
|
|
34
|
+
export type ToolSearchHit = {
|
|
35
|
+
readonly entry: ToolCatalogEntry;
|
|
36
|
+
/** 0–1 relative to the best hit for this query. Never comparable across queries. */
|
|
37
|
+
readonly score: number;
|
|
38
|
+
readonly signals: readonly ToolSearchSignal[];
|
|
39
|
+
};
|
|
40
|
+
export type ToolSearchOutcome = {
|
|
41
|
+
readonly hits: readonly ToolSearchHit[];
|
|
42
|
+
/** The signals actually used. Keyword-only when no embedding provider is wired. */
|
|
43
|
+
readonly modes: readonly ToolSearchSignal[];
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The absolute floor, and why a relative one is not enough.
|
|
47
|
+
*
|
|
48
|
+
* Fused scores are normalised against the best hit, so a query that matched exactly one tool badly gives that
|
|
49
|
+
* tool a score of 1.0 — the relative floor cannot reject a uniformly poor result set, because something is
|
|
50
|
+
* always the best of it. Knowledge retrieval solves this with an absolute `semanticFloor` handed to the vector
|
|
51
|
+
* index; this is the same idea for the keyword signal.
|
|
52
|
+
*
|
|
53
|
+
* **2 is one name or category hit, or two words of prose.** A single common word appearing somewhere in a
|
|
54
|
+
* description is not evidence: "translate this into Welsh" found `parse_csv`, whose description happens to say
|
|
55
|
+
* "into", and returned it with a perfect score. That was a real result from the first run of this file's tests,
|
|
56
|
+
* and it is the least-bad-match failure the floor exists to prevent — a model handed the least-bad tool calls it.
|
|
57
|
+
*/
|
|
58
|
+
export declare const MIN_KEYWORD_SCORE = 2;
|
|
59
|
+
export type ToolSearchDeps = {
|
|
60
|
+
/** Absent means keyword-only — see the note above on why that is the honest default. */
|
|
61
|
+
readonly embeddings?: EmbeddingProvider;
|
|
62
|
+
readonly candidates?: number;
|
|
63
|
+
readonly relevanceFloor?: number;
|
|
64
|
+
/** The absolute keyword floor. See `MIN_KEYWORD_SCORE`. */
|
|
65
|
+
readonly minKeywordScore?: number;
|
|
66
|
+
/** The absolute semantic floor, for the same reason. Defaults to the platform's `SEMANTIC_RELEVANCE_FLOOR`. */
|
|
67
|
+
readonly minSimilarity?: number;
|
|
68
|
+
};
|
|
69
|
+
/** The compact entry a catalogue shows, derived once so search and catalogue cannot disagree about a tool. */
|
|
70
|
+
export declare const compactEntry: (d: ToolDescriptor) => ToolCatalogEntry;
|
|
71
|
+
export declare const termsOf: (text: string) => readonly string[];
|
|
72
|
+
/** What a tool is embedded and matched as. One string, so both signals read the same document. */
|
|
73
|
+
export declare const documentFor: (entry: ToolCatalogEntry) => string;
|
|
74
|
+
/**
|
|
75
|
+
* The keyword signal: how many of the query's terms this tool mentions, and where.
|
|
76
|
+
*
|
|
77
|
+
* Weighted by field, because a term in the *name* is a different claim from a term buried in prose — a query for
|
|
78
|
+
* "issue" should rank `github_create_issue` above a tool whose description mentions issues in passing. Weights
|
|
79
|
+
* are small integers rather than tuned constants: RRF uses only the rank, so their exact values decide ordering
|
|
80
|
+
* and nothing else, and a fused score cannot be dominated by a badly chosen one.
|
|
81
|
+
*/
|
|
82
|
+
export declare const keywordScore: (entry: ToolCatalogEntry, queryTerms: readonly string[]) => number;
|
|
83
|
+
/**
|
|
84
|
+
* The same score, divided by document length — and this is what fixed a real ranking defect.
|
|
85
|
+
*
|
|
86
|
+
* The 200-tool measurement found `find_tools` returning `archive_post_metrics` above `get_post_metrics`. Both
|
|
87
|
+
* match the query's terms identically, so the raw scores tied and the tie-break — alphabetical by name — decided
|
|
88
|
+
* it. Alphabetical order is arbitrary with respect to relevance, and in a catalogue whose near-duplicates are
|
|
89
|
+
* `<verb>_<object>` it is *systematically* wrong: it prefers whichever verb sorts earliest.
|
|
90
|
+
*
|
|
91
|
+
* Length normalisation is the standard answer and it is the correct one here rather than a fitted one: the
|
|
92
|
+
* distractors are the base tool's description **plus** a qualifier, so they say the same thing less densely. A
|
|
93
|
+
* short, focused description that matches your query is a better match than a long one that matches it
|
|
94
|
+
* incidentally. `sqrt` rather than a linear divisor, as in BM25, so a genuinely detailed description is not
|
|
95
|
+
* punished out of contention.
|
|
96
|
+
*
|
|
97
|
+
* The raw score still decides the *floor* (see `MIN_KEYWORD_SCORE`); this decides the *order*. Two values,
|
|
98
|
+
* because "is this a match at all" and "which match is better" are different questions.
|
|
99
|
+
*/
|
|
100
|
+
export declare const weightedKeywordScore: (entry: ToolCatalogEntry, queryTerms: readonly string[]) => number;
|
|
101
|
+
export type ToolSearch = {
|
|
102
|
+
search(input: {
|
|
103
|
+
readonly query: string;
|
|
104
|
+
readonly tools: readonly ToolDescriptor[];
|
|
105
|
+
readonly limit: number;
|
|
106
|
+
}): Promise<ToolSearchOutcome>;
|
|
107
|
+
};
|
|
108
|
+
export declare const createToolSearch: (deps?: ToolSearchDeps) => ToolSearch;
|
|
109
|
+
//# sourceMappingURL=find.d.ts.map
|