@kinqs/brainrouter-cli 0.3.6 → 0.3.8

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 (129) hide show
  1. package/README.md +29 -52
  2. package/agents/architect.json +18 -0
  3. package/agents/explorer.json +18 -0
  4. package/agents/reviewer.json +18 -0
  5. package/agents/verifier.json +18 -0
  6. package/agents/worker.json +18 -0
  7. package/changelog/0.2.0.md +15 -0
  8. package/changelog/0.3.0.md +20 -0
  9. package/changelog/0.3.1.md +22 -0
  10. package/changelog/0.3.2.md +15 -0
  11. package/changelog/0.3.3.md +19 -0
  12. package/changelog/0.3.4.md +20 -0
  13. package/changelog/0.3.5.md +9 -0
  14. package/changelog/0.3.6.md +9 -0
  15. package/changelog/0.3.7.md +20 -0
  16. package/changelog/0.3.8.md +30 -0
  17. package/changelog/README.md +41 -0
  18. package/dist/agent/agent.d.ts +34 -1
  19. package/dist/agent/agent.js +372 -79
  20. package/dist/agent/toolCallRecovery.d.ts +57 -0
  21. package/dist/agent/toolCallRecovery.js +130 -0
  22. package/dist/agent/toolSafety.d.ts +17 -0
  23. package/dist/agent/toolSafety.js +102 -0
  24. package/dist/cli/banner.d.ts +20 -0
  25. package/dist/cli/banner.js +47 -14
  26. package/dist/cli/cliPrompt.d.ts +40 -3
  27. package/dist/cli/cliPrompt.js +117 -25
  28. package/dist/cli/commands/_context.d.ts +3 -1
  29. package/dist/cli/commands/_helpers.d.ts +1 -1
  30. package/dist/cli/commands/config.d.ts +46 -0
  31. package/dist/cli/commands/config.js +1042 -0
  32. package/dist/cli/commands/init.d.ts +20 -0
  33. package/dist/cli/commands/init.js +64 -0
  34. package/dist/cli/commands/login.d.ts +13 -0
  35. package/dist/cli/commands/login.js +179 -0
  36. package/dist/cli/commands/mcp.d.ts +13 -11
  37. package/dist/cli/commands/mcp.js +261 -74
  38. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  39. package/dist/cli/commands/mcpInstall.js +87 -0
  40. package/dist/cli/commands/orchestration.js +51 -0
  41. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  42. package/dist/cli/commands/releaseNotes.js +109 -0
  43. package/dist/cli/commands/schedule.d.ts +18 -0
  44. package/dist/cli/commands/schedule.js +189 -0
  45. package/dist/cli/commands/ui.js +119 -60
  46. package/dist/cli/commands/workflow.d.ts +2 -0
  47. package/dist/cli/commands/workflow.js +54 -8
  48. package/dist/cli/ink/ChatApp.d.ts +206 -0
  49. package/dist/cli/ink/ChatApp.js +493 -0
  50. package/dist/cli/ink/Frame.d.ts +26 -0
  51. package/dist/cli/ink/Frame.js +5 -0
  52. package/dist/cli/ink/Picker.d.ts +71 -0
  53. package/dist/cli/ink/Picker.js +168 -0
  54. package/dist/cli/ink/SlashPalette.d.ts +51 -0
  55. package/dist/cli/ink/SlashPalette.js +136 -0
  56. package/dist/cli/ink/TextField.d.ts +34 -0
  57. package/dist/cli/ink/TextField.js +47 -0
  58. package/dist/cli/ink/WizardApp.d.ts +7 -0
  59. package/dist/cli/ink/WizardApp.js +422 -0
  60. package/dist/cli/ink/ambientChat.d.ts +34 -0
  61. package/dist/cli/ink/ambientChat.js +7 -0
  62. package/dist/cli/ink/consoleCapture.d.ts +11 -0
  63. package/dist/cli/ink/consoleCapture.js +33 -0
  64. package/dist/cli/ink/markdownRender.d.ts +41 -0
  65. package/dist/cli/ink/markdownRender.js +278 -0
  66. package/dist/cli/ink/renderWithResizeClear.d.ts +14 -0
  67. package/dist/cli/ink/renderWithResizeClear.js +33 -0
  68. package/dist/cli/ink/runChat.d.ts +34 -0
  69. package/dist/cli/ink/runChat.js +682 -0
  70. package/dist/cli/ink/runPicker.d.ts +31 -0
  71. package/dist/cli/ink/runPicker.js +139 -0
  72. package/dist/cli/ink/runSlashPalette.d.ts +23 -0
  73. package/dist/cli/ink/runSlashPalette.js +33 -0
  74. package/dist/cli/ink/runWizard.d.ts +22 -0
  75. package/dist/cli/ink/runWizard.js +133 -0
  76. package/dist/cli/ink/stdinHandoff.d.ts +51 -0
  77. package/dist/cli/ink/stdinHandoff.js +78 -0
  78. package/dist/cli/ink/toolFormat.d.ts +75 -0
  79. package/dist/cli/ink/toolFormat.js +206 -0
  80. package/dist/cli/ink/useTerminalSize.d.ts +35 -0
  81. package/dist/cli/ink/useTerminalSize.js +26 -0
  82. package/dist/cli/repl.d.ts +25 -3
  83. package/dist/cli/repl.js +52 -714
  84. package/dist/cli/slashSuggest.d.ts +32 -0
  85. package/dist/cli/slashSuggest.js +146 -0
  86. package/dist/cli/wizard/modelsApi.d.ts +72 -0
  87. package/dist/cli/wizard/modelsApi.js +166 -0
  88. package/dist/cli/wizard/picker.d.ts +202 -0
  89. package/dist/cli/wizard/picker.js +547 -0
  90. package/dist/cli/wizard/providers.d.ts +86 -0
  91. package/dist/cli/wizard/providers.js +190 -0
  92. package/dist/cli/wizard/runner.d.ts +13 -0
  93. package/dist/cli/wizard/runner.js +488 -0
  94. package/dist/cli/wizard/types.d.ts +122 -0
  95. package/dist/cli/wizard/types.js +109 -0
  96. package/dist/config/config.d.ts +13 -1
  97. package/dist/config/config.js +45 -3
  98. package/dist/index.js +157 -206
  99. package/dist/memory/briefing.d.ts +1 -1
  100. package/dist/memory/briefing.js +4 -4
  101. package/dist/memory/consolidation.d.ts +1 -1
  102. package/dist/orchestration/agentRegistry.d.ts +36 -0
  103. package/dist/orchestration/agentRegistry.js +64 -0
  104. package/dist/orchestration/orchestrator.d.ts +7 -0
  105. package/dist/orchestration/orchestrator.js +2 -0
  106. package/dist/orchestration/tools.d.ts +105 -3
  107. package/dist/orchestration/tools.js +167 -8
  108. package/dist/prompt/skillCatalog.d.ts +11 -0
  109. package/dist/prompt/skillCatalog.js +134 -0
  110. package/dist/prompt/skillRunner.d.ts +2 -2
  111. package/dist/prompt/skillRunner.js +2 -31
  112. package/dist/prompt/systemPrompt.js +7 -2
  113. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  114. package/dist/runtime/anthropicAdapter.js +293 -0
  115. package/dist/runtime/cronParser.d.ts +23 -0
  116. package/dist/runtime/cronParser.js +122 -0
  117. package/dist/runtime/mcpClient.js +14 -11
  118. package/dist/runtime/mcpPool.d.ts +170 -0
  119. package/dist/runtime/mcpPool.js +442 -0
  120. package/dist/runtime/mcpUtils.d.ts +17 -1
  121. package/dist/runtime/mcpUtils.js +23 -0
  122. package/dist/runtime/scheduleTicker.d.ts +33 -0
  123. package/dist/runtime/scheduleTicker.js +99 -0
  124. package/dist/runtime/vendorSnippets.d.ts +45 -0
  125. package/dist/runtime/vendorSnippets.js +153 -0
  126. package/dist/state/scheduleStore.d.ts +37 -0
  127. package/dist/state/scheduleStore.js +64 -0
  128. package/package.json +14 -5
  129. package/.env.example +0 -116
@@ -8,7 +8,7 @@ import { askChoice, askYesNo, getActiveReadline, NoTTYError } from '../cli/cliPr
8
8
  import { appendTranscriptEntry } from '../state/sessionStore.js';
9
9
  import { buildSystemPrompt, loadWorkspaceInstructionSummary } from '../prompt/systemPrompt.js';
10
10
  import { formatPlan, readPlan, updatePlan } from '../state/taskStore.js';
11
- import { createSpawnAgentTool, createSpawnAgentsTool, createListAgentsTool, createWaitAgentTool, createWaitAgentsTool, createReadAgentTranscriptTool, createCloseAgentTool, createRouteAgentTool, executeOrchestrationTool, isOrchestrationToolName, } from '../orchestration/tools.js';
11
+ import { createTaskAgentTool, createDelegateAgentTool, createSpawnAgentTool, createSpawnAgentsTool, createListAgentsTool, createWaitAgentTool, createWaitAgentsTool, createReadAgentTranscriptTool, createCloseAgentTool, createRouteAgentTool, executeOrchestrationTool, isOrchestrationToolName, } from '../orchestration/tools.js';
12
12
  import { buildMemoryBriefing, selectCitedRecordIds } from '../memory/briefing.js';
13
13
  import { callMcpTool, extractToolText } from '../runtime/mcpUtils.js';
14
14
  import { acquireLLMSlot } from '../runtime/llmSemaphore.js';
@@ -17,12 +17,102 @@ import { runHooks } from '../state/hooksStore.js';
17
17
  import { resolveSandboxConfig, runShell } from '../runtime/sandbox.js';
18
18
  import { isDangerousCommand, resolveRunCommandApproval } from '../runtime/dangerousCommand.js';
19
19
  import { readPreferences, resolveEffort } from '../state/preferencesStore.js';
20
+ import { shouldUseAnthropicNative, callAnthropic } from '../runtime/anthropicAdapter.js';
20
21
  import { startSpan, traceEvent } from '../runtime/tracing.js';
21
22
  import { buildHookifyContext, evaluateHookify, listHookifyRules } from '../state/hookifyStore.js';
22
23
  import { renderCompactSystemMessage, runCompaction } from '../prompt/compactor.js';
23
24
  import { buildFanOutHint, shouldSuggestFanOut } from '../prompt/breadthHint.js';
25
+ import { isParallelSafe, parallelExecutionEnabled } from './toolSafety.js';
26
+ import { dedupeToolCalls, parseArgumentsOrError, synthesizeOrphanResults, suggestSimilarToolName, } from './toolCallRecovery.js';
24
27
  const execPromise = promisify(exec);
25
28
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.DS_Store', '.next']);
29
+ const DEFAULT_CHILD_DRAIN_TIMEOUT_MS = 30_000;
30
+ function parseJsonObject(text) {
31
+ try {
32
+ const parsed = JSON.parse(text);
33
+ return parsed && typeof parsed === 'object' ? parsed : undefined;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ function collectChildIds(value) {
40
+ if (!value || typeof value !== 'object')
41
+ return [];
42
+ const ids = [];
43
+ const maybeRecord = value;
44
+ if (typeof maybeRecord.id === 'string')
45
+ ids.push(maybeRecord.id);
46
+ if (Array.isArray(maybeRecord.agents)) {
47
+ for (const entry of maybeRecord.agents) {
48
+ if (entry && typeof entry === 'object' && typeof entry.id === 'string') {
49
+ ids.push(entry.id);
50
+ }
51
+ }
52
+ }
53
+ return [...new Set(ids)];
54
+ }
55
+ function trackChildObservation(toolName, args, resultText, spawned, waited) {
56
+ if (toolName === 'spawn_agent' ||
57
+ toolName === 'spawn_agents' ||
58
+ toolName === 'task_agent' ||
59
+ toolName === 'delegate_agent') {
60
+ const ids = collectChildIds(parseJsonObject(resultText));
61
+ for (const id of ids) {
62
+ spawned.add(id);
63
+ // task_agent always blocks internally (wraps spawn with wait: true);
64
+ // spawn_agent({ wait: true }) is the legacy form. Both count as
65
+ // already-observed, so the child-drain guardrail doesn't double-wait.
66
+ // delegate_agent is fire-and-forget — must remain unwaited so the
67
+ // guardrail can force a wait_agents call before the parent answers.
68
+ if (toolName === 'task_agent')
69
+ waited.add(id);
70
+ else if (toolName === 'spawn_agent' && args?.wait)
71
+ waited.add(id);
72
+ }
73
+ return;
74
+ }
75
+ if (toolName === 'wait_agent') {
76
+ const id = typeof args?.id === 'string' ? args.id : undefined;
77
+ if (id)
78
+ waited.add(id);
79
+ return;
80
+ }
81
+ if (toolName === 'wait_agents') {
82
+ const ids = Array.isArray(args?.ids) ? args.ids.filter((id) => typeof id === 'string') : [];
83
+ for (const id of ids)
84
+ waited.add(id);
85
+ }
86
+ }
87
+ function parseChildDrainTimeouts(resultText) {
88
+ const parsed = parseJsonObject(resultText);
89
+ const agents = Array.isArray(parsed?.agents) ? parsed.agents : [];
90
+ return agents
91
+ .filter((entry) => {
92
+ return !!entry && typeof entry === 'object' && entry.status === 'timeout';
93
+ })
94
+ .map((entry) => ({
95
+ id: typeof entry.id === 'string' ? entry.id : '(unknown)',
96
+ role: typeof entry.role === 'string' ? entry.role : undefined,
97
+ status: 'timeout',
98
+ childStatus: typeof entry.childStatus === 'string' ? entry.childStatus : undefined,
99
+ summary: typeof entry.summary === 'string' ? entry.summary : undefined,
100
+ }));
101
+ }
102
+ function formatChildDrainTimeoutAnswer(timeouts) {
103
+ const lines = [
104
+ `Children still running after the bounded wait (${timeouts.length}):`,
105
+ ...timeouts.map((child) => {
106
+ const role = child.role ? ` role=${child.role}` : '';
107
+ const status = child.childStatus ? ` status=${child.childStatus}` : '';
108
+ const summary = child.summary ? ` — ${child.summary}` : '';
109
+ return `- ${child.id}${role}${status}${summary}`;
110
+ }),
111
+ '',
112
+ 'Use `/continue` to drain the pending child output and synthesize the result when it is ready.',
113
+ ];
114
+ return lines.join('\n');
115
+ }
26
116
  export const LOCAL_TOOLS = [
27
117
  {
28
118
  name: 'read_file',
@@ -140,6 +230,8 @@ export const LOCAL_TOOLS = [
140
230
  required: ['patch']
141
231
  }
142
232
  },
233
+ createTaskAgentTool(),
234
+ createDelegateAgentTool(),
143
235
  createSpawnAgentTool(),
144
236
  createSpawnAgentsTool(),
145
237
  createListAgentsTool(),
@@ -414,6 +506,10 @@ export class Agent {
414
506
  agentId = `agent-${Math.random().toString(36).slice(2, 8)}`;
415
507
  /** agent_id of the parent (set by spawn_agent for children). */
416
508
  parentAgentId;
509
+ /** Agent tier — forwarded to OrchestrationContext so grandchildren can inherit hierarchy checks. */
510
+ tier;
511
+ /** Spawn-chain depth (0 = direct chat-root child). Forwarded to hierarchy checks. */
512
+ agentDepth;
417
513
  constructor(mcpClient, llmConfig, options) {
418
514
  this.mcpClient = mcpClient;
419
515
  this.llmConfig = llmConfig;
@@ -437,6 +533,8 @@ export class Agent {
437
533
  this.systemPromptOverride = options.systemPromptOverride;
438
534
  this.parentTraceId = options.parentTraceId;
439
535
  this.parentSpanId = options.parentSpanId;
536
+ this.tier = options.tier;
537
+ this.agentDepth = options.agentDepth ?? 0;
440
538
  }
441
539
  /** Expose for orchestration so spawn_agent can record the parent linkage. */
442
540
  getAgentId() {
@@ -446,13 +544,56 @@ export class Agent {
446
544
  setParentAgentId(id) {
447
545
  this.parentAgentId = id;
448
546
  }
547
+ isModelVisibleMcpTool(tool) {
548
+ const hiddenBrainrouterTools = new Set([
549
+ 'memory_capture_turn',
550
+ 'memory_mark_cited',
551
+ 'memory_resolve_session',
552
+ 'memory_register_skill_hints',
553
+ 'memory_hook_register',
554
+ 'memory_hook_status',
555
+ ]);
556
+ const name = String(tool?.name ?? '');
557
+ const rawName = String(tool?.__rawName ?? this.rawMcpToolName(name));
558
+ if (!hiddenBrainrouterTools.has(rawName))
559
+ return true;
560
+ const serverId = typeof tool?.__serverId === 'string'
561
+ ? tool.__serverId
562
+ : this.serverIdFromMcpToolName(name);
563
+ const status = serverId && typeof this.mcpClient.getStatus === 'function'
564
+ ? this.mcpClient.getStatus(serverId)
565
+ : undefined;
566
+ // Hide only BrainRouter auto-pipeline/admin tools. Third-party MCP tools
567
+ // with coincidentally similar names stay visible.
568
+ return status?.identity !== 'brainrouter';
569
+ }
570
+ rawMcpToolName(name) {
571
+ const serverId = this.serverIdFromMcpToolName(name);
572
+ return serverId ? name.slice(`mcp_${serverId}_`.length) : name;
573
+ }
574
+ serverIdFromMcpToolName(name) {
575
+ // Canonical single-underscore prefix: `mcp_<server>_<tool>`. The pool
576
+ // normalises to this shape at its boundary (0.3.8-R5).
577
+ if (!name.startsWith('mcp_'))
578
+ return undefined;
579
+ const rest = name.slice('mcp_'.length);
580
+ if (typeof this.mcpClient.getServerIds === 'function') {
581
+ const ids = this.mcpClient.getServerIds();
582
+ for (const id of ids.sort((a, b) => b.length - a.length)) {
583
+ if (rest.startsWith(`${id}_`))
584
+ return id;
585
+ }
586
+ }
587
+ const idx = rest.indexOf('_');
588
+ return idx >= 0 ? rest.slice(0, idx) : undefined;
589
+ }
449
590
  allowedToolsForAccess() {
450
591
  // Lifecycle / inspection tools are always available regardless of access
451
592
  // mode — they don't touch the workspace and the agent needs them to end
452
593
  // a goal cleanly (goal_complete / goal_blocked) or observe state.
453
594
  const readOnly = new Set([
454
595
  'read_file', 'list_dir', 'grep_search', 'glob_files', 'fetch_url', 'web_search', 'update_plan',
455
- 'spawn_agent', 'spawn_agents', 'list_agents', 'wait_agent', 'wait_agents',
596
+ 'task_agent', 'delegate_agent', 'spawn_agent', 'spawn_agents', 'list_agents', 'wait_agent', 'wait_agents',
456
597
  'read_agent_transcript', 'close_agent', 'route_agent',
457
598
  'goal_complete', 'goal_blocked',
458
599
  // ask_user_choice doesn't touch the workspace — it's an interaction
@@ -504,27 +645,20 @@ export class Agent {
504
645
  // whenever the inventory shape changed (online → offline or vice
505
646
  // versa) so the next LLM call sees the correct system message.
506
647
  const prevTools = this.lastKnownMcpTools?.map((t) => t.name).sort().join(',');
507
- this.lastKnownMcpTools = mcpTools.map((t) => ({ name: t.name }));
648
+ this.lastKnownMcpTools = mcpTools.map((t) => ({
649
+ name: String(t?.__rawName ?? this.rawMcpToolName(String(t?.name ?? ''))),
650
+ }));
508
651
  const newTools = this.lastKnownMcpTools.map((t) => t.name).sort().join(',');
509
652
  if (prevTools !== newTools && this.chatHistory.length > 0 && this.chatHistory[0].role === 'system') {
510
653
  this.chatHistory[0] = this.createSystemMessage();
511
654
  }
512
655
  const allowed = this.allowedToolsForAccess();
513
656
  const filteredLocalTools = LOCAL_TOOLS.filter(t => allowed.has(t.name));
514
- // Hide MCP tools we already call automatically. Small models otherwise
515
- // try to invoke them with the wrong arguments (most commonly
516
- // memory_capture_turn "Required, Required" comes from missing
517
- // sessionKey + messages). These tools are still callable; the CLI just
518
- // doesn't tell the LLM about them since the auto-pipeline owns them.
519
- const HIDDEN_FROM_LLM = new Set([
520
- 'memory_capture_turn', // called automatically post-turn
521
- 'memory_mark_cited', // called automatically with real citation IDs
522
- 'memory_resolve_session', // called automatically at bootstrap
523
- 'memory_register_skill_hints', // boot-time, not turn-level
524
- 'memory_hook_register', // managed via /hooks
525
- 'memory_hook_status',
526
- ]);
527
- const visibleMcpTools = mcpTools.filter((t) => !HIDDEN_FROM_LLM.has(t.name));
657
+ // Multi-MCP parity: expose every connected third-party MCP tool and the
658
+ // model-safe BrainRouter MCP tools in one turn, using the pool's
659
+ // `mcp_<serverId>_<tool>` namespaces. BrainRouter's auto-pipeline/admin
660
+ // tools stay hidden because the CLI owns those flows.
661
+ const visibleMcpTools = mcpTools.filter((t) => this.isModelVisibleMcpTool(t));
528
662
  const allTools = [...filteredLocalTools, ...visibleMcpTools];
529
663
  callbacks.onStatusUpdate(`Loaded ${filteredLocalTools.length} local tools and ${mcpTools.length} MCP tools.`);
530
664
  // Auto-compact: if the chat history has grown past the configured token
@@ -612,6 +746,34 @@ export class Agent {
612
746
  // signatures so we can interrupt the loop with corrective feedback.
613
747
  const recentToolSignatures = [];
614
748
  const REPEAT_GUARD_LIMIT = 3;
749
+ const spawnedChildIdsThisTurn = new Set();
750
+ const waitedChildIdsThisTurn = new Set();
751
+ const buildOrchestrationContext = () => ({
752
+ workspaceRoot: this.workspaceRoot,
753
+ parentSessionKey: this.sessionKey,
754
+ parentAccessMode: this.accessMode,
755
+ // Thread the parent's trace context so child agents nest their
756
+ // per-turn spans under THIS turn instead of starting a fresh
757
+ // trace tree. Lets observability backends reconstruct fan-out.
758
+ parentTraceId: turnSpan.traceId,
759
+ parentSpanId: turnSpan.spanId,
760
+ parentAgentId: this.agentId,
761
+ parentTier: this.tier,
762
+ depth: this.agentDepth,
763
+ mcpClient: this.mcpClient,
764
+ llmConfig: this.llmConfig,
765
+ launchCwd: this.launchCwd,
766
+ recordOffload: (chars) => { this.memoryMetrics.offloadCharsAvoided += chars; },
767
+ onChildToolStart: (event) => {
768
+ callbacks.onChildToolStart?.(event);
769
+ },
770
+ onChildToolEnd: (event) => {
771
+ callbacks.onChildToolEnd?.(event);
772
+ },
773
+ onChildComplete: (event) => {
774
+ callbacks.onChildComplete?.(event);
775
+ },
776
+ });
615
777
  while (loopCount < maxLoops) {
616
778
  loopCount++;
617
779
  callbacks.onStatusUpdate(`Thinking (turn ${loopCount})...`);
@@ -621,7 +783,15 @@ export class Agent {
621
783
  // (which only refreshes the system prompt) also updates the next
622
784
  // request's reasoning_effort slot — no restart needed.
623
785
  const effort = resolveEffort(this.workspaceRoot).effort;
624
- response = await callOpenAI(this.llmConfig, this.chatHistory, allTools, { effort });
786
+ if (shouldUseAnthropicNative(this.llmConfig)) {
787
+ response = await callAnthropic(this.llmConfig, this.chatHistory, allTools, {
788
+ effort,
789
+ onThinking: (text) => callbacks.onStatusUpdate(`Thinking: ${text.slice(0, 200)}`),
790
+ });
791
+ }
792
+ else {
793
+ response = await callOpenAI(this.llmConfig, this.chatHistory, allTools, { effort });
794
+ }
625
795
  }
626
796
  catch (err) {
627
797
  throw new Error(`LLM Execution failed: ${err.message}`);
@@ -631,6 +801,21 @@ export class Agent {
631
801
  this.lastTurnUsage.completionTokens += response.usage.completion_tokens ?? 0;
632
802
  this.lastTurnUsage.calls += 1;
633
803
  }
804
+ // 0.3.8-I4: Strict tool-call recovery. Real-world LLMs (especially
805
+ // smaller / quantised) sometimes emit duplicate tool_call ids in a
806
+ // single response. If we let both through, OpenAI's next request 400s
807
+ // because one of the duplicates has no paired tool_result. Dedupe
808
+ // before pushing the assistant message — last occurrence wins (closest
809
+ // to the model's final intent).
810
+ // Adapted from deer-flow/backend/packages/harness/deerflow/agents/
811
+ // middlewares/dangling_tool_call_middleware.py — same well-formed
812
+ // history invariant, applied per-response instead of pre-request.
813
+ if (response.toolCalls && response.toolCalls.length > 0) {
814
+ const deduped = dedupeToolCalls(response.toolCalls, (id) => {
815
+ callbacks.onStatusUpdate(`Recovery: dropped duplicate tool_call id "${id}" (last occurrence wins).`);
816
+ });
817
+ response.toolCalls = deduped;
818
+ }
634
819
  // Record Assistant message
635
820
  const assistantMsg = { role: 'assistant', content: response.content };
636
821
  if (response.toolCalls) {
@@ -639,36 +824,76 @@ export class Agent {
639
824
  this.chatHistory.push(assistantMsg);
640
825
  this.recordTranscript(assistantMsg);
641
826
  if (!response.toolCalls || response.toolCalls.length === 0) {
827
+ const unobservedChildIds = [...spawnedChildIdsThisTurn].filter((id) => !waitedChildIdsThisTurn.has(id));
828
+ if (unobservedChildIds.length > 0) {
829
+ const drainTimeoutMs = Math.max(1, Number(process.env.BRAINROUTER_CHILD_DRAIN_TIMEOUT_MS) || DEFAULT_CHILD_DRAIN_TIMEOUT_MS);
830
+ const waitName = 'wait_agents';
831
+ const waitArgs = { ids: unobservedChildIds, timeoutMs: drainTimeoutMs };
832
+ callbacks.onStatusUpdate(`Auto-draining ${unobservedChildIds.length} spawned child agent${unobservedChildIds.length === 1 ? '' : 's'}...`);
833
+ callbacks.onToolStart(waitName, waitArgs);
834
+ this.lastTurnToolCalls += 1;
835
+ let waitResultText = '';
836
+ let waitFailed = false;
837
+ let waitSummary = '';
838
+ try {
839
+ waitResultText = await executeOrchestrationTool(waitName, waitArgs, buildOrchestrationContext());
840
+ waitSummary = getToolSummary(waitName, waitArgs, waitResultText);
841
+ trackChildObservation(waitName, waitArgs, waitResultText, spawnedChildIdsThisTurn, waitedChildIdsThisTurn);
842
+ }
843
+ catch (err) {
844
+ // Wait tool failure: surface the error text to the model so it can
845
+ // report failure rather than silently synthesizing stale output.
846
+ waitFailed = true;
847
+ waitResultText = `Tool execution failed: ${err?.message ?? String(err)}`;
848
+ waitSummary = err?.message ?? String(err);
849
+ }
850
+ callbacks.onToolEnd(waitName, { success: !waitFailed, summary: waitSummary, preview: !waitFailed ? getToolPreview(waitName, waitArgs, waitResultText) : undefined });
851
+ const timeouts = parseChildDrainTimeouts(waitResultText);
852
+ if (timeouts.length > 0) {
853
+ finalAnswer = formatChildDrainTimeoutAnswer(timeouts);
854
+ exitedCleanly = true;
855
+ break;
856
+ }
857
+ const correction = [
858
+ `Runtime child-drain guardrail auto-called \`${waitName}\` because this turn spawned child agents and the model tried to answer without observing them.`,
859
+ `Child wait result:\n${waitResultText}`,
860
+ 'Now synthesize the child output for the user. Do not say you are waiting unless the wait result timed out.',
861
+ ].join('\n\n');
862
+ const guardMsg = { role: 'user', content: correction };
863
+ this.chatHistory.push(guardMsg);
864
+ this.recordTranscript(guardMsg);
865
+ continue;
866
+ }
642
867
  finalAnswer = response.content;
643
868
  exitedCleanly = true;
644
869
  break;
645
870
  }
646
- // Execute tool calls chosen by the LLM
647
- for (const tc of response.toolCalls) {
871
+ // Execute tool calls chosen by the LLM.
872
+ //
873
+ // 0.3.8-R4 — Independent read-only tool calls (read_file, list_dir,
874
+ // grep_search, glob_files, fetch_url, web_search, MCP memory reads)
875
+ // are dispatched concurrently when emitted in the same assistant
876
+ // response; consecutive serial tools (writes, shell, orchestration,
877
+ // unknown names) execute one-by-one in their original position to
878
+ // preserve causality. Tool-result messages are still appended to
879
+ // chatHistory in the ORIGINAL call order so the model's next turn
880
+ // sees a deterministic trace even if a later read settled first.
881
+ const candidates = [
882
+ ...LOCAL_TOOLS.map((lt) => lt.name),
883
+ ...mcpTools.map((t) => t.name).filter((n) => typeof n === 'string'),
884
+ ];
885
+ const toolCalls = response.toolCalls ?? [];
886
+ const normalizedNames = toolCalls.map((tc) => normalizeToolName(tc.function.name, candidates));
887
+ const parallelEnabled = parallelExecutionEnabled();
888
+ const safeFlags = toolCalls.map((_tc, idx) => parallelEnabled && isParallelSafe(normalizedNames[idx]));
889
+ const processOneToolCall = async (tc, name) => {
648
890
  this.lastTurnToolCalls += 1;
649
- // Normalize the tool name against both local and MCP candidates so
650
- // common LLM hallucinations like `Read_File` / `read-file` resolve
651
- // to `read_file` instead of falling through to `-32601 Unknown tool`.
652
- const rawName = tc.function.name;
653
- const candidates = [
654
- ...LOCAL_TOOLS.map((lt) => lt.name),
655
- ...mcpTools.map((t) => t.name).filter((n) => typeof n === 'string'),
656
- ];
657
- const name = normalizeToolName(rawName, candidates);
658
- // Parse JSON args. If the LLM produced malformed JSON, surface that
659
- // explicitly via the tool result so it can self-correct on the next
660
- // turn — the old fallback silently set args={} and the LLM had no
661
- // signal that anything was wrong.
662
- let args = {};
663
- let argParseError;
664
- try {
665
- args = typeof tc.function.arguments === 'string'
666
- ? JSON.parse(tc.function.arguments)
667
- : tc.function.arguments;
668
- }
669
- catch (e) {
670
- argParseError = `Tool argument JSON was malformed: ${e.message}. Re-issue the tool call with valid JSON arguments.`;
671
- }
891
+ // 0.3.8-I4: Use the strict-recovery helper so a malformed-arguments
892
+ // tool_call surfaces as a structured tool_result (with the raw
893
+ // arguments echoed back) instead of throwing out of the loop.
894
+ const parsedArgs = parseArgumentsOrError(tc);
895
+ let args = parsedArgs.args;
896
+ const argParseError = parsedArgs.error;
672
897
  const isLocal = LOCAL_TOOLS.some(lt => lt.name === name);
673
898
  callbacks.onToolStart(name, args);
674
899
  let resultText = '';
@@ -683,9 +908,7 @@ export class Agent {
683
908
  callbacks.onToolEnd(name, { success: false, summary });
684
909
  traceEvent('brainrouter.tool', { tool: name, ok: false, local: isLocal, session_key: this.sessionKey, guard: 'bad_args' }, { traceId: turnSpan.traceId, parentSpanId: turnSpan.spanId });
685
910
  const toolMsg = { role: 'tool', tool_call_id: tc.id, name, content: resultText, isError };
686
- this.chatHistory.push(toolMsg);
687
- this.recordTranscript(toolMsg);
688
- continue;
911
+ return { toolMsg, fullResultText: resultText };
689
912
  }
690
913
  // Repeat-loop guard: if the model has already issued this exact
691
914
  // (name, args) call REPEAT_GUARD_LIMIT times in this turn, short-
@@ -708,9 +931,7 @@ export class Agent {
708
931
  callbacks.onToolEnd(name, { success: false, summary });
709
932
  traceEvent('brainrouter.tool', { tool: name, ok: false, local: isLocal, session_key: this.sessionKey, guard: 'repeat' }, { traceId: turnSpan.traceId, parentSpanId: turnSpan.spanId });
710
933
  const toolMsg = { role: 'tool', tool_call_id: tc.id, name, content: resultText, isError };
711
- this.chatHistory.push(toolMsg);
712
- this.recordTranscript(toolMsg);
713
- continue;
934
+ return { toolMsg, fullResultText: resultText };
714
935
  }
715
936
  recentToolSignatures.push(signature);
716
937
  // Keep the window small so the guard only blocks tight loops, not
@@ -748,30 +969,9 @@ export class Agent {
748
969
  throw new Error(`Tool "${name}" is not permitted in access mode "${this.accessMode}".`);
749
970
  }
750
971
  if (isOrchestrationToolName(name)) {
751
- resultText = await executeOrchestrationTool(name, args, {
752
- workspaceRoot: this.workspaceRoot,
753
- parentSessionKey: this.sessionKey,
754
- parentAccessMode: this.accessMode,
755
- // Thread the parent's trace context so child agents nest their
756
- // per-turn spans under THIS turn instead of starting a fresh
757
- // trace tree. Lets observability backends reconstruct fan-out.
758
- parentTraceId: turnSpan.traceId,
759
- parentSpanId: turnSpan.spanId,
760
- parentAgentId: this.agentId,
761
- mcpClient: this.mcpClient,
762
- llmConfig: this.llmConfig,
763
- launchCwd: this.launchCwd,
764
- recordOffload: (chars) => { this.memoryMetrics.offloadCharsAvoided += chars; },
765
- onChildToolEvent: (event) => {
766
- // Surface to the REPL via the same onToolStart channel so the
767
- // user sees child activity live, prefixed with the child id.
768
- callbacks.onToolStart(`${event.role}:${event.childId} → ${event.tool}`, { ok: event.ok, summary: event.summary });
769
- },
770
- onChildComplete: (event) => {
771
- callbacks.onChildComplete?.(event);
772
- },
773
- });
972
+ resultText = await executeOrchestrationTool(name, args, buildOrchestrationContext());
774
973
  summary = getToolSummary(name, args, resultText);
974
+ trackChildObservation(name, args, resultText, spawnedChildIdsThisTurn, waitedChildIdsThisTurn);
775
975
  }
776
976
  else if (isLocal) {
777
977
  resultText = await this.executeLocalTool(name, args);
@@ -801,8 +1001,14 @@ export class Agent {
801
1001
  // the next iteration self-corrects instead of retrying garbage.
802
1002
  if (/-32601|Unknown tool|MethodNotFound/i.test(message)) {
803
1003
  const hint = explainUnknownToolName(name);
804
- resultText = `Tool "${name}" does not exist. ${hint}\nUnderlying error: ${message}`;
805
- summary = `unknown tool — ${hint.slice(0, 120)}`;
1004
+ // 0.3.8-I4: surface a "did you mean: X?" suggestion when the
1005
+ // LLM-emitted name normalises to a real registered tool (case,
1006
+ // separator, or alias mismatch). This is cheaper for the model
1007
+ // to recover from than the generic skill-vs-tool explanation.
1008
+ const didYouMean = suggestSimilarToolName(name, candidates, normalizeToolName);
1009
+ const suggestionLine = didYouMean ? `did you mean: ${didYouMean}?\n` : '';
1010
+ resultText = `Tool "${name}" does not exist. ${suggestionLine}${hint}\nUnderlying error: ${message}`;
1011
+ summary = didYouMean ? `unknown tool — did you mean ${didYouMean}?` : `unknown tool — ${hint.slice(0, 120)}`;
806
1012
  }
807
1013
  else {
808
1014
  resultText = `Tool execution failed: ${message}`;
@@ -846,10 +1052,89 @@ export class Agent {
846
1052
  content: clampedContent,
847
1053
  isError
848
1054
  };
849
- this.chatHistory.push(toolMsg);
1055
+ // Return; the caller pushes to chatHistory in original call order
1056
+ // (NOT settle order) and records the FULL untruncated result for
1057
+ // /transcript. Doing the push here would let parallel batches land
1058
+ // in finish order, which the LLM's next turn would see as a
1059
+ // non-deterministic trace.
1060
+ return { toolMsg, fullResultText: resultText };
1061
+ };
1062
+ // Partition the tool_calls into runs of consecutive parallel-safe
1063
+ // calls separated by single serial calls. Each run preserves original
1064
+ // position; safe runs of size ≥ 2 dispatch with Promise.allSettled,
1065
+ // serial runs (and unknown-tool fallbacks) execute one-by-one. The
1066
+ // result array is indexed by original call position so the
1067
+ // chatHistory push at the end is deterministic.
1068
+ const processed = new Array(toolCalls.length);
1069
+ const runSafeBatch = async (startIdx, endIdx) => {
1070
+ // [startIdx, endIdx) — at least 1 entry; size > 1 means concurrent.
1071
+ // Calling `processOneToolCall` synchronously schedules every batch
1072
+ // member's onToolStart + repeat-guard prep BEFORE any await yields,
1073
+ // so the user sees N "in flight" tool rows immediately. Promise.
1074
+ // allSettled then waits for all to settle; any rejection is
1075
+ // translated into a "Tool execution failed" envelope so the LLM's
1076
+ // next turn still sees a tool_result for every original tool_call_id.
1077
+ const slice = toolCalls.slice(startIdx, endIdx);
1078
+ const promises = slice.map((tc, j) => processOneToolCall(tc, normalizedNames[startIdx + j]));
1079
+ const settled = await Promise.allSettled(promises);
1080
+ for (let k = 0; k < settled.length; k++) {
1081
+ const s = settled[k];
1082
+ if (s.status === 'fulfilled') {
1083
+ processed[startIdx + k] = s.value;
1084
+ }
1085
+ else {
1086
+ const tc = slice[k];
1087
+ const name = normalizedNames[startIdx + k];
1088
+ const message = s.reason?.message ?? String(s.reason);
1089
+ const resultText = `Tool execution failed: ${message}`;
1090
+ processed[startIdx + k] = {
1091
+ toolMsg: { role: 'tool', tool_call_id: tc.id, name, content: resultText, isError: true },
1092
+ fullResultText: resultText,
1093
+ };
1094
+ }
1095
+ }
1096
+ };
1097
+ let i = 0;
1098
+ while (i < toolCalls.length) {
1099
+ if (safeFlags[i]) {
1100
+ let j = i + 1;
1101
+ while (j < toolCalls.length && safeFlags[j])
1102
+ j++;
1103
+ await runSafeBatch(i, j);
1104
+ i = j;
1105
+ }
1106
+ else {
1107
+ // Serial slot — run in isolation so any state mutation (write,
1108
+ // spawn_agent, update_plan) completes before the next call starts.
1109
+ processed[i] = await processOneToolCall(toolCalls[i], normalizedNames[i]);
1110
+ i++;
1111
+ }
1112
+ }
1113
+ for (const entry of processed) {
1114
+ if (!entry)
1115
+ continue;
1116
+ this.chatHistory.push(entry.toolMsg);
850
1117
  // Record the FULL untruncated result so /transcript shows everything,
851
1118
  // even when the LLM-facing copy was clamped.
852
- this.recordTranscript({ ...toolMsg, content: resultText });
1119
+ this.recordTranscript({ ...entry.toolMsg, content: entry.fullResultText });
1120
+ }
1121
+ // 0.3.8-I4: orphan safety net. Even after dedupe + the per-call
1122
+ // recovery branches above, a tool_call without a paired tool_result
1123
+ // would 400 the next OpenAI request. Synthesize ERROR envelopes for
1124
+ // any unmatched id so strict tool_call ↔ tool_result pairing is
1125
+ // preserved. Synthetic content is a plain `ERROR: …` string so the
1126
+ // R1 child-drain guardrail's parseJsonObject(resultText) returns
1127
+ // undefined and we don't accidentally claim a child was spawned.
1128
+ // Synthetics do NOT bump lastTurnToolCalls — they aren't real
1129
+ // dispatches, just a well-formed-history fix.
1130
+ // Adapted from deer-flow/backend/packages/harness/deerflow/agents/
1131
+ // middlewares/dangling_tool_call_middleware.py.
1132
+ const producedResults = processed.filter((p) => !!p).map((p) => p.toolMsg);
1133
+ const orphans = synthesizeOrphanResults(toolCalls, producedResults);
1134
+ for (const synthetic of orphans) {
1135
+ this.chatHistory.push(synthetic);
1136
+ this.recordTranscript(synthetic);
1137
+ callbacks.onStatusUpdate(`Recovery: synthesized placeholder for orphan tool_call ${synthetic.tool_call_id}.`);
853
1138
  }
854
1139
  }
855
1140
  // Normalize the final answer FIRST so every exit path (loop limit, empty
@@ -1104,7 +1389,7 @@ export class Agent {
1104
1389
  try {
1105
1390
  const res = await fetch(url, {
1106
1391
  headers: {
1107
- 'User-Agent': 'Mozilla/5.0 (compatible; BrainRouterCLI/0.3.5)'
1392
+ 'User-Agent': 'Mozilla/5.0 (compatible; BrainRouterCLI/0.3.8)'
1108
1393
  }
1109
1394
  });
1110
1395
  if (!res.ok) {
@@ -1712,7 +1997,7 @@ async function runWebSearch(query, maxResults) {
1712
1997
  }
1713
1998
  try {
1714
1999
  const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
1715
- const res = await fetch(url, { headers: { 'User-Agent': 'BrainRouterCLI/0.3.5' } });
2000
+ const res = await fetch(url, { headers: { 'User-Agent': 'BrainRouterCLI/0.3.8' } });
1716
2001
  if (!res.ok) {
1717
2002
  return `web_search failed: DuckDuckGo returned ${res.status} ${res.statusText}.`;
1718
2003
  }
@@ -2267,7 +2552,15 @@ export function buildChatCompletionPayload(config, messages, tools, options = {}
2267
2552
  return body;
2268
2553
  }
2269
2554
  export async function callOpenAI(config, messages, tools, options = {}) {
2270
- const endpoint = config.endpoint || 'https://api.openai.com/v1';
2555
+ // Normalize the endpoint to a base URL (everything UP TO `/chat/completions`
2556
+ // exclusive). Earlier callers stored the full chat-completions URL in
2557
+ // `config.endpoint` (e.g. "https://api.openai.com/v1/chat/completions")
2558
+ // because the in-terminal wizard's provider catalog wrote the full path.
2559
+ // We then re-append `/chat/completions` below, producing a duplicate
2560
+ // `/chat/completions/chat/completions` and a 404. Strip the suffix
2561
+ // defensively so both shapes (full URL or base URL) work.
2562
+ const rawEndpoint = config.endpoint || 'https://api.openai.com/v1';
2563
+ const endpoint = rawEndpoint.replace(/\/+$/, '').replace(/\/chat\/completions$/, '');
2271
2564
  let apiKey = config.apiKey || process.env.OPENAI_API_KEY || '';
2272
2565
  const isLocal = endpoint.includes('localhost') || endpoint.includes('127.0.0.1');
2273
2566
  if (!apiKey && !isLocal) {