klyro 0.1.63 → 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.js +6 -1
- 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 +24 -1
- 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 +20 -0
- package/dist/agent/runtime.js +9 -1
- 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/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/repl.js +41 -2
- package/dist/cli/run.d.ts +3 -0
- package/dist/cli/run.js +118 -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/policy/engine.js +3 -2
- 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/package.json +1 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentOrchestrator — spawns child agents and turns them into tasks.
|
|
3
|
+
*
|
|
4
|
+
* The lowest-level way to run Klyro is `run(options, deps)` (one agent,
|
|
5
|
+
* one loop). Orchestration layers on top of that: a parent agent calls
|
|
6
|
+
* `spawn_agent`, which resolves the target `AgentDefinition`, computes its
|
|
7
|
+
* effective capabilities (via `resolveCapabilities`), creates a
|
|
8
|
+
* `TaskRecord`, and runs a *scoped* child loop. The child runs in-process
|
|
9
|
+
* with a `ScopedRegistry` (narrowed tool set), an optional model override,
|
|
10
|
+
* a depth cap, and an AbortController wired to the parent's signal.
|
|
11
|
+
*
|
|
12
|
+
* The compact result is a `ChildSummary` — a `ToolResult` the parent model
|
|
13
|
+
* can act on — never the full child transcript.
|
|
14
|
+
*/
|
|
15
|
+
import { run } from './runtime.js';
|
|
16
|
+
import { ScopedRegistry } from './scoped-registry.js';
|
|
17
|
+
import { globalBus } from '../events/bus.js';
|
|
18
|
+
import { TaskManager } from './task-manager.js';
|
|
19
|
+
import { WorkerSpawner } from './worker-spawner.js';
|
|
20
|
+
import { resolveCapabilities, DEFAULT_WRITE_TOOLS, DEFAULT_SPAWN_TOOLS, DEFAULT_DENIED_TOOLS, } from './capabilities.js';
|
|
21
|
+
/** Default agents a model can delegate to. */
|
|
22
|
+
export const BUILTIN_AGENTS = [
|
|
23
|
+
{
|
|
24
|
+
id: 'explorer',
|
|
25
|
+
description: 'Read-only reconnaissance: map the repo, find symbols and tests.',
|
|
26
|
+
readonly: true,
|
|
27
|
+
canSpawn: false,
|
|
28
|
+
allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'repo_map', 'find_symbol', 'git_status', 'git_log', 'git_diff', 'recent_files', 'imports_of', 'importers_of'],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'implementer',
|
|
32
|
+
description: 'Write-capable worker for concrete, well-scoped coding tasks.',
|
|
33
|
+
canSpawn: false,
|
|
34
|
+
allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'write_file', 'edit_file', 'multi_edit', 'apply_patch', 'shell_exec', 'git_status', 'git_log', 'git_diff', 'run_verify', 'todo_write'],
|
|
35
|
+
maxSteps: 60,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'tester',
|
|
39
|
+
description: 'Runs verification and tests, reports failures with diagnostics.',
|
|
40
|
+
canSpawn: false,
|
|
41
|
+
allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'shell_exec', 'run_verify', 'git_status', 'git_log', 'git_diff'],
|
|
42
|
+
maxTimeMs: 120_000,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'reviewer',
|
|
46
|
+
description: 'Read-only review of a diff or change set for bugs.',
|
|
47
|
+
readonly: true,
|
|
48
|
+
canSpawn: false,
|
|
49
|
+
allowedTools: ['read_file', 'list_dir', 'glob', 'grep', 'search_files', 'git_status', 'git_diff', 'git_log', 'imports_of', 'importers_of', 'find_symbol'],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
/** Map a runtime `RunResult.status` to a task status. */
|
|
53
|
+
function mapResultStatus(status) {
|
|
54
|
+
switch (status) {
|
|
55
|
+
case 'complete':
|
|
56
|
+
return 'succeeded';
|
|
57
|
+
case 'aborted':
|
|
58
|
+
return 'cancelled';
|
|
59
|
+
case 'blocked':
|
|
60
|
+
return 'blocked';
|
|
61
|
+
case 'max_steps':
|
|
62
|
+
case 'no_final':
|
|
63
|
+
case 'verify_failed':
|
|
64
|
+
case 'limit':
|
|
65
|
+
case 'stuck':
|
|
66
|
+
return 'failed';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class AgentOrchestrator {
|
|
70
|
+
sessionId;
|
|
71
|
+
deps;
|
|
72
|
+
taskManager;
|
|
73
|
+
workerSpawner;
|
|
74
|
+
constructor(opts) {
|
|
75
|
+
this.sessionId = opts.sessionId;
|
|
76
|
+
this.deps = opts.deps;
|
|
77
|
+
this.taskManager = opts.taskManager ?? new TaskManager({ sessionId: opts.sessionId });
|
|
78
|
+
this.workerSpawner = opts.workerSpawner ?? new WorkerSpawner();
|
|
79
|
+
}
|
|
80
|
+
listAgents() {
|
|
81
|
+
return [...BUILTIN_AGENTS];
|
|
82
|
+
}
|
|
83
|
+
getAgent(id) {
|
|
84
|
+
return BUILTIN_AGENTS.find((a) => a.id === id);
|
|
85
|
+
}
|
|
86
|
+
/** Build the bridge the parent's runtime hands to tools. */
|
|
87
|
+
bridgeFor(parent) {
|
|
88
|
+
return {
|
|
89
|
+
parent,
|
|
90
|
+
listAgents: () => this.listAgents(),
|
|
91
|
+
getAgent: (id) => this.getAgent(id),
|
|
92
|
+
spawnAgent: (input) => this.spawnAgent(input, parent),
|
|
93
|
+
listTasks: (filter) => this.taskManager.list(filter),
|
|
94
|
+
getTask: (id) => {
|
|
95
|
+
const r = this.taskManager.get(id);
|
|
96
|
+
if (!r)
|
|
97
|
+
return undefined;
|
|
98
|
+
const s = this.taskManager.toSummary(r);
|
|
99
|
+
return r.error ? { ...s, error: { code: r.error.code, message: r.error.message } } : s;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Compute a child's effective capabilities from the parent's own. */
|
|
104
|
+
resolveChild(def, parent, registryTools) {
|
|
105
|
+
const input = {
|
|
106
|
+
parentTools: parent.allowedTools,
|
|
107
|
+
agent: def,
|
|
108
|
+
policyAllowed: registryTools,
|
|
109
|
+
registryTools,
|
|
110
|
+
writeTools: DEFAULT_WRITE_TOOLS,
|
|
111
|
+
spawnTools: DEFAULT_SPAWN_TOOLS,
|
|
112
|
+
denied: DEFAULT_DENIED_TOOLS,
|
|
113
|
+
};
|
|
114
|
+
const resolved = resolveCapabilities({ ...input, maxDepth: parent.maxDepth });
|
|
115
|
+
return resolved;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Spawn a child agent for a given capability context, await its run, and
|
|
119
|
+
* return a compact summary. Blocks until the child settles (P0 scope;
|
|
120
|
+
* async task_wait arrives in a later slice).
|
|
121
|
+
*/
|
|
122
|
+
async spawnAgent(input, parent) {
|
|
123
|
+
const def = this.getAgent(input.agent);
|
|
124
|
+
if (!def) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: { code: 'UNKNOWN_AGENT', message: `Unknown agent: ${input.agent}` },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Depth guard — block before creating a (running) task.
|
|
131
|
+
const childDepth = parent.depth + 1;
|
|
132
|
+
const maxDepth = def.maxDepth ?? parent.maxDepth;
|
|
133
|
+
if (childDepth > maxDepth) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: {
|
|
137
|
+
code: 'RECURSION_LIMIT',
|
|
138
|
+
message: `maxDepth exceeded for agent "${def.id}": cannot spawn at depth ${childDepth} (cap ${maxDepth})`,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const registryTools = new Set(this.deps.registry.list().map((t) => t.name));
|
|
143
|
+
const resolved = this.resolveChild(def, parent, registryTools);
|
|
144
|
+
const childModel = input.model ?? resolved.model ?? parent.model;
|
|
145
|
+
const childCwd = input.cwd ?? parent.cwd;
|
|
146
|
+
const createOpts = {
|
|
147
|
+
agentName: def.id,
|
|
148
|
+
cwd: childCwd,
|
|
149
|
+
depth: childDepth,
|
|
150
|
+
maxDepth,
|
|
151
|
+
model: childModel,
|
|
152
|
+
parentTaskId: parent.taskId,
|
|
153
|
+
timeoutMs: input.timeoutMs ?? def.maxTimeMs,
|
|
154
|
+
abortOnParent: undefined, // wired below via the parent signal
|
|
155
|
+
};
|
|
156
|
+
const record = this.taskManager.create(createOpts);
|
|
157
|
+
const childRegistry = new ScopedRegistry(this.deps.registry, resolved.allowed);
|
|
158
|
+
const childDeps = { ...this.deps, registry: childRegistry };
|
|
159
|
+
const childOptions = {
|
|
160
|
+
task: input.task,
|
|
161
|
+
cwd: childCwd,
|
|
162
|
+
model: childModel ?? 'inherit', // model override must reach the adapter (see runtime)
|
|
163
|
+
maxSteps: def.maxSteps,
|
|
164
|
+
maxCost: def.maxCost,
|
|
165
|
+
maxTimeMs: def.maxTimeMs ?? input.timeoutMs,
|
|
166
|
+
signal: record.abortController.signal,
|
|
167
|
+
nonInteractive: true,
|
|
168
|
+
};
|
|
169
|
+
// Lifecycle events on the shared bus (mirror TaskManager transitions).
|
|
170
|
+
globalBus.emit({
|
|
171
|
+
type: 'subtask.started',
|
|
172
|
+
ts: Date.now(),
|
|
173
|
+
sessionId: this.sessionId,
|
|
174
|
+
taskId: record.id,
|
|
175
|
+
...(record.parentTaskId ? { parentTaskId: record.parentTaskId } : {}),
|
|
176
|
+
agentName: def.id,
|
|
177
|
+
depth: childDepth,
|
|
178
|
+
...(typeof childModel === 'string' ? { model: childModel } : {}),
|
|
179
|
+
});
|
|
180
|
+
const handle = this.workerSpawner.spawn(async () => {
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
result = await run(childOptions, childDeps);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
this.taskManager.finish(record.id, 'failed', {
|
|
187
|
+
error: { code: 'CHILD_CRASH', message: err instanceof Error ? err.message : String(err) },
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const status = mapResultStatus(result.status);
|
|
192
|
+
if (status === 'cancelled') {
|
|
193
|
+
this.taskManager.cancel(record.id, 'parent aborted');
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.taskManager.finish(record.id, status, {
|
|
197
|
+
summary: [`status: ${status}`, `steps: ${result.steps}`, `toolCalls: ${result.toolCalls}`],
|
|
198
|
+
error: status === 'failed'
|
|
199
|
+
? { code: mapFailureCode(result.status), message: result.finalText?.slice(0, 300) ?? 'child failed' }
|
|
200
|
+
: undefined,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const final = this.taskManager.get(record.id);
|
|
204
|
+
const durationMs = final.finishedAt ? final.finishedAt - final.startedAt : 0;
|
|
205
|
+
if (status === 'succeeded') {
|
|
206
|
+
globalBus.emit({
|
|
207
|
+
type: 'subtask.completed',
|
|
208
|
+
ts: Date.now(),
|
|
209
|
+
sessionId: this.sessionId,
|
|
210
|
+
taskId: record.id,
|
|
211
|
+
status: 'succeeded',
|
|
212
|
+
durationMs,
|
|
213
|
+
steps: result?.steps ?? 0,
|
|
214
|
+
toolCalls: result?.toolCalls ?? 0,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
globalBus.emit({
|
|
219
|
+
type: 'subtask.failed',
|
|
220
|
+
ts: Date.now(),
|
|
221
|
+
sessionId: this.sessionId,
|
|
222
|
+
taskId: record.id,
|
|
223
|
+
status: status,
|
|
224
|
+
durationMs,
|
|
225
|
+
error: final.error ? { code: final.error.code, message: final.error.message } : undefined,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}, { label: `agent:${def.id}` });
|
|
229
|
+
await handle.done.catch(() => undefined);
|
|
230
|
+
const finalRecord = this.taskManager.get(record.id);
|
|
231
|
+
if (!finalRecord) {
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: { code: 'INTERNAL', message: `task ${record.id} missing after child run` },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return { ok: true, value: this.toChildSummary(finalRecord, def, resolved.dropped) };
|
|
238
|
+
}
|
|
239
|
+
toChildSummary(r, def, dropped) {
|
|
240
|
+
const s = this.taskManager.toSummary(r);
|
|
241
|
+
return {
|
|
242
|
+
taskId: s.id,
|
|
243
|
+
agentName: s.agentName,
|
|
244
|
+
status: s.status,
|
|
245
|
+
durationMs: s.durationMs ?? 0,
|
|
246
|
+
...(def.model ? { model: def.model } : {}),
|
|
247
|
+
changedFiles: r.summary.filter((line) => line.startsWith('changed: ')).map((line) => line.slice('changed: '.length)),
|
|
248
|
+
droppedTools: dropped.length ? dropped : undefined,
|
|
249
|
+
error: r.error ? { code: r.error.code, message: r.error.message } : undefined,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function mapFailureCode(status) {
|
|
254
|
+
switch (status) {
|
|
255
|
+
case 'max_steps':
|
|
256
|
+
return 'MAX_STEPS';
|
|
257
|
+
case 'verify_failed':
|
|
258
|
+
return 'VERIFY_FAILED';
|
|
259
|
+
case 'limit':
|
|
260
|
+
return 'LIMIT';
|
|
261
|
+
case 'stuck':
|
|
262
|
+
return 'STUCK';
|
|
263
|
+
case 'no_final':
|
|
264
|
+
default:
|
|
265
|
+
return 'NO_FINAL';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Module-scoped holder the orchestrator sets so children inherit the parent's signal. */
|
|
269
|
+
export const parentAbortSignalRef = { current: null };
|
|
@@ -45,6 +45,8 @@ export type StreamEvent = {
|
|
|
45
45
|
code: string;
|
|
46
46
|
message: string;
|
|
47
47
|
retryable: boolean;
|
|
48
|
+
status?: string;
|
|
49
|
+
retryAfterMs?: number;
|
|
48
50
|
};
|
|
49
51
|
export interface ToolDefinition {
|
|
50
52
|
name: string;
|
|
@@ -72,6 +74,13 @@ export interface HttpAdapterOptions {
|
|
|
72
74
|
/** Override fetch (e.g. for tests). */
|
|
73
75
|
fetchImpl?: typeof fetch;
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse a `Retry-After` response header value into milliseconds.
|
|
79
|
+
* Returns undefined when absent or unparseable. Handles both forms:
|
|
80
|
+
* delay-seconds ("120") and HTTP-date ("Wed, 21 Oct 2015 07:28:00 GMT",
|
|
81
|
+
* clamped at 0 when the date is in the past).
|
|
82
|
+
*/
|
|
83
|
+
export declare function parseRetryAfterMs(value: string | null | undefined): number | undefined;
|
|
75
84
|
/** Convert a Zod schema to a permissive JSON Schema object for tool defs. */
|
|
76
85
|
export declare function zodToJsonSchema(schema: z.ZodType<unknown>): Record<string, unknown>;
|
|
77
86
|
interface ChatCompletionsRequest {
|
|
@@ -12,6 +12,25 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { redact } from '../policy/secret-redactor.js';
|
|
14
14
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
+
/**
|
|
16
|
+
* Parse a `Retry-After` response header value into milliseconds.
|
|
17
|
+
* Returns undefined when absent or unparseable. Handles both forms:
|
|
18
|
+
* delay-seconds ("120") and HTTP-date ("Wed, 21 Oct 2015 07:28:00 GMT",
|
|
19
|
+
* clamped at 0 when the date is in the past).
|
|
20
|
+
*/
|
|
21
|
+
export function parseRetryAfterMs(value) {
|
|
22
|
+
if (value == null)
|
|
23
|
+
return undefined;
|
|
24
|
+
const v = value.trim();
|
|
25
|
+
if (!v)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (/^\d+$/.test(v))
|
|
28
|
+
return Number(v) * 1000;
|
|
29
|
+
const t = Date.parse(v);
|
|
30
|
+
if (!Number.isNaN(t))
|
|
31
|
+
return Math.max(0, t - Date.now());
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
15
34
|
/** Convert a Zod schema to a permissive JSON Schema object for tool defs. */
|
|
16
35
|
export function zodToJsonSchema(schema) {
|
|
17
36
|
// We keep this simple: zod's own _def is enough to give the model a
|
|
@@ -183,11 +202,15 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
183
202
|
req.signal?.removeEventListener('abort', onAbort);
|
|
184
203
|
const rawErr = await res.text().catch(() => '');
|
|
185
204
|
const errText = redact(rawErr).slice(0, 500);
|
|
205
|
+
const retryable = res.status >= 500 || res.status === 429;
|
|
206
|
+
const retryAfterMs = retryable ? parseRetryAfterMs(res.headers?.get('retry-after')) : undefined;
|
|
186
207
|
yield {
|
|
187
208
|
kind: 'error',
|
|
188
209
|
code: `HTTP_${res.status}`,
|
|
189
210
|
message: `provider returned ${res.status}: ${errText}`,
|
|
190
|
-
retryable
|
|
211
|
+
retryable,
|
|
212
|
+
status: String(res.status),
|
|
213
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
191
214
|
};
|
|
192
215
|
return;
|
|
193
216
|
}
|
package/dist/agent/registry.d.ts
CHANGED
package/dist/agent/registry.js
CHANGED
package/dist/agent/retry.d.ts
CHANGED
|
@@ -28,8 +28,19 @@ export interface RetryOptions {
|
|
|
28
28
|
sleep?: (ms: number) => Promise<void>;
|
|
29
29
|
/** Test hook: called once per attempt with 0-indexed attempt number. */
|
|
30
30
|
onAttempt?: (attempt: number) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Retry telemetry hook (operator-visible; the model stays blind).
|
|
33
|
+
* Called each time a retryable error is buffered and another attempt
|
|
34
|
+
* will follow (NOT on the terminal failure). `attempt` is the
|
|
35
|
+
* 1-indexed retry number (1 = first retry after the initial failure).
|
|
36
|
+
*/
|
|
37
|
+
onRetry?: (info: {
|
|
38
|
+
attempt: number;
|
|
39
|
+
status: string;
|
|
40
|
+
retryAfterMs?: number;
|
|
41
|
+
}) => void;
|
|
31
42
|
}
|
|
32
|
-
export declare const DEFAULT_RETRY: Required<Omit<RetryOptions, 'signal' | 'onAttempt'>>;
|
|
43
|
+
export declare const DEFAULT_RETRY: Required<Omit<RetryOptions, 'signal' | 'onAttempt' | 'onRetry'>>;
|
|
33
44
|
/**
|
|
34
45
|
* Sleep that resolves early when `signal` aborts (never rejects — callers
|
|
35
46
|
* check `signal.aborted` themselves after waking).
|
package/dist/agent/retry.js
CHANGED
|
@@ -152,7 +152,25 @@ export function retryingAdapter(inner, opts = {}) {
|
|
|
152
152
|
yield lastError;
|
|
153
153
|
return;
|
|
154
154
|
}
|
|
155
|
-
|
|
155
|
+
// Telemetry for the operator: a retryable error was buffered and
|
|
156
|
+
// another attempt will follow. The consumer stream never sees the
|
|
157
|
+
// buffered error, so the model stays blind.
|
|
158
|
+
const errRec = lastError;
|
|
159
|
+
const statusRaw = errRec?.['status'];
|
|
160
|
+
const codeRaw = errRec?.['code'];
|
|
161
|
+
const status = typeof statusRaw === 'string' && statusRaw.length > 0
|
|
162
|
+
? statusRaw
|
|
163
|
+
: typeof codeRaw === 'string' && codeRaw.length > 0
|
|
164
|
+
? codeRaw
|
|
165
|
+
: 'retryable';
|
|
166
|
+
const retryAfterRaw = errRec?.['retryAfterMs'];
|
|
167
|
+
const retryAfterMs = typeof retryAfterRaw === 'number' && Number.isFinite(retryAfterRaw) && retryAfterRaw >= 0
|
|
168
|
+
? retryAfterRaw
|
|
169
|
+
: undefined;
|
|
170
|
+
opts.onRetry?.({ attempt: attempt + 1, status, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
171
|
+
// Honor a server-provided Retry-After delay when present; otherwise
|
|
172
|
+
// fall back to exponential backoff with jitter.
|
|
173
|
+
const delay = retryAfterMs ?? computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
|
|
156
174
|
// Abort-aware backoff: Ctrl+C during the sleep must stop promptly
|
|
157
175
|
// instead of stalling up to maxMs before noticing.
|
|
158
176
|
if (delay > 0)
|
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -87,6 +87,26 @@ export interface RunOptions {
|
|
|
87
87
|
store?: import('../persistence/store.js').SessionStore;
|
|
88
88
|
sessionId?: string;
|
|
89
89
|
};
|
|
90
|
+
/**
|
|
91
|
+
* Orchestration context (P0). Present for any agent that is itself managed
|
|
92
|
+
* by an AgentOrchestrator — so a child knows who its parent is, how deep the
|
|
93
|
+
* call stack is, which tools it may use, and which task/session it belongs to.
|
|
94
|
+
*/
|
|
95
|
+
parentContext?: {
|
|
96
|
+
taskId?: string;
|
|
97
|
+
parentTaskId?: string;
|
|
98
|
+
sessionId: string;
|
|
99
|
+
depth: number;
|
|
100
|
+
maxDepth: number;
|
|
101
|
+
allowedTools?: ReadonlySet<string>;
|
|
102
|
+
model?: string;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Delegation bridge (P0). Present on the root run so the model can call
|
|
106
|
+
* spawn_agent / task_list / task_get. The tool layer reads it from the
|
|
107
|
+
* ToolContext.
|
|
108
|
+
*/
|
|
109
|
+
agentBridge?: import('./orchestrator.js').AgentSpawnBridge;
|
|
90
110
|
}
|
|
91
111
|
/** A single plan step emitted by the agent. */
|
|
92
112
|
export interface PlanStep {
|
package/dist/agent/runtime.js
CHANGED
|
@@ -229,7 +229,7 @@ export async function run(opts, deps) {
|
|
|
229
229
|
emitKlyro({ type: 'context.compacted', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', dropped: c.dropped });
|
|
230
230
|
}
|
|
231
231
|
const req = {
|
|
232
|
-
model: opts.model,
|
|
232
|
+
model: opts.parentContext?.model ?? opts.model,
|
|
233
233
|
system: reqSystem,
|
|
234
234
|
messages: reqMessages,
|
|
235
235
|
tools: toolDefinitions(deps.registry),
|
|
@@ -573,6 +573,14 @@ export async function run(opts, deps) {
|
|
|
573
573
|
signal: opts.signal,
|
|
574
574
|
nonInteractive: opts.nonInteractive,
|
|
575
575
|
sessionId,
|
|
576
|
+
...(opts.agentBridge ? { agentBridge: opts.agentBridge } : {}),
|
|
577
|
+
...(opts.parentContext?.taskId
|
|
578
|
+
? { parentTaskId: opts.parentContext.parentTaskId ?? opts.parentContext.taskId }
|
|
579
|
+
: {}),
|
|
580
|
+
agentDepth: opts.parentContext?.depth ?? 0,
|
|
581
|
+
agentMaxDepth: opts.parentContext?.maxDepth ?? 1,
|
|
582
|
+
...(opts.parentContext?.allowedTools ? { agentAllowedTools: opts.parentContext.allowedTools } : {}),
|
|
583
|
+
...(opts.parentContext?.model ?? opts.model ? { agentModel: opts.parentContext?.model ?? opts.model } : {}),
|
|
576
584
|
};
|
|
577
585
|
const allSafe = finalizedCalls.length > 1 && finalizedCalls.every((c) => deps.registry.get(c.name)?.isConcurrencySafe !== false);
|
|
578
586
|
// Gate phase: policy decision + approval prompt for one call. Runs
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScopedRegistry — a `ToolRegistry` that narrows the surface to a resolved
|
|
3
|
+
* capability set.
|
|
4
|
+
*
|
|
5
|
+
* Both surfaces the runtime relies on (`toolDefinitions(deps.registry)`
|
|
6
|
+
* and `deps.registry.execute(...)`) go through `list()` / `execute()`, so
|
|
7
|
+
* wrapping the child's registry in this class enforces the tool allow-set
|
|
8
|
+
* end-to-end without touching the runtime loop: the model only sees the
|
|
9
|
+
* allowed tools, and any call to a disallowed tool returns `UNKNOWN_TOOL`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Tool, ToolContext, ToolResult } from '../tools/types.js';
|
|
12
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
13
|
+
export declare class ScopedRegistry extends ToolRegistry {
|
|
14
|
+
private readonly parent;
|
|
15
|
+
private readonly allowed;
|
|
16
|
+
constructor(parent: ToolRegistry, allowed: ReadonlySet<string>);
|
|
17
|
+
get(name: string): Tool<unknown, unknown> | undefined;
|
|
18
|
+
list(): Tool<unknown, unknown>[];
|
|
19
|
+
/** Which tools this scope permits, by name (for diagnostics/tests). */
|
|
20
|
+
names(): string[];
|
|
21
|
+
execute(name: string, rawInput: unknown, ctx: ToolContext): Promise<ToolResult<unknown>>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScopedRegistry — a `ToolRegistry` that narrows the surface to a resolved
|
|
3
|
+
* capability set.
|
|
4
|
+
*
|
|
5
|
+
* Both surfaces the runtime relies on (`toolDefinitions(deps.registry)`
|
|
6
|
+
* and `deps.registry.execute(...)`) go through `list()` / `execute()`, so
|
|
7
|
+
* wrapping the child's registry in this class enforces the tool allow-set
|
|
8
|
+
* end-to-end without touching the runtime loop: the model only sees the
|
|
9
|
+
* allowed tools, and any call to a disallowed tool returns `UNKNOWN_TOOL`.
|
|
10
|
+
*/
|
|
11
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
12
|
+
export class ScopedRegistry extends ToolRegistry {
|
|
13
|
+
parent;
|
|
14
|
+
allowed;
|
|
15
|
+
constructor(parent, allowed) {
|
|
16
|
+
super();
|
|
17
|
+
this.parent = parent;
|
|
18
|
+
this.allowed = allowed;
|
|
19
|
+
}
|
|
20
|
+
get(name) {
|
|
21
|
+
return this.allowed.has(name) ? this.parent.get(name) : undefined;
|
|
22
|
+
}
|
|
23
|
+
list() {
|
|
24
|
+
return this.parent.list().filter((t) => this.allowed.has(t.name));
|
|
25
|
+
}
|
|
26
|
+
/** Which tools this scope permits, by name (for diagnostics/tests). */
|
|
27
|
+
names() {
|
|
28
|
+
return [...this.allowed].sort();
|
|
29
|
+
}
|
|
30
|
+
async execute(name, rawInput, ctx) {
|
|
31
|
+
if (!this.allowed.has(name)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: {
|
|
35
|
+
code: 'UNKNOWN_TOOL',
|
|
36
|
+
message: `Tool not available in this agent context: ${name}`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return this.parent.execute(name, rawInput, ctx);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task manager — tracks the lifecycle of a (possibly nested) agent run.
|
|
3
|
+
*
|
|
4
|
+
* Each `spawn_agent` call creates a `TaskRecord` in a `TaskManager`.
|
|
5
|
+
* The manager owns an `AbortController` per task so that the parent (or
|
|
6
|
+
* the user) can cancel a running child without tearing down the entire
|
|
7
|
+
* process. It enforces a recursion / depth cap and emits `subtask.*`
|
|
8
|
+
* events so the event bus and trace writer can see the task tree.
|
|
9
|
+
*
|
|
10
|
+
* Exactly one task manager exists per session, wired into the orchestrator
|
|
11
|
+
* (src/agent/orchestrator.ts), which the runtime reaches via
|
|
12
|
+
* `parentContext`.
|
|
13
|
+
*/
|
|
14
|
+
import { type EventBus } from '../events/bus.js';
|
|
15
|
+
export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' | 'timed_out' | 'blocked';
|
|
16
|
+
export interface TaskError {
|
|
17
|
+
code: string;
|
|
18
|
+
message: string;
|
|
19
|
+
details?: unknown;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The full runtime record for one task. `summary` is a free-form progress
|
|
23
|
+
* log; `done` resolves to this very record once the task reaches a terminal
|
|
24
|
+
* status, so `await rec.done` yields the record with its final status.
|
|
25
|
+
*/
|
|
26
|
+
export interface TaskRecord {
|
|
27
|
+
id: string;
|
|
28
|
+
parentTaskId?: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
agentName: string;
|
|
31
|
+
status: TaskStatus;
|
|
32
|
+
cwd: string;
|
|
33
|
+
depth: number;
|
|
34
|
+
maxDepth: number;
|
|
35
|
+
model?: string;
|
|
36
|
+
startedAt: number;
|
|
37
|
+
finishedAt?: number;
|
|
38
|
+
summary: string[];
|
|
39
|
+
error?: TaskError;
|
|
40
|
+
abortController: AbortController;
|
|
41
|
+
done: Promise<TaskRecord>;
|
|
42
|
+
/** Internal — resolves `done`. Do not call outside TaskManager. */
|
|
43
|
+
_resolveDone: (r: TaskRecord) => void;
|
|
44
|
+
/** Internal cleanup handles, not part of the stable contract. */
|
|
45
|
+
_cleanupParentAbort?: () => void;
|
|
46
|
+
timeoutHandle?: NodeJS.Timeout;
|
|
47
|
+
}
|
|
48
|
+
/** Serialisable, stable subset of a task for logs, tools, and the trace. */
|
|
49
|
+
export interface TaskSummary {
|
|
50
|
+
id: string;
|
|
51
|
+
parentTaskId?: string;
|
|
52
|
+
agentName: string;
|
|
53
|
+
sessionId: string;
|
|
54
|
+
status: TaskStatus;
|
|
55
|
+
cwd: string;
|
|
56
|
+
depth: number;
|
|
57
|
+
model?: string;
|
|
58
|
+
durationMs?: number;
|
|
59
|
+
}
|
|
60
|
+
export interface CreateTaskOpts {
|
|
61
|
+
agentName: string;
|
|
62
|
+
cwd: string;
|
|
63
|
+
depth: number;
|
|
64
|
+
maxDepth: number;
|
|
65
|
+
parentTaskId?: string;
|
|
66
|
+
model?: string;
|
|
67
|
+
timeoutMs?: number;
|
|
68
|
+
/** Abort this task when the given signal fires. */
|
|
69
|
+
abortOnParent?: AbortSignal;
|
|
70
|
+
/** Defaults to true. When false, the task starts in 'queued'. */
|
|
71
|
+
autoStart?: boolean;
|
|
72
|
+
}
|
|
73
|
+
export declare class TaskManager {
|
|
74
|
+
private readonly tasks;
|
|
75
|
+
private readonly watchers;
|
|
76
|
+
private counter;
|
|
77
|
+
private readonly sessionId;
|
|
78
|
+
private readonly bus;
|
|
79
|
+
constructor(opts: {
|
|
80
|
+
sessionId: string;
|
|
81
|
+
bus?: EventBus;
|
|
82
|
+
});
|
|
83
|
+
/** Create a task. Blocks instead of running when the recursion cap is exceeded. */
|
|
84
|
+
create(opts: CreateTaskOpts): TaskRecord;
|
|
85
|
+
/** Look up a live record. */
|
|
86
|
+
get(id: string): TaskRecord | undefined;
|
|
87
|
+
/** List tasks, optionally filtered by parent and/or status. Returns summaries. */
|
|
88
|
+
list(filter?: {
|
|
89
|
+
parentTaskId?: string;
|
|
90
|
+
status?: TaskStatus;
|
|
91
|
+
}): TaskSummary[];
|
|
92
|
+
/** Transition a task to a terminal status and resolve its `done`. */
|
|
93
|
+
finish(id: string, status: 'succeeded' | 'failed' | 'cancelled' | 'timed_out' | 'blocked', patch?: {
|
|
94
|
+
summary?: string[];
|
|
95
|
+
error?: TaskError;
|
|
96
|
+
}): TaskRecord;
|
|
97
|
+
/** Cancel a live task: abort, mark cancelled/timed_out, resolve done. */
|
|
98
|
+
cancel(id: string, reason?: string, finalStatus?: 'cancelled' | 'timed_out'): TaskRecord;
|
|
99
|
+
/** Confirm a task that policy/depth prevented from running. */
|
|
100
|
+
block(id: string, reason: string): TaskRecord;
|
|
101
|
+
/** Cancel `id` and every transitive descendant. */
|
|
102
|
+
cancelTree(id: string, reason?: string): Promise<void>;
|
|
103
|
+
/** Subscribe to status transitions for one task. Delivers the current status immediately. */
|
|
104
|
+
subscribe(id: string, listener: (r: TaskRecord) => void): () => void;
|
|
105
|
+
/** Cancel every live task (used on shutdown / ctrl-c). */
|
|
106
|
+
shutdown(): Promise<void>;
|
|
107
|
+
/** Compact serialisable summary. */
|
|
108
|
+
toSummary(r: TaskRecord): TaskSummary;
|
|
109
|
+
/** Resolve a record's `done` (idempotent). */
|
|
110
|
+
private resolve;
|
|
111
|
+
private _resolveOnce;
|
|
112
|
+
/** Deliver a transition to the task's own watchers, then each ancestor's. */
|
|
113
|
+
private emit;
|
|
114
|
+
private notify;
|
|
115
|
+
}
|