@lwmxiaobei/xbcode 1.0.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 +631 -0
- package/README.zh-CN.md +542 -0
- package/dist/agent.js +1450 -0
- package/dist/busy-status.js +29 -0
- package/dist/clipboard-image.js +97 -0
- package/dist/commands.js +109 -0
- package/dist/compact.js +262 -0
- package/dist/config.js +516 -0
- package/dist/error-log.js +80 -0
- package/dist/http.js +89 -0
- package/dist/idle-watchdog.js +88 -0
- package/dist/index.js +2031 -0
- package/dist/input-submit.js +41 -0
- package/dist/mcp/client.js +466 -0
- package/dist/mcp/manager.js +275 -0
- package/dist/mcp/runtime.js +420 -0
- package/dist/mcp/types.js +12 -0
- package/dist/message-bus.js +180 -0
- package/dist/oauth/openai.js +326 -0
- package/dist/prompt.js +156 -0
- package/dist/session-store.js +186 -0
- package/dist/skills/frontmatter.js +85 -0
- package/dist/skills/index.js +2 -0
- package/dist/skills/loader.js +88 -0
- package/dist/skills/render.js +35 -0
- package/dist/skills/types.js +1 -0
- package/dist/subagents.js +64 -0
- package/dist/supervisor.js +58 -0
- package/dist/task-manager.js +280 -0
- package/dist/team-types.js +1 -0
- package/dist/teammate-manager.js +266 -0
- package/dist/tools.js +1068 -0
- package/dist/trust-store.js +42 -0
- package/dist/types.js +1 -0
- package/dist/usage.js +226 -0
- package/dist/utils.js +21 -0
- package/package.json +67 -0
- package/scripts/postinstall.mjs +30 -0
- package/skills/code-review/SKILL.md +22 -0
- package/skills/pdf/SKILL.md +18 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,1450 @@
|
|
|
1
|
+
import { APIUserAbortError } from "openai";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { microCompact, estimateTokens, autoCompact, autoCompactResponseHistory, TOKEN_THRESHOLD, } from "./compact.js";
|
|
4
|
+
import { isTransientNetworkError } from "./http.js";
|
|
5
|
+
import { combineAbortSignals, createIdleWatchdog, getStreamIdleTimeoutMs } from "./idle-watchdog.js";
|
|
6
|
+
import { logApiError, wrapApiError } from "./error-log.js";
|
|
7
|
+
import { getDynamicMcpToolSurface } from "./mcp/runtime.js";
|
|
8
|
+
import { messageBus, teammateManager, LEAD_NAME, TOOLS, CHAT_TOOLS, BASE_TOOLS, BASE_CHAT_TOOLS, TEAMMATE_TOOLS, TEAMMATE_CHAT_TOOLS, BASE_TOOL_HANDLERS, taskManager } from "./tools.js";
|
|
9
|
+
import { formatTeammateMessages } from "./message-bus.js";
|
|
10
|
+
import { getSubagentDefinition } from "./subagents.js";
|
|
11
|
+
// P1:删除 MailboxEventType / MAILBOX_EVENT_TYPES / normalizeEventType。
|
|
12
|
+
// 这些是 P3 协议消息字段,从 P1 阶段的 MailboxMessage 中已彻底移除。
|
|
13
|
+
const NAG_THRESHOLD = 3;
|
|
14
|
+
const NAG_MESSAGE = "<reminder>Update your tasks with task_list or task_update.</reminder>";
|
|
15
|
+
const RESPONSES_COMPACT_INTERVAL = 20;
|
|
16
|
+
const STREAM_MAX_RETRIES = 2;
|
|
17
|
+
const STREAM_RETRY_DELAYS_MS = [200, 800];
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
export class TurnInterruptedError extends Error {
|
|
22
|
+
responseId;
|
|
23
|
+
partialAssistantText;
|
|
24
|
+
constructor(options) {
|
|
25
|
+
super("Turn interrupted by user.");
|
|
26
|
+
this.name = "TurnInterruptedError";
|
|
27
|
+
this.responseId = options?.responseId;
|
|
28
|
+
this.partialAssistantText = options?.partialAssistantText;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function isTurnInterruptedError(error) {
|
|
32
|
+
return error instanceof TurnInterruptedError;
|
|
33
|
+
}
|
|
34
|
+
function throwIfAborted(signal) {
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
throw new TurnInterruptedError();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function safeJsonParse(value) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(value);
|
|
42
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Human-in-the-loop: only side-effecting tools are gated behind user approval.
|
|
49
|
+
// Read-only tools (read_file/glob/grep/task_*/mcp reads) run without a prompt so
|
|
50
|
+
// the loop stays fast. `bash` keeps its dangerous-command blocklist underneath.
|
|
51
|
+
const TOOLS_REQUIRING_APPROVAL = new Set(["bash", "write_file", "edit_file"]);
|
|
52
|
+
function toolNeedsApproval(name) {
|
|
53
|
+
return TOOLS_REQUIRING_APPROVAL.has(name);
|
|
54
|
+
}
|
|
55
|
+
// Returned to the model in place of a real tool result when the user denies a
|
|
56
|
+
// call, so the tool-call/result pairing stays valid and the model re-plans.
|
|
57
|
+
function buildToolRejectionOutput(name) {
|
|
58
|
+
return `Rejected by user: the tool "${name}" was not run. Do not retry it. Ask the user how they would like to proceed.`;
|
|
59
|
+
}
|
|
60
|
+
// `ask_user_question` 的执行不走 BASE_TOOL_HANDLERS:它必须经 UiBridge 弹出交互菜单,
|
|
61
|
+
// 因此和工具审批一样在 agent loop 层拦截。下面三个辅助函数负责:解析模型给的脏参数、
|
|
62
|
+
// 调 bridge 阻塞等待用户作答、把结果序列化成模型可读的 tool output。
|
|
63
|
+
export const ASK_USER_QUESTION_TOOL_NAME = "ask_user_question";
|
|
64
|
+
export function parseUserChoiceQuestions(raw) {
|
|
65
|
+
if (!Array.isArray(raw)) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const questions = [];
|
|
69
|
+
for (const item of raw) {
|
|
70
|
+
if (!item || typeof item !== "object") {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const obj = item;
|
|
74
|
+
const header = String(obj.header ?? "").trim();
|
|
75
|
+
const question = String(obj.question ?? "").trim();
|
|
76
|
+
const rawOptions = Array.isArray(obj.options) ? obj.options : [];
|
|
77
|
+
const options = [];
|
|
78
|
+
for (const opt of rawOptions) {
|
|
79
|
+
if (!opt || typeof opt !== "object") {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const o = opt;
|
|
83
|
+
const label = String(o.label ?? "").trim();
|
|
84
|
+
if (!label) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const description = o.description != null ? String(o.description) : undefined;
|
|
88
|
+
options.push(description ? { label, description } : { label });
|
|
89
|
+
}
|
|
90
|
+
if (!question || options.length === 0) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
questions.push({ header, question, multiSelect: Boolean(obj.multiSelect), options });
|
|
94
|
+
}
|
|
95
|
+
return questions;
|
|
96
|
+
}
|
|
97
|
+
export function formatUserChoiceResult(questions, answers) {
|
|
98
|
+
const blocks = questions.map((question, index) => {
|
|
99
|
+
const selected = (answers[index] ?? []).filter((label) => label.trim().length > 0);
|
|
100
|
+
const selectedText = selected.length > 0 ? selected.join(", ") : "(no selection)";
|
|
101
|
+
return `Q: ${question.question}\nSelected: ${selectedText}`;
|
|
102
|
+
});
|
|
103
|
+
return `The user answered the question(s):\n\n${blocks.join("\n\n")}`;
|
|
104
|
+
}
|
|
105
|
+
async function runAskUserQuestion(args, bridge) {
|
|
106
|
+
const questions = parseUserChoiceQuestions(args.questions);
|
|
107
|
+
if (questions.length === 0) {
|
|
108
|
+
return 'Error: ask_user_question requires a non-empty "questions" array; each question needs a "question" string and at least one option with a "label".';
|
|
109
|
+
}
|
|
110
|
+
const answers = await bridge.requestUserChoice(questions);
|
|
111
|
+
return formatUserChoiceResult(questions, answers);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 为一个 assistant content part 生成稳定 key。
|
|
115
|
+
*
|
|
116
|
+
* 为什么要单独建 key:
|
|
117
|
+
* - Responses 流里同一段文本可能先经历 `content_part.added`,再经历
|
|
118
|
+
* `output_text.delta`,最后再来 `output_text.done`。
|
|
119
|
+
* - 如果不按 output/content 位置追踪已渲染长度,UI 很容易把同一段文本显示
|
|
120
|
+
* 三遍,尤其是在不同后端混合发送这些事件的时候。
|
|
121
|
+
* - 这里使用 output/content 下标组合,足以在单次响应内唯一标识一个文本块。
|
|
122
|
+
*/
|
|
123
|
+
function getResponseContentKey(outputIndex, contentIndex) {
|
|
124
|
+
return `${String(outputIndex ?? "")}:${String(contentIndex ?? "")}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Normalize Responses API input into the explicit list form expected by the
|
|
128
|
+
* ChatGPT Codex backend.
|
|
129
|
+
*
|
|
130
|
+
* Why this exists:
|
|
131
|
+
* - The public OpenAI Responses API accepts a plain string as shorthand input,
|
|
132
|
+
* but `chatgpt.com/backend-api/codex/responses` rejects that shortcut and
|
|
133
|
+
* requires `input` to be a list.
|
|
134
|
+
* - Converting strings into a single user message preserves the original
|
|
135
|
+
* behavior while making the request shape compatible with both backends.
|
|
136
|
+
* - Existing array inputs are forwarded unchanged so tool-call follow-up items
|
|
137
|
+
* and previous structured payloads keep their original form.
|
|
138
|
+
*/
|
|
139
|
+
function normalizeResponseInput(inputItems) {
|
|
140
|
+
if (Array.isArray(inputItems)) {
|
|
141
|
+
return inputItems;
|
|
142
|
+
}
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
type: "message",
|
|
146
|
+
role: "user",
|
|
147
|
+
content: [
|
|
148
|
+
{
|
|
149
|
+
type: "input_text",
|
|
150
|
+
text: inputItems,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Build a canonical user-message item for stateless Responses replay.
|
|
158
|
+
*
|
|
159
|
+
* Why this exists:
|
|
160
|
+
* - When `previous_response_id` is unavailable we must resend prior turns as an
|
|
161
|
+
* explicit input list, so user prompts need one stable shape.
|
|
162
|
+
* - Keeping the constructor in one place avoids subtle inconsistencies between
|
|
163
|
+
* the first request of a turn and follow-up replay requests after tool calls.
|
|
164
|
+
* - The same shape remains valid for providers that still support the public
|
|
165
|
+
* Responses API shorthand.
|
|
166
|
+
*/
|
|
167
|
+
function buildResponseInputContent(text, attachments = []) {
|
|
168
|
+
const content = [
|
|
169
|
+
{
|
|
170
|
+
type: "input_text",
|
|
171
|
+
text,
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
for (const attachment of attachments) {
|
|
175
|
+
content.push({
|
|
176
|
+
type: "input_image",
|
|
177
|
+
image_url: `data:${attachment.mimeType};base64,${attachment.base64Data}`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return content;
|
|
181
|
+
}
|
|
182
|
+
function buildUserResponseMessage(text, attachments = []) {
|
|
183
|
+
return {
|
|
184
|
+
type: "message",
|
|
185
|
+
role: "user",
|
|
186
|
+
content: buildResponseInputContent(text, attachments),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 在 Responses 模式切链后的首轮请求里,把 compact 摘要显式拼回用户输入。
|
|
191
|
+
*
|
|
192
|
+
* 为什么需要这一步:
|
|
193
|
+
* - 支持 `previous_response_id` 的 provider 平时把上下文保存在服务端,不会自动重放本地 `responseHistory`。
|
|
194
|
+
* - 一旦 compact 时主动断开旧链,如果下一轮只发送新的用户问题,模型就会失去 compact 前的连续性。
|
|
195
|
+
* - 这里把 compact 摘要和当前用户请求合并成一条 user message,可以在不改请求协议的前提下恢复上下文。
|
|
196
|
+
*/
|
|
197
|
+
function buildCompactedResponsesQuery(summary, query) {
|
|
198
|
+
return `${summary}\n\nCurrent user request:\n${query}`;
|
|
199
|
+
}
|
|
200
|
+
function buildChatUserMessageContent(text, attachments = []) {
|
|
201
|
+
if (attachments.length === 0) {
|
|
202
|
+
return text;
|
|
203
|
+
}
|
|
204
|
+
const content = [
|
|
205
|
+
{
|
|
206
|
+
type: "text",
|
|
207
|
+
text,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
for (const attachment of attachments) {
|
|
211
|
+
content.push({
|
|
212
|
+
type: "image_url",
|
|
213
|
+
image_url: {
|
|
214
|
+
url: `data:${attachment.mimeType};base64,${attachment.base64Data}`,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return content;
|
|
219
|
+
}
|
|
220
|
+
function describeAttachments(attachments) {
|
|
221
|
+
if (attachments.length === 0) {
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
return attachments
|
|
225
|
+
.map((attachment, index) => `Image ${index + 1}: ${path.basename(attachment.path)}`)
|
|
226
|
+
.join("\n");
|
|
227
|
+
}
|
|
228
|
+
const CHAT_REASONING_CONTENT_REQUIRED_MODEL_PATTERNS = [
|
|
229
|
+
/^mimo(?:[-_.]|$)/i,
|
|
230
|
+
];
|
|
231
|
+
export function shouldPreserveChatReasoningContent(model, showThinking) {
|
|
232
|
+
if (!showThinking) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const normalizedModel = model.trim();
|
|
236
|
+
return CHAT_REASONING_CONTENT_REQUIRED_MODEL_PATTERNS.some((pattern) => pattern.test(normalizedModel));
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Deep-clone replay items before reusing them in a later request.
|
|
240
|
+
*
|
|
241
|
+
* Why this exists:
|
|
242
|
+
* - Response output objects come from the SDK and may be mutated elsewhere
|
|
243
|
+
* during rendering or debugging.
|
|
244
|
+
* - Stateless replay should preserve the exact model-visible payload from the
|
|
245
|
+
* earlier round, not a shared object that another call could modify.
|
|
246
|
+
* - JSON cloning is sufficient here because Responses items are plain data.
|
|
247
|
+
*/
|
|
248
|
+
function cloneResponseReplayItem(value) {
|
|
249
|
+
return JSON.parse(JSON.stringify(value));
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Keep only the output items that are valid and useful for stateless replay.
|
|
253
|
+
*
|
|
254
|
+
* Why this exists:
|
|
255
|
+
* - The ChatGPT Codex backend rejects `previous_response_id`, so later rounds
|
|
256
|
+
* must be rebuilt from prior assistant messages and function calls.
|
|
257
|
+
* - Message and function-call items are enough to reconstruct the model-visible
|
|
258
|
+
* assistant state; ephemeral bookkeeping items do not help and may be invalid
|
|
259
|
+
* when sent back as input.
|
|
260
|
+
* - Returning cloned objects lets callers append the items directly into
|
|
261
|
+
* `responseHistory` without worrying about shared references.
|
|
262
|
+
*/
|
|
263
|
+
function collectReplayableResponseOutput(output) {
|
|
264
|
+
if (!Array.isArray(output)) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
return output
|
|
268
|
+
.filter((item) => {
|
|
269
|
+
if (!item || typeof item !== "object") {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const type = String(item.type ?? "");
|
|
273
|
+
return type === "message" || type === "function_call";
|
|
274
|
+
})
|
|
275
|
+
.map((item) => cloneResponseReplayItem(item));
|
|
276
|
+
}
|
|
277
|
+
function extractAssistantText(content) {
|
|
278
|
+
if (typeof content === "string") {
|
|
279
|
+
return content;
|
|
280
|
+
}
|
|
281
|
+
if (!Array.isArray(content)) {
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
return content
|
|
285
|
+
.map((item) => {
|
|
286
|
+
if (typeof item === "string")
|
|
287
|
+
return item;
|
|
288
|
+
if (typeof item === "object" && item !== null && "text" in item) {
|
|
289
|
+
return String(item.text ?? "");
|
|
290
|
+
}
|
|
291
|
+
return "";
|
|
292
|
+
})
|
|
293
|
+
.join("");
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* 从 Responses API 的 `output` 里提取 assistant 文本。
|
|
297
|
+
*
|
|
298
|
+
* 为什么要单独做这一步:
|
|
299
|
+
* - UI 实时渲染主要依赖 `response.output_text.delta`,但不同后端并不保证
|
|
300
|
+
* 一定会把最终文本完整地以 delta 形式流出来。
|
|
301
|
+
* - 某些响应会在 `finalResponse().output` 里携带完整 message 文本,同时还带
|
|
302
|
+
* function_call;如果这里只信任流式 delta,UI 就会出现“只看到工具,没有
|
|
303
|
+
* 看到模型文字”的问题。
|
|
304
|
+
* - 这里统一从最终 `output` 兜底提取,确保无论流事件是否完整,assistant
|
|
305
|
+
* 文本都能被恢复。
|
|
306
|
+
*/
|
|
307
|
+
export function extractAssistantTextFromResponseOutput(output) {
|
|
308
|
+
if (!Array.isArray(output)) {
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
return output
|
|
312
|
+
.map((item) => {
|
|
313
|
+
if (item?.type === "message" && item?.role === "assistant") {
|
|
314
|
+
return extractAssistantText(item.content);
|
|
315
|
+
}
|
|
316
|
+
if (item?.type === "text") {
|
|
317
|
+
return extractAssistantText(item.text);
|
|
318
|
+
}
|
|
319
|
+
return "";
|
|
320
|
+
})
|
|
321
|
+
.join("")
|
|
322
|
+
.trim();
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* 计算最终响应里还没有被 UI 显示出来的 assistant 文本增量。
|
|
326
|
+
*
|
|
327
|
+
* 为什么不是直接再次整段 push:
|
|
328
|
+
* - 如果前半段文本已经通过 delta 渲染出来,直接整段补发会导致重复显示。
|
|
329
|
+
* - 最常见的缺口是“完全没流出来”或“只缺最后一小段”,因此优先走前缀补齐,
|
|
330
|
+
* 让 UI 尽量保持一条连续 assistant 消息。
|
|
331
|
+
* - 如果最终文本和已流出的前缀完全对不上,说明后端事件形态和预期差异更大,
|
|
332
|
+
* 这时返回整段完整文本,至少保证用户能看到模型真实回答。
|
|
333
|
+
*/
|
|
334
|
+
export function getMissingAssistantText(streamedText, finalText) {
|
|
335
|
+
const streamed = streamedText.trim();
|
|
336
|
+
const finalized = finalText.trim();
|
|
337
|
+
if (!finalized) {
|
|
338
|
+
return "";
|
|
339
|
+
}
|
|
340
|
+
if (!streamed) {
|
|
341
|
+
return finalized;
|
|
342
|
+
}
|
|
343
|
+
if (finalized === streamed) {
|
|
344
|
+
return "";
|
|
345
|
+
}
|
|
346
|
+
if (finalized.startsWith(streamed)) {
|
|
347
|
+
return finalized.slice(streamed.length);
|
|
348
|
+
}
|
|
349
|
+
return finalized;
|
|
350
|
+
}
|
|
351
|
+
function repairInterruptedToolCallHistory(history) {
|
|
352
|
+
if (history.length === 0) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
let assistantIndex = history.length - 1;
|
|
356
|
+
while (assistantIndex >= 0 && history[assistantIndex]?.role === "tool") {
|
|
357
|
+
assistantIndex -= 1;
|
|
358
|
+
}
|
|
359
|
+
if (assistantIndex < 0) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const assistantMessage = history[assistantIndex];
|
|
363
|
+
if (assistantMessage?.role !== "assistant" || !Array.isArray(assistantMessage.tool_calls) || assistantMessage.tool_calls.length === 0) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const trailingMessages = history.slice(assistantIndex + 1);
|
|
367
|
+
if (!trailingMessages.every((message) => message.role === "tool")) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const expectedToolCallIds = assistantMessage.tool_calls
|
|
371
|
+
.map((toolCall) => String(toolCall?.id ?? ""))
|
|
372
|
+
.filter(Boolean);
|
|
373
|
+
if (expectedToolCallIds.length === 0) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const actualToolCallIds = new Set(trailingMessages.map((message) => String(message.tool_call_id ?? "")).filter(Boolean));
|
|
377
|
+
const hasAllToolResponses = expectedToolCallIds.every((toolCallId) => actualToolCallIds.has(toolCallId));
|
|
378
|
+
if (hasAllToolResponses) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const assistantText = String(assistantMessage.content ?? "");
|
|
382
|
+
history.splice(assistantIndex);
|
|
383
|
+
if (assistantText.trim()) {
|
|
384
|
+
history.push({
|
|
385
|
+
role: "assistant",
|
|
386
|
+
content: assistantText,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function createSilentBridge() {
|
|
391
|
+
return {
|
|
392
|
+
appendAssistantDelta() { },
|
|
393
|
+
appendThinkingDelta() { },
|
|
394
|
+
finalizeStreaming() { },
|
|
395
|
+
pushAssistant() { },
|
|
396
|
+
pushTool() { },
|
|
397
|
+
updateUsage() { },
|
|
398
|
+
noteStreamActivity() { },
|
|
399
|
+
// Sub-agents and teammates run autonomously: auto-approve their tool calls.
|
|
400
|
+
requestToolApproval() {
|
|
401
|
+
return Promise.resolve("approved");
|
|
402
|
+
},
|
|
403
|
+
// 自治 agent 无人可问:对每道题返回首选项作为确定性默认答案。
|
|
404
|
+
requestUserChoice(questions) {
|
|
405
|
+
return Promise.resolve(questions.map((question) => (question.options[0] ? [question.options[0].label] : [])));
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function normalizeTeammateName(value) {
|
|
410
|
+
return String(value ?? "").trim();
|
|
411
|
+
}
|
|
412
|
+
function isValidTeammateName(value) {
|
|
413
|
+
return /^[A-Za-z0-9_-]+$/.test(value);
|
|
414
|
+
}
|
|
415
|
+
// P1:删除 normalizeMessageType。message_send 不再支持 broadcast type;
|
|
416
|
+
// P3 协议消息阶段会用独立工具(不混在 message_send schema 里)。
|
|
417
|
+
function buildTeammateSystem(baseSystem, name, role) {
|
|
418
|
+
return `${baseSystem}
|
|
419
|
+
You are teammate "${name}" in a persistent agent team.
|
|
420
|
+
Your role: ${role}.
|
|
421
|
+
You do not speak directly to the human user.
|
|
422
|
+
You receive work through inbox messages injected as user messages.
|
|
423
|
+
Use message_send to coordinate with lead or other teammates.
|
|
424
|
+
When you complete a meaningful chunk, send a concise update to lead.`;
|
|
425
|
+
}
|
|
426
|
+
function buildInboxWorkPrompt() {
|
|
427
|
+
return "Process the inbox items in order. Use available tools to do the work. Coordinate via message_send when needed.";
|
|
428
|
+
}
|
|
429
|
+
// 子代理需要按定义裁剪工具,而不是总是继承整套 BASE_TOOLS。
|
|
430
|
+
// 这样 `task` 才能从“再跑一次 loop”升级成“按角色运行的 worker”,
|
|
431
|
+
// 这是 Claude Code 子代理体系里最核心、也是最值得最小化迁移的部分。
|
|
432
|
+
function selectToolsByName(tools, allowedToolNames) {
|
|
433
|
+
const allowed = new Set(allowedToolNames);
|
|
434
|
+
return tools.filter((tool) => allowed.has(String(tool?.name ?? "")));
|
|
435
|
+
}
|
|
436
|
+
// `explore` 这类只读 agent 不能直接复用通用 bash handler,
|
|
437
|
+
// 因为通用 handler 允许执行任意工作区命令。这里做一层显式白名单,
|
|
438
|
+
// 目的是把“只读”从 prompt 约束升级为运行时约束,避免模型失手写文件。
|
|
439
|
+
function isReadOnlyShellCommand(command) {
|
|
440
|
+
const trimmed = command.trim();
|
|
441
|
+
if (!trimmed) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
const forbiddenPatterns = [
|
|
445
|
+
/(^|[\s;&|])(rm|mv|cp|mkdir|touch|chmod|chown)\b/,
|
|
446
|
+
/(^|[\s;&|])(git\s+(add|commit|checkout|switch|restore|reset|clean|merge|rebase|pull|push))\b/,
|
|
447
|
+
/(^|[\s;&|])(npm|pnpm|yarn|bun|pip|pip3)\s+(install|add|remove|uninstall)\b/,
|
|
448
|
+
/>/,
|
|
449
|
+
/\|/,
|
|
450
|
+
];
|
|
451
|
+
return !forbiddenPatterns.some((pattern) => pattern.test(trimmed));
|
|
452
|
+
}
|
|
453
|
+
// 这里把“工具列表”和“工具执行函数”一起裁剪。
|
|
454
|
+
// 只裁工具定义不裁 handler 会留下越权入口,只裁 handler 不裁 schema 又会误导模型。
|
|
455
|
+
function buildSubagentRuntime(definition) {
|
|
456
|
+
const responseTools = selectToolsByName(BASE_TOOLS, definition.allowedTools);
|
|
457
|
+
const chatTools = selectToolsByName(BASE_CHAT_TOOLS, definition.allowedTools);
|
|
458
|
+
const handlers = { ...BASE_TOOL_HANDLERS };
|
|
459
|
+
if (definition.readOnlyShell) {
|
|
460
|
+
handlers.bash = ({ command }) => {
|
|
461
|
+
const normalized = String(command ?? "");
|
|
462
|
+
if (!isReadOnlyShellCommand(normalized)) {
|
|
463
|
+
return "Error: This sub-agent is read-only. Only non-mutating shell commands are allowed.";
|
|
464
|
+
}
|
|
465
|
+
return BASE_TOOL_HANDLERS.bash({ command: normalized });
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
for (const toolName of Object.keys(handlers)) {
|
|
469
|
+
if (!definition.allowedTools.includes(toolName)) {
|
|
470
|
+
delete handlers[toolName];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
handlers,
|
|
475
|
+
responseTools,
|
|
476
|
+
chatTools,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
async function prepareToolRuntime(baseHandlers, baseResponseTools, baseChatTools) {
|
|
480
|
+
const dynamicMcp = await getDynamicMcpToolSurface();
|
|
481
|
+
return {
|
|
482
|
+
handlers: {
|
|
483
|
+
...baseHandlers,
|
|
484
|
+
...dynamicMcp.handlers,
|
|
485
|
+
},
|
|
486
|
+
responseTools: [
|
|
487
|
+
...baseResponseTools,
|
|
488
|
+
...dynamicMcp.responseTools,
|
|
489
|
+
],
|
|
490
|
+
chatTools: [
|
|
491
|
+
...baseChatTools,
|
|
492
|
+
...dynamicMcp.chatTools,
|
|
493
|
+
],
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
function calculateCost(inputTokens, outputTokens, cachedInputTokens) {
|
|
497
|
+
const uncachedInput = Math.max(0, inputTokens - cachedInputTokens);
|
|
498
|
+
return (uncachedInput * 2.0 + cachedInputTokens * 0.5 + outputTokens * 8.0) / 1_000_000;
|
|
499
|
+
}
|
|
500
|
+
function extractTokenUsage(usage) {
|
|
501
|
+
const inputTokens = Number(usage?.input_tokens ?? usage?.prompt_tokens ?? 0);
|
|
502
|
+
const outputTokens = Number(usage?.output_tokens ?? usage?.completion_tokens ?? 0);
|
|
503
|
+
const cachedInputTokens = Number(usage?.input_token_details?.cached_tokens ??
|
|
504
|
+
usage?.prompt_tokens_details?.cached_tokens ?? 0);
|
|
505
|
+
const cost = calculateCost(inputTokens, outputTokens, cachedInputTokens);
|
|
506
|
+
return { inputTokens, outputTokens, cachedInputTokens, cost };
|
|
507
|
+
}
|
|
508
|
+
async function streamResponse(client, model, system, showThinking, inputItems, previousResponseId, bridge, tools = TOOLS, control, onUsage, caller = "main") {
|
|
509
|
+
throwIfAborted(control?.signal);
|
|
510
|
+
const normalizedInstructions = system.trim() || "You are a helpful coding assistant.";
|
|
511
|
+
const normalizedInput = normalizeResponseInput(inputItems);
|
|
512
|
+
const idleTimeoutMs = getStreamIdleTimeoutMs();
|
|
513
|
+
let attempt = 0;
|
|
514
|
+
// 仅 attempt 0 时为 false。一旦任何字节通过 bridge 推到 UI,就不能再重试,
|
|
515
|
+
// 否则用户会看到同一段文本被重复 append。
|
|
516
|
+
while (true) {
|
|
517
|
+
// 每个 attempt 一个新 watchdog。它的 signal 会和 user signal 合并传给 SDK,
|
|
518
|
+
// 任意一端 abort 都会触发 SDK 取消请求;catch 时通过 `watchdog.triggered`
|
|
519
|
+
// 标志区分"用户 Esc"还是"watchdog 自动 abort"。
|
|
520
|
+
const watchdog = createIdleWatchdog(idleTimeoutMs);
|
|
521
|
+
const requestSignal = combineAbortSignals([control?.signal, watchdog.signal]);
|
|
522
|
+
const stream = client.responses.stream({
|
|
523
|
+
model,
|
|
524
|
+
instructions: normalizedInstructions,
|
|
525
|
+
input: normalizedInput,
|
|
526
|
+
// ChatGPT Codex backend is stricter than the public Responses API.
|
|
527
|
+
// `sub2api`'s working probe payload explicitly sends `store: false` for the
|
|
528
|
+
// Codex OAuth path, and the public API also accepts this field, so we set
|
|
529
|
+
// it unconditionally to keep one compatible request shape for both backends.
|
|
530
|
+
store: false,
|
|
531
|
+
previous_response_id: previousResponseId,
|
|
532
|
+
tools: tools,
|
|
533
|
+
}, requestSignal ? { signal: requestSignal } : undefined);
|
|
534
|
+
// 请求发出后立刻 arm watchdog。第一个字节最久允许 idleTimeoutMs 出现,
|
|
535
|
+
// 这样"建立 TCP 但服务端完全不发数据"的情况也能被兜住。
|
|
536
|
+
watchdog.reset();
|
|
537
|
+
let responseId;
|
|
538
|
+
let assistantText = "";
|
|
539
|
+
let streamedToBridge = false;
|
|
540
|
+
const streamedFunctionCalls = new Map();
|
|
541
|
+
const streamedAssistantContent = new Map();
|
|
542
|
+
const emitAssistantDelta = (text) => {
|
|
543
|
+
if (!text)
|
|
544
|
+
return;
|
|
545
|
+
streamedToBridge = true;
|
|
546
|
+
bridge.appendAssistantDelta(text);
|
|
547
|
+
};
|
|
548
|
+
const emitThinkingDelta = (text) => {
|
|
549
|
+
if (!text)
|
|
550
|
+
return;
|
|
551
|
+
streamedToBridge = true;
|
|
552
|
+
bridge.appendThinkingDelta(text);
|
|
553
|
+
};
|
|
554
|
+
/**
|
|
555
|
+
* ChatGPT Codex stream responses are slightly different from the public
|
|
556
|
+
* Responses API as surfaced through the OpenAI SDK:
|
|
557
|
+
* - tool-call items are emitted over SSE events,
|
|
558
|
+
* - but `stream.finalResponse()` can still return `output: []`.
|
|
559
|
+
* We therefore key partial function calls by `output_index` and rebuild the
|
|
560
|
+
* final tool-call list from the stream itself when needed.
|
|
561
|
+
*/
|
|
562
|
+
const getFunctionCallKey = (event, fallbackIndex) => {
|
|
563
|
+
if (event?.output_index !== undefined) {
|
|
564
|
+
return String(event.output_index);
|
|
565
|
+
}
|
|
566
|
+
if (event?.item?.call_id) {
|
|
567
|
+
return String(event.item.call_id);
|
|
568
|
+
}
|
|
569
|
+
if (event?.item?.id) {
|
|
570
|
+
return String(event.item.id);
|
|
571
|
+
}
|
|
572
|
+
return String(fallbackIndex ?? streamedFunctionCalls.size);
|
|
573
|
+
};
|
|
574
|
+
try {
|
|
575
|
+
for await (const event of stream) {
|
|
576
|
+
// 心跳:任何 SDK 事件都算"流还活着",包括 reasoning_*.delta 这类
|
|
577
|
+
// 不一定渲染到 UI 的事件。让 UI 能区分"模型在 thinking"和"连接 stall"。
|
|
578
|
+
bridge.noteStreamActivity();
|
|
579
|
+
watchdog.reset();
|
|
580
|
+
if (event.type === "response.created") {
|
|
581
|
+
responseId = String(event.response?.id ?? responseId ?? "");
|
|
582
|
+
}
|
|
583
|
+
if (event.type === "response.output_text.delta") {
|
|
584
|
+
const delta = String(event.delta ?? "");
|
|
585
|
+
assistantText += delta;
|
|
586
|
+
const contentKey = getResponseContentKey(event.output_index, event.content_index);
|
|
587
|
+
streamedAssistantContent.set(contentKey, `${streamedAssistantContent.get(contentKey) ?? ""}${delta}`);
|
|
588
|
+
emitAssistantDelta(delta);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* 有些 Responses 后端不会先发 `output_text.delta`,而是直接先把一个完整或
|
|
593
|
+
* 半完整的 output_text part 塞进 `content_part.added/done`。如果不消费这些
|
|
594
|
+
* 事件,UI 就会出现“只有工具调用,没有 assistant 文本”。
|
|
595
|
+
*/
|
|
596
|
+
if (event.type === "response.content_part.added" && event.part?.type === "output_text") {
|
|
597
|
+
const contentKey = getResponseContentKey(event.output_index, event.content_index);
|
|
598
|
+
const nextText = String(event.part?.text ?? "");
|
|
599
|
+
const emittedText = streamedAssistantContent.get(contentKey) ?? "";
|
|
600
|
+
const missingText = nextText.startsWith(emittedText) ? nextText.slice(emittedText.length) : nextText;
|
|
601
|
+
if (missingText) {
|
|
602
|
+
assistantText += missingText;
|
|
603
|
+
emitAssistantDelta(missingText);
|
|
604
|
+
}
|
|
605
|
+
streamedAssistantContent.set(contentKey, nextText);
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (event.type === "response.output_item.added" && event.item?.type === "function_call") {
|
|
609
|
+
const key = getFunctionCallKey(event);
|
|
610
|
+
streamedFunctionCalls.set(key, {
|
|
611
|
+
...cloneResponseReplayItem(event.item),
|
|
612
|
+
arguments: String(event.item?.arguments ?? ""),
|
|
613
|
+
});
|
|
614
|
+
bridge.finalizeStreaming();
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (event.type === "response.function_call_arguments.delta") {
|
|
618
|
+
const key = getFunctionCallKey(event);
|
|
619
|
+
const current = streamedFunctionCalls.get(key);
|
|
620
|
+
if (current) {
|
|
621
|
+
current.arguments = `${String(current.arguments ?? "")}${String(event.delta ?? "")}`;
|
|
622
|
+
streamedFunctionCalls.set(key, current);
|
|
623
|
+
}
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (event.type === "response.output_item.done" && event.item?.type === "function_call") {
|
|
627
|
+
const key = getFunctionCallKey(event);
|
|
628
|
+
const current = streamedFunctionCalls.get(key) ?? {};
|
|
629
|
+
streamedFunctionCalls.set(key, {
|
|
630
|
+
...current,
|
|
631
|
+
...cloneResponseReplayItem(event.item),
|
|
632
|
+
arguments: String(event.item?.arguments ?? current.arguments ?? ""),
|
|
633
|
+
});
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (event.type === "response.content_part.done" && event.part?.type === "output_text") {
|
|
637
|
+
const contentKey = getResponseContentKey(event.output_index, event.content_index);
|
|
638
|
+
const nextText = String(event.part?.text ?? "");
|
|
639
|
+
const emittedText = streamedAssistantContent.get(contentKey) ?? "";
|
|
640
|
+
const missingText = nextText.startsWith(emittedText) ? nextText.slice(emittedText.length) : nextText;
|
|
641
|
+
if (missingText) {
|
|
642
|
+
assistantText += missingText;
|
|
643
|
+
emitAssistantDelta(missingText);
|
|
644
|
+
}
|
|
645
|
+
streamedAssistantContent.set(contentKey, nextText);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (event.type === "response.output_text.done") {
|
|
649
|
+
const contentKey = getResponseContentKey(event.output_index, event.content_index);
|
|
650
|
+
const nextText = String(event.text ?? "");
|
|
651
|
+
const emittedText = streamedAssistantContent.get(contentKey) ?? "";
|
|
652
|
+
const missingText = nextText.startsWith(emittedText) ? nextText.slice(emittedText.length) : nextText;
|
|
653
|
+
if (missingText) {
|
|
654
|
+
assistantText += missingText;
|
|
655
|
+
emitAssistantDelta(missingText);
|
|
656
|
+
}
|
|
657
|
+
streamedAssistantContent.set(contentKey, nextText);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (showThinking && ["response.reasoning_summary_text.delta", "response.reasoning_text.delta"].includes(event.type)) {
|
|
661
|
+
emitThinkingDelta(String(event.delta ?? ""));
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
const response = await stream.finalResponse();
|
|
666
|
+
watchdog.disarm();
|
|
667
|
+
if (response.usage) {
|
|
668
|
+
onUsage?.(extractTokenUsage(response.usage));
|
|
669
|
+
}
|
|
670
|
+
const sdkOutput = Array.isArray(response.output) ? response.output : [];
|
|
671
|
+
const recoveredAssistantText = extractAssistantTextFromResponseOutput(sdkOutput);
|
|
672
|
+
const missingAssistantText = getMissingAssistantText(assistantText, recoveredAssistantText);
|
|
673
|
+
/**
|
|
674
|
+
* 这里必须在 finalize 之前补 UI:
|
|
675
|
+
* - `appendAssistantDelta()` 依赖当前正在流式渲染的 message id;
|
|
676
|
+
* - 一旦先 finalize,就只能新建一条 assistant 消息,文本会和前面的片段断开;
|
|
677
|
+
* - 先补齐缺失尾巴,再 finalize,才能最大程度保留“同一条回答”的连续性。
|
|
678
|
+
*/
|
|
679
|
+
if (missingAssistantText) {
|
|
680
|
+
emitAssistantDelta(missingAssistantText);
|
|
681
|
+
assistantText = `${assistantText}${missingAssistantText}`;
|
|
682
|
+
}
|
|
683
|
+
bridge.finalizeStreaming();
|
|
684
|
+
/**
|
|
685
|
+
* Preserve SDK output when present, but patch in a synthetic fallback for
|
|
686
|
+
* Codex OAuth streams whose final response omits the items we already saw on
|
|
687
|
+
* the wire.
|
|
688
|
+
*/
|
|
689
|
+
if (sdkOutput.length > 0) {
|
|
690
|
+
return response;
|
|
691
|
+
}
|
|
692
|
+
const rebuiltOutput = [];
|
|
693
|
+
if (assistantText) {
|
|
694
|
+
rebuiltOutput.push({
|
|
695
|
+
type: "message",
|
|
696
|
+
role: "assistant",
|
|
697
|
+
content: [
|
|
698
|
+
{
|
|
699
|
+
type: "output_text",
|
|
700
|
+
text: assistantText,
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
for (const item of streamedFunctionCalls.values()) {
|
|
706
|
+
rebuiltOutput.push(item);
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
...response,
|
|
710
|
+
output: rebuiltOutput,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
watchdog.disarm();
|
|
715
|
+
bridge.finalizeStreaming();
|
|
716
|
+
// 必须在 user-abort 判断之前处理 watchdog 触发:watchdog 也走的是
|
|
717
|
+
// AbortController,SDK 会抛 APIUserAbortError,但语义上不是用户主动停。
|
|
718
|
+
if (watchdog.triggered && !control?.signal?.aborted) {
|
|
719
|
+
// 已经流出过字节就不重试——重试会让 UI 出现重复段。
|
|
720
|
+
// 这种情况只能交给上层(最终 throw 一个明确的 stalled error)。
|
|
721
|
+
if (attempt < STREAM_MAX_RETRIES && !streamedToBridge) {
|
|
722
|
+
await sleep(STREAM_RETRY_DELAYS_MS[attempt] ?? STREAM_RETRY_DELAYS_MS[STREAM_RETRY_DELAYS_MS.length - 1]);
|
|
723
|
+
attempt += 1;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const stalledError = new Error(`Stream stalled: no SSE event for ${idleTimeoutMs}ms (set STREAM_IDLE_TIMEOUT_MS=0 to disable, or a larger value to tolerate slower reasoning models).`);
|
|
727
|
+
logApiError(caller, stalledError, {
|
|
728
|
+
api: "responses",
|
|
729
|
+
model,
|
|
730
|
+
previousResponseId,
|
|
731
|
+
toolCount: tools.length,
|
|
732
|
+
inputItemCount: normalizedInput.length,
|
|
733
|
+
inputCharCount: JSON.stringify(normalizedInput).length,
|
|
734
|
+
showThinking,
|
|
735
|
+
idleTimeoutMs,
|
|
736
|
+
streamedToBridge,
|
|
737
|
+
});
|
|
738
|
+
throw stalledError;
|
|
739
|
+
}
|
|
740
|
+
if (error instanceof APIUserAbortError || control?.signal?.aborted) {
|
|
741
|
+
throw new TurnInterruptedError({
|
|
742
|
+
responseId,
|
|
743
|
+
partialAssistantText: assistantText || undefined,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
// 仅当 transient 网络错误且 UI 还没收到任何内容时才重试,避免重复输出。
|
|
747
|
+
if (attempt < STREAM_MAX_RETRIES &&
|
|
748
|
+
!streamedToBridge &&
|
|
749
|
+
isTransientNetworkError(error)) {
|
|
750
|
+
await sleep(STREAM_RETRY_DELAYS_MS[attempt] ?? STREAM_RETRY_DELAYS_MS[STREAM_RETRY_DELAYS_MS.length - 1]);
|
|
751
|
+
attempt += 1;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
logApiError(caller, error, {
|
|
755
|
+
api: "responses",
|
|
756
|
+
model,
|
|
757
|
+
previousResponseId,
|
|
758
|
+
toolCount: tools.length,
|
|
759
|
+
inputItemCount: normalizedInput.length,
|
|
760
|
+
inputCharCount: JSON.stringify(normalizedInput).length,
|
|
761
|
+
showThinking,
|
|
762
|
+
});
|
|
763
|
+
throw wrapApiError(caller, error);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
async function streamChatCompletion(client, model, system, history, bridge, tools = CHAT_TOOLS, showThinking = false, control, onUsage, caller = "main") {
|
|
768
|
+
throwIfAborted(control?.signal);
|
|
769
|
+
const createParams = {
|
|
770
|
+
model,
|
|
771
|
+
messages: [{ role: "system", content: system }, ...history],
|
|
772
|
+
tools: tools,
|
|
773
|
+
tool_choice: "auto",
|
|
774
|
+
stream: true,
|
|
775
|
+
stream_options: { include_usage: true },
|
|
776
|
+
};
|
|
777
|
+
if (showThinking) {
|
|
778
|
+
createParams.thinking = { type: "enabled" };
|
|
779
|
+
}
|
|
780
|
+
const idleTimeoutMs = getStreamIdleTimeoutMs();
|
|
781
|
+
let attempt = 0;
|
|
782
|
+
while (true) {
|
|
783
|
+
let content = "";
|
|
784
|
+
let reasoningContent = "";
|
|
785
|
+
let streamedToBridge = false;
|
|
786
|
+
const toolCallBuffers = {};
|
|
787
|
+
// 每个 attempt 一个新 watchdog。和 streamResponse 同样的策略:
|
|
788
|
+
// SDK 收到 abort 后会抛 APIUserAbortError,catch 时 watchdog.triggered 优先判断。
|
|
789
|
+
const watchdog = createIdleWatchdog(idleTimeoutMs);
|
|
790
|
+
const requestSignal = combineAbortSignals([control?.signal, watchdog.signal]);
|
|
791
|
+
let stream;
|
|
792
|
+
try {
|
|
793
|
+
// 建立连接前 arm watchdog —— 兜住"connect 完成但服务端永不发任何 chunk"。
|
|
794
|
+
watchdog.reset();
|
|
795
|
+
stream = await client.chat.completions.create(createParams, requestSignal ? { signal: requestSignal } : undefined);
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
watchdog.disarm();
|
|
799
|
+
if (watchdog.triggered && !control?.signal?.aborted) {
|
|
800
|
+
if (attempt < STREAM_MAX_RETRIES) {
|
|
801
|
+
await sleep(STREAM_RETRY_DELAYS_MS[attempt] ?? STREAM_RETRY_DELAYS_MS[STREAM_RETRY_DELAYS_MS.length - 1]);
|
|
802
|
+
attempt += 1;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
throw new Error(`Stream stalled before first event: no response for ${idleTimeoutMs}ms`);
|
|
806
|
+
}
|
|
807
|
+
if (error instanceof APIUserAbortError || control?.signal?.aborted) {
|
|
808
|
+
throw new TurnInterruptedError({});
|
|
809
|
+
}
|
|
810
|
+
if (attempt < STREAM_MAX_RETRIES &&
|
|
811
|
+
isTransientNetworkError(error)) {
|
|
812
|
+
await sleep(STREAM_RETRY_DELAYS_MS[attempt] ?? STREAM_RETRY_DELAYS_MS[STREAM_RETRY_DELAYS_MS.length - 1]);
|
|
813
|
+
attempt += 1;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
logApiError(caller, error, {
|
|
817
|
+
api: "chat-completions",
|
|
818
|
+
model,
|
|
819
|
+
toolCount: tools.length,
|
|
820
|
+
inputItemCount: history.length,
|
|
821
|
+
inputCharCount: JSON.stringify(history).length,
|
|
822
|
+
showThinking,
|
|
823
|
+
});
|
|
824
|
+
throw wrapApiError(caller, error);
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
for await (const chunk of stream) {
|
|
828
|
+
// 心跳:每个 chunk(即使是 usage-only 或空 delta)都算"流还活着"。
|
|
829
|
+
// 关键场景:mimo 这类 reasoning 模型在 thinking 阶段会持续吐
|
|
830
|
+
// `reasoning_content` chunk,但用户没开 SHOW_THINKING 时 UI 不渲染——
|
|
831
|
+
// 没有心跳的话,外部就以为"卡死"了。
|
|
832
|
+
bridge.noteStreamActivity();
|
|
833
|
+
watchdog.reset();
|
|
834
|
+
if (chunk.usage) {
|
|
835
|
+
onUsage?.(extractTokenUsage(chunk.usage));
|
|
836
|
+
}
|
|
837
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
838
|
+
if (!delta)
|
|
839
|
+
continue;
|
|
840
|
+
if (showThinking && delta.reasoning_content) {
|
|
841
|
+
reasoningContent += delta.reasoning_content;
|
|
842
|
+
streamedToBridge = true;
|
|
843
|
+
bridge.appendThinkingDelta(delta.reasoning_content);
|
|
844
|
+
}
|
|
845
|
+
if (delta.content) {
|
|
846
|
+
content += delta.content;
|
|
847
|
+
streamedToBridge = true;
|
|
848
|
+
bridge.appendAssistantDelta(delta.content);
|
|
849
|
+
}
|
|
850
|
+
if (delta.tool_calls) {
|
|
851
|
+
for (const tc of delta.tool_calls) {
|
|
852
|
+
if (!toolCallBuffers[tc.index]) {
|
|
853
|
+
toolCallBuffers[tc.index] = {
|
|
854
|
+
id: tc.id ?? "",
|
|
855
|
+
type: "function",
|
|
856
|
+
function: { name: "", arguments: "" },
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
const buf = toolCallBuffers[tc.index];
|
|
860
|
+
if (tc.id)
|
|
861
|
+
buf.id = tc.id;
|
|
862
|
+
if (tc.function?.name)
|
|
863
|
+
buf.function.name += tc.function.name;
|
|
864
|
+
if (tc.function?.arguments)
|
|
865
|
+
buf.function.arguments += tc.function.arguments;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
watchdog.disarm();
|
|
870
|
+
bridge.finalizeStreaming();
|
|
871
|
+
const toolCalls = Object.keys(toolCallBuffers)
|
|
872
|
+
.sort((left, right) => Number(left) - Number(right))
|
|
873
|
+
.map((key) => toolCallBuffers[Number(key)]);
|
|
874
|
+
return {
|
|
875
|
+
content: content || null,
|
|
876
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : [],
|
|
877
|
+
reasoning_content: reasoningContent || undefined,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
catch (error) {
|
|
881
|
+
watchdog.disarm();
|
|
882
|
+
bridge.finalizeStreaming();
|
|
883
|
+
// watchdog 优先:和 streamResponse 同样的理由。
|
|
884
|
+
if (watchdog.triggered && !control?.signal?.aborted) {
|
|
885
|
+
if (attempt < STREAM_MAX_RETRIES && !streamedToBridge) {
|
|
886
|
+
await sleep(STREAM_RETRY_DELAYS_MS[attempt] ?? STREAM_RETRY_DELAYS_MS[STREAM_RETRY_DELAYS_MS.length - 1]);
|
|
887
|
+
attempt += 1;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const stalledError = new Error(`Stream stalled: no SSE event for ${idleTimeoutMs}ms (set STREAM_IDLE_TIMEOUT_MS=0 to disable, or a larger value to tolerate slower reasoning models).`);
|
|
891
|
+
logApiError(caller, stalledError, {
|
|
892
|
+
api: "chat-completions",
|
|
893
|
+
model,
|
|
894
|
+
toolCount: tools.length,
|
|
895
|
+
inputItemCount: history.length,
|
|
896
|
+
inputCharCount: JSON.stringify(history).length,
|
|
897
|
+
showThinking,
|
|
898
|
+
idleTimeoutMs,
|
|
899
|
+
streamedToBridge,
|
|
900
|
+
});
|
|
901
|
+
throw stalledError;
|
|
902
|
+
}
|
|
903
|
+
if (error instanceof APIUserAbortError || control?.signal?.aborted) {
|
|
904
|
+
throw new TurnInterruptedError({
|
|
905
|
+
partialAssistantText: content || undefined,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
if (attempt < STREAM_MAX_RETRIES &&
|
|
909
|
+
!streamedToBridge &&
|
|
910
|
+
isTransientNetworkError(error)) {
|
|
911
|
+
await sleep(STREAM_RETRY_DELAYS_MS[attempt] ?? STREAM_RETRY_DELAYS_MS[STREAM_RETRY_DELAYS_MS.length - 1]);
|
|
912
|
+
attempt += 1;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
logApiError(caller, error, {
|
|
916
|
+
api: "chat-completions",
|
|
917
|
+
model,
|
|
918
|
+
toolCount: tools.length,
|
|
919
|
+
inputItemCount: history.length,
|
|
920
|
+
inputCharCount: JSON.stringify(history).length,
|
|
921
|
+
showThinking,
|
|
922
|
+
});
|
|
923
|
+
throw wrapApiError(caller, error);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
async function runToolCall(toolCall, bridge, handlers, control, requireApproval = false) {
|
|
928
|
+
const name = String(toolCall.name ?? toolCall.function?.name ?? "unknown_tool");
|
|
929
|
+
const rawArgs = String(toolCall.arguments ?? toolCall.function?.arguments ?? "{}");
|
|
930
|
+
const args = safeJsonParse(rawArgs);
|
|
931
|
+
// ask_user_question 自带交互,不经审批,由 bridge 直接驱动 UI 并等待用户作答。
|
|
932
|
+
if (name === ASK_USER_QUESTION_TOOL_NAME) {
|
|
933
|
+
const output = await runAskUserQuestion(args, bridge);
|
|
934
|
+
bridge.pushTool(name, args, output);
|
|
935
|
+
return {
|
|
936
|
+
type: "function_call_output",
|
|
937
|
+
call_id: toolCall.call_id,
|
|
938
|
+
output,
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
if (requireApproval && toolNeedsApproval(name)) {
|
|
942
|
+
const decision = await bridge.requestToolApproval(name, args);
|
|
943
|
+
if (decision === "rejected") {
|
|
944
|
+
const rejection = buildToolRejectionOutput(name);
|
|
945
|
+
bridge.pushTool(name, args, rejection);
|
|
946
|
+
return {
|
|
947
|
+
type: "function_call_output",
|
|
948
|
+
call_id: toolCall.call_id,
|
|
949
|
+
output: rejection,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const handler = handlers[name];
|
|
954
|
+
const outputText = handler ? await handler(args, control) : `Unknown tool: ${name}`;
|
|
955
|
+
bridge.pushTool(name, args, outputText);
|
|
956
|
+
return {
|
|
957
|
+
type: "function_call_output",
|
|
958
|
+
call_id: toolCall.call_id,
|
|
959
|
+
output: outputText,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
// P1 简化版:只支持 from/to/content。
|
|
963
|
+
// 广播 / 协议字段(type/eventType/taskId/threadId/payload)全部移除:broadcast 由 P3 重做,
|
|
964
|
+
// 其余字段属于协议消息范畴,P3 用独立 schema 实现。
|
|
965
|
+
async function sendTeamMessage(from, to, content) {
|
|
966
|
+
const recipient = to.trim();
|
|
967
|
+
const body = content.trim();
|
|
968
|
+
if (!recipient) {
|
|
969
|
+
return "Error: Missing recipient.";
|
|
970
|
+
}
|
|
971
|
+
if (!body) {
|
|
972
|
+
return "Error: Missing content.";
|
|
973
|
+
}
|
|
974
|
+
// 校验收件人:lead 总是合法;teammate 必须存在且在运行。
|
|
975
|
+
if (recipient !== LEAD_NAME) {
|
|
976
|
+
const member = teammateManager.getMember(recipient);
|
|
977
|
+
if (!member) {
|
|
978
|
+
return `Error: Unknown teammate: ${recipient}`;
|
|
979
|
+
}
|
|
980
|
+
if (!teammateManager.isRunning(recipient)) {
|
|
981
|
+
return `Error: Teammate ${recipient} is not running. Spawn or restart it first.`;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
await messageBus.send({ from, to: recipient, content: body });
|
|
985
|
+
// 给 teammate 发消息时主动 wake 一下,让 idle 队友立刻处理;
|
|
986
|
+
// 给 lead 的消息由 MessageBus.onSend("lead") 在 UI 层触发自动续轮,此处不耦合。
|
|
987
|
+
if (recipient !== LEAD_NAME) {
|
|
988
|
+
teammateManager.wake(recipient);
|
|
989
|
+
}
|
|
990
|
+
return `Sent message to ${recipient}`;
|
|
991
|
+
}
|
|
992
|
+
function buildSharedTeamHandlers(agentName) {
|
|
993
|
+
return {
|
|
994
|
+
// P1:消息工具只支持 to + content。扩展字段(type/eventType/taskId 等)随 P3 协议消息重做。
|
|
995
|
+
message_send: async ({ to, content }) => sendTeamMessage(agentName, String(to ?? ""), String(content ?? "")),
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
async function launchTeammateRuntime(config, control) {
|
|
999
|
+
const bridge = createSilentBridge();
|
|
1000
|
+
while (true) {
|
|
1001
|
+
// 没未读消息则进 idle,等待 wake(teammateManager.wake 由 sendTeamMessage 主动调用)。
|
|
1002
|
+
if (!teammateManager.shouldStop(control) && (await messageBus.unreadCount(control.name)) === 0) {
|
|
1003
|
+
teammateManager.markIdle(control.name);
|
|
1004
|
+
await teammateManager.waitForWake(control);
|
|
1005
|
+
}
|
|
1006
|
+
// P1:用 readUnread + markRead 替代 drainInbox。文件保留全部历史,
|
|
1007
|
+
// 重启后未处理的 unread 消息仍然可见,便于审计与可恢复性。
|
|
1008
|
+
// shutdown_request 协议消息从 P1 阶段的 MailboxMessage 中已移除,本轮不再过滤;
|
|
1009
|
+
// P3 阶段重做协议时会用独立机制(不再混在 mailbox)。
|
|
1010
|
+
const inbox = await messageBus.readUnread(control.name);
|
|
1011
|
+
if (inbox.length > 0) {
|
|
1012
|
+
await messageBus.markRead(control.name, inbox);
|
|
1013
|
+
}
|
|
1014
|
+
// P1:保持 shutdown 路径在外层(teammateManager.requestStop / shouldStop),
|
|
1015
|
+
// 此处不再检测「邮件中是否含 shutdown_request」。
|
|
1016
|
+
const shutdownRequested = false;
|
|
1017
|
+
const actionableMessages = inbox;
|
|
1018
|
+
if (actionableMessages.length > 0) {
|
|
1019
|
+
teammateManager.markWorking(control.name);
|
|
1020
|
+
const prompt = `${formatTeammateMessages(actionableMessages)}\n\n${buildInboxWorkPrompt()}`;
|
|
1021
|
+
const attachments = [];
|
|
1022
|
+
const runtime = await prepareToolRuntime(buildTeammateHandlers(control.name), TEAMMATE_TOOLS, TEAMMATE_CHAT_TOOLS);
|
|
1023
|
+
await runTurn(config, prompt, attachments, control.state, bridge, runtime.handlers, runtime.responseTools, runtime.chatTools, undefined, `teammate:${control.name}`);
|
|
1024
|
+
}
|
|
1025
|
+
if (shutdownRequested || teammateManager.shouldStop(control)) {
|
|
1026
|
+
// P1:删除 shutdown_response 协议邮件。lead 通过 teammate_list 看 status=stopped
|
|
1027
|
+
// 即可感知;P3 协议消息阶段会用独立 schema 重做这个回执。
|
|
1028
|
+
// 仍然给 lead 发一条人类可读的简短通知,便于 UI 显示队友已退出。
|
|
1029
|
+
await messageBus.send({
|
|
1030
|
+
from: control.name,
|
|
1031
|
+
to: LEAD_NAME,
|
|
1032
|
+
content: `Teammate ${control.name} has shut down.`,
|
|
1033
|
+
});
|
|
1034
|
+
teammateManager.markStopped(control.name);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
function buildLeadHandlers(config, bridge) {
|
|
1040
|
+
return {
|
|
1041
|
+
...BASE_TOOL_HANDLERS,
|
|
1042
|
+
...buildSharedTeamHandlers(LEAD_NAME),
|
|
1043
|
+
task: async ({ description, subagent_type }) => {
|
|
1044
|
+
const taskDescription = String(description ?? "");
|
|
1045
|
+
const definition = getSubagentDefinition(typeof subagent_type === "string" ? subagent_type : undefined);
|
|
1046
|
+
const subSystem = `${config.system}\n${definition.systemPrompt}`;
|
|
1047
|
+
bridge.pushTool("task", { description: taskDescription, subagent_type: definition.name }, `launching ${definition.name} sub-agent...`);
|
|
1048
|
+
let result;
|
|
1049
|
+
if (config.apiMode === "chat-completions") {
|
|
1050
|
+
result = await subAgentLoopChatCompletions(config.client, config.model, subSystem, taskDescription, bridge, definition);
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
result = await subAgentLoopResponses(config.client, config.model, subSystem, taskDescription, bridge, definition);
|
|
1054
|
+
}
|
|
1055
|
+
return result;
|
|
1056
|
+
},
|
|
1057
|
+
teammate_spawn: async ({ name, role, prompt }) => {
|
|
1058
|
+
const teammateName = normalizeTeammateName(name);
|
|
1059
|
+
const teammateRole = String(role ?? "").trim();
|
|
1060
|
+
const initialPrompt = String(prompt ?? "").trim();
|
|
1061
|
+
if (!teammateName || !teammateRole || !initialPrompt) {
|
|
1062
|
+
return "Error: name, role, and prompt are required.";
|
|
1063
|
+
}
|
|
1064
|
+
if (!isValidTeammateName(teammateName)) {
|
|
1065
|
+
return `Error: Invalid teammate name: ${teammateName}`;
|
|
1066
|
+
}
|
|
1067
|
+
if (teammateName === LEAD_NAME) {
|
|
1068
|
+
return `Error: ${LEAD_NAME} is reserved.`;
|
|
1069
|
+
}
|
|
1070
|
+
if (teammateManager.isRunning(teammateName)) {
|
|
1071
|
+
return `Error: Teammate ${teammateName} is already running. Use message_send to assign more work.`;
|
|
1072
|
+
}
|
|
1073
|
+
teammateManager.ensureMember(teammateName, teammateRole);
|
|
1074
|
+
const { started } = teammateManager.startRuntime(teammateName, teammateRole, async (control) => {
|
|
1075
|
+
const teammateConfig = {
|
|
1076
|
+
...config,
|
|
1077
|
+
system: buildTeammateSystem(config.system, teammateName, teammateRole),
|
|
1078
|
+
};
|
|
1079
|
+
await launchTeammateRuntime(teammateConfig, control);
|
|
1080
|
+
});
|
|
1081
|
+
if (!started) {
|
|
1082
|
+
return `Error: Teammate ${teammateName} is already running.`;
|
|
1083
|
+
}
|
|
1084
|
+
// P1:teammate_spawn 后向新队友邮箱投递初始 prompt。
|
|
1085
|
+
// 简化为 from/to/content;新 send API 是 async,必须 await。
|
|
1086
|
+
await messageBus.send({
|
|
1087
|
+
from: LEAD_NAME,
|
|
1088
|
+
to: teammateName,
|
|
1089
|
+
content: initialPrompt,
|
|
1090
|
+
});
|
|
1091
|
+
teammateManager.wake(teammateName);
|
|
1092
|
+
return `Spawned teammate ${teammateName} (${teammateRole}). Initial prompt delivered.`;
|
|
1093
|
+
},
|
|
1094
|
+
teammate_shutdown: ({ name }) => {
|
|
1095
|
+
const requestedName = String(name ?? "").trim();
|
|
1096
|
+
const targets = requestedName
|
|
1097
|
+
? [requestedName]
|
|
1098
|
+
: teammateManager.listMembers().map((member) => member.name);
|
|
1099
|
+
if (targets.length === 0) {
|
|
1100
|
+
return "(no teammates)";
|
|
1101
|
+
}
|
|
1102
|
+
return targets
|
|
1103
|
+
.map((teammateName) => {
|
|
1104
|
+
const member = teammateManager.getMember(teammateName);
|
|
1105
|
+
if (!member) {
|
|
1106
|
+
return `- ${teammateName}: not found`;
|
|
1107
|
+
}
|
|
1108
|
+
if (!teammateManager.isRunning(teammateName)) {
|
|
1109
|
+
teammateManager.markStopped(teammateName);
|
|
1110
|
+
return `- ${teammateName}: already stopped`;
|
|
1111
|
+
}
|
|
1112
|
+
// P1:删除 shutdown_request 协议邮件。优雅退出由 teammateManager.requestStop
|
|
1113
|
+
// 在控制平面(control.stopRequested)实现,不再依赖邮箱传协议字段。
|
|
1114
|
+
// 同时给目标队友发一条人类可读的退出通知,让队友 loop 在处理完最后一条邮件后
|
|
1115
|
+
// 通过 shouldStop 检测到主动退出意图(注:本通知是普通消息,不是协议)。
|
|
1116
|
+
void messageBus.send({
|
|
1117
|
+
from: LEAD_NAME,
|
|
1118
|
+
to: teammateName,
|
|
1119
|
+
content: "Graceful shutdown requested by lead.",
|
|
1120
|
+
});
|
|
1121
|
+
teammateManager.requestStop(teammateName);
|
|
1122
|
+
teammateManager.wake(teammateName);
|
|
1123
|
+
return `- ${teammateName}: shutdown requested`;
|
|
1124
|
+
})
|
|
1125
|
+
.join("\n");
|
|
1126
|
+
},
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
function buildTeammateHandlers(agentName) {
|
|
1130
|
+
return {
|
|
1131
|
+
...BASE_TOOL_HANDLERS,
|
|
1132
|
+
...buildSharedTeamHandlers(agentName),
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
async function subAgentLoopResponses(client, model, system, description, bridge, definition) {
|
|
1136
|
+
const runtime = buildSubagentRuntime(definition);
|
|
1137
|
+
let nextInput = [
|
|
1138
|
+
{ role: "user", content: [{ type: "input_text", text: description }] },
|
|
1139
|
+
];
|
|
1140
|
+
let currentResponseId;
|
|
1141
|
+
let lastText = "";
|
|
1142
|
+
const caller = `subagent:${definition.name}`;
|
|
1143
|
+
for (let round = 0; round < definition.maxRounds; round += 1) {
|
|
1144
|
+
const response = await streamResponse(client, model, system, false, nextInput, currentResponseId, bridge, runtime.responseTools, undefined, undefined, caller);
|
|
1145
|
+
currentResponseId = response.id;
|
|
1146
|
+
const textItems = Array.isArray(response.output)
|
|
1147
|
+
? response.output.filter((item) => item.type === "message" || item.type === "text")
|
|
1148
|
+
: [];
|
|
1149
|
+
for (const item of textItems) {
|
|
1150
|
+
const text = extractAssistantText(item.content ?? item.text ?? "");
|
|
1151
|
+
if (text.trim())
|
|
1152
|
+
lastText = text.trim();
|
|
1153
|
+
}
|
|
1154
|
+
const outputText = Array.isArray(response.output)
|
|
1155
|
+
? response.output
|
|
1156
|
+
.map((item) => {
|
|
1157
|
+
if (item.type === "message")
|
|
1158
|
+
return extractAssistantText(item.content);
|
|
1159
|
+
return "";
|
|
1160
|
+
})
|
|
1161
|
+
.join("")
|
|
1162
|
+
.trim()
|
|
1163
|
+
: "";
|
|
1164
|
+
if (outputText)
|
|
1165
|
+
lastText = outputText;
|
|
1166
|
+
const toolCalls = Array.isArray(response.output)
|
|
1167
|
+
? response.output.filter((item) => item.type === "function_call")
|
|
1168
|
+
: [];
|
|
1169
|
+
if (toolCalls.length === 0)
|
|
1170
|
+
break;
|
|
1171
|
+
const results = [];
|
|
1172
|
+
for (const toolCall of toolCalls) {
|
|
1173
|
+
results.push(await runToolCall(toolCall, bridge, runtime.handlers));
|
|
1174
|
+
}
|
|
1175
|
+
nextInput = results;
|
|
1176
|
+
}
|
|
1177
|
+
return lastText || "(sub-agent completed with no text output)";
|
|
1178
|
+
}
|
|
1179
|
+
async function subAgentLoopChatCompletions(client, model, system, description, bridge, definition) {
|
|
1180
|
+
const runtime = buildSubagentRuntime(definition);
|
|
1181
|
+
const history = [{ role: "user", content: description }];
|
|
1182
|
+
let lastText = "";
|
|
1183
|
+
const caller = `subagent:${definition.name}`;
|
|
1184
|
+
for (let round = 0; round < definition.maxRounds; round += 1) {
|
|
1185
|
+
const message = await streamChatCompletion(client, model, system, history, bridge, runtime.chatTools, false, undefined, undefined, caller);
|
|
1186
|
+
const assistantText = extractAssistantText(message.content);
|
|
1187
|
+
if (assistantText.trim()) {
|
|
1188
|
+
lastText = assistantText.trim();
|
|
1189
|
+
}
|
|
1190
|
+
history.push({
|
|
1191
|
+
role: "assistant",
|
|
1192
|
+
content: message.content ?? "",
|
|
1193
|
+
tool_calls: message.tool_calls.length > 0 ? message.tool_calls : undefined,
|
|
1194
|
+
...(message.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
|
|
1195
|
+
});
|
|
1196
|
+
const toolCalls = message.tool_calls;
|
|
1197
|
+
if (toolCalls.length === 0)
|
|
1198
|
+
break;
|
|
1199
|
+
for (const toolCall of toolCalls) {
|
|
1200
|
+
const name = String(toolCall.function?.name ?? "unknown_tool");
|
|
1201
|
+
const args = safeJsonParse(String(toolCall.function?.arguments ?? "{}"));
|
|
1202
|
+
const handler = runtime.handlers[name];
|
|
1203
|
+
const outputText = handler ? await handler(args) : `Unknown tool: ${name}`;
|
|
1204
|
+
bridge.pushTool(name, args, outputText);
|
|
1205
|
+
history.push({
|
|
1206
|
+
role: "tool",
|
|
1207
|
+
tool_call_id: toolCall.id,
|
|
1208
|
+
content: outputText,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return lastText || "(sub-agent completed with no text output)";
|
|
1213
|
+
}
|
|
1214
|
+
async function agentLoop(config, query, attachments, previousResponseId, bridge, state, handlers, tools = TOOLS, control, onUsage, caller = "main") {
|
|
1215
|
+
/**
|
|
1216
|
+
* Most Responses providers support `previous_response_id`, so they can keep
|
|
1217
|
+
* server-side state and only receive the latest delta. ChatGPT Codex OAuth
|
|
1218
|
+
* does not support that parameter, so in that one branch we replay the local
|
|
1219
|
+
* conversation transcript on every round instead.
|
|
1220
|
+
*/
|
|
1221
|
+
const usesStatelessReplay = !config.supportsPreviousResponseId;
|
|
1222
|
+
/**
|
|
1223
|
+
* 无论 provider 是否支持 `previous_response_id`,本地都持续维护一份可重放历史。
|
|
1224
|
+
*
|
|
1225
|
+
* 为什么要在 stateful provider 上也这么做:
|
|
1226
|
+
* - responses 模式的 compact 需要本地历史做总结,否则只能定期把链路清空。
|
|
1227
|
+
* - `/resume`、状态栏估算、手动 `/compact` 也都依赖同一份本地上下文副本。
|
|
1228
|
+
* - 真正请求模型时,只有 stateless 分支会重放它,因此不会改变支持服务端链路的正常交互成本。
|
|
1229
|
+
*/
|
|
1230
|
+
const replayHistory = [
|
|
1231
|
+
...state.responseHistory.map((item) => cloneResponseReplayItem(item)),
|
|
1232
|
+
buildUserResponseMessage(query, attachments),
|
|
1233
|
+
];
|
|
1234
|
+
let nextInput = usesStatelessReplay
|
|
1235
|
+
? replayHistory
|
|
1236
|
+
: [buildUserResponseMessage(query, attachments)];
|
|
1237
|
+
let currentResponseId = usesStatelessReplay ? undefined : previousResponseId;
|
|
1238
|
+
while (true) {
|
|
1239
|
+
throwIfAborted(control?.signal);
|
|
1240
|
+
const response = await streamResponse(config.client, config.model, config.system, config.showThinking, nextInput, currentResponseId, bridge, tools, control, onUsage, caller);
|
|
1241
|
+
currentResponseId = response.id;
|
|
1242
|
+
replayHistory.push(...collectReplayableResponseOutput(response.output));
|
|
1243
|
+
const toolCalls = Array.isArray(response.output)
|
|
1244
|
+
? response.output.filter((item) => item.type === "function_call")
|
|
1245
|
+
: [];
|
|
1246
|
+
if (toolCalls.length === 0) {
|
|
1247
|
+
state.responseHistory = replayHistory.map((item) => cloneResponseReplayItem(item));
|
|
1248
|
+
return currentResponseId;
|
|
1249
|
+
}
|
|
1250
|
+
const hasTaskCall = toolCalls.some((tc) => String(tc.name).startsWith("task_"));
|
|
1251
|
+
state.roundsSinceTask = hasTaskCall ? 0 : state.roundsSinceTask + 1;
|
|
1252
|
+
const results = [];
|
|
1253
|
+
for (const toolCall of toolCalls) {
|
|
1254
|
+
throwIfAborted(control?.signal);
|
|
1255
|
+
results.push(await runToolCall(toolCall, bridge, handlers, control, true));
|
|
1256
|
+
throwIfAborted(control?.signal);
|
|
1257
|
+
}
|
|
1258
|
+
if (state.roundsSinceTask >= NAG_THRESHOLD && await taskManager.hasActiveTasks()) {
|
|
1259
|
+
const lastResult = results[results.length - 1];
|
|
1260
|
+
if (lastResult) {
|
|
1261
|
+
lastResult.output = `${NAG_MESSAGE}\n${lastResult.output}`;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
replayHistory.push(...results.map((item) => cloneResponseReplayItem(item)));
|
|
1265
|
+
if (usesStatelessReplay) {
|
|
1266
|
+
nextInput = replayHistory;
|
|
1267
|
+
currentResponseId = undefined;
|
|
1268
|
+
}
|
|
1269
|
+
else {
|
|
1270
|
+
nextInput = results;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
async function agentLoopWithChatCompletions(config, history, bridge, state, handlers, tools = CHAT_TOOLS, control, onUsage, caller = "main") {
|
|
1275
|
+
while (true) {
|
|
1276
|
+
throwIfAborted(control?.signal);
|
|
1277
|
+
microCompact(history);
|
|
1278
|
+
if (estimateTokens(history) > TOKEN_THRESHOLD) {
|
|
1279
|
+
bridge.pushAssistant("Context approaching limit, compacting conversation...");
|
|
1280
|
+
try {
|
|
1281
|
+
const compacted = await autoCompact(config.client, config.model, history);
|
|
1282
|
+
history.length = 0;
|
|
1283
|
+
history.push(...compacted.messages);
|
|
1284
|
+
state.compactCount += 1;
|
|
1285
|
+
}
|
|
1286
|
+
catch (error) {
|
|
1287
|
+
logApiError(caller, error, {
|
|
1288
|
+
api: "autoCompact",
|
|
1289
|
+
model: config.model,
|
|
1290
|
+
historyLength: history.length,
|
|
1291
|
+
});
|
|
1292
|
+
bridge.pushAssistant("⚠️ Compaction failed due to API error. Proceeding with raw history.");
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
let message;
|
|
1296
|
+
try {
|
|
1297
|
+
message = await streamChatCompletion(config.client, config.model, config.system, history, bridge, tools, config.showThinking, control, onUsage, caller);
|
|
1298
|
+
}
|
|
1299
|
+
catch (error) {
|
|
1300
|
+
if (error instanceof TurnInterruptedError && error.partialAssistantText) {
|
|
1301
|
+
history.push({
|
|
1302
|
+
role: "assistant",
|
|
1303
|
+
content: error.partialAssistantText,
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
throw error;
|
|
1307
|
+
}
|
|
1308
|
+
history.push({
|
|
1309
|
+
role: "assistant",
|
|
1310
|
+
content: message.content ?? "",
|
|
1311
|
+
tool_calls: message.tool_calls.length > 0 ? message.tool_calls : undefined,
|
|
1312
|
+
...(message.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
|
|
1313
|
+
});
|
|
1314
|
+
const toolCalls = message.tool_calls;
|
|
1315
|
+
if (toolCalls.length === 0) {
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
const hasTaskCall = toolCalls.some((tc) => String(tc.function?.name).startsWith("task_"));
|
|
1319
|
+
state.roundsSinceTask = hasTaskCall ? 0 : state.roundsSinceTask + 1;
|
|
1320
|
+
for (const toolCall of toolCalls) {
|
|
1321
|
+
throwIfAborted(control?.signal);
|
|
1322
|
+
const name = String(toolCall.function?.name ?? "unknown_tool");
|
|
1323
|
+
const args = safeJsonParse(String(toolCall.function?.arguments ?? "{}"));
|
|
1324
|
+
let outputText;
|
|
1325
|
+
if (name === ASK_USER_QUESTION_TOOL_NAME) {
|
|
1326
|
+
outputText = await runAskUserQuestion(args, bridge);
|
|
1327
|
+
}
|
|
1328
|
+
else if (toolNeedsApproval(name) && (await bridge.requestToolApproval(name, args)) === "rejected") {
|
|
1329
|
+
outputText = buildToolRejectionOutput(name);
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
const handler = handlers[name];
|
|
1333
|
+
outputText = handler ? await handler(args, control) : `Unknown tool: ${name}`;
|
|
1334
|
+
}
|
|
1335
|
+
bridge.pushTool(name, args, outputText);
|
|
1336
|
+
history.push({
|
|
1337
|
+
role: "tool",
|
|
1338
|
+
tool_call_id: toolCall.id,
|
|
1339
|
+
content: outputText,
|
|
1340
|
+
});
|
|
1341
|
+
throwIfAborted(control?.signal);
|
|
1342
|
+
}
|
|
1343
|
+
if (state.roundsSinceTask >= NAG_THRESHOLD && await taskManager.hasActiveTasks()) {
|
|
1344
|
+
history.push({
|
|
1345
|
+
role: "user",
|
|
1346
|
+
content: NAG_MESSAGE,
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
async function runTurn(config, query, attachments, state, bridge, handlers, responseTools, chatTools, control, caller = "main") {
|
|
1352
|
+
throwIfAborted(control?.signal);
|
|
1353
|
+
const { apiMode } = config;
|
|
1354
|
+
state.turnCount += 1;
|
|
1355
|
+
state.roundsSinceTask = 0;
|
|
1356
|
+
const turnUsage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cost: 0 };
|
|
1357
|
+
const onUsage = (u) => {
|
|
1358
|
+
turnUsage.inputTokens += u.inputTokens;
|
|
1359
|
+
turnUsage.outputTokens += u.outputTokens;
|
|
1360
|
+
turnUsage.cachedInputTokens += u.cachedInputTokens;
|
|
1361
|
+
turnUsage.cost += u.cost;
|
|
1362
|
+
bridge.updateUsage({ ...turnUsage });
|
|
1363
|
+
};
|
|
1364
|
+
if (apiMode === "chat-completions") {
|
|
1365
|
+
if (!shouldPreserveChatReasoningContent(config.model, config.showThinking)) {
|
|
1366
|
+
for (const msg of state.chatHistory) {
|
|
1367
|
+
if (msg.role === "assistant" && "reasoning_content" in msg) {
|
|
1368
|
+
delete msg.reasoning_content;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
microCompact(state.chatHistory);
|
|
1373
|
+
if (estimateTokens(state.chatHistory) > TOKEN_THRESHOLD) {
|
|
1374
|
+
bridge.pushAssistant("Context approaching limit, compacting conversation...");
|
|
1375
|
+
try {
|
|
1376
|
+
const compacted = await autoCompact(config.client, config.model, state.chatHistory);
|
|
1377
|
+
state.chatHistory.length = 0;
|
|
1378
|
+
state.chatHistory.push(...compacted.messages);
|
|
1379
|
+
state.compactCount += 1;
|
|
1380
|
+
}
|
|
1381
|
+
catch (error) {
|
|
1382
|
+
logApiError(caller, error, {
|
|
1383
|
+
api: "autoCompact",
|
|
1384
|
+
model: config.model,
|
|
1385
|
+
historyLength: state.chatHistory.length,
|
|
1386
|
+
});
|
|
1387
|
+
bridge.pushAssistant("⚠️ Compaction failed due to API error. Proceeding with raw history.");
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
state.chatHistory.push({ role: "user", content: buildChatUserMessageContent(query, attachments) });
|
|
1391
|
+
try {
|
|
1392
|
+
await agentLoopWithChatCompletions(config, state.chatHistory, bridge, state, handlers, chatTools, control, onUsage, caller);
|
|
1393
|
+
}
|
|
1394
|
+
catch (error) {
|
|
1395
|
+
if (error instanceof TurnInterruptedError) {
|
|
1396
|
+
repairInterruptedToolCallHistory(state.chatHistory);
|
|
1397
|
+
}
|
|
1398
|
+
throw error;
|
|
1399
|
+
}
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (state.turnCount > 1 && (state.turnCount - 1) % RESPONSES_COMPACT_INTERVAL === 0) {
|
|
1403
|
+
bridge.pushAssistant("Compacting Responses API context chain...");
|
|
1404
|
+
if (state.responseHistory.length > 0) {
|
|
1405
|
+
try {
|
|
1406
|
+
const compacted = await autoCompactResponseHistory(config.client, config.model, state.responseHistory);
|
|
1407
|
+
state.responseHistory = compacted.messages;
|
|
1408
|
+
/**
|
|
1409
|
+
* 仅 stateful provider 需要额外保存待注入的 compact 摘要。
|
|
1410
|
+
*
|
|
1411
|
+
* 为什么 stateless replay 不需要:
|
|
1412
|
+
* - stateless 分支下一轮会直接发送 `state.responseHistory`,其中已经包含 compact summary。
|
|
1413
|
+
* - stateful 分支不会默认重放本地历史,所以切链后的第一轮必须显式把摘要带回请求里。
|
|
1414
|
+
*/
|
|
1415
|
+
state.pendingCompactedContext = config.supportsPreviousResponseId
|
|
1416
|
+
? compacted.continuationMessage
|
|
1417
|
+
: undefined;
|
|
1418
|
+
state.previousResponseId = undefined;
|
|
1419
|
+
state.compactCount += 1;
|
|
1420
|
+
}
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
logApiError(caller, error, {
|
|
1423
|
+
api: "autoCompactResponseHistory",
|
|
1424
|
+
model: config.model,
|
|
1425
|
+
historyLength: state.responseHistory.length,
|
|
1426
|
+
});
|
|
1427
|
+
bridge.pushAssistant("⚠️ Responses API context compaction failed. Proceeding with raw history.");
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const pendingCompactedContext = state.pendingCompactedContext;
|
|
1432
|
+
const responsesQuery = pendingCompactedContext
|
|
1433
|
+
? buildCompactedResponsesQuery(pendingCompactedContext, query)
|
|
1434
|
+
: query;
|
|
1435
|
+
try {
|
|
1436
|
+
state.previousResponseId = await agentLoop(config, responsesQuery, attachments, state.previousResponseId, bridge, state, handlers, responseTools, control, onUsage, caller);
|
|
1437
|
+
state.pendingCompactedContext = undefined;
|
|
1438
|
+
}
|
|
1439
|
+
catch (error) {
|
|
1440
|
+
if (error instanceof TurnInterruptedError && error.responseId) {
|
|
1441
|
+
state.previousResponseId = error.responseId;
|
|
1442
|
+
state.pendingCompactedContext = undefined;
|
|
1443
|
+
}
|
|
1444
|
+
throw error;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
export async function runAgentTurn(config, query, attachments, state, bridge, control) {
|
|
1448
|
+
const runtime = await prepareToolRuntime(buildLeadHandlers(config, bridge), TOOLS, CHAT_TOOLS);
|
|
1449
|
+
await runTurn(config, query, attachments, state, bridge, runtime.handlers, runtime.responseTools, runtime.chatTools, control);
|
|
1450
|
+
}
|