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.
Files changed (62) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +30 -9
  2. package/dist/agent/anthropic-adapter.js +107 -49
  3. package/dist/agent/capabilities.d.ts +122 -0
  4. package/dist/agent/capabilities.js +150 -0
  5. package/dist/agent/orchestrator.d.ts +131 -0
  6. package/dist/agent/orchestrator.js +269 -0
  7. package/dist/agent/provider-adapter.d.ts +9 -0
  8. package/dist/agent/provider-adapter.js +85 -39
  9. package/dist/agent/registry.d.ts +1 -0
  10. package/dist/agent/registry.js +1 -0
  11. package/dist/agent/retry.d.ts +12 -1
  12. package/dist/agent/retry.js +19 -1
  13. package/dist/agent/runtime.d.ts +23 -1
  14. package/dist/agent/runtime.js +236 -26
  15. package/dist/agent/scoped-registry.d.ts +22 -0
  16. package/dist/agent/scoped-registry.js +42 -0
  17. package/dist/agent/task-manager.d.ts +115 -0
  18. package/dist/agent/task-manager.js +250 -0
  19. package/dist/agent/worker-spawner.d.ts +17 -12
  20. package/dist/agent/worker-spawner.js +26 -20
  21. package/dist/cli/config.d.ts +21 -0
  22. package/dist/cli/config.js +31 -0
  23. package/dist/cli/dotenv.d.ts +3 -0
  24. package/dist/cli/dotenv.js +57 -0
  25. package/dist/cli/repl.js +72 -4
  26. package/dist/cli/run.d.ts +3 -0
  27. package/dist/cli/run.js +127 -7
  28. package/dist/context/klyro-md.d.ts +6 -0
  29. package/dist/context/klyro-md.js +21 -15
  30. package/dist/context/trust.d.ts +42 -0
  31. package/dist/context/trust.js +111 -0
  32. package/dist/events/catalog.d.ts +71 -0
  33. package/dist/index.js +4 -0
  34. package/dist/mcp/client.d.ts +53 -0
  35. package/dist/mcp/client.js +225 -0
  36. package/dist/mcp/config.d.ts +30 -0
  37. package/dist/mcp/config.js +82 -0
  38. package/dist/mcp/policy.d.ts +13 -0
  39. package/dist/mcp/policy.js +12 -0
  40. package/dist/mcp/registry.d.ts +50 -0
  41. package/dist/mcp/registry.js +172 -0
  42. package/dist/mcp/schema.d.ts +11 -0
  43. package/dist/mcp/schema.js +46 -0
  44. package/dist/persistence/store.d.ts +1 -1
  45. package/dist/policy/approval.d.ts +27 -6
  46. package/dist/policy/approval.js +36 -8
  47. package/dist/policy/engine.d.ts +13 -0
  48. package/dist/policy/engine.js +31 -2
  49. package/dist/policy/patterns.d.ts +16 -0
  50. package/dist/policy/patterns.js +26 -0
  51. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  52. package/dist/tools/agent/spawn-agent.js +50 -0
  53. package/dist/tools/agent/task-get.d.ts +8 -0
  54. package/dist/tools/agent/task-get.js +40 -0
  55. package/dist/tools/agent/task-list.d.ts +4 -0
  56. package/dist/tools/agent/task-list.js +41 -0
  57. package/dist/tools/plan/todo-write.d.ts +1 -1
  58. package/dist/tools/registry.js +6 -0
  59. package/dist/tools/types.d.ts +12 -0
  60. package/dist/tui/approval.d.ts +1 -1
  61. package/dist/tui/approval.js +2 -2
  62. package/package.json +2 -2
@@ -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 {
@@ -139,6 +159,7 @@ export type RuntimeEvent = {
139
159
  kind: 'usage';
140
160
  input: number;
141
161
  output: number;
162
+ estimated?: boolean;
142
163
  } | {
143
164
  kind: 'final_text';
144
165
  text: string;
@@ -171,7 +192,7 @@ export type RuntimeEvent = {
171
192
  sessionId: string;
172
193
  };
173
194
  export interface RunResult {
174
- status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked';
195
+ status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked' | 'stuck';
175
196
  steps: number;
176
197
  toolCalls: number;
177
198
  finalText: string;
@@ -181,6 +202,7 @@ export interface RunResult {
181
202
  usage: {
182
203
  input: number;
183
204
  output: number;
205
+ estimated?: boolean;
184
206
  };
185
207
  /** Number of policy-driven user prompts the user accepted. */
186
208
  repairs?: number;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { text, toolUse, toolResult as mkToolResult } from './message.js';
18
18
  import { redact } from '../policy/secret-redactor.js';
19
+ import { patternForCall } from '../policy/patterns.js';
19
20
  import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
20
21
  import * as path from 'node:path';
21
22
  import { verify, diagnosticForModel } from '../verification/engine.js';
@@ -173,6 +174,8 @@ export async function run(opts, deps) {
173
174
  // 5.2 — stuck detection state
174
175
  const callHistory = [];
175
176
  const fileEditCounts = new Map();
177
+ let stuckTriggers = 0;
178
+ let stuckAbort = false;
176
179
  outer: while (steps < maxSteps) {
177
180
  // 5.1 limits: max-cost, max-time
178
181
  if (maxCost !== undefined) {
@@ -226,7 +229,7 @@ export async function run(opts, deps) {
226
229
  emitKlyro({ type: 'context.compacted', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', dropped: c.dropped });
227
230
  }
228
231
  const req = {
229
- model: opts.model,
232
+ model: opts.parentContext?.model ?? opts.model,
230
233
  system: reqSystem,
231
234
  messages: reqMessages,
232
235
  tools: toolDefinitions(deps.registry),
@@ -273,6 +276,17 @@ export async function run(opts, deps) {
273
276
  telemetry.recordUsage(ev.usage.input, ev.usage.output);
274
277
  emit?.({ kind: 'usage', input: usage.input, output: usage.output });
275
278
  }
279
+ else {
280
+ // Providers that omit usage (Ollama, vLLM, proxies): estimate from
281
+ // the actual request + generated output so cost accounting never
282
+ // silently records zero. Marked estimated for the UI/debugging.
283
+ const est = estimateTurnUsage(reqSystem, reqMessages, textBuf, pendingToolCalls);
284
+ usage.input += est.input;
285
+ usage.output += est.output;
286
+ usage.estimated = true;
287
+ telemetry.recordUsage(est.input, est.output);
288
+ emit?.({ kind: 'usage', input: usage.input, output: usage.output, estimated: true });
289
+ }
276
290
  }
277
291
  else if (ev.kind === 'error') {
278
292
  telemetry.recordError(`stream_error: ${ev.code}`);
@@ -296,22 +310,47 @@ export async function run(opts, deps) {
296
310
  };
297
311
  }
298
312
  }
299
- // Build the assistant message.
313
+ // Build the assistant message. Tool calls are finalized here: JSON is
314
+ // parsed and schema-validated BEFORE policy/execution. Malformed calls
315
+ // become structured MALFORMED_TOOL_CALL results — garbage arguments must
316
+ // never reach a real tool.
300
317
  const assistantContent = [];
301
318
  if (textBuf)
302
319
  assistantContent.push(text(textBuf));
303
320
  const finalizedCalls = [];
321
+ let hadInvalidTool = false;
304
322
  for (const tc of pendingToolCalls.values()) {
305
- let input = {};
306
- try {
307
- input = JSON.parse(tc.argsJson || '{}');
308
- }
309
- catch {
310
- input = { _parse_error: true, raw: tc.argsJson };
323
+ const validated = validateFinalizedCall(deps.registry, tc.id, tc.name, tc.argsJson);
324
+ if (!validated.ok) {
325
+ hadInvalidTool = true;
326
+ // Record the model's tool_use (empty input — safe to replay to any
327
+ // provider) and answer it immediately with a structured error.
328
+ assistantContent.push(toolUse(tc.id, tc.name, {}));
329
+ emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: {} });
330
+ const errMsg = {
331
+ role: 'tool',
332
+ content: [mkToolResult(tc.id, tc.name, validated.output, true)],
333
+ };
334
+ transcript.push(errMsg);
335
+ await checkpoint(errMsg, {
336
+ toolCallId: tc.id, toolName: tc.name,
337
+ input: { raw: redact(tc.argsJson).slice(0, 500) }, output: validated.output, isError: true,
338
+ });
339
+ let attempted = {};
340
+ try {
341
+ attempted = JSON.parse(tc.argsJson);
342
+ }
343
+ catch {
344
+ attempted = {};
345
+ }
346
+ telemetry.recordToolError(toolUse(tc.id, tc.name, attempted), validated.code);
347
+ emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
348
+ emit?.({ kind: 'tool_result', id: tc.id, name: tc.name, output: validated.output, isError: true, latencyMs: 0 });
349
+ continue;
311
350
  }
312
- finalizedCalls.push(toolUse(tc.id, tc.name, input));
313
- assistantContent.push(toolUse(tc.id, tc.name, input));
314
- emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input });
351
+ finalizedCalls.push(toolUse(tc.id, tc.name, validated.input));
352
+ assistantContent.push(toolUse(tc.id, tc.name, validated.input));
353
+ emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input: validated.input });
315
354
  }
316
355
  const assistantMsg = { role: 'assistant', content: assistantContent };
317
356
  transcript.push(assistantMsg);
@@ -332,6 +371,14 @@ export async function run(opts, deps) {
332
371
  return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
333
372
  }
334
373
  if (finalizedCalls.length === 0) {
374
+ if (hadInvalidTool) {
375
+ // The model attempted a tool call that failed validation; the error is
376
+ // already a tool_result in the transcript — loop so the model can
377
+ // repair next turn instead of treating an answerable error as a
378
+ // completion. Bounded by maxSteps and stuck detection (P0-3).
379
+ emit?.({ kind: 'step_end', step: steps });
380
+ continue;
381
+ }
335
382
  finalText = textBuf;
336
383
  // Level 8 — Verification + Autonomous Repair (gated on hasEdits below — pure analysis skips verify)
337
384
  const verifyEnabled = opts.verify?.enabled !== false;
@@ -526,10 +573,21 @@ export async function run(opts, deps) {
526
573
  signal: opts.signal,
527
574
  nonInteractive: opts.nonInteractive,
528
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 } : {}),
529
584
  };
530
585
  const allSafe = finalizedCalls.length > 1 && finalizedCalls.every((c) => deps.registry.get(c.name)?.isConcurrencySafe !== false);
531
- const runOne = async (call) => {
532
- // Use per-call handling without continue (runOne is not a loop)
586
+ // Gate phase: policy decision + approval prompt for one call. Runs
587
+ // sequentially (approval UI is one-modal-at-a-time). Commits deny/user-deny
588
+ // results immediately — gate runs in call order so these stay ordered.
589
+ // Returns true when the call is approved for execution.
590
+ const gateCall = async (call) => {
533
591
  const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
534
592
  emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
535
593
  // Mirror to KlyroEvent bus
@@ -546,7 +604,7 @@ export async function run(opts, deps) {
546
604
  emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
547
605
  telemetry.recordToolError(call, 'policy_denied');
548
606
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
549
- return;
607
+ return false;
550
608
  }
551
609
  if (decision.action === 'ask') {
552
610
  emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
@@ -554,6 +612,8 @@ export async function run(opts, deps) {
554
612
  toolName: call.name,
555
613
  reason: decision.reason,
556
614
  summary: summarizeToolCall(call),
615
+ input: call.input,
616
+ pattern: patternForCall(call.name, call.input),
557
617
  });
558
618
  // Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
559
619
  if (choice === 'deny') {
@@ -567,15 +627,49 @@ export async function run(opts, deps) {
567
627
  await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
568
628
  telemetry.recordToolError(call, 'user_denied');
569
629
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
570
- return;
630
+ return false;
571
631
  }
572
632
  // Handle 'edit' choice: for now treat as allow with edited input (future: re-prompt)
573
633
  repairs++;
574
634
  }
635
+ return true;
636
+ };
637
+ // Execute phase: run the tool with no transcript writes, so concurrent
638
+ // executions can't interleave. A throw here becomes a tool error (an
639
+ // executor crash must never kill the step).
640
+ const execTool = async (call) => {
575
641
  const t0 = Date.now();
576
642
  emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
577
- const obs = await deps.registry.execute(call.name, call.input, toolCtx);
643
+ let obs;
644
+ try {
645
+ obs = await deps.registry.execute(call.name, call.input, toolCtx);
646
+ }
647
+ catch (err) {
648
+ obs = { ok: false, error: { code: 'EXEC_CRASH', message: err instanceof Error ? err.message : String(err) } };
649
+ }
578
650
  const latencyMs = Date.now() - t0;
651
+ return { obs, latencyMs };
652
+ };
653
+ // 5.2 stuck termination (P0-3): the FIRST detection injects one
654
+ // "change approach" synthetic message for the next model turn; the SECOND
655
+ // detection aborts the automated loop with a `stuck` status so a repeated
656
+ // tool loop never burns unbounded tokens. Covers both signals: identical
657
+ // call ×3 and same-file edited >8×.
658
+ const markStuck = async (msg) => {
659
+ stuckTriggers++;
660
+ emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: msg });
661
+ if (stuckTriggers === 1) {
662
+ const note = { role: 'user', content: [text(`[system note] Stuck detected: ${msg}. Stop repeating the same action; change approach.`)] };
663
+ transcript.push(note);
664
+ await checkpoint(note);
665
+ }
666
+ else {
667
+ stuckAbort = true;
668
+ }
669
+ };
670
+ // Commit phase: fold one execution result into the transcript, in original
671
+ // call order. The only writer — call sequentially, never concurrently.
672
+ const commitResult = async (call, obs, latencyMs) => {
579
673
  const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
580
674
  const toolMsg = {
581
675
  role: 'tool',
@@ -620,7 +714,7 @@ export async function run(opts, deps) {
620
714
  const cnt = (fileEditCounts.get(fileChanged.path) ?? 0) + 1;
621
715
  fileEditCounts.set(fileChanged.path, cnt);
622
716
  if (cnt > 8) {
623
- emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: `same file edited >8×: ${fileChanged.path}` });
717
+ await markStuck(`same file edited >8×: ${fileChanged.path}`);
624
718
  }
625
719
  }
626
720
  }
@@ -631,23 +725,42 @@ export async function run(opts, deps) {
631
725
  callHistory.shift();
632
726
  const last3 = callHistory.slice(-3);
633
727
  if (last3.length === 3 && last3[0] === last3[1] && last3[1] === last3[2]) {
634
- emitKlyro({ type: 'error', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', code: 'stuck', message: `identical call ×3: ${sig}` });
635
- // Inject system note for next turn
636
- const note = { role: 'user', content: [text(`[system note] Stuck detected: identical call ×3: ${sig}. Try a different approach.`)] };
637
- transcript.push(note);
638
- await checkpoint(note);
728
+ await markStuck(`identical call ×3: ${sig}`);
639
729
  }
640
730
  };
641
- // 3.5 — parallel if all concurrencySafe, sequential otherwise
642
- // BUG-002: preserve call order — run sequentially even when allSafe to avoid out-of-order transcript
643
- // Parallel execution previously pushed tool_results out of order via Promise.all
731
+ // Sequential path: gate → execute → commit per call, in order.
732
+ const runOne = async (call) => {
733
+ if (!(await gateCall(call)))
734
+ return;
735
+ const { obs, latencyMs } = await execTool(call);
736
+ await commitResult(call, obs, latencyMs);
737
+ };
738
+ // 3.5 — parallel when every call is concurrencySafe, sequential otherwise.
739
+ // Gate runs sequentially in both paths (approval UI is one-at-a-time).
740
+ // Parallel path executes concurrently but commits in original call order,
741
+ // so the transcript reads exactly as if the calls ran in order (this is
742
+ // what the old BUG-002 sequential fallback was protecting).
644
743
  if (allSafe) {
744
+ const approved = [];
645
745
  for (const call of finalizedCalls) {
646
746
  toolCallCount++;
647
- await runOne(call);
747
+ if (await gateCall(call))
748
+ approved.push(call);
648
749
  if (opts.signal?.aborted)
649
750
  break;
650
751
  }
752
+ if (approved.length > 0 && !opts.signal?.aborted) {
753
+ const settled = await Promise.allSettled(approved.map((c) => execTool(c)));
754
+ for (let i = 0; i < approved.length; i++) {
755
+ const s = settled[i];
756
+ if (s.status === 'fulfilled') {
757
+ await commitResult(approved[i], s.value.obs, s.value.latencyMs);
758
+ }
759
+ else {
760
+ await commitResult(approved[i], { ok: false, error: { code: 'EXEC_CRASH', message: String(s.reason) } }, 0);
761
+ }
762
+ }
763
+ }
651
764
  }
652
765
  else {
653
766
  for (const call of finalizedCalls) {
@@ -666,6 +779,9 @@ export async function run(opts, deps) {
666
779
  }
667
780
  catch { /* ignore */ }
668
781
  }
782
+ // 5.2 — terminate the automated loop once stuck recurs after the note
783
+ if (stuckAbort)
784
+ break;
669
785
  }
670
786
  if (opts.signal?.aborted) {
671
787
  emit?.({ kind: 'aborted' });
@@ -678,6 +794,18 @@ export async function run(opts, deps) {
678
794
  await closeTracer();
679
795
  return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
680
796
  }
797
+ // 5.2 — stuck termination (P0-3): bounded stop instead of unbounded token burn.
798
+ if (stuckAbort) {
799
+ emit?.({ kind: 'final_text', text: finalText });
800
+ if (store && sessionId) {
801
+ try {
802
+ await store.setStatus(sessionId, 'stuck', finalText);
803
+ }
804
+ catch { /* ignore */ }
805
+ }
806
+ await closeTracer();
807
+ return { status: 'stuck', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
808
+ }
681
809
  emit?.({ kind: 'final_text', text: finalText });
682
810
  if (store && sessionId) {
683
811
  try {
@@ -688,6 +816,88 @@ export async function run(opts, deps) {
688
816
  await closeTracer();
689
817
  return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, hasEdits, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
690
818
  }
819
+ /**
820
+ * Estimate usage for a turn whose provider omitted it (Ollama, vLLM,
821
+ * proxies). Input is measured from the actual request via the tokenizer;
822
+ * output is a chars/4 heuristic over generated text + tool arguments.
823
+ */
824
+ function estimateTurnUsage(system, messages, textOut, toolCalls) {
825
+ let argsChars = 0;
826
+ for (const tc of toolCalls.values())
827
+ argsChars += tc.argsJson.length;
828
+ return {
829
+ input: totalTokens(system, messages),
830
+ output: Math.max(1, Math.ceil((textOut.length + argsChars) / 4)),
831
+ };
832
+ }
833
+ /**
834
+ * Validate one assembled tool call before it reaches policy or execution.
835
+ * Returns the parsed+schema-validated input, or a structured error output
836
+ * (MALFORMED_TOOL_CALL / UNKNOWN_TOOL) that the runtime records as a tool
837
+ * result without executing anything.
838
+ */
839
+ function validateFinalizedCall(registry, id, name, argsJson) {
840
+ void id;
841
+ let parsed = {};
842
+ if (argsJson.trim()) {
843
+ try {
844
+ parsed = JSON.parse(argsJson);
845
+ }
846
+ catch {
847
+ return {
848
+ ok: false,
849
+ code: 'MALFORMED_TOOL_CALL',
850
+ output: {
851
+ code: 'MALFORMED_TOOL_CALL',
852
+ tool: name,
853
+ message: 'Tool arguments were incomplete or invalid JSON. Re-issue the call with complete arguments.',
854
+ retryable: true,
855
+ },
856
+ };
857
+ }
858
+ }
859
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
860
+ return {
861
+ ok: false,
862
+ code: 'MALFORMED_TOOL_CALL',
863
+ output: {
864
+ code: 'MALFORMED_TOOL_CALL',
865
+ tool: name,
866
+ message: 'Tool arguments must be a JSON object. Re-issue the call with complete arguments.',
867
+ retryable: true,
868
+ },
869
+ };
870
+ }
871
+ const tool = registry.get(name);
872
+ if (!tool) {
873
+ return {
874
+ ok: false,
875
+ code: 'UNKNOWN_TOOL',
876
+ output: {
877
+ code: 'MALFORMED_TOOL_CALL',
878
+ tool: name,
879
+ message: `Unknown tool "${name}". Use one of the available tools.`,
880
+ retryable: true,
881
+ },
882
+ };
883
+ }
884
+ const checked = tool.inputSchema.safeParse(parsed);
885
+ if (!checked.success) {
886
+ const first = checked.error.issues[0];
887
+ const detail = first ? ` (${first.path.join('.') || 'input'}: ${first.message})` : '';
888
+ return {
889
+ ok: false,
890
+ code: 'MALFORMED_TOOL_CALL',
891
+ output: {
892
+ code: 'MALFORMED_TOOL_CALL',
893
+ tool: name,
894
+ message: `Tool arguments failed validation${detail}. Re-issue the call with correct arguments.`,
895
+ retryable: true,
896
+ },
897
+ };
898
+ }
899
+ return { ok: true, input: parsed };
900
+ }
691
901
  function redactOutput(v) {
692
902
  if (typeof v === 'string')
693
903
  return redact(v);
@@ -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
+ }