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