@kinqs/brainrouter-cli 0.3.7 → 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 (59) hide show
  1. package/changelog/0.2.0.md +15 -0
  2. package/changelog/0.3.0.md +20 -0
  3. package/changelog/0.3.1.md +22 -0
  4. package/changelog/0.3.2.md +15 -0
  5. package/changelog/0.3.3.md +19 -0
  6. package/changelog/0.3.4.md +20 -0
  7. package/changelog/0.3.5.md +9 -0
  8. package/changelog/0.3.6.md +9 -0
  9. package/changelog/0.3.7.md +20 -0
  10. package/changelog/0.3.8.md +30 -0
  11. package/changelog/README.md +41 -0
  12. package/dist/agent/agent.d.ts +22 -0
  13. package/dist/agent/agent.js +259 -82
  14. package/dist/agent/toolCallRecovery.d.ts +57 -0
  15. package/dist/agent/toolCallRecovery.js +130 -0
  16. package/dist/agent/toolSafety.d.ts +17 -0
  17. package/dist/agent/toolSafety.js +102 -0
  18. package/dist/cli/banner.js +2 -2
  19. package/dist/cli/cliPrompt.js +65 -0
  20. package/dist/cli/commands/config.js +1 -1
  21. package/dist/cli/commands/mcp.d.ts +1 -1
  22. package/dist/cli/commands/mcp.js +29 -7
  23. package/dist/cli/commands/mcpInstall.d.ts +20 -0
  24. package/dist/cli/commands/mcpInstall.js +87 -0
  25. package/dist/cli/commands/orchestration.js +33 -0
  26. package/dist/cli/commands/releaseNotes.d.ts +24 -0
  27. package/dist/cli/commands/releaseNotes.js +109 -0
  28. package/dist/cli/commands/schedule.d.ts +18 -0
  29. package/dist/cli/commands/schedule.js +189 -0
  30. package/dist/cli/commands/ui.js +2 -2
  31. package/dist/cli/ink/Picker.d.ts +6 -0
  32. package/dist/cli/ink/Picker.js +41 -6
  33. package/dist/cli/ink/runChat.js +112 -1
  34. package/dist/cli/ink/toolFormat.d.ts +11 -9
  35. package/dist/cli/ink/toolFormat.js +42 -16
  36. package/dist/cli/repl.d.ts +1 -1
  37. package/dist/cli/repl.js +9 -2
  38. package/dist/config/config.d.ts +1 -1
  39. package/dist/index.js +10 -1
  40. package/dist/memory/briefing.js +4 -4
  41. package/dist/orchestration/tools.d.ts +95 -2
  42. package/dist/orchestration/tools.js +119 -4
  43. package/dist/prompt/systemPrompt.js +5 -4
  44. package/dist/runtime/anthropicAdapter.d.ts +100 -0
  45. package/dist/runtime/anthropicAdapter.js +293 -0
  46. package/dist/runtime/cronParser.d.ts +23 -0
  47. package/dist/runtime/cronParser.js +122 -0
  48. package/dist/runtime/mcpClient.js +1 -1
  49. package/dist/runtime/mcpPool.d.ts +8 -0
  50. package/dist/runtime/mcpPool.js +19 -0
  51. package/dist/runtime/mcpUtils.d.ts +14 -0
  52. package/dist/runtime/mcpUtils.js +23 -0
  53. package/dist/runtime/scheduleTicker.d.ts +33 -0
  54. package/dist/runtime/scheduleTicker.js +99 -0
  55. package/dist/runtime/vendorSnippets.d.ts +45 -0
  56. package/dist/runtime/vendorSnippets.js +153 -0
  57. package/dist/state/scheduleStore.d.ts +37 -0
  58. package/dist/state/scheduleStore.js +64 -0
  59. package/package.json +7 -4
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * — one-line, identity-revealing, no JSON. These helpers do the same
13
13
  * mapping for our built-in LOCAL_TOOLS (cli/../agent/agent.ts) + MCP
14
- * tool names (which carry an `mcp__<server>__` namespace prefix that
14
+ * tool names (which carry an `mcp_<server>_` namespace prefix that
15
15
  * the user doesn't care about).
16
16
  *
17
17
  * Reference for the convention: claude-code transcripts (see
@@ -28,10 +28,12 @@
28
28
  * → "Bash(npm test)"
29
29
  * formatToolCall('grep_search', { query: 'authenticate', path: '.' })
30
30
  * → 'Grep("authenticate")'
31
- * formatToolCall('mcp__brainrouter__memory_search', { q: 'auth' })
31
+ * formatToolCall('mcp_brainrouter_memory_search', { q: 'auth' })
32
32
  * → 'MemorySearch("auth")'
33
33
  * formatToolCall('spawn_agent', { role: 'researcher', prompt: '...' })
34
34
  * → 'Spawn(researcher, "...")'
35
+ * formatToolCall('task_agent', { role: 'reviewer', prompt: '...' })
36
+ * → 'Task(reviewer, "...")'
35
37
  */
36
38
  export function formatToolCall(name, args) {
37
39
  const safeArgs = args ?? {};
@@ -69,6 +71,18 @@ export function formatToolCall(name, args) {
69
71
  const task = truncateOneLine(safeArgs.prompt ?? '', 50);
70
72
  return `Spawn(${role}${label}, "${task}")`;
71
73
  }
74
+ case 'task_agent': {
75
+ const role = String(safeArgs.role ?? safeArgs.agentId ?? 'agent');
76
+ const label = safeArgs.label ? ` [${safeArgs.label}]` : '';
77
+ const task = truncateOneLine(safeArgs.prompt ?? '', 50);
78
+ return `Task(${role}${label}, "${task}")`;
79
+ }
80
+ case 'delegate_agent': {
81
+ const role = String(safeArgs.role ?? safeArgs.agentId ?? 'agent');
82
+ const label = safeArgs.label ? ` [${safeArgs.label}]` : '';
83
+ const task = truncateOneLine(safeArgs.prompt ?? '', 50);
84
+ return `Delegate(${role}${label}, "${task}")`;
85
+ }
72
86
  case 'spawn_agents': {
73
87
  const agents = Array.isArray(safeArgs.agents) ? safeArgs.agents : [];
74
88
  const roles = agents.map((a) => String(a?.role ?? 'agent')).join(', ');
@@ -89,23 +103,35 @@ export function formatToolCall(name, args) {
89
103
  // No args, or no string args — just show the name.
90
104
  return `${pretty}()`;
91
105
  }
106
+ // Server ids registered at boot. MCP server names may contain underscores
107
+ // (`my_server`), so a naive `^mcp_[^_]+_(.+)$` regex would mis-strip
108
+ // `mcp_my_server_memory_search` to `server_memory_search`. Callers that
109
+ // know the live server inventory register it here so `stripMcpPrefix` can
110
+ // match the longest known id first.
111
+ let knownMcpServerIds = [];
112
+ export function setKnownMcpServerIds(ids) {
113
+ knownMcpServerIds = [...ids].sort((a, b) => b.length - a.length);
114
+ }
92
115
  /**
93
- * Strip the `mcp__<server>__` or `mcp_<server>_` namespace prefix from MCP tool
94
- * names. Server ids may contain underscores (e.g. `my_server`), so the
95
- * double-underscore form uses a lazy match. Both prefix conventions are in use
96
- * across the multi-MCP codepaths until naming is unified.
97
- * `mcp__brainrouter__memory_search` → `memory_search`
98
- * `mcp__my_server__memory_search` → `memory_search`
99
- * `mcp_brainrouter_memory_search` → `memory_search`
116
+ * Strip the `mcp_<server>_` namespace prefix from MCP tool names. As of
117
+ * 0.3.8-R5 the pool normalises to single-underscore at the boundary, so
118
+ * downstream call-sites only ever see this shape.
119
+ * `mcp_brainrouter_memory_search` `memory_search`
120
+ * `mcp_my_server_memory_search` → `memory_search` (when `my_server`
121
+ * is registered via `setKnownMcpServerIds`)
100
122
  */
101
123
  export function stripMcpPrefix(name) {
102
- const dbl = name.match(/^mcp__.+?__(.+)$/);
103
- if (dbl)
104
- return dbl[1];
105
- const sgl = name.match(/^mcp_[^_]+_(.+)$/);
106
- if (sgl)
107
- return sgl[1];
108
- return name;
124
+ if (!name.startsWith('mcp_'))
125
+ return name;
126
+ const rest = name.slice('mcp_'.length);
127
+ for (const id of knownMcpServerIds) {
128
+ if (rest.startsWith(`${id}_`))
129
+ return rest.slice(id.length + 1);
130
+ }
131
+ // Fallback when no server ids are registered (test fixtures, early boot):
132
+ // assume the server id has no underscores.
133
+ const idx = rest.indexOf('_');
134
+ return idx >= 0 ? rest.slice(idx + 1) : name;
109
135
  }
110
136
  /**
111
137
  * Convert snake_case to PascalCase for readable display names.
@@ -10,7 +10,7 @@ import type { ReplContext } from './commands/_context.js';
10
10
  * The Ink chat REPL (cli/ink/runChat.tsx) consumes this same list for its
11
11
  * inline slash palette so both surfaces stay in lockstep as new commands land.
12
12
  */
13
- export declare const SLASH_COMMANDS: readonly ["/help", "/status", "/workspace", "/where", "/tools", "/skills", "/plan", "/transcript", "/doctor", "/config", "/diff", "/commit", "/clear", "/compact", "/exit", "/quit", "/roles", "/agents", "/agent", "/spawn", "/wait", "/spec", "/feature-dev", "/grill-me", "/review", "/implement-plan", "/skill", "/workflow", "/workflows", "/approve", "/memory", "/recall", "/briefing", "/scenes", "/working", "/forget", "/init", "/login", "/sessions", "/resume", "/model", "/mcp", "/goal", "/copy", "/fork", "/rename", "/permissions", "/hooks", "/hookify", "/loop", "/continue", "/auto-review", "/vim", "/statusline", "/quiet", "/handover", "/explain", "/trace", "/failed", "/verify", "/audit", "/export", "/import", "/persona", "/skill-hints", "/diagnostics", "/tokens", "/watch", "/yolo", "/mode", "/review-policy", "/sandbox", "/kill", "/theme", "/title", "/personality", "/effort", "/new", "/side", "/btw", "/raw", "/feedback", "/rollout", "/ps", "/stop", "/logout", "/apps", "/plugins", "/experimental", "/memories", "/debug-config", "/mention", "/keymap", "/ide"];
13
+ export declare const SLASH_COMMANDS: readonly ["/help", "/status", "/workspace", "/where", "/tools", "/skills", "/plan", "/transcript", "/doctor", "/config", "/diff", "/commit", "/clear", "/compact", "/exit", "/quit", "/roles", "/agents", "/agent", "/spawn", "/wait", "/spec", "/feature-dev", "/grill-me", "/review", "/implement-plan", "/skill", "/workflow", "/workflows", "/approve", "/memory", "/recall", "/briefing", "/scenes", "/working", "/forget", "/init", "/login", "/sessions", "/resume", "/model", "/mcp", "/goal", "/copy", "/fork", "/rename", "/permissions", "/hooks", "/hookify", "/loop", "/schedule", "/continue", "/auto-review", "/vim", "/statusline", "/quiet", "/release-notes", "/handover", "/explain", "/trace", "/failed", "/verify", "/audit", "/export", "/import", "/persona", "/skill-hints", "/diagnostics", "/tokens", "/watch", "/yolo", "/mode", "/review-policy", "/sandbox", "/kill", "/theme", "/title", "/personality", "/effort", "/new", "/side", "/btw", "/raw", "/feedback", "/rollout", "/ps", "/stop", "/logout", "/apps", "/plugins", "/experimental", "/memories", "/debug-config", "/mention", "/keymap", "/ide"];
14
14
  export declare function renderHelp(category?: string): void;
15
15
  /**
16
16
  * Look up a one-line description for a slash command by walking the
package/dist/cli/repl.js CHANGED
@@ -15,6 +15,8 @@ import { tryHandleMcpCommand } from './commands/mcp.js';
15
15
  import { tryHandleInitCommand } from './commands/init.js';
16
16
  import { tryHandleConfigCommand } from './commands/config.js';
17
17
  import { tryHandleLoginCommand } from './commands/login.js';
18
+ import { tryHandleScheduleCommand } from './commands/schedule.js';
19
+ import { tryHandleReleaseNotesCommand } from './commands/releaseNotes.js';
18
20
  /**
19
21
  * All slash commands the REPL recognizes. Used for tab autocomplete and for
20
22
  * the readline completer. Keep alphabetically grouped roughly by surface area.
@@ -29,8 +31,8 @@ export const SLASH_COMMANDS = [
29
31
  '/spec', '/feature-dev', '/grill-me', '/review', '/implement-plan', '/skill', '/workflow', '/workflows', '/approve',
30
32
  '/memory', '/recall', '/briefing', '/scenes', '/working', '/forget',
31
33
  '/init', '/login', '/sessions', '/resume', '/model', '/mcp',
32
- '/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop',
33
- '/continue', '/auto-review', '/vim', '/statusline', '/quiet',
34
+ '/goal', '/copy', '/fork', '/rename', '/permissions', '/hooks', '/hookify', '/loop', '/schedule',
35
+ '/continue', '/auto-review', '/vim', '/statusline', '/quiet', '/release-notes',
34
36
  '/handover', '/explain', '/trace', '/failed', '/verify', '/audit',
35
37
  '/export', '/import', '/persona', '/skill-hints', '/diagnostics',
36
38
  '/tokens', '/watch', '/yolo', '/mode', '/review-policy', '/sandbox', '/kill',
@@ -173,6 +175,7 @@ const HELP_CATEGORIES = [
173
175
  { cmd: '/apps /plugins', desc: 'List workspace skills and plugin folders' },
174
176
  { cmd: '/feedback [message]', desc: 'Append feedback entry' },
175
177
  { cmd: '/experimental [on|off]', desc: 'Toggle experimental features' },
178
+ { cmd: '/release-notes [version|list]', desc: 'Show changelog for current (or specified) CLI version' },
176
179
  ],
177
180
  },
178
181
  ];
@@ -263,6 +266,10 @@ export async function handleSlashCommand(command, args, agent, mcpClient, config
263
266
  return;
264
267
  if (await tryHandleWorkflowCommand(cmdCtx))
265
268
  return;
269
+ if (await tryHandleScheduleCommand(cmdCtx))
270
+ return;
271
+ if (await tryHandleReleaseNotesCommand(cmdCtx))
272
+ return;
266
273
  if (await tryHandleObsCommand(cmdCtx))
267
274
  return;
268
275
  if (await tryHandleOrchestrationCommand(cmdCtx))
@@ -26,7 +26,7 @@ export interface ServerConfig {
26
26
  identity?: 'brainrouter' | 'third-party';
27
27
  }
28
28
  export interface LLMConfig {
29
- provider: 'openai';
29
+ provider: 'openai' | 'anthropic';
30
30
  apiKey: string;
31
31
  model: string;
32
32
  endpoint?: string;
package/dist/index.js CHANGED
@@ -84,6 +84,7 @@ import chalk from 'chalk';
84
84
  import { loadConfig, loadOrInitConfig, saveConfig, getConfigPath } from './config/config.js';
85
85
  import { McpClientWrapper } from './runtime/mcpClient.js';
86
86
  import { McpClientPool, selectMcpServerIds } from './runtime/mcpPool.js';
87
+ import { setKnownMcpServerIds } from './cli/ink/toolFormat.js';
87
88
  import { Agent } from './agent/agent.js';
88
89
  import { runChat } from './cli/ink/runChat.js';
89
90
  import { applyWorkspaceRoot, findWorkspaceRoot } from './config/workspace.js';
@@ -100,7 +101,7 @@ const program = new Command();
100
101
  program
101
102
  .name('brainrouter')
102
103
  .description('BrainRouter CLI — Premium interactive terminal-based agent client.')
103
- .version('0.3.7');
104
+ .version('0.3.8');
104
105
  // Chat Command (default)
105
106
  program
106
107
  .command('chat', { isDefault: true })
@@ -197,6 +198,10 @@ program
197
198
  // comment); the banner's per-server row is the success signal.
198
199
  const mcpClient = new McpClientPool();
199
200
  const statuses = await mcpClient.connectAll(targetServers, llm, { timeoutMs: 5_000 });
201
+ // Register live server ids for Ink tool-name display so multi-word
202
+ // server names (e.g. `my_server`) don't get mis-stripped by the
203
+ // single-underscore prefix regex.
204
+ setKnownMcpServerIds(mcpClient.getServerIds());
200
205
  const failures = statuses.filter((s) => s.status === 'failed');
201
206
  if (failures.length === statuses.length) {
202
207
  // Every server failed — equivalent to the pre-0.3.7 "MCP
@@ -302,6 +307,10 @@ program
302
307
  llm.model = options.model;
303
308
  const mcpClient = new McpClientPool();
304
309
  const statuses = await mcpClient.connectAll(targetServers, llm, { timeoutMs: 5_000 });
310
+ // Register live server ids for Ink tool-name display so multi-word
311
+ // server names (e.g. `my_server`) don't get mis-stripped by the
312
+ // single-underscore prefix regex.
313
+ setKnownMcpServerIds(mcpClient.getServerIds());
305
314
  const allFailed = statuses.length > 0 && statuses.every((s) => s.status === 'failed');
306
315
  if (allFailed) {
307
316
  const summary = statuses.map((s) => `${s.serverId}: ${s.error ?? 'unknown'}`).join('; ');
@@ -1,5 +1,5 @@
1
1
  import { redactText } from '../state/sessionStore.js';
2
- import { callMcpTool } from '../runtime/mcpUtils.js';
2
+ import { callMcpTool, hasMcpTool } from '../runtime/mcpUtils.js';
3
3
  /**
4
4
  * Run pre-turn memory queries in parallel and assemble a compact briefing block.
5
5
  * This is the System-1 entry point: every turn pays a small fixed cost to ask
@@ -11,13 +11,13 @@ export async function buildMemoryBriefing(inputs) {
11
11
  const maxChars = inputs.maxCharsPerSource ?? 4000;
12
12
  const toolNames = new Set(mcpTools.map((t) => t.name));
13
13
  const tasks = [];
14
- if (toolNames.has('memory_recall')) {
14
+ if (hasMcpTool(toolNames, 'memory_recall')) {
15
15
  tasks.push(callSafe('memory_recall', { sessionKey, query, activeSkill }, mcpClient, maxChars, extractRecords));
16
16
  }
17
- if (toolNames.has('memory_working_context')) {
17
+ if (hasMcpTool(toolNames, 'memory_working_context')) {
18
18
  tasks.push(callSafe('memory_working_context', { sessionKey, workspacePath: workspaceRoot }, mcpClient, maxChars));
19
19
  }
20
- if (toolNames.has('memory_task_state') && !inputs.hasActiveGoal) {
20
+ if (hasMcpTool(toolNames, 'memory_task_state') && !inputs.hasActiveGoal) {
21
21
  tasks.push(callSafe('memory_task_state', { query }, mcpClient, maxChars));
22
22
  }
23
23
  const results = await Promise.all(tasks);
@@ -31,13 +31,26 @@ export interface OrchestrationContext {
31
31
  launchCwd: string;
32
32
  /** Called when a child output got offloaded — chars beyond preview that didn't land in parent context. */
33
33
  recordOffload?: (charsAvoided: number) => void;
34
- /** Called when the child agent emits a tool call, for live observability. */
35
- onChildToolEvent?: (event: {
34
+ /**
35
+ * Paired child tool lifecycle callbacks. Fire from the child agent's
36
+ * onToolStart / onToolEnd so the parent's REPL can render explicit
37
+ * "child began X" / "child finished X" rows in the scrollback — without
38
+ * these, long child runs look like the parent has frozen (roadmap §3).
39
+ */
40
+ onChildToolStart?: (event: {
41
+ childId: string;
42
+ role: string;
43
+ tool: string;
44
+ args: Record<string, any>;
45
+ }) => void;
46
+ onChildToolEnd?: (event: {
36
47
  childId: string;
37
48
  role: string;
38
49
  tool: string;
39
50
  ok: boolean;
40
51
  summary: string;
52
+ preview?: string;
53
+ durationMs: number;
41
54
  }) => void;
42
55
  /**
43
56
  * Called when a child agent's runTurn ends — success, fail, or empty answer.
@@ -117,6 +130,86 @@ export declare function createSpawnAgentTool(): {
117
130
  required: string[];
118
131
  };
119
132
  };
133
+ export declare function createTaskAgentTool(): {
134
+ name: string;
135
+ description: string;
136
+ inputSchema: {
137
+ type: string;
138
+ properties: {
139
+ role: {
140
+ type: string;
141
+ description: string;
142
+ };
143
+ agentId: {
144
+ type: string;
145
+ description: string;
146
+ };
147
+ prompt: {
148
+ type: string;
149
+ description: string;
150
+ };
151
+ label: {
152
+ type: string;
153
+ description: string;
154
+ };
155
+ access: {
156
+ type: string;
157
+ enum: string[];
158
+ description: string;
159
+ };
160
+ timeoutMs: {
161
+ type: string;
162
+ description: string;
163
+ };
164
+ seedRecordIds: {
165
+ type: string;
166
+ items: {
167
+ type: string;
168
+ };
169
+ description: string;
170
+ };
171
+ };
172
+ required: string[];
173
+ };
174
+ };
175
+ export declare function createDelegateAgentTool(): {
176
+ name: string;
177
+ description: string;
178
+ inputSchema: {
179
+ type: string;
180
+ properties: {
181
+ role: {
182
+ type: string;
183
+ description: string;
184
+ };
185
+ agentId: {
186
+ type: string;
187
+ description: string;
188
+ };
189
+ prompt: {
190
+ type: string;
191
+ description: string;
192
+ };
193
+ label: {
194
+ type: string;
195
+ description: string;
196
+ };
197
+ access: {
198
+ type: string;
199
+ enum: string[];
200
+ description: string;
201
+ };
202
+ seedRecordIds: {
203
+ type: string;
204
+ items: {
205
+ type: string;
206
+ };
207
+ description: string;
208
+ };
209
+ };
210
+ required: string[];
211
+ };
212
+ };
120
213
  export declare function createListAgentsTool(): {
121
214
  name: string;
122
215
  description: string;
@@ -55,7 +55,16 @@ export function extractChildPreview(output, maxChars) {
55
55
  const tail = maxChars - head - 6; // 6 chars for the `\n...\n` divider
56
56
  return output.slice(0, head) + '\n…\n' + output.slice(-tail);
57
57
  }
58
+ // Default wait/timeout for foreground delegation. Mirrors wait_agent's
59
+ // historical 120 s default so task_agent and spawn_agent({ wait: true })
60
+ // behave identically when no explicit timeoutMs is passed. Only used inside
61
+ // handleTaskAgent (call-time); kept out of the schema-creator bodies to
62
+ // avoid the ESM circular-import TDZ between tools.ts and agent.ts (agent.ts
63
+ // constructs the LOCAL_TOOLS array eagerly at module load).
64
+ const DEFAULT_TASK_AGENT_TIMEOUT_MS = 120_000;
58
65
  const ORCHESTRATION_TOOL_NAMES = new Set([
66
+ 'task_agent',
67
+ 'delegate_agent',
59
68
  'spawn_agent',
60
69
  'spawn_agents',
61
70
  'list_agents',
@@ -122,6 +131,54 @@ export function createSpawnAgentTool() {
122
131
  },
123
132
  };
124
133
  }
134
+ export function createTaskAgentTool() {
135
+ return {
136
+ name: 'task_agent',
137
+ description: 'Run one foreground child agent for a bounded task and wait for its result. ' +
138
+ 'Returns the completed child output, failure, or an explicit timeout envelope. Prefer this over spawn_agent({ wait: true }) for foreground delegation.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ role: { type: 'string', description: 'One of: explorer, architect, reviewer, worker, verifier. Prefer agentId for custom definitions.' },
143
+ agentId: { type: 'string', description: 'Registry id of the agent definition. Takes precedence over role when both are provided.' },
144
+ prompt: { type: 'string', description: 'The bounded task prompt for the child agent.' },
145
+ label: { type: 'string', description: 'Optional short label for the child run.' },
146
+ access: { type: 'string', enum: ['read', 'write', 'shell'], description: 'Override the role default access mode. Default: role default.' },
147
+ timeoutMs: { type: 'integer', description: 'Optional timeout in milliseconds. Default 120000.' },
148
+ seedRecordIds: {
149
+ type: 'array',
150
+ items: { type: 'string' },
151
+ description: 'Optional BrainRouter memory record IDs that the parent already recalled.',
152
+ },
153
+ },
154
+ required: ['prompt'],
155
+ },
156
+ };
157
+ }
158
+ export function createDelegateAgentTool() {
159
+ return {
160
+ name: 'delegate_agent',
161
+ description: 'Start one background child agent and keep working in the parent turn. ' +
162
+ 'Non-blocking — there is no `timeoutMs`; the child runs until it finishes or is cancelled. ' +
163
+ 'Returns a running child id plus a reminder to continue useful work; call wait_agent later when the result is needed.',
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ role: { type: 'string', description: 'One of: explorer, architect, reviewer, worker, verifier. Prefer agentId for custom definitions.' },
168
+ agentId: { type: 'string', description: 'Registry id of the agent definition. Takes precedence over role when both are provided.' },
169
+ prompt: { type: 'string', description: 'The bounded task prompt for the child agent.' },
170
+ label: { type: 'string', description: 'Optional short label for the child run.' },
171
+ access: { type: 'string', enum: ['read', 'write', 'shell'], description: 'Override the role default access mode. Default: role default.' },
172
+ seedRecordIds: {
173
+ type: 'array',
174
+ items: { type: 'string' },
175
+ description: 'Optional BrainRouter memory record IDs that the parent already recalled.',
176
+ },
177
+ },
178
+ required: ['prompt'],
179
+ },
180
+ };
181
+ }
125
182
  export function createListAgentsTool() {
126
183
  return {
127
184
  name: 'list_agents',
@@ -225,6 +282,10 @@ export function createRouteAgentTool() {
225
282
  }
226
283
  export async function executeOrchestrationTool(name, args, ctx) {
227
284
  switch (name) {
285
+ case 'task_agent':
286
+ return await handleTaskAgent(args, ctx);
287
+ case 'delegate_agent':
288
+ return await handleDelegateAgent(args, ctx);
228
289
  case 'spawn_agent':
229
290
  return await handleSpawn(args, ctx);
230
291
  case 'spawn_agents':
@@ -245,6 +306,31 @@ export async function executeOrchestrationTool(name, args, ctx) {
245
306
  throw new Error(`Unknown orchestration tool: ${name}`);
246
307
  }
247
308
  }
309
+ async function handleTaskAgent(args, ctx) {
310
+ return await handleSpawn({ ...args, wait: true, timeoutMs: args?.timeoutMs ?? DEFAULT_TASK_AGENT_TIMEOUT_MS }, ctx);
311
+ }
312
+ async function handleDelegateAgent(args, ctx) {
313
+ const spawned = await handleSpawn({ ...args, wait: false }, ctx);
314
+ let parsed;
315
+ try {
316
+ const value = JSON.parse(spawned);
317
+ if (value && typeof value === 'object' && !Array.isArray(value))
318
+ parsed = value;
319
+ }
320
+ catch {
321
+ // not JSON; fall through to verbatim propagation
322
+ }
323
+ // If handleSpawn returned an error string or a non-object payload (no id to
324
+ // attach next-step semantics to), propagate it verbatim — wrapping it in
325
+ // { raw, nextAction } would hide the failure from the model and prevent the
326
+ // child-drain guardrail from finding a child id to wait on.
327
+ if (!parsed || typeof parsed.id !== 'string')
328
+ return spawned;
329
+ return JSON.stringify({
330
+ ...parsed,
331
+ nextAction: 'continue working in the parent turn; call wait_agent when this child output is needed',
332
+ }, null, 2);
333
+ }
248
334
  async function handleSpawnBatch(args, ctx) {
249
335
  const list = Array.isArray(args?.agents) ? args.agents : [];
250
336
  if (list.length === 0)
@@ -396,16 +482,32 @@ async function handleSpawn(args, ctx) {
396
482
  updateSession(ctx.workspaceRoot, record.id, { status: 'running' });
397
483
  const promise = (async () => {
398
484
  try {
485
+ // Track per-tool start times so the paired onChildToolEnd carries a
486
+ // real duration — the REPL renders this on the child's end row.
487
+ const childToolStarts = new Map();
399
488
  const output = await childAgent.runTurn(prompt, {
400
489
  onStatusUpdate: () => { },
401
- onToolStart: () => { },
490
+ onToolStart: (tool, args) => {
491
+ childToolStarts.set(tool, Date.now());
492
+ ctx.onChildToolStart?.({
493
+ childId: record.id,
494
+ role: role.name,
495
+ tool,
496
+ args: args ?? {},
497
+ });
498
+ },
402
499
  onToolEnd: (tool, result) => {
403
- ctx.onChildToolEvent?.({
500
+ const startedAt = childToolStarts.get(tool);
501
+ childToolStarts.delete(tool);
502
+ const durationMs = startedAt ? Date.now() - startedAt : 0;
503
+ ctx.onChildToolEnd?.({
404
504
  childId: record.id,
405
505
  role: role.name,
406
506
  tool,
407
507
  ok: result.success,
408
508
  summary: result.summary,
509
+ preview: result.preview,
510
+ durationMs,
409
511
  });
410
512
  },
411
513
  });
@@ -506,12 +608,25 @@ async function handleWait(args, ctx) {
506
608
  const promise = runningPromises.get(id);
507
609
  if (promise) {
508
610
  let timedOut = false;
611
+ let timeout;
509
612
  await Promise.race([
510
613
  promise,
511
- new Promise((resolve) => setTimeout(() => { timedOut = true; resolve(); }, timeoutMs)),
614
+ new Promise((resolve) => {
615
+ timeout = setTimeout(() => { timedOut = true; resolve(); }, timeoutMs);
616
+ }),
512
617
  ]);
618
+ if (timeout)
619
+ clearTimeout(timeout);
513
620
  if (timedOut) {
514
- return JSON.stringify({ id, status: 'timeout' }, null, 2);
621
+ const record = getSession(ctx.workspaceRoot, id);
622
+ return JSON.stringify({
623
+ id,
624
+ status: 'timeout',
625
+ childStatus: record?.status ?? 'unknown',
626
+ role: record?.role,
627
+ label: record?.label,
628
+ summary: record ? formatSessionSummary(record) : `No child session with id ${id}.`,
629
+ }, null, 2);
515
630
  }
516
631
  }
517
632
  const record = getSession(ctx.workspaceRoot, id);
@@ -77,9 +77,9 @@ function clarifyOverlay(activeSkill) {
77
77
  function isBrainOnline(connectedTools) {
78
78
  if (!connectedTools)
79
79
  return true;
80
- // Match bare `memory_recall`, double-underscore `mcp__<server>__memory_recall`,
81
- // and single-underscore `mcp_<server>_memory_recall` (both prefix conventions
82
- // are in use across the multi-MCP codepaths until naming is unified).
80
+ // Match bare `memory_recall` and the canonical single-underscore prefixed
81
+ // form `mcp_<server>_memory_recall` (pool normalises any legacy
82
+ // double-underscore emissions at the boundary 0.3.8-R5).
83
83
  return connectedTools.some((tool) => tool === 'memory_recall' ||
84
84
  (tool.startsWith('mcp_') && tool.endsWith('memory_recall')));
85
85
  }
@@ -127,7 +127,8 @@ export function buildSystemPrompt(context) {
127
127
  brainOnline ? memoryFirstSection() : brainOfflineNotice(),
128
128
  '',
129
129
  '## Multi-agent orchestration',
130
- '- Delegate parallel, bounded work via `spawn_agent` (one) or `spawn_agents` (batch). Roles: explorer (read-only investigation), architect (design alternatives), reviewer (code review), worker (write access), verifier (tests/checks). Omit `role` in `spawn_agents` to auto-route from the leading verb; use `route_agent` for a dry run.',
130
+ '- Delegation order: direct answer direct tool `task_agent` for needed child results `delegate_agent` when you can keep working. `spawn_agent` / `spawn_agents` are low-level compatibility/batch tools.',
131
+ '- Roles: explorer, architect, reviewer, worker, verifier. Omit `role` in `spawn_agents` to auto-route from the leading verb; use `route_agent` for a dry run.',
131
132
  '- Fan-out triggers: phrasings like "everything", "all", "in 1 go", "in parallel", "thoroughly", "comprehensive", "across the codebase" → ALWAYS `spawn_agents` with ≥3 children. One tool call + "what next?" is NOT acceptable for those prompts.',
132
133
  '- Use `wait_agent` / `wait_agents` to drain before yielding. Synthesize child outputs in your own words — never claim work is done just because a child returned.',
133
134
  '',
@@ -0,0 +1,100 @@
1
+ import type { LLMConfig } from '../config/config.js';
2
+ import type { EffortLevel } from '../state/preferencesStore.js';
3
+ /**
4
+ * Route to the native adapter when the profile is Anthropic AND the
5
+ * endpoint hostname is `api.anthropic.com`, OR the explicit
6
+ * `BRAINROUTER_ANTHROPIC_NATIVE=1` override is set (for vended /
7
+ * reverse-proxied endpoints that still speak the native shape).
8
+ *
9
+ * Anything else — including `provider:'anthropic'` pointed at an
10
+ * OpenAI-compat gateway — stays on the existing OpenAI path so we
11
+ * don't break the OpenRouter / Anthropic-compat-shim flows.
12
+ */
13
+ export declare function shouldUseAnthropicNative(config: LLMConfig, env?: NodeJS.ProcessEnv): boolean;
14
+ export interface AnthropicBuildOptions {
15
+ effort?: EffortLevel;
16
+ cacheEnabled?: boolean;
17
+ maxTokens?: number;
18
+ thinkingBudgetTokens?: number;
19
+ }
20
+ interface AnthropicMessage {
21
+ role: 'user' | 'assistant';
22
+ content: any[];
23
+ }
24
+ interface AnthropicTool {
25
+ name: string;
26
+ description: string;
27
+ input_schema: any;
28
+ cache_control?: {
29
+ type: 'ephemeral';
30
+ };
31
+ }
32
+ export interface AnthropicRequestPayload {
33
+ model: string;
34
+ max_tokens: number;
35
+ system?: any;
36
+ messages: AnthropicMessage[];
37
+ tools?: AnthropicTool[];
38
+ thinking?: {
39
+ type: 'enabled';
40
+ budget_tokens: number;
41
+ };
42
+ }
43
+ /**
44
+ * Pure transform: BrainRouter chat history (OpenAI shape) →
45
+ * Anthropic `/v1/messages` request body.
46
+ *
47
+ * Invariants enforced here so callers don't need to know the Anthropic
48
+ * rules:
49
+ * - The system message (first message with role:'system') is hoisted
50
+ * to the top-level `system` field and dropped from `messages`.
51
+ * - Consecutive `tool` role entries are merged into one synthetic
52
+ * `user` message whose content is an array of `tool_result` blocks.
53
+ * - Assistant messages with `tool_calls` emit a content array that
54
+ * interleaves text (when present) and `tool_use` blocks. The
55
+ * OpenAI tool_call.id is reused as the Anthropic tool_use.id —
56
+ * callers must echo it back on the matching tool_result.
57
+ */
58
+ export declare function buildAnthropicRequest(config: LLMConfig, messages: any[], tools: any[], options?: AnthropicBuildOptions): AnthropicRequestPayload;
59
+ export interface AnthropicParsedResponse {
60
+ content: string;
61
+ toolCalls?: Array<{
62
+ id: string;
63
+ type: 'function';
64
+ function: {
65
+ name: string;
66
+ arguments: string;
67
+ };
68
+ }>;
69
+ usage?: {
70
+ prompt_tokens?: number;
71
+ completion_tokens?: number;
72
+ };
73
+ thinking?: string;
74
+ }
75
+ /**
76
+ * Pure transform: Anthropic response body → BrainRouter's internal
77
+ * `ChatResponse` shape. tool_use blocks become OpenAI-style toolCalls
78
+ * with the Anthropic id preserved verbatim, and `input` is re-serialized
79
+ * to the `function.arguments` JSON string the agent loop expects.
80
+ */
81
+ export declare function parseAnthropicResponse(data: any): AnthropicParsedResponse;
82
+ export interface CallAnthropicOptions extends AnthropicBuildOptions {
83
+ onThinking?: (text: string) => void;
84
+ }
85
+ export declare function callAnthropic(config: LLMConfig, messages: any[], tools: any[], options?: CallAnthropicOptions): Promise<{
86
+ content: string;
87
+ toolCalls: {
88
+ id: string;
89
+ type: "function";
90
+ function: {
91
+ name: string;
92
+ arguments: string;
93
+ };
94
+ }[] | undefined;
95
+ usage: {
96
+ prompt_tokens?: number;
97
+ completion_tokens?: number;
98
+ } | undefined;
99
+ }>;
100
+ export {};