@kinqs/brainrouter-cli 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +109 -0
- package/README.md +185 -0
- package/dist/agent/agent.d.ts +765 -0
- package/dist/agent/agent.js +1977 -0
- package/dist/cli/cliPrompt.d.ts +15 -0
- package/dist/cli/cliPrompt.js +62 -0
- package/dist/cli/commands/_context.d.ts +53 -0
- package/dist/cli/commands/_context.js +14 -0
- package/dist/cli/commands/_helpers.d.ts +45 -0
- package/dist/cli/commands/_helpers.js +140 -0
- package/dist/cli/commands/guard.d.ts +6 -0
- package/dist/cli/commands/guard.js +292 -0
- package/dist/cli/commands/memory.d.ts +12 -0
- package/dist/cli/commands/memory.js +263 -0
- package/dist/cli/commands/obs.d.ts +6 -0
- package/dist/cli/commands/obs.js +208 -0
- package/dist/cli/commands/orchestration.d.ts +6 -0
- package/dist/cli/commands/orchestration.js +218 -0
- package/dist/cli/commands/session.d.ts +6 -0
- package/dist/cli/commands/session.js +191 -0
- package/dist/cli/commands/ui.d.ts +6 -0
- package/dist/cli/commands/ui.js +477 -0
- package/dist/cli/commands/workflow.d.ts +6 -0
- package/dist/cli/commands/workflow.js +691 -0
- package/dist/cli/repl.d.ts +12 -0
- package/dist/cli/repl.js +894 -0
- package/dist/config/config.d.ts +22 -0
- package/dist/config/config.js +105 -0
- package/dist/config/workspace.d.ts +7 -0
- package/dist/config/workspace.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +610 -0
- package/dist/memory/briefing.d.ts +46 -0
- package/dist/memory/briefing.js +152 -0
- package/dist/memory/consolidation.d.ts +60 -0
- package/dist/memory/consolidation.js +208 -0
- package/dist/memory/formatters.d.ts +38 -0
- package/dist/memory/formatters.js +102 -0
- package/dist/memory/mentions.d.ts +10 -0
- package/dist/memory/mentions.js +72 -0
- package/dist/orchestration/orchestrator.d.ts +36 -0
- package/dist/orchestration/orchestrator.js +71 -0
- package/dist/orchestration/roles.d.ts +11 -0
- package/dist/orchestration/roles.js +117 -0
- package/dist/orchestration/tools.d.ts +244 -0
- package/dist/orchestration/tools.js +528 -0
- package/dist/prompt/breadthHint.d.ts +48 -0
- package/dist/prompt/breadthHint.js +93 -0
- package/dist/prompt/compactor.d.ts +31 -0
- package/dist/prompt/compactor.js +112 -0
- package/dist/prompt/initAgentMd.d.ts +13 -0
- package/dist/prompt/initAgentMd.js +194 -0
- package/dist/prompt/skillRunner.d.ts +34 -0
- package/dist/prompt/skillRunner.js +146 -0
- package/dist/prompt/systemPrompt.d.ts +10 -0
- package/dist/prompt/systemPrompt.js +171 -0
- package/dist/runtime/clipboard.d.ts +17 -0
- package/dist/runtime/clipboard.js +52 -0
- package/dist/runtime/llmSemaphore.d.ts +30 -0
- package/dist/runtime/llmSemaphore.js +67 -0
- package/dist/runtime/loopRunner.d.ts +25 -0
- package/dist/runtime/loopRunner.js +79 -0
- package/dist/runtime/mcpClient.d.ts +156 -0
- package/dist/runtime/mcpClient.js +234 -0
- package/dist/runtime/mcpUtils.d.ts +36 -0
- package/dist/runtime/mcpUtils.js +64 -0
- package/dist/runtime/sandbox.d.ts +48 -0
- package/dist/runtime/sandbox.js +156 -0
- package/dist/runtime/tracing.d.ts +25 -0
- package/dist/runtime/tracing.js +91 -0
- package/dist/state/cliState.d.ts +59 -0
- package/dist/state/cliState.js +311 -0
- package/dist/state/goalStore.d.ts +174 -0
- package/dist/state/goalStore.js +410 -0
- package/dist/state/hookifyStore.d.ts +80 -0
- package/dist/state/hookifyStore.js +237 -0
- package/dist/state/hooksStore.d.ts +42 -0
- package/dist/state/hooksStore.js +71 -0
- package/dist/state/preferencesStore.d.ts +41 -0
- package/dist/state/preferencesStore.js +25 -0
- package/dist/state/sessionStore.d.ts +42 -0
- package/dist/state/sessionStore.js +193 -0
- package/dist/state/taskStore.d.ts +23 -0
- package/dist/state/taskStore.js +80 -0
- package/dist/state/workflowArtifacts.d.ts +33 -0
- package/dist/state/workflowArtifacts.js +139 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1977 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { askYesNo } from '../cli/cliPrompt.js';
|
|
7
|
+
import { appendTranscriptEntry } from '../state/sessionStore.js';
|
|
8
|
+
import { buildSystemPrompt, loadWorkspaceInstructionSummary } from '../prompt/systemPrompt.js';
|
|
9
|
+
import { formatPlan, readPlan, updatePlan } from '../state/taskStore.js';
|
|
10
|
+
import { createSpawnAgentTool, createSpawnAgentsTool, createListAgentsTool, createWaitAgentTool, createWaitAgentsTool, createReadAgentTranscriptTool, createCloseAgentTool, createRouteAgentTool, executeOrchestrationTool, isOrchestrationToolName, } from '../orchestration/tools.js';
|
|
11
|
+
import { buildMemoryBriefing, selectCitedRecordIds } from '../memory/briefing.js';
|
|
12
|
+
import { callMcpTool, extractToolText } from '../runtime/mcpUtils.js';
|
|
13
|
+
import { acquireLLMSlot } from '../runtime/llmSemaphore.js';
|
|
14
|
+
import { blockGoal, completeGoal, formatGoalBlock, readGoal } from '../state/goalStore.js';
|
|
15
|
+
import { runHooks } from '../state/hooksStore.js';
|
|
16
|
+
import { resolveSandboxConfig, runShell } from '../runtime/sandbox.js';
|
|
17
|
+
import { readPreferences } from '../state/preferencesStore.js';
|
|
18
|
+
import { startSpan, traceEvent } from '../runtime/tracing.js';
|
|
19
|
+
import { buildHookifyContext, evaluateHookify, listHookifyRules } from '../state/hookifyStore.js';
|
|
20
|
+
import { renderCompactSystemMessage, runCompaction } from '../prompt/compactor.js';
|
|
21
|
+
import { buildFanOutHint, shouldSuggestFanOut } from '../prompt/breadthHint.js';
|
|
22
|
+
const execPromise = promisify(exec);
|
|
23
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.DS_Store', '.next']);
|
|
24
|
+
export const LOCAL_TOOLS = [
|
|
25
|
+
{
|
|
26
|
+
name: 'read_file',
|
|
27
|
+
description: 'Read the contents of a file from the workspace. Optional line ranges can be provided.',
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
path: { type: 'string', description: 'Path to the file, relative to workspace root.' },
|
|
32
|
+
startLine: { type: 'integer', description: 'Optional 1-based start line number to read from.' },
|
|
33
|
+
endLine: { type: 'integer', description: 'Optional 1-based end line number to read to.' }
|
|
34
|
+
},
|
|
35
|
+
required: ['path']
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'write_file',
|
|
40
|
+
description: 'Create a new file or completely overwrite an existing file in the workspace.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
path: { type: 'string', description: 'Path to the file, relative to workspace root.' },
|
|
45
|
+
content: { type: 'string', description: 'The full content to write to the file.' }
|
|
46
|
+
},
|
|
47
|
+
required: ['path', 'content']
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'edit_file',
|
|
52
|
+
description: 'Edit an existing file in the workspace by replacing a target substring with a replacement string.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
path: { type: 'string', description: 'Path to the file, relative to workspace root.' },
|
|
57
|
+
targetContent: { type: 'string', description: 'The exact substring in the file to be replaced.' },
|
|
58
|
+
replacementContent: { type: 'string', description: 'The replacement string.' }
|
|
59
|
+
},
|
|
60
|
+
required: ['path', 'targetContent', 'replacementContent']
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'list_dir',
|
|
65
|
+
description: 'List the contents of a directory in the workspace.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
path: { type: 'string', description: 'Path to the directory, relative to workspace root. Defaults to "."' }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'grep_search',
|
|
75
|
+
description: 'Search for a query string in files within a directory in the workspace.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
path: { type: 'string', description: 'Path to search under. Defaults to "."' },
|
|
80
|
+
query: { type: 'string', description: 'String or regex query pattern to search for.' }
|
|
81
|
+
},
|
|
82
|
+
required: ['query']
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'glob_files',
|
|
87
|
+
description: 'Recursively find files in the workspace matching a glob/wildcard pattern (e.g., "src/**/*.ts" or "*.json").',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
pattern: { type: 'string', description: 'The glob or wildcard pattern to search for.' }
|
|
92
|
+
},
|
|
93
|
+
required: ['pattern']
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'run_command',
|
|
98
|
+
description: 'Run a shell command on the user\'s terminal. Requires user approval before execution.',
|
|
99
|
+
inputSchema: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
command: { type: 'string', description: 'The shell command to run.' }
|
|
103
|
+
},
|
|
104
|
+
required: ['command']
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'fetch_url',
|
|
109
|
+
description: 'Fetch the text content of a URL from the internet (e.g. documentation, api references, etc.).',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
url: { type: 'string', description: 'The absolute HTTP or HTTPS URL to fetch.' }
|
|
114
|
+
},
|
|
115
|
+
required: ['url']
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'web_search',
|
|
120
|
+
description: 'Search the public web for a query and return top results (title, url, snippet). Useful when fetch_url needs a starting point.',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
query: { type: 'string', description: 'The search query.' },
|
|
125
|
+
maxResults: { type: 'integer', description: 'Maximum results to return. Default 5, max 10.' }
|
|
126
|
+
},
|
|
127
|
+
required: ['query']
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'apply_patch',
|
|
132
|
+
description: 'Apply a multi-file patch using the Begin/End envelope format ("*** Begin Patch / *** Update File: path / @@ context / -old / +new / *** Add File: / *** Delete File: / *** End Patch"). Lets you make several coordinated edits across files in one tool call.',
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
patch: { type: 'string', description: 'The full patch text including Begin Patch/End Patch envelope.' }
|
|
137
|
+
},
|
|
138
|
+
required: ['patch']
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
createSpawnAgentTool(),
|
|
142
|
+
createSpawnAgentsTool(),
|
|
143
|
+
createListAgentsTool(),
|
|
144
|
+
createWaitAgentTool(),
|
|
145
|
+
createWaitAgentsTool(),
|
|
146
|
+
createReadAgentTranscriptTool(),
|
|
147
|
+
createCloseAgentTool(),
|
|
148
|
+
createRouteAgentTool(),
|
|
149
|
+
{
|
|
150
|
+
name: 'update_plan',
|
|
151
|
+
description: 'Create or update the durable CLI task plan. Use this for multi-step work and keep at most one item in_progress.',
|
|
152
|
+
inputSchema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
explanation: { type: 'string', description: 'Optional short explanation of the plan update.' },
|
|
156
|
+
plan: {
|
|
157
|
+
type: 'array',
|
|
158
|
+
description: 'Ordered plan items.',
|
|
159
|
+
items: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {
|
|
162
|
+
step: { type: 'string' },
|
|
163
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] }
|
|
164
|
+
},
|
|
165
|
+
required: ['step', 'status']
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
required: ['plan']
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'goal_complete',
|
|
174
|
+
description: 'Mark the active /goal complete. CALL ONLY when concrete evidence in the thread (tests passing, file written, benchmark hit, artifact produced) proves the outcome is satisfied. Pass a 1–2 sentence proof citing the evidence. PRECONDITION: if you have an active plan (from update_plan), every item must be marked `completed` before this call succeeds — call update_plan first to mark finished work done (or mark intentionally-dropped items completed with a rationale). The CLI hard-refuses goal_complete while pending / in_progress items remain. CRITICAL: in the SAME assistant message as this tool call, ALSO write the user-visible deliverable as prose — the actual answer, analysis, summary, or report the user asked for. The `proof` field is short audit metadata (file paths, test names, command exit codes), NOT the deliverable. If you skip the prose, the user sees only a placeholder and your work is invisible to them.',
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
proof: { type: 'string', description: 'Short evidence-based justification (file path / test name / output). Audit metadata only — NOT the user-visible answer; put that in the assistant message text.' },
|
|
179
|
+
},
|
|
180
|
+
required: ['proof'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'goal_blocked',
|
|
185
|
+
description: 'Mark the active /goal blocked. CALL when no defensible path remains within boundaries (missing data, ambiguous spec, external dependency). Pass a reason and what user input would unblock it. CRITICAL: in the SAME assistant message as this tool call, ALSO write the user-visible explanation as prose — what you tried, what you learned, why you stopped, what the user needs to do next. The `reason` / `needed` fields are short audit metadata, NOT the deliverable.',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {
|
|
189
|
+
reason: { type: 'string', description: 'Short reason progress stalled. Audit metadata only — write the full explanation in the assistant message text.' },
|
|
190
|
+
needed: { type: 'string', description: 'What user input or external resource would unblock progress.' },
|
|
191
|
+
},
|
|
192
|
+
required: ['reason'],
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
];
|
|
196
|
+
/**
|
|
197
|
+
* @deprecated Prefer passing an explicit workspaceRoot. Returns process.cwd()
|
|
198
|
+
* which is brittle when the Agent was constructed with a workspace different
|
|
199
|
+
* from cwd (e.g. when /resume re-attaches a session originally captured in
|
|
200
|
+
* another dir, or when the user cd's away after launch).
|
|
201
|
+
*/
|
|
202
|
+
export function getWorkspaceRoot() {
|
|
203
|
+
return fs.realpathSync(process.cwd());
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Best-effort guidance for the LLM when it calls a tool name that doesn't
|
|
207
|
+
* exist (JSON-RPC -32601). The most common cause is confusing a BrainRouter
|
|
208
|
+
* skill (documentation) for an invocable tool. Pattern-match on the name and
|
|
209
|
+
* return a corrective hint that the next agent turn will see as the tool
|
|
210
|
+
* result.
|
|
211
|
+
*/
|
|
212
|
+
export function explainUnknownToolName(name) {
|
|
213
|
+
const trimmed = (name ?? '').trim();
|
|
214
|
+
const lower = trimmed.toLowerCase();
|
|
215
|
+
const looksLikeSkill = lower.endsWith('-skill') ||
|
|
216
|
+
/(implementation|workflow|driven|generator|recovery|cleanup|simplification)$/i.test(lower) ||
|
|
217
|
+
/skill$/i.test(lower);
|
|
218
|
+
if (looksLikeSkill) {
|
|
219
|
+
return ('It looks like you tried to invoke a SKILL as if it were a tool. ' +
|
|
220
|
+
'Skills are markdown documentation packages, not invocable functions. ' +
|
|
221
|
+
'To use one: call `list_skills({ scope: "all" })` to find the canonical name, ' +
|
|
222
|
+
`then \`get_skill({ name: "${trimmed}" })\` (or the closest match) to load its instructions, ` +
|
|
223
|
+
'and then follow the steps yourself with the regular tools (read_file, write_file, run_command, spawn_agent, …).');
|
|
224
|
+
}
|
|
225
|
+
return ('Verify the tool name by inspecting the tool list that was attached at turn start. ' +
|
|
226
|
+
'If you intended a skill (documentation/workflow), load it via `get_skill` first; ' +
|
|
227
|
+
'skills are not directly callable.');
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Cross-vendor tool-name aliases. Models trained on Claude Code's tool
|
|
231
|
+
* vocabulary often emit `Bash` / `bash` when they want to run a shell command;
|
|
232
|
+
* BrainRouter's canonical name is `run_command`. Rather than rename the tool
|
|
233
|
+
* (breaking transcripts and prompts), normalize the alias at dispatch time.
|
|
234
|
+
*
|
|
235
|
+
* Keep this list short: every alias is a hint the LLM doesn't read its own
|
|
236
|
+
* tool list before calling. Aliases for read_file / write_file / etc. could
|
|
237
|
+
* follow if observed empirically.
|
|
238
|
+
*/
|
|
239
|
+
const TOOL_NAME_ALIASES = {
|
|
240
|
+
bash: 'run_command',
|
|
241
|
+
shell: 'run_command',
|
|
242
|
+
sh: 'run_command',
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* Normalize a tool name the LLM emitted into the canonical form used by the
|
|
246
|
+
* tool registry. Handles common variants: case (`Read_File`), separators
|
|
247
|
+
* (`read-file`, `read.file`), surrounding whitespace, and a short list of
|
|
248
|
+
* cross-vendor aliases (`Bash` → `run_command`).
|
|
249
|
+
*
|
|
250
|
+
* Returns the exact canonical name if a unique match is found among the
|
|
251
|
+
* provided candidates; otherwise returns the trimmed input (so the regular
|
|
252
|
+
* dispatch/explainUnknownToolName path still runs).
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeToolName(raw, candidates) {
|
|
255
|
+
const trimmed = (raw ?? '').trim();
|
|
256
|
+
if (!trimmed)
|
|
257
|
+
return trimmed;
|
|
258
|
+
// Exact match short-circuits — keeps the hot path cheap.
|
|
259
|
+
if (candidates.includes(trimmed))
|
|
260
|
+
return trimmed;
|
|
261
|
+
const flatten = (s) => s.toLowerCase().replace(/[-.\s_]+/g, '');
|
|
262
|
+
const target = flatten(trimmed);
|
|
263
|
+
// Cross-vendor alias resolution: check before generic case/separator
|
|
264
|
+
// matching so `Bash` resolves to `run_command` even though the flattened
|
|
265
|
+
// forms differ. Only honored when the canonical target is actually
|
|
266
|
+
// registered — keeps us from silently rerouting in unexpected configs.
|
|
267
|
+
const aliased = TOOL_NAME_ALIASES[target];
|
|
268
|
+
if (aliased && candidates.includes(aliased))
|
|
269
|
+
return aliased;
|
|
270
|
+
const matches = candidates.filter((c) => flatten(c) === target);
|
|
271
|
+
if (matches.length === 1)
|
|
272
|
+
return matches[0];
|
|
273
|
+
return trimmed;
|
|
274
|
+
}
|
|
275
|
+
export function isPathInside(parent, candidate) {
|
|
276
|
+
const relative = path.relative(parent, candidate);
|
|
277
|
+
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Resolve a workspace-relative path against the given workspaceRoot. Throws
|
|
281
|
+
* if the result escapes the workspace.
|
|
282
|
+
*
|
|
283
|
+
* `workspaceRoot` is REQUIRED — passing a stale `process.cwd()` was the bug
|
|
284
|
+
* that let tool writes land in `~/.brainrouter` when the user's cwd drifted.
|
|
285
|
+
*
|
|
286
|
+
* For backwards compatibility, the workspaceRoot parameter may be omitted; it
|
|
287
|
+
* then falls back to process.cwd(). New code should always pass it explicitly.
|
|
288
|
+
*/
|
|
289
|
+
export function resolveWorkspacePath(workspaceRootOrPath = '.', inputPathOrOptions, maybeOptions) {
|
|
290
|
+
// Two call shapes are supported during the migration of callers:
|
|
291
|
+
// resolveWorkspacePath(workspaceRoot, inputPath, options)
|
|
292
|
+
// resolveWorkspacePath(inputPath, options) ← deprecated; falls back to cwd
|
|
293
|
+
let workspaceRoot;
|
|
294
|
+
let inputPath;
|
|
295
|
+
let options;
|
|
296
|
+
if (typeof inputPathOrOptions === 'string') {
|
|
297
|
+
workspaceRoot = workspaceRootOrPath;
|
|
298
|
+
inputPath = inputPathOrOptions;
|
|
299
|
+
options = maybeOptions ?? {};
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
workspaceRoot = fs.realpathSync(process.cwd());
|
|
303
|
+
inputPath = workspaceRootOrPath;
|
|
304
|
+
options = inputPathOrOptions ?? {};
|
|
305
|
+
}
|
|
306
|
+
if (typeof inputPath !== 'string' || inputPath.trim() === '') {
|
|
307
|
+
throw new Error('Path must be a non-empty string.');
|
|
308
|
+
}
|
|
309
|
+
const root = fs.realpathSync(workspaceRoot);
|
|
310
|
+
const resolved = path.resolve(root, inputPath);
|
|
311
|
+
const checkPath = options.forWrite ? path.dirname(resolved) : resolved;
|
|
312
|
+
const existingCheckPath = fs.existsSync(checkPath) ? fs.realpathSync(checkPath) : checkPath;
|
|
313
|
+
if (!isPathInside(root, existingCheckPath) || !isPathInside(root, resolved)) {
|
|
314
|
+
throw new Error(`Path escapes workspace root: ${inputPath}`);
|
|
315
|
+
}
|
|
316
|
+
return resolved;
|
|
317
|
+
}
|
|
318
|
+
export class Agent {
|
|
319
|
+
mcpClient;
|
|
320
|
+
llmConfig;
|
|
321
|
+
sessionKey;
|
|
322
|
+
workspaceRoot;
|
|
323
|
+
launchCwd;
|
|
324
|
+
chatHistory = [];
|
|
325
|
+
initialized = false;
|
|
326
|
+
recalledRecordIds = [];
|
|
327
|
+
recalledRecords = [];
|
|
328
|
+
lastBriefingSources = [];
|
|
329
|
+
roleOverlay;
|
|
330
|
+
accessMode;
|
|
331
|
+
silent;
|
|
332
|
+
enableRecall;
|
|
333
|
+
systemPromptOverride;
|
|
334
|
+
/**
|
|
335
|
+
* Name of the BrainRouter skill currently being executed (e.g. via `/skill`
|
|
336
|
+
* or implicit memetic activation). Threaded into `memory_recall` and
|
|
337
|
+
* `memory_capture_turn` so skill-scoped recall boost, neural-spark
|
|
338
|
+
* prewarming, and per-record `skill_tag` extraction all fire correctly.
|
|
339
|
+
* Null/undefined when no skill is active.
|
|
340
|
+
*/
|
|
341
|
+
activeSkill;
|
|
342
|
+
/**
|
|
343
|
+
* Parent trace context (set by spawn_agent for child agents). When present,
|
|
344
|
+
* the per-turn span uses these as its trace/parent so OTEL viewers can
|
|
345
|
+
* stitch the fan-out tree together. Top-level (REPL) agents leave these
|
|
346
|
+
* undefined and get a fresh trace per turn.
|
|
347
|
+
*/
|
|
348
|
+
parentTraceId;
|
|
349
|
+
parentSpanId;
|
|
350
|
+
/**
|
|
351
|
+
* Synthetic agent id used in OTEL attributes so child spans can be grouped
|
|
352
|
+
* even without trace links. Equals `agent-<6 random hex>` per Agent
|
|
353
|
+
* instance. Surfaced as the `agent_id` / `parent_agent_id` span attrs.
|
|
354
|
+
*/
|
|
355
|
+
agentId = `agent-${Math.random().toString(36).slice(2, 8)}`;
|
|
356
|
+
/** agent_id of the parent (set by spawn_agent for children). */
|
|
357
|
+
parentAgentId;
|
|
358
|
+
constructor(mcpClient, llmConfig, options) {
|
|
359
|
+
this.mcpClient = mcpClient;
|
|
360
|
+
this.llmConfig = llmConfig;
|
|
361
|
+
this.workspaceRoot = options.workspaceRoot;
|
|
362
|
+
this.launchCwd = options.launchCwd;
|
|
363
|
+
this.sessionKey = options.sessionKey ?? `brainrouter-cli:${this.workspaceRoot}`;
|
|
364
|
+
this.roleOverlay = options.roleOverlay;
|
|
365
|
+
this.accessMode = options.accessMode ?? 'shell';
|
|
366
|
+
this.silent = options.silent ?? false;
|
|
367
|
+
// Children default to no recall (their seed context already covers the parent's recall).
|
|
368
|
+
// Parents (non-silent) always recall.
|
|
369
|
+
this.enableRecall = options.enableRecall ?? !this.silent;
|
|
370
|
+
this.systemPromptOverride = options.systemPromptOverride;
|
|
371
|
+
this.parentTraceId = options.parentTraceId;
|
|
372
|
+
this.parentSpanId = options.parentSpanId;
|
|
373
|
+
}
|
|
374
|
+
/** Expose for orchestration so spawn_agent can record the parent linkage. */
|
|
375
|
+
getAgentId() {
|
|
376
|
+
return this.agentId;
|
|
377
|
+
}
|
|
378
|
+
/** Internal — used by spawn_agent to record which parent dispatched us. */
|
|
379
|
+
setParentAgentId(id) {
|
|
380
|
+
this.parentAgentId = id;
|
|
381
|
+
}
|
|
382
|
+
allowedToolsForAccess() {
|
|
383
|
+
// Lifecycle / inspection tools are always available regardless of access
|
|
384
|
+
// mode — they don't touch the workspace and the agent needs them to end
|
|
385
|
+
// a goal cleanly (goal_complete / goal_blocked) or observe state.
|
|
386
|
+
const readOnly = new Set([
|
|
387
|
+
'read_file', 'list_dir', 'grep_search', 'glob_files', 'fetch_url', 'web_search', 'update_plan',
|
|
388
|
+
'spawn_agent', 'spawn_agents', 'list_agents', 'wait_agent', 'wait_agents',
|
|
389
|
+
'read_agent_transcript', 'close_agent', 'route_agent',
|
|
390
|
+
'goal_complete', 'goal_blocked',
|
|
391
|
+
]);
|
|
392
|
+
const writeAdds = new Set(['write_file', 'edit_file', 'apply_patch']);
|
|
393
|
+
const shellAdds = new Set(['run_command']);
|
|
394
|
+
if (this.accessMode === 'read')
|
|
395
|
+
return readOnly;
|
|
396
|
+
if (this.accessMode === 'write')
|
|
397
|
+
return new Set([...readOnly, ...writeAdds]);
|
|
398
|
+
return new Set([...readOnly, ...writeAdds, ...shellAdds]);
|
|
399
|
+
}
|
|
400
|
+
async runTurn(prompt, callbacks) {
|
|
401
|
+
if (!this.initialized) {
|
|
402
|
+
await this.bootstrapSession(callbacks);
|
|
403
|
+
}
|
|
404
|
+
this.lastTurnUsage = { promptTokens: 0, completionTokens: 0, calls: 0 };
|
|
405
|
+
this.lastTurnToolCalls = 0;
|
|
406
|
+
this.lastGoalTransition = undefined;
|
|
407
|
+
// OTEL-style span: one trace per turn, tool calls become child spans.
|
|
408
|
+
// When this Agent was spawned as a child, inherit the parent's traceId
|
|
409
|
+
// + spanId so fan-out runs stitch into one tree across processes (or
|
|
410
|
+
// promises). Top-level REPL agents get a fresh trace per turn.
|
|
411
|
+
const turnSpan = startSpan('brainrouter.turn', {
|
|
412
|
+
session_key: this.sessionKey,
|
|
413
|
+
access_mode: this.accessMode,
|
|
414
|
+
model: this.llmConfig.model,
|
|
415
|
+
role_overlay: this.roleOverlay ? 'set' : 'none',
|
|
416
|
+
agent_id: this.agentId,
|
|
417
|
+
parent_agent_id: this.parentAgentId,
|
|
418
|
+
}, {
|
|
419
|
+
traceId: this.parentTraceId,
|
|
420
|
+
parentSpanId: this.parentSpanId,
|
|
421
|
+
});
|
|
422
|
+
callbacks.onStatusUpdate('Loading available tools...');
|
|
423
|
+
let mcpTools = [];
|
|
424
|
+
try {
|
|
425
|
+
const toolsRes = await this.mcpClient.listTools();
|
|
426
|
+
mcpTools = toolsRes.tools || [];
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
// Non-fatal: continue with local tools only
|
|
430
|
+
}
|
|
431
|
+
const allowed = this.allowedToolsForAccess();
|
|
432
|
+
const filteredLocalTools = LOCAL_TOOLS.filter(t => allowed.has(t.name));
|
|
433
|
+
// Hide MCP tools we already call automatically. Small models otherwise
|
|
434
|
+
// try to invoke them with the wrong arguments (most commonly
|
|
435
|
+
// memory_capture_turn — "Required, Required" comes from missing
|
|
436
|
+
// sessionKey + messages). These tools are still callable; the CLI just
|
|
437
|
+
// doesn't tell the LLM about them since the auto-pipeline owns them.
|
|
438
|
+
const HIDDEN_FROM_LLM = new Set([
|
|
439
|
+
'memory_capture_turn', // called automatically post-turn
|
|
440
|
+
'memory_mark_cited', // called automatically with real citation IDs
|
|
441
|
+
'memory_resolve_session', // called automatically at bootstrap
|
|
442
|
+
'memory_register_skill_hints', // boot-time, not turn-level
|
|
443
|
+
'memory_hook_register', // managed via /hooks
|
|
444
|
+
'memory_hook_status',
|
|
445
|
+
]);
|
|
446
|
+
const visibleMcpTools = mcpTools.filter((t) => !HIDDEN_FROM_LLM.has(t.name));
|
|
447
|
+
const allTools = [...filteredLocalTools, ...visibleMcpTools];
|
|
448
|
+
callbacks.onStatusUpdate(`Loaded ${filteredLocalTools.length} local tools and ${mcpTools.length} MCP tools.`);
|
|
449
|
+
// Auto-compact: if the chat history has grown past the configured token
|
|
450
|
+
// budget, summarize before this turn starts. Otherwise the model sees
|
|
451
|
+
// ever-growing context (briefings, tool outputs, prior turns) and the
|
|
452
|
+
// request balloons until the endpoint rejects it. Default threshold is
|
|
453
|
+
// generous; users can lower BRAINROUTER_AUTO_COMPACT_TOKENS to ~30000
|
|
454
|
+
// for cost-sensitive models.
|
|
455
|
+
if (!this.silent) {
|
|
456
|
+
const autoCompactThreshold = Number(process.env.BRAINROUTER_AUTO_COMPACT_TOKENS) || 80_000;
|
|
457
|
+
const estimated = Agent.estimateTokens(JSON.stringify(this.chatHistory));
|
|
458
|
+
if (estimated > autoCompactThreshold && this.chatHistory.length > 6) {
|
|
459
|
+
callbacks.onStatusUpdate(`Auto-compacting history (~${estimated} tokens > ${autoCompactThreshold})...`);
|
|
460
|
+
try {
|
|
461
|
+
await this.compactHistory();
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// If compaction fails (no LLM, network), continue without it — better
|
|
465
|
+
// a big payload than a hard turn failure.
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
await this.injectRecallContext(prompt, mcpTools, callbacks);
|
|
470
|
+
// Lifecycle: pre-turn hook (informational; failures don't abort the turn).
|
|
471
|
+
if (!this.silent)
|
|
472
|
+
runHooks(this.workspaceRoot, 'pre-turn', { payload: { prompt } });
|
|
473
|
+
this.lastUserPrompt = prompt;
|
|
474
|
+
this.lastTurnHitLoopLimit = false;
|
|
475
|
+
// Breadth-intent detection: when the user signals "do everything" / "in 1 go"
|
|
476
|
+
// / "thoroughly" / "as much as possible", inject a fan-out hint so the
|
|
477
|
+
// agent reaches for spawn_agents instead of a single sequential tool call.
|
|
478
|
+
// Skipped for child agents (silent) — they've already been narrowed by
|
|
479
|
+
// their parent.
|
|
480
|
+
if (!this.silent) {
|
|
481
|
+
const { suggest, intent } = shouldSuggestFanOut(prompt);
|
|
482
|
+
if (suggest) {
|
|
483
|
+
this.replaceTaggedSystemMessage('fanout-hint', buildFanOutHint(prompt, intent));
|
|
484
|
+
callbacks.onStatusUpdate(`Fan-out hint injected (signals: ${intent.signals.join(', ')})`);
|
|
485
|
+
// Mirror onMemoryEvent's shape so REPL has one render path — but use
|
|
486
|
+
// onToolStart since it goes through the safePrint pipeline that the
|
|
487
|
+
// user already sees. Tag as a virtual tool so it's obvious.
|
|
488
|
+
callbacks.onToolStart('breadth-detector', { signals: intent.signals, score: intent.score });
|
|
489
|
+
callbacks.onToolEnd('breadth-detector', { success: true, summary: `fan-out hint injected (${intent.signals.length} signals)` });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const userMsg = { role: 'user', content: prompt };
|
|
493
|
+
this.chatHistory.push(userMsg);
|
|
494
|
+
this.recordTranscript(userMsg);
|
|
495
|
+
let loopCount = 0;
|
|
496
|
+
// Multi-agent workflows (explorers → wait → architect → wait → write spec
|
|
497
|
+
// → write tasks) can easily eat 10-15 iterations. 20 was too tight and
|
|
498
|
+
// caused workflows to abort mid-architect. Cap defaults to 60 and is
|
|
499
|
+
// overridable via BRAINROUTER_MAX_TOOL_LOOPS for very heavy workflows.
|
|
500
|
+
const maxLoops = Math.max(5, Number(process.env.BRAINROUTER_MAX_TOOL_LOOPS) || 60);
|
|
501
|
+
let finalAnswer = '';
|
|
502
|
+
// Tracks whether we exited the loop because the LLM stopped requesting
|
|
503
|
+
// tools (clean break) vs because we hit maxLoops. Critical: an empty
|
|
504
|
+
// `finalAnswer === ''` from a clean break is NOT a loop-limit timeout.
|
|
505
|
+
let exitedCleanly = false;
|
|
506
|
+
// Repeat-loop guard: when the model calls the same tool with identical
|
|
507
|
+
// args over and over, the result is by definition the same. Track recent
|
|
508
|
+
// signatures so we can interrupt the loop with corrective feedback.
|
|
509
|
+
const recentToolSignatures = [];
|
|
510
|
+
const REPEAT_GUARD_LIMIT = 3;
|
|
511
|
+
while (loopCount < maxLoops) {
|
|
512
|
+
loopCount++;
|
|
513
|
+
callbacks.onStatusUpdate(`Thinking (turn ${loopCount})...`);
|
|
514
|
+
let response;
|
|
515
|
+
try {
|
|
516
|
+
response = await callOpenAI(this.llmConfig, this.chatHistory, allTools);
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
throw new Error(`LLM Execution failed: ${err.message}`);
|
|
520
|
+
}
|
|
521
|
+
if (response.usage) {
|
|
522
|
+
this.lastTurnUsage.promptTokens += response.usage.prompt_tokens ?? 0;
|
|
523
|
+
this.lastTurnUsage.completionTokens += response.usage.completion_tokens ?? 0;
|
|
524
|
+
this.lastTurnUsage.calls += 1;
|
|
525
|
+
}
|
|
526
|
+
// Record Assistant message
|
|
527
|
+
const assistantMsg = { role: 'assistant', content: response.content };
|
|
528
|
+
if (response.toolCalls) {
|
|
529
|
+
assistantMsg.tool_calls = response.toolCalls;
|
|
530
|
+
}
|
|
531
|
+
this.chatHistory.push(assistantMsg);
|
|
532
|
+
this.recordTranscript(assistantMsg);
|
|
533
|
+
if (!response.toolCalls || response.toolCalls.length === 0) {
|
|
534
|
+
finalAnswer = response.content;
|
|
535
|
+
exitedCleanly = true;
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
// Execute tool calls chosen by the LLM
|
|
539
|
+
for (const tc of response.toolCalls) {
|
|
540
|
+
this.lastTurnToolCalls += 1;
|
|
541
|
+
// Normalize the tool name against both local and MCP candidates so
|
|
542
|
+
// common LLM hallucinations like `Read_File` / `read-file` resolve
|
|
543
|
+
// to `read_file` instead of falling through to `-32601 Unknown tool`.
|
|
544
|
+
const rawName = tc.function.name;
|
|
545
|
+
const candidates = [
|
|
546
|
+
...LOCAL_TOOLS.map((lt) => lt.name),
|
|
547
|
+
...mcpTools.map((t) => t.name).filter((n) => typeof n === 'string'),
|
|
548
|
+
];
|
|
549
|
+
const name = normalizeToolName(rawName, candidates);
|
|
550
|
+
// Parse JSON args. If the LLM produced malformed JSON, surface that
|
|
551
|
+
// explicitly via the tool result so it can self-correct on the next
|
|
552
|
+
// turn — the old fallback silently set args={} and the LLM had no
|
|
553
|
+
// signal that anything was wrong.
|
|
554
|
+
let args = {};
|
|
555
|
+
let argParseError;
|
|
556
|
+
try {
|
|
557
|
+
args = typeof tc.function.arguments === 'string'
|
|
558
|
+
? JSON.parse(tc.function.arguments)
|
|
559
|
+
: tc.function.arguments;
|
|
560
|
+
}
|
|
561
|
+
catch (e) {
|
|
562
|
+
argParseError = `Tool argument JSON was malformed: ${e.message}. Re-issue the tool call with valid JSON arguments.`;
|
|
563
|
+
}
|
|
564
|
+
const isLocal = LOCAL_TOOLS.some(lt => lt.name === name);
|
|
565
|
+
callbacks.onToolStart(name, args);
|
|
566
|
+
let resultText = '';
|
|
567
|
+
let isError = false;
|
|
568
|
+
let summary = '';
|
|
569
|
+
// If the LLM emitted malformed JSON for arguments, fail the tool call
|
|
570
|
+
// up-front with a clear error so it can self-correct next turn.
|
|
571
|
+
if (argParseError) {
|
|
572
|
+
isError = true;
|
|
573
|
+
resultText = argParseError;
|
|
574
|
+
summary = 'malformed JSON args';
|
|
575
|
+
callbacks.onToolEnd(name, { success: false, summary });
|
|
576
|
+
traceEvent('brainrouter.tool', { tool: name, ok: false, local: isLocal, session_key: this.sessionKey, guard: 'bad_args' }, { traceId: turnSpan.traceId, parentSpanId: turnSpan.spanId });
|
|
577
|
+
const toolMsg = { role: 'tool', tool_call_id: tc.id, name, content: resultText, isError };
|
|
578
|
+
this.chatHistory.push(toolMsg);
|
|
579
|
+
this.recordTranscript(toolMsg);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
// Repeat-loop guard: if the model has already issued this exact
|
|
583
|
+
// (name, args) call REPEAT_GUARD_LIMIT times in this turn, short-
|
|
584
|
+
// circuit with corrective feedback instead of executing again.
|
|
585
|
+
const signature = `${name}::${(() => { try {
|
|
586
|
+
return JSON.stringify(args);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return String(args);
|
|
590
|
+
} })()}`;
|
|
591
|
+
const repeatCount = recentToolSignatures.filter((s) => s === signature).length;
|
|
592
|
+
if (repeatCount >= REPEAT_GUARD_LIMIT) {
|
|
593
|
+
isError = true;
|
|
594
|
+
resultText = [
|
|
595
|
+
`Repeat-loop guard tripped: \`${name}\` has been called ${repeatCount + 1} times with identical args this turn.`,
|
|
596
|
+
`The result hasn't changed and won't change on another call.`,
|
|
597
|
+
'Pick a different action: read a different file, write the output you have, spawn a worker child, or call `goal_blocked` if no further path remains.',
|
|
598
|
+
].join(' ');
|
|
599
|
+
summary = `repeat guard tripped (${repeatCount + 1}× ${name})`;
|
|
600
|
+
callbacks.onToolEnd(name, { success: false, summary });
|
|
601
|
+
traceEvent('brainrouter.tool', { tool: name, ok: false, local: isLocal, session_key: this.sessionKey, guard: 'repeat' }, { traceId: turnSpan.traceId, parentSpanId: turnSpan.spanId });
|
|
602
|
+
const toolMsg = { role: 'tool', tool_call_id: tc.id, name, content: resultText, isError };
|
|
603
|
+
this.chatHistory.push(toolMsg);
|
|
604
|
+
this.recordTranscript(toolMsg);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
recentToolSignatures.push(signature);
|
|
608
|
+
// Keep the window small so the guard only blocks tight loops, not
|
|
609
|
+
// legitimate revisits separated by other tool calls.
|
|
610
|
+
if (recentToolSignatures.length > 12)
|
|
611
|
+
recentToolSignatures.shift();
|
|
612
|
+
// Lifecycle: pre-tool hook. Non-zero exit blocks the tool call.
|
|
613
|
+
let blockedByHook;
|
|
614
|
+
const hookifyWarnings = [];
|
|
615
|
+
if (!this.silent) {
|
|
616
|
+
const preResults = runHooks(this.workspaceRoot, 'pre-tool', { tool: name, payload: args });
|
|
617
|
+
const denial = preResults.find((r) => r.exitCode !== 0);
|
|
618
|
+
if (denial) {
|
|
619
|
+
blockedByHook = (denial.stderr || denial.stdout || '').toString().trim() || `Hook ${denial.hook.id} denied tool call (exit ${denial.exitCode})`;
|
|
620
|
+
}
|
|
621
|
+
// Hookify markdown rules: warn/block matching by event + pattern.
|
|
622
|
+
const rules = listHookifyRules(this.workspaceRoot);
|
|
623
|
+
if (rules.length > 0) {
|
|
624
|
+
const ctx = buildHookifyContext(name, args);
|
|
625
|
+
const matches = evaluateHookify(rules, ctx);
|
|
626
|
+
for (const m of matches) {
|
|
627
|
+
if (m.action === 'block') {
|
|
628
|
+
blockedByHook = `Hookify rule "${m.rule.name}" blocked this ${ctx.event} operation: ${m.rule.message.slice(0, 240)}`;
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
hookifyWarnings.push(`⚠️ ${m.rule.name}: ${m.rule.message.slice(0, 200)}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
if (blockedByHook) {
|
|
637
|
+
throw new Error(`Blocked by pre-tool hook: ${blockedByHook}`);
|
|
638
|
+
}
|
|
639
|
+
if (!allowed.has(name) && isLocal) {
|
|
640
|
+
throw new Error(`Tool "${name}" is not permitted in access mode "${this.accessMode}".`);
|
|
641
|
+
}
|
|
642
|
+
if (isOrchestrationToolName(name)) {
|
|
643
|
+
resultText = await executeOrchestrationTool(name, args, {
|
|
644
|
+
workspaceRoot: this.workspaceRoot,
|
|
645
|
+
parentSessionKey: this.sessionKey,
|
|
646
|
+
parentAccessMode: this.accessMode,
|
|
647
|
+
// Thread the parent's trace context so child agents nest their
|
|
648
|
+
// per-turn spans under THIS turn instead of starting a fresh
|
|
649
|
+
// trace tree. Lets observability backends reconstruct fan-out.
|
|
650
|
+
parentTraceId: turnSpan.traceId,
|
|
651
|
+
parentSpanId: turnSpan.spanId,
|
|
652
|
+
parentAgentId: this.agentId,
|
|
653
|
+
mcpClient: this.mcpClient,
|
|
654
|
+
llmConfig: this.llmConfig,
|
|
655
|
+
launchCwd: this.launchCwd,
|
|
656
|
+
recordOffload: (chars) => { this.memoryMetrics.offloadCharsAvoided += chars; },
|
|
657
|
+
onChildToolEvent: (event) => {
|
|
658
|
+
// Surface to the REPL via the same onToolStart channel so the
|
|
659
|
+
// user sees child activity live, prefixed with the child id.
|
|
660
|
+
callbacks.onToolStart(`${event.role}:${event.childId} → ${event.tool}`, { ok: event.ok, summary: event.summary });
|
|
661
|
+
},
|
|
662
|
+
onChildComplete: (event) => {
|
|
663
|
+
callbacks.onChildComplete?.(event);
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
summary = getToolSummary(name, args, resultText);
|
|
667
|
+
}
|
|
668
|
+
else if (isLocal) {
|
|
669
|
+
resultText = await this.executeLocalTool(name, args);
|
|
670
|
+
summary = getToolSummary(name, args, resultText);
|
|
671
|
+
// Plan-ticker: surface update_plan changes to the REPL so the user
|
|
672
|
+
// sees the live ✓/⏳/☐ checklist instead of having to run /plan.
|
|
673
|
+
if (name === 'update_plan' && Array.isArray(args.plan) && callbacks.onPlanUpdate) {
|
|
674
|
+
callbacks.onPlanUpdate(args.plan, args.explanation);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
const mcpRes = await this.mcpClient.callTool(name, args);
|
|
679
|
+
if (mcpRes.isError) {
|
|
680
|
+
isError = true;
|
|
681
|
+
}
|
|
682
|
+
resultText = extractToolText(mcpRes);
|
|
683
|
+
summary = `MCP: ${resultText.length} chars returned`;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
catch (err) {
|
|
687
|
+
isError = true;
|
|
688
|
+
const message = err?.message ?? String(err);
|
|
689
|
+
// -32601 is JSON-RPC's MethodNotFound. We hit it most often when
|
|
690
|
+
// the LLM hallucinates a tool name — typically a skill name
|
|
691
|
+
// ("incremental-implementation", "spec-driven", "...-skill") that
|
|
692
|
+
// it has confused for an invocable tool. Surface a correction so
|
|
693
|
+
// the next iteration self-corrects instead of retrying garbage.
|
|
694
|
+
if (/-32601|Unknown tool|MethodNotFound/i.test(message)) {
|
|
695
|
+
const hint = explainUnknownToolName(name);
|
|
696
|
+
resultText = `Tool "${name}" does not exist. ${hint}\nUnderlying error: ${message}`;
|
|
697
|
+
summary = `unknown tool — ${hint.slice(0, 120)}`;
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
resultText = `Tool execution failed: ${message}`;
|
|
701
|
+
summary = message;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
const finalSummary = hookifyWarnings.length > 0 ? `${summary} | ${hookifyWarnings.join(' | ')}` : summary;
|
|
705
|
+
// Inspection tools (list_dir, grep_search, glob_files) commonly fail to
|
|
706
|
+
// surface anything when the LLM gets lazy and replies with a stub like
|
|
707
|
+
// "I have listed the directory" instead of echoing the contents. Compute
|
|
708
|
+
// a short preview from the raw result so the REPL can show the user
|
|
709
|
+
// SOMETHING even when the model declines to.
|
|
710
|
+
const preview = !isError ? getToolPreview(name, args, resultText) : undefined;
|
|
711
|
+
callbacks.onToolEnd(name, { success: !isError, summary: finalSummary, preview });
|
|
712
|
+
traceEvent('brainrouter.tool', {
|
|
713
|
+
tool: name,
|
|
714
|
+
ok: !isError,
|
|
715
|
+
local: isLocal,
|
|
716
|
+
session_key: this.sessionKey,
|
|
717
|
+
}, { traceId: turnSpan.traceId, parentSpanId: turnSpan.spanId });
|
|
718
|
+
if (!this.silent) {
|
|
719
|
+
runHooks(this.workspaceRoot, 'post-tool', {
|
|
720
|
+
tool: name,
|
|
721
|
+
payload: { args, ok: !isError, summary, resultPreview: resultText.slice(0, 1000) },
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
// Tool-result clamp: huge MCP payloads (memory_recall, spawn_agent
|
|
725
|
+
// outputs, big greps, file dumps) used to be re-sent to the LLM
|
|
726
|
+
// verbatim every subsequent turn, which blew the context window in
|
|
727
|
+
// long sessions. Clamp at ~8 KB per result for the LLM-visible copy
|
|
728
|
+
// while keeping the full text on disk via recordTranscript.
|
|
729
|
+
const MAX_TOOL_RESULT_CHARS = Number(process.env.BRAINROUTER_MAX_TOOL_RESULT_CHARS) || 8000;
|
|
730
|
+
const clampedContent = resultText.length > MAX_TOOL_RESULT_CHARS
|
|
731
|
+
? resultText.slice(0, MAX_TOOL_RESULT_CHARS) +
|
|
732
|
+
`\n…[truncated ${resultText.length - MAX_TOOL_RESULT_CHARS} chars — full output recorded in transcript; call memory_working_offload or re-read with a narrower scope]`
|
|
733
|
+
: resultText;
|
|
734
|
+
const toolMsg = {
|
|
735
|
+
role: 'tool',
|
|
736
|
+
tool_call_id: tc.id,
|
|
737
|
+
name: name,
|
|
738
|
+
content: clampedContent,
|
|
739
|
+
isError
|
|
740
|
+
};
|
|
741
|
+
this.chatHistory.push(toolMsg);
|
|
742
|
+
// Record the FULL untruncated result so /transcript shows everything,
|
|
743
|
+
// even when the LLM-facing copy was clamped.
|
|
744
|
+
this.recordTranscript({ ...toolMsg, content: resultText });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// Normalize the final answer FIRST so every exit path (loop limit, empty
|
|
748
|
+
// commentary after tool calls, normal) feeds the same non-empty string
|
|
749
|
+
// into both lastAnswer and captureTurn. Previously this happened AFTER
|
|
750
|
+
// captureTurn, which meant memory capture + citation feedback silently
|
|
751
|
+
// skipped every turn that hit the loop limit or returned no prose.
|
|
752
|
+
if (!exitedCleanly) {
|
|
753
|
+
this.lastTurnHitLoopLimit = true;
|
|
754
|
+
finalAnswer =
|
|
755
|
+
`I could not finish before the tool-call loop limit of ${maxLoops} was reached. ` +
|
|
756
|
+
`Use \`/continue\` to pick up where I left off (drain pending children, finish writing artifacts), ` +
|
|
757
|
+
`\`/agents\` to see what's running, or set BRAINROUTER_MAX_TOOL_LOOPS to a higher number.`;
|
|
758
|
+
}
|
|
759
|
+
else if (!finalAnswer.trim()) {
|
|
760
|
+
if (this.lastGoalTransition && this.lastTurnToolCalls > 0) {
|
|
761
|
+
// The model fired goal_complete / goal_blocked but skipped the
|
|
762
|
+
// user-visible prose summary in the same response. Without this
|
|
763
|
+
// branch the user saw "Tool calls completed (N)..." and the proof
|
|
764
|
+
// string was buried in goal.json — invisible to them. Surface the
|
|
765
|
+
// proof/reason directly so the work isn't wasted, and warn that
|
|
766
|
+
// the model should have written a real answer.
|
|
767
|
+
const goal = readGoal(this.workspaceRoot, this.sessionKey);
|
|
768
|
+
const evidence = goal?.blockedReason?.trim() || '(no detail recorded)';
|
|
769
|
+
const action = this.lastGoalTransition === 'complete' ? 'completed' : 'blocked';
|
|
770
|
+
const field = this.lastGoalTransition === 'complete' ? 'proof' : 'reason';
|
|
771
|
+
finalAnswer =
|
|
772
|
+
`Goal ${action} after ${this.lastTurnToolCalls} tool call${this.lastTurnToolCalls === 1 ? '' : 's'}, ` +
|
|
773
|
+
`but the model skipped writing a user-visible answer in this turn.\n\n` +
|
|
774
|
+
`Recorded ${field}:\n${evidence}\n\n` +
|
|
775
|
+
`(If you wanted a full analysis/report, ask "summarize what you just analyzed" — the work is in memory.)`;
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
finalAnswer = this.lastTurnToolCalls > 0
|
|
779
|
+
? `Tool calls completed (${this.lastTurnToolCalls}) and the model returned no additional commentary.`
|
|
780
|
+
: 'The model returned an empty response.';
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
this.lastAnswer = finalAnswer;
|
|
784
|
+
await this.captureTurn(prompt, finalAnswer, callbacks);
|
|
785
|
+
if (!this.silent) {
|
|
786
|
+
runHooks(this.workspaceRoot, 'post-turn', {
|
|
787
|
+
payload: { prompt, answerPreview: finalAnswer.slice(0, 1000), tokens: this.lastTurnUsage },
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
turnSpan.end({
|
|
791
|
+
outcome: exitedCleanly ? 'ok' : 'loop_limit',
|
|
792
|
+
loops_used: loopCount,
|
|
793
|
+
tokens_in: this.lastTurnUsage.promptTokens,
|
|
794
|
+
tokens_out: this.lastTurnUsage.completionTokens,
|
|
795
|
+
});
|
|
796
|
+
if (!exitedCleanly) {
|
|
797
|
+
// Same string as finalAnswer above; preserve the historical early-return
|
|
798
|
+
// shape so callers that switch on the loop-limit branch keep working.
|
|
799
|
+
return finalAnswer;
|
|
800
|
+
}
|
|
801
|
+
this.sessionUsage.promptTokens += this.lastTurnUsage.promptTokens;
|
|
802
|
+
this.sessionUsage.completionTokens += this.lastTurnUsage.completionTokens;
|
|
803
|
+
this.sessionUsage.calls += this.lastTurnUsage.calls;
|
|
804
|
+
this.sessionUsage.turns += 1;
|
|
805
|
+
return finalAnswer;
|
|
806
|
+
}
|
|
807
|
+
/** Rough token estimate (1 token ≈ 4 characters of English / code). */
|
|
808
|
+
static estimateTokens(text) {
|
|
809
|
+
return Math.ceil(text.length / 4);
|
|
810
|
+
}
|
|
811
|
+
async executeLocalTool(name, args) {
|
|
812
|
+
// Bind path resolution to this agent's workspace, never to process.cwd().
|
|
813
|
+
// The Agent might have been constructed with a workspace different from
|
|
814
|
+
// the launching shell's cwd (e.g. /resume from another dir), and cwd can
|
|
815
|
+
// drift in unexpected ways. Explicit beats implicit here.
|
|
816
|
+
const resolveHere = (p, opts = {}) => resolveWorkspacePath(this.workspaceRoot, p, opts);
|
|
817
|
+
switch (name) {
|
|
818
|
+
case 'read_file': {
|
|
819
|
+
const resolved = resolveHere(args.path);
|
|
820
|
+
if (!fs.existsSync(resolved)) {
|
|
821
|
+
throw new Error(`File not found: ${args.path}`);
|
|
822
|
+
}
|
|
823
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
824
|
+
const startLine = args.startLine ? Number(args.startLine) : 1;
|
|
825
|
+
const endLine = args.endLine ? Number(args.endLine) : undefined;
|
|
826
|
+
if (startLine === 1 && endLine === undefined) {
|
|
827
|
+
return content;
|
|
828
|
+
}
|
|
829
|
+
const lines = content.split('\n');
|
|
830
|
+
const endIdx = endLine !== undefined ? Math.min(endLine, lines.length) : lines.length;
|
|
831
|
+
const startIdx = Math.max(1, Math.min(startLine, lines.length));
|
|
832
|
+
if (startIdx > endIdx) {
|
|
833
|
+
return '';
|
|
834
|
+
}
|
|
835
|
+
return lines.slice(startIdx - 1, endIdx).join('\n');
|
|
836
|
+
}
|
|
837
|
+
case 'write_file': {
|
|
838
|
+
const resolved = resolveHere(args.path, { forWrite: true });
|
|
839
|
+
const dir = path.dirname(resolved);
|
|
840
|
+
if (!fs.existsSync(dir)) {
|
|
841
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
842
|
+
}
|
|
843
|
+
fs.writeFileSync(resolved, args.content, 'utf8');
|
|
844
|
+
return `Successfully wrote file: ${args.path}`;
|
|
845
|
+
}
|
|
846
|
+
case 'edit_file': {
|
|
847
|
+
const resolved = resolveHere(args.path);
|
|
848
|
+
if (!fs.existsSync(resolved)) {
|
|
849
|
+
throw new Error(`File not found: ${args.path}`);
|
|
850
|
+
}
|
|
851
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
852
|
+
const target = args.targetContent;
|
|
853
|
+
const replacement = args.replacementContent;
|
|
854
|
+
const occurrences = content.split(target).length - 1;
|
|
855
|
+
if (occurrences === 0) {
|
|
856
|
+
throw new Error(`Target content not found in ${args.path}. Ensure targetContent matches exact indentation and newlines.`);
|
|
857
|
+
}
|
|
858
|
+
if (occurrences > 1) {
|
|
859
|
+
throw new Error(`Target content found ${occurrences} times in ${args.path}. Specify more surrounding context to target uniquely.`);
|
|
860
|
+
}
|
|
861
|
+
const updated = content.replace(target, replacement);
|
|
862
|
+
fs.writeFileSync(resolved, updated, 'utf8');
|
|
863
|
+
return `Successfully edited ${args.path}`;
|
|
864
|
+
}
|
|
865
|
+
case 'list_dir': {
|
|
866
|
+
const targetDir = resolveHere(args.path || '.');
|
|
867
|
+
if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
|
|
868
|
+
throw new Error(`Directory not found: ${args.path || '.'}`);
|
|
869
|
+
}
|
|
870
|
+
const items = fs.readdirSync(targetDir);
|
|
871
|
+
const list = items.map(item => {
|
|
872
|
+
const full = path.join(targetDir, item);
|
|
873
|
+
const stat = fs.statSync(full);
|
|
874
|
+
return {
|
|
875
|
+
name: item,
|
|
876
|
+
type: stat.isDirectory() ? 'directory' : 'file',
|
|
877
|
+
size: stat.isFile() ? stat.size : undefined
|
|
878
|
+
};
|
|
879
|
+
});
|
|
880
|
+
return JSON.stringify(list, null, 2);
|
|
881
|
+
}
|
|
882
|
+
case 'grep_search': {
|
|
883
|
+
const wsRoot = fs.realpathSync(this.workspaceRoot);
|
|
884
|
+
const root = resolveHere(args.path || '.');
|
|
885
|
+
const results = [];
|
|
886
|
+
const search = (dir) => {
|
|
887
|
+
if (results.length >= 50)
|
|
888
|
+
return;
|
|
889
|
+
const files = fs.readdirSync(dir);
|
|
890
|
+
for (const file of files) {
|
|
891
|
+
if (IGNORED_DIRS.has(file))
|
|
892
|
+
continue;
|
|
893
|
+
const full = path.join(dir, file);
|
|
894
|
+
if (!isPathInside(wsRoot, fs.realpathSync(full)))
|
|
895
|
+
continue;
|
|
896
|
+
const stat = fs.statSync(full);
|
|
897
|
+
if (stat.isDirectory()) {
|
|
898
|
+
search(full);
|
|
899
|
+
}
|
|
900
|
+
else if (stat.isFile()) {
|
|
901
|
+
try {
|
|
902
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
903
|
+
const lines = content.split('\n');
|
|
904
|
+
for (let i = 0; i < lines.length; i++) {
|
|
905
|
+
if (lines[i].includes(args.query)) {
|
|
906
|
+
results.push({
|
|
907
|
+
path: path.relative(wsRoot, full),
|
|
908
|
+
line: i + 1,
|
|
909
|
+
text: lines[i].trim()
|
|
910
|
+
});
|
|
911
|
+
if (results.length >= 50)
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
// Ignore binary or unreadable files
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
search(root);
|
|
923
|
+
return JSON.stringify(results, null, 2);
|
|
924
|
+
}
|
|
925
|
+
case 'glob_files': {
|
|
926
|
+
const pattern = args.pattern;
|
|
927
|
+
if (!pattern) {
|
|
928
|
+
throw new Error('Missing parameter "pattern" for glob_files.');
|
|
929
|
+
}
|
|
930
|
+
const matches = globFiles(pattern, this.workspaceRoot);
|
|
931
|
+
return JSON.stringify(matches, null, 2);
|
|
932
|
+
}
|
|
933
|
+
case 'run_command': {
|
|
934
|
+
const cmd = args.command;
|
|
935
|
+
if (this.accessMode !== 'shell') {
|
|
936
|
+
return `Command execution denied: agent access mode is "${this.accessMode}".`;
|
|
937
|
+
}
|
|
938
|
+
// Approval gating. Two cases:
|
|
939
|
+
// • Interactive parent (this.silent === false): show y/N unless
|
|
940
|
+
// autoApproveShell is set (i.e. /yolo on).
|
|
941
|
+
// • Silent child: cannot prompt; the previous code path silently
|
|
942
|
+
// auto-approved, which let a spawn_agent({role:'verifier'}) child
|
|
943
|
+
// run arbitrary shell with no user gate — a sandbox bypass. Now
|
|
944
|
+
// refuse unless the parent has explicitly opted in via prefs.
|
|
945
|
+
const prefs = readPreferences(this.workspaceRoot);
|
|
946
|
+
if (this.silent) {
|
|
947
|
+
if (!prefs.autoApproveShell) {
|
|
948
|
+
return (`Command execution denied: silent child agents may not run shell ` +
|
|
949
|
+
`without parent opt-in. Set \`autoApproveShell\` (via /yolo on) ` +
|
|
950
|
+
`in the workspace preferences, or have a parent agent run this command.`);
|
|
951
|
+
}
|
|
952
|
+
console.log(chalk.gray(`▶ Auto-approved (silent child): ${chalk.cyan(cmd)}`));
|
|
953
|
+
}
|
|
954
|
+
else if (!prefs.autoApproveShell) {
|
|
955
|
+
// Use the parent REPL's readline interface for the y/N prompt.
|
|
956
|
+
// Spinning up an inquirer prompt opens a second readline against
|
|
957
|
+
// the same stdin and dumps a stray "line" event back into the
|
|
958
|
+
// parent rl when it exits, which used to surface as the bogus
|
|
959
|
+
// "A previous turn is still running" warning.
|
|
960
|
+
console.log(`\n${chalk.yellow('⚠️ Command execution request:')} ${chalk.cyan(cmd)}`);
|
|
961
|
+
const approved = await askYesNo('Allow execution? (y/N) ', false);
|
|
962
|
+
if (!approved) {
|
|
963
|
+
return 'Command execution rejected by user.';
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
console.log(chalk.gray(`▶ Auto-approved: ${chalk.cyan(cmd)}`));
|
|
968
|
+
}
|
|
969
|
+
const sandboxConfig = resolveSandboxConfig(this.workspaceRoot, {
|
|
970
|
+
readPaths: prefs.sandboxReadPaths,
|
|
971
|
+
writePaths: prefs.sandboxWritePaths,
|
|
972
|
+
});
|
|
973
|
+
const result = await runShell(cmd, sandboxConfig);
|
|
974
|
+
const sandboxBadge = result.sandboxed
|
|
975
|
+
? `[sandboxed via ${result.sandboxTool}] `
|
|
976
|
+
: sandboxConfig.enabled
|
|
977
|
+
? `[sandbox requested but unavailable] `
|
|
978
|
+
: '';
|
|
979
|
+
const notice = result.notice ? `${result.notice}\n` : '';
|
|
980
|
+
return `${notice}${sandboxBadge}Exit Code: ${result.exitCode}\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`;
|
|
981
|
+
}
|
|
982
|
+
case 'fetch_url': {
|
|
983
|
+
const url = args.url;
|
|
984
|
+
try {
|
|
985
|
+
const res = await fetch(url, {
|
|
986
|
+
headers: {
|
|
987
|
+
'User-Agent': 'Mozilla/5.0 (compatible; BrainRouterCLI/0.3.4)'
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
if (!res.ok) {
|
|
991
|
+
throw new Error(`Failed to fetch URL: ${res.status} ${res.statusText}`);
|
|
992
|
+
}
|
|
993
|
+
const text = await res.text();
|
|
994
|
+
if (url.includes('.html') || text.includes('<html') || text.includes('<!DOCTYPE html')) {
|
|
995
|
+
const cleanText = text
|
|
996
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
997
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
|
998
|
+
.replace(/<[^>]+>/g, ' ')
|
|
999
|
+
.replace(/\s+/g, ' ')
|
|
1000
|
+
.trim();
|
|
1001
|
+
return cleanText.slice(0, 15000);
|
|
1002
|
+
}
|
|
1003
|
+
return text.slice(0, 15000);
|
|
1004
|
+
}
|
|
1005
|
+
catch (err) {
|
|
1006
|
+
return `Failed to fetch URL ${url}: ${err.message}`;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
case 'web_search': {
|
|
1010
|
+
const query = String(args.query ?? '').trim();
|
|
1011
|
+
if (!query)
|
|
1012
|
+
throw new Error('web_search requires a non-empty query.');
|
|
1013
|
+
const maxResults = Math.max(1, Math.min(10, Number(args.maxResults ?? 5)));
|
|
1014
|
+
return await runWebSearch(query, maxResults);
|
|
1015
|
+
}
|
|
1016
|
+
case 'apply_patch': {
|
|
1017
|
+
const patch = String(args.patch ?? '');
|
|
1018
|
+
if (!patch.trim())
|
|
1019
|
+
throw new Error('apply_patch requires a non-empty patch.');
|
|
1020
|
+
return applyPatchEnvelope(patch, this.workspaceRoot);
|
|
1021
|
+
}
|
|
1022
|
+
case 'update_plan': {
|
|
1023
|
+
const state = updatePlan(this.workspaceRoot, {
|
|
1024
|
+
explanation: args.explanation,
|
|
1025
|
+
plan: args.plan,
|
|
1026
|
+
}, this.sessionKey);
|
|
1027
|
+
return formatPlan(state);
|
|
1028
|
+
}
|
|
1029
|
+
case 'goal_complete': {
|
|
1030
|
+
const proof = String(args.proof ?? '').trim();
|
|
1031
|
+
if (!proof)
|
|
1032
|
+
throw new Error('goal_complete requires a non-empty proof.');
|
|
1033
|
+
// Plan-honesty guard: refuse to mark the goal complete while the
|
|
1034
|
+
// active plan still has pending / in_progress items. The model
|
|
1035
|
+
// built that plan as its own contract — declaring done while items
|
|
1036
|
+
// remain open is misleading (this is the exact bug the user hit
|
|
1037
|
+
// when /goal analyze fired with 3 of 4 plan items still ☐). The
|
|
1038
|
+
// model must either finish the work, explicitly mark dropped
|
|
1039
|
+
// items completed via update_plan (creating an audit trail), or
|
|
1040
|
+
// switch to goal_blocked.
|
|
1041
|
+
const plan = readPlan(this.workspaceRoot, this.sessionKey);
|
|
1042
|
+
const open = plan.items.filter((i) => i.status !== 'completed');
|
|
1043
|
+
if (open.length > 0) {
|
|
1044
|
+
const open_summary = open
|
|
1045
|
+
.map((i) => ` - [${i.status === 'in_progress' ? '⏳' : '☐'}] ${i.step}`)
|
|
1046
|
+
.join('\n');
|
|
1047
|
+
throw new Error(`goal_complete refused: the active plan still has ${open.length} incomplete item(s):\n${open_summary}\n\n` +
|
|
1048
|
+
`Do ONE of:\n` +
|
|
1049
|
+
` 1. Finish the remaining work, then call update_plan to mark those items completed.\n` +
|
|
1050
|
+
` 2. If you decided to drop them, call update_plan FIRST and mark them completed with a brief explanation (the plan is your honest record — leaving items pending while declaring done is misleading).\n` +
|
|
1051
|
+
` 3. Call goal_blocked instead if no defensible path remains.\n\n` +
|
|
1052
|
+
`Then retry goal_complete in the same response as the user-visible prose summary.`);
|
|
1053
|
+
}
|
|
1054
|
+
const goal = completeGoal(this.workspaceRoot, this.sessionKey, proof);
|
|
1055
|
+
if (!goal)
|
|
1056
|
+
return 'No active goal to complete.';
|
|
1057
|
+
this.lastGoalTransition = 'complete';
|
|
1058
|
+
return `Goal marked complete. Proof: ${proof}`;
|
|
1059
|
+
}
|
|
1060
|
+
case 'goal_blocked': {
|
|
1061
|
+
const reason = String(args.reason ?? '').trim();
|
|
1062
|
+
if (!reason)
|
|
1063
|
+
throw new Error('goal_blocked requires a non-empty reason.');
|
|
1064
|
+
const needed = String(args.needed ?? '').trim();
|
|
1065
|
+
const note = needed ? `${reason} (needed: ${needed})` : reason;
|
|
1066
|
+
const goal = blockGoal(this.workspaceRoot, this.sessionKey, note);
|
|
1067
|
+
if (!goal)
|
|
1068
|
+
return 'No active goal to block.';
|
|
1069
|
+
this.lastGoalTransition = 'blocked';
|
|
1070
|
+
return `Goal marked blocked. Reason: ${note}`;
|
|
1071
|
+
}
|
|
1072
|
+
default:
|
|
1073
|
+
throw new Error(`Unknown local tool: ${name}`);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
clearHistory() {
|
|
1077
|
+
this.chatHistory = [this.createSystemMessage()];
|
|
1078
|
+
this.initialized = true;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Compaction for /compact: summarize current chat history via the LLM,
|
|
1082
|
+
* then replace the verbose log with [system, compactedSummary,
|
|
1083
|
+
* lastUserMessage]. Returns the summary so the REPL can display it.
|
|
1084
|
+
*/
|
|
1085
|
+
async compactHistory() {
|
|
1086
|
+
if (this.chatHistory.length < 4)
|
|
1087
|
+
return null;
|
|
1088
|
+
const before = this.chatHistory.length;
|
|
1089
|
+
const userMessages = this.chatHistory.filter((m) => m.role === 'user');
|
|
1090
|
+
const lastUserMessage = userMessages.length > 0 ? String(userMessages[userMessages.length - 1].content ?? '') : undefined;
|
|
1091
|
+
const result = await runCompaction(this.llmConfig, {
|
|
1092
|
+
messages: this.chatHistory.map((m) => ({
|
|
1093
|
+
role: m.role,
|
|
1094
|
+
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''),
|
|
1095
|
+
name: m.name,
|
|
1096
|
+
})),
|
|
1097
|
+
workspaceRoot: this.workspaceRoot,
|
|
1098
|
+
lastUserMessage,
|
|
1099
|
+
});
|
|
1100
|
+
const next = [this.createSystemMessage(), { role: 'system', content: renderCompactSystemMessage(result.summary) }];
|
|
1101
|
+
if (lastUserMessage)
|
|
1102
|
+
next.push({ role: 'user', content: lastUserMessage });
|
|
1103
|
+
this.chatHistory = next;
|
|
1104
|
+
this.initialized = true;
|
|
1105
|
+
return { ...result, replacedMessages: before };
|
|
1106
|
+
}
|
|
1107
|
+
/** Runtime model switch. Used by `/model` slash command. */
|
|
1108
|
+
setModel(model) {
|
|
1109
|
+
this.llmConfig = { ...this.llmConfig, model };
|
|
1110
|
+
}
|
|
1111
|
+
getModel() {
|
|
1112
|
+
return this.llmConfig.model;
|
|
1113
|
+
}
|
|
1114
|
+
/** Runtime access-mode cycle for `/permissions` and Shift+Tab plan-mode toggle. */
|
|
1115
|
+
getAccessMode() {
|
|
1116
|
+
return this.accessMode;
|
|
1117
|
+
}
|
|
1118
|
+
setAccessMode(mode) {
|
|
1119
|
+
this.accessMode = mode;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Seed the chat history from a persisted transcript so the user can resume
|
|
1123
|
+
* a previous session. The system message is regenerated for the current
|
|
1124
|
+
* runtime so workspace/session context is fresh, but the user/assistant/tool
|
|
1125
|
+
* messages are kept verbatim.
|
|
1126
|
+
*/
|
|
1127
|
+
loadHistory(entries) {
|
|
1128
|
+
const replay = entries
|
|
1129
|
+
.filter((e) => e.role === 'user' || e.role === 'assistant' || e.role === 'tool')
|
|
1130
|
+
.map((e) => {
|
|
1131
|
+
const msg = { role: e.role, content: typeof e.content === 'string' ? e.content : JSON.stringify(e.content ?? '') };
|
|
1132
|
+
if (e.name)
|
|
1133
|
+
msg.name = e.name;
|
|
1134
|
+
if (e.tool_call_id)
|
|
1135
|
+
msg.tool_call_id = e.tool_call_id;
|
|
1136
|
+
if (e.tool_calls)
|
|
1137
|
+
msg.tool_calls = e.tool_calls;
|
|
1138
|
+
return msg;
|
|
1139
|
+
});
|
|
1140
|
+
this.chatHistory = [this.createSystemMessage(), ...replay];
|
|
1141
|
+
this.initialized = true;
|
|
1142
|
+
return replay.length;
|
|
1143
|
+
}
|
|
1144
|
+
/** Cumulative token usage across the last runTurn. Cleared at each new turn. */
|
|
1145
|
+
lastTurnUsage = { promptTokens: 0, completionTokens: 0, calls: 0 };
|
|
1146
|
+
/** Cumulative token usage across the WHOLE CLI session (all turns). */
|
|
1147
|
+
sessionUsage = { promptTokens: 0, completionTokens: 0, calls: 0, turns: 0 };
|
|
1148
|
+
/**
|
|
1149
|
+
* Memory-derived savings counters. These let `/tokens` produce a "memory
|
|
1150
|
+
* saved you ~N tokens" narrative the user can actually point at.
|
|
1151
|
+
*
|
|
1152
|
+
* - briefingTokensInjected: approx tokens added to context as memory
|
|
1153
|
+
* briefings (recall + persona + scenes + recency). Each briefing
|
|
1154
|
+
* provides cross-session context that would otherwise require re-reading
|
|
1155
|
+
* files or re-explaining via prompts.
|
|
1156
|
+
* - offloadCharsAvoided: chars of child-agent output that were pushed
|
|
1157
|
+
* to working memory instead of pasted back into parent context.
|
|
1158
|
+
* - recallRecordsConsulted: count of memory record references the
|
|
1159
|
+
* briefing put in front of the model this session.
|
|
1160
|
+
*/
|
|
1161
|
+
memoryMetrics = {
|
|
1162
|
+
briefingTokensInjected: 0,
|
|
1163
|
+
offloadCharsAvoided: 0,
|
|
1164
|
+
recallRecordsConsulted: 0,
|
|
1165
|
+
};
|
|
1166
|
+
/** Last assistant message of the most recent turn — used by `/copy`. */
|
|
1167
|
+
lastAnswer = '';
|
|
1168
|
+
/** Last user prompt (post-mention-expansion). Used by `/continue` to resume after a loop-limit abort. */
|
|
1169
|
+
lastUserPrompt = '';
|
|
1170
|
+
/** True when the most recent turn hit the loop-limit ceiling before producing a final answer. */
|
|
1171
|
+
lastTurnHitLoopLimit = false;
|
|
1172
|
+
/** Count of tool calls executed during the most recent runTurn. The goal */
|
|
1173
|
+
/** continuation loop uses this to suppress auto-continuation after prose-only turns. */
|
|
1174
|
+
lastTurnToolCalls = 0;
|
|
1175
|
+
/** Goal lifecycle transition the LLM triggered during the most recent turn, if any. */
|
|
1176
|
+
lastGoalTransition;
|
|
1177
|
+
/** Allow REPL slash commands to refresh the system prompt without bumping a new turn. */
|
|
1178
|
+
refreshSystemPrompt() {
|
|
1179
|
+
if (this.chatHistory.length > 0 && this.chatHistory[0].role === 'system') {
|
|
1180
|
+
this.chatHistory[0] = this.createSystemMessage();
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Push (or replace) a tagged system message in `chatHistory`. Per-turn
|
|
1185
|
+
* directives like the briefing block and the fan-out hint used to be pushed
|
|
1186
|
+
* unconditionally — each turn added a fresh copy without removing the prior
|
|
1187
|
+
* one, so a 10-turn conversation carried 10 stacked briefings. This helper
|
|
1188
|
+
* removes any older entry with the same tag before appending the new one,
|
|
1189
|
+
* keeping the model's view of "current memory state" current.
|
|
1190
|
+
*/
|
|
1191
|
+
replaceTaggedSystemMessage(tag, content) {
|
|
1192
|
+
const marker = `<!--brainrouter:${tag}-->\n`;
|
|
1193
|
+
this.chatHistory = this.chatHistory.filter((msg) => !(msg.role === 'system' && typeof msg.content === 'string' && msg.content.startsWith(marker)));
|
|
1194
|
+
this.chatHistory.push({ role: 'system', content: `${marker}${content}` });
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Drop any system message previously installed under `tag`. Used to retract
|
|
1198
|
+
* one-off directives once the condition that motivated them no longer
|
|
1199
|
+
* holds — e.g. the budget-steering "wrap up gracefully" message must
|
|
1200
|
+
* disappear after the user extends the goal's budget, otherwise it keeps
|
|
1201
|
+
* telling the model "this is your last turn" for every subsequent turn.
|
|
1202
|
+
*
|
|
1203
|
+
* Idempotent: calling this with a tag that isn't present is a no-op.
|
|
1204
|
+
*/
|
|
1205
|
+
removeTaggedSystemMessage(tag) {
|
|
1206
|
+
const marker = `<!--brainrouter:${tag}-->\n`;
|
|
1207
|
+
this.chatHistory = this.chatHistory.filter((msg) => !(msg.role === 'system' && typeof msg.content === 'string' && msg.content.startsWith(marker)));
|
|
1208
|
+
}
|
|
1209
|
+
/** Fork the current chat history into a fresh sessionKey. Returns the new key. */
|
|
1210
|
+
fork(newSessionKey) {
|
|
1211
|
+
this.sessionKey = newSessionKey;
|
|
1212
|
+
// Replace the system message so workspace/session context is fresh,
|
|
1213
|
+
// but keep the user/assistant/tool exchange.
|
|
1214
|
+
if (this.chatHistory.length > 0 && this.chatHistory[0].role === 'system') {
|
|
1215
|
+
this.chatHistory[0] = this.createSystemMessage();
|
|
1216
|
+
}
|
|
1217
|
+
else {
|
|
1218
|
+
this.chatHistory = [this.createSystemMessage(), ...this.chatHistory];
|
|
1219
|
+
}
|
|
1220
|
+
return this.sessionKey;
|
|
1221
|
+
}
|
|
1222
|
+
async bootstrapSession(callbacks) {
|
|
1223
|
+
if (this.silent) {
|
|
1224
|
+
this.chatHistory = [this.createSystemMessage()];
|
|
1225
|
+
this.initialized = true;
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
callbacks.onStatusUpdate('Resolving BrainRouter session...');
|
|
1229
|
+
const resolved = await callMcpTool(this.mcpClient, 'memory_resolve_session', {
|
|
1230
|
+
workspacePath: this.workspaceRoot,
|
|
1231
|
+
suggestedKey: this.sessionKey,
|
|
1232
|
+
});
|
|
1233
|
+
if (!resolved.isError && resolved.parsed?.sessionKey) {
|
|
1234
|
+
this.sessionKey = resolved.parsed.sessionKey;
|
|
1235
|
+
}
|
|
1236
|
+
// If resolution failed (missing tool, network), keep the deterministic session key we already have.
|
|
1237
|
+
this.chatHistory = [this.createSystemMessage()];
|
|
1238
|
+
this.initialized = true;
|
|
1239
|
+
}
|
|
1240
|
+
createSystemMessage() {
|
|
1241
|
+
const prefs = readPreferences(this.workspaceRoot);
|
|
1242
|
+
const base = this.systemPromptOverride ?? buildSystemPrompt({
|
|
1243
|
+
workspaceRoot: this.workspaceRoot,
|
|
1244
|
+
launchCwd: this.launchCwd,
|
|
1245
|
+
sessionKey: this.sessionKey,
|
|
1246
|
+
instructionSummary: loadWorkspaceInstructionSummary(this.workspaceRoot),
|
|
1247
|
+
personality: prefs.personality,
|
|
1248
|
+
});
|
|
1249
|
+
const parts = [base];
|
|
1250
|
+
if (this.roleOverlay)
|
|
1251
|
+
parts.push(this.roleOverlay);
|
|
1252
|
+
// Sticky goal lives on disk so it survives CLI restarts; injected here so
|
|
1253
|
+
// every turn (including the first after `/resume`) sees it. Goals are
|
|
1254
|
+
// scoped to the current sessionKey so /side and /fork don't drag their
|
|
1255
|
+
// parent's goal along, but a workspace-level legacy goal still works as a
|
|
1256
|
+
// fallback for sessions that don't have one yet.
|
|
1257
|
+
const goal = readGoal(this.workspaceRoot, this.sessionKey);
|
|
1258
|
+
if (goal?.text)
|
|
1259
|
+
parts.push(formatGoalBlock(goal));
|
|
1260
|
+
return { role: 'system', content: parts.join('\n\n') };
|
|
1261
|
+
}
|
|
1262
|
+
async injectRecallContext(prompt, mcpTools, callbacks) {
|
|
1263
|
+
if (!this.enableRecall) {
|
|
1264
|
+
this.recalledRecords = [];
|
|
1265
|
+
this.recalledRecordIds = [];
|
|
1266
|
+
this.lastBriefingSources = [];
|
|
1267
|
+
callbacks.onMemoryEvent?.({ kind: 'skipped', reason: this.silent ? 'silent agent (child)' : 'recall disabled' });
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
callbacks.onStatusUpdate('Briefing from BrainRouter memory...');
|
|
1271
|
+
const briefing = await buildMemoryBriefing({
|
|
1272
|
+
mcpClient: this.mcpClient,
|
|
1273
|
+
mcpTools,
|
|
1274
|
+
sessionKey: this.sessionKey,
|
|
1275
|
+
workspaceRoot: this.workspaceRoot,
|
|
1276
|
+
query: prompt,
|
|
1277
|
+
activeSkill: this.activeSkill,
|
|
1278
|
+
});
|
|
1279
|
+
this.recalledRecords = briefing.recalledRecords;
|
|
1280
|
+
this.recalledRecordIds = briefing.recalledRecordIds;
|
|
1281
|
+
this.lastBriefingSources = briefing.sourcesQueried;
|
|
1282
|
+
if (briefing.block) {
|
|
1283
|
+
this.replaceTaggedSystemMessage('memory-briefing', briefing.block);
|
|
1284
|
+
callbacks.onStatusUpdate(`Memory briefing loaded: ${briefing.sourcesQueried.join(', ')} (${briefing.recalledRecordIds.length} records).`);
|
|
1285
|
+
this.memoryMetrics.briefingTokensInjected += Agent.estimateTokens(briefing.block);
|
|
1286
|
+
this.memoryMetrics.recallRecordsConsulted += briefing.recalledRecordIds.length;
|
|
1287
|
+
}
|
|
1288
|
+
callbacks.onMemoryEvent?.({
|
|
1289
|
+
kind: 'briefing',
|
|
1290
|
+
sources: briefing.sourcesQueried,
|
|
1291
|
+
recordCount: briefing.recalledRecordIds.length,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
/** Inspectable summary of the most recent memory briefing. Used by the `/briefing` slash command. */
|
|
1295
|
+
getLastBriefing() {
|
|
1296
|
+
return { sources: [...this.lastBriefingSources], recordIds: [...this.recalledRecordIds] };
|
|
1297
|
+
}
|
|
1298
|
+
/** One-line summary of any new contradiction surfaced after the last capture, or undefined if none. */
|
|
1299
|
+
lastContradictionWarning;
|
|
1300
|
+
takeContradictionWarning() {
|
|
1301
|
+
const w = this.lastContradictionWarning;
|
|
1302
|
+
this.lastContradictionWarning = undefined;
|
|
1303
|
+
return w;
|
|
1304
|
+
}
|
|
1305
|
+
async checkContradictions(callbacks) {
|
|
1306
|
+
if (!this.enableRecall)
|
|
1307
|
+
return;
|
|
1308
|
+
const res = await callMcpTool(this.mcpClient, 'memory_contradictions', { action: 'list' });
|
|
1309
|
+
if (res.isError || !res.parsed)
|
|
1310
|
+
return;
|
|
1311
|
+
const list = res.parsed?.contradictions ?? res.parsed?.items ?? res.parsed;
|
|
1312
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
1313
|
+
return;
|
|
1314
|
+
const first = list[0];
|
|
1315
|
+
const summary = first?.summary || first?.description || first?.title || JSON.stringify(first).slice(0, 200);
|
|
1316
|
+
this.lastContradictionWarning = `${list.length} unresolved contradiction(s). First: ${summary}`;
|
|
1317
|
+
callbacks?.onMemoryEvent?.({ kind: 'contradiction', warning: this.lastContradictionWarning });
|
|
1318
|
+
}
|
|
1319
|
+
async captureTurn(prompt, finalAnswer, callbacks) {
|
|
1320
|
+
if (this.silent)
|
|
1321
|
+
return;
|
|
1322
|
+
if (!finalAnswer)
|
|
1323
|
+
return;
|
|
1324
|
+
const timestamp = Date.now();
|
|
1325
|
+
try {
|
|
1326
|
+
if (this.recalledRecordIds.length > 0) {
|
|
1327
|
+
const cited = selectCitedRecordIds(this.recalledRecords, finalAnswer);
|
|
1328
|
+
await this.mcpClient.callTool('memory_mark_cited', {
|
|
1329
|
+
citedRecordIds: cited,
|
|
1330
|
+
allRecalledRecordIds: this.recalledRecordIds,
|
|
1331
|
+
});
|
|
1332
|
+
if (cited.length > 0) {
|
|
1333
|
+
callbacks?.onMemoryEvent?.({ kind: 'citation', recordIds: cited });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
// Citation feedback should not break the user-facing turn.
|
|
1339
|
+
}
|
|
1340
|
+
try {
|
|
1341
|
+
const captureRes = await this.mcpClient.callTool('memory_capture_turn', {
|
|
1342
|
+
sessionKey: this.sessionKey,
|
|
1343
|
+
activeSkill: this.activeSkill,
|
|
1344
|
+
messages: [
|
|
1345
|
+
{ role: 'user', content: prompt, timestamp },
|
|
1346
|
+
{ role: 'assistant', content: finalAnswer, timestamp: Date.now() },
|
|
1347
|
+
],
|
|
1348
|
+
});
|
|
1349
|
+
// Parse the structured result so the REPL can tell "wrote 2 sensory + 0
|
|
1350
|
+
// cognitive (extractor not running)" apart from "wrote 2 + 3 cognitive
|
|
1351
|
+
// — fully captured." Previously the CLI printed 💾 Captured even when
|
|
1352
|
+
// the extractor was silently disabled, leaving the user to discover
|
|
1353
|
+
// the gap by running SQL against memory.db.
|
|
1354
|
+
let parsed;
|
|
1355
|
+
try {
|
|
1356
|
+
const text = extractToolText(captureRes);
|
|
1357
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
1358
|
+
}
|
|
1359
|
+
catch {
|
|
1360
|
+
parsed = undefined;
|
|
1361
|
+
}
|
|
1362
|
+
// Only warn when the LLM call ITSELF failed (status === 'failed').
|
|
1363
|
+
// A successful call that returned 0 records is a legitimate "nothing
|
|
1364
|
+
// notable to capture" outcome (e.g. a greeting) and should not look
|
|
1365
|
+
// like an error to the user. The previous heuristic conflated both
|
|
1366
|
+
// and surfaced a misleading warning after every trivial exchange.
|
|
1367
|
+
const status = parsed?.cognitiveExtractionStatus;
|
|
1368
|
+
const extractionWarning = status === 'failed'
|
|
1369
|
+
? (typeof parsed?.cognitiveExtractionError === 'string'
|
|
1370
|
+
? `extraction failed: ${parsed.cognitiveExtractionError.slice(0, 140)}`
|
|
1371
|
+
: 'extraction failed — check MCP server logs and LLM credentials')
|
|
1372
|
+
: undefined;
|
|
1373
|
+
callbacks?.onMemoryEvent?.({
|
|
1374
|
+
kind: 'capture',
|
|
1375
|
+
sessionKey: this.sessionKey,
|
|
1376
|
+
messageCount: 2,
|
|
1377
|
+
sensoryRecorded: typeof parsed?.sensoryRecordedCount === 'number' ? parsed.sensoryRecordedCount : undefined,
|
|
1378
|
+
extractionTriggered: typeof parsed?.cognitiveExtractionTriggered === 'boolean' ? parsed.cognitiveExtractionTriggered : undefined,
|
|
1379
|
+
extractedCount: typeof parsed?.cognitiveExtractedCount === 'number' ? parsed.cognitiveExtractedCount : undefined,
|
|
1380
|
+
extractionWarning,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
catch {
|
|
1384
|
+
// Passive capture is best effort in the CLI.
|
|
1385
|
+
}
|
|
1386
|
+
await this.checkContradictions(callbacks);
|
|
1387
|
+
}
|
|
1388
|
+
recordTranscript(message) {
|
|
1389
|
+
try {
|
|
1390
|
+
appendTranscriptEntry(this.workspaceRoot, this.sessionKey, message);
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
// Transcript persistence should not break the interactive turn.
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Run a web search via DuckDuckGo's Instant Answer API. No API key required.
|
|
1399
|
+
*
|
|
1400
|
+
* This is a thin, dependency-free default. For production-grade results, users
|
|
1401
|
+
* can configure an upstream search provider (Brave / Tavily / SerpAPI) and
|
|
1402
|
+
* point `BRAINROUTER_WEB_SEARCH_ENDPOINT` at it — when set, we POST the query
|
|
1403
|
+
* and expect `{ results: [{title, url, snippet}] }`.
|
|
1404
|
+
*/
|
|
1405
|
+
async function runWebSearch(query, maxResults) {
|
|
1406
|
+
const customEndpoint = process.env.BRAINROUTER_WEB_SEARCH_ENDPOINT?.trim();
|
|
1407
|
+
if (customEndpoint) {
|
|
1408
|
+
try {
|
|
1409
|
+
const res = await fetch(customEndpoint, {
|
|
1410
|
+
method: 'POST',
|
|
1411
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1412
|
+
body: JSON.stringify({ query, maxResults }),
|
|
1413
|
+
});
|
|
1414
|
+
if (res.ok) {
|
|
1415
|
+
const body = await res.json();
|
|
1416
|
+
if (Array.isArray(body?.results)) {
|
|
1417
|
+
return JSON.stringify(body.results.slice(0, maxResults), null, 2);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
catch {
|
|
1422
|
+
// fall through to DuckDuckGo fallback
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
try {
|
|
1426
|
+
const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
|
|
1427
|
+
const res = await fetch(url, { headers: { 'User-Agent': 'BrainRouterCLI/0.3.4' } });
|
|
1428
|
+
if (!res.ok) {
|
|
1429
|
+
return `web_search failed: DuckDuckGo returned ${res.status} ${res.statusText}.`;
|
|
1430
|
+
}
|
|
1431
|
+
const data = await res.json();
|
|
1432
|
+
const results = [];
|
|
1433
|
+
if (data?.AbstractURL && data?.AbstractText) {
|
|
1434
|
+
results.push({ title: data.Heading ?? query, url: data.AbstractURL, snippet: data.AbstractText });
|
|
1435
|
+
}
|
|
1436
|
+
const topics = Array.isArray(data?.RelatedTopics) ? data.RelatedTopics : [];
|
|
1437
|
+
for (const t of topics) {
|
|
1438
|
+
if (results.length >= maxResults)
|
|
1439
|
+
break;
|
|
1440
|
+
if (t.FirstURL && t.Text) {
|
|
1441
|
+
results.push({ title: t.Text.split(' - ')[0] ?? t.Text, url: t.FirstURL, snippet: t.Text });
|
|
1442
|
+
}
|
|
1443
|
+
else if (Array.isArray(t?.Topics)) {
|
|
1444
|
+
for (const inner of t.Topics) {
|
|
1445
|
+
if (results.length >= maxResults)
|
|
1446
|
+
break;
|
|
1447
|
+
if (inner.FirstURL && inner.Text) {
|
|
1448
|
+
results.push({ title: inner.Text.split(' - ')[0] ?? inner.Text, url: inner.FirstURL, snippet: inner.Text });
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (results.length === 0) {
|
|
1454
|
+
return `web_search returned no results for "${query}". DuckDuckGo Instant Answer is best for factual queries; configure BRAINROUTER_WEB_SEARCH_ENDPOINT for a full search backend.`;
|
|
1455
|
+
}
|
|
1456
|
+
return JSON.stringify(results.slice(0, maxResults), null, 2);
|
|
1457
|
+
}
|
|
1458
|
+
catch (err) {
|
|
1459
|
+
return `web_search failed: ${err?.message ?? err}`;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Apply a Begin/End-envelope patch:
|
|
1464
|
+
*
|
|
1465
|
+
* *** Begin Patch
|
|
1466
|
+
* *** Update File: path/relative/to/workspace
|
|
1467
|
+
* @@ optional context anchor
|
|
1468
|
+
* -old line
|
|
1469
|
+
* +new line
|
|
1470
|
+
* unchanged line
|
|
1471
|
+
* *** Add File: another/path
|
|
1472
|
+
* +line 1
|
|
1473
|
+
* +line 2
|
|
1474
|
+
* *** Delete File: third/path
|
|
1475
|
+
* *** End Patch
|
|
1476
|
+
*
|
|
1477
|
+
* Returns a JSON summary of operations performed; throws on a malformed envelope
|
|
1478
|
+
* or when an Update fails to match its context block uniquely.
|
|
1479
|
+
*/
|
|
1480
|
+
export function applyPatchEnvelope(patch, workspaceRoot) {
|
|
1481
|
+
const text = patch.replace(/\r\n/g, '\n').trim();
|
|
1482
|
+
if (!text.startsWith('*** Begin Patch')) {
|
|
1483
|
+
throw new Error('apply_patch: missing "*** Begin Patch" header.');
|
|
1484
|
+
}
|
|
1485
|
+
if (!text.endsWith('*** End Patch')) {
|
|
1486
|
+
throw new Error('apply_patch: missing "*** End Patch" footer.');
|
|
1487
|
+
}
|
|
1488
|
+
const inner = text.slice('*** Begin Patch'.length, text.length - '*** End Patch'.length);
|
|
1489
|
+
const lines = inner.split('\n');
|
|
1490
|
+
const ops = [];
|
|
1491
|
+
let i = 0;
|
|
1492
|
+
while (i < lines.length) {
|
|
1493
|
+
const line = lines[i];
|
|
1494
|
+
if (line.startsWith('*** Update File: ')) {
|
|
1495
|
+
const file = line.slice('*** Update File: '.length).trim();
|
|
1496
|
+
i++;
|
|
1497
|
+
// Optional @@ anchor (single line for now).
|
|
1498
|
+
if (i < lines.length && lines[i].startsWith('@@')) {
|
|
1499
|
+
i++;
|
|
1500
|
+
}
|
|
1501
|
+
const oldLines = [];
|
|
1502
|
+
const newLines = [];
|
|
1503
|
+
while (i < lines.length && !lines[i].startsWith('*** ')) {
|
|
1504
|
+
const l = lines[i];
|
|
1505
|
+
if (l.startsWith('-')) {
|
|
1506
|
+
oldLines.push(l.slice(1));
|
|
1507
|
+
}
|
|
1508
|
+
else if (l.startsWith('+')) {
|
|
1509
|
+
newLines.push(l.slice(1));
|
|
1510
|
+
}
|
|
1511
|
+
else if (l.startsWith(' ')) {
|
|
1512
|
+
oldLines.push(l.slice(1));
|
|
1513
|
+
newLines.push(l.slice(1));
|
|
1514
|
+
}
|
|
1515
|
+
else if (l === '') {
|
|
1516
|
+
// tolerate blank lines as untouched
|
|
1517
|
+
oldLines.push('');
|
|
1518
|
+
newLines.push('');
|
|
1519
|
+
}
|
|
1520
|
+
else {
|
|
1521
|
+
throw new Error(`apply_patch: unexpected line in Update File "${file}": ${JSON.stringify(l)}`);
|
|
1522
|
+
}
|
|
1523
|
+
i++;
|
|
1524
|
+
}
|
|
1525
|
+
ops.push({ kind: 'update', file, oldBlock: oldLines.join('\n'), newBlock: newLines.join('\n') });
|
|
1526
|
+
}
|
|
1527
|
+
else if (line.startsWith('*** Add File: ')) {
|
|
1528
|
+
const file = line.slice('*** Add File: '.length).trim();
|
|
1529
|
+
i++;
|
|
1530
|
+
const body = [];
|
|
1531
|
+
while (i < lines.length && !lines[i].startsWith('*** ')) {
|
|
1532
|
+
const l = lines[i];
|
|
1533
|
+
if (l.startsWith('+'))
|
|
1534
|
+
body.push(l.slice(1));
|
|
1535
|
+
else if (l === '')
|
|
1536
|
+
body.push('');
|
|
1537
|
+
else
|
|
1538
|
+
throw new Error(`apply_patch: Add File "${file}" lines must start with '+': ${JSON.stringify(l)}`);
|
|
1539
|
+
i++;
|
|
1540
|
+
}
|
|
1541
|
+
ops.push({ kind: 'add', file, body: body.join('\n') });
|
|
1542
|
+
}
|
|
1543
|
+
else if (line.startsWith('*** Delete File: ')) {
|
|
1544
|
+
const file = line.slice('*** Delete File: '.length).trim();
|
|
1545
|
+
ops.push({ kind: 'delete', file });
|
|
1546
|
+
i++;
|
|
1547
|
+
}
|
|
1548
|
+
else if (line === '' || line.startsWith('***')) {
|
|
1549
|
+
i++;
|
|
1550
|
+
}
|
|
1551
|
+
else {
|
|
1552
|
+
throw new Error(`apply_patch: expected an operation header, got ${JSON.stringify(line)}`);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
const applied = [];
|
|
1556
|
+
const wsRoot = workspaceRoot ?? fs.realpathSync(process.cwd());
|
|
1557
|
+
for (const op of ops) {
|
|
1558
|
+
const resolved = resolveWorkspacePath(wsRoot, op.file, { forWrite: op.kind !== 'delete' });
|
|
1559
|
+
if (op.kind === 'add') {
|
|
1560
|
+
const dir = path.dirname(resolved);
|
|
1561
|
+
if (!fs.existsSync(dir))
|
|
1562
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1563
|
+
if (fs.existsSync(resolved)) {
|
|
1564
|
+
throw new Error(`apply_patch: Add File "${op.file}" already exists. Use Update File instead.`);
|
|
1565
|
+
}
|
|
1566
|
+
fs.writeFileSync(resolved, op.body, 'utf8');
|
|
1567
|
+
applied.push({ kind: 'add', file: op.file });
|
|
1568
|
+
}
|
|
1569
|
+
else if (op.kind === 'delete') {
|
|
1570
|
+
if (!fs.existsSync(resolved)) {
|
|
1571
|
+
throw new Error(`apply_patch: Delete File "${op.file}" does not exist.`);
|
|
1572
|
+
}
|
|
1573
|
+
fs.unlinkSync(resolved);
|
|
1574
|
+
applied.push({ kind: 'delete', file: op.file });
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
if (!fs.existsSync(resolved)) {
|
|
1578
|
+
throw new Error(`apply_patch: Update File "${op.file}" does not exist.`);
|
|
1579
|
+
}
|
|
1580
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
1581
|
+
const count = op.oldBlock === '' ? 0 : content.split(op.oldBlock).length - 1;
|
|
1582
|
+
if (count === 0) {
|
|
1583
|
+
throw new Error(`apply_patch: context for Update File "${op.file}" did not match. Re-read the file and resubmit.`);
|
|
1584
|
+
}
|
|
1585
|
+
if (count > 1) {
|
|
1586
|
+
throw new Error(`apply_patch: context for Update File "${op.file}" matched ${count} times. Add more surrounding lines for uniqueness.`);
|
|
1587
|
+
}
|
|
1588
|
+
const updated = content.replace(op.oldBlock, op.newBlock);
|
|
1589
|
+
fs.writeFileSync(resolved, updated, 'utf8');
|
|
1590
|
+
applied.push({ kind: 'update', file: op.file });
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return JSON.stringify({ applied }, null, 2);
|
|
1594
|
+
}
|
|
1595
|
+
export function matchGlob(pattern, filePath) {
|
|
1596
|
+
const base = path.basename(filePath);
|
|
1597
|
+
const convertPattern = (p) => new RegExp(`^${globToRegexSource(p)}$`);
|
|
1598
|
+
const normPath = filePath.replace(/\\/g, '/');
|
|
1599
|
+
if (convertPattern(pattern).test(normPath)) {
|
|
1600
|
+
return true;
|
|
1601
|
+
}
|
|
1602
|
+
if (!pattern.includes('/') && convertPattern(pattern).test(base)) {
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
return false;
|
|
1606
|
+
}
|
|
1607
|
+
function globToRegexSource(pattern) {
|
|
1608
|
+
let source = '';
|
|
1609
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
1610
|
+
const char = pattern[index];
|
|
1611
|
+
const next = pattern[index + 1];
|
|
1612
|
+
const afterNext = pattern[index + 2];
|
|
1613
|
+
if (char === '*' && next === '*' && afterNext === '/') {
|
|
1614
|
+
source += '(?:.*/)?';
|
|
1615
|
+
index += 2;
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
if (char === '*' && next === '*') {
|
|
1619
|
+
source += '.*';
|
|
1620
|
+
index += 1;
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
if (char === '*') {
|
|
1624
|
+
source += '[^/]*';
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
if (char === '?') {
|
|
1628
|
+
source += '.';
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
source += char.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
|
|
1632
|
+
}
|
|
1633
|
+
return source;
|
|
1634
|
+
}
|
|
1635
|
+
export function globFiles(pattern, workspaceRoot, dir) {
|
|
1636
|
+
const wsRoot = fs.realpathSync(workspaceRoot ?? process.cwd());
|
|
1637
|
+
const startDir = dir ?? wsRoot;
|
|
1638
|
+
const safeDir = resolveWorkspacePath(wsRoot, path.relative(wsRoot, startDir) || '.');
|
|
1639
|
+
const results = [];
|
|
1640
|
+
const items = fs.readdirSync(safeDir);
|
|
1641
|
+
for (const item of items) {
|
|
1642
|
+
if (IGNORED_DIRS.has(item)) {
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
const fullPath = path.join(safeDir, item);
|
|
1646
|
+
if (!isPathInside(wsRoot, fs.realpathSync(fullPath))) {
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
const stat = fs.statSync(fullPath);
|
|
1650
|
+
if (stat.isDirectory()) {
|
|
1651
|
+
results.push(...globFiles(pattern, wsRoot, fullPath));
|
|
1652
|
+
}
|
|
1653
|
+
else if (stat.isFile()) {
|
|
1654
|
+
const relPath = path.relative(wsRoot, fullPath);
|
|
1655
|
+
if (matchGlob(pattern, relPath)) {
|
|
1656
|
+
results.push(relPath);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return results;
|
|
1661
|
+
}
|
|
1662
|
+
export function getToolSummary(name, args, result) {
|
|
1663
|
+
switch (name) {
|
|
1664
|
+
case 'read_file': {
|
|
1665
|
+
const lines = result.split('\n').length;
|
|
1666
|
+
return `read ${lines} lines (${result.length} characters) from ${args.path}`;
|
|
1667
|
+
}
|
|
1668
|
+
case 'write_file':
|
|
1669
|
+
return `wrote to ${args.path}`;
|
|
1670
|
+
case 'edit_file':
|
|
1671
|
+
return `edited ${args.path}`;
|
|
1672
|
+
case 'list_dir':
|
|
1673
|
+
try {
|
|
1674
|
+
const items = JSON.parse(result);
|
|
1675
|
+
return `listed ${items.length} items in ${args.path || '.'}`;
|
|
1676
|
+
}
|
|
1677
|
+
catch {
|
|
1678
|
+
return `listed directory ${args.path || '.'}`;
|
|
1679
|
+
}
|
|
1680
|
+
case 'grep_search':
|
|
1681
|
+
try {
|
|
1682
|
+
const matches = JSON.parse(result);
|
|
1683
|
+
return `found ${matches.length} matches for "${args.query}"`;
|
|
1684
|
+
}
|
|
1685
|
+
catch {
|
|
1686
|
+
return `searched for "${args.query}"`;
|
|
1687
|
+
}
|
|
1688
|
+
case 'glob_files':
|
|
1689
|
+
try {
|
|
1690
|
+
const matched = JSON.parse(result);
|
|
1691
|
+
return `found ${matched.length} files matching "${args.pattern}"`;
|
|
1692
|
+
}
|
|
1693
|
+
catch {
|
|
1694
|
+
return `searched pattern "${args.pattern}"`;
|
|
1695
|
+
}
|
|
1696
|
+
case 'run_command':
|
|
1697
|
+
if (result.includes('rejected by user')) {
|
|
1698
|
+
return 'execution rejected by user';
|
|
1699
|
+
}
|
|
1700
|
+
const exitCodeMatch = result.match(/Exit Code: (\d+)/);
|
|
1701
|
+
const code = exitCodeMatch ? exitCodeMatch[1] : '0';
|
|
1702
|
+
return `exited with code ${code}`;
|
|
1703
|
+
case 'fetch_url':
|
|
1704
|
+
if (result.startsWith('Failed')) {
|
|
1705
|
+
return 'failed web fetch';
|
|
1706
|
+
}
|
|
1707
|
+
return `fetched content from ${args.url}`;
|
|
1708
|
+
case 'web_search':
|
|
1709
|
+
try {
|
|
1710
|
+
return `${JSON.parse(result).length} web results for "${args.query}"`;
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
return `searched web for "${args.query}"`;
|
|
1714
|
+
}
|
|
1715
|
+
case 'apply_patch':
|
|
1716
|
+
try {
|
|
1717
|
+
return `applied ${JSON.parse(result).applied.length} file ops`;
|
|
1718
|
+
}
|
|
1719
|
+
catch {
|
|
1720
|
+
return 'applied patch';
|
|
1721
|
+
}
|
|
1722
|
+
case 'update_plan':
|
|
1723
|
+
return 'updated durable plan';
|
|
1724
|
+
case 'spawn_agent':
|
|
1725
|
+
return `spawned ${args.role} agent`;
|
|
1726
|
+
case 'list_agents':
|
|
1727
|
+
try {
|
|
1728
|
+
return `${JSON.parse(result).length} child sessions`;
|
|
1729
|
+
}
|
|
1730
|
+
catch {
|
|
1731
|
+
return 'listed agents';
|
|
1732
|
+
}
|
|
1733
|
+
case 'wait_agent':
|
|
1734
|
+
try {
|
|
1735
|
+
const p = JSON.parse(result);
|
|
1736
|
+
return `agent ${p.id} ${p.status}`;
|
|
1737
|
+
}
|
|
1738
|
+
catch {
|
|
1739
|
+
return 'waited';
|
|
1740
|
+
}
|
|
1741
|
+
case 'read_agent_transcript':
|
|
1742
|
+
try {
|
|
1743
|
+
return `${JSON.parse(result).entries?.length || 0} transcript entries`;
|
|
1744
|
+
}
|
|
1745
|
+
catch {
|
|
1746
|
+
return 'read transcript';
|
|
1747
|
+
}
|
|
1748
|
+
case 'close_agent':
|
|
1749
|
+
return `closed agent ${args.id}`;
|
|
1750
|
+
default:
|
|
1751
|
+
return `${name} executed`;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Optional inline preview for inspection-style tools. The REPL renders this
|
|
1756
|
+
* indented below the one-line summary so the user can SEE the result even if
|
|
1757
|
+
* the LLM forgets to echo it in its reply. Limited to a handful of tools where
|
|
1758
|
+
* the result is concise and the user's intent is almost always "show me this":
|
|
1759
|
+
* `list_dir`, `grep_search`, `glob_files`. Other tools (read_file, run_command)
|
|
1760
|
+
* fire too often as internal exploration steps — previewing them would flood
|
|
1761
|
+
* the terminal. Returns undefined when no useful preview is available.
|
|
1762
|
+
*/
|
|
1763
|
+
export function getToolPreview(name, args, result) {
|
|
1764
|
+
switch (name) {
|
|
1765
|
+
case 'list_dir': {
|
|
1766
|
+
try {
|
|
1767
|
+
const items = JSON.parse(result);
|
|
1768
|
+
if (!Array.isArray(items))
|
|
1769
|
+
return undefined;
|
|
1770
|
+
if (items.length === 0)
|
|
1771
|
+
return '(empty directory)';
|
|
1772
|
+
const MAX = 30;
|
|
1773
|
+
const sliced = items.slice(0, MAX);
|
|
1774
|
+
const lines = sliced.map((it) => {
|
|
1775
|
+
const tag = it.type === 'directory' ? '📁' : '📄';
|
|
1776
|
+
const size = it.type === 'file' && typeof it.size === 'number' ? ` (${formatBytes(it.size)})` : '';
|
|
1777
|
+
return `${tag} ${it.name}${size}`;
|
|
1778
|
+
});
|
|
1779
|
+
if (items.length > MAX)
|
|
1780
|
+
lines.push(`…and ${items.length - MAX} more`);
|
|
1781
|
+
return lines.join('\n');
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
return undefined;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
case 'grep_search': {
|
|
1788
|
+
try {
|
|
1789
|
+
const matches = JSON.parse(result);
|
|
1790
|
+
if (!Array.isArray(matches))
|
|
1791
|
+
return undefined;
|
|
1792
|
+
if (matches.length === 0)
|
|
1793
|
+
return '(no matches)';
|
|
1794
|
+
const MAX = 10;
|
|
1795
|
+
const sliced = matches.slice(0, MAX);
|
|
1796
|
+
const lines = sliced.map((m) => `${m.path}:${m.line} ${m.text.slice(0, 120)}`);
|
|
1797
|
+
if (matches.length > MAX)
|
|
1798
|
+
lines.push(`…and ${matches.length - MAX} more`);
|
|
1799
|
+
return lines.join('\n');
|
|
1800
|
+
}
|
|
1801
|
+
catch {
|
|
1802
|
+
return undefined;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
case 'glob_files': {
|
|
1806
|
+
try {
|
|
1807
|
+
const paths = JSON.parse(result);
|
|
1808
|
+
if (!Array.isArray(paths))
|
|
1809
|
+
return undefined;
|
|
1810
|
+
if (paths.length === 0)
|
|
1811
|
+
return '(no matches)';
|
|
1812
|
+
const MAX = 20;
|
|
1813
|
+
const sliced = paths.slice(0, MAX);
|
|
1814
|
+
const lines = sliced.map((p) => p);
|
|
1815
|
+
if (paths.length > MAX)
|
|
1816
|
+
lines.push(`…and ${paths.length - MAX} more`);
|
|
1817
|
+
return lines.join('\n');
|
|
1818
|
+
}
|
|
1819
|
+
catch {
|
|
1820
|
+
return undefined;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
default:
|
|
1824
|
+
return undefined;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
function formatBytes(n) {
|
|
1828
|
+
if (n < 1024)
|
|
1829
|
+
return `${n} B`;
|
|
1830
|
+
if (n < 1024 * 1024)
|
|
1831
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
1832
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
1833
|
+
}
|
|
1834
|
+
// Internal marker lines used by Agent.replaceTaggedSystemMessage to dedupe
|
|
1835
|
+
// per-turn system messages (briefing, fan-out hint). Strip them before the
|
|
1836
|
+
// payload reaches the LLM so the model doesn't see the bookkeeping.
|
|
1837
|
+
const TAG_MARKER_RE = /^<!--brainrouter:[a-z0-9-]+-->\n/;
|
|
1838
|
+
export function buildChatCompletionPayload(config, messages, tools) {
|
|
1839
|
+
const stripTag = (content) => typeof content === 'string' && TAG_MARKER_RE.test(content)
|
|
1840
|
+
? content.replace(TAG_MARKER_RE, '')
|
|
1841
|
+
: content;
|
|
1842
|
+
const mappedMessages = messages.map(m => {
|
|
1843
|
+
if (m.role === 'tool') {
|
|
1844
|
+
return {
|
|
1845
|
+
role: 'tool',
|
|
1846
|
+
tool_call_id: m.tool_call_id,
|
|
1847
|
+
name: m.name,
|
|
1848
|
+
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
if (m.role === 'assistant') {
|
|
1852
|
+
const out = { role: 'assistant', content: m.content || null };
|
|
1853
|
+
if (m.tool_calls)
|
|
1854
|
+
out.tool_calls = m.tool_calls;
|
|
1855
|
+
return out;
|
|
1856
|
+
}
|
|
1857
|
+
return {
|
|
1858
|
+
role: m.role,
|
|
1859
|
+
content: stripTag(m.content),
|
|
1860
|
+
};
|
|
1861
|
+
});
|
|
1862
|
+
const body = {
|
|
1863
|
+
model: config.model,
|
|
1864
|
+
messages: mappedMessages,
|
|
1865
|
+
};
|
|
1866
|
+
if (tools.length > 0) {
|
|
1867
|
+
body.tools = tools.map(t => ({
|
|
1868
|
+
type: 'function',
|
|
1869
|
+
function: {
|
|
1870
|
+
name: t.name,
|
|
1871
|
+
description: t.description || '',
|
|
1872
|
+
parameters: t.inputSchema || { type: 'object', properties: {} }
|
|
1873
|
+
}
|
|
1874
|
+
}));
|
|
1875
|
+
body.tool_choice = 'auto';
|
|
1876
|
+
}
|
|
1877
|
+
return body;
|
|
1878
|
+
}
|
|
1879
|
+
export async function callOpenAI(config, messages, tools) {
|
|
1880
|
+
const endpoint = config.endpoint || 'https://api.openai.com/v1';
|
|
1881
|
+
let apiKey = config.apiKey || process.env.OPENAI_API_KEY || '';
|
|
1882
|
+
const isLocal = endpoint.includes('localhost') || endpoint.includes('127.0.0.1');
|
|
1883
|
+
if (!apiKey && !isLocal) {
|
|
1884
|
+
throw new Error('LLM API key is required for OpenAI provider.');
|
|
1885
|
+
}
|
|
1886
|
+
if (!apiKey && isLocal) {
|
|
1887
|
+
apiKey = 'sk-local-placeholder';
|
|
1888
|
+
}
|
|
1889
|
+
const body = buildChatCompletionPayload(config, messages, tools);
|
|
1890
|
+
const headers = {
|
|
1891
|
+
'Content-Type': 'application/json'
|
|
1892
|
+
};
|
|
1893
|
+
if (apiKey) {
|
|
1894
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
1895
|
+
}
|
|
1896
|
+
const timeoutMs = Number(process.env.BRAINROUTER_LLM_TIMEOUT_MS || 120000);
|
|
1897
|
+
const controller = new AbortController();
|
|
1898
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1899
|
+
// Gate every chat LLM call through the process-wide semaphore. This
|
|
1900
|
+
// prevents a fan-out of N parallel children from firing N simultaneous
|
|
1901
|
+
// requests at the backend — the same condition that was unloading the
|
|
1902
|
+
// local LM Studio model. The MCP child has its own matching semaphore;
|
|
1903
|
+
// both consume the BRAINROUTER_LLM_MAX_CONCURRENT budget on the same
|
|
1904
|
+
// backend instance.
|
|
1905
|
+
const release = await acquireLLMSlot();
|
|
1906
|
+
let res;
|
|
1907
|
+
try {
|
|
1908
|
+
res = await fetch(`${endpoint}/chat/completions`, {
|
|
1909
|
+
method: 'POST',
|
|
1910
|
+
headers,
|
|
1911
|
+
body: JSON.stringify(body),
|
|
1912
|
+
signal: controller.signal,
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
catch (err) {
|
|
1916
|
+
release();
|
|
1917
|
+
if (err?.name === 'AbortError') {
|
|
1918
|
+
throw new Error(`LLM request timed out after ${timeoutMs}ms. Check that ${endpoint} is running and that model "${config.model}" can answer chat/completions requests with tools enabled.`);
|
|
1919
|
+
}
|
|
1920
|
+
throw err;
|
|
1921
|
+
}
|
|
1922
|
+
finally {
|
|
1923
|
+
clearTimeout(timeout);
|
|
1924
|
+
}
|
|
1925
|
+
// Release once the headers are back; reading the body is local work that
|
|
1926
|
+
// doesn't need to block other LLM callers from starting.
|
|
1927
|
+
release();
|
|
1928
|
+
if (!res.ok) {
|
|
1929
|
+
const errText = await res.text();
|
|
1930
|
+
throw new Error(`OpenAI API error: ${res.status} ${res.statusText} - ${errText}`);
|
|
1931
|
+
}
|
|
1932
|
+
const data = await res.json();
|
|
1933
|
+
// Defensive response-shape parsing. Some endpoints (LM Studio with certain
|
|
1934
|
+
// models, OpenRouter on specific upstream errors, local vLLM under load,
|
|
1935
|
+
// gpt-oss reasoning models with a non-standard envelope) return a 200 OK
|
|
1936
|
+
// with NO `choices` array — they smuggle the failure into the body as
|
|
1937
|
+
// `{error: ...}` or change the schema entirely. Unguarded `data.choices[0]`
|
|
1938
|
+
// then crashes with "Cannot read properties of undefined" and the user
|
|
1939
|
+
// has no idea what the upstream actually sent. Surface the body in the
|
|
1940
|
+
// error so they can spot the actual problem (wrong model name, OOM,
|
|
1941
|
+
// content-filter refusal, etc.).
|
|
1942
|
+
if (data && typeof data === 'object' && data.error) {
|
|
1943
|
+
const errMsg = typeof data.error === 'string'
|
|
1944
|
+
? data.error
|
|
1945
|
+
: (data.error.message ?? JSON.stringify(data.error).slice(0, 400));
|
|
1946
|
+
throw new Error(`LLM endpoint returned an error envelope (HTTP 200): ${errMsg}`);
|
|
1947
|
+
}
|
|
1948
|
+
if (!Array.isArray(data?.choices) || data.choices.length === 0) {
|
|
1949
|
+
throw new Error(`LLM endpoint returned no choices. ` +
|
|
1950
|
+
`Model "${config.model}" at ${endpoint} may not support chat/completions, ` +
|
|
1951
|
+
`may need a different request shape (reasoning/harmony format?), or be misconfigured. ` +
|
|
1952
|
+
`Response body: ${JSON.stringify(data).slice(0, 600)}`);
|
|
1953
|
+
}
|
|
1954
|
+
const choice = data.choices[0];
|
|
1955
|
+
if (!choice?.message) {
|
|
1956
|
+
// Streaming-style frames have `delta` instead of `message` — accept both
|
|
1957
|
+
// so a partially-misconfigured endpoint at least surfaces what it sent.
|
|
1958
|
+
const delta = choice?.delta;
|
|
1959
|
+
if (delta && typeof delta === 'object') {
|
|
1960
|
+
return {
|
|
1961
|
+
content: delta.content || '',
|
|
1962
|
+
toolCalls: delta.tool_calls,
|
|
1963
|
+
usage: data.usage,
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
throw new Error(`OpenAI-compatible endpoint returned an invalid chat completion response: ${JSON.stringify(data).slice(0, 1000)}`);
|
|
1967
|
+
}
|
|
1968
|
+
return {
|
|
1969
|
+
// Some reasoning models put the visible answer in `message.content` and
|
|
1970
|
+
// chain-of-thought in `message.reasoning_content` / `reasoning`. We use
|
|
1971
|
+
// content (the canonical user-visible field) but tolerate it being null
|
|
1972
|
+
// when there are tool_calls but no prose.
|
|
1973
|
+
content: choice.message.content ?? '',
|
|
1974
|
+
toolCalls: choice.message.tool_calls,
|
|
1975
|
+
usage: data.usage,
|
|
1976
|
+
};
|
|
1977
|
+
}
|