mouaif 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/ai-stream.js
ADDED
|
@@ -0,0 +1,2048 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Streaming core for the AI client.
|
|
4
|
+
//
|
|
5
|
+
// Owns the multi-turn tool loop (streamChat), the single tool-call
|
|
6
|
+
// runner (runSingleToolCall), and the byte-stream helpers shared with
|
|
7
|
+
// the provider parsers (readSSE / parseSSEFrame / readNDJSON).
|
|
8
|
+
// Provider definitions, request builders and event parsers live in
|
|
9
|
+
// src/ai-endpoints.js; the public facade is src/ai.js.
|
|
10
|
+
|
|
11
|
+
const { endpointFor, requireApiKey, BUILDERS, PARSERS, parseMiniMaxTextToolCalls } = require('./ai-endpoints.js');
|
|
12
|
+
const { projectModelRecord } = require('./util.js');
|
|
13
|
+
const toolFeedback = require('./toolFeedback.js');
|
|
14
|
+
const usageMetrics = require('./usage.js');
|
|
15
|
+
|
|
16
|
+
// ---- Streaming core ----------------------------------------------------
|
|
17
|
+
|
|
18
|
+
// Walks an SSE byte stream and yields {eventName, data} pairs.
|
|
19
|
+
// `stream` is a ReadableStream<Uint8Array> from fetch().
|
|
20
|
+
async function* readSSE(stream) {
|
|
21
|
+
const decoder = new TextDecoder('utf-8');
|
|
22
|
+
let buf = '';
|
|
23
|
+
// SSE frames are separated by a blank line. The spec allows LF, CRLF,
|
|
24
|
+
// and bare CR as line endings, and all three occur in the wild: Azure
|
|
25
|
+
// OpenAI, Ollama's OpenAI-compatible mode, and many gateways emit CRLF,
|
|
26
|
+
// and an old-style proxy can emit CR-only. Splitting on `\n\n` alone
|
|
27
|
+
// would glue such frames into one giant frame with literal `\r` inside
|
|
28
|
+
// the JSON, so every chunk would fail JSON.parse and stream as
|
|
29
|
+
// `passthrough` (no visible text). The separator pattern below matches
|
|
30
|
+
// all three (CRLF first, so it is never read as two CR/LF breaks), and
|
|
31
|
+
// each frame's line endings are normalized to LF so parseSSEFrame's
|
|
32
|
+
// `\n`-based field splitting sees a clean frame.
|
|
33
|
+
const FRAME_SEP = /(?:\r\n|\r|\n){2}/;
|
|
34
|
+
for await (const chunk of stream) {
|
|
35
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
36
|
+
let idx;
|
|
37
|
+
while ((idx = buf.search(FRAME_SEP)) !== -1) {
|
|
38
|
+
const frame = buf.slice(0, idx).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
39
|
+
// Consume the separator (2 or 4 chars: "\n\n" / "\r\n\r\n" / "\r\r")
|
|
40
|
+
// — a plain slice by fixed length would leave a stray "\r" behind.
|
|
41
|
+
const sep = buf.slice(idx, idx + 4).match(FRAME_SEP)[0];
|
|
42
|
+
buf = buf.slice(idx + sep.length);
|
|
43
|
+
const ev = parseSSEFrame(frame);
|
|
44
|
+
if (ev) yield ev;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Tail without trailing blank line.
|
|
48
|
+
if (buf.trim()) {
|
|
49
|
+
const ev = parseSSEFrame(buf);
|
|
50
|
+
if (ev) yield ev;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSSEFrame(frame) {
|
|
55
|
+
let eventName = 'message';
|
|
56
|
+
const dataLines = [];
|
|
57
|
+
for (const line of frame.split('\n')) {
|
|
58
|
+
if (!line) continue;
|
|
59
|
+
if (line.startsWith(':')) continue; // comment / heartbeat
|
|
60
|
+
const colon = line.indexOf(':');
|
|
61
|
+
if (colon === -1) continue;
|
|
62
|
+
const field = line.slice(0, colon);
|
|
63
|
+
let value = line.slice(colon + 1);
|
|
64
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
65
|
+
if (field === 'event') eventName = value;
|
|
66
|
+
else if (field === 'data') dataLines.push(value);
|
|
67
|
+
}
|
|
68
|
+
// No data lines -> comment-only / heartbeat / empty frame; skip.
|
|
69
|
+
if (!dataLines.length) return null;
|
|
70
|
+
return { eventName, data: dataLines.join('\n') };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Walks an NDJSON byte stream and yields one parsed object per line.
|
|
74
|
+
async function* readNDJSON(stream) {
|
|
75
|
+
const decoder = new TextDecoder('utf-8');
|
|
76
|
+
let buf = '';
|
|
77
|
+
for await (const chunk of stream) {
|
|
78
|
+
buf += decoder.decode(chunk, { stream: true });
|
|
79
|
+
let idx;
|
|
80
|
+
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
81
|
+
const line = buf.slice(0, idx).trim();
|
|
82
|
+
buf = buf.slice(idx + 1);
|
|
83
|
+
if (!line) continue;
|
|
84
|
+
try { yield JSON.parse(line); }
|
|
85
|
+
catch { /* ignore malformed line, upstream is responsible */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (buf.trim()) {
|
|
89
|
+
try { yield JSON.parse(buf); } catch { /* ignore */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Single tool-call runner ---------------------------------------------
|
|
94
|
+
// Executes one tool call through the full native pipeline — circuit
|
|
95
|
+
// breaker, authorization gate, dispatch — emitting tool_call/tool_result
|
|
96
|
+
// events and appending the `tool` message to `cx.convo` (when provided).
|
|
97
|
+
// Shared by the streamChat tool loop and by POST /api/tools/subagent
|
|
98
|
+
// (direct @agent dispatch from the composer).
|
|
99
|
+
//
|
|
100
|
+
// cx = {
|
|
101
|
+
// opts, onEvent, convo, // streamChat closure (convo optional)
|
|
102
|
+
// toolSpecs, promptProfilesMod, discoveredToolNames,
|
|
103
|
+
// modelContentForTool, // (name, exec) -> string
|
|
104
|
+
// dispatchTool, firstStringArgument, toolResultImageParts, // helpers
|
|
105
|
+
// getLastToolCallKey/setLastToolCallKey,
|
|
106
|
+
// getRepeatedToolCallCount/setRepeatedToolCallCount, REPEATED_TOOL_CALL_LIMIT
|
|
107
|
+
// }
|
|
108
|
+
async function runSingleToolCall(c, cx) {
|
|
109
|
+
const { opts, onEvent, convo, toolSpecs, promptProfilesMod, discoveredToolNames, modelContentForTool } = cx;
|
|
110
|
+
const dispatchTool = cx.dispatchTool;
|
|
111
|
+
const firstStringArgument = cx.firstStringArgument;
|
|
112
|
+
const toolResultImageParts = cx.toolResultImageParts;
|
|
113
|
+
let args = {};
|
|
114
|
+
if (c.arguments) {
|
|
115
|
+
try { args = JSON.parse(c.arguments); }
|
|
116
|
+
catch { args = { __raw: c.arguments }; }
|
|
117
|
+
}
|
|
118
|
+
let exec;
|
|
119
|
+
let callEmitted = false;
|
|
120
|
+
// Captured when the authorization gate resolves a prompt for an
|
|
121
|
+
// `ask_user` call. The runner reads it to fold the user's
|
|
122
|
+
// structured answer into the `tool` message it returns.
|
|
123
|
+
let callOptsAnswerPayload = null;
|
|
124
|
+
// Captured when the authorization gate resolves a prompt for a
|
|
125
|
+
// `subagent` call whose card carried a user-picked model. The
|
|
126
|
+
// payload is { modelOverride: { providerId, modelId } }; the
|
|
127
|
+
// subagent dispatcher resolves it to a hydrated model so the
|
|
128
|
+
// delegated run executes on the chosen model for this call only.
|
|
129
|
+
let callOptsModelOverride = null;
|
|
130
|
+
// Captured from the same authorization payload: a per-run thinking
|
|
131
|
+
// level the user picked on the approval card (this call only).
|
|
132
|
+
let callOptsThinkingLevel = null;
|
|
133
|
+
const pushToolMessage = (name, content) => {
|
|
134
|
+
if (convo) convo.push({ role: 'tool', tool_call_id: c.id || undefined, name, content });
|
|
135
|
+
};
|
|
136
|
+
// Identical-call circuit breaker. A model retrying the exact same
|
|
137
|
+
// call with the exact same arguments (typically after a tool
|
|
138
|
+
// error) never converges — enforce that the returned result is
|
|
139
|
+
// identical, so nothing was learned from the retry. Refuse it
|
|
140
|
+
// with an explanatory tool error so the model is forced to vary
|
|
141
|
+
// the command or answer in plain text. Counts per consecutive
|
|
142
|
+
// identical call; any different call resets the streak.
|
|
143
|
+
const callKey = c.name + '\n' + (c.arguments || '');
|
|
144
|
+
if (callKey === cx.getLastToolCallKey()) {
|
|
145
|
+
cx.setRepeatedToolCallCount(cx.getRepeatedToolCallCount() + 1);
|
|
146
|
+
} else {
|
|
147
|
+
cx.setLastToolCallKey(callKey);
|
|
148
|
+
cx.setRepeatedToolCallCount(0);
|
|
149
|
+
}
|
|
150
|
+
const loopLimit = typeof cx.REPEATED_TOOL_CALL_LIMIT === 'number' ? cx.REPEATED_TOOL_CALL_LIMIT : 3;
|
|
151
|
+
if (cx.getRepeatedToolCallCount() >= loopLimit) {
|
|
152
|
+
const r = {
|
|
153
|
+
error: {
|
|
154
|
+
code: 'ELOOP',
|
|
155
|
+
message: 'You have called ' + c.name + ' with identical arguments ' + (cx.getRepeatedToolCallCount() + 1) + ' times in a row with identical results. The call was refused. Do not retry it — change the command/arguments or answer the user in plain text instead.'
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
exec = { ok: false, content: JSON.stringify(r), result: r };
|
|
159
|
+
onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
160
|
+
callEmitted = true;
|
|
161
|
+
onEvent('tool_result', { id: c.id || null, name: c.name, ok: false, result: exec.result });
|
|
162
|
+
pushToolMessage(c.name, modelContentForTool(c.name, exec));
|
|
163
|
+
return exec;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
// list_features is a read-only metadata tool that bypasses
|
|
167
|
+
// the authorization gate — it only returns feature state.
|
|
168
|
+
if (c.name === 'list_features') {
|
|
169
|
+
let af;
|
|
170
|
+
try { af = require('./agentFeatures.js'); }
|
|
171
|
+
catch (e) {
|
|
172
|
+
exec = { ok: false, content: JSON.stringify({ error: { code: 'EMODULE', message: 'agentFeatures module unavailable: ' + (e.message || e) } }), result: { error: { code: 'EMODULE' } } };
|
|
173
|
+
}
|
|
174
|
+
if (!exec) {
|
|
175
|
+
exec = await af.dispatchListFeatures(args, Object.assign({}, opts, { callId: c.id || null }));
|
|
176
|
+
}
|
|
177
|
+
onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
178
|
+
callEmitted = true;
|
|
179
|
+
} else if (c.name === 'activate_skill') {
|
|
180
|
+
// Activation is a read-only lookup constrained to the enum of enabled,
|
|
181
|
+
// project-contained skills, so it does not require a separate approval.
|
|
182
|
+
onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
183
|
+
callEmitted = true;
|
|
184
|
+
exec = await dispatchTool(c.name, args, Object.assign({}, opts, { callId: c.id || null }));
|
|
185
|
+
} else if (c.name === 'report_progress') {
|
|
186
|
+
// report_progress is a read-only UI/update tool. It honors
|
|
187
|
+
// the project `off` visibility gate, but does not show an
|
|
188
|
+
// interactive authorization prompt because progress updates
|
|
189
|
+
// do not read or modify project resources.
|
|
190
|
+
try {
|
|
191
|
+
const authGate = require('./tools/authorization.js');
|
|
192
|
+
const cfg = authGate.effectiveConfig(opts && opts.projectDir, c.name, opts && opts.chatId);
|
|
193
|
+
if (cfg && cfg.mode === 'off') {
|
|
194
|
+
exec = { ok: false, content: JSON.stringify({ ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' }), result: { ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' } };
|
|
195
|
+
}
|
|
196
|
+
} catch { /* unreadable authorization state: keep compatibility path */ }
|
|
197
|
+
// Emit the running card before dispatch so the subsequent
|
|
198
|
+
// progress_update can attach to the same call id.
|
|
199
|
+
onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
200
|
+
callEmitted = true;
|
|
201
|
+
if (!exec) exec = await dispatchTool(c.name, args, Object.assign({}, opts, { callId: c.id || null }));
|
|
202
|
+
} else if (promptProfilesMod && c.name === promptProfilesMod.DISCOVER_TOOL_NAME) {
|
|
203
|
+
const requested = args && (args.toolName || args.name || args.tool);
|
|
204
|
+
const spec = (toolSpecs || []).find(s => s && s.function && s.function.name === requested);
|
|
205
|
+
if (!spec) {
|
|
206
|
+
exec = {
|
|
207
|
+
ok: false,
|
|
208
|
+
content: JSON.stringify({ error: { code: 'EUNKNOWN_TOOL', message: 'Unknown tool: ' + requested } }),
|
|
209
|
+
result: { error: { code: 'EUNKNOWN_TOOL', message: 'Unknown tool: ' + requested } }
|
|
210
|
+
};
|
|
211
|
+
} else {
|
|
212
|
+
if (discoveredToolNames) discoveredToolNames.add(requested);
|
|
213
|
+
const fn = spec.function || {};
|
|
214
|
+
exec = {
|
|
215
|
+
ok: true,
|
|
216
|
+
content: JSON.stringify({ name: fn.name, description: fn.description, parameters: fn.parameters }),
|
|
217
|
+
result: { name: fn.name, description: fn.description, parameters: fn.parameters }
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
221
|
+
callEmitted = true;
|
|
222
|
+
} else {
|
|
223
|
+
const authGate = require('./tools/authorization.js');
|
|
224
|
+
// The summary shown on the "Authorization required" card and
|
|
225
|
+
// matched against the file-tool allowlist needs the right
|
|
226
|
+
// argument per tool family. For shell it's the command; for
|
|
227
|
+
// the file tools it's the path (with the optional query /
|
|
228
|
+
// content as a hint, when relevant).
|
|
229
|
+
let summary;
|
|
230
|
+
if (c.name === 'shell') summary = (args && args.cmd) || '';
|
|
231
|
+
else if (c.name === 'subagent') summary = (args && args.task) || '';
|
|
232
|
+
else if (c.name === 'webpreview') {
|
|
233
|
+
// Show the URL the model wants to open so the user can tell
|
|
234
|
+
// at a glance which site it'll preview — beats the generic
|
|
235
|
+
// first-arg fallback because every webpreview call has a
|
|
236
|
+
// `url` argument anyway.
|
|
237
|
+
summary = (args && args.url) || '';
|
|
238
|
+
}
|
|
239
|
+
else if (c.name === 'read_file' || c.name === 'list_files' || c.name === 'search_files' || c.name === 'write_file' || c.name === 'edit_file') {
|
|
240
|
+
summary = (args && (args.path || args.file)) || (args && args.query) || '';
|
|
241
|
+
} else if (String(c.name).startsWith('mcp__')) {
|
|
242
|
+
// MCP allowlists (shared or per-server/per-tool) match
|
|
243
|
+
// against "<composedName> <firstStringArg>" so a pattern
|
|
244
|
+
// can pin either the tool itself (^mcp__fs__read_file$)
|
|
245
|
+
// or the resource it touches (^mcp__fs__read_file src/).
|
|
246
|
+
const first = firstStringArgument(args);
|
|
247
|
+
summary = first ? (c.name + ' ' + first) : c.name;
|
|
248
|
+
} else {
|
|
249
|
+
summary = firstStringArgument(args);
|
|
250
|
+
}
|
|
251
|
+
const authResult = await authGate.authorize({
|
|
252
|
+
projectDir: opts && opts.projectDir,
|
|
253
|
+
chatId: opts && opts.chatId,
|
|
254
|
+
tool: c.name,
|
|
255
|
+
callId: c.id,
|
|
256
|
+
cmd: args && args.cmd,
|
|
257
|
+
path: args && args.path,
|
|
258
|
+
query: args && args.query,
|
|
259
|
+
summary,
|
|
260
|
+
timeoutMs: args && args.timeoutMs,
|
|
261
|
+
args
|
|
262
|
+
});
|
|
263
|
+
// The gate returns the timeout it resolved against the project's
|
|
264
|
+
// `defaultTimeoutMs` / `maxTimeoutMs`. That value — never the raw
|
|
265
|
+
// model-supplied one — is what the runner must use and what the
|
|
266
|
+
// approval card must show: the model cannot raise its own ceiling
|
|
267
|
+
// by asking for a longer timeout.
|
|
268
|
+
const clampedTimeoutMs = Number.isFinite(authResult && authResult.timeoutMs)
|
|
269
|
+
? authResult.timeoutMs
|
|
270
|
+
: (args && args.timeoutMs);
|
|
271
|
+
|
|
272
|
+
// `ask_user` rides a separate UI card (question + options +
|
|
273
|
+
// free-form "extra" textbox). The same authorization gate is
|
|
274
|
+
// reused so the audit log, session grants, and `off` /
|
|
275
|
+
// `allow-always` semantics work the same as for the other
|
|
276
|
+
// tools. The dedicated `ask_user_required` event carries the
|
|
277
|
+
// validated question payload so the chat UI can render the
|
|
278
|
+
// right component without parsing `args` itself.
|
|
279
|
+
let askUserPayload = null;
|
|
280
|
+
if (c.name === 'ask_user') {
|
|
281
|
+
try {
|
|
282
|
+
const askMod = require('./tools/ask.js');
|
|
283
|
+
askUserPayload = askMod.validateArgs(args);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
// The model fed us a bad question (too many options,
|
|
286
|
+
// duplicate value, missing label, ...). Surface the
|
|
287
|
+
// validation error directly as a tool_result so the
|
|
288
|
+
// model can self-correct on the next turn; do NOT block
|
|
289
|
+
// the gate on a user prompt, because the bug is on the
|
|
290
|
+
// model side, not the user side.
|
|
291
|
+
const r = { error: { code: e.code || 'EBADINPUT', message: e.message } };
|
|
292
|
+
exec = { ok: false, content: JSON.stringify(r), result: r };
|
|
293
|
+
onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
294
|
+
callEmitted = true;
|
|
295
|
+
onEvent('tool_result', { id: c.id || null, name: c.name, ok: false, result: exec.result });
|
|
296
|
+
pushToolMessage(c.name, modelContentForTool(c.name, exec));
|
|
297
|
+
return exec;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (authResult.decision === 'prompt') {
|
|
302
|
+
if (c.name === 'ask_user' && askUserPayload) {
|
|
303
|
+
onEvent('ask_user_required', {
|
|
304
|
+
chatId: opts && opts.chatId,
|
|
305
|
+
callId: c.id,
|
|
306
|
+
tool: c.name,
|
|
307
|
+
question: askUserPayload.question,
|
|
308
|
+
options: askUserPayload.options,
|
|
309
|
+
multiSelect: askUserPayload.multiSelect,
|
|
310
|
+
presets: askUserPayload.presets,
|
|
311
|
+
projectDir: opts && opts.projectDir
|
|
312
|
+
});
|
|
313
|
+
} else {
|
|
314
|
+
onEvent('authorization_required', {
|
|
315
|
+
chatId: opts && opts.chatId,
|
|
316
|
+
callId: c.id,
|
|
317
|
+
tool: c.name,
|
|
318
|
+
cmd: args && args.cmd,
|
|
319
|
+
path: args && args.path,
|
|
320
|
+
query: args && args.query,
|
|
321
|
+
summary,
|
|
322
|
+
timeoutMs: clampedTimeoutMs,
|
|
323
|
+
projectDir: opts && opts.projectDir
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
// Capture the resolved value (allow, payload, ...) so the
|
|
327
|
+
// `ask_user` runner can read the user's structured answer.
|
|
328
|
+
// For every other tool the payload is undefined and the
|
|
329
|
+
// runner ignores it.
|
|
330
|
+
const authDecision = await authResult.wait;
|
|
331
|
+
if (authDecision && authDecision.payload) {
|
|
332
|
+
if (c.name === 'ask_user') {
|
|
333
|
+
callOptsAnswerPayload = authDecision.payload;
|
|
334
|
+
} else if (c.name === 'subagent') {
|
|
335
|
+
if (authDecision.payload.modelOverride) {
|
|
336
|
+
// The authorization card let the user pick a model for
|
|
337
|
+
// this delegated run. Hand it to the subagent dispatcher,
|
|
338
|
+
// which resolves it to a hydrated model and runs the
|
|
339
|
+
// nested call on it (per-call override, never persisted).
|
|
340
|
+
callOptsModelOverride = authDecision.payload.modelOverride;
|
|
341
|
+
}
|
|
342
|
+
if (typeof authDecision.payload.thinkingLevel === 'string') {
|
|
343
|
+
// Per-run thinking level chosen on the same card.
|
|
344
|
+
callOptsThinkingLevel = authDecision.payload.thinkingLevel;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Only announce a running tool after authorization has completed.
|
|
350
|
+
// Previously the UI showed "tool call — running" while the server
|
|
351
|
+
// was actually blocked waiting for an authorization decision. If the
|
|
352
|
+
// authorization card was missed or the page reloaded, the transcript
|
|
353
|
+
// appeared permanently stuck on a tool call with no messages.
|
|
354
|
+
onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
355
|
+
callEmitted = true;
|
|
356
|
+
exec = await dispatchTool(c.name, args, Object.assign({}, opts, { callId: c.id || null, toolTimeoutMs: clampedTimeoutMs, answerPayload: callOptsAnswerPayload, modelOverride: callOptsModelOverride, thinkingLevel: callOptsThinkingLevel }));
|
|
357
|
+
}
|
|
358
|
+
} catch (e) {
|
|
359
|
+
// Denied/disabled/error calls still need a call card immediately
|
|
360
|
+
// before their result so persisted history remains a valid pair.
|
|
361
|
+
if (!callEmitted) onEvent('tool_call', { id: c.id || null, name: c.name, args });
|
|
362
|
+
if (e.code === 'EDENIED') {
|
|
363
|
+
// For `ask_user` we want the runner to produce a
|
|
364
|
+
// `cancelled: true` result so the model can decide what to
|
|
365
|
+
// do next (fall back to a free-form chat, stop, ask a
|
|
366
|
+
// different question, ...). For every other tool a deny
|
|
367
|
+
// stays a plain EDENIED stub.
|
|
368
|
+
if (c.name === 'ask_user') {
|
|
369
|
+
exec = await dispatchTool('ask_user', args, Object.assign({}, opts, { callId: c.id || null, answerPayload: { cancelled: true } }));
|
|
370
|
+
} else {
|
|
371
|
+
exec = { ok: false, content: JSON.stringify({ ok: false, code: 'EDENIED', reason: 'user denied' }), result: { ok: false, code: 'EDENIED', reason: 'user denied' } };
|
|
372
|
+
}
|
|
373
|
+
} else if (e.code === 'ETOOL_DISABLED') {
|
|
374
|
+
exec = { ok: false, content: JSON.stringify({ ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' }), result: { ok: false, code: 'ETOOL_DISABLED', reason: 'tool is disabled' } };
|
|
375
|
+
} else {
|
|
376
|
+
exec = { ok: false, content: JSON.stringify({ ok: false, error: e.message }), result: { ok: false, error: e.message } };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
onEvent('tool_result', { id: c.id || null, name: c.name, ok: exec.ok, result: exec.result });
|
|
381
|
+
|
|
382
|
+
pushToolMessage(c.name, modelContentForTool(c.name, exec));
|
|
383
|
+
// webpreview screenshots are rendered for the user in the preview dock. The
|
|
384
|
+
// model only receives the compact JSON result and can call the tool again to
|
|
385
|
+
// reload the page; it does not receive or inspect the image bytes.
|
|
386
|
+
const imageParts = c.name === 'webpreview' ? [] : toolResultImageParts(exec && exec.result);
|
|
387
|
+
return { exec, imageParts };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- OpenAI prompt-cache routing key ----------------------------------
|
|
391
|
+
// OpenAI-family endpoints cache prompt prefixes automatically, but they only
|
|
392
|
+
// serve a warm cache when consecutive requests of one conversation reach the
|
|
393
|
+
// same machine. `prompt_cache_key` is the documented routing hint for that.
|
|
394
|
+
//
|
|
395
|
+
// The key must be identical for every request of one chat — every tool round
|
|
396
|
+
// and every follow-up turn — and different between chats, which makes the
|
|
397
|
+
// chat id exactly the right source. A request with no chat (a one-shot
|
|
398
|
+
// /api/chat call) returns null, and the builder then omits the field: there
|
|
399
|
+
// is no conversation worth keeping warm. The builder decides which providers
|
|
400
|
+
// accept the field (see ai-endpoints.js → PROMPT_CACHE_KEY_PROVIDERS).
|
|
401
|
+
function promptCacheKeyFor(opts) {
|
|
402
|
+
const chatId = opts && opts.chatId;
|
|
403
|
+
if (!chatId) return null;
|
|
404
|
+
const key = 'mouaif-' + String(chatId);
|
|
405
|
+
// Defensive cap so a hand-set or future id shape can never produce an
|
|
406
|
+
// oversized field. Truncation keeps the prefix, so distinct ids stay
|
|
407
|
+
// distinct.
|
|
408
|
+
return key.length > 64 ? key.slice(0, 64) : key;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function streamChat(opts) {
|
|
412
|
+
const { model, messages, signal, onEvent, onRoundUsage, onRoundCommit, thinkingLevel, maxOutputTokens } = opts || {};
|
|
413
|
+
if (!model || !model.provider) {
|
|
414
|
+
return { ok: false, error: { code: 'EBADMODEL', message: 'Missing model.provider' } };
|
|
415
|
+
}
|
|
416
|
+
if (!Array.isArray(messages) || !messages.length) {
|
|
417
|
+
return { ok: false, error: { code: 'EBADINPUT', message: 'messages must be a non-empty array' } };
|
|
418
|
+
}
|
|
419
|
+
if (typeof onEvent !== 'function') {
|
|
420
|
+
return { ok: false, error: { code: 'EBADINPUT', message: 'onEvent must be a function' } };
|
|
421
|
+
}
|
|
422
|
+
// Pass thinking level and max output tokens down to the request builders
|
|
423
|
+
// so they can inject provider-specific fields (reasoning_effort, thinking
|
|
424
|
+
// budget, max_completion_tokens, max_tokens, etc.).
|
|
425
|
+
if (typeof thinkingLevel === 'string' && thinkingLevel) {
|
|
426
|
+
model.thinkingLevel = thinkingLevel;
|
|
427
|
+
}
|
|
428
|
+
if (typeof maxOutputTokens === 'string' && maxOutputTokens) {
|
|
429
|
+
model.maxOutputTokens = maxOutputTokens;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let def, build, parse;
|
|
433
|
+
try {
|
|
434
|
+
def = endpointFor(model);
|
|
435
|
+
// requireApiKey is async on the OAuth path (proactive refresh);
|
|
436
|
+
// sync-fast on the API-key path. We always `await` it here.
|
|
437
|
+
await requireApiKey(model, def);
|
|
438
|
+
build = BUILDERS[model.provider];
|
|
439
|
+
parse = PARSERS[model.provider];
|
|
440
|
+
} catch (e) {
|
|
441
|
+
return { ok: false, error: { code: e.code || 'EBADMODEL', message: e.message } };
|
|
442
|
+
}
|
|
443
|
+
if (!build || !parse) {
|
|
444
|
+
return { ok: false, error: { code: 'EUNKNOWN_PROVIDER', message: 'No builder/parser for ' + model.provider } };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---- Tool specs advertised to the model ----------------------------
|
|
448
|
+
// Three sources feed the `tools` field of the outgoing request:
|
|
449
|
+
// 1. The native `shell` tool (src/tools/shell.js), always present.
|
|
450
|
+
// 2. The native `report_progress` tool (src/tools/progress.js), always present.
|
|
451
|
+
// 3. The native `subagent` tool (src/tools/subagent.js), always present.
|
|
452
|
+
// 4. The native `ask_user` tool (src/tools/ask.js), always present.
|
|
453
|
+
// Lets the model pause and ask the user a structured question
|
|
454
|
+
// with a list of options (2+, no cap). The user always has a
|
|
455
|
+
// free-form "extra" textbox alongside their pick, so the answer
|
|
456
|
+
// is never constrained to the offered options. See
|
|
457
|
+
// docs/features/ask-user-tool.md.
|
|
458
|
+
// 5. The native file tools (read_file / list_files / search_files /
|
|
459
|
+
// write_file, src/tools/files.js), always present. Authorization
|
|
460
|
+
// decides whether a call prompts, runs, or is rejected. These cover
|
|
461
|
+
// "read this file / find where X is used / patch a small file"
|
|
462
|
+
// loop without requiring an MCP server.
|
|
463
|
+
// 6. MCP-discovered tools (decision §18), which use the
|
|
464
|
+
// mcp__<serverSlug>__<toolName> name convention.
|
|
465
|
+
// Tool calling and the multi-turn loop are wired for the OpenAI-
|
|
466
|
+
// compatible tool shape (openai-compatible + github-copilot + openrouter)
|
|
467
|
+
// and for Anthropic's native tool_use shape (buildAnthropicRequest
|
|
468
|
+
// converts the specs and the parser emits tool_call_delta for tool_use
|
|
469
|
+
// blocks). Other providers stream normally and never see a `tools`
|
|
470
|
+
// field, so their happy path is unchanged.
|
|
471
|
+
const toolSpecs = [];
|
|
472
|
+
try { toolSpecs.push(require('./tools/shell.js').SPEC); }
|
|
473
|
+
catch { /* shell tool module unavailable; skip */ }
|
|
474
|
+
try { toolSpecs.push(require('./tools/progress.js').SPEC); }
|
|
475
|
+
catch { /* progress tool module unavailable; skip */ }
|
|
476
|
+
try {
|
|
477
|
+
const sub = require('./tools/subagent.js');
|
|
478
|
+
// Enumerate the project's agent names in the `agent` parameter
|
|
479
|
+
// description so the model knows exactly what it can delegate to.
|
|
480
|
+
let agentNames = [];
|
|
481
|
+
try {
|
|
482
|
+
if (opts && opts.projectDir) agentNames = require('./agents.js').list(opts.projectDir).map((a) => a.name);
|
|
483
|
+
} catch { /* no agents */ }
|
|
484
|
+
toolSpecs.push(sub.buildSpec ? sub.buildSpec(agentNames) : sub.SPEC);
|
|
485
|
+
}
|
|
486
|
+
catch { /* subagent tool module unavailable; skip */ }
|
|
487
|
+
try { toolSpecs.push(require('./tools/ask.js').SPEC); }
|
|
488
|
+
catch { /* ask_user tool module unavailable; skip */ }
|
|
489
|
+
try { toolSpecs.push(require('./agentFeatures.js').LIST_FEATURES_SPEC); }
|
|
490
|
+
catch { /* list_features tool module unavailable; skip */ }
|
|
491
|
+
try { toolSpecs.push(require('./tools/task.js').SPEC); }
|
|
492
|
+
catch { /* task tool module unavailable; skip */ }
|
|
493
|
+
try { toolSpecs.push(require('./tools/restart.js').SPEC); }
|
|
494
|
+
catch { /* restart tool module unavailable; skip */ }
|
|
495
|
+
try {
|
|
496
|
+
const skillSpec = require('./agentSkills.js').buildSpec(opts && opts.projectDir, opts && opts.chat);
|
|
497
|
+
|
|
498
|
+
if (skillSpec) toolSpecs.push(skillSpec);
|
|
499
|
+
} catch { /* skills unavailable; skip */ }
|
|
500
|
+
try {
|
|
501
|
+
const ft = require('./tools/files.js');
|
|
502
|
+
for (const name of ft.FILE_TOOL_NAMES) toolSpecs.push(ft.SPECS[name]);
|
|
503
|
+
} catch { /* file tools module unavailable; skip */ }
|
|
504
|
+
// Native web-preview tool: opens a URL in the debug Chrome and
|
|
505
|
+
// returns a small JPEG thumbnail. Same CDP bridge as the Inspector
|
|
506
|
+
// tab, gated by the project-level `webpreview` authorization mode.
|
|
507
|
+
try { toolSpecs.push(require('./tools/webpreview.js').SPEC); }
|
|
508
|
+
catch { /* webpreview module unavailable; skip */ }
|
|
509
|
+
try {
|
|
510
|
+
if (opts && opts.projectDir) {
|
|
511
|
+
const mcpMod = require('./mcp.js');
|
|
512
|
+
const specs = mcpMod.listComposedToolSpecs(opts.projectDir);
|
|
513
|
+
if (specs && specs.length) {
|
|
514
|
+
for (const s of specs) {
|
|
515
|
+
toolSpecs.push({
|
|
516
|
+
type: 'function',
|
|
517
|
+
function: { name: s.name, description: s.description, parameters: s.parameters }
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} catch { /* mcp module not loaded or project dir invalid; fall through without MCP tools */ }
|
|
523
|
+
|
|
524
|
+
// Tools in `off` authorization mode are dropped from the advertised
|
|
525
|
+
// set: a hidden tool costs zero prompt tokens and the model cannot
|
|
526
|
+
// waste turns calling something that would fail with ETOOL_DISABLED.
|
|
527
|
+
// authorize() still rejects `off` calls at execution time as
|
|
528
|
+
// defense-in-depth (e.g. a hand-crafted REST call or a stale spec
|
|
529
|
+
// name kept in a chat's tool filter). File tools resolve through
|
|
530
|
+
// their `file` family name; MCP tools resolve per tool / per server
|
|
531
|
+
// through the layered .mcp.json authorization block.
|
|
532
|
+
try {
|
|
533
|
+
if (opts && opts.projectDir) {
|
|
534
|
+
const authz = require('./tools/authorization.js');
|
|
535
|
+
const authState = authz.getAuthorization(opts.projectDir, opts && opts.chatId);
|
|
536
|
+
for (const family of ['shell', 'subagent', 'file', 'ask_user', 'report_progress', 'task', 'webpreview', 'restart_app']) {
|
|
537
|
+
const cfg = authState.tools[family];
|
|
538
|
+
if (cfg && cfg.mode === 'off') {
|
|
539
|
+
const hidden = family === 'file' ? authz.FILE_FAMILY_TOOLS : new Set([family]);
|
|
540
|
+
for (let i = toolSpecs.length - 1; i >= 0; i--) {
|
|
541
|
+
const spec = toolSpecs[i];
|
|
542
|
+
if (spec && spec.function && hidden.has(spec.function.name)) toolSpecs.splice(i, 1);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Per-leaf file overrides: a single file operation can carry its own
|
|
547
|
+
// `off` (e.g. tools.read_file.mode = "off") while the `file` family
|
|
548
|
+
// stays enabled. The family loop above only fires when the family
|
|
549
|
+
// itself is `off`, so resolve each file-tool spec through
|
|
550
|
+
// effectiveConfig to honor the leaf. Without this the leaf was still
|
|
551
|
+
// advertised even though the execution gate rejects it with
|
|
552
|
+
// ETOOL_DISABLED — the model paid tokens for a tool it could never
|
|
553
|
+
// use. A family-level `off` still hides every leaf (the loop above
|
|
554
|
+
// runs first and drops them all).
|
|
555
|
+
for (let i = toolSpecs.length - 1; i >= 0; i--) {
|
|
556
|
+
const spec = toolSpecs[i];
|
|
557
|
+
if (!spec || !spec.function || !authz.FILE_FAMILY_TOOLS.has(spec.function.name)) continue;
|
|
558
|
+
const cfg = authz.effectiveConfig(opts.projectDir, spec.function.name, opts && opts.chatId);
|
|
559
|
+
if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
|
|
560
|
+
}
|
|
561
|
+
// MCP tools resolve through the layered gate (per-tool →
|
|
562
|
+
// per-server → shared, see authorization.mcpLayeredConfig): an
|
|
563
|
+
// `off` at any level hides exactly the mcp__<slug>__<tool> specs
|
|
564
|
+
// it covers — one server, or one tool — at zero prompt-token
|
|
565
|
+
// cost. Execution still rejects forged calls with ETOOL_DISABLED
|
|
566
|
+
// through authorize().
|
|
567
|
+
for (let i = toolSpecs.length - 1; i >= 0; i--) {
|
|
568
|
+
const spec = toolSpecs[i];
|
|
569
|
+
if (!spec || !spec.function || !String(spec.function.name).startsWith('mcp__')) continue;
|
|
570
|
+
const cfg = authz.effectiveConfig(opts.projectDir, spec.function.name, opts && opts.chatId);
|
|
571
|
+
if (cfg && cfg.mode === 'off') toolSpecs.splice(i, 1);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
} catch { /* authorization state unreadable; keep every tool advertised */ }
|
|
575
|
+
|
|
576
|
+
// Per-chat tool filter. opts.enabledTools === null / undefined:
|
|
577
|
+
// legacy behavior — every collected spec is advertised. An array
|
|
578
|
+
// (even empty): restrict to those names exactly. Unknown names
|
|
579
|
+
// are dropped silently so a stale chat (a tool that was renamed
|
|
580
|
+
// or whose MCP server was stopped) does not fail the request.
|
|
581
|
+
// The array is captured here once — the chat UI persists the
|
|
582
|
+
// same set on the chat record, so we don't need to re-read it.
|
|
583
|
+
let visibleToolSpecs = toolSpecs;
|
|
584
|
+
if (opts && Array.isArray(opts.enabledTools)) {
|
|
585
|
+
const allow = new Set(opts.enabledTools.map((n) => String(n)));
|
|
586
|
+
visibleToolSpecs = toolSpecs.filter((s) => s && s.function && allow.has(s.function.name));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Shrink the tool declaration according to the active prompt-size
|
|
590
|
+
// profile (decisions §4). For very-small, the list is compact (name +
|
|
591
|
+
// description, no schemas) but FIXED for the whole turn — it must not
|
|
592
|
+
// grow between tool-loop requests, because Anthropic's cached prefix
|
|
593
|
+
// (system + tools) would change and the warm cache would be
|
|
594
|
+
// invalidated on every round. average/extensive send the full specs
|
|
595
|
+
// from the start. `discoveredToolNames` is retained for the
|
|
596
|
+
// discover_tool dispatcher (it decides what the tool returns), but it
|
|
597
|
+
// no longer changes the advertised tool list.
|
|
598
|
+
const discoveredToolNames = new Set();
|
|
599
|
+
let promptProfilesMod = null;
|
|
600
|
+
try { promptProfilesMod = require('./promptProfiles.js'); } catch { /* optional */ }
|
|
601
|
+
|
|
602
|
+
// The multi-turn tool loop. `convo` is the working message array; it
|
|
603
|
+
// grows by one assistant (tool-call) message + N tool-result messages
|
|
604
|
+
// each iteration the model asks for tools. The model decides when its
|
|
605
|
+
// task is complete; tool use is not cut off after an arbitrary count.
|
|
606
|
+
const convo = messages.slice();
|
|
607
|
+
const usage = { promptTokens: 0, completionTokens: 0 };
|
|
608
|
+
// Anthropic prompt-cache totals across all tool rounds in this turn.
|
|
609
|
+
// Filled by commitRoundUsage() from the per-round trackers; folded into
|
|
610
|
+
// the final `done` usage block so the server and chat UI can price the
|
|
611
|
+
// cached tokens at the discounted rate.
|
|
612
|
+
const usageCache = { readTokens: 0, creationTokens: 0 };
|
|
613
|
+
const delegatedUsage = { promptTokens: 0, completionTokens: 0, count: 0, costCount: 0 };
|
|
614
|
+
let providerCost = null;
|
|
615
|
+
let delegatedProviderCost = null;
|
|
616
|
+
let completedToolRound = false;
|
|
617
|
+
let emptyPostToolRetries = 0;
|
|
618
|
+
const FINAL_ANSWER_RETRIES = 2;
|
|
619
|
+
// Identical-call circuit breaker state (enforced in runOneCall below).
|
|
620
|
+
// The loop has no fixed turn limit, so a model retrying the exact
|
|
621
|
+
// same failing call (e.g. an interactive command that exits
|
|
622
|
+
// immediately) would otherwise spin forever.
|
|
623
|
+
let lastToolCallKey = null;
|
|
624
|
+
let repeatedToolCallCount = 0;
|
|
625
|
+
const REPEATED_TOOL_CALL_LIMIT = 3;
|
|
626
|
+
|
|
627
|
+
function modelContentForTool(name, exec) {
|
|
628
|
+
return toolFeedback.compactToolFeedback({
|
|
629
|
+
name,
|
|
630
|
+
content: exec && exec.content,
|
|
631
|
+
result: exec && exec.result,
|
|
632
|
+
maxBytes: opts && opts.appSettings && opts.appSettings.toolFeedbackMaxBytes,
|
|
633
|
+
toolOutput: opts && opts.toolOutput
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
while (true) {
|
|
638
|
+
let effectiveToolSpecs = visibleToolSpecs;
|
|
639
|
+
try {
|
|
640
|
+
effectiveToolSpecs = promptProfilesMod
|
|
641
|
+
? promptProfilesMod.reduceToolSpecs(visibleToolSpecs, opts && opts.promptSize, { discoveredToolNames })
|
|
642
|
+
: visibleToolSpecs;
|
|
643
|
+
} catch { /* non-fatal; fall back to the per-chat filtered set */ }
|
|
644
|
+
const result = await runUpstreamTurn(convo, effectiveToolSpecs);
|
|
645
|
+
if (!result.ok) return { ok: false, error: result.error, usage };
|
|
646
|
+
|
|
647
|
+
const calls = result.toolCalls;
|
|
648
|
+
if (!calls || !calls.length) {
|
|
649
|
+
// Some OpenAI-compatible models end the first follow-up request with
|
|
650
|
+
// `stop` but no content after receiving a tool result. Treat that as
|
|
651
|
+
// an incomplete exchange rather than a successful empty answer. A
|
|
652
|
+
// short system reminder reliably gets the model to summarize the tool
|
|
653
|
+
// output, while the retry cap prevents a silent model from looping.
|
|
654
|
+
if (completedToolRound && !String(result.assistantText || '').trim() && emptyPostToolRetries < FINAL_ANSWER_RETRIES) {
|
|
655
|
+
emptyPostToolRetries++;
|
|
656
|
+
// Anthropic merges every system-role message into the cached
|
|
657
|
+
// system block, so a mid-conversation system reminder would
|
|
658
|
+
// change the cache prefix and invalidate the warm cache for the
|
|
659
|
+
// rest of the chat. Ride it as a user message instead — the
|
|
660
|
+
// alternating user/assistant pattern stays valid and the system
|
|
661
|
+
// block (the cache breakpoint) stays byte-identical.
|
|
662
|
+
convo.push({
|
|
663
|
+
role: model.provider === 'anthropic' ? 'user' : 'system',
|
|
664
|
+
content: 'Your previous response was empty. Return the final user-facing answer now. Do not call a tool and do not return an empty response.'
|
|
665
|
+
});
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (completedToolRound && !String(result.assistantText || '').trim()) {
|
|
669
|
+
onEvent('message', {
|
|
670
|
+
delta: 'Tool execution finished, but the model did not provide a final response. Review the tool results above before retrying.'
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
// No tool calls this turn -> the assistant is done. Emit the
|
|
674
|
+
// final `done` with the accumulated usage and return. Parent
|
|
675
|
+
// prompt tokens stay last-round-wins, but delegated subagent
|
|
676
|
+
// requests are separate upstream calls and must be added so the
|
|
677
|
+
// chat's total usage/cost matches what providers billed.
|
|
678
|
+
const finalUsage = usageWithDelegated();
|
|
679
|
+
const finalProviderCost = totalProviderCost();
|
|
680
|
+
const finalDelegatedCost = delegatedCostTotal();
|
|
681
|
+
const finalTotalCost = totalRunCost();
|
|
682
|
+
onEvent('done', { usage: finalUsage, providerCost: finalProviderCost, delegatedCost: finalDelegatedCost });
|
|
683
|
+
return { ok: true, usage: finalUsage, providerCost: finalProviderCost, delegatedCost: finalDelegatedCost, totalCost: finalTotalCost };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
for (const c of calls) {
|
|
687
|
+
if (!c.id) c.id = 'call_' + Math.random().toString(36).slice(2, 12);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// The model asked for tools. Append the assistant's tool-call
|
|
691
|
+
// message (OpenAI shape) so the follow-up request has the context.
|
|
692
|
+
convo.push({
|
|
693
|
+
role: 'assistant',
|
|
694
|
+
content: result.assistantText || null,
|
|
695
|
+
tool_calls: calls.map(c => ({
|
|
696
|
+
id: c.id || undefined,
|
|
697
|
+
type: 'function',
|
|
698
|
+
function: { name: c.name, arguments: c.arguments || '{}' }
|
|
699
|
+
}))
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Close the streamed assistant segment before tool cards are emitted.
|
|
703
|
+
// A model may send explanatory text and then request a tool; without an
|
|
704
|
+
// explicit boundary the browser keeps one live bubble above the tool
|
|
705
|
+
// cards and appends the post-tool answer back into that old bubble.
|
|
706
|
+
onEvent('assistant_turn_end', { content: result.assistantText || '', hasToolCalls: true });
|
|
707
|
+
|
|
708
|
+
// Execute each call, emit tool_call + tool_result, and append the
|
|
709
|
+
// `tool` result message the upstream needs on the next turn.
|
|
710
|
+
// Image blocks are also attached as native vision message parts after
|
|
711
|
+
// all required tool messages have been added.
|
|
712
|
+
//
|
|
713
|
+
// Parallelism: when every call in this turn is a `subagent`, run them
|
|
714
|
+
// concurrently — subagents are read-mostly nested chats, so the model
|
|
715
|
+
// can fan out independent research/analysis tasks in one turn. Mixed
|
|
716
|
+
// batches (subagent + file/shell/MCP) stay sequential so ordering
|
|
717
|
+
// guarantees hold for tools with side effects. The authorization
|
|
718
|
+
// session is keyed by callId and the UI routes nested events by
|
|
719
|
+
// parentCallId, so concurrent subagents prompt and render correctly.
|
|
720
|
+
const runParallel = calls.length > 1 && calls.every((c) => c.name === 'subagent');
|
|
721
|
+
const postToolImageMessages = [];
|
|
722
|
+
// Single-call runner shared with POST /api/tools/subagent (direct
|
|
723
|
+
// @agent dispatch from the composer). Closure state: convo (tool
|
|
724
|
+
// messages), call-key circuit breaker, delegated-usage counters,
|
|
725
|
+
// and the discoveredToolNames set for the very-small profile.
|
|
726
|
+
const runOneCall = async (c) => {
|
|
727
|
+
const out = await runSingleToolCall(c, {
|
|
728
|
+
opts,
|
|
729
|
+
onEvent,
|
|
730
|
+
convo,
|
|
731
|
+
toolSpecs,
|
|
732
|
+
visibleToolSpecs,
|
|
733
|
+
promptProfilesMod,
|
|
734
|
+
discoveredToolNames,
|
|
735
|
+
modelContentForTool,
|
|
736
|
+
dispatchTool,
|
|
737
|
+
firstStringArgument,
|
|
738
|
+
toolResultImageParts,
|
|
739
|
+
getLastToolCallKey: () => lastToolCallKey,
|
|
740
|
+
setLastToolCallKey: (k) => { lastToolCallKey = k; },
|
|
741
|
+
getRepeatedToolCallCount: () => repeatedToolCallCount,
|
|
742
|
+
setRepeatedToolCallCount: (n) => { repeatedToolCallCount = n; },
|
|
743
|
+
REPEATED_TOOL_CALL_LIMIT
|
|
744
|
+
});
|
|
745
|
+
const imageParts = out && out.imageParts;
|
|
746
|
+
if (imageParts && imageParts.length) {
|
|
747
|
+
postToolImageMessages.push({
|
|
748
|
+
role: 'user',
|
|
749
|
+
content: [
|
|
750
|
+
{ type: 'text', text: 'Image result from tool `' + c.name + '`:' },
|
|
751
|
+
...imageParts
|
|
752
|
+
]
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
return out.exec;
|
|
756
|
+
};
|
|
757
|
+
if (runParallel) {
|
|
758
|
+
// Concurrent subagent fan-out. `convo` and `postToolImageMessages`
|
|
759
|
+
// are appended from each async worker; ordering of the tool
|
|
760
|
+
// messages in the follow-up request doesn't carry semantics (each
|
|
761
|
+
// is matched by tool_call_id), so completion order is fine.
|
|
762
|
+
await Promise.all(calls.map((c) => runOneCall(c)));
|
|
763
|
+
} else {
|
|
764
|
+
for (const c of calls) await runOneCall(c);
|
|
765
|
+
}
|
|
766
|
+
if (postToolImageMessages.length) convo.push(...postToolImageMessages);
|
|
767
|
+
completedToolRound = true;
|
|
768
|
+
emptyPostToolRetries = 0;
|
|
769
|
+
// Loop: request again with the tool results in context.
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ---- One upstream request (stream + accumulate) --------------------
|
|
773
|
+
// Performs a single request/response against the provider, streaming
|
|
774
|
+
// `message` deltas through onEvent as they arrive. Returns
|
|
775
|
+
// { ok: true, assistantText, toolCalls: [{ id, name, arguments }] }
|
|
776
|
+
// or { ok: false, error }. `done` is NOT emitted here — the caller
|
|
777
|
+
// decides when the whole exchange is finished.
|
|
778
|
+
async function runUpstreamTurn(convoMessages, specs) {
|
|
779
|
+
const req = build(model, convoMessages, true, specs, { promptCacheKey: promptCacheKeyFor(opts) });
|
|
780
|
+
const supportsOpenAITools = model.provider === 'openai-compatible'
|
|
781
|
+
|| model.provider === 'openrouter'
|
|
782
|
+
|| model.provider === 'github-copilot';
|
|
783
|
+
if (supportsOpenAITools && specs && specs.length) {
|
|
784
|
+
const builderBody = req.body;
|
|
785
|
+
if (builderBody && typeof builderBody === 'object') {
|
|
786
|
+
builderBody.tools = specs;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
// Anthropic streams a tool_use block's arguments as input_json_delta
|
|
790
|
+
// frames across multiple SSE events. The parser generator is re-created
|
|
791
|
+
// for every frame, so the tool_use accumulator lives here — one map per
|
|
792
|
+
// upstream turn, shared by every parser call of that turn. Other
|
|
793
|
+
// providers pass the accumulator-less parser through untouched.
|
|
794
|
+
const anthropicToolAcc = model.provider === 'anthropic' ? new Map() : null;
|
|
795
|
+
const parseTurn = (eventName, data) => anthropicToolAcc
|
|
796
|
+
? parse(eventName, data, anthropicToolAcc)
|
|
797
|
+
: parse(eventName, data);
|
|
798
|
+
// Idle watchdog on the upstream request. The provider can accept the
|
|
799
|
+
// socket and then go silent (dead gateway, stalled network, overloaded
|
|
800
|
+
// model): without a deadline the server waits forever, the chat shows
|
|
801
|
+
// "streaming…" permanently, and the running marker wedges the chat
|
|
802
|
+
// (every retry gets 409 EALREADY_RUNNING). The timer resets on every
|
|
803
|
+
// streamed byte, so a slow-but-chatty model never trips it — only a
|
|
804
|
+
// genuinely silent one does. UPSTREAM_IDLE_MS covers the quiet gap
|
|
805
|
+
// before the first token too (models can "think" for a long time
|
|
806
|
+
// before emitting anything).
|
|
807
|
+
const UPSTREAM_IDLE_MS = 180000; // 3 min of total silence = stuck
|
|
808
|
+
const upstreamCtl = new AbortController();
|
|
809
|
+
let idleTimer = null;
|
|
810
|
+
const resetIdle = () => {
|
|
811
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
812
|
+
idleTimer = setTimeout(() => {
|
|
813
|
+
try { upstreamCtl.abort(new Error('provider idle timeout')); } catch { /* already settled */ }
|
|
814
|
+
}, UPSTREAM_IDLE_MS);
|
|
815
|
+
};
|
|
816
|
+
resetIdle();
|
|
817
|
+
const stopIdle = () => { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } };
|
|
818
|
+
// An outer signal (client disconnect) aborts the same request.
|
|
819
|
+
let outerAbort = null;
|
|
820
|
+
if (signal) {
|
|
821
|
+
outerAbort = () => { try { upstreamCtl.abort(signal.reason || new Error('client disconnected')); } catch { /* already settled */ } };
|
|
822
|
+
if (signal.aborted) outerAbort();
|
|
823
|
+
else signal.addEventListener('abort', outerAbort, { once: true });
|
|
824
|
+
}
|
|
825
|
+
const upstream = await fetch(req.url, {
|
|
826
|
+
method: 'POST',
|
|
827
|
+
headers: req.headers,
|
|
828
|
+
body: JSON.stringify(req.body),
|
|
829
|
+
signal: upstreamCtl.signal
|
|
830
|
+
}).catch((e) => {
|
|
831
|
+
return { __networkError: e };
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
if (upstream && upstream.__networkError) {
|
|
835
|
+
stopIdle();
|
|
836
|
+
if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
|
|
837
|
+
const e = upstream.__networkError;
|
|
838
|
+
if (e && e.name === 'AbortError') {
|
|
839
|
+
const idle = upstreamCtl.signal.reason && upstreamCtl.signal.reason.message === 'provider idle timeout';
|
|
840
|
+
const clientGone = signal && signal.aborted;
|
|
841
|
+
if (clientGone) return { ok: false, error: { code: 'EABORTED', message: 'aborted' } };
|
|
842
|
+
if (idle) return { ok: false, error: { code: 'ETIMEOUT', message: 'Provider sent nothing for 3 minutes — the request was cancelled. Try again.' } };
|
|
843
|
+
return { ok: false, error: { code: 'EABORTED', message: 'aborted' } };
|
|
844
|
+
}
|
|
845
|
+
return { ok: false, error: { code: 'ENETWORK', message: e.message || 'network error' } };
|
|
846
|
+
}
|
|
847
|
+
if (!upstream.ok) {
|
|
848
|
+
stopIdle();
|
|
849
|
+
if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
|
|
850
|
+
let detail = '';
|
|
851
|
+
try { detail = await upstream.text(); } catch { /* ignore */ }
|
|
852
|
+
return {
|
|
853
|
+
ok: false,
|
|
854
|
+
error: {
|
|
855
|
+
code: 'EUPSTREAM',
|
|
856
|
+
message: 'Upstream ' + upstream.status + ' ' + upstream.statusText,
|
|
857
|
+
detail: detail.slice(0, 2000)
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Stream -> normalize -> onEvent. Assistant text and any tool-call
|
|
863
|
+
// deltas are accumulated locally; the caller (the tool loop) decides
|
|
864
|
+
// what to do with them. `done` is NOT emitted here.
|
|
865
|
+
let sawError = null;
|
|
866
|
+
let assistantText = '';
|
|
867
|
+
let reasoningText = '';
|
|
868
|
+
// OpenAI tool-call accumulator. Deltas arrive split across frames;
|
|
869
|
+
// we assemble by `index`. The accumulator lives only for the
|
|
870
|
+
// duration of one turn.
|
|
871
|
+
const toolAcc = new Map(); // index -> { id, name, arguments }
|
|
872
|
+
// Per-round usage trackers. OpenAI-shaped providers report usage once
|
|
873
|
+
// on the final chunk (with stream_options.include_usage), but some
|
|
874
|
+
// compatible gateways stamp a running total on every chunk. Summing
|
|
875
|
+
// those would double-count, so the round's LAST non-zero report is
|
|
876
|
+
// committed once by commitRoundUsage() when the stream ends. Across
|
|
877
|
+
// tool rounds, completion tokens and cost are genuinely new and do sum.
|
|
878
|
+
let roundPromptTokens = null;
|
|
879
|
+
let roundCompletionTokens = null;
|
|
880
|
+
// Anthropic prompt-cache metrics. `cache_read_input_tokens` are tokens
|
|
881
|
+
// served from the provider's prompt cache (billed at ~10% of the input
|
|
882
|
+
// rate); `cache_creation_input_tokens` are the tokens written into the
|
|
883
|
+
// cache on this request (billed at 1.25× the input rate). They flow from
|
|
884
|
+
// message_start (parseAnthropicSSE → usage_input) into the per-round
|
|
885
|
+
// trackers so per-segment cost and the final turn cost can price them
|
|
886
|
+
// correctly instead of charging everything at the full input rate.
|
|
887
|
+
let roundCacheReadTokens = null;
|
|
888
|
+
let roundCacheCreationTokens = null;
|
|
889
|
+
let roundProviderCost = null;
|
|
890
|
+
let roundProviderCostInput = null;
|
|
891
|
+
let roundProviderCostOutput = null;
|
|
892
|
+
let roundUsageCommitted = false;
|
|
893
|
+
const stream = upstream.body;
|
|
894
|
+
const isNDJSON = def.streamFormat === 'ndjson';
|
|
895
|
+
try {
|
|
896
|
+
if (isNDJSON) {
|
|
897
|
+
for await (const obj of readNDJSON(stream)) {
|
|
898
|
+
resetIdle(); // any upstream byte proves the provider is alive
|
|
899
|
+
for (const ev of parseTurn('', JSON.stringify(obj))) {
|
|
900
|
+
apply(ev);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
} else {
|
|
904
|
+
for await (const ev of readSSE(stream)) {
|
|
905
|
+
resetIdle(); // any upstream byte proves the provider is alive
|
|
906
|
+
for (const out of parseTurn(ev.eventName, ev.data)) {
|
|
907
|
+
apply(out);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
} catch (e) {
|
|
912
|
+
stopIdle();
|
|
913
|
+
if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
|
|
914
|
+
if (e && e.name === 'AbortError') {
|
|
915
|
+
const idle = upstreamCtl.signal.reason && upstreamCtl.signal.reason.message === 'provider idle timeout';
|
|
916
|
+
const clientGone = signal && signal.aborted;
|
|
917
|
+
if (!clientGone && idle) {
|
|
918
|
+
onEvent('error', { code: 'ETIMEOUT', message: 'Provider went silent mid-stream — the request was cancelled. Try again.' });
|
|
919
|
+
return { ok: false, error: { code: 'ETIMEOUT', message: 'Provider went silent mid-stream — the request was cancelled. Try again.' } };
|
|
920
|
+
}
|
|
921
|
+
if (!clientGone) onEvent('error', { code: 'EABORTED', message: 'aborted' });
|
|
922
|
+
return { ok: false, error: { code: 'EABORTED', message: 'aborted' } };
|
|
923
|
+
}
|
|
924
|
+
onEvent('error', { code: 'EUPSTREAM', message: e.message || 'stream error' });
|
|
925
|
+
return { ok: false, error: { code: 'EUPSTREAM', message: e.message || 'stream error' } };
|
|
926
|
+
}
|
|
927
|
+
stopIdle();
|
|
928
|
+
if (signal && outerAbort) signal.removeEventListener('abort', outerAbort);
|
|
929
|
+
|
|
930
|
+
// Commit the round's usage exactly once. Providers that stamp usage
|
|
931
|
+
// on every chunk (not just the final one) would otherwise have their
|
|
932
|
+
// running totals summed into the turn aggregate (double-count). The
|
|
933
|
+
// last non-zero report of the round wins — see apply()'s `done`.
|
|
934
|
+
commitRoundUsage();
|
|
935
|
+
|
|
936
|
+
if (sawError) return { ok: false, error: sawError };
|
|
937
|
+
|
|
938
|
+
// Collapse the accumulator into an ordered list of tool calls.
|
|
939
|
+
const toolCalls = [];
|
|
940
|
+
for (const tc of toolAcc.values()) {
|
|
941
|
+
if (tc.name) toolCalls.push({ id: tc.id, name: tc.name, arguments: tc.arguments });
|
|
942
|
+
}
|
|
943
|
+
// MiniMax/OpenRouter compatibility: if no native OpenAI tool call was
|
|
944
|
+
// emitted, recover calls serialized into assistant text. OpenRouter text is
|
|
945
|
+
// buffered for one turn so private sentinels never flash in the browser.
|
|
946
|
+
if (!toolCalls.length && model.provider === 'openrouter') {
|
|
947
|
+
const compat = parseMiniMaxTextToolCalls(assistantText);
|
|
948
|
+
if (compat.calls.length) {
|
|
949
|
+
assistantText = compat.text;
|
|
950
|
+
toolCalls.push(...compat.calls);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
if (model.provider === 'openrouter') {
|
|
954
|
+
// Reasoning already streamed live as it arrived (see apply()).
|
|
955
|
+
// Only the assistant text is buffered for MiniMax tool-call
|
|
956
|
+
// compatibility; replaying it here would duplicate every thinking
|
|
957
|
+
// delta the client already rendered.
|
|
958
|
+
if (assistantText) onEvent('message', { delta: assistantText });
|
|
959
|
+
}
|
|
960
|
+
return { ok: true, assistantText, toolCalls };
|
|
961
|
+
|
|
962
|
+
function apply(ev) {
|
|
963
|
+
if (ev.name === 'message') {
|
|
964
|
+
if (ev.data && typeof ev.data.delta === 'string') assistantText += ev.data.delta;
|
|
965
|
+
// OpenRouter is buffered until the turn completes because MiniMax may
|
|
966
|
+
// serialize a tool call across several ordinary content deltas.
|
|
967
|
+
if (model.provider !== 'openrouter') onEvent('message', ev.data);
|
|
968
|
+
}
|
|
969
|
+
else if (ev.name === 'reasoning') {
|
|
970
|
+
if (ev.data && typeof ev.data.delta === 'string') reasoningText += ev.data.delta;
|
|
971
|
+
// Reasoning deltas stream live for every provider, OpenRouter
|
|
972
|
+
// included. Unlike `content`, reasoning text never carries a
|
|
973
|
+
// MiniMax-style serialized tool call, so there is no compat
|
|
974
|
+
// reason to buffer it — streaming keeps the client's
|
|
975
|
+
// "Thinking…" block filling in real time instead of popping
|
|
976
|
+
// in as one burst at turn end.
|
|
977
|
+
onEvent('reasoning', ev.data);
|
|
978
|
+
}
|
|
979
|
+
else if (ev.name === 'done') {
|
|
980
|
+
// Record the round's usage into per-round trackers; committed once
|
|
981
|
+
// by commitRoundUsage() when the stream ends. Do NOT emit `done`
|
|
982
|
+
// here — the outer tool loop owns the single final `done` after
|
|
983
|
+
// the whole exchange (all tool round-trips) has completed.
|
|
984
|
+
//
|
|
985
|
+
// promptTokens: the last report wins. Every round-trip re-sends the
|
|
986
|
+
// full conversation, so summing would double-count the context on
|
|
987
|
+
// tool-heavy turns (N rounds × full convo). The final round's
|
|
988
|
+
// prompt is the accurate footprint.
|
|
989
|
+
// completionTokens / providerCost: summed ACROSS rounds (each
|
|
990
|
+
// round's output is genuinely new) but last-wins WITHIN a round,
|
|
991
|
+
// so a provider that stamps running totals on intermediate chunks
|
|
992
|
+
// is not double-counted.
|
|
993
|
+
if (ev.data && ev.data.usage) {
|
|
994
|
+
const p = Number(ev.data.usage.promptTokens);
|
|
995
|
+
const c = Number(ev.data.usage.completionTokens);
|
|
996
|
+
// Only overwrite when the provider actually reported a count;
|
|
997
|
+
// a 0/absent value must not clobber a real number.
|
|
998
|
+
if (isFinite(p) && p > 0) roundPromptTokens = p;
|
|
999
|
+
if (isFinite(c) && c > 0) roundCompletionTokens = c;
|
|
1000
|
+
// Surface the round's prompt footprint so the chat UI can
|
|
1001
|
+
// refresh its context-usage line mid-exchange (tool rounds).
|
|
1002
|
+
// Anthropic already streams usage_input/usage_output; this
|
|
1003
|
+
// covers OpenAI-shaped providers that only report on `done`.
|
|
1004
|
+
if (isFinite(p) && p > 0) onEvent('usage_input', { promptTokens: p });
|
|
1005
|
+
}
|
|
1006
|
+
const cost = ev.data && ev.data.providerCost;
|
|
1007
|
+
if (typeof cost === 'number' && isFinite(cost) && cost >= 0) {
|
|
1008
|
+
roundProviderCost = cost;
|
|
1009
|
+
// firstFiniteNumberOrNull leaves absent fields null; keep "no
|
|
1010
|
+
// breakdown reported" distinct from a genuine $0 so the split
|
|
1011
|
+
// doesn't masquerade as known.
|
|
1012
|
+
const norm = (v) => (typeof v === 'number' && isFinite(v) && v > 0) ? v : null;
|
|
1013
|
+
roundProviderCostInput = norm(ev.data && ev.data.providerCostInput);
|
|
1014
|
+
roundProviderCostOutput = norm(ev.data && ev.data.providerCostOutput);
|
|
1015
|
+
}
|
|
1016
|
+
} else if (ev.name === 'usage_input') {
|
|
1017
|
+
const p = Number(ev.data && ev.data.promptTokens);
|
|
1018
|
+
// Anthropic reports this once at message_start; last wins so the
|
|
1019
|
+
// final round's prompt (the full conversation footprint) prevails.
|
|
1020
|
+
if (isFinite(p) && p > 0) roundPromptTokens = p;
|
|
1021
|
+
// Cache metrics ride the same message_start frame. The last non-zero
|
|
1022
|
+
// report wins (mirrors the prompt-token rule); a 0/absent value must
|
|
1023
|
+
// not clobber a real number already recorded this round.
|
|
1024
|
+
const cr = Number(ev.data && ev.data.cacheReadTokens);
|
|
1025
|
+
const cc = Number(ev.data && ev.data.cacheCreationTokens);
|
|
1026
|
+
if (isFinite(cr) && cr > 0) roundCacheReadTokens = cr;
|
|
1027
|
+
if (isFinite(cc) && cc > 0) roundCacheCreationTokens = cc;
|
|
1028
|
+
onEvent('usage_input', ev.data);
|
|
1029
|
+
} else if (ev.name === 'usage_output') {
|
|
1030
|
+
const c = Number(ev.data && ev.data.completionTokens);
|
|
1031
|
+
if (isFinite(c) && c > 0) roundCompletionTokens = c;
|
|
1032
|
+
onEvent('usage_output', ev.data);
|
|
1033
|
+
// Anthropic does not put usage on its `done` frame; the output
|
|
1034
|
+
// count arrives on `message_delta` and the input count on
|
|
1035
|
+
// `message_start`. Emit the round snapshot here so per-round cost
|
|
1036
|
+
// reaches intermediate segments for Anthropic too. Anthropic's
|
|
1037
|
+
// deltas are cumulative, so fire per delta — the server keeps the
|
|
1038
|
+
// last (richest) value. The trackers are intentionally NOT
|
|
1039
|
+
// committed here; the end-of-stream commitRoundUsage() folds the
|
|
1040
|
+
// final values into the turn aggregate exactly once.
|
|
1041
|
+
if (typeof onRoundUsage === 'function') {
|
|
1042
|
+
try {
|
|
1043
|
+
if ((roundPromptTokens || 0) > 0 || (roundCompletionTokens || 0) > 0) {
|
|
1044
|
+
onRoundUsage({
|
|
1045
|
+
promptTokens: roundPromptTokens || 0,
|
|
1046
|
+
completionTokens: roundCompletionTokens || 0,
|
|
1047
|
+
cacheReadTokens: roundCacheReadTokens || 0,
|
|
1048
|
+
cacheCreationTokens: roundCacheCreationTokens || 0,
|
|
1049
|
+
providerCost: null,
|
|
1050
|
+
providerCostInput: null,
|
|
1051
|
+
providerCostOutput: null
|
|
1052
|
+
});
|
|
1053
|
+
// The round has a snapshot; commitRoundUsage() must not
|
|
1054
|
+
// emit a duplicate when it folds the aggregates.
|
|
1055
|
+
roundUsageCommitted = true;
|
|
1056
|
+
}
|
|
1057
|
+
} catch { /* listener errors must not abort the stream */ }
|
|
1058
|
+
}
|
|
1059
|
+
} else if (ev.name === 'finish') {
|
|
1060
|
+
// The tool-call finish reason is handled by the outer loop
|
|
1061
|
+
// (it emits tool_call / tool_result). Pass through only the
|
|
1062
|
+
// non-tool finish reasons so the UI can show them.
|
|
1063
|
+
if (!(ev.data && ev.data.reason === 'tool_calls')) {
|
|
1064
|
+
onEvent('finish', ev.data);
|
|
1065
|
+
}
|
|
1066
|
+
} else if (ev.name === 'tool_call_delta') {
|
|
1067
|
+
// OpenAI streams tool calls as a list of deltas. Accumulate
|
|
1068
|
+
// by `index`. The first delta carries the `id`; subsequent
|
|
1069
|
+
// deltas fill in `function.name` (sometimes) and
|
|
1070
|
+
// `function.arguments` (a JSON string we concatenate).
|
|
1071
|
+
const d = ev.data;
|
|
1072
|
+
const idx = (typeof d.index === 'number') ? d.index : 0;
|
|
1073
|
+
let cur = toolAcc.get(idx);
|
|
1074
|
+
if (!cur) { cur = { id: null, name: '', arguments: '' }; toolAcc.set(idx, cur); }
|
|
1075
|
+
if (d.id) cur.id = d.id;
|
|
1076
|
+
if (d.function) {
|
|
1077
|
+
if (typeof d.function.name === 'string' && d.function.name) cur.name = d.function.name;
|
|
1078
|
+
if (typeof d.function.arguments === 'string') cur.arguments += d.function.arguments;
|
|
1079
|
+
}
|
|
1080
|
+
} else if (ev.name === 'error') {
|
|
1081
|
+
sawError = { code: ev.data.code || 'EUPSTREAM', message: ev.data.message || 'upstream error' };
|
|
1082
|
+
onEvent('error', ev.data);
|
|
1083
|
+
} else if (ev.name === 'passthrough') {
|
|
1084
|
+
onEvent('passthrough', ev.data);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Fold the round's latest usage report into the turn aggregate. The
|
|
1089
|
+
// trackers are consumed, so each report is added exactly once: a
|
|
1090
|
+
// provider that stamps usage on every chunk overwrites the pending
|
|
1091
|
+
// value (last-wins) instead of accumulating it. Anthropic commits per
|
|
1092
|
+
// `usage_output` delta; the end-of-stream call is then a no-op and the
|
|
1093
|
+
// real commit for providers that report on `done` (OpenAI-shaped,
|
|
1094
|
+
// Ollama). The per-round snapshot for segment costing is emitted once
|
|
1095
|
+
// per round, carrying the most recent numbers.
|
|
1096
|
+
function commitRoundUsage() {
|
|
1097
|
+
const promptTokens = roundPromptTokens || 0;
|
|
1098
|
+
const completionTokens = roundCompletionTokens || 0;
|
|
1099
|
+
const hasUsage = promptTokens > 0 || completionTokens > 0;
|
|
1100
|
+
const hasCost = typeof roundProviderCost === 'number' && isFinite(roundProviderCost) && roundProviderCost >= 0;
|
|
1101
|
+
if (!hasUsage && !hasCost) return;
|
|
1102
|
+
if (promptTokens > 0) usage.promptTokens = promptTokens;
|
|
1103
|
+
if (completionTokens > 0) usage.completionTokens = (usage.completionTokens || 0) + completionTokens;
|
|
1104
|
+
if (roundCacheReadTokens) usageCache.readTokens += roundCacheReadTokens;
|
|
1105
|
+
if (roundCacheCreationTokens) usageCache.creationTokens += roundCacheCreationTokens;
|
|
1106
|
+
if (hasCost) providerCost = (providerCost || 0) + roundProviderCost;
|
|
1107
|
+
if (!roundUsageCommitted && hasUsage && typeof onRoundUsage === 'function') {
|
|
1108
|
+
try {
|
|
1109
|
+
onRoundUsage({
|
|
1110
|
+
promptTokens,
|
|
1111
|
+
completionTokens,
|
|
1112
|
+
cacheReadTokens: roundCacheReadTokens || 0,
|
|
1113
|
+
cacheCreationTokens: roundCacheCreationTokens || 0,
|
|
1114
|
+
providerCost: hasCost ? roundProviderCost : null,
|
|
1115
|
+
providerCostInput: roundProviderCostInput,
|
|
1116
|
+
providerCostOutput: roundProviderCostOutput
|
|
1117
|
+
});
|
|
1118
|
+
} catch { /* listener errors must not abort the stream */ }
|
|
1119
|
+
}
|
|
1120
|
+
if (typeof onRoundCommit === 'function') {
|
|
1121
|
+
// Fires exactly once per round — when the round's upstream stream has
|
|
1122
|
+
// ended — with the round's final token/cost snapshot. Unlike
|
|
1123
|
+
// onRoundUsage (a snapshot stream that providers reporting usage per
|
|
1124
|
+
// delta fire several times per round), this is a per-round boundary:
|
|
1125
|
+
// the subagent dispatcher bills each nested round the moment it
|
|
1126
|
+
// finishes so the parent chat's running total grows while the
|
|
1127
|
+
// delegated run is still working.
|
|
1128
|
+
try {
|
|
1129
|
+
onRoundCommit({
|
|
1130
|
+
promptTokens,
|
|
1131
|
+
completionTokens,
|
|
1132
|
+
cacheReadTokens: roundCacheReadTokens || 0,
|
|
1133
|
+
cacheCreationTokens: roundCacheCreationTokens || 0,
|
|
1134
|
+
providerCost: hasCost ? roundProviderCost : null,
|
|
1135
|
+
providerCostInput: roundProviderCostInput,
|
|
1136
|
+
providerCostOutput: roundProviderCostOutput
|
|
1137
|
+
});
|
|
1138
|
+
} catch { /* listener errors must not abort the stream */ }
|
|
1139
|
+
}
|
|
1140
|
+
roundUsageCommitted = true;
|
|
1141
|
+
roundPromptTokens = null;
|
|
1142
|
+
roundCompletionTokens = null;
|
|
1143
|
+
roundCacheReadTokens = null;
|
|
1144
|
+
roundCacheCreationTokens = null;
|
|
1145
|
+
roundProviderCost = null;
|
|
1146
|
+
roundProviderCostInput = null;
|
|
1147
|
+
roundProviderCostOutput = null;
|
|
1148
|
+
}
|
|
1149
|
+
} // end runUpstreamTurn
|
|
1150
|
+
|
|
1151
|
+
function usageWithDelegated(includeDelegated = true) {
|
|
1152
|
+
const u = {
|
|
1153
|
+
promptTokens: (usage.promptTokens || 0) + (includeDelegated ? (delegatedUsage.promptTokens || 0) : 0),
|
|
1154
|
+
completionTokens: (usage.completionTokens || 0) + (includeDelegated ? (delegatedUsage.completionTokens || 0) : 0)
|
|
1155
|
+
};
|
|
1156
|
+
// Cache totals are Anthropic-only and carry no delegated counterpart
|
|
1157
|
+
// (subagents may run on a different provider), so include them only
|
|
1158
|
+
// when the parent turn actually reported some.
|
|
1159
|
+
if (usageCache.readTokens || usageCache.creationTokens) {
|
|
1160
|
+
u.cacheReadTokens = usageCache.readTokens;
|
|
1161
|
+
u.cacheCreationTokens = usageCache.creationTokens;
|
|
1162
|
+
}
|
|
1163
|
+
return u;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function parentProviderCost() {
|
|
1167
|
+
return (typeof providerCost === 'number' && isFinite(providerCost) && providerCost >= 0) ? providerCost : null;
|
|
1168
|
+
}
|
|
1169
|
+
function delegatedCostTotal() {
|
|
1170
|
+
if (!delegatedUsage.count) return 0;
|
|
1171
|
+
return delegatedUsage.costCount === delegatedUsage.count ? delegatedProviderCost : null;
|
|
1172
|
+
}
|
|
1173
|
+
function totalProviderCost() {
|
|
1174
|
+
const parent = parentProviderCost();
|
|
1175
|
+
const delegated = delegatedCostTotal();
|
|
1176
|
+
// Keep this legacy aggregate for callers that consume providerCost. New
|
|
1177
|
+
// callers can use delegatedCost/totalCost to avoid pricing nested tokens
|
|
1178
|
+
// with the parent model when only one side has provider-reported billing.
|
|
1179
|
+
if (parent == null || delegated == null) return null;
|
|
1180
|
+
return parent + delegated;
|
|
1181
|
+
}
|
|
1182
|
+
function parentCostTotal() {
|
|
1183
|
+
const exact = parentProviderCost();
|
|
1184
|
+
if (exact != null) return exact;
|
|
1185
|
+
try {
|
|
1186
|
+
const app = opts && opts.appSettings ? opts.appSettings : require('./settings.js').getApp();
|
|
1187
|
+
const estimate = usageMetrics.computeCost({ model, usage: usageWithDelegated(false), app });
|
|
1188
|
+
return estimate && estimate.known ? estimate.total : null;
|
|
1189
|
+
} catch { return null; }
|
|
1190
|
+
}
|
|
1191
|
+
function totalRunCost() {
|
|
1192
|
+
const parent = parentCostTotal();
|
|
1193
|
+
const delegated = delegatedCostTotal();
|
|
1194
|
+
return parent == null || delegated == null ? null : parent + delegated;
|
|
1195
|
+
}
|
|
1196
|
+
function delegatedCostForResult(result) {
|
|
1197
|
+
// An explicit null/undefined means "unknown", not $0: Number(null) is
|
|
1198
|
+
// 0, so the old form counted a subagent whose cost could not be
|
|
1199
|
+
// determined as an exact zero and silently dragged the run's known-cost
|
|
1200
|
+
// guard into "known", understating the total.
|
|
1201
|
+
const total = result && result.totalCost != null ? Number(result.totalCost) : NaN;
|
|
1202
|
+
if (isFinite(total) && total >= 0) return total;
|
|
1203
|
+
const exact = result && result.providerCost != null ? Number(result.providerCost) : NaN;
|
|
1204
|
+
if (isFinite(exact) && exact >= 0) return exact;
|
|
1205
|
+
if (!result || !result.model || !result.usage) return null;
|
|
1206
|
+
try {
|
|
1207
|
+
const app = opts && opts.appSettings ? opts.appSettings : require('./settings.js').getApp();
|
|
1208
|
+
const estimate = usageMetrics.computeCost({ model: result.model, usage: result.usage, app });
|
|
1209
|
+
return estimate && estimate.known ? estimate.total : null;
|
|
1210
|
+
} catch { return null; }
|
|
1211
|
+
}
|
|
1212
|
+
// reportDelegatedUsage(report)
|
|
1213
|
+
//
|
|
1214
|
+
// A nested subagent is one or more *separate* billed upstream calls, so
|
|
1215
|
+
// its tokens and cost ride on top of the parent turn. Reports arrive as
|
|
1216
|
+
// deltas and are folded in as they land:
|
|
1217
|
+
//
|
|
1218
|
+
// - every nested round that finishes reports its own increment (see the
|
|
1219
|
+
// `subagent` branch of dispatchTool), so the chat's live "Total" pill
|
|
1220
|
+
// grows while the delegated run is still working instead of jumping
|
|
1221
|
+
// when the whole run returns;
|
|
1222
|
+
// - the completion report (report.complete) commits the run's `count` —
|
|
1223
|
+
// and `costCount` when the run's cost is known — and adds only the
|
|
1224
|
+
// cost the round reports did not already cover. Only a run that
|
|
1225
|
+
// actually returned is counted; a delegated run that failed mid-way
|
|
1226
|
+
// keeps the cost its finished rounds already reported (those tokens
|
|
1227
|
+
// were really billed and are already on screen) without entering the
|
|
1228
|
+
// counts, mirroring the pre-existing rule that a failed subagent is
|
|
1229
|
+
// not a priced unit.
|
|
1230
|
+
//
|
|
1231
|
+
// `delegatedCostTotal()` requires one known-cost report per counted run,
|
|
1232
|
+
// so the counters are only touched on completion: a run in flight must
|
|
1233
|
+
// not make the aggregate look "known" while rounds are still unbilled.
|
|
1234
|
+
function reportDelegatedUsage(report) {
|
|
1235
|
+
if (!report) return;
|
|
1236
|
+
const promptTokens = Number(report.promptTokens);
|
|
1237
|
+
const completionTokens = Number(report.completionTokens);
|
|
1238
|
+
if (isFinite(promptTokens) && promptTokens > 0) delegatedUsage.promptTokens += promptTokens;
|
|
1239
|
+
if (isFinite(completionTokens) && completionTokens > 0) delegatedUsage.completionTokens += completionTokens;
|
|
1240
|
+
const cost = (typeof report.cost === 'number' && isFinite(report.cost) && report.cost > 0) ? report.cost : null;
|
|
1241
|
+
if (cost != null) {
|
|
1242
|
+
delegatedProviderCost = (delegatedProviderCost || 0) + cost;
|
|
1243
|
+
// Surface the increment to the SSE stream so the chat's "Total"
|
|
1244
|
+
// pill updates immediately. The wire shape matches the persisted
|
|
1245
|
+
// `cost` block on assistant messages
|
|
1246
|
+
// ({ known, total, input, output, currency }) so the client can
|
|
1247
|
+
// drop it into the live running total with no extra plumbing. The
|
|
1248
|
+
// final `done` event folds the same number into the parent
|
|
1249
|
+
// remainder; the client clears the running delta at that point so
|
|
1250
|
+
// nothing is double-counted.
|
|
1251
|
+
onEvent('usage_update', {
|
|
1252
|
+
cost: {
|
|
1253
|
+
known: true,
|
|
1254
|
+
total: cost,
|
|
1255
|
+
input: 0,
|
|
1256
|
+
output: 0,
|
|
1257
|
+
currency: 'USD'
|
|
1258
|
+
},
|
|
1259
|
+
source: 'subagent',
|
|
1260
|
+
modelId: report.modelId
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
if (report.complete && report.ok) {
|
|
1264
|
+
delegatedUsage.count++;
|
|
1265
|
+
if (report.costKnown) delegatedUsage.costCount++;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function toolResultImageParts(result) {
|
|
1270
|
+
if (!result || !Array.isArray(result.content)) return [];
|
|
1271
|
+
const out = [];
|
|
1272
|
+
for (const block of result.content) {
|
|
1273
|
+
if (!block || typeof block !== 'object') continue;
|
|
1274
|
+
// MCP tools return images two ways: a top-level `image` content block
|
|
1275
|
+
// ({ type:'image', data, mimeType }) or an embedded `resource` block
|
|
1276
|
+
// ({ type:'resource', resource:{ blob, mimeType } }) — screenshot,
|
|
1277
|
+
// chart, and diagram servers commonly use the resource shape. Accept
|
|
1278
|
+
// both so those images actually reach the model instead of being
|
|
1279
|
+
// silently dropped.
|
|
1280
|
+
let data = null;
|
|
1281
|
+
let mimeType = null;
|
|
1282
|
+
if (block.type === 'image') {
|
|
1283
|
+
data = block.data || block.base64;
|
|
1284
|
+
mimeType = block.mimeType || block.mime_type || block.mediaType || block.media_type || 'image/png';
|
|
1285
|
+
} else if (block.type === 'resource' && block.resource && typeof block.resource === 'object') {
|
|
1286
|
+
const res = block.resource;
|
|
1287
|
+
const resMime = res.mimeType || res.mime_type || res.mediaType || res.media_type || '';
|
|
1288
|
+
// Only forward binary resources that are actually images; text
|
|
1289
|
+
// resources ride along in the stringified tool result instead.
|
|
1290
|
+
const blob = res.blob || res.data || res.base64;
|
|
1291
|
+
if (typeof blob === 'string' && blob && /^image\//i.test(resMime)) {
|
|
1292
|
+
data = blob;
|
|
1293
|
+
mimeType = resMime;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (typeof data === 'string' && data) {
|
|
1297
|
+
const url = data.startsWith('data:') ? data : ('data:' + mimeType + ';base64,' + data);
|
|
1298
|
+
out.push({ type: 'image_url', image_url: { url } });
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return out;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// rawImageBlocks(result) — the raw `{ type:'image', data, mimeType }`
|
|
1305
|
+
// blocks on a tool result, in the same shape read_file's image path and
|
|
1306
|
+
// MCP image results emit.
|
|
1307
|
+
// Accepts the same `image` / `resource` shapes toolResultImageParts does,
|
|
1308
|
+
// but keeps the blocks as native image content (not `image_url`) so they
|
|
1309
|
+
// can be re-attached to a subagent's result `content` and picked up by the
|
|
1310
|
+
// parent's own toolResultImageParts pass. This is what carries a delegated
|
|
1311
|
+
// picture (e.g. a subagent's read_file or MCP image result) back to the
|
|
1312
|
+
// main agent.
|
|
1313
|
+
function rawImageBlocks(result) {
|
|
1314
|
+
if (!result || !Array.isArray(result.content)) return [];
|
|
1315
|
+
const out = [];
|
|
1316
|
+
for (const block of result.content) {
|
|
1317
|
+
if (!block || typeof block !== 'object') continue;
|
|
1318
|
+
let data = null;
|
|
1319
|
+
let mimeType = null;
|
|
1320
|
+
if (block.type === 'image') {
|
|
1321
|
+
data = block.data || block.base64;
|
|
1322
|
+
mimeType = block.mimeType || block.mime_type || block.mediaType || block.media_type || 'image/png';
|
|
1323
|
+
} else if (block.type === 'resource' && block.resource && typeof block.resource === 'object') {
|
|
1324
|
+
const res = block.resource;
|
|
1325
|
+
const resMime = res.mimeType || res.mime_type || res.mediaType || res.media_type || '';
|
|
1326
|
+
const blob = res.blob || res.data || res.base64;
|
|
1327
|
+
if (typeof blob === 'string' && blob && /^image\//i.test(resMime)) {
|
|
1328
|
+
data = blob;
|
|
1329
|
+
mimeType = resMime;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (typeof data === 'string' && data) out.push({ type: 'image', data, mimeType });
|
|
1333
|
+
}
|
|
1334
|
+
return out;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function firstStringArgument(value) {
|
|
1338
|
+
if (!value || typeof value !== 'object') return '';
|
|
1339
|
+
for (const item of Object.values(value)) {
|
|
1340
|
+
if (typeof item === 'string') return item;
|
|
1341
|
+
}
|
|
1342
|
+
return '';
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// ---- Tool dispatcher -----------------------------------------------
|
|
1346
|
+
// Routes one tool call to its runner and returns
|
|
1347
|
+
// { ok, content, result } where `content` is the string fed back
|
|
1348
|
+
// to the model as the `tool` message, and `result` is the richer
|
|
1349
|
+
// object surfaced to the chat UI in the tool_result SSE event.
|
|
1350
|
+
async function dispatchTool(name, args, callOpts) {
|
|
1351
|
+
// Native shell tool.
|
|
1352
|
+
if (name === 'shell') {
|
|
1353
|
+
let out;
|
|
1354
|
+
try {
|
|
1355
|
+
const shell = require('./tools/shell.js');
|
|
1356
|
+
const parentOnEvent = callOpts && callOpts.onEvent;
|
|
1357
|
+
const callId = callOpts && callOpts.callId;
|
|
1358
|
+
out = await shell.runShell({
|
|
1359
|
+
projectDir: callOpts.projectDir,
|
|
1360
|
+
cmd: args && args.cmd,
|
|
1361
|
+
shell: args && args.shell,
|
|
1362
|
+
// Clamped by the authorization gate against the project's
|
|
1363
|
+
// maxTimeoutMs. Falls back to the model's request only when the
|
|
1364
|
+
// gate returned no value (it always does, so this is defensive).
|
|
1365
|
+
timeoutMs: (callOpts && callOpts.toolTimeoutMs != null) ? callOpts.toolTimeoutMs : (args && args.timeoutMs),
|
|
1366
|
+
// Stream decoded output chunks to the chat UI while the
|
|
1367
|
+
// command is still running so the tool card shows a live
|
|
1368
|
+
// preview instead of a silent spinner.
|
|
1369
|
+
onOutput: typeof parentOnEvent === 'function'
|
|
1370
|
+
? (stream, delta) => {
|
|
1371
|
+
try {
|
|
1372
|
+
parentOnEvent('shell_output', { id: callId || null, stream, delta });
|
|
1373
|
+
// Inside a subagent run, re-emit under the nested event
|
|
1374
|
+
// name so the chunk lands in the parent subagent card
|
|
1375
|
+
// instead of a standalone transcript card.
|
|
1376
|
+
if (callOpts && callOpts.nestedSubagent) {
|
|
1377
|
+
parentOnEvent('subagent_event', {
|
|
1378
|
+
parentCallId: callOpts.callId || null,
|
|
1379
|
+
kind: 'shell_output',
|
|
1380
|
+
data: { id: callId || null, stream, delta }
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
} catch { /* best-effort */ }
|
|
1384
|
+
}
|
|
1385
|
+
: null
|
|
1386
|
+
});
|
|
1387
|
+
} catch (e) {
|
|
1388
|
+
out = { ok: false, error: e.message || String(e), code: 'ESHELL' };
|
|
1389
|
+
}
|
|
1390
|
+
// The first tool message line tells the model which software and
|
|
1391
|
+
// which shell ran the command (the spec description says the same
|
|
1392
|
+
// thing up front; the per-call line survives prompt compaction).
|
|
1393
|
+
const identity = (out && out.identity) || 'mouaif shell';
|
|
1394
|
+
return { ok: !!out.ok, content: '# ' + identity + '\n' + JSON.stringify(out), result: out };
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (name === 'activate_skill') {
|
|
1398
|
+
try {
|
|
1399
|
+
return require('./agentSkills.js').activate(callOpts && callOpts.projectDir, opts && opts.chat, args && args.name);
|
|
1400
|
+
} catch (e) {
|
|
1401
|
+
return { ok: false, content: JSON.stringify({ error: e.message, code: e.code || 'ENO_SKILL' }), result: { error: e.message, code: e.code || 'ENO_SKILL' } };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Native task tool. Manages structured tasks with subtasks, progress
|
|
1406
|
+
// tracking, and completion. Tasks are in-memory per chat (do not
|
|
1407
|
+
// survive a server restart).
|
|
1408
|
+
if (name === 'task') {
|
|
1409
|
+
try {
|
|
1410
|
+
const taskMod = require('./tools/task.js');
|
|
1411
|
+
const validated = taskMod.validateArgs(args);
|
|
1412
|
+
const out = taskMod.dispatchTask(callOpts && callOpts.chatId, validated);
|
|
1413
|
+
// Surface task progress changes as a progress_update event so the
|
|
1414
|
+
// frontend progress card and the per-chat updatable status push
|
|
1415
|
+
// notification show the current task title and count — the same
|
|
1416
|
+
// notification slot the report_progress tool uses. Creation is
|
|
1417
|
+
// skipped: a fresh task
|
|
1418
|
+
// always starts at 0%, which would be a noise notification.
|
|
1419
|
+
if (out && out.ok && out.result && out.result.task
|
|
1420
|
+
&& (out.result.action === 'progress_updated' || out.result.action === 'completed')
|
|
1421
|
+
&& callOpts && typeof callOpts.onEvent === 'function') {
|
|
1422
|
+
const t = out.result.task;
|
|
1423
|
+
const completed = out.result.action === 'completed';
|
|
1424
|
+
callOpts.onEvent('progress_update', {
|
|
1425
|
+
callId: (callOpts && callOpts.callId) || null,
|
|
1426
|
+
kind: 'task',
|
|
1427
|
+
title: t.title || 'Task',
|
|
1428
|
+
current: typeof t.current === 'number' ? t.current : 0,
|
|
1429
|
+
total: typeof t.total === 'number' ? t.total : 100,
|
|
1430
|
+
status: completed ? 'completed' : 'running',
|
|
1431
|
+
message: completed
|
|
1432
|
+
? 'Task complete'
|
|
1433
|
+
: ((typeof t.current === 'number' && typeof t.total === 'number')
|
|
1434
|
+
? t.current + ' of ' + t.total
|
|
1435
|
+
: '')
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
return out;
|
|
1439
|
+
} catch (e) {
|
|
1440
|
+
const r = { error: { code: e.code || 'ETASK', message: e.message || String(e) } };
|
|
1441
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Native app restart tool. Authorization is handled by the shared gate;
|
|
1446
|
+
// this dispatcher schedules the graceful relaunch after its result has had
|
|
1447
|
+
// time to flush through the current chat stream.
|
|
1448
|
+
if (name === 'restart_app') {
|
|
1449
|
+
try {
|
|
1450
|
+
return require('./tools/restart.js').runRestart(args, {
|
|
1451
|
+
lifecycle: callOpts && callOpts.lifecycle
|
|
1452
|
+
});
|
|
1453
|
+
} catch (e) {
|
|
1454
|
+
const r = { error: { code: e.code || 'ERESTART', message: e.message || String(e) } };
|
|
1455
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// Native subagent tool. It delegates to the same model with the same
|
|
1459
|
+
// project tool surface, including MCP. The nested call intentionally omits
|
|
1460
|
+
|
|
1461
|
+
// only `subagent` itself to avoid unbounded recursive delegation loops.
|
|
1462
|
+
// Authorization uses the parent chat id so the existing chat popup/card is
|
|
1463
|
+
// reused for any nested tool or MCP call that needs approval.
|
|
1464
|
+
if (name === 'subagent') {
|
|
1465
|
+
const task = args && typeof args.task === 'string' ? args.task.trim() : '';
|
|
1466
|
+
const context = args && typeof args.context === 'string' ? args.context.trim() : '';
|
|
1467
|
+
const agentName = args && typeof args.agent === 'string' ? args.agent.trim() : '';
|
|
1468
|
+
if (!task) {
|
|
1469
|
+
const r = { error: { code: 'EBADINPUT', message: 'task is required' } };
|
|
1470
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1471
|
+
}
|
|
1472
|
+
// Resolve the requested agent persona (if any). Agents are
|
|
1473
|
+
// named subagent personas from .mouaif.json — an unknown name is
|
|
1474
|
+
// a hard, typed error so the model can retry with a valid one
|
|
1475
|
+
// instead of silently delegating to a generic subagent.
|
|
1476
|
+
let agentTools = null;
|
|
1477
|
+
let nestedModel = model; // default: inherit the chat's model
|
|
1478
|
+
// Default: inherit the chat's thinking level (null → inherit). An
|
|
1479
|
+
// explicit per-run override (authorization card) wins; otherwise an
|
|
1480
|
+
// agent's thinkingLevel applies. Empty string = "No thinking".
|
|
1481
|
+
let nestedThinkingLevel = null;
|
|
1482
|
+
const nestedMessages = [];
|
|
1483
|
+
// Per-call model override chosen on the authorization card.
|
|
1484
|
+
// { providerId, modelId } — the user explicitly picked a model
|
|
1485
|
+
// while approving this call, so it wins over the agent's pin
|
|
1486
|
+
// and the chat's model. Resolved to a hydrated model below.
|
|
1487
|
+
const chosenModel = (callOpts && callOpts.modelOverride && typeof callOpts.modelOverride === 'object')
|
|
1488
|
+
? callOpts.modelOverride
|
|
1489
|
+
: null;
|
|
1490
|
+
if (chosenModel && (!chosenModel.providerId || !chosenModel.modelId)) {
|
|
1491
|
+
const r = { error: { code: 'EBADINPUT', message: 'Model override must set providerId and modelId' } };
|
|
1492
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1493
|
+
}
|
|
1494
|
+
if (agentName) {
|
|
1495
|
+
if (!callOpts || !callOpts.projectDir) {
|
|
1496
|
+
const r = { error: { code: 'EUNKNOWN_AGENT', message: 'No project context to resolve agent "' + agentName + '"', available: [] } };
|
|
1497
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1498
|
+
}
|
|
1499
|
+
let agent = null;
|
|
1500
|
+
let available = [];
|
|
1501
|
+
let agentMod = null;
|
|
1502
|
+
try {
|
|
1503
|
+
agentMod = require('./agents.js');
|
|
1504
|
+
const all = agentMod.list(callOpts.projectDir);
|
|
1505
|
+
available = all.map((a) => a.name);
|
|
1506
|
+
agent = all.find((a) => a.name === agentName) || null;
|
|
1507
|
+
} catch { /* fall through to typed error */ }
|
|
1508
|
+
if (!agent) {
|
|
1509
|
+
const r = { error: { code: 'EUNKNOWN_AGENT', message: 'Unknown agent "' + agentName + '"', available } };
|
|
1510
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1511
|
+
}
|
|
1512
|
+
// Optional per-agent model pin. When set, the nested call runs
|
|
1513
|
+
// on that project model (hydrated with its provider connection)
|
|
1514
|
+
// instead of inheriting the chat's model. Unknown model ids
|
|
1515
|
+
// fail loudly — never a silent fallback.
|
|
1516
|
+
if (agent.modelId) {
|
|
1517
|
+
try {
|
|
1518
|
+
const rec = agentMod.resolveModel(callOpts.projectDir, agent);
|
|
1519
|
+
const settingsMod = require('./settings.js');
|
|
1520
|
+
const app = settingsMod.getApp();
|
|
1521
|
+
const providers = Array.isArray(app.providers) ? app.providers : [];
|
|
1522
|
+
const connection = providers.find((p) => p && p.id === rec.provider) || null;
|
|
1523
|
+
if (!connection) {
|
|
1524
|
+
const r = { error: { code: 'EPROVIDER_NOT_FOUND', message: 'No provider connection for "' + rec.provider + '"' } };
|
|
1525
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1526
|
+
}
|
|
1527
|
+
nestedModel = Object.assign({}, connection, projectModelRecord(rec), {
|
|
1528
|
+
provider: rec.provider,
|
|
1529
|
+
auth: connection.auth || 'apikey'
|
|
1530
|
+
});
|
|
1531
|
+
} catch (e) {
|
|
1532
|
+
const r = { error: { code: e.code || 'EUNKNOWN_MODEL', message: e.message || String(e) } };
|
|
1533
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
nestedMessages.push({ role: 'system', content: [{ type: 'text', text: agent.content, cache_control: { type: 'ephemeral' } }] });
|
|
1537
|
+
agentTools = Array.isArray(agent.tools) && agent.tools.length ? agent.tools : null;
|
|
1538
|
+
// Per-agent thinking level (optional). Applies only when no
|
|
1539
|
+
// explicit per-run override was chosen on the approval card.
|
|
1540
|
+
if (typeof agent.thinkingLevel === 'string' && agent.thinkingLevel.trim()) {
|
|
1541
|
+
nestedThinkingLevel = agent.thinkingLevel;
|
|
1542
|
+
}
|
|
1543
|
+
} else {
|
|
1544
|
+
nestedMessages.push({
|
|
1545
|
+
role: 'system',
|
|
1546
|
+
content: [{ type: 'text', text: 'You are a focused subagent. Answer only the delegated task. Be concise. You may use the available project tools and MCP tools when they help; authorization prompts are handled by the parent chat.', cache_control: { type: 'ephemeral' } }]
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
// Authorization-time model override. The user picked a model on
|
|
1550
|
+
// the approval card for THIS delegated run, so it wins over both
|
|
1551
|
+
// the chat's model and an agent's pin. Resolve it the same way
|
|
1552
|
+
// the chat model is resolved: prefer the project model record,
|
|
1553
|
+
// then fall back to a live-catalog entry for the provider, and
|
|
1554
|
+
// hydrate either with the app-level provider connection. The
|
|
1555
|
+
// project record may only contribute identity/selection metadata
|
|
1556
|
+
// — committed project JSON must never redirect a credentialed
|
|
1557
|
+
// provider connection candidate (mirrors resolveModel in
|
|
1558
|
+
// server-shared.js).
|
|
1559
|
+
if (chosenModel) {
|
|
1560
|
+
try {
|
|
1561
|
+
const settingsMod = require('./settings.js');
|
|
1562
|
+
const resolved = settingsMod.getResolved(callOpts && callOpts.projectDir || null);
|
|
1563
|
+
const models = Array.isArray(resolved.models) ? resolved.models : [];
|
|
1564
|
+
let rec = models.find((x) => x && x.id === chosenModel.modelId && x.provider === chosenModel.providerId) || null;
|
|
1565
|
+
if (!rec) {
|
|
1566
|
+
// Live-catalog models (OpenRouter & co.) are resolved by
|
|
1567
|
+
// provider id; the URL/credential come from the connection.
|
|
1568
|
+
rec = { id: chosenModel.modelId, provider: chosenModel.providerId };
|
|
1569
|
+
}
|
|
1570
|
+
const safe = projectModelRecord(rec);
|
|
1571
|
+
const app = settingsMod.getApp();
|
|
1572
|
+
const providers = Array.isArray(app.providers) ? app.providers : [];
|
|
1573
|
+
const connection = providers.find((p) => p && p.id === rec.provider) || null;
|
|
1574
|
+
if (!connection) {
|
|
1575
|
+
const r = { error: { code: 'EPROVIDER_NOT_FOUND', message: 'No provider connection for "' + rec.provider + '"' } };
|
|
1576
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1577
|
+
}
|
|
1578
|
+
nestedModel = Object.assign({}, connection, safe, {
|
|
1579
|
+
provider: rec.provider,
|
|
1580
|
+
auth: connection.auth || 'apikey'
|
|
1581
|
+
});
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
const r = { error: { code: e.code || 'EUNKNOWN_MODEL', message: e.message || String(e) } };
|
|
1584
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
nestedMessages.push({
|
|
1588
|
+
role: 'user',
|
|
1589
|
+
content: context ? ('Task:\n' + task + '\n\nContext:\n' + context) : task
|
|
1590
|
+
});
|
|
1591
|
+
// Per-run thinking level from the authorization card wins over an
|
|
1592
|
+
// agent's pin. An empty string clears both so the nested call uses
|
|
1593
|
+
// the provider default (the user explicitly chose "No thinking").
|
|
1594
|
+
if (callOpts && typeof callOpts.thinkingLevel === 'string') {
|
|
1595
|
+
nestedThinkingLevel = callOpts.thinkingLevel;
|
|
1596
|
+
}
|
|
1597
|
+
// Apply the resolved level directly onto the nested model. `streamChat`
|
|
1598
|
+
// only assigns a truthy `thinkingLevel` opt, so an explicit "" (clear)
|
|
1599
|
+
// must delete the inherited value rather than ride through it.
|
|
1600
|
+
if (nestedThinkingLevel !== null) {
|
|
1601
|
+
if (nestedThinkingLevel === '') delete nestedModel.thinkingLevel;
|
|
1602
|
+
else nestedModel.thinkingLevel = nestedThinkingLevel;
|
|
1603
|
+
}
|
|
1604
|
+
// ---- live delegated billing ---------------------------------------
|
|
1605
|
+
// A delegated run is several separate billed upstream calls. Billing
|
|
1606
|
+
// it only when the whole run returns leaves the chat's live "Total"
|
|
1607
|
+
// frozen for the entire subagent — which, with tool-using subagents,
|
|
1608
|
+
// can be a long time. So every nested round reports its own increment
|
|
1609
|
+
// the moment it finishes, and the completion below only adds what the
|
|
1610
|
+
// round reports did not cover.
|
|
1611
|
+
//
|
|
1612
|
+
// `mirror` reproduces the nested run's own usage aggregation (see
|
|
1613
|
+
// commitRoundUsage: prompt tokens are last-round-wins because every
|
|
1614
|
+
// round re-sends the conversation, while completion, cache and
|
|
1615
|
+
// provider-reported cost accumulate), so `nestedCostSoFar()` is
|
|
1616
|
+
// exactly the cost the nested run reports when it returns.
|
|
1617
|
+
// `forwarded` tracks what has already been handed to the parent, so
|
|
1618
|
+
// the deltas can never bill a round twice.
|
|
1619
|
+
const nestedModelRef = nestedModel && nestedModel.id
|
|
1620
|
+
? { id: nestedModel.id, pricing: nestedModel.pricing }
|
|
1621
|
+
: null;
|
|
1622
|
+
const mirror = {
|
|
1623
|
+
lastPromptTokens: 0,
|
|
1624
|
+
completionTokens: 0,
|
|
1625
|
+
cacheReadTokens: 0,
|
|
1626
|
+
cacheCreationTokens: 0,
|
|
1627
|
+
providerCost: null
|
|
1628
|
+
};
|
|
1629
|
+
const forwarded = { promptTokens: 0, completionTokens: 0, cost: 0 };
|
|
1630
|
+
let runCostKnown = false;
|
|
1631
|
+
|
|
1632
|
+
// Cost of the nested run as reported so far, resolved the same way
|
|
1633
|
+
// the completion path resolves it: provider-reported cost when a
|
|
1634
|
+
// round carried one, otherwise an estimate priced with the nested
|
|
1635
|
+
// model. Null while pricing is unknown.
|
|
1636
|
+
function nestedCostSoFar() {
|
|
1637
|
+
const usageSoFar = {
|
|
1638
|
+
promptTokens: mirror.lastPromptTokens,
|
|
1639
|
+
completionTokens: mirror.completionTokens
|
|
1640
|
+
};
|
|
1641
|
+
if (mirror.cacheReadTokens) usageSoFar.cacheReadTokens = mirror.cacheReadTokens;
|
|
1642
|
+
if (mirror.cacheCreationTokens) usageSoFar.cacheCreationTokens = mirror.cacheCreationTokens;
|
|
1643
|
+
return delegatedCostForResult({
|
|
1644
|
+
ok: true,
|
|
1645
|
+
model: nestedModelRef,
|
|
1646
|
+
providerCost: mirror.providerCost,
|
|
1647
|
+
usage: usageSoFar
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// One nested round finished (streamChat's onRoundCommit): mirror its
|
|
1652
|
+
// usage and forward the increments to the parent's delegated totals,
|
|
1653
|
+
// which also updates the chat UI's running total.
|
|
1654
|
+
function forwardNestedRound(round) {
|
|
1655
|
+
if (!round) return;
|
|
1656
|
+
const promptTokens = Number(round.promptTokens);
|
|
1657
|
+
const completionTokens = Number(round.completionTokens);
|
|
1658
|
+
if (isFinite(promptTokens) && promptTokens > 0) mirror.lastPromptTokens = promptTokens;
|
|
1659
|
+
if (isFinite(completionTokens) && completionTokens > 0) mirror.completionTokens += completionTokens;
|
|
1660
|
+
mirror.cacheReadTokens += Number(round.cacheReadTokens) || 0;
|
|
1661
|
+
mirror.cacheCreationTokens += Number(round.cacheCreationTokens) || 0;
|
|
1662
|
+
// `providerCost: null` means "no provider-reported cost", NOT $0 —
|
|
1663
|
+
// Number(null) is 0, which would look like a real (free) price and
|
|
1664
|
+
// shadow the estimate for every round.
|
|
1665
|
+
const roundCost = round.providerCost == null ? NaN : Number(round.providerCost);
|
|
1666
|
+
if (isFinite(roundCost) && roundCost >= 0) mirror.providerCost = (mirror.providerCost || 0) + roundCost;
|
|
1667
|
+
const promptDelta = Math.max(0, mirror.lastPromptTokens - forwarded.promptTokens);
|
|
1668
|
+
const completionDelta = isFinite(completionTokens) && completionTokens > 0 ? completionTokens : 0;
|
|
1669
|
+
const costSoFar = nestedCostSoFar();
|
|
1670
|
+
if (costSoFar != null) runCostKnown = true;
|
|
1671
|
+
const costDelta = costSoFar == null ? 0 : Math.max(0, costSoFar - forwarded.cost);
|
|
1672
|
+
forwarded.promptTokens += promptDelta;
|
|
1673
|
+
forwarded.completionTokens += completionDelta;
|
|
1674
|
+
if (costSoFar != null && costSoFar > forwarded.cost) forwarded.cost = costSoFar;
|
|
1675
|
+
reportDelegatedUsage({
|
|
1676
|
+
promptTokens: promptDelta,
|
|
1677
|
+
completionTokens: completionDelta,
|
|
1678
|
+
cost: costDelta,
|
|
1679
|
+
modelId: nestedModelRef ? nestedModelRef.id : undefined
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const nestedEvents = [];
|
|
1684
|
+
const parentEnabled = callOpts && Array.isArray(callOpts.enabledTools) ? callOpts.enabledTools : null;
|
|
1685
|
+
let nestedEnabled = parentEnabled
|
|
1686
|
+
? parentEnabled.filter((toolName) => toolName !== 'subagent')
|
|
1687
|
+
: visibleToolSpecs
|
|
1688
|
+
.map((spec) => spec && spec.function && spec.function.name)
|
|
1689
|
+
.filter((toolName) => toolName && toolName !== 'subagent');
|
|
1690
|
+
// An agent's tool allowlist restricts the nested call's surface.
|
|
1691
|
+
// Agent tool entries can be exact tool names (e.g. "shell") or MCP
|
|
1692
|
+
// server slugs (e.g. "mcp__fs") which should allow every tool from
|
|
1693
|
+
// that server (mcp__fs__read_file, mcp__fs__write_file, ...).
|
|
1694
|
+
if (agentTools) {
|
|
1695
|
+
const allow = new Set(agentTools);
|
|
1696
|
+
nestedEnabled = nestedEnabled.filter((toolName) => {
|
|
1697
|
+
if (allow.has(toolName)) return true;
|
|
1698
|
+
// Prefix match for MCP server slugs: "mcp__fs" allows
|
|
1699
|
+
// "mcp__fs__read_file", "mcp__fs__write_file", etc.
|
|
1700
|
+
for (const prefix of allow) {
|
|
1701
|
+
if (prefix.startsWith('mcp__') && toolName.startsWith(prefix + '__')) return true;
|
|
1702
|
+
}
|
|
1703
|
+
return false;
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
const nested = await streamChat({
|
|
1707
|
+
model: nestedModel,
|
|
1708
|
+
messages: nestedMessages,
|
|
1709
|
+
signal,
|
|
1710
|
+
projectDir: callOpts && callOpts.projectDir,
|
|
1711
|
+
chatId: callOpts && callOpts.chatId,
|
|
1712
|
+
appSettings: callOpts && callOpts.appSettings,
|
|
1713
|
+
lifecycle: callOpts && callOpts.lifecycle,
|
|
1714
|
+
promptSize: callOpts && callOpts.promptSize,
|
|
1715
|
+
|
|
1716
|
+
toolOutput: callOpts && callOpts.toolOutput,
|
|
1717
|
+
enabledTools: nestedEnabled,
|
|
1718
|
+
// Per-round billing for the live delegated cost (see above).
|
|
1719
|
+
onRoundCommit: forwardNestedRound,
|
|
1720
|
+
// Marker the shell dispatcher reads to re-emit live output
|
|
1721
|
+
// chunks as subagent_event so they render inside this card.
|
|
1722
|
+
nestedSubagent: true,
|
|
1723
|
+
onEvent: (eventName, data) => {
|
|
1724
|
+
nestedEvents.push({ name: eventName, data });
|
|
1725
|
+
if (typeof onEvent !== 'function') return;
|
|
1726
|
+
// Authorization (and ask_user) still ride the normal event
|
|
1727
|
+
// so the parent chat popup/card handles the nested
|
|
1728
|
+
// approval. The `parentTool` tag tells the chat UI to
|
|
1729
|
+
// route the card into the subagent's live container.
|
|
1730
|
+
if (eventName === 'authorization_required' || eventName === 'ask_user_required') {
|
|
1731
|
+
onEvent(eventName, Object.assign({}, data, { parentTool: 'subagent' }));
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
// Forward nested progress to the parent as-is. `report_progress`
|
|
1735
|
+
// and `task` progress updates from a subagent must still reach
|
|
1736
|
+
// the parent's push layer (sendChatPush 'progress' + the per-chat
|
|
1737
|
+
// updatable notification) and the transcript progress card.
|
|
1738
|
+
// `progress_update` never gets persisted by the parent, so it is
|
|
1739
|
+
// safe to reuse the event name directly (no transcript corruption,
|
|
1740
|
+
// unlike tool_call / tool_result / message below).
|
|
1741
|
+
if (eventName === 'progress_update') {
|
|
1742
|
+
onEvent('progress_update', data);
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
// Forward nested progress under a distinct event name. The
|
|
1746
|
+
// parent's SSE layer persists every `tool_call` / `tool_result`
|
|
1747
|
+
// / `message` it sees, so reusing those names would corrupt
|
|
1748
|
+
// the transcript with the subagent's internal turns.
|
|
1749
|
+
if (eventName === 'tool_call' || eventName === 'tool_result' || eventName === 'message') {
|
|
1750
|
+
onEvent('subagent_event', {
|
|
1751
|
+
parentCallId: (callOpts && callOpts.callId) || null,
|
|
1752
|
+
kind: eventName,
|
|
1753
|
+
data
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
let text = '';
|
|
1759
|
+
const nestedToolEvents = [];
|
|
1760
|
+
for (const ev of nestedEvents) {
|
|
1761
|
+
if (ev.name === 'message' && ev.data && typeof ev.data.delta === 'string') text += ev.data.delta;
|
|
1762
|
+
else if (ev.name === 'tool_call' || ev.name === 'tool_result' || ev.name === 'authorization_required') nestedToolEvents.push(ev);
|
|
1763
|
+
}
|
|
1764
|
+
// Rebuild a faithful nested transcript for the UI. The plain
|
|
1765
|
+
// `chat` (system+user+final assistant) hides every tool turn,
|
|
1766
|
+
// which made the subagent preview look like no tools ran. We fold
|
|
1767
|
+
// streamed tool_call / tool_result events back into OpenAI-shaped
|
|
1768
|
+
// messages so the chat card can render them.
|
|
1769
|
+
const chat = nestedMessages.slice();
|
|
1770
|
+
// Image blocks produced by the subagent's own tool calls (a read_file
|
|
1771
|
+
// image, an MCP image result). Accumulated here so the delegated
|
|
1772
|
+
// result can carry the pixels back up to the parent — see below where
|
|
1773
|
+
// they are set on `r.content`, which the parent's line-387
|
|
1774
|
+
// toolResultImageParts(exec.result) then attaches as a vision message.
|
|
1775
|
+
const nestedImageParts = [];
|
|
1776
|
+
{
|
|
1777
|
+
let pendingCalls = [];
|
|
1778
|
+
const flushCalls = () => {
|
|
1779
|
+
if (!pendingCalls.length) return;
|
|
1780
|
+
chat.push({
|
|
1781
|
+
role: 'assistant',
|
|
1782
|
+
content: null,
|
|
1783
|
+
tool_calls: pendingCalls.map((c) => ({
|
|
1784
|
+
id: c.id || undefined,
|
|
1785
|
+
type: 'function',
|
|
1786
|
+
function: { name: c.name, arguments: typeof c.args === 'string' ? c.args : JSON.stringify(c.args || {}) }
|
|
1787
|
+
}))
|
|
1788
|
+
});
|
|
1789
|
+
pendingCalls = [];
|
|
1790
|
+
};
|
|
1791
|
+
for (const ev of nestedToolEvents) {
|
|
1792
|
+
const d = ev.data || {};
|
|
1793
|
+
if (ev.name === 'tool_call') {
|
|
1794
|
+
pendingCalls.push({ id: d.id, name: d.name, args: d.args });
|
|
1795
|
+
} else if (ev.name === 'tool_result') {
|
|
1796
|
+
flushCalls();
|
|
1797
|
+
chat.push({
|
|
1798
|
+
role: 'tool',
|
|
1799
|
+
tool_call_id: d.id || undefined,
|
|
1800
|
+
name: d.name,
|
|
1801
|
+
content: typeof d.result === 'string' ? d.result : JSON.stringify(d.result)
|
|
1802
|
+
});
|
|
1803
|
+
// webpreview's screenshot is a user-only preview (see line ~387);
|
|
1804
|
+
// never forward it as a model vision part.
|
|
1805
|
+
if (d.name !== 'webpreview') {
|
|
1806
|
+
for (const block of rawImageBlocks(d.result)) nestedImageParts.push(block);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
flushCalls();
|
|
1811
|
+
}
|
|
1812
|
+
chat.push({ role: 'assistant', content: text });
|
|
1813
|
+
const r = nested && nested.ok
|
|
1814
|
+
? { ok: true, text, chat, toolEvents: nestedToolEvents, usage: nested.usage || null, providerCost: nested.providerCost ?? null, totalCost: nested.totalCost ?? null, model: nestedModelRef }
|
|
1815
|
+
: { ok: false, text, chat, toolEvents: nestedToolEvents, error: nested && nested.error ? nested.error : { code: 'ESUBAGENT', message: 'subagent failed' } };
|
|
1816
|
+
// Record WHICH agent ran, when one was named. The name is not derivable
|
|
1817
|
+
// from the nested transcript (an agent's system message is its
|
|
1818
|
+
// instructions, not its name), and only the delegated call knows it, so
|
|
1819
|
+
// it has to ride the result payload or the chat card cannot say which
|
|
1820
|
+
// agent answered. Omitted entirely for a generic delegation.
|
|
1821
|
+
if (agentName) r.agent = agentName;
|
|
1822
|
+
// Carry the subagent's generated pictures on the result `content`, in
|
|
1823
|
+
// the same `{ type:'image', data, mimeType }` shape read_file's image
|
|
1824
|
+
// path and MCP image results emit, so
|
|
1825
|
+
// the parent's toolResultImageParts(exec.result) (line ~387) attaches
|
|
1826
|
+
// them as a vision message and postToolImageMessages paints the
|
|
1827
|
+
// delegated artwork for the main agent. Without this the pixels lived
|
|
1828
|
+
// only in the nested transcript and the main agent never saw them.
|
|
1829
|
+
if (nestedImageParts.length) r.content = nestedImageParts;
|
|
1830
|
+
// Commit the run. The round reports above already billed every nested
|
|
1831
|
+
// round, so this only adds what they missed — a run whose rounds
|
|
1832
|
+
// never reported usage, or a cost estimate that only became
|
|
1833
|
+
// resolvable from the full result — and marks the run as counted so
|
|
1834
|
+
// delegatedCostTotal() can be known again.
|
|
1835
|
+
const finalCost = delegatedCostForResult(r);
|
|
1836
|
+
if (finalCost != null) {
|
|
1837
|
+
runCostKnown = true;
|
|
1838
|
+
if (finalCost > forwarded.cost) {
|
|
1839
|
+
reportDelegatedUsage({
|
|
1840
|
+
cost: finalCost - forwarded.cost,
|
|
1841
|
+
modelId: nestedModelRef ? nestedModelRef.id : undefined
|
|
1842
|
+
});
|
|
1843
|
+
forwarded.cost = finalCost;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
reportDelegatedUsage({ complete: true, ok: !!(nested && nested.ok), costKnown: runCostKnown });
|
|
1847
|
+
return { ok: !!(nested && nested.ok), content: JSON.stringify(r), result: r };
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
// Native ask_user tool. The runner is a thin shim: it folds the
|
|
1851
|
+
// user's structured answer (carried on callOpts.answerPayload, set
|
|
1852
|
+
// by the authorization gate above) into a { ok, content, result }
|
|
1853
|
+
// triple the AI client returns to the model. The actual user
|
|
1854
|
+
// interaction rides the `ask_user_required` SSE event; the chat
|
|
1855
|
+
// UI is the only thing that ever sees the question payload.
|
|
1856
|
+
if (name === 'ask_user') {
|
|
1857
|
+
let askMod;
|
|
1858
|
+
try { askMod = require('./tools/ask.js'); }
|
|
1859
|
+
catch (e) {
|
|
1860
|
+
const r = { error: { code: 'EMODULE', message: 'ask_user tool module unavailable: ' + (e.message || e) } };
|
|
1861
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1862
|
+
}
|
|
1863
|
+
let validated;
|
|
1864
|
+
try { validated = askMod.validateArgs(args); }
|
|
1865
|
+
catch (e) {
|
|
1866
|
+
const r = { error: { code: e.code || 'EBADINPUT', message: e.message } };
|
|
1867
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1868
|
+
}
|
|
1869
|
+
// The deny path passes { cancelled: true } explicitly. Any other
|
|
1870
|
+
// missing payload means the gate resolved without prompting — a
|
|
1871
|
+
// bug, not a user dismissal — so surface it as an internal error
|
|
1872
|
+
// the model can report instead of a silent "cancelled".
|
|
1873
|
+
const payload = (callOpts && callOpts.answerPayload);
|
|
1874
|
+
if (!payload) {
|
|
1875
|
+
const r = { error: { code: 'ENOANSWER', message: 'ask_user resolved without a user answer; the question was not shown or the session was stale. Ask the user again.' } };
|
|
1876
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1877
|
+
}
|
|
1878
|
+
const choice = payload && Array.isArray(payload.choice) ? payload.choice.slice() : (payload && typeof payload.choice === 'string' ? payload.choice : '');
|
|
1879
|
+
const extra = askMod.clampExtra(payload && typeof payload.extra === 'string' ? payload.extra : '');
|
|
1880
|
+
const out = askMod.buildResult({
|
|
1881
|
+
choice,
|
|
1882
|
+
extra,
|
|
1883
|
+
options: validated.options,
|
|
1884
|
+
multiSelect: validated.multiSelect,
|
|
1885
|
+
cancelled: !!(payload && payload.cancelled)
|
|
1886
|
+
});
|
|
1887
|
+
return { ok: out.ok, content: out.content, result: out.result };
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Native list_features tool — returns the full structured feature
|
|
1891
|
+
// state for the current project and chat. Not gated by authorization:
|
|
1892
|
+
// it is read-only metadata, does not execute commands or modify files.
|
|
1893
|
+
if (name === 'list_features') {
|
|
1894
|
+
let af;
|
|
1895
|
+
try { af = require('./agentFeatures.js'); }
|
|
1896
|
+
catch (e) {
|
|
1897
|
+
const r = { error: { code: 'EMODULE', message: 'agentFeatures module unavailable: ' + (e.message || e) } };
|
|
1898
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1899
|
+
}
|
|
1900
|
+
return await af.dispatchListFeatures(args, {
|
|
1901
|
+
projectDir: callOpts && callOpts.projectDir,
|
|
1902
|
+
chatId: callOpts && callOpts.chatId,
|
|
1903
|
+
chat: callOpts && callOpts.chat
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Native report_progress tool — validates args, emits a
|
|
1908
|
+
// progress_update SSE event so the frontend can show a live
|
|
1909
|
+
// progress bar, and returns the structured data to the model.
|
|
1910
|
+
if (name === 'report_progress') {
|
|
1911
|
+
let progMod;
|
|
1912
|
+
try { progMod = require('./tools/progress.js'); }
|
|
1913
|
+
catch (e) {
|
|
1914
|
+
const r = { error: { code: 'EMODULE', message: 'report_progress tool module unavailable: ' + (e.message || e) } };
|
|
1915
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1916
|
+
}
|
|
1917
|
+
let validated;
|
|
1918
|
+
try { validated = progMod.validateArgs(args); }
|
|
1919
|
+
catch (e) {
|
|
1920
|
+
const r = { error: { code: e.code || 'EBADINPUT', message: e.message } };
|
|
1921
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1922
|
+
}
|
|
1923
|
+
// Emit progress_update SSE event for the frontend.
|
|
1924
|
+
if (callOpts && callOpts.onEvent && typeof callOpts.onEvent === 'function') {
|
|
1925
|
+
callOpts.onEvent('progress_update', {
|
|
1926
|
+
callId: (callOpts && callOpts.callId) || null,
|
|
1927
|
+
title: validated.title,
|
|
1928
|
+
current: validated.current,
|
|
1929
|
+
total: validated.total,
|
|
1930
|
+
status: validated.status,
|
|
1931
|
+
message: validated.message || ''
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
return progMod.buildResult(validated);
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// Native file tools: read_file, list_files, search_files, write_file,
|
|
1938
|
+
// edit_file (compatibility alias for a full-file write).
|
|
1939
|
+
// Gated by callOpts.fileToolsEnabled (matches the spec-collection
|
|
1940
|
+
// branch above). Dispatched in one shot — all four share the same
|
|
1941
|
+
// path-safety, size-cap, and authorization story, so a single
|
|
1942
|
+
// dispatch helper keeps the call site readable.
|
|
1943
|
+
if (name === 'read_file' || name === 'list_files' || name === 'search_files' || name === 'write_file' || name === 'edit_file') {
|
|
1944
|
+
let ft;
|
|
1945
|
+
try { ft = require('./tools/files.js'); }
|
|
1946
|
+
catch (e) {
|
|
1947
|
+
const r = { error: { code: 'EMODULE', message: 'file tools module unavailable: ' + (e.message || e) } };
|
|
1948
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1949
|
+
}
|
|
1950
|
+
return await ft.runFileTool(name, {
|
|
1951
|
+
projectDir: callOpts && callOpts.projectDir,
|
|
1952
|
+
args,
|
|
1953
|
+
settings: callOpts && callOpts.appSettings,
|
|
1954
|
+
toolOutput: callOpts && callOpts.toolOutput
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Native web-preview tool: opens a URL in the debug Chrome and
|
|
1959
|
+
// returns a small JPEG screenshot of what is on the page. The
|
|
1960
|
+
// chat UI renders it as a thumbnail card; tapping the card
|
|
1961
|
+
// opens a full-screen modal with a close button. Authorize is
|
|
1962
|
+
// handled by the dispatch gate above (it treats `webpreview` as
|
|
1963
|
+
// a native family like the others), so the runner does not see
|
|
1964
|
+
// a denial again — it just runs.
|
|
1965
|
+
if (name === 'webpreview') {
|
|
1966
|
+
let wp;
|
|
1967
|
+
try { wp = require('./tools/webpreview.js'); }
|
|
1968
|
+
catch (e) {
|
|
1969
|
+
const r = { error: { code: 'EMODULE', message: 'webpreview tool module unavailable: ' + (e.message || e) } };
|
|
1970
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1971
|
+
}
|
|
1972
|
+
try {
|
|
1973
|
+
const out = await wp.runWebpreview({
|
|
1974
|
+
url: args && args.url,
|
|
1975
|
+
viewport: args && args.viewport,
|
|
1976
|
+
signal: callOpts && callOpts.signal
|
|
1977
|
+
});
|
|
1978
|
+
return out;
|
|
1979
|
+
} catch (e) {
|
|
1980
|
+
const r = { error: { code: 'EWEBPREVIEW', message: e.message || String(e) } };
|
|
1981
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// MCP tools (mcp__<serverSlug>__<toolName>).
|
|
1986
|
+
if (callOpts && callOpts.projectDir) {
|
|
1987
|
+
let mcpMod;
|
|
1988
|
+
try { mcpMod = require('./mcp.js'); }
|
|
1989
|
+
catch (e) {
|
|
1990
|
+
const r = { error: { code: 'EMODULE', message: 'MCP module unavailable: ' + (e.message || e) } };
|
|
1991
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
1992
|
+
}
|
|
1993
|
+
const parsed = mcpMod.parseServerSlugAndToolName(name);
|
|
1994
|
+
if (parsed) {
|
|
1995
|
+
let error = null;
|
|
1996
|
+
let out;
|
|
1997
|
+
try {
|
|
1998
|
+
out = await mcpMod.callTool(callOpts.projectDir, parsed.serverSlug, parsed.toolName, args);
|
|
1999
|
+
} catch (e) {
|
|
2000
|
+
error = {
|
|
2001
|
+
code: (e && e.code) || 'EMCP_RPC',
|
|
2002
|
+
message: (e && e.message) || String(e)
|
|
2003
|
+
};
|
|
2004
|
+
if (e && e.serverSlug) error.serverSlug = e.serverSlug;
|
|
2005
|
+
if (e && e.toolName) error.toolName = e.toolName;
|
|
2006
|
+
out = { ok: false, content: [{ type: 'text', text: 'MCP error: ' + (error.message || error.code) }], isError: true };
|
|
2007
|
+
}
|
|
2008
|
+
// MCP servers may report an error as a normal result with isError=true.
|
|
2009
|
+
// Give that path the same typed envelope as a thrown transport/RPC error.
|
|
2010
|
+
if (!out.ok && !error) {
|
|
2011
|
+
const text = Array.isArray(out.content)
|
|
2012
|
+
? out.content.find((block) => block && block.type === 'text' && block.text)
|
|
2013
|
+
: null;
|
|
2014
|
+
error = {
|
|
2015
|
+
code: 'EMCP_RPC',
|
|
2016
|
+
message: (text && text.text) || 'MCP tool failed',
|
|
2017
|
+
serverSlug: parsed.serverSlug,
|
|
2018
|
+
toolName: parsed.toolName
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
const result = {
|
|
2022
|
+
content: out.content,
|
|
2023
|
+
isError: !!out.isError,
|
|
2024
|
+
serverSlug: parsed.serverSlug,
|
|
2025
|
+
toolName: parsed.toolName
|
|
2026
|
+
};
|
|
2027
|
+
if (error) result.error = error;
|
|
2028
|
+
return { ok: !!out.ok, content: JSON.stringify(result), result };
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Unknown tool.
|
|
2033
|
+
const r = { error: { code: 'EUNKNOWN_TOOL', message: 'Unknown tool: ' + name } };
|
|
2034
|
+
return { ok: false, content: JSON.stringify(r), result: r };
|
|
2035
|
+
}
|
|
2036
|
+
} // end streamChat
|
|
2037
|
+
|
|
2038
|
+
module.exports = {
|
|
2039
|
+
streamChat,
|
|
2040
|
+
runSingleToolCall,
|
|
2041
|
+
// byte-stream helpers, re-exported for tests through src/ai.js
|
|
2042
|
+
parseSSEFrame,
|
|
2043
|
+
readSSE,
|
|
2044
|
+
readNDJSON,
|
|
2045
|
+
// OpenAI prompt-cache routing key derivation, exported for tests
|
|
2046
|
+
promptCacheKeyFor
|
|
2047
|
+
};
|
|
2048
|
+
|