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.
Files changed (51) hide show
  1. package/dist/agent/anthropic-adapter.js +6 -1
  2. package/dist/agent/capabilities.d.ts +122 -0
  3. package/dist/agent/capabilities.js +150 -0
  4. package/dist/agent/orchestrator.d.ts +131 -0
  5. package/dist/agent/orchestrator.js +269 -0
  6. package/dist/agent/provider-adapter.d.ts +9 -0
  7. package/dist/agent/provider-adapter.js +24 -1
  8. package/dist/agent/registry.d.ts +1 -0
  9. package/dist/agent/registry.js +1 -0
  10. package/dist/agent/retry.d.ts +12 -1
  11. package/dist/agent/retry.js +19 -1
  12. package/dist/agent/runtime.d.ts +20 -0
  13. package/dist/agent/runtime.js +9 -1
  14. package/dist/agent/scoped-registry.d.ts +22 -0
  15. package/dist/agent/scoped-registry.js +42 -0
  16. package/dist/agent/task-manager.d.ts +115 -0
  17. package/dist/agent/task-manager.js +250 -0
  18. package/dist/agent/worker-spawner.d.ts +17 -12
  19. package/dist/agent/worker-spawner.js +26 -20
  20. package/dist/cli/dotenv.d.ts +3 -0
  21. package/dist/cli/dotenv.js +57 -0
  22. package/dist/cli/repl.js +41 -2
  23. package/dist/cli/run.d.ts +3 -0
  24. package/dist/cli/run.js +118 -7
  25. package/dist/context/klyro-md.d.ts +6 -0
  26. package/dist/context/klyro-md.js +21 -15
  27. package/dist/context/trust.d.ts +42 -0
  28. package/dist/context/trust.js +111 -0
  29. package/dist/events/catalog.d.ts +71 -0
  30. package/dist/index.js +4 -0
  31. package/dist/mcp/client.d.ts +53 -0
  32. package/dist/mcp/client.js +225 -0
  33. package/dist/mcp/config.d.ts +30 -0
  34. package/dist/mcp/config.js +82 -0
  35. package/dist/mcp/policy.d.ts +13 -0
  36. package/dist/mcp/policy.js +12 -0
  37. package/dist/mcp/registry.d.ts +50 -0
  38. package/dist/mcp/registry.js +172 -0
  39. package/dist/mcp/schema.d.ts +11 -0
  40. package/dist/mcp/schema.js +46 -0
  41. package/dist/policy/engine.js +3 -2
  42. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  43. package/dist/tools/agent/spawn-agent.js +50 -0
  44. package/dist/tools/agent/task-get.d.ts +8 -0
  45. package/dist/tools/agent/task-get.js +40 -0
  46. package/dist/tools/agent/task-list.d.ts +4 -0
  47. package/dist/tools/agent/task-list.js +41 -0
  48. package/dist/tools/plan/todo-write.d.ts +1 -1
  49. package/dist/tools/registry.js +6 -0
  50. package/dist/tools/types.d.ts +12 -0
  51. 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: res.status >= 500 || res.status === 429,
211
+ retryable,
212
+ status: String(res.status),
213
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
191
214
  };
192
215
  return;
193
216
  }
@@ -39,4 +39,5 @@ export declare function buildProviderFromCli(args: {
39
39
  baseUrl?: string;
40
40
  apiKey?: string;
41
41
  timeoutMs?: number;
42
+ retry?: Partial<RetryOptions> | false;
42
43
  }): ProviderAdapter;
@@ -190,5 +190,6 @@ export function buildProviderFromCli(args) {
190
190
  baseURL: args.baseUrl,
191
191
  apiKey: args.apiKey,
192
192
  timeoutMs: args.timeoutMs,
193
+ ...(args.retry !== undefined ? { retry: args.retry } : {}),
193
194
  });
194
195
  }
@@ -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).
@@ -152,7 +152,25 @@ export function retryingAdapter(inner, opts = {}) {
152
152
  yield lastError;
153
153
  return;
154
154
  }
155
- const delay = computeBackoff(attempt, cfg.baseMs, cfg.maxMs);
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)
@@ -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 {
@@ -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
+ }