@xcanwin/manyoyo 8.6.6 → 8.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/web/AGENTS.md +5 -0
- package/lib/web/agent-queue.js +150 -0
- package/lib/web/agent-run-control.js +124 -0
- package/lib/web/index.html +86 -85
- package/lib/web/server.js +760 -301
- package/package.json +2 -1
package/lib/web/server.js
CHANGED
|
@@ -58,6 +58,9 @@ const {
|
|
|
58
58
|
const { FileEventStore } = require('../core/event-store');
|
|
59
59
|
const historyStore = require('./history-store');
|
|
60
60
|
const messagesView = require('./messages-view');
|
|
61
|
+
const agentRunControl = require('./agent-run-control');
|
|
62
|
+
const agentQueue = require('./agent-queue');
|
|
63
|
+
const { EventEmitter } = require('events');
|
|
61
64
|
const zlib = require('zlib');
|
|
62
65
|
const { buildManyoyoLogPath, getLocalDateTag } = require('../log-path');
|
|
63
66
|
const { isValidLogDateTag, readServeLogEntries } = require('../serve-log-reader');
|
|
@@ -216,7 +219,9 @@ function createEmptyWebAgentSession(agentId, agentName) {
|
|
|
216
219
|
lastResumeError: '',
|
|
217
220
|
engineSessionId: '',
|
|
218
221
|
usageTotal: null,
|
|
219
|
-
model: ''
|
|
222
|
+
model: '',
|
|
223
|
+
queue: [],
|
|
224
|
+
queuePaused: false
|
|
220
225
|
};
|
|
221
226
|
}
|
|
222
227
|
|
|
@@ -268,7 +273,9 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
|
|
|
268
273
|
lastResumeError: typeof source.lastResumeError === 'string' ? source.lastResumeError : '',
|
|
269
274
|
engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : '',
|
|
270
275
|
usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
|
|
271
|
-
model: normalizeStoredAgentModelValue(source.model)
|
|
276
|
+
model: normalizeStoredAgentModelValue(source.model),
|
|
277
|
+
queue: agentQueue.normalizeStoredQueue(source.queue),
|
|
278
|
+
queuePaused: source.queuePaused === true
|
|
272
279
|
};
|
|
273
280
|
}
|
|
274
281
|
|
|
@@ -828,6 +835,44 @@ function stripAnsi(text) {
|
|
|
828
835
|
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
829
836
|
}
|
|
830
837
|
|
|
838
|
+
// Agent 原始输出只保留开头 AGENT_RAW_OUTPUT_MAX_CHARS(会话 id 在开头),另外保留一份结尾:
|
|
839
|
+
// 最终回复和用量都在输出末尾,超过上限后只看开头会把截断点之前的某条中间说明当成最终回复
|
|
840
|
+
const AGENT_RAW_OUTPUT_MAX_CHARS = 32 * 1024 * 1024;
|
|
841
|
+
const AGENT_OUTPUT_TAIL_MAX_CHARS = 8 * 1024 * 1024;
|
|
842
|
+
// 最终回复的上限:远大于正常回复,只挡住把整个文件当回复打出来的极端情况
|
|
843
|
+
const AGENT_FINAL_REPLY_MAX_CHARS = 1000000;
|
|
844
|
+
|
|
845
|
+
function createTailBuffer(maxChars) {
|
|
846
|
+
let parts = [];
|
|
847
|
+
let size = 0;
|
|
848
|
+
return {
|
|
849
|
+
push(text) {
|
|
850
|
+
if (!text) return;
|
|
851
|
+
parts.push(text);
|
|
852
|
+
size += text.length;
|
|
853
|
+
if (size > maxChars * 2) {
|
|
854
|
+
const joined = parts.join('').slice(-maxChars);
|
|
855
|
+
parts = [joined];
|
|
856
|
+
size = joined.length;
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
text() {
|
|
860
|
+
return parts.join('').slice(-maxChars);
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// 结束后的回复文本:结构化输出里提取到的最终回复按 AGENT_FINAL_REPLY_MAX_CHARS(远大于正常回复)保留;
|
|
866
|
+
// 提取不到才退回原始输出,原始输出按 WEB_OUTPUT_MAX_CHARS 截断
|
|
867
|
+
function resolveAgentFinalOutput(agentProgram, { stdout, stdoutTruncated, stdoutTail, raw }) {
|
|
868
|
+
const extracted = (stdoutTruncated ? extractAgentMessageFromStructuredOutput(agentProgram, stdoutTail) : '')
|
|
869
|
+
|| extractAgentMessageFromStructuredOutput(agentProgram, stdout);
|
|
870
|
+
if (extracted) {
|
|
871
|
+
return clipText(stripAnsi(extracted).trim(), AGENT_FINAL_REPLY_MAX_CHARS);
|
|
872
|
+
}
|
|
873
|
+
return clipText(stripAnsi(raw).trim() || '(无输出)');
|
|
874
|
+
}
|
|
875
|
+
|
|
831
876
|
function clipText(text, maxChars = WEB_OUTPUT_MAX_CHARS) {
|
|
832
877
|
if (typeof text !== 'string') return '';
|
|
833
878
|
if (text.length <= maxChars) return text;
|
|
@@ -933,14 +978,26 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
933
978
|
if (model) {
|
|
934
979
|
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
|
|
935
980
|
}
|
|
981
|
+
// 子 Agent 说的话和思考默认不转发(v2.1.211+ 才有 --forward-subagent-text,旧版本不认这个参数会直接报错)。
|
|
982
|
+
// 'auto':在容器里随命令探测(claude --help 约 50ms),支持就展开成这个参数、不支持展开成空;true:直接加。
|
|
983
|
+
// 'auto' 的参数放在整条命令末尾,不改变前面已有参数的相对顺序
|
|
984
|
+
const forwardSubagentText = options && options.forwardSubagentText;
|
|
985
|
+
const templateHasForward = /(?:^|\s)--forward-subagent-text(?:\s|$)/.test(templateText);
|
|
986
|
+
if (forwardSubagentText === true) {
|
|
987
|
+
flagSpecs.push({ flag: '--forward-subagent-text', pattern: /(?:^|\s)--forward-subagent-text(?:\s|$)/ });
|
|
988
|
+
}
|
|
936
989
|
const claudeTemplate = prependAgentFlags(
|
|
937
990
|
templateText,
|
|
938
991
|
/^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
|
|
939
992
|
flagSpecs
|
|
940
993
|
);
|
|
941
|
-
|
|
994
|
+
const rendered = claudeTemplate === templateText
|
|
942
995
|
? renderAgentPromptCommand(templateText, prompt)
|
|
943
996
|
: renderAgentPromptCommand(claudeTemplate, prompt);
|
|
997
|
+
if (forwardSubagentText === 'auto' && !templateHasForward) {
|
|
998
|
+
return `MANYOYO_FWD_SUBAGENT="$(claude --help 2>&1 | grep -q -- '--forward-subagent-text' && echo --forward-subagent-text)"; ${rendered} $MANYOYO_FWD_SUBAGENT`;
|
|
999
|
+
}
|
|
1000
|
+
return rendered;
|
|
944
1001
|
}
|
|
945
1002
|
|
|
946
1003
|
function buildGeminiAgentExecCommand(template, prompt, options = {}) {
|
|
@@ -1043,7 +1100,12 @@ function extractClaudeAgentMessage(text) {
|
|
|
1043
1100
|
let lastMessage = '';
|
|
1044
1101
|
for (const rawLine of String(text || '').split('\n')) {
|
|
1045
1102
|
const payload = parseJsonObjectLine(rawLine);
|
|
1046
|
-
|
|
1103
|
+
// result 事件带权威的最终文本;后台任务场景结尾有两个 result(第一个是中间结论),取最后一个
|
|
1104
|
+
if (payload && payload.type === 'result' && typeof payload.result === 'string' && payload.result.trim()) {
|
|
1105
|
+
lastMessage = payload.result.trim();
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
if (!payload || payload.type !== 'assistant' || pickFirstString(payload.parent_tool_use_id)) {
|
|
1047
1109
|
continue;
|
|
1048
1110
|
}
|
|
1049
1111
|
const message = toPlainObject(payload.message);
|
|
@@ -1594,7 +1656,21 @@ function createStructuredTraceEvent(provider, kind, eventType, textValue, extra
|
|
|
1594
1656
|
};
|
|
1595
1657
|
}
|
|
1596
1658
|
|
|
1659
|
+
// 派生子 Agent 的工具:v2.1.63 起叫 Agent,旧名字 Task 仍是别名,两个都认
|
|
1660
|
+
const CLAUDE_SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
|
|
1661
|
+
|
|
1662
|
+
// 子 Agent 里的 assistant / user 消息带 parent_tool_use_id(发起它的那次 Agent 调用的 id),主对话里是 null。
|
|
1663
|
+
// 只给事件加 parentToolId 字段,不改已有字段的含义
|
|
1597
1664
|
function prepareClaudeTraceEvents(payload, state) {
|
|
1665
|
+
const events = prepareClaudeTraceEventsBase(payload, state);
|
|
1666
|
+
const parentToolId = pickFirstString(payload && payload.parent_tool_use_id);
|
|
1667
|
+
if (parentToolId) {
|
|
1668
|
+
events.forEach(event => { event.parentToolId = parentToolId; });
|
|
1669
|
+
}
|
|
1670
|
+
return events;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function prepareClaudeTraceEventsBase(payload, state) {
|
|
1598
1674
|
const eventType = pickFirstString(payload.type);
|
|
1599
1675
|
const subtype = pickFirstString(payload.subtype);
|
|
1600
1676
|
const message = toPlainObject(payload.message);
|
|
@@ -1632,6 +1708,23 @@ function prepareClaudeTraceEvents(payload, state) {
|
|
|
1632
1708
|
if (toolId) {
|
|
1633
1709
|
toolNamesById.set(toolId, toolName);
|
|
1634
1710
|
}
|
|
1711
|
+
if (CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName)) {
|
|
1712
|
+
const input = toPlainObject(item.input);
|
|
1713
|
+
const subagentType = pickFirstString(input.subagent_type, input.name);
|
|
1714
|
+
const description = pickFirstString(input.description, input.name);
|
|
1715
|
+
events.push(createStructuredTraceEvent('claude', 'subagent', eventType, `[子 Agent] ${[subagentType, description].filter(Boolean).join(' ') || toolName}`, {
|
|
1716
|
+
phase: 'started',
|
|
1717
|
+
status: 'in_progress',
|
|
1718
|
+
toolName,
|
|
1719
|
+
toolId,
|
|
1720
|
+
subagentType,
|
|
1721
|
+
description,
|
|
1722
|
+
prompt: pickFirstString(input.prompt),
|
|
1723
|
+
arguments: input,
|
|
1724
|
+
argumentSummary: summarizeTraceArguments(input)
|
|
1725
|
+
}));
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1635
1728
|
const summary = summarizeTraceArguments(toPlainObject(item.input));
|
|
1636
1729
|
events.push(createStructuredTraceEvent(
|
|
1637
1730
|
'claude',
|
|
@@ -1659,7 +1752,7 @@ function prepareClaudeTraceEvents(payload, state) {
|
|
|
1659
1752
|
const toolId = pickFirstString(item.tool_use_id);
|
|
1660
1753
|
const toolName = pickFirstString(toolNamesById.get(toolId), toolId, 'tool');
|
|
1661
1754
|
const status = item.is_error === true ? 'error' : 'success';
|
|
1662
|
-
events.push(createStructuredTraceEvent('claude', 'tool', eventType, `[工具完成] ${toolName} (${status})`, {
|
|
1755
|
+
events.push(createStructuredTraceEvent('claude', CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName) ? 'subagent' : 'tool', eventType, `[${CLAUDE_SUBAGENT_TOOL_NAMES.has(toolName) ? '子 Agent 完成' : '工具完成'}] ${toolName} (${status})`, {
|
|
1663
1756
|
phase: 'completed',
|
|
1664
1757
|
status,
|
|
1665
1758
|
toolName,
|
|
@@ -1867,6 +1960,9 @@ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
|
|
|
1867
1960
|
// 一轮里可能有多条 assistant 消息(正文→工具→正文),message_start 清空累计,
|
|
1868
1961
|
// 语义与下面整条 assistant 消息的 reset 一致
|
|
1869
1962
|
if (payloadType === 'stream_event') {
|
|
1963
|
+
if (pickFirstString(payload.parent_tool_use_id)) {
|
|
1964
|
+
return null;
|
|
1965
|
+
}
|
|
1870
1966
|
const streamEvent = toPlainObject(payload.event);
|
|
1871
1967
|
const streamEventType = pickFirstString(streamEvent.type);
|
|
1872
1968
|
if (streamEventType === 'message_start') {
|
|
@@ -1890,6 +1986,10 @@ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
|
|
|
1890
1986
|
if (payloadType !== 'assistant') {
|
|
1891
1987
|
return null;
|
|
1892
1988
|
}
|
|
1989
|
+
// 子 Agent 说的话(--forward-subagent-text)只属于执行过程,不是主 Agent 的回复
|
|
1990
|
+
if (pickFirstString(payload.parent_tool_use_id)) {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1893
1993
|
const message = toPlainObject(payload.message);
|
|
1894
1994
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
1895
1995
|
const text = content
|
|
@@ -2215,7 +2315,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
|
2215
2315
|
: buildAgentPromptWithHistory(agentSession, prompt);
|
|
2216
2316
|
const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
|
|
2217
2317
|
sessionId: sessionIdForRun,
|
|
2218
|
-
model: agentSession.model || ''
|
|
2318
|
+
model: agentSession.model || '',
|
|
2319
|
+
forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false
|
|
2219
2320
|
});
|
|
2220
2321
|
const contextMode = resumeSucceeded ? 'resume' : (hasPriorConversation ? 'history-injected' : 'first-turn');
|
|
2221
2322
|
|
|
@@ -2233,7 +2334,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
|
|
|
2233
2334
|
}
|
|
2234
2335
|
|
|
2235
2336
|
function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, meta, result) {
|
|
2236
|
-
|
|
2337
|
+
// 用量在输出末尾:原始输出超过上限时开头里没有它
|
|
2338
|
+
const turnUsage = extractTurnUsage(agentMeta.agentProgram, result.stdoutTail || result.stdout);
|
|
2237
2339
|
const replyMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'assistant', result.output, {
|
|
2238
2340
|
exitCode: result.exitCode,
|
|
2239
2341
|
mode: 'agent',
|
|
@@ -3498,6 +3600,7 @@ async function startContainerServices(ctx, state) {
|
|
|
3498
3600
|
const reconcile = () => reconcileContainerServices(ctx, state, manager);
|
|
3499
3601
|
state.networkWatch = manager.watchStarts({ onReconnect: reconcile });
|
|
3500
3602
|
await reconcile();
|
|
3603
|
+
resumeQueuedAgentTurns(ctx, state).catch(() => {});
|
|
3501
3604
|
}
|
|
3502
3605
|
|
|
3503
3606
|
// 拉起 autostartOnServe 的容器并恢复端口暴露:serve 启动时做一次,运行时重启后(事件流重连)再做一次,幂等
|
|
@@ -3719,7 +3822,8 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
|
|
|
3719
3822
|
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
3720
3823
|
);
|
|
3721
3824
|
|
|
3722
|
-
const MAX_RAW_OUTPUT_CHARS =
|
|
3825
|
+
const MAX_RAW_OUTPUT_CHARS = ctx.agentRawOutputMaxChars || AGENT_RAW_OUTPUT_MAX_CHARS;
|
|
3826
|
+
const stdoutTail = createTailBuffer(Math.min(MAX_RAW_OUTPUT_CHARS, AGENT_OUTPUT_TAIL_MAX_CHARS));
|
|
3723
3827
|
let stdoutOutput = '';
|
|
3724
3828
|
let stderrOutput = '';
|
|
3725
3829
|
let stdoutTruncated = false;
|
|
@@ -3729,6 +3833,7 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
|
|
|
3729
3833
|
if (!chunk) return;
|
|
3730
3834
|
const text = chunk.toString('utf-8');
|
|
3731
3835
|
if (!text) return;
|
|
3836
|
+
if (target.tail) target.tail.push(text);
|
|
3732
3837
|
if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
|
|
3733
3838
|
target.truncated = true;
|
|
3734
3839
|
return;
|
|
@@ -3742,7 +3847,11 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
|
|
|
3742
3847
|
target.value += text;
|
|
3743
3848
|
}
|
|
3744
3849
|
|
|
3850
|
+
// 按流解码:逐块 toString 会把跨块的多字节字符(中文)拆成乱码
|
|
3851
|
+
process.stdout.setEncoding('utf8');
|
|
3852
|
+
process.stderr.setEncoding('utf8');
|
|
3745
3853
|
process.stdout.on('data', chunk => appendChunk(chunk, {
|
|
3854
|
+
tail: stdoutTail,
|
|
3746
3855
|
get value() { return stdoutOutput; },
|
|
3747
3856
|
set value(nextValue) { stdoutOutput = nextValue; },
|
|
3748
3857
|
get truncated() { return stdoutTruncated; },
|
|
@@ -3761,13 +3870,14 @@ async function execCommandInWebContainer(ctx, containerName, command, options =
|
|
|
3761
3870
|
const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
|
|
3762
3871
|
const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
|
|
3763
3872
|
const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
|
|
3764
|
-
|
|
3765
|
-
const
|
|
3766
|
-
const output =
|
|
3873
|
+
// 结尾只在开头被截断时才需要(拼接最多 16MB,只算一次)
|
|
3874
|
+
const tailText = stdoutTruncated ? stdoutTail.text() : '';
|
|
3875
|
+
const output = resolveAgentFinalOutput(agentProgram, { stdout: clippedStdout, stdoutTruncated, stdoutTail: tailText, raw: clippedRaw });
|
|
3767
3876
|
resolve({
|
|
3768
3877
|
exitCode,
|
|
3769
3878
|
output,
|
|
3770
3879
|
stdout: clippedStdout,
|
|
3880
|
+
...(stdoutTruncated ? { stdoutTail: tailText } : {}),
|
|
3771
3881
|
stderr: clippedStderr
|
|
3772
3882
|
});
|
|
3773
3883
|
});
|
|
@@ -4151,7 +4261,8 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4151
4261
|
process = spawnContainerExec(
|
|
4152
4262
|
ctx,
|
|
4153
4263
|
sessionRef.containerName,
|
|
4154
|
-
|
|
4264
|
+
// 包一层:新会话 + MANYOYO_RUN_ID + PID 文件,停止 / 打断时才能进到容器里按进程组发信号
|
|
4265
|
+
{ command: ['/bin/bash', '-lc', agentRunControl.wrapAgentCommand(command, runState.runId)] },
|
|
4155
4266
|
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
4156
4267
|
);
|
|
4157
4268
|
} catch (e) {
|
|
@@ -4166,15 +4277,12 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4166
4277
|
state.agentRuns.set(sessionRef.containerName, runState);
|
|
4167
4278
|
// 准备阶段(拉起容器等)用户就点了停止:进程刚 spawn 出来立刻收掉
|
|
4168
4279
|
if (runState.stopping === true) {
|
|
4169
|
-
|
|
4170
|
-
process.kill('SIGTERM');
|
|
4171
|
-
} catch (e) {
|
|
4172
|
-
// 进程可能已经退出,忽略
|
|
4173
|
-
}
|
|
4280
|
+
terminateWebAgentRun(ctx, runState);
|
|
4174
4281
|
}
|
|
4175
4282
|
|
|
4176
4283
|
return await new Promise((resolve, reject) => {
|
|
4177
|
-
const MAX_RAW_OUTPUT_CHARS =
|
|
4284
|
+
const MAX_RAW_OUTPUT_CHARS = ctx.agentRawOutputMaxChars || AGENT_RAW_OUTPUT_MAX_CHARS;
|
|
4285
|
+
const stdoutTail = createTailBuffer(Math.min(MAX_RAW_OUTPUT_CHARS, AGENT_OUTPUT_TAIL_MAX_CHARS));
|
|
4178
4286
|
let stdoutOutput = '';
|
|
4179
4287
|
let stderrOutput = '';
|
|
4180
4288
|
let stdoutTruncated = false;
|
|
@@ -4189,6 +4297,7 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4189
4297
|
if (!chunk) return;
|
|
4190
4298
|
const text = chunk.toString('utf-8');
|
|
4191
4299
|
if (!text) return;
|
|
4300
|
+
if (target.tail) target.tail.push(text);
|
|
4192
4301
|
if (target.value.length >= MAX_RAW_OUTPUT_CHARS) {
|
|
4193
4302
|
target.truncated = true;
|
|
4194
4303
|
return;
|
|
@@ -4269,8 +4378,12 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4269
4378
|
return pending;
|
|
4270
4379
|
}
|
|
4271
4380
|
|
|
4381
|
+
// 按流解码:逐块 toString 会把跨块的多字节字符(中文)拆成乱码
|
|
4382
|
+
process.stdout.setEncoding('utf8');
|
|
4383
|
+
process.stderr.setEncoding('utf8');
|
|
4272
4384
|
process.stdout.on('data', chunk => {
|
|
4273
4385
|
appendChunk(chunk, {
|
|
4386
|
+
tail: stdoutTail,
|
|
4274
4387
|
get value() { return stdoutOutput; },
|
|
4275
4388
|
set value(nextValue) { stdoutOutput = nextValue; },
|
|
4276
4389
|
get truncated() { return stdoutTruncated; },
|
|
@@ -4290,10 +4403,12 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4290
4403
|
|
|
4291
4404
|
process.on('error', error => {
|
|
4292
4405
|
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
4406
|
+
runState.markClosed();
|
|
4293
4407
|
reject(error);
|
|
4294
4408
|
});
|
|
4295
4409
|
process.on('close', code => {
|
|
4296
4410
|
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
4411
|
+
runState.markClosed();
|
|
4297
4412
|
if (stdoutPending) {
|
|
4298
4413
|
emitStdoutTraceLine(stdoutPending);
|
|
4299
4414
|
stdoutPending = '';
|
|
@@ -4306,14 +4421,16 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4306
4421
|
const clippedStdout = stdoutTruncated ? `${stdoutOutput}\n...[stdout-truncated]` : stdoutOutput;
|
|
4307
4422
|
const clippedStderr = stderrTruncated ? `${stderrOutput}\n...[stderr-truncated]` : stderrOutput;
|
|
4308
4423
|
const clippedRaw = `${clippedStdout}${clippedStdout && clippedStderr ? '\n' : ''}${clippedStderr}`;
|
|
4309
|
-
|
|
4310
|
-
const
|
|
4311
|
-
const output =
|
|
4424
|
+
// 结尾只在开头被截断时才需要(拼接最多 16MB,只算一次)
|
|
4425
|
+
const tailText = stdoutTruncated ? stdoutTail.text() : '';
|
|
4426
|
+
const output = resolveAgentFinalOutput(agentProgram, { stdout: clippedStdout, stdoutTruncated, stdoutTail: tailText, raw: clippedRaw });
|
|
4312
4427
|
resolve({
|
|
4313
4428
|
exitCode,
|
|
4314
4429
|
output,
|
|
4315
4430
|
stdout: clippedStdout,
|
|
4316
|
-
|
|
4431
|
+
...(stdoutTruncated ? { stdoutTail: tailText } : {}),
|
|
4432
|
+
// 真实的 claude 收到 SIGINT 会正常收尾、退出码为 0(L3 实测):发过信号就算被打断,不能只看退出码
|
|
4433
|
+
interrupted: runState.stopping === true && (exitCode !== 0 || runState.signaled === true)
|
|
4317
4434
|
});
|
|
4318
4435
|
});
|
|
4319
4436
|
});
|
|
@@ -4361,15 +4478,16 @@ function readClientMessageId(res, payload) {
|
|
|
4361
4478
|
|
|
4362
4479
|
// 同一个 clientMessageId 已经出现在该 agent 的消息里:这条提示词服务端已经收到过,不能再执行一遍
|
|
4363
4480
|
// (前端在网络中断后重发同一条时,靠它兜住“服务端其实已经收到”的情况)
|
|
4364
|
-
function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
|
|
4481
|
+
function rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId, options = {}) {
|
|
4365
4482
|
if (!clientMessageId) {
|
|
4366
4483
|
return false;
|
|
4367
4484
|
}
|
|
4368
4485
|
const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
|
|
4369
4486
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
4370
|
-
|
|
4487
|
+
// includeQueue:排队中的也算收到过(追加请求网络中断后,前端把原文放回输入框再发,不能执行两次)
|
|
4488
|
+
const duplicated = Boolean(agentSession) && (agentSession.messages.some(
|
|
4371
4489
|
message => message && message.role === 'user' && message.clientMessageId === clientMessageId
|
|
4372
|
-
);
|
|
4490
|
+
) || (options.includeQueue === true && agentQueue.getQueue(agentSession).some(item => item.clientMessageId === clientMessageId)));
|
|
4373
4491
|
if (!duplicated) {
|
|
4374
4492
|
return false;
|
|
4375
4493
|
}
|
|
@@ -4385,7 +4503,7 @@ function claimClientMessage(res, webHistoryDir, sessionRef, clientMessageId) {
|
|
|
4385
4503
|
if (!clientMessageId) {
|
|
4386
4504
|
return () => {};
|
|
4387
4505
|
}
|
|
4388
|
-
if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId)) {
|
|
4506
|
+
if (rejectDuplicateClientMessage(res, webHistoryDir, sessionRef, clientMessageId, { includeQueue: true })) {
|
|
4389
4507
|
return null;
|
|
4390
4508
|
}
|
|
4391
4509
|
const key = `${path.resolve(webHistoryDir)}|${buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)}|${clientMessageId}`;
|
|
@@ -4502,12 +4620,18 @@ function resolveWebStreamControlEventType(event) {
|
|
|
4502
4620
|
function reserveWebAgentRun(state, sessionRef) {
|
|
4503
4621
|
const runState = {
|
|
4504
4622
|
containerName: sessionRef.containerName,
|
|
4623
|
+
sessionRef: { containerName: sessionRef.containerName, agentId: sessionRef.agentId },
|
|
4505
4624
|
sessionKey: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
4506
4625
|
process: null,
|
|
4507
4626
|
command: '',
|
|
4508
4627
|
startedAt: new Date().toISOString(),
|
|
4509
|
-
stopping: false
|
|
4628
|
+
stopping: false,
|
|
4629
|
+
// 进程 close 时 resolve:停止 / 打断时据此判断容器里的进程有没有退出(见 agent-run-control.js)
|
|
4630
|
+
runId: agentRunControl.createRunId(),
|
|
4631
|
+
closed: null,
|
|
4632
|
+
markClosed: null
|
|
4510
4633
|
};
|
|
4634
|
+
runState.closed = new Promise(resolve => { runState.markClosed = resolve; });
|
|
4511
4635
|
state.agentRuns.set(sessionRef.containerName, runState);
|
|
4512
4636
|
return runState;
|
|
4513
4637
|
}
|
|
@@ -4519,11 +4643,172 @@ function releaseWebAgentRun(state, containerName, runState) {
|
|
|
4519
4643
|
}
|
|
4520
4644
|
}
|
|
4521
4645
|
|
|
4522
|
-
|
|
4646
|
+
// 追加提示词时打断当前这一轮(立即 / 当前步骤结束后 / 步骤超时):和用户停止一样走进程组信号,
|
|
4647
|
+
// 区别是不置 userStopped,这一轮结束后会接着发队首
|
|
4648
|
+
function steerWebAgentRun(ctx, runState, reason, state = null) {
|
|
4649
|
+
if (!runState || runState.steered || runState.stopping) {
|
|
4650
|
+
return false;
|
|
4651
|
+
}
|
|
4652
|
+
// 排队的 now / step 项已被取消:不要白白打断正在跑的任务
|
|
4653
|
+
if (state && !queueHasSteerItems(state, runState.sessionRef)) {
|
|
4654
|
+
clearTimeout(runState.stepTimer);
|
|
4655
|
+
runState.stepPending = false;
|
|
4656
|
+
return false;
|
|
4657
|
+
}
|
|
4658
|
+
clearTimeout(runState.stepTimer);
|
|
4659
|
+
runState.steered = reason;
|
|
4660
|
+
runState.stopping = true;
|
|
4661
|
+
if (runState.process) {
|
|
4662
|
+
terminateWebAgentRun(ctx, runState);
|
|
4663
|
+
}
|
|
4664
|
+
return true;
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
function queueHasSteerItems(state, sessionRef) {
|
|
4668
|
+
return peekAgentQueue(state, sessionRef).some(item => item.when === 'now' || item.when === 'step');
|
|
4669
|
+
}
|
|
4670
|
+
|
|
4671
|
+
function armStepSteer(ctx, state, runState) {
|
|
4672
|
+
if (runState.steered || runState.stopping || runState.stepPending) {
|
|
4673
|
+
return;
|
|
4674
|
+
}
|
|
4675
|
+
runState.stepPending = true;
|
|
4676
|
+
runState.stepTimer = setTimeout(() => {
|
|
4677
|
+
if (steerWebAgentRun(ctx, runState, 'step-timeout', state)) {
|
|
4678
|
+
ctx.logger.warn('agent steer step fallback to now', { session: runState.sessionKey });
|
|
4679
|
+
}
|
|
4680
|
+
}, ctx.agentStepFallbackMs || agentQueue.STEP_FALLBACK_MS);
|
|
4681
|
+
if (typeof runState.stepTimer.unref === 'function') {
|
|
4682
|
+
runState.stepTimer.unref();
|
|
4683
|
+
}
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
// 没有前端连着时顶替 res:事件照常落盘,只是没人收
|
|
4687
|
+
function createHeadlessResponse() {
|
|
4688
|
+
const sink = new EventEmitter();
|
|
4689
|
+
sink.writableEnded = false;
|
|
4690
|
+
sink.writableFinished = false;
|
|
4691
|
+
sink.destroyed = false;
|
|
4692
|
+
sink.writeHead = () => {};
|
|
4693
|
+
sink.write = () => true;
|
|
4694
|
+
sink.end = () => {
|
|
4695
|
+
sink.writableEnded = true;
|
|
4696
|
+
sink.writableFinished = true;
|
|
4697
|
+
sink.emit('close');
|
|
4698
|
+
};
|
|
4699
|
+
return sink;
|
|
4700
|
+
}
|
|
4701
|
+
|
|
4702
|
+
function peekAgentSession(state, sessionRef) {
|
|
4703
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
4704
|
+
return getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
4705
|
+
}
|
|
4706
|
+
|
|
4707
|
+
function peekAgentQueue(state, sessionRef) {
|
|
4708
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
4709
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
4710
|
+
return agentSession && Array.isArray(agentSession.queue) ? agentSession.queue : [];
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
// 同步取出队首并开始新一轮(取出和占锁之间没有 await,提示词不会丢也不会重复发)。
|
|
4714
|
+
// 开不起来(容器忙、准备失败)就放回队首,由用户决定
|
|
4715
|
+
// force:用户点了“继续发送”,解除暂停后发;其余(自动调度)遇到暂停的队列不发
|
|
4716
|
+
function dispatchNextQueued(ctx, state, sessionRef, { force = false } = {}) {
|
|
4717
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
4718
|
+
return false;
|
|
4719
|
+
}
|
|
4720
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
4721
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
4722
|
+
if (!agentSession || !agentQueue.getQueue(agentSession).length) {
|
|
4723
|
+
return false;
|
|
4724
|
+
}
|
|
4725
|
+
if (force) {
|
|
4726
|
+
agentQueue.resumeQueue(agentSession);
|
|
4727
|
+
} else if (agentQueue.isPaused(agentSession)) {
|
|
4728
|
+
return false;
|
|
4729
|
+
}
|
|
4730
|
+
const item = agentQueue.takeNext(agentSession);
|
|
4731
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
|
|
4732
|
+
const putBack = reason => {
|
|
4733
|
+
const latest = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
4734
|
+
const latestSession = getWebAgentSession(latest, sessionRef.agentId, { includeArchived: true });
|
|
4735
|
+
if (latestSession) {
|
|
4736
|
+
agentQueue.getQueue(latestSession).unshift(item);
|
|
4737
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, latest);
|
|
4738
|
+
}
|
|
4739
|
+
ctx.logger.warn('queued prompt put back', { session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), reason });
|
|
4740
|
+
};
|
|
4741
|
+
runWebAgentStreamTurn(ctx, state, sessionRef, item.text, item.clientMessageId || '', createHeadlessResponse())
|
|
4742
|
+
.then(turn => {
|
|
4743
|
+
if (turn && turn.busy) putBack('busy');
|
|
4744
|
+
else if (turn && turn.error) putBack(turn.error);
|
|
4745
|
+
})
|
|
4746
|
+
.catch(e => putBack(e && e.message ? e.message : 'failed'));
|
|
4747
|
+
return true;
|
|
4748
|
+
}
|
|
4749
|
+
|
|
4750
|
+
// 容器里还活着的运行 id(上一个 serve 留下的孤儿任务,或另一个 serve 实例正在跑的任务);读不到当作没有
|
|
4751
|
+
function listLiveRunsInContainer(ctx, containerName) {
|
|
4752
|
+
return new Promise(resolve => {
|
|
4753
|
+
let child;
|
|
4754
|
+
try {
|
|
4755
|
+
child = spawnContainerExec(ctx, containerName, { command: agentRunControl.buildLiveRunsCommand() }, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
4756
|
+
} catch (e) {
|
|
4757
|
+
resolve([]);
|
|
4758
|
+
return;
|
|
4759
|
+
}
|
|
4760
|
+
let out = '';
|
|
4761
|
+
child.stdout.on('data', chunk => { out += chunk; });
|
|
4762
|
+
child.once('close', () => resolve(out.split('\n').map(line => line.trim()).filter(agentRunControl.isValidRunId)));
|
|
4763
|
+
child.once('error', () => resolve([]));
|
|
4764
|
+
});
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
// serve 重启后:队列里还有内容(用户停止过的除外)、又没有在跑的任务,就当作“这一轮结束后发送”处理。
|
|
4768
|
+
// 容器里还有活着的 Agent 进程时不发:那是上一个 serve 留下的孤儿任务或另一个 serve 实例的任务,
|
|
4769
|
+
// 再开一轮会和它并发;排队保留,由用户在界面上“继续发送”
|
|
4770
|
+
async function resumeQueuedAgentTurns(ctx, state) {
|
|
4771
|
+
let containers = {};
|
|
4772
|
+
try {
|
|
4773
|
+
containers = listWebManyoyoContainers(ctx);
|
|
4774
|
+
} catch (e) {
|
|
4775
|
+
return;
|
|
4776
|
+
}
|
|
4777
|
+
for (const containerName of Object.keys(containers)) {
|
|
4778
|
+
const history = loadWebSessionHistory(state.webHistoryDir, containerName);
|
|
4779
|
+
const agentIds = Object.keys(history.agents || {}).filter(agentId => agentQueue.shouldAutoDispatch(history.agents[agentId]));
|
|
4780
|
+
if (!agentIds.length) {
|
|
4781
|
+
continue;
|
|
4782
|
+
}
|
|
4783
|
+
const live = await listLiveRunsInContainer(ctx, containerName);
|
|
4784
|
+
if (live.length) {
|
|
4785
|
+
ctx.logger.warn('queued prompts kept: agent still running in container', { container: containerName, runs: live.length });
|
|
4786
|
+
continue;
|
|
4787
|
+
}
|
|
4788
|
+
for (const agentId of agentIds) {
|
|
4789
|
+
dispatchNextQueued(ctx, state, { containerName, agentId });
|
|
4790
|
+
}
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4794
|
+
// signals:默认 INT → TERM → KILL(用户点停止);ctx 缺省时退回只杀宿主机上的客户端
|
|
4795
|
+
function stopWebAgentRun(state, containerName, ctx = null) {
|
|
4523
4796
|
const runState = state.agentRuns.get(containerName);
|
|
4524
4797
|
if (!runState) {
|
|
4525
4798
|
return false;
|
|
4526
4799
|
}
|
|
4800
|
+
// 用户主动停止:排队的提示词不再自动发送(队列保留,由用户决定取消或继续)。
|
|
4801
|
+
// 暂停要落盘:只记在这一轮的内存里的话,serve 重启或下一轮结束后会被自动发出去
|
|
4802
|
+
runState.userStopped = true;
|
|
4803
|
+
if (runState.sessionRef) {
|
|
4804
|
+
const history = loadWebSessionHistory(state.webHistoryDir, runState.sessionRef.containerName);
|
|
4805
|
+
const agentSession = getWebAgentSession(history, runState.sessionRef.agentId, { includeArchived: true });
|
|
4806
|
+
if (agentSession && agentQueue.getQueue(agentSession).length && !agentQueue.isPaused(agentSession)) {
|
|
4807
|
+
agentQueue.pauseQueue(agentSession);
|
|
4808
|
+
saveWebSessionHistory(state.webHistoryDir, runState.sessionRef.containerName, history);
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
clearTimeout(runState.stepTimer);
|
|
4527
4812
|
if (!runState.process) {
|
|
4528
4813
|
// 还在准备阶段(拉起容器 / 探测 resume),进程尚未 spawn:先记下停止意图,
|
|
4529
4814
|
// execAgentInWebContainerStream 拿到进程后会立刻收掉它
|
|
@@ -4534,14 +4819,42 @@ function stopWebAgentRun(state, containerName) {
|
|
|
4534
4819
|
return false;
|
|
4535
4820
|
}
|
|
4536
4821
|
runState.stopping = true;
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4822
|
+
if (!ctx) {
|
|
4823
|
+
try {
|
|
4824
|
+
runState.process.kill('SIGTERM');
|
|
4825
|
+
} catch (e) {
|
|
4826
|
+
// 进程可能已自行退出,视为停止成功(close 事件会收尾)
|
|
4827
|
+
}
|
|
4828
|
+
return true;
|
|
4541
4829
|
}
|
|
4830
|
+
terminateWebAgentRun(ctx, runState);
|
|
4542
4831
|
return true;
|
|
4543
4832
|
}
|
|
4544
4833
|
|
|
4834
|
+
// 进到容器里按进程组停:只杀宿主机上的 exec 客户端,容器里的进程会继续跑(实测)
|
|
4835
|
+
function terminateWebAgentRun(ctx, runState) {
|
|
4836
|
+
if (runState.terminating) {
|
|
4837
|
+
return runState.terminating;
|
|
4838
|
+
}
|
|
4839
|
+
runState.terminating = agentRunControl.terminateRun({
|
|
4840
|
+
runState,
|
|
4841
|
+
graceMs: ctx.agentStopGraceMs || agentRunControl.DEFAULT_GRACE_MS,
|
|
4842
|
+
killInContainer: (runId, signal) => new Promise(resolve => {
|
|
4843
|
+
runState.signaled = true;
|
|
4844
|
+
let child;
|
|
4845
|
+
try {
|
|
4846
|
+
child = spawnContainerExec(ctx, runState.containerName, { command: agentRunControl.buildRunKillCommand(runId, signal) }, { stdio: 'ignore' });
|
|
4847
|
+
} catch (e) {
|
|
4848
|
+
resolve();
|
|
4849
|
+
return;
|
|
4850
|
+
}
|
|
4851
|
+
child.once('close', resolve);
|
|
4852
|
+
child.once('error', resolve);
|
|
4853
|
+
})
|
|
4854
|
+
}).catch(() => {});
|
|
4855
|
+
return runState.terminating;
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4545
4858
|
// serve 重启等原因会丢失 agentRuns 运行登记,但消息里可能残留 pending 标记
|
|
4546
4859
|
// (孤儿任务:容器里的进程或许还在跑,但再没有任何代码会去收尾这些消息)。
|
|
4547
4860
|
// 前端把 pending 视为"任务进行中"会永久禁用输入,这里把它们标记为已中断
|
|
@@ -4568,6 +4881,310 @@ function interruptOrphanPendingMessages(webHistoryDir, sessionRef) {
|
|
|
4568
4881
|
saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
|
|
4569
4882
|
}
|
|
4570
4883
|
|
|
4884
|
+
// 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
|
|
4885
|
+
// 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
|
|
4886
|
+
// 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
|
|
4887
|
+
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res) {
|
|
4888
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
4889
|
+
// 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
|
|
4890
|
+
return { busy: true };
|
|
4891
|
+
}
|
|
4892
|
+
// 紧接着检查同步占位,中间不能有 await,否则并发请求会同时穿过上面这道检查
|
|
4893
|
+
const runState = reserveWebAgentRun(state, sessionRef);
|
|
4894
|
+
|
|
4895
|
+
let userMessage;
|
|
4896
|
+
let traceMessage;
|
|
4897
|
+
try {
|
|
4898
|
+
userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
4899
|
+
mode: 'agent',
|
|
4900
|
+
pending: true,
|
|
4901
|
+
...(clientMessageId ? { clientMessageId: clientMessageId } : {})
|
|
4902
|
+
});
|
|
4903
|
+
traceMessage = appendWebAgentTraceMessage(
|
|
4904
|
+
state.webHistoryDir,
|
|
4905
|
+
sessionRef,
|
|
4906
|
+
'[执行过程]\n等待 Agent 启动…',
|
|
4907
|
+
{
|
|
4908
|
+
traceEvents: [],
|
|
4909
|
+
pending: true
|
|
4910
|
+
}
|
|
4911
|
+
);
|
|
4912
|
+
} catch (e) {
|
|
4913
|
+
// 落盘失败(磁盘错误等):锁必须放掉,否则这个容器永远发不出消息
|
|
4914
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
4915
|
+
throw e;
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4918
|
+
let prepared = null;
|
|
4919
|
+
try {
|
|
4920
|
+
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
4921
|
+
} catch (e) {
|
|
4922
|
+
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
4923
|
+
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
4924
|
+
removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
|
|
4925
|
+
return { error: e && e.message ? e.message : 'Agent 执行准备失败' };
|
|
4926
|
+
}
|
|
4927
|
+
|
|
4928
|
+
const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
|
|
4929
|
+
const traceLines = ['[执行过程]'];
|
|
4930
|
+
const traceEvents = [];
|
|
4931
|
+
let streamingReplyMessageId = '';
|
|
4932
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
4933
|
+
pending: true,
|
|
4934
|
+
contextMode
|
|
4935
|
+
});
|
|
4936
|
+
|
|
4937
|
+
res.writeHead(200, {
|
|
4938
|
+
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
4939
|
+
'Cache-Control': 'no-store',
|
|
4940
|
+
'X-Accel-Buffering': 'no'
|
|
4941
|
+
});
|
|
4942
|
+
// agent 流的生命周期日志:每轮最多三条(开始 / 客户端断开 / 结束),
|
|
4943
|
+
// 不是逐事件打点。没有这几条的话,用户报"跑到一半断流了"时服务端
|
|
4944
|
+
// 日志里查不到任何痕迹——断连本身只发生在客户端侧,服务端不主动感知
|
|
4945
|
+
const turnStartedAt = Date.now();
|
|
4946
|
+
const turnSessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
|
|
4947
|
+
ctx.logger.info('agent turn started', {
|
|
4948
|
+
session: turnSessionKey,
|
|
4949
|
+
agentProgram: agentMeta.agentProgram,
|
|
4950
|
+
contextMode,
|
|
4951
|
+
resumeAttempted,
|
|
4952
|
+
resumeSucceeded
|
|
4953
|
+
});
|
|
4954
|
+
// 'close' 先于 'finish' 触发且 writableFinished 仍为 false,说明是
|
|
4955
|
+
// 客户端/反代把连接掐了,而不是我们正常收尾。此时容器里的任务还在跑,
|
|
4956
|
+
// 前端会转入恢复轮询——日志要把这个区别写清楚,否则事后无法归因
|
|
4957
|
+
res.on('close', () => {
|
|
4958
|
+
if (res.writableFinished) {
|
|
4959
|
+
return;
|
|
4960
|
+
}
|
|
4961
|
+
ctx.logger.warn('agent stream client disconnected', {
|
|
4962
|
+
session: turnSessionKey,
|
|
4963
|
+
elapsedMs: Date.now() - turnStartedAt,
|
|
4964
|
+
runContinues: state.agentRuns.has(sessionRef.containerName)
|
|
4965
|
+
});
|
|
4966
|
+
});
|
|
4967
|
+
// agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
|
|
4968
|
+
// 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
|
|
4969
|
+
// 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
|
|
4970
|
+
// 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
|
|
4971
|
+
const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
|
|
4972
|
+
let lastStreamWriteAt = Date.now();
|
|
4973
|
+
const heartbeatTimer = setInterval(() => {
|
|
4974
|
+
if (res.writableEnded || res.destroyed) {
|
|
4975
|
+
return;
|
|
4976
|
+
}
|
|
4977
|
+
if (Date.now() - lastStreamWriteAt < heartbeatMs) {
|
|
4978
|
+
return;
|
|
4979
|
+
}
|
|
4980
|
+
sendNdjson(res, { type: 'ping' });
|
|
4981
|
+
lastStreamWriteAt = Date.now();
|
|
4982
|
+
}, Math.max(50, Math.floor(heartbeatMs / 2)));
|
|
4983
|
+
if (typeof heartbeatTimer.unref === 'function') {
|
|
4984
|
+
heartbeatTimer.unref();
|
|
4985
|
+
}
|
|
4986
|
+
const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
|
|
4987
|
+
const emitStreamEvent = (...args) => {
|
|
4988
|
+
lastStreamWriteAt = Date.now();
|
|
4989
|
+
return rawEmitStreamEvent(...args);
|
|
4990
|
+
};
|
|
4991
|
+
emitStreamEvent({
|
|
4992
|
+
type: 'meta',
|
|
4993
|
+
containerName: sessionRef.containerName,
|
|
4994
|
+
sessionName: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
4995
|
+
// 前端用这两个 id 把本地乐观占位(local-* / __streaming_trace__)换成
|
|
4996
|
+
// 服务端持久化 id,轮询对账时 React key 才能对得上、DOM 不整体重建
|
|
4997
|
+
userMessageId: userMessage && userMessage.id,
|
|
4998
|
+
traceMessageId: traceMessage && traceMessage.id,
|
|
4999
|
+
contextMode,
|
|
5000
|
+
resumeAttempted,
|
|
5001
|
+
resumeSucceeded,
|
|
5002
|
+
agentProgram: agentMeta.agentProgram
|
|
5003
|
+
}, 'session.ready', { agentProgram: agentMeta.agentProgram });
|
|
5004
|
+
if (contextMode) {
|
|
5005
|
+
traceLines.push(`上下文模式: ${contextMode}`);
|
|
5006
|
+
}
|
|
5007
|
+
if (resumeAttempted) {
|
|
5008
|
+
traceLines.push(resumeSucceeded ? '会话恢复成功' : '会话恢复失败,已回退到历史注入');
|
|
5009
|
+
}
|
|
5010
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5011
|
+
content: traceLines.join('\n'),
|
|
5012
|
+
traceEvents: traceEvents.slice(),
|
|
5013
|
+
contextMode,
|
|
5014
|
+
resumeAttempted,
|
|
5015
|
+
resumeSucceeded,
|
|
5016
|
+
pending: true
|
|
5017
|
+
});
|
|
5018
|
+
|
|
5019
|
+
let lastPartialPersistAt = 0;
|
|
5020
|
+
const handleStreamEvent = event => {
|
|
5021
|
+
if (runState.stepPending && agentQueue.isStepBoundary(event)) {
|
|
5022
|
+
steerWebAgentRun(ctx, runState, 'step', state);
|
|
5023
|
+
}
|
|
5024
|
+
// token 级增量:只走网线,不进事件日志,历史落盘按
|
|
5025
|
+
// AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
|
|
5026
|
+
const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
|
|
5027
|
+
if (event && event.type === 'trace' && event.text) {
|
|
5028
|
+
traceLines.push(String(event.text));
|
|
5029
|
+
if (event.traceEvent && typeof event.traceEvent === 'object') {
|
|
5030
|
+
traceEvents.push(event.traceEvent);
|
|
5031
|
+
}
|
|
5032
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5033
|
+
content: traceLines.join('\n'),
|
|
5034
|
+
traceEvents: traceEvents.slice(),
|
|
5035
|
+
pending: true
|
|
5036
|
+
}, { defer: true });
|
|
5037
|
+
}
|
|
5038
|
+
if (event && event.type === 'content_delta' && typeof event.content === 'string') {
|
|
5039
|
+
const now = Date.now();
|
|
5040
|
+
const shouldPersist = !isPartialDelta
|
|
5041
|
+
|| now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
|
|
5042
|
+
if (!streamingReplyMessageId) {
|
|
5043
|
+
const streamingReplyMessage = appendWebSessionMessage(
|
|
5044
|
+
state.webHistoryDir,
|
|
5045
|
+
sessionRef,
|
|
5046
|
+
'assistant',
|
|
5047
|
+
event.content,
|
|
5048
|
+
{
|
|
5049
|
+
mode: 'agent',
|
|
5050
|
+
streamingReply: true,
|
|
5051
|
+
pending: true
|
|
5052
|
+
}
|
|
5053
|
+
);
|
|
5054
|
+
streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
|
|
5055
|
+
? streamingReplyMessage.id
|
|
5056
|
+
: '';
|
|
5057
|
+
lastPartialPersistAt = now;
|
|
5058
|
+
} else if (shouldPersist) {
|
|
5059
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
|
|
5060
|
+
content: event.content,
|
|
5061
|
+
pending: true
|
|
5062
|
+
}, { defer: true });
|
|
5063
|
+
lastPartialPersistAt = now;
|
|
5064
|
+
}
|
|
5065
|
+
}
|
|
5066
|
+
if (isPartialDelta) {
|
|
5067
|
+
lastStreamWriteAt = Date.now();
|
|
5068
|
+
// 只发增量片段:前端在 content_chunk 上做追加,
|
|
5069
|
+
// reset 表示换了一条 assistant 消息、从空白重新开始
|
|
5070
|
+
sendNdjson(res, event.chunkReset === true
|
|
5071
|
+
? { type: 'content_chunk', text: '', reset: true }
|
|
5072
|
+
: { type: 'content_chunk', text: event.chunk });
|
|
5073
|
+
return;
|
|
5074
|
+
}
|
|
5075
|
+
emitStreamEvent(event, resolveWebStreamControlEventType(event), {
|
|
5076
|
+
transportType: event && event.type ? event.type : '',
|
|
5077
|
+
text: event && (event.text || event.content) ? String(event.text || event.content) : ''
|
|
5078
|
+
});
|
|
5079
|
+
|
|
5080
|
+
};
|
|
5081
|
+
// 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
|
|
5082
|
+
const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
|
|
5083
|
+
try {
|
|
5084
|
+
if (deniedNotifier) deniedNotifier.start();
|
|
5085
|
+
let result;
|
|
5086
|
+
try {
|
|
5087
|
+
result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
5088
|
+
agentProgram: agentMeta.agentProgram,
|
|
5089
|
+
// 复用路由入口占好的那把锁,而不是另起一份运行登记
|
|
5090
|
+
runState,
|
|
5091
|
+
onEvent: handleStreamEvent
|
|
5092
|
+
});
|
|
5093
|
+
} finally {
|
|
5094
|
+
if (deniedNotifier) deniedNotifier.stop();
|
|
5095
|
+
}
|
|
5096
|
+
ctx.logger.info('agent turn finished', {
|
|
5097
|
+
session: turnSessionKey,
|
|
5098
|
+
durationMs: Date.now() - turnStartedAt,
|
|
5099
|
+
exitCode: result.exitCode,
|
|
5100
|
+
interrupted: result.interrupted === true
|
|
5101
|
+
});
|
|
5102
|
+
traceLines.push(result.interrupted === true
|
|
5103
|
+
? (runState.steered && runState.userStopped !== true ? '[任务] 已打断,继续执行新的提示词' : '[任务] 已停止')
|
|
5104
|
+
: '[任务] 已完成');
|
|
5105
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5106
|
+
pending: false,
|
|
5107
|
+
contextMode,
|
|
5108
|
+
resumeAttempted,
|
|
5109
|
+
resumeSucceeded
|
|
5110
|
+
});
|
|
5111
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5112
|
+
content: traceLines.join('\n'),
|
|
5113
|
+
traceEvents,
|
|
5114
|
+
contextMode,
|
|
5115
|
+
resumeAttempted,
|
|
5116
|
+
resumeSucceeded,
|
|
5117
|
+
interrupted: result.interrupted === true,
|
|
5118
|
+
pending: false
|
|
5119
|
+
});
|
|
5120
|
+
if (streamingReplyMessageId) {
|
|
5121
|
+
removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
|
|
5122
|
+
}
|
|
5123
|
+
const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
|
|
5124
|
+
contextMode,
|
|
5125
|
+
resumeAttempted,
|
|
5126
|
+
resumeSucceeded,
|
|
5127
|
+
resumeError,
|
|
5128
|
+
engineSessionId
|
|
5129
|
+
}, result);
|
|
5130
|
+
emitStreamEvent({
|
|
5131
|
+
type: 'result',
|
|
5132
|
+
exitCode: result.exitCode,
|
|
5133
|
+
output: result.output,
|
|
5134
|
+
// 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
|
|
5135
|
+
// 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
|
|
5136
|
+
replyMessageId: replyMessage && replyMessage.id,
|
|
5137
|
+
contextMode,
|
|
5138
|
+
resumeAttempted,
|
|
5139
|
+
resumeSucceeded,
|
|
5140
|
+
interrupted: result.interrupted === true
|
|
5141
|
+
}, result.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
|
|
5142
|
+
} catch (e) {
|
|
5143
|
+
ctx.logger.error('agent turn failed', {
|
|
5144
|
+
session: turnSessionKey,
|
|
5145
|
+
durationMs: Date.now() - turnStartedAt,
|
|
5146
|
+
message: e && e.message ? e.message : 'Agent 执行失败'
|
|
5147
|
+
});
|
|
5148
|
+
traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
|
|
5149
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
5150
|
+
pending: false,
|
|
5151
|
+
contextMode,
|
|
5152
|
+
resumeAttempted,
|
|
5153
|
+
resumeSucceeded
|
|
5154
|
+
});
|
|
5155
|
+
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
5156
|
+
content: traceLines.join('\n'),
|
|
5157
|
+
traceEvents,
|
|
5158
|
+
contextMode,
|
|
5159
|
+
resumeAttempted,
|
|
5160
|
+
resumeSucceeded,
|
|
5161
|
+
interrupted: true,
|
|
5162
|
+
pending: false
|
|
5163
|
+
});
|
|
5164
|
+
if (streamingReplyMessageId) {
|
|
5165
|
+
removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
|
|
5166
|
+
}
|
|
5167
|
+
emitStreamEvent({
|
|
5168
|
+
type: 'error',
|
|
5169
|
+
error: e && e.message ? e.message : 'Agent 执行失败'
|
|
5170
|
+
}, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
|
|
5171
|
+
} finally {
|
|
5172
|
+
clearInterval(heartbeatTimer);
|
|
5173
|
+
clearTimeout(runState.stepTimer);
|
|
5174
|
+
// 用户停止的不自动发;其余(完成 / 失败 / 被追加打断)有排队就接着发队首
|
|
5175
|
+
const hasNext = runState.userStopped !== true && agentQueue.shouldAutoDispatch(peekAgentSession(state, sessionRef));
|
|
5176
|
+
if (hasNext && !res.writableEnded && !res.destroyed) {
|
|
5177
|
+
// 前端收到后改为跟随下一轮(走掉线恢复的那套轮询)
|
|
5178
|
+
sendNdjson(res, { type: 'queue_next' });
|
|
5179
|
+
}
|
|
5180
|
+
res.end();
|
|
5181
|
+
if (hasNext) {
|
|
5182
|
+
setImmediate(() => dispatchNextQueued(ctx, state, sessionRef));
|
|
5183
|
+
}
|
|
5184
|
+
}
|
|
5185
|
+
return { started: true };
|
|
5186
|
+
}
|
|
5187
|
+
|
|
4571
5188
|
function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
|
|
4572
5189
|
sendBody(res, statusCode, 'text/html; charset=utf-8', html, extraHeaders, options);
|
|
4573
5190
|
}
|
|
@@ -6143,6 +6760,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6143
6760
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
6144
6761
|
containerName: sessionRef.containerName,
|
|
6145
6762
|
agentId: sessionRef.agentId,
|
|
6763
|
+
queue: agentQueue.publicQueue(agentSession),
|
|
6764
|
+
queuePaused: agentQueue.isPaused(agentSession),
|
|
6146
6765
|
...view
|
|
6147
6766
|
}, {}, { etag });
|
|
6148
6767
|
}
|
|
@@ -6689,285 +7308,109 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6689
7308
|
if (!streamClientId) {
|
|
6690
7309
|
return;
|
|
6691
7310
|
}
|
|
6692
|
-
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value)) {
|
|
7311
|
+
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
|
|
6693
7312
|
return;
|
|
6694
7313
|
}
|
|
6695
|
-
|
|
6696
|
-
|
|
7314
|
+
const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res);
|
|
7315
|
+
if (turn.busy) {
|
|
6697
7316
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
7317
|
+
} else if (turn.error) {
|
|
7318
|
+
sendJson(res, 400, { error: turn.error });
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
},
|
|
7322
|
+
{
|
|
7323
|
+
method: 'POST',
|
|
7324
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue$/),
|
|
7325
|
+
handler: async match => {
|
|
7326
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7327
|
+
if (!sessionRef) {
|
|
6698
7328
|
return;
|
|
6699
7329
|
}
|
|
6700
|
-
|
|
6701
|
-
const
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
mode: 'agent',
|
|
6705
|
-
pending: true,
|
|
6706
|
-
...(streamClientId.value ? { clientMessageId: streamClientId.value } : {})
|
|
6707
|
-
});
|
|
6708
|
-
const traceMessage = appendWebAgentTraceMessage(
|
|
6709
|
-
state.webHistoryDir,
|
|
6710
|
-
sessionRef,
|
|
6711
|
-
'[执行过程]\n等待 Agent 启动…',
|
|
6712
|
-
{
|
|
6713
|
-
traceEvents: [],
|
|
6714
|
-
pending: true
|
|
6715
|
-
}
|
|
6716
|
-
);
|
|
6717
|
-
|
|
6718
|
-
let prepared = null;
|
|
6719
|
-
try {
|
|
6720
|
-
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt);
|
|
6721
|
-
} catch (e) {
|
|
6722
|
-
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
6723
|
-
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
6724
|
-
removeWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id);
|
|
6725
|
-
sendJson(res, 400, { error: e && e.message ? e.message : 'Agent 执行准备失败' });
|
|
7330
|
+
const payload = await readJsonBody(req);
|
|
7331
|
+
const text = (payload.prompt || '').trim();
|
|
7332
|
+
if (!text) {
|
|
7333
|
+
sendJson(res, 400, { error: 'prompt 不能为空' });
|
|
6726
7334
|
return;
|
|
6727
7335
|
}
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
let streamingReplyMessageId = '';
|
|
6733
|
-
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
6734
|
-
pending: true,
|
|
6735
|
-
contextMode
|
|
6736
|
-
});
|
|
6737
|
-
|
|
6738
|
-
res.writeHead(200, {
|
|
6739
|
-
'Content-Type': 'application/x-ndjson; charset=utf-8',
|
|
6740
|
-
'Cache-Control': 'no-store',
|
|
6741
|
-
'X-Accel-Buffering': 'no'
|
|
6742
|
-
});
|
|
6743
|
-
// agent 流的生命周期日志:每轮最多三条(开始 / 客户端断开 / 结束),
|
|
6744
|
-
// 不是逐事件打点。没有这几条的话,用户报"跑到一半断流了"时服务端
|
|
6745
|
-
// 日志里查不到任何痕迹——断连本身只发生在客户端侧,服务端不主动感知
|
|
6746
|
-
const turnStartedAt = Date.now();
|
|
6747
|
-
const turnSessionKey = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
|
|
6748
|
-
ctx.logger.info('agent turn started', {
|
|
6749
|
-
session: turnSessionKey,
|
|
6750
|
-
agentProgram: agentMeta.agentProgram,
|
|
6751
|
-
contextMode,
|
|
6752
|
-
resumeAttempted,
|
|
6753
|
-
resumeSucceeded
|
|
6754
|
-
});
|
|
6755
|
-
// 'close' 先于 'finish' 触发且 writableFinished 仍为 false,说明是
|
|
6756
|
-
// 客户端/反代把连接掐了,而不是我们正常收尾。此时容器里的任务还在跑,
|
|
6757
|
-
// 前端会转入恢复轮询——日志要把这个区别写清楚,否则事后无法归因
|
|
6758
|
-
res.on('close', () => {
|
|
6759
|
-
if (res.writableFinished) {
|
|
6760
|
-
return;
|
|
6761
|
-
}
|
|
6762
|
-
ctx.logger.warn('agent stream client disconnected', {
|
|
6763
|
-
session: turnSessionKey,
|
|
6764
|
-
elapsedMs: Date.now() - turnStartedAt,
|
|
6765
|
-
runContinues: state.agentRuns.has(sessionRef.containerName)
|
|
6766
|
-
});
|
|
6767
|
-
});
|
|
6768
|
-
// agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
|
|
6769
|
-
// 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
|
|
6770
|
-
// 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
|
|
6771
|
-
// 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
|
|
6772
|
-
const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
|
|
6773
|
-
let lastStreamWriteAt = Date.now();
|
|
6774
|
-
const heartbeatTimer = setInterval(() => {
|
|
6775
|
-
if (res.writableEnded || res.destroyed) {
|
|
6776
|
-
return;
|
|
6777
|
-
}
|
|
6778
|
-
if (Date.now() - lastStreamWriteAt < heartbeatMs) {
|
|
6779
|
-
return;
|
|
6780
|
-
}
|
|
6781
|
-
sendNdjson(res, { type: 'ping' });
|
|
6782
|
-
lastStreamWriteAt = Date.now();
|
|
6783
|
-
}, Math.max(50, Math.floor(heartbeatMs / 2)));
|
|
6784
|
-
if (typeof heartbeatTimer.unref === 'function') {
|
|
6785
|
-
heartbeatTimer.unref();
|
|
6786
|
-
}
|
|
6787
|
-
const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
|
|
6788
|
-
const emitStreamEvent = (...args) => {
|
|
6789
|
-
lastStreamWriteAt = Date.now();
|
|
6790
|
-
return rawEmitStreamEvent(...args);
|
|
6791
|
-
};
|
|
6792
|
-
emitStreamEvent({
|
|
6793
|
-
type: 'meta',
|
|
6794
|
-
containerName: sessionRef.containerName,
|
|
6795
|
-
sessionName: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
6796
|
-
// 前端用这两个 id 把本地乐观占位(local-* / __streaming_trace__)换成
|
|
6797
|
-
// 服务端持久化 id,轮询对账时 React key 才能对得上、DOM 不整体重建
|
|
6798
|
-
userMessageId: userMessage && userMessage.id,
|
|
6799
|
-
traceMessageId: traceMessage && traceMessage.id,
|
|
6800
|
-
contextMode,
|
|
6801
|
-
resumeAttempted,
|
|
6802
|
-
resumeSucceeded,
|
|
6803
|
-
agentProgram: agentMeta.agentProgram
|
|
6804
|
-
}, 'session.ready', { agentProgram: agentMeta.agentProgram });
|
|
6805
|
-
if (contextMode) {
|
|
6806
|
-
traceLines.push(`上下文模式: ${contextMode}`);
|
|
7336
|
+
const when = agentQueue.normalizeWhen(payload.when === undefined ? 'turn' : payload.when);
|
|
7337
|
+
if (!when) {
|
|
7338
|
+
sendJson(res, 400, { error: 'when 只能是 now、step 或 turn' });
|
|
7339
|
+
return;
|
|
6807
7340
|
}
|
|
6808
|
-
|
|
6809
|
-
|
|
7341
|
+
const queueClientId = readClientMessageId(res, payload);
|
|
7342
|
+
if (!queueClientId) {
|
|
7343
|
+
return;
|
|
6810
7344
|
}
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
}
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
const
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
}
|
|
6836
|
-
if (event && event.type === 'content_delta' && typeof event.content === 'string') {
|
|
6837
|
-
const now = Date.now();
|
|
6838
|
-
const shouldPersist = !isPartialDelta
|
|
6839
|
-
|| now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
|
|
6840
|
-
if (!streamingReplyMessageId) {
|
|
6841
|
-
const streamingReplyMessage = appendWebSessionMessage(
|
|
6842
|
-
state.webHistoryDir,
|
|
6843
|
-
sessionRef,
|
|
6844
|
-
'assistant',
|
|
6845
|
-
event.content,
|
|
6846
|
-
{
|
|
6847
|
-
mode: 'agent',
|
|
6848
|
-
streamingReply: true,
|
|
6849
|
-
pending: true
|
|
6850
|
-
}
|
|
6851
|
-
);
|
|
6852
|
-
streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
|
|
6853
|
-
? streamingReplyMessage.id
|
|
6854
|
-
: '';
|
|
6855
|
-
lastPartialPersistAt = now;
|
|
6856
|
-
} else if (shouldPersist) {
|
|
6857
|
-
patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
|
|
6858
|
-
content: event.content,
|
|
6859
|
-
pending: true
|
|
6860
|
-
}, { defer: true });
|
|
6861
|
-
lastPartialPersistAt = now;
|
|
6862
|
-
}
|
|
6863
|
-
}
|
|
6864
|
-
if (isPartialDelta) {
|
|
6865
|
-
lastStreamWriteAt = Date.now();
|
|
6866
|
-
// 只发增量片段:前端在 content_chunk 上做追加,
|
|
6867
|
-
// reset 表示换了一条 assistant 消息、从空白重新开始
|
|
6868
|
-
sendNdjson(res, event.chunkReset === true
|
|
6869
|
-
? { type: 'content_chunk', text: '', reset: true }
|
|
6870
|
-
: { type: 'content_chunk', text: event.chunk });
|
|
6871
|
-
return;
|
|
6872
|
-
}
|
|
6873
|
-
emitStreamEvent(event, resolveWebStreamControlEventType(event), {
|
|
6874
|
-
transportType: event && event.type ? event.type : '',
|
|
6875
|
-
text: event && (event.text || event.content) ? String(event.text || event.content) : ''
|
|
6876
|
-
});
|
|
6877
|
-
|
|
6878
|
-
};
|
|
6879
|
-
// 有域名规则时被过滤代理拦下的新域名:对话进行中立即提示一次
|
|
6880
|
-
const deniedNotifier = createWebDeniedNotifier(ctx, sessionRef, text => handleStreamEvent({ type: 'trace', stream: 'stdout', text }));
|
|
6881
|
-
try {
|
|
6882
|
-
if (deniedNotifier) deniedNotifier.start();
|
|
6883
|
-
let result;
|
|
6884
|
-
try {
|
|
6885
|
-
result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
|
|
6886
|
-
agentProgram: agentMeta.agentProgram,
|
|
6887
|
-
// 复用路由入口占好的那把锁,而不是另起一份运行登记
|
|
6888
|
-
runState,
|
|
6889
|
-
onEvent: handleStreamEvent
|
|
6890
|
-
});
|
|
6891
|
-
} finally {
|
|
6892
|
-
if (deniedNotifier) deniedNotifier.stop();
|
|
6893
|
-
}
|
|
6894
|
-
ctx.logger.info('agent turn finished', {
|
|
6895
|
-
session: turnSessionKey,
|
|
6896
|
-
durationMs: Date.now() - turnStartedAt,
|
|
6897
|
-
exitCode: result.exitCode,
|
|
6898
|
-
interrupted: result.interrupted === true
|
|
6899
|
-
});
|
|
6900
|
-
traceLines.push(result.interrupted === true ? '[任务] 已停止' : '[任务] 已完成');
|
|
6901
|
-
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
6902
|
-
pending: false,
|
|
6903
|
-
contextMode,
|
|
6904
|
-
resumeAttempted,
|
|
6905
|
-
resumeSucceeded
|
|
6906
|
-
});
|
|
6907
|
-
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
6908
|
-
content: traceLines.join('\n'),
|
|
6909
|
-
traceEvents,
|
|
6910
|
-
contextMode,
|
|
6911
|
-
resumeAttempted,
|
|
6912
|
-
resumeSucceeded,
|
|
6913
|
-
interrupted: result.interrupted === true,
|
|
6914
|
-
pending: false
|
|
6915
|
-
});
|
|
6916
|
-
if (streamingReplyMessageId) {
|
|
6917
|
-
removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
|
|
6918
|
-
}
|
|
6919
|
-
const replyMessage = finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, {
|
|
6920
|
-
contextMode,
|
|
6921
|
-
resumeAttempted,
|
|
6922
|
-
resumeSucceeded,
|
|
6923
|
-
resumeError,
|
|
6924
|
-
engineSessionId
|
|
6925
|
-
}, result);
|
|
6926
|
-
emitStreamEvent({
|
|
6927
|
-
type: 'result',
|
|
6928
|
-
exitCode: result.exitCode,
|
|
6929
|
-
output: result.output,
|
|
6930
|
-
// 最终 assistant 消息的持久化 id:前端把 __streaming__ 占位换成它,
|
|
6931
|
-
// 发送后的首次对账才不会重建整个消息列表 DOM(滚动位置/选区保持)
|
|
6932
|
-
replyMessageId: replyMessage && replyMessage.id,
|
|
6933
|
-
contextMode,
|
|
6934
|
-
resumeAttempted,
|
|
6935
|
-
resumeSucceeded,
|
|
6936
|
-
interrupted: result.interrupted === true
|
|
6937
|
-
}, result.interrupted === true ? 'process.interrupted' : 'process.exited', { exitCode: result.exitCode });
|
|
6938
|
-
} catch (e) {
|
|
6939
|
-
ctx.logger.error('agent turn failed', {
|
|
6940
|
-
session: turnSessionKey,
|
|
6941
|
-
durationMs: Date.now() - turnStartedAt,
|
|
6942
|
-
message: e && e.message ? e.message : 'Agent 执行失败'
|
|
6943
|
-
});
|
|
6944
|
-
traceLines.push(`[错误] ${e && e.message ? e.message : 'Agent 执行失败'}`);
|
|
6945
|
-
patchWebSessionMessage(state.webHistoryDir, sessionRef, userMessage && userMessage.id, {
|
|
6946
|
-
pending: false,
|
|
6947
|
-
contextMode,
|
|
6948
|
-
resumeAttempted,
|
|
6949
|
-
resumeSucceeded
|
|
6950
|
-
});
|
|
6951
|
-
patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
|
|
6952
|
-
content: traceLines.join('\n'),
|
|
6953
|
-
traceEvents,
|
|
6954
|
-
contextMode,
|
|
6955
|
-
resumeAttempted,
|
|
6956
|
-
resumeSucceeded,
|
|
6957
|
-
interrupted: true,
|
|
6958
|
-
pending: false
|
|
6959
|
-
});
|
|
6960
|
-
if (streamingReplyMessageId) {
|
|
6961
|
-
removeWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId);
|
|
7345
|
+
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, queueClientId.value)) {
|
|
7346
|
+
return;
|
|
7347
|
+
}
|
|
7348
|
+
const runState = state.agentRuns.get(sessionRef.containerName);
|
|
7349
|
+
if (runState && runState.sessionKey !== buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)) {
|
|
7350
|
+
sendJson(res, 409, { error: '当前容器已有其他会话的 agent 任务在运行,请等它结束或先停止' });
|
|
7351
|
+
return;
|
|
7352
|
+
}
|
|
7353
|
+
// 入队与下面的调度之间没有 await:队列落盘和打断判断对同一会话是原子的
|
|
7354
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
7355
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
7356
|
+
const added = agentQueue.enqueue(agentSession, { clientMessageId: queueClientId.value, text, when });
|
|
7357
|
+
if (!added.ok) {
|
|
7358
|
+
sendJson(res, 409, { error: '排队已满(最多 10 条),请先取消几条或等它们发出', full: true });
|
|
7359
|
+
return;
|
|
7360
|
+
}
|
|
7361
|
+
if (!added.duplicate) {
|
|
7362
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
|
|
7363
|
+
if (!runState) {
|
|
7364
|
+
dispatchNextQueued(ctx, state, sessionRef);
|
|
7365
|
+
} else if (when === 'now') {
|
|
7366
|
+
steerWebAgentRun(ctx, runState, 'now', state);
|
|
7367
|
+
} else if (when === 'step') {
|
|
7368
|
+
armStepSteer(ctx, state, runState);
|
|
6962
7369
|
}
|
|
6963
|
-
emitStreamEvent({
|
|
6964
|
-
type: 'error',
|
|
6965
|
-
error: e && e.message ? e.message : 'Agent 执行失败'
|
|
6966
|
-
}, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
|
|
6967
|
-
} finally {
|
|
6968
|
-
clearInterval(heartbeatTimer);
|
|
6969
|
-
res.end();
|
|
6970
7370
|
}
|
|
7371
|
+
sendJson(res, 200, { item: added.item, duplicate: added.duplicate, queue: agentQueue.publicQueue(agentSession) });
|
|
7372
|
+
}
|
|
7373
|
+
},
|
|
7374
|
+
{
|
|
7375
|
+
method: 'DELETE',
|
|
7376
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue\/([A-Za-z0-9_-]+)$/),
|
|
7377
|
+
handler: async match => {
|
|
7378
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7379
|
+
if (!sessionRef) {
|
|
7380
|
+
return;
|
|
7381
|
+
}
|
|
7382
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
7383
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
7384
|
+
const removed = agentSession ? agentQueue.removeFromQueue(agentSession, match[2]) : null;
|
|
7385
|
+
if (!removed) {
|
|
7386
|
+
// 已经发出去了或早被取消:前端据此刷新,不当错误
|
|
7387
|
+
sendJson(res, 404, { error: '这条排队的提示词已经发出或已取消' });
|
|
7388
|
+
return;
|
|
7389
|
+
}
|
|
7390
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
|
|
7391
|
+
// 取消的是最后一个 now / step:撤掉还在等的“步骤结束后打断”
|
|
7392
|
+
const activeRun = state.agentRuns.get(sessionRef.containerName);
|
|
7393
|
+
if (activeRun && activeRun.stepPending && !queueHasSteerItems(state, sessionRef)) {
|
|
7394
|
+
clearTimeout(activeRun.stepTimer);
|
|
7395
|
+
activeRun.stepPending = false;
|
|
7396
|
+
}
|
|
7397
|
+
sendJson(res, 200, { removed: removed.id, queue: agentQueue.publicQueue(agentSession) });
|
|
7398
|
+
}
|
|
7399
|
+
},
|
|
7400
|
+
{
|
|
7401
|
+
method: 'POST',
|
|
7402
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/queue\/continue$/),
|
|
7403
|
+
handler: async match => {
|
|
7404
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
7405
|
+
if (!sessionRef) {
|
|
7406
|
+
return;
|
|
7407
|
+
}
|
|
7408
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
7409
|
+
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务' });
|
|
7410
|
+
return;
|
|
7411
|
+
}
|
|
7412
|
+
const started = dispatchNextQueued(ctx, state, sessionRef, { force: true });
|
|
7413
|
+
sendJson(res, 200, { started });
|
|
6971
7414
|
}
|
|
6972
7415
|
},
|
|
6973
7416
|
{
|
|
@@ -6978,7 +7421,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
6978
7421
|
if (!sessionRef) {
|
|
6979
7422
|
return;
|
|
6980
7423
|
}
|
|
6981
|
-
const stopped = stopWebAgentRun(state, sessionRef.containerName);
|
|
7424
|
+
const stopped = stopWebAgentRun(state, sessionRef.containerName, ctx);
|
|
6982
7425
|
if (!stopped) {
|
|
6983
7426
|
// 运行登记不存在(serve 重启后常见的孤儿任务):把残留的
|
|
6984
7427
|
// pending 消息标记为已中断,否则前端会因 pending 永久禁用输入
|
|
@@ -7143,6 +7586,18 @@ async function startWebServer(options) {
|
|
|
7143
7586
|
agentStreamHeartbeatMs: Number.isFinite(options.agentStreamHeartbeatMs) && options.agentStreamHeartbeatMs > 0
|
|
7144
7587
|
? options.agentStreamHeartbeatMs
|
|
7145
7588
|
: DEFAULT_AGENT_STREAM_HEARTBEAT_MS,
|
|
7589
|
+
// 停止 / 打断时每一级信号(INT → TERM → KILL)等进程退出的时间
|
|
7590
|
+
agentStopGraceMs: Number.isFinite(options.agentStopGraceMs) && options.agentStopGraceMs > 0
|
|
7591
|
+
? options.agentStopGraceMs
|
|
7592
|
+
: agentRunControl.DEFAULT_GRACE_MS,
|
|
7593
|
+
// Agent 原始输出保留开头的上限(测试可调小);结尾另留一份,见 createTailBuffer
|
|
7594
|
+
agentRawOutputMaxChars: Number.isFinite(options.agentRawOutputMaxChars) && options.agentRawOutputMaxChars > 0
|
|
7595
|
+
? options.agentRawOutputMaxChars
|
|
7596
|
+
: AGENT_RAW_OUTPUT_MAX_CHARS,
|
|
7597
|
+
// 追加提示词“当前步骤结束后插入”等不到步骤边界时,退化成立即打断的等待时间
|
|
7598
|
+
agentStepFallbackMs: Number.isFinite(options.agentStepFallbackMs) && options.agentStepFallbackMs > 0
|
|
7599
|
+
? options.agentStepFallbackMs
|
|
7600
|
+
: agentQueue.STEP_FALLBACK_MS,
|
|
7146
7601
|
colors: options.colors || {
|
|
7147
7602
|
GREEN: '',
|
|
7148
7603
|
CYAN: '',
|
|
@@ -7426,6 +7881,10 @@ module.exports = {
|
|
|
7426
7881
|
buildEnvModelOptions,
|
|
7427
7882
|
// 仅供测试:直接驱动历史读写层
|
|
7428
7883
|
__testing: {
|
|
7884
|
+
prepareStructuredTraceEvents,
|
|
7885
|
+
buildClaudeAgentExecCommand,
|
|
7886
|
+
extractContentDeltaFromPayload,
|
|
7887
|
+
extractAgentMessageFromStructuredOutput,
|
|
7429
7888
|
appendWebSessionMessage,
|
|
7430
7889
|
appendWebSessionControlEvent,
|
|
7431
7890
|
removeWebSessionMessage,
|