@xcanwin/manyoyo 8.7.1 → 8.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/docker/res/claude/settings.json +3 -3
- package/lib/container-init.sh +45 -0
- package/lib/image-pull.js +24 -0
- package/lib/init-config.js +5 -3
- package/lib/model-services.js +51 -1
- package/lib/provider-presets.js +13 -0
- package/lib/web/AGENTS.md +7 -2
- package/lib/web/agent-effort.js +57 -0
- package/lib/web/agent-light-call.js +95 -0
- package/lib/web/agent-side.js +77 -0
- package/lib/web/context-usage.js +117 -0
- package/lib/web/extensions.js +158 -0
- package/lib/web/index.html +85 -85
- package/lib/web/recap.js +82 -0
- package/lib/web/server.js +723 -25
- package/lib/web/session-meta.js +65 -0
- package/package.json +2 -2
package/lib/web/server.js
CHANGED
|
@@ -15,6 +15,12 @@ const { applyAptMirror } = require('../mirrors');
|
|
|
15
15
|
const { BUILTIN_AGENT_INSTRUCTIONS, buildAgentInstructions, composeSystemPrompt, SYSTEM_PROMPT_MAX_CHARS, toTomlBasicString } = require('../agent-instructions');
|
|
16
16
|
const containerState = require('../container-state');
|
|
17
17
|
const sessionMeta = require('./session-meta');
|
|
18
|
+
const agentEffort = require('./agent-effort');
|
|
19
|
+
const agentLight = require('./agent-light-call');
|
|
20
|
+
const webRecap = require('./recap');
|
|
21
|
+
const webExtensions = require('./extensions');
|
|
22
|
+
const agentSide = require('./agent-side');
|
|
23
|
+
const contextUsageLib = require('./context-usage');
|
|
18
24
|
const gitChanges = require('./git-changes');
|
|
19
25
|
const { buildExecArgs, resolveContainerId, readMergedEnv } = require('../container-exec');
|
|
20
26
|
const { createNetworkManager, NETWORK_NAME } = require('../container-network');
|
|
@@ -233,14 +239,25 @@ function createEmptyWebAgentSession(agentId, agentName) {
|
|
|
233
239
|
engineSessionId: '',
|
|
234
240
|
usageTotal: null,
|
|
235
241
|
model: '',
|
|
242
|
+
// 推理强度:''=默认(不注入,尊重容器 env / 配置)
|
|
243
|
+
effort: '',
|
|
244
|
+
// claude 最近一轮结束时的上下文占用(见 lib/web/context-usage.js)
|
|
245
|
+
contextUsage: null,
|
|
236
246
|
queue: [],
|
|
237
247
|
queuePaused: false,
|
|
238
248
|
title: '',
|
|
249
|
+
// 自动标题(轻量模型调用生成)与是否已经试过(失败也不重试,避免每次刷新都花钱)
|
|
250
|
+
autoTitle: '',
|
|
251
|
+
autoTitleTried: false,
|
|
252
|
+
// 回顾:{ forMessageId, text, createdAt },对应哪条最终回复
|
|
253
|
+
recap: null,
|
|
239
254
|
firstPrompt: '',
|
|
240
255
|
pinned: false,
|
|
241
256
|
slashCommands: null,
|
|
242
257
|
// 回退对话后,下一轮 Claude 命令带 --resume-session-at <uuid>(只用一次)
|
|
243
|
-
rewindToUuid: ''
|
|
258
|
+
rewindToUuid: '',
|
|
259
|
+
// 分叉出来的 AGENT:下一轮 Claude 命令带 --fork-session(从原会话另开一条,拿到新会话 id 后清掉)
|
|
260
|
+
forkPending: false
|
|
244
261
|
};
|
|
245
262
|
}
|
|
246
263
|
|
|
@@ -293,13 +310,19 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
|
|
|
293
310
|
engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : '',
|
|
294
311
|
usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
|
|
295
312
|
model: normalizeStoredAgentModelValue(source.model),
|
|
313
|
+
effort: agentEffort.isEffortValue(source.effort) ? source.effort : '',
|
|
314
|
+
contextUsage: contextUsageLib.normalizeStoredContextUsage(source.contextUsage),
|
|
296
315
|
queue: agentQueue.normalizeStoredQueue(source.queue),
|
|
297
316
|
queuePaused: source.queuePaused === true,
|
|
298
317
|
title: sessionMeta.normalizeTitle(source.title),
|
|
318
|
+
autoTitle: sessionMeta.normalizeTitle(source.autoTitle),
|
|
319
|
+
autoTitleTried: source.autoTitleTried === true,
|
|
320
|
+
recap: webRecap.normalizeStoredRecap(source.recap, text => agentLight.cleanLightText(text, agentLight.RECAP_MAX_CHARS)),
|
|
299
321
|
firstPrompt: sessionMeta.normalizeFirstPrompt(source.firstPrompt),
|
|
300
322
|
pinned: source.pinned === true,
|
|
301
323
|
slashCommands: normalizeStoredSlashCommands(source.slashCommands),
|
|
302
|
-
rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : ''
|
|
324
|
+
rewindToUuid: typeof source.rewindToUuid === 'string' && webRewind.UUID_PATTERN.test(source.rewindToUuid) ? source.rewindToUuid : '',
|
|
325
|
+
forkPending: source.forkPending === true
|
|
303
326
|
};
|
|
304
327
|
}
|
|
305
328
|
|
|
@@ -995,6 +1018,11 @@ function renderAgentPromptCommand(template, prompt) {
|
|
|
995
1018
|
return templateText.replace(/\{prompt\}/g, () => safePrompt);
|
|
996
1019
|
}
|
|
997
1020
|
|
|
1021
|
+
// 推理强度只接受小写字母 2~16 位(claude: low/medium/high/xhigh/max;codex 因模型而异),其余一律当没选
|
|
1022
|
+
function normalizeEffortValue(value) {
|
|
1023
|
+
return typeof value === 'string' && /^[a-z]{2,16}$/.test(value) ? value : '';
|
|
1024
|
+
}
|
|
1025
|
+
|
|
998
1026
|
function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
999
1027
|
const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
1000
1028
|
const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
|
|
@@ -1015,15 +1043,19 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
|
|
|
1015
1043
|
// 图片:--image=<路径>(等号形式只吃这一个值,不会把后面的提示词当成图片);resume 子命令同样支持
|
|
1016
1044
|
const images = options && Array.isArray(options.images) ? options.images : [];
|
|
1017
1045
|
const imageFlags = images.map(item => ` --image=${quoteBashSingleValue(item)}`).join('');
|
|
1046
|
+
const effort = normalizeEffortValue(options && options.effort);
|
|
1047
|
+
const effortFlag = effort && !/model_reasoning_effort/.test(suffix)
|
|
1048
|
+
? ` -c model_reasoning_effort=${effort}`
|
|
1049
|
+
: '';
|
|
1018
1050
|
const instructionsFlag = instructions && !/developer_instructions/.test(suffix)
|
|
1019
1051
|
? ` -c ${quoteBashSingleValue(`developer_instructions=${toTomlBasicString(instructions)}`)}`
|
|
1020
1052
|
: '';
|
|
1021
1053
|
if (sessionId) {
|
|
1022
1054
|
const promptIndex = suffix.indexOf('{prompt}');
|
|
1023
1055
|
const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
|
|
1024
|
-
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
|
|
1056
|
+
codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${resumeSuffix}`;
|
|
1025
1057
|
} else {
|
|
1026
|
-
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${instructionsFlag}${imageFlags}${suffix}`;
|
|
1058
|
+
codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${effortFlag}${instructionsFlag}${imageFlags}${suffix}`;
|
|
1027
1059
|
}
|
|
1028
1060
|
}
|
|
1029
1061
|
// 兼容服务:OPENAI_BASE_URL 在容器里由 shell 展开成 -c openai_base_url=…(codex 不认这个环境变量,见 withCodexBaseUrl)
|
|
@@ -1050,8 +1082,18 @@ function prependAgentFlags(commandText, matchPattern, flagSpecs) {
|
|
|
1050
1082
|
return `${prefix}${suffix}`;
|
|
1051
1083
|
}
|
|
1052
1084
|
|
|
1085
|
+
// 计划模式:-p 下 --permission-mode plan 和免确认参数同时出现时计划模式无效(实测),
|
|
1086
|
+
// 所以在渲染提示词之前从模板里去掉免确认参数和别的 permission-mode,再加上 plan。只处理模板,不碰提示词
|
|
1087
|
+
function applyPlanPermissionMode(templateText) {
|
|
1088
|
+
const stripped = templateText
|
|
1089
|
+
.replace(/(^|\s)--(?:allow-)?dangerously-skip-permissions(?=\s|$)/g, '$1')
|
|
1090
|
+
.replace(/(^|\s)--permission-mode(?:=\S+|\s+\S+)?(?=\s|$)/g, '$1');
|
|
1091
|
+
return stripped.replace(/^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*claude)\b/, '$1 --permission-mode plan');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1053
1094
|
function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
1054
|
-
const
|
|
1095
|
+
const rawTemplate = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
|
|
1096
|
+
const templateText = options && options.permissionMode === 'plan' ? applyPlanPermissionMode(rawTemplate) : rawTemplate;
|
|
1055
1097
|
const flagSpecs = [
|
|
1056
1098
|
{ flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
|
|
1057
1099
|
{ flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
|
|
@@ -1069,6 +1111,9 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
1069
1111
|
if (sessionId && resumeAtUuid) {
|
|
1070
1112
|
flagSpecs.push({ flag: `--resume-session-at ${resumeAtUuid}`, pattern: /(?:^|\s)--resume-session-at(?:\s|$)/ });
|
|
1071
1113
|
}
|
|
1114
|
+
if (sessionId && options && options.forkSession === true) {
|
|
1115
|
+
flagSpecs.push({ flag: '--fork-session', pattern: /(?:^|\s)--fork-session(?:\s|$)/ });
|
|
1116
|
+
}
|
|
1072
1117
|
const model = options && typeof options.model === 'string' ? options.model.trim() : '';
|
|
1073
1118
|
if (model) {
|
|
1074
1119
|
flagSpecs.push({ flag: `--model ${quoteBashSingleValue(model)}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
|
|
@@ -1089,11 +1134,15 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
|
|
|
1089
1134
|
if (forwardSubagentText === true) {
|
|
1090
1135
|
flagSpecs.push({ flag: '--forward-subagent-text', pattern: /(?:^|\s)--forward-subagent-text(?:\s|$)/ });
|
|
1091
1136
|
}
|
|
1092
|
-
const
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
);
|
|
1137
|
+
const claudePattern = /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/;
|
|
1138
|
+
let claudeTemplate = prependAgentFlags(templateText, claudePattern, flagSpecs);
|
|
1139
|
+
// 推理强度:CLAUDE_CODE_EFFORT_LEVEL 优先于 --effort 和 settings(实测),所以只能用命令前缀的环境变量覆盖容器里
|
|
1140
|
+
// 可能设了的值;模板自己写了就以模板为准
|
|
1141
|
+
const effort = normalizeEffortValue(options && options.effort);
|
|
1142
|
+
const claudePrefix = (templateText.match(claudePattern) || [])[2];
|
|
1143
|
+
if (effort && claudePrefix !== undefined && !/(?:^|\s)CLAUDE_CODE_EFFORT_LEVEL=/.test(claudePrefix)) {
|
|
1144
|
+
claudeTemplate = claudeTemplate.replace(/^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b/, (match, envPrefix) => `${envPrefix}CLAUDE_CODE_EFFORT_LEVEL=${effort} claude`);
|
|
1145
|
+
}
|
|
1097
1146
|
const rendered = claudeTemplate === templateText
|
|
1098
1147
|
? renderAgentPromptCommand(templateText, prompt)
|
|
1099
1148
|
: renderAgentPromptCommand(claudeTemplate, prompt);
|
|
@@ -1525,10 +1574,13 @@ function normalizeAgentModelCatalogEntry(item) {
|
|
|
1525
1574
|
if (!value) {
|
|
1526
1575
|
return null;
|
|
1527
1576
|
}
|
|
1577
|
+
const efforts = agentEffort.extractEfforts(item);
|
|
1528
1578
|
return {
|
|
1529
1579
|
value,
|
|
1530
1580
|
label: pickFirstString(item && item.displayName, value),
|
|
1531
|
-
description: pickFirstString(item && item.description)
|
|
1581
|
+
description: pickFirstString(item && item.description),
|
|
1582
|
+
...(efforts.length ? { efforts } : {}),
|
|
1583
|
+
...(typeof (item && item.resolvedModel) === 'string' && item.resolvedModel ? { resolvedModel: normalizeStoredAgentModelValue(item.resolvedModel) } : {})
|
|
1532
1584
|
};
|
|
1533
1585
|
}
|
|
1534
1586
|
|
|
@@ -1545,6 +1597,17 @@ function extractClaudeModelsFromControlResponse(payload) {
|
|
|
1545
1597
|
return models.map(normalizeAgentModelCatalogEntry).filter(Boolean);
|
|
1546
1598
|
}
|
|
1547
1599
|
|
|
1600
|
+
function extractClaudeCommandsFromControlResponse(payload) {
|
|
1601
|
+
if (!payload || payload.type !== 'control_response') {
|
|
1602
|
+
return undefined;
|
|
1603
|
+
}
|
|
1604
|
+
const response = toPlainObject(payload.response);
|
|
1605
|
+
if (response.request_id !== 'manyoyo-command-catalog') {
|
|
1606
|
+
return undefined;
|
|
1607
|
+
}
|
|
1608
|
+
return sessionMeta.normalizeCommandCatalog(toPlainObject(response.response).commands);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1548
1611
|
function extractCodexModelsFromListResponse(payload) {
|
|
1549
1612
|
if (!payload || payload.id !== 'manyoyo-model-list' || !payload.result) {
|
|
1550
1613
|
return undefined;
|
|
@@ -1590,11 +1653,73 @@ async function fetchAgentModelCatalogCached(ctx, containerName, agentProgram) {
|
|
|
1590
1653
|
return pending;
|
|
1591
1654
|
}
|
|
1592
1655
|
|
|
1656
|
+
// 斜杠命令目录(claude 的 initialize 请求,不调用模型):同样按“容器”缓存 10 分钟,拿不到不缓存
|
|
1657
|
+
const commandCatalogCaches = new WeakMap();
|
|
1658
|
+
const COMMAND_CATALOG_FAILURE_TTL_MS = 15 * 1000;
|
|
1659
|
+
|
|
1660
|
+
async function fetchClaudeCommandCatalogCached(ctx, containerName) {
|
|
1661
|
+
let cache = commandCatalogCaches.get(ctx);
|
|
1662
|
+
if (!cache) {
|
|
1663
|
+
cache = new Map();
|
|
1664
|
+
commandCatalogCaches.set(ctx, cache);
|
|
1665
|
+
}
|
|
1666
|
+
const hit = cache.get(containerName);
|
|
1667
|
+
if (hit && (hit.pending || Date.now() - hit.at < (hit.commands.length ? MODEL_CATALOG_TTL_MS : COMMAND_CATALOG_FAILURE_TTL_MS))) {
|
|
1668
|
+
return hit.pending || hit.commands;
|
|
1669
|
+
}
|
|
1670
|
+
const requestLine = JSON.stringify({
|
|
1671
|
+
type: 'control_request',
|
|
1672
|
+
request_id: 'manyoyo-command-catalog',
|
|
1673
|
+
request: { subtype: 'initialize' }
|
|
1674
|
+
});
|
|
1675
|
+
const pending = runAgentProtocolProcess(
|
|
1676
|
+
ctx,
|
|
1677
|
+
containerName,
|
|
1678
|
+
'claude -p --input-format stream-json --output-format stream-json --verbose',
|
|
1679
|
+
{ stdinLines: [requestLine], onLine: extractClaudeCommandsFromControlResponse }
|
|
1680
|
+
).then(({ result }) => {
|
|
1681
|
+
const commands = Array.isArray(result) ? result : [];
|
|
1682
|
+
// 取不到(容器停了、claude 不在)也记一小会儿:输入框里每敲一个字符都会请求一次,不能每次都进容器等 8 秒
|
|
1683
|
+
cache.set(containerName, { at: Date.now(), commands });
|
|
1684
|
+
return commands;
|
|
1685
|
+
}, () => {
|
|
1686
|
+
cache.set(containerName, { at: Date.now(), commands: [] });
|
|
1687
|
+
return [];
|
|
1688
|
+
});
|
|
1689
|
+
cache.set(containerName, { pending });
|
|
1690
|
+
return pending;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// 当前模型能选的推理强度(空数组 = 确实没有,不显示;null = 目录取不到,不知道)。codex 接了第三方服务时目录里全是 OpenAI 的模型,直接当没有
|
|
1694
|
+
async function resolveEffortLevels(ctx, state, sessionRef, currentModel) {
|
|
1695
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
1696
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
1697
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
|
|
1698
|
+
history,
|
|
1699
|
+
sessionRef.agentId,
|
|
1700
|
+
containerInfo.defaultCommand
|
|
1701
|
+
));
|
|
1702
|
+
if (agentProgram !== 'claude' && agentProgram !== 'codex') {
|
|
1703
|
+
return [];
|
|
1704
|
+
}
|
|
1705
|
+
try {
|
|
1706
|
+
if (agentProgram === 'codex') {
|
|
1707
|
+
const env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
|
|
1708
|
+
if (env.OPENAI_BASE_URL) return [];
|
|
1709
|
+
}
|
|
1710
|
+
const catalog = await fetchAgentModelCatalogCached(ctx, sessionRef.containerName, agentProgram);
|
|
1711
|
+
// 目录取不到(容器没运行、探测超时)= 不知道,和“确实不支持”要分开:不知道时不能改用户已经选好的强度
|
|
1712
|
+
return catalog.length ? agentEffort.effortLevelsFor(agentProgram, catalog, currentModel) : null;
|
|
1713
|
+
} catch (e) {
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1593
1718
|
// 以会话实际配置的 env 生成模型选项。defaultModel 是“跟随默认”实际用的模型;
|
|
1594
1719
|
// Claude 另有按档位别名(opus / sonnet / haiku …)一行一个的选项:value 是别名(--model opus 由 CLI 按 env 解析),
|
|
1595
1720
|
// label 是该档位对应的真实模型名。不同档位指向同一个模型也各占一行,不合并。
|
|
1596
1721
|
// 只读 *_MODEL 这类非敏感键,容器可写的 env 只用来展示,不参与任何安全判断
|
|
1597
|
-
const CLAUDE_TIER_ORDER = ['
|
|
1722
|
+
const CLAUDE_TIER_ORDER = ['fable', 'opus', 'sonnet', 'haiku'];
|
|
1598
1723
|
|
|
1599
1724
|
function buildEnvModelOptions(agentProgram, env) {
|
|
1600
1725
|
const source = env && typeof env === 'object' ? env : {};
|
|
@@ -2382,7 +2507,7 @@ function prepareCodexTraceEvent(payload) {
|
|
|
2382
2507
|
});
|
|
2383
2508
|
}
|
|
2384
2509
|
|
|
2385
|
-
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = []) {
|
|
2510
|
+
async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images = [], turnOptions = {}) {
|
|
2386
2511
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2387
2512
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
|
|
2388
2513
|
const containerMap = listWebManyoyoContainers(ctx);
|
|
@@ -2448,9 +2573,12 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt, images =
|
|
|
2448
2573
|
const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
|
|
2449
2574
|
sessionId: sessionIdForRun,
|
|
2450
2575
|
model: agentSession.model || '',
|
|
2576
|
+
effort: agentSession.effort || '',
|
|
2451
2577
|
forwardSubagentText: agentMeta.agentProgram === 'claude' ? 'auto' : false,
|
|
2452
2578
|
images: imagesByFlag ? images : [],
|
|
2453
2579
|
resumeAtUuid: agentSession.rewindToUuid || '',
|
|
2580
|
+
forkSession: agentSession.forkPending === true,
|
|
2581
|
+
permissionMode: turnOptions.permissionMode === 'plan' && agentMeta.agentProgram === 'claude' ? 'plan' : '',
|
|
2454
2582
|
instructions: buildAgentInstructions(
|
|
2455
2583
|
configSnapshot.parseError ? {} : configSnapshot.parsed,
|
|
2456
2584
|
runName,
|
|
@@ -2485,20 +2613,36 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
|
|
|
2485
2613
|
...(result.networkDenied ? { networkDenied: result.networkDenied } : {}),
|
|
2486
2614
|
...(turnUsage ? { usage: turnUsage } : {})
|
|
2487
2615
|
});
|
|
2616
|
+
// 分叉的第一轮:命令里带的是原会话 id,输出里的才是新会话 id
|
|
2617
|
+
const extractedSessionId = extractEngineSessionId(agentMeta.agentProgram, result.stdout);
|
|
2618
|
+
const engineSessionIdNow = agentSession.forkPending === true
|
|
2619
|
+
? extractedSessionId || meta.engineSessionId
|
|
2620
|
+
: meta.engineSessionId || extractedSessionId;
|
|
2621
|
+
// 分叉出来的 AGENT 第一轮:拿到了新的会话 id 才算分叉成功。没成功(命令失败)就保留 forkPending 和回退位置,
|
|
2622
|
+
// 下一轮仍然是分叉,不能退化成直接续接原会话(那会改写原 AGENT 的会话)
|
|
2623
|
+
// (命令在 init 之后才失败时输出里也有新 id,但新会话可能没落盘完整:这一轮必须成功才算分叉完成)
|
|
2624
|
+
const forkDone = agentSession.forkPending === true && Boolean(engineSessionIdNow) && engineSessionIdNow !== agentSession.engineSessionId
|
|
2625
|
+
&& result.exitCode === 0 && result.interrupted !== true;
|
|
2626
|
+
const keepFork = agentSession.forkPending === true && !forkDone;
|
|
2488
2627
|
const patch = {
|
|
2489
|
-
rewindToUuid: '',
|
|
2628
|
+
rewindToUuid: keepFork ? agentSession.rewindToUuid || '' : '',
|
|
2629
|
+
...(agentSession.forkPending === true && forkDone ? { forkPending: false } : {}),
|
|
2490
2630
|
lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
|
|
2491
2631
|
lastResumeOk: meta.resumeAttempted ? meta.resumeSucceeded : agentSession.lastResumeOk,
|
|
2492
2632
|
lastResumeError: meta.resumeAttempted ? (meta.resumeSucceeded ? '' : meta.resumeError) : (agentSession.lastResumeError || '')
|
|
2493
2633
|
};
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
patch.engineSessionId = engineSessionId;
|
|
2634
|
+
if (engineSessionIdNow) {
|
|
2635
|
+
patch.engineSessionId = engineSessionIdNow;
|
|
2497
2636
|
}
|
|
2498
2637
|
if (turnUsage) {
|
|
2499
2638
|
patch.usageTotal = accumulateUsageTotal(agentSession.usageTotal, turnUsage);
|
|
2500
2639
|
}
|
|
2501
2640
|
if (agentMeta.agentProgram === 'claude') {
|
|
2641
|
+
// 上下文占用:只在轮结束时写一次;这一轮拿不到数据就保留上一次的
|
|
2642
|
+
const contextUsage = contextUsageLib.extractClaudeContextUsage(result.stdoutTail || result.stdout);
|
|
2643
|
+
if (contextUsage) {
|
|
2644
|
+
patch.contextUsage = { ...contextUsage, updatedAt: new Date().toISOString() };
|
|
2645
|
+
}
|
|
2502
2646
|
const slashCommands = extractClaudeInitSlashCommands(result.stdout);
|
|
2503
2647
|
if (slashCommands) {
|
|
2504
2648
|
patch.slashCommands = slashCommands;
|
|
@@ -2625,7 +2769,7 @@ async function rewindWebSessionLocked(ctx, state, sessionRef, scope, target) {
|
|
|
2625
2769
|
if (!removed.length) {
|
|
2626
2770
|
return { status: 409, error: '这条提示词已经不在了' };
|
|
2627
2771
|
}
|
|
2628
|
-
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid });
|
|
2772
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId, rewindToUuid, contextUsage: null });
|
|
2629
2773
|
appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopped', { reason: 'rewind', scope });
|
|
2630
2774
|
}
|
|
2631
2775
|
return { ok: true, scope, filesRestored, conversationMode, prompt: message.content, images: Array.isArray(message.images) ? message.images : [] };
|
|
@@ -2681,6 +2825,296 @@ async function refreshWebAgentTitle(ctx, state, sessionRef, agentProgram) {
|
|
|
2681
2825
|
}
|
|
2682
2826
|
}
|
|
2683
2827
|
|
|
2828
|
+
// 轻量模型调用(标题、回顾):不占容器运行锁,同一 AGENT 同一种用途同时最多一个,超时 90 秒,失败返回空串。
|
|
2829
|
+
// 高频路径,失败不写日志
|
|
2830
|
+
const lightCallsInFlight = new WeakMap();
|
|
2831
|
+
|
|
2832
|
+
async function runWebLightCall(ctx, state, sessionRef, agentProgram, kind, rule, input, maxChars) {
|
|
2833
|
+
if (agentProgram !== 'claude' && agentProgram !== 'codex') {
|
|
2834
|
+
return '';
|
|
2835
|
+
}
|
|
2836
|
+
let inFlight = lightCallsInFlight.get(ctx);
|
|
2837
|
+
if (!inFlight) {
|
|
2838
|
+
inFlight = new Set();
|
|
2839
|
+
lightCallsInFlight.set(ctx, inFlight);
|
|
2840
|
+
}
|
|
2841
|
+
const flightKey = `${sessionRef.containerName}\u0000${sessionRef.agentId}\u0000${kind}`;
|
|
2842
|
+
if (inFlight.has(flightKey)) {
|
|
2843
|
+
return '';
|
|
2844
|
+
}
|
|
2845
|
+
inFlight.add(flightKey);
|
|
2846
|
+
try {
|
|
2847
|
+
const agentSession = getWebAgentSession(
|
|
2848
|
+
loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
|
|
2849
|
+
sessionRef.agentId,
|
|
2850
|
+
{ includeArchived: true }
|
|
2851
|
+
);
|
|
2852
|
+
let env = {};
|
|
2853
|
+
try {
|
|
2854
|
+
env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
|
|
2855
|
+
} catch (e) { /* 读不到 env 就按没有处理 */ }
|
|
2856
|
+
const model = agentSession ? agentSession.model || '' : '';
|
|
2857
|
+
const run = command => runWebSideCommand(ctx, sessionRef.containerName, command, { timeoutMs: agentLight.LIGHT_CALL_TIMEOUT_MS });
|
|
2858
|
+
let text = '';
|
|
2859
|
+
if (agentProgram === 'claude') {
|
|
2860
|
+
const command = agentLight.buildClaudeLightCommand({ rule, input, model: agentLight.pickClaudeLightModel(env, model) });
|
|
2861
|
+
text = agentLight.parseClaudeLightOutput((await run(command)).stdout);
|
|
2862
|
+
} else {
|
|
2863
|
+
const prompt = agentLight.buildCodexLightPrompt(rule, input);
|
|
2864
|
+
const attempt = async withEffort => {
|
|
2865
|
+
const template = withEffort ? agentLight.CODEX_LIGHT_TEMPLATE : agentLight.CODEX_LIGHT_TEMPLATE.replace(' -c model_reasoning_effort=low', '');
|
|
2866
|
+
const { stdout } = await run(buildCodexAgentExecCommand(template, prompt, { model }));
|
|
2867
|
+
return extractAgentMessageFromStructuredOutput('codex', stdout);
|
|
2868
|
+
};
|
|
2869
|
+
// 有的模型不接受 low:去掉强度再试一次
|
|
2870
|
+
text = await attempt(true) || await attempt(false);
|
|
2871
|
+
}
|
|
2872
|
+
return agentLight.cleanLightText(text, maxChars);
|
|
2873
|
+
} catch (e) {
|
|
2874
|
+
return '';
|
|
2875
|
+
} finally {
|
|
2876
|
+
inFlight.delete(flightKey);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
// 侧问:不占容器运行锁,同一 AGENT 同时一个。命令和主任务一样套上 PID 文件包装,
|
|
2881
|
+
// 客户端断开 / 超时时进到容器里终止(只杀宿主机上的 exec 客户端,容器里的进程会继续跑)
|
|
2882
|
+
const sideQuestionsInFlight = new WeakMap();
|
|
2883
|
+
|
|
2884
|
+
function runWebSideCommand(ctx, containerName, command, { onAbortSignal, timeoutMs }) {
|
|
2885
|
+
return new Promise(resolve => {
|
|
2886
|
+
const runState = { runId: agentRunControl.createRunId(), containerName, process: null, closed: null };
|
|
2887
|
+
let stdout = '';
|
|
2888
|
+
let settled = false;
|
|
2889
|
+
let child;
|
|
2890
|
+
try {
|
|
2891
|
+
child = spawnContainerExec(
|
|
2892
|
+
ctx,
|
|
2893
|
+
containerName,
|
|
2894
|
+
{ command: ['/bin/bash', '-lc', agentRunControl.wrapAgentCommand(command, runState.runId)] },
|
|
2895
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
2896
|
+
);
|
|
2897
|
+
} catch (e) {
|
|
2898
|
+
resolve({ stdout: '', error: '启动失败' });
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2901
|
+
runState.process = child;
|
|
2902
|
+
let markClosed;
|
|
2903
|
+
runState.closed = new Promise(done => { markClosed = done; });
|
|
2904
|
+
let aborted = '';
|
|
2905
|
+
const stop = reason => {
|
|
2906
|
+
if (settled || aborted) return;
|
|
2907
|
+
aborted = reason;
|
|
2908
|
+
terminateWebAgentRun(ctx, runState);
|
|
2909
|
+
};
|
|
2910
|
+
const timer = setTimeout(() => stop('timeout'), timeoutMs);
|
|
2911
|
+
if (typeof onAbortSignal === 'function') onAbortSignal(() => stop('aborted'));
|
|
2912
|
+
child.stdout.setEncoding('utf8');
|
|
2913
|
+
child.stdout.on('data', chunk => { if (stdout.length < 4 * 1024 * 1024) stdout += chunk; });
|
|
2914
|
+
const finish = () => {
|
|
2915
|
+
if (settled) return;
|
|
2916
|
+
settled = true;
|
|
2917
|
+
clearTimeout(timer);
|
|
2918
|
+
markClosed();
|
|
2919
|
+
resolve({ stdout, aborted });
|
|
2920
|
+
};
|
|
2921
|
+
child.once('close', finish);
|
|
2922
|
+
child.once('error', finish);
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
async function answerWebSideQuestion(ctx, state, sessionRef, agentProgram, agentSession, question, onAbortSignal) {
|
|
2927
|
+
const options = { model: agentSession.model || '', effort: agentSession.effort || '' };
|
|
2928
|
+
let command;
|
|
2929
|
+
if (agentProgram === 'claude') {
|
|
2930
|
+
command = agentSide.buildClaudeSideCommand({ sessionId: agentSession.engineSessionId, ...options, question });
|
|
2931
|
+
} else {
|
|
2932
|
+
command = buildCodexAgentExecCommand(agentSide.buildCodexSideTemplate({ threadId: agentSession.engineSessionId, ...options }), question, {});
|
|
2933
|
+
}
|
|
2934
|
+
const { stdout, aborted } = await runWebSideCommand(ctx, sessionRef.containerName, command, {
|
|
2935
|
+
onAbortSignal,
|
|
2936
|
+
timeoutMs: ctx.sideQuestionTimeoutMs || agentSide.SIDE_TIMEOUT_MS
|
|
2937
|
+
});
|
|
2938
|
+
if (aborted) {
|
|
2939
|
+
return { error: aborted === 'timeout' ? '侧问超时,已停止' : '已取消' };
|
|
2940
|
+
}
|
|
2941
|
+
const answer = agentProgram === 'claude'
|
|
2942
|
+
? agentLight.parseClaudeLightOutput(stdout)
|
|
2943
|
+
: extractAgentMessageFromStructuredOutput('codex', stdout);
|
|
2944
|
+
return answer && answer.trim() ? { answer: agentSide.clipAnswer(answer) } : { error: 'Agent 没有给出回答' };
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
const EXTENSION_AGENTS = [
|
|
2948
|
+
{ program: 'claude', label: 'Claude Code' },
|
|
2949
|
+
{ program: 'codex', label: 'Codex CLI' }
|
|
2950
|
+
];
|
|
2951
|
+
|
|
2952
|
+
async function readWebExtensions(ctx, containerName) {
|
|
2953
|
+
const probe = async command => (await runAgentProtocolProcess(ctx, containerName, command, { timeoutMs: 10000 })).stdout;
|
|
2954
|
+
const present = await Promise.all(EXTENSION_AGENTS.map(async agent => (await probe(`command -v ${agent.program}`)).trim() !== ''));
|
|
2955
|
+
return Promise.all(EXTENSION_AGENTS.filter((agent, index) => present[index]).map(async agent => {
|
|
2956
|
+
if (agent.program === 'claude') {
|
|
2957
|
+
const [catalog, mcpOutput] = await Promise.all([
|
|
2958
|
+
fetchClaudeCommandCatalogCached(ctx, containerName),
|
|
2959
|
+
runAgentProtocolProcess(ctx, containerName, 'claude mcp list', { timeoutMs: 30000 }).then(item => item.stdout)
|
|
2960
|
+
]);
|
|
2961
|
+
return { ...agent, skills: webExtensions.classifyClaudeSkills(catalog), mcp: webExtensions.parseClaudeMcpList(mcpOutput) };
|
|
2962
|
+
}
|
|
2963
|
+
const [skillsOutput, mcpOutput] = await Promise.all([
|
|
2964
|
+
probe(webExtensions.buildCodexSkillsCommand()),
|
|
2965
|
+
probe('codex mcp list --json')
|
|
2966
|
+
]);
|
|
2967
|
+
return { ...agent, skills: webExtensions.parseCodexSkillsOutput(skillsOutput), mcp: webExtensions.parseCodexMcpJson(mcpOutput) };
|
|
2968
|
+
}));
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
// 分叉出来的 AGENT 不继承的字段(运行态、统计、队列);其余在下面逐个列出要复制的
|
|
2972
|
+
function cloneMessageForFork(message) {
|
|
2973
|
+
const copy = JSON.parse(JSON.stringify(message));
|
|
2974
|
+
// 幂等 id 和执行中标记只属于原来那一轮
|
|
2975
|
+
delete copy.clientMessageId;
|
|
2976
|
+
delete copy.pending;
|
|
2977
|
+
return copy;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
async function forkWebAgentSession(ctx, state, sessionRef, messageId) {
|
|
2981
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
2982
|
+
return { status: 409, error: '任务进行中,暂时不能分叉' };
|
|
2983
|
+
}
|
|
2984
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
2985
|
+
const source = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
2986
|
+
if (!source || !Array.isArray(source.messages) || source.messages.length === 0) {
|
|
2987
|
+
return { status: 404, error: '这个 AGENT 还没有对话,没法分叉' };
|
|
2988
|
+
}
|
|
2989
|
+
let copyUntil = source.messages.length;
|
|
2990
|
+
let prompt = '';
|
|
2991
|
+
let images = [];
|
|
2992
|
+
let target = null;
|
|
2993
|
+
if (messageId) {
|
|
2994
|
+
const index = source.messages.findIndex(item => item && String(item.id || '') === messageId);
|
|
2995
|
+
target = index >= 0 ? source.messages[index] : null;
|
|
2996
|
+
if (!target || target.role !== 'user' || target.mode !== 'agent') {
|
|
2997
|
+
return { status: 404, error: '没有找到这条提示词' };
|
|
2998
|
+
}
|
|
2999
|
+
if (target.pending === true) {
|
|
3000
|
+
return { status: 409, error: '这一轮还在进行中' };
|
|
3001
|
+
}
|
|
3002
|
+
copyUntil = index;
|
|
3003
|
+
prompt = target.content;
|
|
3004
|
+
images = Array.isArray(target.images) ? target.images : [];
|
|
3005
|
+
} else if (source.messages.some(item => item && item.pending === true)) {
|
|
3006
|
+
return { status: 409, error: '任务进行中,暂时不能分叉' };
|
|
3007
|
+
}
|
|
3008
|
+
const program = getAgentRuntimeMeta(resolveEffectiveAgentPromptCommandForSession(
|
|
3009
|
+
history, sessionRef.agentId, (listWebManyoyoContainers(ctx)[sessionRef.containerName] || {}).defaultCommand
|
|
3010
|
+
)).agentProgram;
|
|
3011
|
+
// 引擎会话:claude 用 --fork-session(带目标那一轮之前的位置);位置找不到或其它 Agent 就退回历史注入(不精确但不丢数据)
|
|
3012
|
+
let engineSessionId = '';
|
|
3013
|
+
let rewindToUuid = '';
|
|
3014
|
+
let conversationMode = 'history';
|
|
3015
|
+
if (program === 'claude' && source.engineSessionId) {
|
|
3016
|
+
try {
|
|
3017
|
+
const command = webRewind.buildClaudeRecordsCommand(source.engineSessionId);
|
|
3018
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
3019
|
+
const records = command ? webRewind.parseClaudeRecords(await runWebContainerScript(ctx, sessionRef.containerName, command, 30000)) : [];
|
|
3020
|
+
if (!target) {
|
|
3021
|
+
// 从最新处分叉也要固定分叉点:否则原 AGENT 之后又聊了几轮,新 AGENT 第一轮会 fork 到原会话的最新状态
|
|
3022
|
+
const lastAssistant = [...records].reverse().find(item => item.t === 'assistant');
|
|
3023
|
+
if (lastAssistant && webRewind.UUID_PATTERN.test(lastAssistant.u)) {
|
|
3024
|
+
engineSessionId = source.engineSessionId;
|
|
3025
|
+
rewindToUuid = lastAssistant.u;
|
|
3026
|
+
conversationMode = 'precise';
|
|
3027
|
+
}
|
|
3028
|
+
} else {
|
|
3029
|
+
const pick = webRewind.pickClaudeResumeAt(records, { text: target.content, timeMs: Date.parse(target.timestamp) });
|
|
3030
|
+
if (pick.found && pick.uuid) {
|
|
3031
|
+
engineSessionId = source.engineSessionId;
|
|
3032
|
+
rewindToUuid = pick.uuid;
|
|
3033
|
+
conversationMode = 'precise';
|
|
3034
|
+
} else if (pick.found) {
|
|
3035
|
+
// 分叉点在第一轮之前:什么都不需要记得,开新会话
|
|
3036
|
+
conversationMode = 'precise';
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
} catch (e) {
|
|
3040
|
+
// 容器起不来 / 读不到记录:退回历史注入(不精确但不丢数据)
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
// 上面有 await:重新读一次,期间可能有人开始了新一轮
|
|
3044
|
+
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
3045
|
+
return { status: 409, error: '任务进行中,暂时不能分叉' };
|
|
3046
|
+
}
|
|
3047
|
+
const freshHistory = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
3048
|
+
const freshSource = getWebAgentSession(freshHistory, sessionRef.agentId, { includeArchived: true });
|
|
3049
|
+
if (!freshSource || freshSource.messages.length < copyUntil) {
|
|
3050
|
+
return { status: 409, error: '对话刚刚发生了变化,请重试' };
|
|
3051
|
+
}
|
|
3052
|
+
const forked = createWebAgentSession(freshHistory);
|
|
3053
|
+
forked.messages = freshSource.messages.slice(0, copyUntil).map(cloneMessageForFork);
|
|
3054
|
+
forked.agentPromptCommand = freshSource.agentPromptCommand || '';
|
|
3055
|
+
forked.model = freshSource.model || '';
|
|
3056
|
+
forked.effort = freshSource.effort || '';
|
|
3057
|
+
forked.firstPrompt = freshSource.firstPrompt || '';
|
|
3058
|
+
forked.slashCommands = freshSource.slashCommands || null;
|
|
3059
|
+
forked.engineSessionId = engineSessionId;
|
|
3060
|
+
forked.rewindToUuid = rewindToUuid;
|
|
3061
|
+
forked.forkPending = Boolean(engineSessionId);
|
|
3062
|
+
// 标题:沿用原来的显示名加“(分叉)”,不再花钱重新生成
|
|
3063
|
+
const baseTitle = freshSource.remark || freshSource.title || freshSource.autoTitle || freshSource.firstPrompt || '';
|
|
3064
|
+
forked.autoTitle = baseTitle ? sessionMeta.normalizeTitle(`${Array.from(baseTitle).slice(0, 24).join('')}(分叉)`) : '';
|
|
3065
|
+
forked.autoTitleTried = true;
|
|
3066
|
+
saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, freshHistory);
|
|
3067
|
+
return {
|
|
3068
|
+
result: {
|
|
3069
|
+
name: buildWebSessionKey(sessionRef.containerName, forked.agentId),
|
|
3070
|
+
agentId: forked.agentId,
|
|
3071
|
+
prompt,
|
|
3072
|
+
images,
|
|
3073
|
+
conversationMode
|
|
3074
|
+
}
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
// 第一条 Agent 消息发出后与第一轮并行生成标题(像 CLI 那样);写回前重新读一次,AGENT 被删了就放弃
|
|
3079
|
+
async function maybeStartAutoTitle(ctx, state, sessionRef, agentProgram, prompt) {
|
|
3080
|
+
try {
|
|
3081
|
+
await startAutoTitle(ctx, state, sessionRef, agentProgram, prompt);
|
|
3082
|
+
} catch (e) {
|
|
3083
|
+
// 调用方是 void:这里抛出去就是 unhandled rejection,会让 serve 退出。标题是锦上添花,失败静默
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
async function startAutoTitle(ctx, state, sessionRef, agentProgram, prompt) {
|
|
3088
|
+
if (ctx.autoTitle === false || (agentProgram !== 'claude' && agentProgram !== 'codex')) {
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
const agentSession = getWebAgentSession(
|
|
3092
|
+
loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
|
|
3093
|
+
sessionRef.agentId,
|
|
3094
|
+
{ includeArchived: true }
|
|
3095
|
+
);
|
|
3096
|
+
if (!agentSession || agentSession.autoTitleTried || agentSession.autoTitle || agentSession.title || agentSession.remark) {
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
const userMessages = agentSession.messages.filter(item => item && item.role === 'user' && item.mode === 'agent');
|
|
3100
|
+
if (userMessages.length !== 1) {
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { autoTitleTried: true });
|
|
3104
|
+
const title = await runWebLightCall(ctx, state, sessionRef, agentProgram, 'title', agentLight.TITLE_RULE, agentLight.buildTitleInput(prompt), agentLight.TITLE_MAX_CHARS);
|
|
3105
|
+
if (!title) {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
const fresh = getWebAgentSession(
|
|
3109
|
+
loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName),
|
|
3110
|
+
sessionRef.agentId,
|
|
3111
|
+
{ includeArchived: true }
|
|
3112
|
+
);
|
|
3113
|
+
if (fresh && !fresh.autoTitle) {
|
|
3114
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { autoTitle: title });
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
|
|
2684
3118
|
function appendWebAgentTraceMessage(webHistoryDir, sessionRefOrContainerName, content, extra = {}) {
|
|
2685
3119
|
const text = String(content || '').trim();
|
|
2686
3120
|
if (!text) {
|
|
@@ -4773,6 +5207,17 @@ try {
|
|
|
4773
5207
|
`);
|
|
4774
5208
|
}
|
|
4775
5209
|
|
|
5210
|
+
// 开头事件里就带的引擎会话 id:claude 是 system/init 的 session_id,codex 是 thread.started 的 thread_id
|
|
5211
|
+
function extractEarlyEngineSessionId(agentProgram, payload) {
|
|
5212
|
+
if (!payload || typeof payload !== 'object') {
|
|
5213
|
+
return '';
|
|
5214
|
+
}
|
|
5215
|
+
const id = agentProgram === 'claude' && payload.type === 'system' && payload.subtype === 'init'
|
|
5216
|
+
? payload.session_id
|
|
5217
|
+
: (agentProgram === 'codex' && payload.type === 'thread.started' ? payload.thread_id : '');
|
|
5218
|
+
return typeof id === 'string' && webRewind.UUID_PATTERN.test(id) ? id : '';
|
|
5219
|
+
}
|
|
5220
|
+
|
|
4776
5221
|
async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerName, command, options = {}) {
|
|
4777
5222
|
const opts = options && typeof options === 'object' ? options : {};
|
|
4778
5223
|
const sessionRef = typeof sessionRefOrContainerName === 'string'
|
|
@@ -4848,6 +5293,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
|
|
|
4848
5293
|
if (agentProgram === 'claude' || agentProgram === 'gemini' || agentProgram === 'codex' || agentProgram === 'opencode') {
|
|
4849
5294
|
const payload = parseJsonObjectLine(rawLine);
|
|
4850
5295
|
if (payload) {
|
|
5296
|
+
// claude 开头的 init 事件就带会话 id:第一轮没结束就让侧问等功能拿到它
|
|
5297
|
+
if (typeof opts.onEngineSessionId === 'function') {
|
|
5298
|
+
const earlySessionId = extractEarlyEngineSessionId(agentProgram, payload);
|
|
5299
|
+
if (earlySessionId) {
|
|
5300
|
+
try { opts.onEngineSessionId(earlySessionId); } catch (e) { /* 记不下来不影响本轮 */ }
|
|
5301
|
+
}
|
|
5302
|
+
}
|
|
4851
5303
|
const traceEvents = prepareStructuredTraceEvents(agentProgram, payload, structuredTraceState);
|
|
4852
5304
|
traceEvents.forEach(traceEvent => {
|
|
4853
5305
|
if (!traceEvent || !traceEvent.text) {
|
|
@@ -5540,7 +5992,7 @@ async function reconcileOrphanTurns(ctx, state) {
|
|
|
5540
5992
|
// 一轮 agent 对话的完整流程(占锁 → 落盘 → 执行 → 收尾)。res 是 NDJSON 响应;
|
|
5541
5993
|
// 排队的提示词在没有前端连接时由 createHeadlessResponse() 顶替,事件照常落盘。
|
|
5542
5994
|
// 返回 { busy } / { error }(没开始)或 { started: true }(跑完了)
|
|
5543
|
-
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = []) {
|
|
5995
|
+
async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessageId, res, images = [], turnOptions = {}) {
|
|
5544
5996
|
if (state.agentRuns.has(sessionRef.containerName)) {
|
|
5545
5997
|
// 锁是容器级的:同一容器里其他 agent 会话在跑任务时同样命中这里
|
|
5546
5998
|
return { busy: true };
|
|
@@ -5554,6 +6006,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5554
6006
|
userMessage = appendWebSessionMessage(state.webHistoryDir, sessionRef, 'user', prompt, {
|
|
5555
6007
|
mode: 'agent',
|
|
5556
6008
|
pending: true,
|
|
6009
|
+
...(turnOptions.permissionMode === 'plan' ? { planMode: true } : {}),
|
|
5557
6010
|
...(images.length ? { images } : {}),
|
|
5558
6011
|
...(clientMessageId ? { clientMessageId: clientMessageId } : {})
|
|
5559
6012
|
});
|
|
@@ -5574,7 +6027,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5574
6027
|
|
|
5575
6028
|
let prepared = null;
|
|
5576
6029
|
try {
|
|
5577
|
-
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images);
|
|
6030
|
+
prepared = await prepareWebAgentExecution(ctx, state, sessionRef, prompt, images, turnOptions);
|
|
5578
6031
|
} catch (e) {
|
|
5579
6032
|
releaseWebAgentRun(state, sessionRef.containerName, runState);
|
|
5580
6033
|
removeWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id);
|
|
@@ -5583,6 +6036,7 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5583
6036
|
}
|
|
5584
6037
|
|
|
5585
6038
|
const { agentSession, agentMeta, command, contextMode, resumeAttempted, resumeSucceeded, resumeError, engineSessionId } = prepared;
|
|
6039
|
+
void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
|
|
5586
6040
|
const traceLines = ['[执行过程]'];
|
|
5587
6041
|
const traceEvents = [];
|
|
5588
6042
|
let streamingReplyMessageId = '';
|
|
@@ -5759,6 +6213,10 @@ async function runWebAgentStreamTurn(ctx, state, sessionRef, prompt, clientMessa
|
|
|
5759
6213
|
agentProgram: agentMeta.agentProgram,
|
|
5760
6214
|
// 复用路由入口占好的那把锁,而不是另起一份运行登记
|
|
5761
6215
|
runState,
|
|
6216
|
+
// 只在这个 AGENT 还没有引擎会话时提前记下(分叉 / 续接的轮次已经有了,不能被改写)
|
|
6217
|
+
onEngineSessionId: (agentMeta.agentProgram === 'claude' || agentMeta.agentProgram === 'codex') && !agentSession.engineSessionId
|
|
6218
|
+
? sessionId => patchWebAgentSessionState(state.webHistoryDir, sessionRef, { engineSessionId: sessionId })
|
|
6219
|
+
: undefined,
|
|
5762
6220
|
onEvent: handleStreamEvent
|
|
5763
6221
|
});
|
|
5764
6222
|
} finally {
|
|
@@ -6186,6 +6644,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
|
|
|
6186
6644
|
agentName: agentSession.agentName,
|
|
6187
6645
|
agentRemark: agentSession.remark || '',
|
|
6188
6646
|
agentTitle: agentSession.title || '',
|
|
6647
|
+
autoTitle: agentSession.autoTitle || '',
|
|
6648
|
+
recap: agentSession.recap || null,
|
|
6189
6649
|
firstPrompt: agentSession.firstPrompt || '',
|
|
6190
6650
|
pinned: agentSession.pinned === true,
|
|
6191
6651
|
containerRemark: history.remark || '',
|
|
@@ -6198,6 +6658,8 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHist
|
|
|
6198
6658
|
agentProgram: effectiveAgentProgram || '',
|
|
6199
6659
|
resumeSupported: effectiveResumeSupported,
|
|
6200
6660
|
model: agentSession.model || '',
|
|
6661
|
+
effort: agentSession.effort || '',
|
|
6662
|
+
contextUsage: agentSession.contextUsage || null,
|
|
6201
6663
|
hostPath: applied.hostPath || '',
|
|
6202
6664
|
containerPath: applied.containerPath || '',
|
|
6203
6665
|
archived: agentSession.archived === true,
|
|
@@ -8226,9 +8688,22 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8226
8688
|
}
|
|
8227
8689
|
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8228
8690
|
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true });
|
|
8691
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
8692
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(
|
|
8693
|
+
history,
|
|
8694
|
+
sessionRef.agentId,
|
|
8695
|
+
containerInfo.defaultCommand
|
|
8696
|
+
));
|
|
8697
|
+
// 只有 claude 有可透传的斜杠命令;新 AGENT 没跑过一轮也能靠 initialize 目录列出来
|
|
8698
|
+
const catalog = agentProgram === 'claude'
|
|
8699
|
+
? await fetchClaudeCommandCatalogCached(ctx, sessionRef.containerName)
|
|
8700
|
+
: [];
|
|
8229
8701
|
sendJson(res, 200, {
|
|
8230
8702
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8231
|
-
|
|
8703
|
+
agentProgram,
|
|
8704
|
+
commands: agentProgram === 'claude'
|
|
8705
|
+
? sessionMeta.listSlashCommandEntries(agentSession && agentSession.slashCommands, catalog)
|
|
8706
|
+
: []
|
|
8232
8707
|
});
|
|
8233
8708
|
}
|
|
8234
8709
|
},
|
|
@@ -8260,7 +8735,8 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8260
8735
|
sendJson(res, 200, {
|
|
8261
8736
|
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8262
8737
|
agentProgram,
|
|
8263
|
-
|
|
8738
|
+
// 强度列表等内部字段只给服务端用(推理强度选项),不进这个接口的返回
|
|
8739
|
+
models: models.map(({ value, label, description }) => ({ value, label, description }))
|
|
8264
8740
|
});
|
|
8265
8741
|
}
|
|
8266
8742
|
},
|
|
@@ -8308,8 +8784,216 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8308
8784
|
sendJson(res, 400, { error: e.message || String(e) });
|
|
8309
8785
|
return;
|
|
8310
8786
|
}
|
|
8311
|
-
|
|
8312
|
-
|
|
8787
|
+
const patch = { model };
|
|
8788
|
+
// 换模型后,新模型不支持当前强度就回到默认(下次发送不再注入)
|
|
8789
|
+
const before = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8790
|
+
if (before && before.effort) {
|
|
8791
|
+
const levels = await resolveEffortLevels(ctx, state, sessionRef, model);
|
|
8792
|
+
if (levels && !levels.includes(before.effort)) patch.effort = '';
|
|
8793
|
+
}
|
|
8794
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, patch);
|
|
8795
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), model, effort: patch.effort !== undefined ? patch.effort : (before ? before.effort || '' : '') });
|
|
8796
|
+
}
|
|
8797
|
+
},
|
|
8798
|
+
{
|
|
8799
|
+
// 技能与 MCP(只读):容器里装了哪个 Agent 就列哪个。claude 的 skill 来自命令目录,MCP 来自 mcp list(会真实连接,最长 30 秒)
|
|
8800
|
+
method: 'GET',
|
|
8801
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/extensions$/),
|
|
8802
|
+
handler: async match => {
|
|
8803
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8804
|
+
if (!sessionRef) {
|
|
8805
|
+
return;
|
|
8806
|
+
}
|
|
8807
|
+
try {
|
|
8808
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
8809
|
+
} catch (e) {
|
|
8810
|
+
sendJson(res, 400, { error: e && e.message ? e.message : '容器没有运行' });
|
|
8811
|
+
return;
|
|
8812
|
+
}
|
|
8813
|
+
const agents = await readWebExtensions(ctx, sessionRef.containerName);
|
|
8814
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), agents });
|
|
8815
|
+
}
|
|
8816
|
+
},
|
|
8817
|
+
{
|
|
8818
|
+
// 分叉:同容器新建一个 AGENT,带着目标那一轮之前的对话(不给 messageId 就是全部)。文件不复制(同一个工作目录)
|
|
8819
|
+
method: 'POST',
|
|
8820
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/fork$/),
|
|
8821
|
+
handler: async match => {
|
|
8822
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8823
|
+
if (!sessionRef) {
|
|
8824
|
+
return;
|
|
8825
|
+
}
|
|
8826
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8827
|
+
const messageId = payload.messageId === undefined || payload.messageId === null ? '' : String(payload.messageId);
|
|
8828
|
+
const outcome = await forkWebAgentSession(ctx, state, sessionRef, messageId);
|
|
8829
|
+
if (outcome.error) {
|
|
8830
|
+
sendJson(res, outcome.status || 400, { error: outcome.error });
|
|
8831
|
+
return;
|
|
8832
|
+
}
|
|
8833
|
+
sendJson(res, 200, outcome.result);
|
|
8834
|
+
}
|
|
8835
|
+
},
|
|
8836
|
+
{
|
|
8837
|
+
// 侧问:带着上下文问一句,不占运行锁(主任务可以同时在跑)、不写历史、不写控制事件
|
|
8838
|
+
method: 'POST',
|
|
8839
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/side$/),
|
|
8840
|
+
handler: async match => {
|
|
8841
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8842
|
+
if (!sessionRef) {
|
|
8843
|
+
return;
|
|
8844
|
+
}
|
|
8845
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8846
|
+
const question = agentSide.normalizeSideQuestion(payload.question);
|
|
8847
|
+
if (!question) {
|
|
8848
|
+
sendJson(res, 400, { error: '请输入要问的内容(不超过 4000 字)' });
|
|
8849
|
+
return;
|
|
8850
|
+
}
|
|
8851
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8852
|
+
// 还没发过任何消息的 AGENT 没有历史记录:按空会话处理,不要为此创建
|
|
8853
|
+
const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true }) || createEmptyWebAgentSession();
|
|
8854
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
8855
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(history, sessionRef.agentId, containerInfo.defaultCommand));
|
|
8856
|
+
if (agentProgram !== 'claude' && agentProgram !== 'codex') {
|
|
8857
|
+
sendJson(res, 400, { error: '当前 Agent 不支持侧问' });
|
|
8858
|
+
return;
|
|
8859
|
+
}
|
|
8860
|
+
// 刚分叉 / 刚回退、下一轮还没发出去:引擎会话 id 还指着原来的完整会话,带上它会看到分叉点之后 / 已被回退掉的内容
|
|
8861
|
+
if (agentSession.forkPending === true || agentSession.rewindToUuid) {
|
|
8862
|
+
sendJson(res, 409, { error: '刚分叉或回退过,先正常发一条消息再用侧问' });
|
|
8863
|
+
return;
|
|
8864
|
+
}
|
|
8865
|
+
let inFlight = sideQuestionsInFlight.get(ctx);
|
|
8866
|
+
if (!inFlight) {
|
|
8867
|
+
inFlight = new Set();
|
|
8868
|
+
sideQuestionsInFlight.set(ctx, inFlight);
|
|
8869
|
+
}
|
|
8870
|
+
const flightKey = `${sessionRef.containerName}\u0000${sessionRef.agentId}`;
|
|
8871
|
+
if (inFlight.has(flightKey)) {
|
|
8872
|
+
sendJson(res, 409, { error: '上一个侧问还在回答,请稍等' });
|
|
8873
|
+
return;
|
|
8874
|
+
}
|
|
8875
|
+
inFlight.add(flightKey);
|
|
8876
|
+
// 客户端在容器还没就绪时就断开也要算:先登记,拿到终止函数后再补上
|
|
8877
|
+
let abortSide = null;
|
|
8878
|
+
let clientGone = false;
|
|
8879
|
+
res.once('close', () => {
|
|
8880
|
+
if (res.writableEnded) return;
|
|
8881
|
+
clientGone = true;
|
|
8882
|
+
if (abortSide) abortSide();
|
|
8883
|
+
});
|
|
8884
|
+
try {
|
|
8885
|
+
await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
|
|
8886
|
+
if (clientGone) {
|
|
8887
|
+
return;
|
|
8888
|
+
}
|
|
8889
|
+
const outcome = await answerWebSideQuestion(ctx, state, sessionRef, agentProgram, agentSession, question, abort => {
|
|
8890
|
+
abortSide = abort;
|
|
8891
|
+
if (clientGone) abort();
|
|
8892
|
+
});
|
|
8893
|
+
if (outcome.error) {
|
|
8894
|
+
sendJson(res, 502, { error: outcome.error });
|
|
8895
|
+
} else {
|
|
8896
|
+
sendJson(res, 200, { answer: outcome.answer });
|
|
8897
|
+
}
|
|
8898
|
+
} catch (e) {
|
|
8899
|
+
sendJson(res, 500, { error: e && e.message ? e.message : '侧问失败' });
|
|
8900
|
+
} finally {
|
|
8901
|
+
inFlight.delete(flightKey);
|
|
8902
|
+
}
|
|
8903
|
+
}
|
|
8904
|
+
},
|
|
8905
|
+
{
|
|
8906
|
+
// 回顾:隔了很久回来时由前端调用(服务端再校验条件,避免列表接口里悄悄花钱);force 给 /recap 用
|
|
8907
|
+
method: 'POST',
|
|
8908
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/agent\/recap$/),
|
|
8909
|
+
handler: async match => {
|
|
8910
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8911
|
+
if (!sessionRef) {
|
|
8912
|
+
return;
|
|
8913
|
+
}
|
|
8914
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8915
|
+
const readSession = () => getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8916
|
+
const agentSession = readSession();
|
|
8917
|
+
const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
|
|
8918
|
+
const containerInfo = listWebManyoyoContainers(ctx)[sessionRef.containerName] || {};
|
|
8919
|
+
const agentProgram = resolveAgentProgram(resolveEffectiveAgentPromptCommandForSession(history, sessionRef.agentId, containerInfo.defaultCommand));
|
|
8920
|
+
const respond = () => {
|
|
8921
|
+
const current = readSession();
|
|
8922
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), recap: current && current.recap ? current.recap : null });
|
|
8923
|
+
};
|
|
8924
|
+
if (!agentSession || (agentProgram !== 'claude' && agentProgram !== 'codex') || state.agentRuns.has(sessionRef.containerName)) {
|
|
8925
|
+
respond();
|
|
8926
|
+
return;
|
|
8927
|
+
}
|
|
8928
|
+
const target = webRecap.shouldGenerateRecap(agentSession, ctx.nowMs(), payload.force === true);
|
|
8929
|
+
if (!target) {
|
|
8930
|
+
respond();
|
|
8931
|
+
return;
|
|
8932
|
+
}
|
|
8933
|
+
const text = await runWebLightCall(ctx, state, sessionRef, agentProgram, 'recap', agentLight.RECAP_RULE, agentLight.buildRecapInput(target.promptText, target.replyText), agentLight.RECAP_MAX_CHARS);
|
|
8934
|
+
if (text) {
|
|
8935
|
+
// 生成期间可能又发了新一轮:回复已经不是最后一条就丢掉
|
|
8936
|
+
const fresh = readSession();
|
|
8937
|
+
const latest = fresh ? webRecap.findRecapTarget(fresh.messages) : null;
|
|
8938
|
+
if (latest && latest.replyId === target.replyId) {
|
|
8939
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { recap: { forMessageId: target.replyId, text, createdAt: new Date().toISOString() } });
|
|
8940
|
+
}
|
|
8941
|
+
}
|
|
8942
|
+
respond();
|
|
8943
|
+
}
|
|
8944
|
+
},
|
|
8945
|
+
{
|
|
8946
|
+
// 推理强度选项:来自 Agent 的模型目录(claude 的 initialize、codex 的 model/list,10 分钟缓存),找不到就是空数组(界面不显示)
|
|
8947
|
+
method: 'GET',
|
|
8948
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/effort$/),
|
|
8949
|
+
handler: async match => {
|
|
8950
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8951
|
+
if (!sessionRef) {
|
|
8952
|
+
return;
|
|
8953
|
+
}
|
|
8954
|
+
const agentSession = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8955
|
+
const levels = (await resolveEffortLevels(ctx, state, sessionRef, agentSession ? agentSession.model : '')) || [];
|
|
8956
|
+
let envDefault = '';
|
|
8957
|
+
try {
|
|
8958
|
+
const env = readMergedEnv({ homeDir: ctx.homeDir || os.homedir(), dockerExecArgs: ctx.dockerExecArgs }, sessionRef.containerName);
|
|
8959
|
+
envDefault = agentEffort.isEffortValue(env.CLAUDE_CODE_EFFORT_LEVEL) ? env.CLAUDE_CODE_EFFORT_LEVEL : '';
|
|
8960
|
+
} catch (e) { /* 读不到 env 就不提示 */ }
|
|
8961
|
+
sendJson(res, 200, {
|
|
8962
|
+
name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
|
|
8963
|
+
levels,
|
|
8964
|
+
effort: agentSession && levels.includes(agentSession.effort) ? agentSession.effort : '',
|
|
8965
|
+
envDefault
|
|
8966
|
+
});
|
|
8967
|
+
}
|
|
8968
|
+
},
|
|
8969
|
+
{
|
|
8970
|
+
method: 'POST',
|
|
8971
|
+
match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/effort$/),
|
|
8972
|
+
handler: async match => {
|
|
8973
|
+
const sessionRef = getValidSessionRef(ctx, res, match[1]);
|
|
8974
|
+
if (!sessionRef) {
|
|
8975
|
+
return;
|
|
8976
|
+
}
|
|
8977
|
+
const payload = toPlainObject(await readJsonBody(req));
|
|
8978
|
+
const effort = payload.effort === undefined || payload.effort === null ? '' : payload.effort;
|
|
8979
|
+
if (effort !== '' && !agentEffort.isEffortValue(effort)) {
|
|
8980
|
+
sendJson(res, 400, { error: '推理强度不合法' });
|
|
8981
|
+
return;
|
|
8982
|
+
}
|
|
8983
|
+
if (effort !== '') {
|
|
8984
|
+
const agentSession = getWebAgentSession(loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName), sessionRef.agentId, { includeArchived: true });
|
|
8985
|
+
const levels = await resolveEffortLevels(ctx, state, sessionRef, agentSession ? agentSession.model : '');
|
|
8986
|
+
if (levels === null) {
|
|
8987
|
+
sendJson(res, 409, { error: '暂时取不到当前模型支持的推理强度(容器没有运行?),稍后再试' });
|
|
8988
|
+
return;
|
|
8989
|
+
}
|
|
8990
|
+
if (!levels.includes(effort)) {
|
|
8991
|
+
sendJson(res, 400, { error: '当前模型不支持这个推理强度' });
|
|
8992
|
+
return;
|
|
8993
|
+
}
|
|
8994
|
+
}
|
|
8995
|
+
patchWebAgentSessionState(state.webHistoryDir, sessionRef, { effort });
|
|
8996
|
+
sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), effort });
|
|
8313
8997
|
}
|
|
8314
8998
|
},
|
|
8315
8999
|
{
|
|
@@ -8468,6 +9152,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8468
9152
|
...(agentClientId.value ? { clientMessageId: agentClientId.value } : {})
|
|
8469
9153
|
});
|
|
8470
9154
|
releaseAgentClaim();
|
|
9155
|
+
void maybeStartAutoTitle(ctx, state, sessionRef, agentMeta.agentProgram, prompt);
|
|
8471
9156
|
const result = await execCommandInWebContainer(ctx, sessionRef.containerName, command, {
|
|
8472
9157
|
agentProgram: agentMeta.agentProgram
|
|
8473
9158
|
});
|
|
@@ -8518,7 +9203,9 @@ async function handleWebApi(req, res, pathname, ctx, state) {
|
|
|
8518
9203
|
if (rejectDuplicateClientMessage(res, state.webHistoryDir, sessionRef, streamClientId.value, { includeQueue: true })) {
|
|
8519
9204
|
return;
|
|
8520
9205
|
}
|
|
8521
|
-
|
|
9206
|
+
// 计划模式只对 claude 有意义;其它取值一律当没选
|
|
9207
|
+
const turnOptions = payload.permissionMode === 'plan' ? { permissionMode: 'plan' } : {};
|
|
9208
|
+
const turn = await runWebAgentStreamTurn(ctx, state, sessionRef, prompt, streamClientId.value, res, streamImages, turnOptions);
|
|
8522
9209
|
if (turn.busy) {
|
|
8523
9210
|
sendJson(res, 409, { error: '当前容器已有运行中的 agent 任务,请等它结束或先停止' });
|
|
8524
9211
|
} else if (turn.error) {
|
|
@@ -8861,6 +9548,8 @@ async function startWebServer(options) {
|
|
|
8861
9548
|
dockerExecArgs: options.dockerExecArgs,
|
|
8862
9549
|
dockerExecAsync: options.dockerExecAsync,
|
|
8863
9550
|
nowMs: typeof options.nowMs === 'function' ? options.nowMs : Date.now,
|
|
9551
|
+
// 自动标题(第一句话发出后的一次轻量模型调用);测试里按“最后一次 exec 就是 Agent 那一轮”断言时可关掉
|
|
9552
|
+
autoTitle: options.autoTitle !== false,
|
|
8864
9553
|
retryRuntimeHeal: options.retryRuntimeHeal,
|
|
8865
9554
|
showImagePullHint: options.showImagePullHint,
|
|
8866
9555
|
removeContainer: options.removeContainer,
|
|
@@ -8977,6 +9666,11 @@ async function startWebServer(options) {
|
|
|
8977
9666
|
sendJson(res, 409, { error: e.message, imagePreparing: true });
|
|
8978
9667
|
return;
|
|
8979
9668
|
}
|
|
9669
|
+
// 镜像不存在 / 拉不到:附上结构化信息,网页用弹窗告诉用户怎么办
|
|
9670
|
+
if (e && e.imageProblem) {
|
|
9671
|
+
sendJson(res, 409, { error: e.message, imageProblem: e.imageProblem });
|
|
9672
|
+
return;
|
|
9673
|
+
}
|
|
8980
9674
|
ctx.logger.error('http request error', {
|
|
8981
9675
|
method: req && req.method ? req.method : '',
|
|
8982
9676
|
url: redactLoginTokenInUrl(req && req.url ? req.url : ''),
|
|
@@ -9179,6 +9873,10 @@ module.exports = {
|
|
|
9179
9873
|
__testing: {
|
|
9180
9874
|
prepareStructuredTraceEvents,
|
|
9181
9875
|
buildClaudeAgentExecCommand,
|
|
9876
|
+
buildCodexAgentExecCommand,
|
|
9877
|
+
extractEarlyEngineSessionId,
|
|
9878
|
+
finalizeWebAgentExecution,
|
|
9879
|
+
patchWebAgentSessionState,
|
|
9182
9880
|
buildWebAgentExecCommand,
|
|
9183
9881
|
buildContainerFileListCommand,
|
|
9184
9882
|
extractContentDeltaFromPayload,
|