klyro 0.1.62 → 1.0.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/dist/agent/anthropic-adapter.d.ts +30 -9
- package/dist/agent/anthropic-adapter.js +107 -49
- package/dist/agent/capabilities.d.ts +122 -0
- package/dist/agent/capabilities.js +150 -0
- package/dist/agent/orchestrator.d.ts +131 -0
- package/dist/agent/orchestrator.js +269 -0
- package/dist/agent/provider-adapter.d.ts +9 -0
- package/dist/agent/provider-adapter.js +85 -39
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +12 -1
- package/dist/agent/retry.js +19 -1
- package/dist/agent/runtime.d.ts +23 -1
- package/dist/agent/runtime.js +236 -26
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- package/dist/cli/config.d.ts +21 -0
- package/dist/cli/config.js +31 -0
- package/dist/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/repl.js +72 -4
- package/dist/cli/run.d.ts +3 -0
- package/dist/cli/run.js +127 -7
- package/dist/context/klyro-md.d.ts +6 -0
- package/dist/context/klyro-md.js +21 -15
- package/dist/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +71 -0
- package/dist/index.js +4 -0
- package/dist/mcp/client.d.ts +53 -0
- package/dist/mcp/client.js +225 -0
- package/dist/mcp/config.d.ts +30 -0
- package/dist/mcp/config.js +82 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +50 -0
- package/dist/mcp/registry.js +172 -0
- package/dist/mcp/schema.d.ts +11 -0
- package/dist/mcp/schema.js +46 -0
- package/dist/persistence/store.d.ts +1 -1
- package/dist/policy/approval.d.ts +27 -6
- package/dist/policy/approval.js +36 -8
- package/dist/policy/engine.d.ts +13 -0
- package/dist/policy/engine.js +31 -2
- package/dist/policy/patterns.d.ts +16 -0
- package/dist/policy/patterns.js +26 -0
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -0
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/types.d.ts +12 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +2 -2
- package/package.json +2 -2
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* 1. `system` is a top-level field, not a message with role=system.
|
|
9
9
|
* 2. Tool definitions use `input_schema` not `parameters`, and have no
|
|
10
10
|
* `type: 'function'` wrapper.
|
|
11
|
-
* 3. `tool_use_id` becomes our `id`;
|
|
12
|
-
* `input_json_delta`
|
|
11
|
+
* 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
|
|
12
|
+
* `input_json_delta` frames, assembled per content_block index (never
|
|
13
|
+
* assumed whole, never silently dropped).
|
|
13
14
|
*
|
|
14
15
|
* Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
|
|
15
16
|
* Auth can be a Bearer token (for proxies) — the adapter accepts either.
|
|
16
17
|
*/
|
|
17
18
|
import type { Message } from './message.js';
|
|
18
|
-
import type { ProviderAdapter, ToolDefinition } from './provider-adapter.js';
|
|
19
|
+
import type { ProviderAdapter, StreamEvent, ToolDefinition } from './provider-adapter.js';
|
|
19
20
|
export interface AnthropicAdapterOptions {
|
|
20
21
|
baseURL?: string;
|
|
21
22
|
apiKey: string;
|
|
@@ -50,17 +51,37 @@ interface AnthropicMessage {
|
|
|
50
51
|
is_error?: boolean;
|
|
51
52
|
}>;
|
|
52
53
|
}
|
|
54
|
+
interface AnthropicSseEvent {
|
|
55
|
+
type: string;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
53
58
|
export declare class AnthropicApiError extends Error {
|
|
54
59
|
readonly status: number;
|
|
55
60
|
readonly body: string;
|
|
56
61
|
constructor(status: number, body: string);
|
|
57
62
|
}
|
|
58
63
|
export declare function anthropicAdapter(opts: AnthropicAdapterOptions): ProviderAdapter;
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Mutable per-stream assembly state. Blocks are keyed by content_block
|
|
66
|
+
* index; the tool id is carried inside the block entry. There is no global
|
|
67
|
+
* "current tool" — concurrent or interleaved blocks stay correctly routed.
|
|
68
|
+
*/
|
|
69
|
+
interface AnthropicStreamState {
|
|
70
|
+
blocks: Map<number, {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
argsJson: string;
|
|
74
|
+
open: boolean;
|
|
75
|
+
}>;
|
|
76
|
+
/** Fragments for an index with no open block yet (flushed on block start). */
|
|
77
|
+
orphans: Map<number, string>;
|
|
78
|
+
thinkingIdx: number | null;
|
|
79
|
+
usage: {
|
|
80
|
+
input?: number;
|
|
81
|
+
output?: number;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
declare function translateSse(event: string, parsed: AnthropicSseEvent, state: AnthropicStreamState): StreamEvent[];
|
|
64
85
|
declare function toAnthropicMessages(messages: Message[]): AnthropicMessage[];
|
|
65
86
|
declare function toAnthropicTool(t: ToolDefinition): {
|
|
66
87
|
name: string;
|
|
@@ -70,6 +91,6 @@ declare function toAnthropicTool(t: ToolDefinition): {
|
|
|
70
91
|
export declare const _internal: {
|
|
71
92
|
toAnthropicMessages: typeof toAnthropicMessages;
|
|
72
93
|
toAnthropicTool: typeof toAnthropicTool;
|
|
73
|
-
|
|
94
|
+
translateSse: typeof translateSse;
|
|
74
95
|
};
|
|
75
96
|
export {};
|
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* 1. `system` is a top-level field, not a message with role=system.
|
|
9
9
|
* 2. Tool definitions use `input_schema` not `parameters`, and have no
|
|
10
10
|
* `type: 'function'` wrapper.
|
|
11
|
-
* 3. `tool_use_id` becomes our `id`;
|
|
12
|
-
* `input_json_delta`
|
|
11
|
+
* 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
|
|
12
|
+
* `input_json_delta` frames, assembled per content_block index (never
|
|
13
|
+
* assumed whole, never silently dropped).
|
|
13
14
|
*
|
|
14
15
|
* Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
|
|
15
16
|
* Auth can be a Bearer token (for proxies) — the adapter accepts either.
|
|
16
17
|
*/
|
|
17
18
|
import { assertSafeBaseURL } from '../chat.js';
|
|
19
|
+
import { parseRetryAfterMs } from './provider-adapter.js';
|
|
18
20
|
const DEFAULT_VERSION = '2023-06-01';
|
|
19
21
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
20
22
|
export class AnthropicApiError extends Error {
|
|
@@ -96,11 +98,15 @@ async function* streamAnthropic(req, opts) {
|
|
|
96
98
|
clearTimeout(timer);
|
|
97
99
|
if (!resp.ok || !resp.body) {
|
|
98
100
|
const text = await resp.text().catch(() => '<unreadable>');
|
|
101
|
+
const retryable = resp.status >= 500 || resp.status === 429;
|
|
102
|
+
const retryAfterMs = retryable ? parseRetryAfterMs(resp.headers?.get('retry-after')) : undefined;
|
|
99
103
|
yield {
|
|
100
104
|
kind: 'error',
|
|
101
105
|
code: `http_${resp.status}`,
|
|
102
106
|
message: `Anthropic API returned ${resp.status}: ${text.slice(0, 500)}`,
|
|
103
|
-
retryable
|
|
107
|
+
retryable,
|
|
108
|
+
status: String(resp.status),
|
|
109
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
104
110
|
};
|
|
105
111
|
return;
|
|
106
112
|
}
|
|
@@ -111,12 +117,14 @@ async function* streamAnthropic(req, opts) {
|
|
|
111
117
|
const reader = resp.body.getReader();
|
|
112
118
|
const decoder = new TextDecoder('utf-8');
|
|
113
119
|
let buf = '';
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
// Per-block assembly keyed by content_block index (tool input IS
|
|
121
|
+
// fragmented across input_json_delta frames — never assume otherwise).
|
|
122
|
+
const state = {
|
|
123
|
+
blocks: new Map(),
|
|
124
|
+
orphans: new Map(),
|
|
125
|
+
thinkingIdx: null,
|
|
126
|
+
usage: {},
|
|
127
|
+
};
|
|
120
128
|
// message_stop already yields message_end — don't emit a second one at EOF.
|
|
121
129
|
let sawMessageEnd = false;
|
|
122
130
|
try {
|
|
@@ -155,7 +163,7 @@ async function* streamAnthropic(req, opts) {
|
|
|
155
163
|
catch {
|
|
156
164
|
continue;
|
|
157
165
|
}
|
|
158
|
-
const out = translateSse(e.event, parsed,
|
|
166
|
+
const out = translateSse(e.event, parsed, state);
|
|
159
167
|
for (const ev of out) {
|
|
160
168
|
if (ev.kind === 'message_end')
|
|
161
169
|
sawMessageEnd = true;
|
|
@@ -172,23 +180,69 @@ async function* streamAnthropic(req, opts) {
|
|
|
172
180
|
finally {
|
|
173
181
|
reader.releaseLock();
|
|
174
182
|
}
|
|
175
|
-
|
|
176
|
-
|
|
183
|
+
// Truncated stream: close open blocks so the runtime finalizes them as
|
|
184
|
+
// (malformed) structured errors instead of hanging, then terminate.
|
|
185
|
+
for (const b of state.blocks.values()) {
|
|
186
|
+
if (b.open) {
|
|
187
|
+
b.open = false;
|
|
188
|
+
yield { kind: 'tool_call_end', id: b.id };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
state.blocks.clear();
|
|
192
|
+
// Fragments that could never be attributed to a tool block are a stream
|
|
193
|
+
// integrity failure — surface loudly, never silently drop.
|
|
194
|
+
if (state.orphans.size > 0 && !sawMessageEnd) {
|
|
195
|
+
const count = [...state.orphans.values()].reduce((n, s) => n + s.length, 0);
|
|
196
|
+
state.orphans.clear();
|
|
197
|
+
yield {
|
|
198
|
+
kind: 'error',
|
|
199
|
+
code: 'ORPHAN_TOOL_DELTAS',
|
|
200
|
+
message: `stream ended with ${count} chars of tool input that match no content block`,
|
|
201
|
+
retryable: false,
|
|
202
|
+
};
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
state.orphans.clear();
|
|
206
|
+
if (!sawMessageEnd) {
|
|
207
|
+
yield {
|
|
208
|
+
kind: 'message_end',
|
|
209
|
+
finishReason: 'stop',
|
|
210
|
+
...(state.usage.input !== undefined || state.usage.output !== undefined
|
|
211
|
+
? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
|
|
212
|
+
: {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
177
215
|
}
|
|
178
|
-
function translateSse(event, parsed,
|
|
216
|
+
function translateSse(event, parsed, state) {
|
|
179
217
|
const out = [];
|
|
180
218
|
switch (event) {
|
|
219
|
+
case 'message_start': {
|
|
220
|
+
const usage = parsed.message?.usage;
|
|
221
|
+
if (typeof usage?.input_tokens === 'number')
|
|
222
|
+
state.usage.input = usage.input_tokens;
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
case 'message_delta': {
|
|
226
|
+
const usage = parsed.usage;
|
|
227
|
+
if (typeof usage?.output_tokens === 'number') {
|
|
228
|
+
state.usage.output = (state.usage.output ?? 0) + usage.output_tokens;
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
181
232
|
case 'content_block_start': {
|
|
182
233
|
const block = parsed.content_block;
|
|
183
234
|
const idx = parsed.index;
|
|
184
|
-
if (block?.type === 'tool_use' && block.id && block.name) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
235
|
+
if (block?.type === 'tool_use' && block.id && block.name && idx !== undefined) {
|
|
236
|
+
// Flush any fragments that arrived before the block start.
|
|
237
|
+
const stashed = state.orphans.get(idx);
|
|
238
|
+
state.orphans.delete(idx);
|
|
239
|
+
state.blocks.set(idx, { id: block.id, name: block.name, argsJson: stashed ?? '', open: true });
|
|
188
240
|
out.push({ kind: 'tool_call_start', id: block.id, name: block.name });
|
|
241
|
+
if (stashed)
|
|
242
|
+
out.push({ kind: 'tool_call_delta', id: block.id, argsJson: stashed });
|
|
189
243
|
}
|
|
190
|
-
else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') &&
|
|
191
|
-
|
|
244
|
+
else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && idx !== undefined) {
|
|
245
|
+
state.thinkingIdx = idx;
|
|
192
246
|
}
|
|
193
247
|
return out;
|
|
194
248
|
}
|
|
@@ -202,31 +256,50 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
|
|
|
202
256
|
out.push({ kind: 'thinking_delta', text: delta.thinking });
|
|
203
257
|
}
|
|
204
258
|
else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
259
|
+
const entry = index !== undefined ? state.blocks.get(index) : undefined;
|
|
260
|
+
if (entry && entry.open) {
|
|
261
|
+
entry.argsJson += delta.partial_json;
|
|
262
|
+
out.push({ kind: 'tool_call_delta', id: entry.id, argsJson: delta.partial_json });
|
|
263
|
+
}
|
|
264
|
+
else if (index !== undefined) {
|
|
265
|
+
const open = [...state.blocks.values()].filter((b) => b.open);
|
|
266
|
+
if (open.length === 1) {
|
|
267
|
+
// Single in-flight tool: attribute here (documented heuristic).
|
|
268
|
+
open[0].argsJson += delta.partial_json;
|
|
269
|
+
out.push({ kind: 'tool_call_delta', id: open[0].id, argsJson: delta.partial_json });
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
// No safe attribution — stash for a later block start, or
|
|
273
|
+
// surface as ORPHAN_TOOL_DELTAS at stream end. Never drop.
|
|
274
|
+
state.orphans.set(index, (state.orphans.get(index) ?? '') + delta.partial_json);
|
|
211
275
|
}
|
|
212
276
|
}
|
|
277
|
+
else {
|
|
278
|
+
state.orphans.set(-1, (state.orphans.get(-1) ?? '') + delta.partial_json);
|
|
279
|
+
}
|
|
213
280
|
}
|
|
214
281
|
return out;
|
|
215
282
|
}
|
|
216
283
|
case 'content_block_stop': {
|
|
217
284
|
const index = parsed.index;
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
out.push({ kind: 'tool_call_end', id });
|
|
285
|
+
if (index !== undefined && index === state.thinkingIdx)
|
|
286
|
+
state.thinkingIdx = null;
|
|
287
|
+
const entry = index !== undefined ? state.blocks.get(index) : undefined;
|
|
288
|
+
if (entry && entry.open) {
|
|
289
|
+
entry.open = false;
|
|
290
|
+
state.blocks.delete(index);
|
|
291
|
+
out.push({ kind: 'tool_call_end', id: entry.id });
|
|
225
292
|
}
|
|
226
293
|
return out;
|
|
227
294
|
}
|
|
228
295
|
case 'message_stop': {
|
|
229
|
-
out.push({
|
|
296
|
+
out.push({
|
|
297
|
+
kind: 'message_end',
|
|
298
|
+
finishReason: 'stop',
|
|
299
|
+
...(state.usage.input !== undefined || state.usage.output !== undefined
|
|
300
|
+
? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
|
|
301
|
+
: {}),
|
|
302
|
+
});
|
|
230
303
|
return out;
|
|
231
304
|
}
|
|
232
305
|
case 'error': {
|
|
@@ -243,21 +316,6 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
|
|
|
243
316
|
return out;
|
|
244
317
|
}
|
|
245
318
|
}
|
|
246
|
-
/** Match an Anthropic content_block index to the tool_use id we emitted. */
|
|
247
|
-
function findToolIdByIndex(index, buffers, indexToToolId) {
|
|
248
|
-
if (index === undefined)
|
|
249
|
-
return undefined;
|
|
250
|
-
if (indexToToolId) {
|
|
251
|
-
const direct = indexToToolId.get(index);
|
|
252
|
-
if (direct)
|
|
253
|
-
return direct;
|
|
254
|
-
}
|
|
255
|
-
// Single-buffer fallback: if only one in-flight tool, any delta belongs to it
|
|
256
|
-
if (buffers.size === 1)
|
|
257
|
-
return buffers.keys().next().value;
|
|
258
|
-
// No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
319
|
function toAnthropicMessages(messages) {
|
|
262
320
|
return messages.map((m) => {
|
|
263
321
|
if (m.role === 'user') {
|
|
@@ -320,4 +378,4 @@ function toAnthropicTool(t) {
|
|
|
320
378
|
};
|
|
321
379
|
}
|
|
322
380
|
// Re-export for testability.
|
|
323
|
-
export const _internal = { toAnthropicMessages, toAnthropicTool,
|
|
381
|
+
export const _internal = { toAnthropicMessages, toAnthropicTool, translateSse };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent capability enforcement (P0.2 from r-6-10.fix.md).
|
|
3
|
+
*
|
|
4
|
+
* Resolves the **effective tool set** for a (child) agent by intersecting:
|
|
5
|
+
*
|
|
6
|
+
* 1. the parent's effective tools — a child can never receive more tools than its parent
|
|
7
|
+
* 2. the agent definition's `allowedTools` (if specified)
|
|
8
|
+
* 3. the runtime policy's allowed tools (e.g. `--allow-write` flags)
|
|
9
|
+
*
|
|
10
|
+
* Then applies safety modifiers:
|
|
11
|
+
*
|
|
12
|
+
* - `readonly: true` strips every tool classified as write or mutation.
|
|
13
|
+
* - `canSpawn: false` (default for children) removes any spawn_agent-like tools.
|
|
14
|
+
* - A deny-list always wins, regardless of what the agent declares.
|
|
15
|
+
*
|
|
16
|
+
* This is a pure function — no I/O, no side effects. It exists so the runtime
|
|
17
|
+
* can decide which tools to expose to the model BEFORE the model picks one.
|
|
18
|
+
*
|
|
19
|
+
* Wiring lives in src/agent/runtime.ts (see the tool-filter hook).
|
|
20
|
+
*/
|
|
21
|
+
export interface AgentCapabilities {
|
|
22
|
+
/** Tools the agent definition explicitly allows. `undefined` means "no allow-list". */
|
|
23
|
+
allowedTools?: string[];
|
|
24
|
+
/** If true, write/mutation tools are stripped. */
|
|
25
|
+
readonly?: boolean;
|
|
26
|
+
/** If false (or unset), tools that can spawn new agents are stripped. */
|
|
27
|
+
canSpawn?: boolean;
|
|
28
|
+
/** Model override — if the provider cannot satisfy it, callers must error rather than fall back. */
|
|
29
|
+
model?: string;
|
|
30
|
+
/** Recursion depth cap. Beyond this, spawn attempts are blocked. */
|
|
31
|
+
maxDepth?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface ResolveToolsInput {
|
|
34
|
+
/** Tools the parent is allowed to use (or `null` for the root agent = all registered tools). */
|
|
35
|
+
parentTools: ReadonlySet<string> | null;
|
|
36
|
+
/** The agent's declared capability profile. */
|
|
37
|
+
agent: AgentCapabilities;
|
|
38
|
+
/** Tools currently allowed by runtime policy (e.g. user-permission flags). */
|
|
39
|
+
policyAllowed: ReadonlySet<string>;
|
|
40
|
+
/** All tools known to the registry (used when parentTools is null). */
|
|
41
|
+
registryTools: ReadonlySet<string>;
|
|
42
|
+
/** Tools that mutate state — stripped under `readonly`. */
|
|
43
|
+
writeTools: ReadonlySet<string>;
|
|
44
|
+
/** Tools that spawn other agents — stripped when `canSpawn` is false. */
|
|
45
|
+
spawnTools: ReadonlySet<string>;
|
|
46
|
+
/** Tools that are NEVER allowed, even if explicitly requested. */
|
|
47
|
+
denied: ReadonlySet<string>;
|
|
48
|
+
/** If true, spawn tools are kept even when agent.canSpawn is false/unset. */
|
|
49
|
+
canSpawnOverride?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export interface ResolveToolsResult {
|
|
52
|
+
/** Tools the child agent is permitted to call. */
|
|
53
|
+
allowed: string[];
|
|
54
|
+
/** Tools that were denied (debugging/observability). */
|
|
55
|
+
dropped: {
|
|
56
|
+
tool: string;
|
|
57
|
+
reason: 'denied' | 'readonly' | 'no-spawn' | 'not-in-parent' | 'not-in-policy' | 'unknown';
|
|
58
|
+
}[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the effective tool set for an agent.
|
|
62
|
+
*
|
|
63
|
+
* Order of operations:
|
|
64
|
+
* 1. Start from `parentTools ?? registryTools`.
|
|
65
|
+
* 2. Intersect with `policyAllowed`.
|
|
66
|
+
* 3. If `allowedTools` is set, intersect again.
|
|
67
|
+
* 4. Strip `writeTools` if `readonly`.
|
|
68
|
+
* 5. Strip `spawnTools` if `!canSpawn`.
|
|
69
|
+
* 6. Remove `denied` last (always wins).
|
|
70
|
+
*/
|
|
71
|
+
export declare function resolveAgentTools(input: ResolveToolsInput): ResolveToolsResult;
|
|
72
|
+
/**
|
|
73
|
+
* Reason a tool was dropped from the resolved capability set. Mirrors
|
|
74
|
+
* `ResolveToolsResult.dropped[].reason`.
|
|
75
|
+
*/
|
|
76
|
+
export type DropReason = 'denied' | 'readonly' | 'no-spawn' | 'not-in-parent' | 'not-in-policy' | 'unknown';
|
|
77
|
+
/**
|
|
78
|
+
* Fully-resolved capability profile for a child agent — tool set plus
|
|
79
|
+
* non-tool knobs (model override, recursion cap). Computed once at spawn
|
|
80
|
+
* time and threaded through the runtime as `parentContext`.
|
|
81
|
+
*/
|
|
82
|
+
export interface ResolvedCapabilities {
|
|
83
|
+
/** Sorted tool names the child is allowed to invoke. */
|
|
84
|
+
allowed: ReadonlySet<string>;
|
|
85
|
+
/** Per-tool drop reasons — surfaced in ChildSummary for parent visibility. */
|
|
86
|
+
dropped: {
|
|
87
|
+
tool: string;
|
|
88
|
+
reason: DropReason;
|
|
89
|
+
}[];
|
|
90
|
+
/** Model override to send to the provider (or undefined to inherit parent). */
|
|
91
|
+
model?: string;
|
|
92
|
+
/** Effective recursion depth cap. Caller MUST reject spawn when depth + 1 > maxDepth. */
|
|
93
|
+
maxDepth: number;
|
|
94
|
+
readonly: boolean;
|
|
95
|
+
canSpawn: boolean;
|
|
96
|
+
}
|
|
97
|
+
/** Extra knobs consumed by `resolveCapabilities` on top of `ResolveToolsInput`. */
|
|
98
|
+
export interface ResolveCapabilitiesExtra {
|
|
99
|
+
/** Caller-provided maxDepth (e.g. from CLI `--max-depth` or a parent's maxDepth). Required. */
|
|
100
|
+
maxDepth: number;
|
|
101
|
+
/** If true, the resolved `allowed` set may include tools that themselves spawn agents. */
|
|
102
|
+
canSpawnOverride?: boolean;
|
|
103
|
+
}
|
|
104
|
+
/** Combined input for the one-shot resolver used by the orchestrator. */
|
|
105
|
+
export interface ResolveCapabilitiesInput extends ResolveToolsInput, ResolveCapabilitiesExtra {
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* One-shot capability resolver: tool intersection + per-agent model/maxDepth.
|
|
109
|
+
*
|
|
110
|
+
* `maxDepth` precedence (most specific wins):
|
|
111
|
+
* 1. `agent.maxDepth` (if set on the definition)
|
|
112
|
+
* 2. `extra.maxDepth` (parent / CLI cap)
|
|
113
|
+
* `model` is propagated only when the agent declares one; we do not silently
|
|
114
|
+
* override a parent's chosen model.
|
|
115
|
+
*/
|
|
116
|
+
export declare function resolveCapabilities(input: ResolveCapabilitiesInput): ResolvedCapabilities;
|
|
117
|
+
/** Sensible defaults for "what counts as a write tool" if a caller doesn't override. */
|
|
118
|
+
export declare const DEFAULT_WRITE_TOOLS: ReadonlySet<string>;
|
|
119
|
+
/** Default spawn tools — removed from children by default. */
|
|
120
|
+
export declare const DEFAULT_SPAWN_TOOLS: ReadonlySet<string>;
|
|
121
|
+
/** Default deny-list — these are NEVER allowed, even if explicitly requested. */
|
|
122
|
+
export declare const DEFAULT_DENIED_TOOLS: ReadonlySet<string>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent capability enforcement (P0.2 from r-6-10.fix.md).
|
|
3
|
+
*
|
|
4
|
+
* Resolves the **effective tool set** for a (child) agent by intersecting:
|
|
5
|
+
*
|
|
6
|
+
* 1. the parent's effective tools — a child can never receive more tools than its parent
|
|
7
|
+
* 2. the agent definition's `allowedTools` (if specified)
|
|
8
|
+
* 3. the runtime policy's allowed tools (e.g. `--allow-write` flags)
|
|
9
|
+
*
|
|
10
|
+
* Then applies safety modifiers:
|
|
11
|
+
*
|
|
12
|
+
* - `readonly: true` strips every tool classified as write or mutation.
|
|
13
|
+
* - `canSpawn: false` (default for children) removes any spawn_agent-like tools.
|
|
14
|
+
* - A deny-list always wins, regardless of what the agent declares.
|
|
15
|
+
*
|
|
16
|
+
* This is a pure function — no I/O, no side effects. It exists so the runtime
|
|
17
|
+
* can decide which tools to expose to the model BEFORE the model picks one.
|
|
18
|
+
*
|
|
19
|
+
* Wiring lives in src/agent/runtime.ts (see the tool-filter hook).
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the effective tool set for an agent.
|
|
23
|
+
*
|
|
24
|
+
* Order of operations:
|
|
25
|
+
* 1. Start from `parentTools ?? registryTools`.
|
|
26
|
+
* 2. Intersect with `policyAllowed`.
|
|
27
|
+
* 3. If `allowedTools` is set, intersect again.
|
|
28
|
+
* 4. Strip `writeTools` if `readonly`.
|
|
29
|
+
* 5. Strip `spawnTools` if `!canSpawn`.
|
|
30
|
+
* 6. Remove `denied` last (always wins).
|
|
31
|
+
*/
|
|
32
|
+
export function resolveAgentTools(input) {
|
|
33
|
+
const { parentTools, agent, policyAllowed, registryTools, writeTools, spawnTools, denied, } = input;
|
|
34
|
+
const allowed = new Set();
|
|
35
|
+
const dropped = [];
|
|
36
|
+
// 1. Baseline: parent's tools, or all registered tools for the root agent.
|
|
37
|
+
// Record narrowing for observability: registry tools outside the parent's
|
|
38
|
+
// set are reported as 'not-in-parent' drops (child can never exceed parent).
|
|
39
|
+
const baseline = parentTools ?? registryTools;
|
|
40
|
+
if (parentTools !== null) {
|
|
41
|
+
for (const name of registryTools) {
|
|
42
|
+
if (!parentTools.has(name)) {
|
|
43
|
+
dropped.push({ tool: name, reason: 'not-in-parent' });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const name of baseline) {
|
|
48
|
+
if (!registryTools.has(name)) {
|
|
49
|
+
dropped.push({ tool: name, reason: 'unknown' });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!policyAllowed.has(name)) {
|
|
53
|
+
dropped.push({ tool: name, reason: 'not-in-policy' });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
allowed.add(name);
|
|
57
|
+
}
|
|
58
|
+
// 3. Apply the agent's allow-list (if any). Anything not in the list is dropped.
|
|
59
|
+
if (agent.allowedTools) {
|
|
60
|
+
const explicit = new Set(agent.allowedTools);
|
|
61
|
+
for (const name of [...allowed]) {
|
|
62
|
+
if (!explicit.has(name)) {
|
|
63
|
+
allowed.delete(name);
|
|
64
|
+
dropped.push({ tool: name, reason: 'not-in-parent' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Tools requested in allowedTools but not in the baseline cannot be granted.
|
|
68
|
+
for (const name of agent.allowedTools) {
|
|
69
|
+
if (!allowed.has(name) && registryTools.has(name) && policyAllowed.has(name)) {
|
|
70
|
+
dropped.push({ tool: name, reason: 'not-in-parent' });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 4. readonly → strip write tools.
|
|
75
|
+
if (agent.readonly) {
|
|
76
|
+
for (const name of [...allowed]) {
|
|
77
|
+
if (writeTools.has(name)) {
|
|
78
|
+
allowed.delete(name);
|
|
79
|
+
dropped.push({ tool: name, reason: 'readonly' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// 5. canSpawn=false → strip spawn tools. Default: root agent (parentTools
|
|
84
|
+
// === null) may spawn; children strip unless explicitly enabled. An explicit
|
|
85
|
+
// canSpawnOverride=true (via resolveCapabilities) also keeps them.
|
|
86
|
+
const canSpawnEffective = input.canSpawnOverride ?? input.agent.canSpawn ?? (input.parentTools === null);
|
|
87
|
+
if (!canSpawnEffective) {
|
|
88
|
+
for (const name of [...allowed]) {
|
|
89
|
+
if (spawnTools.has(name)) {
|
|
90
|
+
allowed.delete(name);
|
|
91
|
+
dropped.push({ tool: name, reason: 'no-spawn' });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// 6. Deny-list always wins.
|
|
96
|
+
for (const name of [...allowed]) {
|
|
97
|
+
if (denied.has(name)) {
|
|
98
|
+
allowed.delete(name);
|
|
99
|
+
dropped.push({ tool: name, reason: 'denied' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
allowed: [...allowed].sort(),
|
|
104
|
+
dropped: dropped.sort((a, b) => a.tool.localeCompare(b.tool)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* One-shot capability resolver: tool intersection + per-agent model/maxDepth.
|
|
109
|
+
*
|
|
110
|
+
* `maxDepth` precedence (most specific wins):
|
|
111
|
+
* 1. `agent.maxDepth` (if set on the definition)
|
|
112
|
+
* 2. `extra.maxDepth` (parent / CLI cap)
|
|
113
|
+
* `model` is propagated only when the agent declares one; we do not silently
|
|
114
|
+
* override a parent's chosen model.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveCapabilities(input) {
|
|
117
|
+
const resolved = resolveAgentTools(input);
|
|
118
|
+
const allowed = new Set(resolved.allowed);
|
|
119
|
+
const model = input.agent.model;
|
|
120
|
+
const maxDepth = input.agent.maxDepth ?? input.maxDepth;
|
|
121
|
+
const canSpawn = input.canSpawnOverride ?? input.agent.canSpawn ?? false;
|
|
122
|
+
return {
|
|
123
|
+
allowed,
|
|
124
|
+
dropped: resolved.dropped,
|
|
125
|
+
...(model !== undefined ? { model } : {}),
|
|
126
|
+
maxDepth,
|
|
127
|
+
readonly: input.agent.readonly ?? false,
|
|
128
|
+
canSpawn,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Sensible defaults for "what counts as a write tool" if a caller doesn't override. */
|
|
132
|
+
export const DEFAULT_WRITE_TOOLS = new Set([
|
|
133
|
+
'write_file',
|
|
134
|
+
'edit_file',
|
|
135
|
+
'multi_edit',
|
|
136
|
+
'apply_patch',
|
|
137
|
+
'shell_exec',
|
|
138
|
+
'memory_write',
|
|
139
|
+
'background_shell',
|
|
140
|
+
'todo_write',
|
|
141
|
+
]);
|
|
142
|
+
/** Default spawn tools — removed from children by default. */
|
|
143
|
+
export const DEFAULT_SPAWN_TOOLS = new Set([
|
|
144
|
+
'spawn_agent',
|
|
145
|
+
'subtask',
|
|
146
|
+
]);
|
|
147
|
+
/** Default deny-list — these are NEVER allowed, even if explicitly requested. */
|
|
148
|
+
export const DEFAULT_DENIED_TOOLS = new Set([
|
|
149
|
+
// Add dangerous tools here. Empty by default — extend as policy matures.
|
|
150
|
+
]);
|