@aalis/plugin-agent 0.15.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/helpers.d.ts +59 -26
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +150 -78
- package/dist/helpers.js.map +1 -1
- package/dist/index.d.ts +18 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1317 -1350
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +58 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +58 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/prompt-assembly.d.ts +6 -6
- package/dist/prompt-assembly.d.ts.map +1 -1
- package/dist/prompt-assembly.js +36 -21
- package/dist/prompt-assembly.js.map +1 -1
- package/dist/session-queue.d.ts +86 -0
- package/dist/session-queue.d.ts.map +1 -0
- package/dist/session-queue.js +194 -0
- package/dist/session-queue.js.map +1 -0
- package/dist/texts.d.ts +115 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +62 -0
- package/dist/texts.js.map +1 -0
- package/package.json +28 -21
package/dist/index.js
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { agent as agentService, } from '@aalis/api-agent';
|
|
3
|
+
import { authority as authorityService, originTurnOf } from '@aalis/api-authority';
|
|
3
4
|
import { commands as commandsService } from '@aalis/api-commands';
|
|
4
5
|
import { contributions } from '@aalis/api-contributions';
|
|
5
6
|
import { gateway as gatewayService } from '@aalis/api-gateway';
|
|
6
7
|
import { hooks } from '@aalis/api-hooks';
|
|
7
|
-
import {
|
|
8
|
+
import { ownHostConfig } from '@aalis/api-host-config';
|
|
9
|
+
import { estimateMsgTokens, estimateTextTokens, LLMCapabilities, listLLMModels, llm as llmService, MODEL_REF_TEXT, resolveLLMModel, } from '@aalis/api-llm';
|
|
8
10
|
import { media as mediaService } from '@aalis/api-media';
|
|
9
11
|
import { memory as memoryService } from '@aalis/api-memory';
|
|
10
|
-
import { messageArchive as messageArchiveService } from '@aalis/api-message-archive';
|
|
12
|
+
import { messageArchive as messageArchiveService, speakerOf } from '@aalis/api-message-archive';
|
|
11
13
|
import { persona as personaService } from '@aalis/api-persona';
|
|
12
14
|
import { getPlatformSelfIdentity, platform as platformService } from '@aalis/api-platform';
|
|
13
|
-
import { sessionManager as sessionManagerService } from '@aalis/api-session-manager';
|
|
14
|
-
import {
|
|
15
|
+
import { originChatOf, rootSessionOf, sessionManager as sessionManagerService } from '@aalis/api-session-manager';
|
|
16
|
+
import { sessionFields as sessionFieldsService } from '@aalis/api-session-settings';
|
|
17
|
+
import { isStorageUri, storage as storageService } from '@aalis/api-storage';
|
|
15
18
|
import { tools as toolsService, } from '@aalis/api-tools';
|
|
16
19
|
import { config as configCap, definePlugin, events, lifecycle, logger as loggerCap, optional, pluginsService, provide, services, } from '@aalis/core';
|
|
17
20
|
import { defineConfig, parseConfig } from '@aalis/schema-config';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
+
import { textLines, textOf, toSource } from '@aalis/schema-i18n';
|
|
22
|
+
import { arrivalTag, buildIncomingContent, CONTROL_KINDS, DIRECTIVE_KINDS, getMessageName, getSenderLabel, groupToolCalls, WellKnownKinds, WellKnownMetadataKeys, wasAddressed, } from '@aalis/schema-message';
|
|
23
|
+
import { stripLeakedSpecialTokens, truncateChars } from '@aalis/util-text-normalize';
|
|
24
|
+
import { buildFocusGuidance, describeLLMFailure, estimateHistoryTokens, estimateTokens, formatTimeLabel, formatToolArgsForLog, hasAttachmentDescriptions, historyForModel, INPUT_CONVENTIONS, isSameMessage, parseToolArguments, queuedNote, readHistory, stoppedNote, } from './helpers.js';
|
|
21
25
|
import { assemblePromptContributions, VOLATILE_INJECTOR } from './prompt-assembly.js';
|
|
26
|
+
import { SessionQueues } from './session-queue.js';
|
|
27
|
+
import { t } from './texts.js';
|
|
22
28
|
// ===== 能力声明 =====
|
|
23
29
|
//
|
|
24
30
|
// 对话编排要碰的东西全在这里:没写进 uses 的,插件碰不到。全部 optional——少一样能力
|
|
@@ -32,7 +38,6 @@ const uses = {
|
|
|
32
38
|
contributions,
|
|
33
39
|
lifecycle,
|
|
34
40
|
provide,
|
|
35
|
-
services,
|
|
36
41
|
commands: optional(commandsService),
|
|
37
42
|
tools: optional(toolsService),
|
|
38
43
|
llm: optional(llmService),
|
|
@@ -40,11 +45,19 @@ const uses = {
|
|
|
40
45
|
persona: optional(personaService),
|
|
41
46
|
messageArchive: optional(messageArchiveService),
|
|
42
47
|
sessionManager: optional(sessionManagerService),
|
|
48
|
+
/** 判谁是 owner:参与者里合一 owner 的各个身份、存档不在时当轮正文的发送者前缀(与存档同一规则,speakerOf)、/stop */
|
|
49
|
+
authority: optional(authorityService),
|
|
50
|
+
/** 按会话读本插件的四项(模型、深度思考、最大工具迭代、历史条数上限);缺席时只取插件配置里的值 */
|
|
51
|
+
sessionFields: optional(sessionFieldsService),
|
|
52
|
+
/** 按会话字段的全局层:宿主配置文档里本实例的那一段 */
|
|
53
|
+
document: optional(ownHostConfig),
|
|
43
54
|
platform: optional(platformService),
|
|
44
55
|
media: optional(mediaService),
|
|
45
56
|
storage: optional(storageService),
|
|
46
57
|
gateway: optional(gatewayService),
|
|
47
58
|
plugins: optional(pluginsService),
|
|
59
|
+
/** 会话没设模型时比对本插件该生效的 llm 偏好与实际用的模型 */
|
|
60
|
+
services,
|
|
48
61
|
};
|
|
49
62
|
/**
|
|
50
63
|
* 默认 Agent 实现 —— 对话编排器
|
|
@@ -61,36 +74,27 @@ const uses = {
|
|
|
61
74
|
*/
|
|
62
75
|
/** 统一读取 LLM 单次最大输出 token;缺省回退 4096(与各 adapter 默认一致)。 */
|
|
63
76
|
const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
|
77
|
+
// 宿主通知的公开链接须在最终正文保留;同时识别普通文本和 Markdown 链接的边界。
|
|
78
|
+
const NOTICE_URL = /https?:\/\/[^\s<>()[\]"'`*“”‘’,。!?;、]+/gu;
|
|
64
79
|
function getModelMaxOutput(llm) {
|
|
65
80
|
return llm.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
66
81
|
}
|
|
82
|
+
/** 条目固定的那个模型写成模型引用 */
|
|
83
|
+
const refOf = (entry) => ({ provider: entry.use.providerId, model: entry.use.id });
|
|
67
84
|
class DefaultAgent {
|
|
85
|
+
instanceId;
|
|
68
86
|
caps;
|
|
69
87
|
logger;
|
|
70
88
|
systemPrompt;
|
|
71
89
|
memoryTokenBudget;
|
|
72
|
-
historyLimit;
|
|
73
|
-
maxToolIterations;
|
|
74
90
|
/** 单个 agent:prompt 贡献 build 的等待上限(毫秒;0=不设限)——见 prompt-assembly 的护栏说明 */
|
|
75
91
|
promptBuildTimeoutMs;
|
|
76
92
|
/** 单条工具结果占上下文窗口的最大比例 (0~1),超出则截断 */
|
|
77
93
|
toolResultMaxRatio;
|
|
78
|
-
/**
|
|
94
|
+
/** 裁剪触发比例 (0~1):裁剪预算按上下文长度乘它再扣输出预留,见 computeTokenBudget */
|
|
79
95
|
trimThresholdRatio;
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
* key = `${sessionId}::${source}` — 同一 session 不同来源(user / scheduler)
|
|
84
|
-
* 独立管理,互不打断;同来源新消息会中止旧的生成。
|
|
85
|
-
*/
|
|
86
|
-
activeControllers = new Map();
|
|
87
|
-
/**
|
|
88
|
-
* 在飞回合 Promise。关停时 abort 之后等它们以 AbortError(或正常完成)收尾,
|
|
89
|
-
* 而不是等 LLM 跑完——收尾里的钩子 / 出站事件还要用 memory。
|
|
90
|
-
*/
|
|
91
|
-
inflightTurns = new Set();
|
|
92
|
-
/** 同一 lane 的入站消息归档串行化,避免连续消息读取历史时漏掉前一条输入。 */
|
|
93
|
-
archiveQueues = new Map();
|
|
96
|
+
/** 各会话的通道:在跑的一轮、待插入与排队的输入(见 session-queue.ts)。停机由 run() 直接经它停,清消息历史经 clearing */
|
|
97
|
+
queues;
|
|
94
98
|
/** 每次激活只在实际写入缺少归档时告警一次,不因插件加载顺序误报。 */
|
|
95
99
|
archiveUnavailableWarned = false;
|
|
96
100
|
/**
|
|
@@ -100,55 +104,75 @@ class DefaultAgent {
|
|
|
100
104
|
* 与 token:usage 事件共用同一份 breakdown 数据,零额外计算。
|
|
101
105
|
*/
|
|
102
106
|
tokenLogState = new Map();
|
|
107
|
+
/** 已告警过的(llm 服务的偏好,实际用的模型)组合 */
|
|
108
|
+
preferenceFallbacks = new Set();
|
|
103
109
|
/** 已注册的预处理器(name → { dispose }) */
|
|
104
110
|
preprocessors = new Map();
|
|
105
|
-
|
|
111
|
+
/** 本插件的按会话字段:每一轮按会话现读;模型没设时的 actual 是服务此刻为本插件挑的那一个 */
|
|
112
|
+
fields;
|
|
113
|
+
/** 判一个身份是不是 owner:工具调用上下文里合一 owner 的各个身份、/stop 都用它;权限服务不在时没有人是 owner */
|
|
114
|
+
isOwner = (platform, userId) => this.caps.authority.present && this.caps.authority.use.isOwner(platform, userId);
|
|
115
|
+
constructor(caps, cfg,
|
|
116
|
+
/** 本插件实例 id:读它的本插件偏好 */
|
|
117
|
+
instanceId) {
|
|
118
|
+
this.instanceId = instanceId;
|
|
106
119
|
this.caps = caps;
|
|
107
|
-
this.
|
|
120
|
+
this.queues = new SessionQueues((sessionId, waiting) => {
|
|
121
|
+
void caps.events.use.emit('agent:queue', { sessionId, waiting });
|
|
122
|
+
});
|
|
123
|
+
this.fields = caps.sessionFields.use.fields(configSchema, caps, {
|
|
124
|
+
llm: () => {
|
|
125
|
+
const entry = this.resolveLLM(undefined);
|
|
126
|
+
return entry && { value: refOf(entry) };
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
this.logger = caps.logger.use.child('agent');
|
|
108
130
|
this.systemPrompt = cfg.systemPrompt;
|
|
109
131
|
this.memoryTokenBudget = cfg.memoryTokenBudget;
|
|
110
|
-
this.historyLimit = cfg.historyLimit;
|
|
111
|
-
this.maxToolIterations = cfg.maxToolIterations;
|
|
112
132
|
this.promptBuildTimeoutMs = cfg.promptBuildTimeoutMs;
|
|
113
133
|
this.toolResultMaxRatio = cfg.toolResultMaxRatio;
|
|
114
134
|
this.trimThresholdRatio = cfg.trimThresholdRatio;
|
|
115
135
|
this.logger.info('默认对话代理已初始化');
|
|
116
136
|
}
|
|
117
137
|
/**
|
|
118
|
-
*
|
|
138
|
+
* 解析会话该用哪个 LLMModel entry:ref 是这个会话的「模型」(按会话字段 llm 的生效值)。
|
|
119
139
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* 3. 平台 profile
|
|
124
|
-
* 4. ServicePreference(由 agent 启动时锁定的全局默认 entry,未传 ref 时生效)
|
|
140
|
+
* 「模型」的全局层是本插件的 llm 偏好,哪一层都没设时不给值,由 resolveLLMModel 落到本插件此刻选中的那一个(本插件偏好 >
|
|
141
|
+
* 全局偏好 > 提供者优先级 > 登记先后)。这时该生效的偏好(设了本插件偏好就是它,否则是全局偏好)指向的模型不在(模型删了、
|
|
142
|
+
* 提供者在重载或激活失败),服务落到别的模型上:照常用它,每个(偏好,实际)组合告警一次。
|
|
125
143
|
*
|
|
126
|
-
* 返回
|
|
144
|
+
* 返回 llm 的提供者条目(id 与这一家的业务句柄 use),条目固定那一个模型。
|
|
127
145
|
*/
|
|
128
|
-
|
|
129
|
-
let ref;
|
|
130
|
-
// session-manager 一步到位:会话 > 父 sessionDefaults > platform profile
|
|
131
|
-
const sm = this.caps.sessionManager.current;
|
|
132
|
-
if (sm && sessionId) {
|
|
133
|
-
const resolved = sm.resolveConfig(sessionId, platform);
|
|
134
|
-
if (resolved.llm?.provider && resolved.llm?.model)
|
|
135
|
-
ref = resolved.llm;
|
|
136
|
-
}
|
|
146
|
+
resolveLLM(ref) {
|
|
137
147
|
// 解析为具体 LLMModel entry(要求至少 chat 能力;ref 为空时走 ServicePreference / 优先级)
|
|
138
|
-
|
|
148
|
+
const entry = resolveLLMModel(this.caps.llm, ref, ['chat']);
|
|
149
|
+
if (ref || !entry)
|
|
150
|
+
return entry;
|
|
151
|
+
const own = this.caps.services.use.preferred(llmService, { for: this.instanceId });
|
|
152
|
+
const preferred = own ?? this.caps.services.use.preferred(llmService);
|
|
153
|
+
if (preferred !== undefined && entry.id !== preferred) {
|
|
154
|
+
const combo = `${preferred}\n${entry.id}`;
|
|
155
|
+
if (!this.preferenceFallbacks.has(combo)) {
|
|
156
|
+
this.preferenceFallbacks.add(combo);
|
|
157
|
+
this.logger.warn(`llm 服务的${own === undefined ? '' : '本插件'}偏好 ${preferred} 没有生效(指向的模型不在、提供者在重载或激活失败),实际用的是 ${entry.id}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return entry;
|
|
139
161
|
}
|
|
140
162
|
/** 解析失败时说清**为什么**(取数在此,措辞在 helpers 的 describeLLMFailure,纯函数便于单测)。 */
|
|
141
|
-
explainLLMFailure(
|
|
142
|
-
const available = listLLMModels(this.caps.llm, { caps: ['chat'] }).map(e => e.
|
|
143
|
-
const sm = this.caps.sessionManager.current;
|
|
144
|
-
const wanted = sm && sessionId ? sm.resolveConfig(sessionId, platform).llm : undefined;
|
|
163
|
+
explainLLMFailure(wanted) {
|
|
164
|
+
const available = listLLMModels(this.caps.llm, { caps: ['chat'] }).map(e => e.id);
|
|
145
165
|
return describeLLMFailure(available, wanted, this.erroredLLMPlugins());
|
|
146
166
|
}
|
|
167
|
+
/** 把这个会话本会话层的「模型」设成 model:经读取器写本插件的字段,会话设置不在时抛错 */
|
|
168
|
+
async assignModel(sessionId, model) {
|
|
169
|
+
await this.fields.set(sessionId, 'llm', model);
|
|
170
|
+
}
|
|
147
171
|
/** 激活失败且声明提供 llm 的插件实例名——「一个模型都没有」时,病因通常就在它们身上(多为缺 apiKey)。 */
|
|
148
172
|
erroredLLMPlugins() {
|
|
149
|
-
|
|
150
|
-
if (!pm)
|
|
173
|
+
if (!this.caps.plugins.present)
|
|
151
174
|
return [];
|
|
175
|
+
const pm = this.caps.plugins.use;
|
|
152
176
|
// 只取实例名:这串会拼进**发到聊天**(含群聊)的文案里,error 原文可能带内网地址与端口等部署细节,
|
|
153
177
|
// 原因留在日志、WebUI 与 /doctor
|
|
154
178
|
return pm
|
|
@@ -156,51 +180,33 @@ class DefaultAgent {
|
|
|
156
180
|
.filter(p => p.state === 'error' && p.provides?.includes('llm'))
|
|
157
181
|
.map(p => p.instanceId);
|
|
158
182
|
}
|
|
159
|
-
/** 生成 lane key:同 session + 同 source 共用一个 lane */ laneKey(sessionId, source) {
|
|
160
|
-
return `${sessionId}::${source ?? 'user'}`;
|
|
161
|
-
}
|
|
162
183
|
/**
|
|
163
|
-
*
|
|
184
|
+
* 停下这个会话在跑的一轮;queued 为真时连同排队与待插入的一起放掉,放掉的在那一轮落定之后归档(不带回合 id);by 是停下它的
|
|
185
|
+
* 人,写进停下标记。禁言、全部停止、停止键与 /stop 经这里
|
|
164
186
|
*/
|
|
165
|
-
abort(sessionId) {
|
|
166
|
-
|
|
167
|
-
if (key.startsWith(`${sessionId}::`)) {
|
|
168
|
-
controller.abort();
|
|
169
|
-
this.activeControllers.delete(key);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
this.logger.info(`生成已中止: session=${sessionId}`);
|
|
187
|
+
abort(sessionId, options = {}) {
|
|
188
|
+
return this.queues.stop(sessionId, { ...options, archive: true });
|
|
173
189
|
}
|
|
174
190
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* activeControllers 是实例私有的,bounce 后新实例看不见旧实例的在飞回合。
|
|
178
|
-
* abort 走已有的 AbortError 收尾(outbound:stream done + turn:after outcome='aborted'),
|
|
179
|
-
* 与 WebUI「停止生成」同语义。关停在 onDrain 里先调本方法再等回合 Promise;
|
|
180
|
-
* onDispose 再调一次作兜底(已中止的 controller 不在表里,是空操作)。
|
|
191
|
+
* 清消息历史:先按住这些会话,再停下在跑的一轮、放掉排着与待插入的(不归档),等它落定再跑 clear。被停的一轮落定之前还会写
|
|
192
|
+
* 工具调用组,先停后清,清空的历史里才不留它的写入。按住期间要交给通道的输入放开再交
|
|
181
193
|
*/
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
async clearing(sessionIds, clear, reason = '清消息历史') {
|
|
195
|
+
const release = this.queues.hold(sessionIds);
|
|
196
|
+
try {
|
|
197
|
+
await this.queues.stopAll(sessionIds ?? this.queues.sessions(), { archive: false, reason });
|
|
198
|
+
await clear();
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
release();
|
|
188
202
|
}
|
|
189
|
-
if (n > 0)
|
|
190
|
-
this.logger.info(`拆卸:已中止 ${n} 个在飞回合`);
|
|
191
203
|
}
|
|
192
204
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
205
|
+
* 会话删除(session:deleted):停掉这个会话在跑的一轮、放掉排着的(不归档)并等它收尾(在 clearing 之内时它已经停过):没有
|
|
206
|
+
* 上限,不响应中止的工具会让它一直等到工具返回。再丢弃它的 token 日志节流状态
|
|
195
207
|
*/
|
|
196
|
-
async
|
|
197
|
-
this.
|
|
198
|
-
if (this.inflightTurns.size === 0)
|
|
199
|
-
return;
|
|
200
|
-
await Promise.allSettled([...this.inflightTurns]);
|
|
201
|
-
}
|
|
202
|
-
/** 会话删除时丢弃其节流日志状态(订阅见 run() 的 session:deleted)。 */
|
|
203
|
-
dropTokenLogState(sessionId) {
|
|
208
|
+
async sessionDeleted(sessionId) {
|
|
209
|
+
await this.queues.stopAll([sessionId], { archive: false, reason: '会话删除' });
|
|
204
210
|
this.tokenLogState.delete(sessionId);
|
|
205
211
|
}
|
|
206
212
|
/**
|
|
@@ -213,7 +219,7 @@ class DefaultAgent {
|
|
|
213
219
|
registerPreprocessor(name, handler) {
|
|
214
220
|
// 同名替换
|
|
215
221
|
this.preprocessors.get(name)?.dispose();
|
|
216
|
-
const dispose = this.caps.hooks.middleware('agent:input:before', async (data, next) => {
|
|
222
|
+
const dispose = this.caps.hooks.use.middleware('agent:input:before', async (data, next) => {
|
|
217
223
|
await handler(data.message, next);
|
|
218
224
|
});
|
|
219
225
|
// 退订按条目引用比对:同名重注册后,旧退订闭包只摘自己的中间件,不动新登记的账目
|
|
@@ -230,39 +236,17 @@ class DefaultAgent {
|
|
|
230
236
|
this.logger.info(`预处理器已注册: ${name}`);
|
|
231
237
|
return entry.dispose;
|
|
232
238
|
}
|
|
233
|
-
/**
|
|
234
|
-
* 获取当前所有已注册预处理器的元信息(按注册顺序返回)
|
|
235
|
-
*/
|
|
236
|
-
getPreprocessors() {
|
|
237
|
-
return [...this.preprocessors.keys()].map(name => ({ name }));
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* 获取 Agent 子系统的插件分组
|
|
241
|
-
*
|
|
242
|
-
* 仅纳入 Agent 直接依赖的能力提供者;不包含 `platform`
|
|
243
|
-
* (平台属于独立子系统,由 api-platform 的 helper 负责)。
|
|
244
|
-
*/
|
|
245
|
-
getPluginGroups() {
|
|
246
|
-
const pm = this.caps.plugins.current;
|
|
247
|
-
if (!pm)
|
|
248
|
-
return [];
|
|
249
|
-
// 子系统归属:Agent 域的服务(不含 platform——平台是独立子系统)
|
|
250
|
-
const targetServices = new Set(['llm', 'memory', 'persona', 'message-archive']);
|
|
251
|
-
const grouped = [];
|
|
252
|
-
for (const p of pm.getStatus()) {
|
|
253
|
-
if (p.provides?.some(s => targetServices.has(s))) {
|
|
254
|
-
grouped.push(p.instanceId);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return [{ label: 'Agent', plugins: grouped }];
|
|
258
|
-
}
|
|
259
239
|
/**
|
|
260
240
|
* 消费流式 LLM 调用,累积完整响应,同时向前端推送增量事件。
|
|
261
241
|
*
|
|
262
242
|
* 同时构建本轮调用的 segments 时间线(按 chunk 到达顺序记录 text / reasoning_text,
|
|
263
243
|
* 相邻同类合并)——用于上层将多轮调用 + 工具执行交错拼接成统一时间线。
|
|
244
|
+
*
|
|
245
|
+
* 工具调用的生成进度按调用的序号各配一个 callId 随进度推出,界面在参数生成期间就画出它的工具卡。generating 按序号
|
|
246
|
+
* 记下 callId 与工具名(provider 按序号组装 toolCalls,序号即调用在 toolCalls 里的位置);没报过进度的调用没有。
|
|
264
247
|
*/
|
|
265
|
-
async consumeStream(llm, request, sessionId, platform) {
|
|
248
|
+
async consumeStream(llm, request, turnId, sessionId, platform, emitProgress = true) {
|
|
249
|
+
const generating = new Map();
|
|
266
250
|
let content = '';
|
|
267
251
|
let reasoningContent = '';
|
|
268
252
|
let toolCalls;
|
|
@@ -277,7 +261,7 @@ class DefaultAgent {
|
|
|
277
261
|
segments.push({ type: kind, content: delta });
|
|
278
262
|
}
|
|
279
263
|
};
|
|
280
|
-
for await (const chunk of llm.chatStream(request)) {
|
|
264
|
+
for await (const chunk of llm.chatStream({ ...request, sessionId, purpose: this.purposeOf(sessionId) })) {
|
|
281
265
|
// 检查中止信号
|
|
282
266
|
if (request.signal?.aborted) {
|
|
283
267
|
throw new DOMException('Generation aborted', 'AbortError');
|
|
@@ -285,26 +269,34 @@ class DefaultAgent {
|
|
|
285
269
|
if (chunk.contentDelta) {
|
|
286
270
|
content += chunk.contentDelta;
|
|
287
271
|
appendDelta('text', chunk.contentDelta);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
272
|
+
if (emitProgress)
|
|
273
|
+
await this.caps.events.use.emit('outbound:stream', {
|
|
274
|
+
sessionId,
|
|
275
|
+
turnId,
|
|
276
|
+
platform,
|
|
277
|
+
contentDelta: chunk.contentDelta,
|
|
278
|
+
});
|
|
293
279
|
}
|
|
294
280
|
if (chunk.reasoningDelta) {
|
|
295
281
|
reasoningContent += chunk.reasoningDelta;
|
|
296
282
|
appendDelta('reasoning_text', chunk.reasoningDelta);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
283
|
+
if (emitProgress)
|
|
284
|
+
await this.caps.events.use.emit('outbound:stream', {
|
|
285
|
+
sessionId,
|
|
286
|
+
turnId,
|
|
287
|
+
platform,
|
|
288
|
+
reasoningDelta: chunk.reasoningDelta,
|
|
289
|
+
});
|
|
302
290
|
}
|
|
303
|
-
if (chunk.toolCallProgress) {
|
|
304
|
-
|
|
291
|
+
if (emitProgress && chunk.toolCallProgress) {
|
|
292
|
+
const { index, name } = chunk.toolCallProgress;
|
|
293
|
+
const callId = generating.get(index)?.callId ?? crypto.randomUUID();
|
|
294
|
+
generating.set(index, { callId, name });
|
|
295
|
+
await this.caps.events.use.emit('outbound:stream', {
|
|
305
296
|
sessionId,
|
|
297
|
+
turnId,
|
|
306
298
|
platform,
|
|
307
|
-
toolCallProgress: chunk.toolCallProgress,
|
|
299
|
+
toolCallProgress: { ...chunk.toolCallProgress, callId },
|
|
308
300
|
});
|
|
309
301
|
}
|
|
310
302
|
if (chunk.done) {
|
|
@@ -314,23 +306,33 @@ class DefaultAgent {
|
|
|
314
306
|
usage = chunk.usage;
|
|
315
307
|
}
|
|
316
308
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
this.caps.logger.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
|
|
323
|
-
}
|
|
324
|
-
finalContent = normalizeAssistantContent(content);
|
|
309
|
+
// 经 OpenAI 兼容接口接入时,服务端漏进正文的工具调用标记只有这一层会剥:只剥不执行,落库的正文与时间线都过它。
|
|
310
|
+
// 时间线的正文段是 content 的切片,剥空的段不留
|
|
311
|
+
const { sanitized, hadLeak } = stripLeakedSpecialTokens(content);
|
|
312
|
+
if (hadLeak) {
|
|
313
|
+
this.caps.logger.use.warn(`agent: 检测到 LLM 内容残留 DSML 标记,已剥离(session=${sessionId} platform=${platform} 原长=${content.length} 净化后=${sanitized.length})`);
|
|
325
314
|
}
|
|
326
315
|
return {
|
|
327
|
-
content:
|
|
316
|
+
content: sanitized,
|
|
328
317
|
reasoningContent: reasoningContent || undefined,
|
|
329
318
|
toolCalls,
|
|
330
319
|
usage,
|
|
331
|
-
segments
|
|
320
|
+
segments: segments.flatMap((segment) => {
|
|
321
|
+
if (segment.type !== 'text')
|
|
322
|
+
return [segment];
|
|
323
|
+
const text = stripLeakedSpecialTokens(segment.content).sanitized;
|
|
324
|
+
return text ? [{ ...segment, content: text }] : [];
|
|
325
|
+
}),
|
|
326
|
+
generating,
|
|
332
327
|
};
|
|
333
328
|
}
|
|
329
|
+
/** 记账的用途:子会话里的回合是子任务,其余是对话 */
|
|
330
|
+
purposeOf(sessionId) {
|
|
331
|
+
return (this.caps.sessionManager.present ? this.caps.sessionManager.use.getSession(sessionId)?.kind : undefined) ===
|
|
332
|
+
'task'
|
|
333
|
+
? '子任务'
|
|
334
|
+
: '对话';
|
|
335
|
+
}
|
|
334
336
|
/**
|
|
335
337
|
* Debug 模式下格式化输出 LLM 响应详情
|
|
336
338
|
*/
|
|
@@ -434,672 +436,782 @@ class DefaultAgent {
|
|
|
434
436
|
lines.push(sep);
|
|
435
437
|
this.logger.debug(lines.join('\n'));
|
|
436
438
|
}
|
|
439
|
+
/** 会话管理给这个会话的代号(api-session-manager 的 generationOf);会话管理不在时没有 */
|
|
440
|
+
generationOf(sessionId) {
|
|
441
|
+
return this.caps.sessionManager.present ? this.caps.sessionManager.use.generationOf(sessionId) : undefined;
|
|
442
|
+
}
|
|
437
443
|
async handleMessage(incoming) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
444
|
+
// 入口记下这个会话的代号:交给通道之前变了(会话管理这期间开始删了这个会话)的是删除之前的一代,放掉、不归档
|
|
445
|
+
const generation = this.generationOf(incoming.sessionId);
|
|
446
|
+
// Hook: agent:input:before — 每条输入一次(预处理器挂在这里),中间件可以改写这条;不调 next() 即丢掉它,不进一轮
|
|
447
|
+
const inputData = { message: incoming };
|
|
448
|
+
let placed = false;
|
|
449
|
+
await this.caps.hooks.use.run('agent:input:before', inputData, async () => {
|
|
450
|
+
placed = true;
|
|
451
|
+
const { message } = inputData;
|
|
452
|
+
// 清消息历史与删会话期间按住的会话:放开再交给通道
|
|
453
|
+
await this.queues.held(message.sessionId);
|
|
454
|
+
if (this.generationOf(incoming.sessionId) !== generation)
|
|
455
|
+
this.logRoute(message, '放掉');
|
|
456
|
+
else
|
|
457
|
+
await this.settleInput(message, this.queues.place(message));
|
|
458
|
+
});
|
|
459
|
+
// 输入被丢掉(如流控缓冲),通知前端结束 loading:它没进一轮,流结束标记的回合 id 只是占位
|
|
460
|
+
if (!placed) {
|
|
461
|
+
await this.caps.events.use.emit('outbound:stream', {
|
|
462
|
+
sessionId: incoming.sessionId,
|
|
463
|
+
turnId: crypto.randomUUID(),
|
|
464
|
+
platform: incoming.platform,
|
|
465
|
+
done: true,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/** 逐条输入的默认动作:交给通道之后等这条落定(它所在的一轮结束,或它被放掉);轮到它开轮时由它跑这一轮 */
|
|
470
|
+
async settleInput(message, placement) {
|
|
471
|
+
if (placement.route === 'drop') {
|
|
472
|
+
this.logRoute(message, '放掉');
|
|
473
|
+
// 停机放掉的在这个会话在跑的一轮落定之后归档
|
|
474
|
+
await this.queues.settled(message.sessionId);
|
|
475
|
+
await this.archiveIncomingMessage(message);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (placement.route !== 'open')
|
|
479
|
+
this.logRoute(message, placement.route === 'join' ? '待插入' : '排队');
|
|
480
|
+
const turn = placement.route === 'open' ? placement.turn : await placement.ready;
|
|
481
|
+
if (turn)
|
|
482
|
+
await this.playTurn(turn);
|
|
483
|
+
}
|
|
484
|
+
/** 每条输入一行 debug:去向、到达序号、会话与回合,不记正文与昵称 */
|
|
485
|
+
logRoute(message, route, turnId) {
|
|
486
|
+
this.logger.debug(`→ ${route}${arrivalTag(message)} session=${message.sessionId}${turnId ? ` turn=${turnId}` : ''}`);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* 跑一轮:agent:turn:before 的默认动作是这一轮的全部,中间件不调 next() 即拦下这一轮(不归档、不调模型、不发
|
|
490
|
+
* agent:turn:after)。之后按先后归档这期间放掉、要归档的输入,再交出下一批:agent 在一个会话里的写入因此不交错
|
|
491
|
+
*/
|
|
492
|
+
async playTurn(turn) {
|
|
493
|
+
const { sessionId, turnId } = turn;
|
|
494
|
+
const inputs = turn.batch.map(message => ({ message }));
|
|
495
|
+
for (const message of turn.batch)
|
|
496
|
+
this.logRoute(message, '开轮', turnId);
|
|
497
|
+
const startedAt = Date.now();
|
|
498
|
+
let outcome;
|
|
457
499
|
try {
|
|
458
|
-
await turn
|
|
500
|
+
await this.caps.hooks.use.run('agent:turn:before', { turnId, sessionId, inputs }, async () => {
|
|
501
|
+
outcome = await this.runTurn(turn, inputs);
|
|
502
|
+
});
|
|
459
503
|
}
|
|
460
504
|
finally {
|
|
461
|
-
|
|
505
|
+
// 这一轮被拦下(如已删子会话的消息),通知前端结束 loading
|
|
506
|
+
if (outcome === undefined) {
|
|
507
|
+
await this.caps.events.use.emit('outbound:stream', {
|
|
508
|
+
sessionId,
|
|
509
|
+
turnId,
|
|
510
|
+
platform: inputs[0].message.platform,
|
|
511
|
+
done: true,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
for (let dropped = this.queues.drain(turn); dropped.length > 0; dropped = this.queues.drain(turn)) {
|
|
515
|
+
for (const { message, archive, settle } of dropped) {
|
|
516
|
+
this.logRoute(message, '放掉');
|
|
517
|
+
if (archive)
|
|
518
|
+
await this.archiveIncomingMessage(message);
|
|
519
|
+
settle();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const arrivals = inputs.map(({ message }) => arrivalTag(message)).join('');
|
|
523
|
+
this.logger.info(`回合结束 turn=${turnId} session=${sessionId} 结局=${outcome ?? '拦下'}` +
|
|
524
|
+
`${turn.reason ? ` 原因=${turn.reason}` : ''} 用时=${Date.now() - startedAt}ms${arrivals && ` 输入${arrivals}`}`);
|
|
525
|
+
this.queues.release(turn);
|
|
462
526
|
}
|
|
463
527
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
528
|
+
/**
|
|
529
|
+
* agent:turn:before 的默认动作:先按先后归档开轮的输入,再跑这一轮,返回结局。真人消息开的一轮在每次调用模型之前(第一次与
|
|
530
|
+
* 每批工具之后;格式重试的那几次不算)取走待插入的输入,最后一次模型调用返回不带工具调用的回复之后不再收
|
|
531
|
+
*/
|
|
532
|
+
async runTurn(turn, inputs) {
|
|
533
|
+
const { sessionId, turnId } = turn;
|
|
534
|
+
const { signal } = turn.controller;
|
|
535
|
+
// 同一轮的输入会话与来源相同;带来源的注入(宿主通知、委派)各自成一轮,只有这一条
|
|
536
|
+
const first = inputs[0].message;
|
|
537
|
+
const { platform } = first;
|
|
538
|
+
const reportOnly = first.hostNotice?.reportOnly === true;
|
|
539
|
+
const noticeLinks = reportOnly ? (first.content.match(NOTICE_URL) ?? []) : [];
|
|
540
|
+
const recordOf = (outcome) => ({ outcome, ...(first.source && { source: first.source }) });
|
|
541
|
+
// 回合记录一轮只写一次:回复了的随回复落库时写下,之后外发或 agent:turn:after 的中间件出错不再另记一条
|
|
542
|
+
let recorded = false;
|
|
543
|
+
/** 这一轮落过工具调用组 */
|
|
544
|
+
let savedGroups = false;
|
|
545
|
+
/**
|
|
546
|
+
* 收尾:还没写回合记录的(静默、中止、出错)落一条只带回合 id 与记录的事件标记,不进模型、不进摘要,会话服务据此在历史里还原
|
|
547
|
+
* 这一轮的结局;中止的先落一条给下一轮的模型看的停下标记。再发 agent:turn:after
|
|
548
|
+
*/
|
|
549
|
+
const end = async (outcome, { reply = '', error } = {}) => {
|
|
550
|
+
if (!recorded) {
|
|
551
|
+
recorded = true;
|
|
552
|
+
// 只在这一轮在历史里留下了输入或工具调用组时写:什么也没留下时(空闲开话题还没调工具就被停下),下一轮模型看到的上文是
|
|
553
|
+
// 之前已经答完的一轮,写「上面这一轮被停下了」会被读成上一句回复没发出
|
|
554
|
+
if (outcome === 'aborted' && (savedGroups || inputs.some(input => input.archived))) {
|
|
555
|
+
await this.saveToMemory(sessionId, stoppedNote(this.stopperLabel(sessionId, turn.stoppedBy), turnId));
|
|
556
|
+
}
|
|
557
|
+
await this.saveToMemory(sessionId, {
|
|
558
|
+
role: 'system',
|
|
559
|
+
kind: WellKnownKinds.EventMarker,
|
|
560
|
+
content: '',
|
|
561
|
+
timestamp: Date.now(),
|
|
562
|
+
metadata: { [WellKnownMetadataKeys.TurnId]: turnId, [WellKnownMetadataKeys.TurnRecord]: recordOf(outcome) },
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
await this.caps.hooks.use.run('agent:turn:after', {
|
|
566
|
+
inputs,
|
|
567
|
+
reply,
|
|
568
|
+
outcome,
|
|
569
|
+
...(error === undefined ? {} : { error }),
|
|
570
|
+
sessionId,
|
|
571
|
+
turnId,
|
|
572
|
+
});
|
|
573
|
+
return outcome;
|
|
470
574
|
};
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
await this.dispatchOutbound({
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
575
|
+
for (const input of inputs)
|
|
576
|
+
input.archived = await this.archiveIncomingMessage(input.message, { turnId, entry: 'opened' });
|
|
577
|
+
// 这一轮的设置在开轮时读一次(下一轮再读,改了不必重载):插进来的输入不重读,同一轮里换模型或工具列表会改掉之后各次调用的
|
|
578
|
+
// 前缀。同一会话的输入会话相同,入口平台只对没有出生平台的会话起作用,取开轮那条的
|
|
579
|
+
const settings = this.fields.get(sessionId, { platform });
|
|
580
|
+
const resolved = this.resolveLLM(settings.llm);
|
|
581
|
+
if (!resolved) {
|
|
582
|
+
const why = this.explainLLMFailure(settings.llm);
|
|
583
|
+
this.logger.warn(`LLM 解析失败,无法处理消息:${toSource(why)}`);
|
|
584
|
+
if (!reportOnly) {
|
|
585
|
+
const text = t('systemNotice', { why });
|
|
586
|
+
await this.dispatchOutbound({ content: toSource(text), text, sessionId, platform, source: 'system', turnId });
|
|
587
|
+
}
|
|
588
|
+
return signal.aborted ? end('aborted') : end('error', { error: why });
|
|
589
|
+
}
|
|
590
|
+
const llm = resolved.use;
|
|
591
|
+
// 这一轮的主模型能不能亲眼看图:按模型服务登记的能力(端点报告或单模型能力覆盖),
|
|
592
|
+
// 工具上下文与 agent:llm:before 同一个值
|
|
593
|
+
const acceptsImages = llm.capabilities.includes(LLMCapabilities.Vision);
|
|
594
|
+
const maxTokens = getModelMaxOutput(llm);
|
|
595
|
+
const contextLength = llm.contextLength;
|
|
596
|
+
const tokenBudget = this.computeTokenBudget(contextLength, maxTokens);
|
|
597
|
+
try {
|
|
598
|
+
const { maxToolIterations, historyLimit } = settings;
|
|
599
|
+
// 人设服务按这个会话自己读它的设置(人设、额外提示、结构化输出的两个开关)
|
|
600
|
+
const personaOpts = { sessionId, platform };
|
|
601
|
+
// 会话的深度思考档位:当前模型有这一档才带上;没设置、模型没有这一档或不能调时不带,由模型服务按它的默认档
|
|
602
|
+
const level = settings.think;
|
|
603
|
+
const thinking = llm.thinking?.levels.some(({ id }) => id === level) ? { think: level } : {};
|
|
604
|
+
// 多方与否取根会话记下的聊天窗口,开轮时取一次:说明文字与焦点指引同一个出处
|
|
605
|
+
const multiParty = originChatOf(sessionId, this.caps.sessionManager)?.multiParty === true;
|
|
606
|
+
const built = await this.buildMessages(turn, inputs, historyLimit, acceptsImages, multiParty, personaOpts);
|
|
607
|
+
const messages = built.messages;
|
|
608
|
+
/** 会话历史的条数:这一轮开头的活跃条数(含没发给模型的),加本回合追加的工具调用、结果与插进来的输入 */
|
|
609
|
+
let historyMessages = built.historyMessages;
|
|
610
|
+
// 这个会话启用的工具分组:工具注册表按会话读它的设置,不在时没有工具
|
|
611
|
+
const enabledGroups = this.caps.tools.present ? this.caps.tools.use.sessionGroups(sessionId, platform) : [];
|
|
612
|
+
this.logger.debug(`工具分组: platform=${platform}, enabledGroups=${JSON.stringify(enabledGroups)}`);
|
|
613
|
+
const tools = reportOnly || !this.caps.tools.present ? [] : this.caps.tools.use.getDefinitions({ groups: enabledGroups });
|
|
614
|
+
/**
|
|
615
|
+
* 一批工具调用的上下文,按这批开始那一刻已送进模型的输入构造:会话、发言者、参与者与入站回合由 originTurnOf 拼(字段语义
|
|
616
|
+
* 见它与 ToolCallContext,owner 的各个身份按权限服务合成一个参与者);入站回合只有这里填,工具据此正向区分真人消息与
|
|
617
|
+
* 内部注入。叠进来的人从下一批起算,已执行的不追溯。续做取起它的那一轮的发言者只用在这里,归档与提示词贡献仍按输入本身的
|
|
618
|
+
* userId
|
|
619
|
+
*/
|
|
620
|
+
const toolContext = () => ({
|
|
621
|
+
...originTurnOf(inputs.map(input => input.message), this.isOwner),
|
|
622
|
+
// 恒为数组:没开分组时是 [](列举面本就等价于「只给无分组工具」),让执行面的分组闸总是生效
|
|
623
|
+
enabledGroups,
|
|
624
|
+
// 为 true 时本循环把 ToolExecutionResult.images 挂到 tool 消息交给主模型(出口由 prepareLLMMessages 编码)
|
|
625
|
+
acceptsImages,
|
|
626
|
+
// 回合中止信号:等审批期间这一轮被停下时,还没执行的工具不再执行
|
|
627
|
+
signal,
|
|
628
|
+
turnId,
|
|
629
|
+
});
|
|
630
|
+
// 保存原始完整工具列表,后续迭代均以此为基础(避免被 hooks 修改后丢失)
|
|
631
|
+
const originalTools = [...tools];
|
|
632
|
+
// Hook: agent:llm:before — 插件可以修改消息或工具列表
|
|
633
|
+
const llmBeforeData = { messages, tools, acceptsImages, sessionId, platform, source: first.source };
|
|
634
|
+
// 组装先于链:贡献块(档案/技能/记忆/即时提示)先物化,拦截者审完整成品
|
|
635
|
+
await assemblePromptContributions(this.caps, llmBeforeData, inputs, {
|
|
636
|
+
buildTimeoutMs: this.promptBuildTimeoutMs,
|
|
637
|
+
signal,
|
|
638
|
+
});
|
|
639
|
+
await this.caps.hooks.use.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
|
|
640
|
+
if (reportOnly) {
|
|
641
|
+
llmBeforeData.tools = [];
|
|
642
|
+
// 这是结果汇报,不续跑历史工具链。保留人设与对话,去掉会诱发继续查任务的工具协议。
|
|
643
|
+
llmBeforeData.messages = llmBeforeData.messages.filter(message => message.role !== 'tool' &&
|
|
644
|
+
!message.toolCalls?.length &&
|
|
645
|
+
message.metadata?.injector !== WellKnownKinds.HostNotice);
|
|
646
|
+
// 放在钩子之后、消息末尾,确保裁剪仍保留本次结果及转述要求。
|
|
647
|
+
llmBeforeData.messages.push({
|
|
648
|
+
role: 'system',
|
|
649
|
+
content: `[宿主通知]\n${first.content}${first.hostNotice?.untrusted ? `\n\n${first.hostNotice.untrusted}` : ''}\n\n` +
|
|
650
|
+
'本轮只转述以上已确认的结果。沿用当前人设和回复格式,向当前会话自然地说明结果,保留通知中的链接;' +
|
|
651
|
+
'不要调用工具、重新查询状态或继续执行,不把编号、内部流程当成回复正文,也不要编造通知里没有的细节。请给出非空回复。',
|
|
652
|
+
metadata: { injector: WellKnownKinds.HostNotice },
|
|
487
653
|
});
|
|
488
|
-
return;
|
|
489
654
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// 由各 provider 按自己的全局配置决定(ollama thinking / deepseek thinkingMode)。
|
|
515
|
-
const thinkOverride = resolved?.think;
|
|
516
|
-
const messages = await this.buildMessages(incoming, personaOpts, archivedIncoming);
|
|
517
|
-
// 通过 resolved config 获取工具分组
|
|
518
|
-
let enabledGroups;
|
|
519
|
-
if (resolved?.enabledToolGroups && resolved.enabledToolGroups.length > 0) {
|
|
520
|
-
enabledGroups = resolved.enabledToolGroups;
|
|
655
|
+
/**
|
|
656
|
+
* 插入点(调用模型之前,组装与 agent:llm:before 之后、硬裁剪之前):取这一轮待插入的输入,逐条归档、追加到消息末尾、挂上
|
|
657
|
+
* 各自的图,再增量组装、重跑 agent:llm:before(插进来的图要经它规范化),然后再取,取空为止
|
|
658
|
+
*/
|
|
659
|
+
const insertJoined = async (data) => {
|
|
660
|
+
for (let taken = this.queues.take(turn); taken.length > 0; taken = this.queues.take(turn)) {
|
|
661
|
+
const joined = [];
|
|
662
|
+
for (const message of taken) {
|
|
663
|
+
joined.push({ message, archived: await this.archiveIncomingMessage(message, { turnId, entry: 'joined' }) });
|
|
664
|
+
this.logRoute(message, '插入', turnId);
|
|
665
|
+
}
|
|
666
|
+
inputs.push(...joined);
|
|
667
|
+
// 多方房间里插进被点名的:在它们之前另加一块列出至今被点名的各条,不改前面那块(之后各次请求仍以之前的为前缀)
|
|
668
|
+
const focus = multiParty && joined.some(input => wasAddressed(input.message)) ? this.focusOf(inputs) : null;
|
|
669
|
+
if (focus)
|
|
670
|
+
data.messages.push(focus);
|
|
671
|
+
for (const input of joined)
|
|
672
|
+
data.messages.push(await this.inputMessage(input, acceptsImages));
|
|
673
|
+
historyMessages += joined.length;
|
|
674
|
+
await assemblePromptContributions(this.caps, data, inputs, {
|
|
675
|
+
buildTimeoutMs: this.promptBuildTimeoutMs,
|
|
676
|
+
signal,
|
|
677
|
+
});
|
|
678
|
+
await this.caps.hooks.use.run('agent:llm:before', data, undefined, { warnOnStall: true });
|
|
521
679
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
680
|
+
};
|
|
681
|
+
await insertJoined(llmBeforeData);
|
|
682
|
+
/** 本回合到目前为止经过了硬裁剪:首次调用的裁剪结果写回底稿,之后各次调用都在它上面追加 */
|
|
683
|
+
let turnTrimmed = false;
|
|
684
|
+
/** 首次调用的硬裁剪从底稿里去掉的历史与工具结果:之后各次调用按底稿估算没裁剪的量时补回 */
|
|
685
|
+
let trimmedFromBase = 0;
|
|
686
|
+
/**
|
|
687
|
+
* 硬裁剪这次要发出的列表(按 buildMessages 交出的消息对象认出历史),删了消息时记一行日志;untrimmedHistory 按没经过
|
|
688
|
+
* 硬裁剪的列表估算
|
|
689
|
+
*/
|
|
690
|
+
const trimForCall = (list) => {
|
|
691
|
+
const sent = this.trimMessages(list, tokenBudget, built.history);
|
|
692
|
+
if (sent.messages.length < list.length) {
|
|
693
|
+
this.logger.info(`上下文硬裁剪: session=${sessionId}, ${list.length} → ${sent.messages.length} 条消息`);
|
|
694
|
+
}
|
|
695
|
+
turnTrimmed ||= sent.trimmed;
|
|
696
|
+
return { messages: sent.messages, untrimmedHistory: estimateHistoryTokens(list) + trimmedFromBase };
|
|
697
|
+
};
|
|
698
|
+
// 裁剪消息以确保不超过上下文窗口;裁剪结果写回,作为之后各次调用的底稿
|
|
699
|
+
const firstSent = trimForCall(llmBeforeData.messages);
|
|
700
|
+
trimmedFromBase = firstSent.untrimmedHistory - estimateHistoryTokens(firstSent.messages);
|
|
701
|
+
llmBeforeData.messages = firstSent.messages;
|
|
702
|
+
// 每次模型调用之后发一次用量:拆分按这次发出的消息与工具定义估算,used 取模型服务返回的输入 token
|
|
703
|
+
const reportUsage = (sent, sentTools, response) => this.emitTokenUsage({
|
|
704
|
+
sessionId,
|
|
705
|
+
platform,
|
|
706
|
+
turnId,
|
|
707
|
+
contextWindow: contextLength,
|
|
708
|
+
maxTokens,
|
|
709
|
+
tokenBudget,
|
|
710
|
+
messages: sent.messages,
|
|
711
|
+
tools: sentTools,
|
|
712
|
+
trimmed: turnTrimmed,
|
|
713
|
+
untrimmedHistory: sent.untrimmedHistory,
|
|
714
|
+
historyMessages,
|
|
715
|
+
historyLimit,
|
|
716
|
+
promptTokens: response.usage?.promptTokens,
|
|
717
|
+
});
|
|
718
|
+
this.logger.debug(`LLM 请求: ${llmBeforeData.messages.length} 条消息, ` +
|
|
719
|
+
`${llmBeforeData.tools.length} 个工具, ` +
|
|
720
|
+
`maxTokens=${maxTokens}`);
|
|
721
|
+
const t0 = Date.now();
|
|
722
|
+
// 本轮累加的用量,用于最终 assistant 消息的 modelInfo 元数据。
|
|
723
|
+
// 注:缓存命中量(usage.cachedPromptTokens)不进这里——本对象只喂
|
|
724
|
+
// assistant 消息的 modelInfo,而其字段是逐个挑的(见下方 turnModelInfo),
|
|
725
|
+
// 加进来也不会流到下游。命中率的观测出口是 LLM 响应日志那一行。
|
|
726
|
+
const turnUsageAcc = { promptTokens: 0, completionTokens: 0 };
|
|
727
|
+
const accUsage = (u) => {
|
|
728
|
+
if (!u)
|
|
729
|
+
return;
|
|
730
|
+
// promptTokens 语义 = "生成这条回复时的上下文大小"。工具迭代循环里每轮都把同一份
|
|
731
|
+
// (不断增长的)上下文重新发给模型,若累加会把重复发送的上下文重复计入,导致单条
|
|
732
|
+
// 回复虚高到几十万 token。故取最后一次调用的 prompt(覆盖而非累加)= 回复定稿时的真实上下文。
|
|
733
|
+
if (u.promptTokens != null)
|
|
734
|
+
turnUsageAcc.promptTokens = u.promptTokens;
|
|
735
|
+
// completionTokens 语义 = "本回合真正新生成的输出"(含中间 reasoning / tool-call 生成
|
|
736
|
+
// 与最终回复文本),每次调用都是不重复的新输出,故逐次累加。
|
|
737
|
+
turnUsageAcc.completionTokens += u.completionTokens ?? 0;
|
|
738
|
+
};
|
|
739
|
+
const firstResult = await this.consumeStream(llm, {
|
|
740
|
+
messages: llmBeforeData.messages,
|
|
741
|
+
tools: llmBeforeData.tools.length > 0 ? llmBeforeData.tools : undefined,
|
|
742
|
+
maxTokens,
|
|
743
|
+
signal,
|
|
744
|
+
...thinking,
|
|
745
|
+
}, turnId, sessionId, platform, !reportOnly);
|
|
746
|
+
await reportUsage(firstSent, llmBeforeData.tools, firstResult);
|
|
747
|
+
accUsage(firstResult.usage);
|
|
748
|
+
/** 这一次模型回复里各工具调用在生成进度里配好的 callId 与工具名,按调用的位置 */
|
|
749
|
+
let generating = firstResult.generating;
|
|
750
|
+
// 维护本轮(一次完整对话回合)的统一时间线 segments:
|
|
751
|
+
// 多次 LLM 调用 + 工具执行的输出按到达顺序拼接,保留模型原本的"思考/回答/工具/思考/回答"交错。
|
|
752
|
+
const turnSegments = [...firstResult.segments];
|
|
753
|
+
let response = firstResult;
|
|
754
|
+
this.debugLogResponse(response, Date.now() - t0);
|
|
755
|
+
// Hook: agent:llm:after — 插件可以处理 LLM 返回结果
|
|
756
|
+
const llmAfterData = { response, messages: llmBeforeData.messages };
|
|
757
|
+
await this.caps.hooks.use.run('agent:llm:after', llmAfterData);
|
|
758
|
+
response = llmAfterData.response;
|
|
759
|
+
const assistantMetadata = this.buildAssistantMetadata(first, turnId);
|
|
760
|
+
// 工具调用循环:上限没设即不限
|
|
761
|
+
let iterations = 0;
|
|
762
|
+
const withinLimit = () => maxToolIterations === undefined || iterations < maxToolIterations;
|
|
763
|
+
while (!reportOnly && response.toolCalls && response.toolCalls.length > 0 && withinLimit()) {
|
|
764
|
+
if (signal.aborted)
|
|
765
|
+
throw new DOMException('Generation aborted', 'AbortError');
|
|
766
|
+
iterations++;
|
|
767
|
+
this.logger.debug(`工具调用迭代 ${iterations}: ${response.toolCalls.map(tc => tc.function.name).join(', ')}`);
|
|
768
|
+
// 先解析各次调用的参数:解析不了的,写进本回合消息与存档的那次调用的 arguments 换成 {}(见 parseToolArguments)
|
|
769
|
+
const parsedCalls = response.toolCalls.map(parseToolArguments);
|
|
770
|
+
const toolCalls = parsedCalls.map(parsed => parsed.toolCall);
|
|
771
|
+
// 将 assistant 消息 (含 toolCalls) 加入历史
|
|
772
|
+
llmBeforeData.messages.push({
|
|
773
|
+
role: 'assistant',
|
|
774
|
+
content: response.content,
|
|
775
|
+
toolCalls,
|
|
776
|
+
reasoningContent: response.reasoningContent,
|
|
777
|
+
metadata: assistantMetadata,
|
|
556
778
|
});
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
`${llmBeforeData.tools.length} 个工具, ` +
|
|
564
|
-
`maxTokens=${maxTokens}`);
|
|
565
|
-
const t0 = Date.now();
|
|
566
|
-
// 本轮初始时间与累加用量,用于最终 assistant 消息的 modelInfo 元数据。
|
|
567
|
-
const turnStartTs = t0;
|
|
568
|
-
// 注:缓存命中量(usage.cachedPromptTokens)不进这里——本对象只喂
|
|
569
|
-
// assistant 消息的 modelInfo,而其字段是逐个挑的(见下方 turnModelInfo),
|
|
570
|
-
// 加进来也不会流到下游。命中率的观测出口是 LLM 响应日志那一行。
|
|
571
|
-
const turnUsageAcc = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
572
|
-
const accUsage = (u) => {
|
|
573
|
-
if (!u)
|
|
574
|
-
return;
|
|
575
|
-
// promptTokens 语义 = "生成这条回复时的上下文大小"。工具迭代循环里每轮都把同一份
|
|
576
|
-
// (不断增长的)上下文重新发给模型,若累加会把重复发送的上下文重复计入,导致单条
|
|
577
|
-
// 回复虚高到几十万 token。故取最后一次调用的 prompt(覆盖而非累加)= 回复定稿时的真实上下文。
|
|
578
|
-
if (u.promptTokens != null)
|
|
579
|
-
turnUsageAcc.promptTokens = u.promptTokens;
|
|
580
|
-
// completionTokens 语义 = "本回合真正新生成的输出"(含中间 reasoning / tool-call 生成
|
|
581
|
-
// 与最终回复文本),每次调用都是不重复的新输出,故逐次累加。
|
|
582
|
-
turnUsageAcc.completionTokens += u.completionTokens ?? 0;
|
|
583
|
-
turnUsageAcc.totalTokens = turnUsageAcc.promptTokens + turnUsageAcc.completionTokens;
|
|
779
|
+
const assistantToolMessage = {
|
|
780
|
+
role: 'assistant',
|
|
781
|
+
content: response.content,
|
|
782
|
+
toolCalls,
|
|
783
|
+
reasoningContent: response.reasoningContent,
|
|
784
|
+
metadata: assistantMetadata,
|
|
584
785
|
};
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const assistantMetadata = this.buildAssistantMetadata(incoming);
|
|
610
|
-
// 工具调用循环
|
|
611
|
-
let iterations = 0;
|
|
612
|
-
while (response.toolCalls && response.toolCalls.length > 0 && iterations < maxToolIterations) {
|
|
613
|
-
if (signal.aborted)
|
|
614
|
-
throw new DOMException('Generation aborted', 'AbortError');
|
|
615
|
-
iterations++;
|
|
616
|
-
this.logger.debug(`工具调用迭代 ${iterations}: ${response.toolCalls.map(tc => tc.function.name).join(', ')}`);
|
|
617
|
-
// 将 assistant 消息 (含 toolCalls) 加入历史
|
|
618
|
-
llmBeforeData.messages.push({
|
|
619
|
-
role: 'assistant',
|
|
620
|
-
content: response.content,
|
|
621
|
-
toolCalls: response.toolCalls,
|
|
622
|
-
reasoningContent: response.reasoningContent,
|
|
623
|
-
metadata: assistantMetadata,
|
|
786
|
+
const toolMessages = [];
|
|
787
|
+
// 并行执行所有工具调用(互不依赖的工具无需串行等待)
|
|
788
|
+
const toolResultMaxChars = Math.floor(contextLength * this.toolResultMaxRatio * 3.5);
|
|
789
|
+
// 这一批的参与者按此刻已送进模型的输入算
|
|
790
|
+
const toolCtx = toolContext();
|
|
791
|
+
const parallelResults = await Promise.all(parsedCalls.map(async (parsed, position) => {
|
|
792
|
+
const { toolCall } = parsed;
|
|
793
|
+
// 这次调用的 id:开始与结束事件、工具上下文都带它。同一位置生成参数时报过同一个工具的进度,用那时配的
|
|
794
|
+
// (会话里已有它的工具卡);没报过的、或钩子改过调用列表而对不上的现在生成,免得接管画着别的工具的卡。
|
|
795
|
+
// 自己生成而不用模型给的 toolCall.id——模型给的 id 不保证唯一(有的 provider 用毫秒时间戳加序号拼,并发的
|
|
796
|
+
// 两轮会撞上),而 callId 要在全局配对开始与结束。模型的 id 只留在发回模型的 tool 消息里配对调用与结果。
|
|
797
|
+
const generated = generating.get(position);
|
|
798
|
+
const callId = generated?.name === toolCall.function.name ? generated.callId : crypto.randomUUID();
|
|
799
|
+
// 会话里的工具卡跟着这一对事件走:没执行就失败的调用(参数不合法、执行前的钩子出错)同样发一对,
|
|
800
|
+
// 结束带回给模型的错误,否则那张卡一直停在生成中
|
|
801
|
+
const announce = (phase, toolName, toolArgs, result) => this.caps.events.use.emit('tool:execute', {
|
|
802
|
+
sessionId,
|
|
803
|
+
turnId,
|
|
804
|
+
callId,
|
|
805
|
+
platform,
|
|
806
|
+
toolName,
|
|
807
|
+
args: toolArgs,
|
|
808
|
+
phase,
|
|
809
|
+
result,
|
|
624
810
|
});
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
// 参数不是合法 JSON:跳过本次执行,把错误当工具结果回给模型重新生成 arguments。
|
|
644
|
-
// 旧行为 args = {} 会让"无必填参数"的工具真的空参跑一遍(副作用已发生,模型却不知情)。
|
|
645
|
-
const reason = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
646
|
-
this.logger.warn(`工具参数解析失败: ${toolCall.function.name} (${reason}) 原始参数=${rawArgs?.slice(0, 200)}`);
|
|
647
|
-
const failedAt = Date.now();
|
|
648
|
-
return {
|
|
649
|
-
toolCall,
|
|
650
|
-
result: JSON.stringify({ error: `工具参数不是合法 JSON:${reason};请重新生成 arguments` }),
|
|
651
|
-
resultImages: undefined,
|
|
652
|
-
toolName: toolCall.function.name,
|
|
653
|
-
toolArgs: {},
|
|
654
|
-
startTime: failedAt,
|
|
655
|
-
endTime: failedAt,
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
// Hook: agent:tool:before — 插件可以拦截或修改工具调用
|
|
659
|
-
const toolBeforeData = { name: toolCall.function.name, args, toolCallContext: toolCtx };
|
|
660
|
-
// 单个工具的链路异常(钩子 / 守卫 / 结果处理抛错)只算该工具失败,转成它自己的错误结果:
|
|
661
|
-
// 整批照常落定、整组落库,兄弟工具已发生的副作用不会因连坐而从历史里消失。
|
|
662
|
-
// executeDone 区分失败发生在执行前还是执行后——后者须明说「已执行」,免得模型重做有副作用的操作。
|
|
663
|
-
let executeDone = false;
|
|
664
|
-
let toolT0;
|
|
665
|
-
try {
|
|
666
|
-
await this.caps.hooks.run('agent:tool:before', toolBeforeData);
|
|
667
|
-
// 通知平台:工具开始执行
|
|
668
|
-
await this.caps.events.emit('tool:execute', {
|
|
669
|
-
sessionId: incoming.sessionId,
|
|
670
|
-
platform: incoming.platform,
|
|
671
|
-
toolName: toolBeforeData.name,
|
|
672
|
-
args: toolBeforeData.args,
|
|
673
|
-
phase: 'start',
|
|
674
|
-
});
|
|
675
|
-
// 紧随 start 赋值:catch 据此判断 start 已发出、要补发配对的 end
|
|
676
|
-
toolT0 = Date.now();
|
|
677
|
-
this.logger.debug(`工具执行: ${toolBeforeData.name} 参数=${formatToolArgsForLog(toolBeforeData.args)}`);
|
|
678
|
-
const executed = await (this.caps.tools.current?.execute(toolBeforeData.name, toolBeforeData.args, toolCtx) ?? Promise.resolve({ content: JSON.stringify({ error: 'tools 服务不可用' }) }));
|
|
679
|
-
executeDone = true;
|
|
680
|
-
let result = executed.content;
|
|
681
|
-
// 工具交给主模型看的图:只随本回合的 tool 消息走(出口由 prepareLLMMessages 编码),
|
|
682
|
-
// 不进钩子/事件/时间线(那些面都是文本),也不落库(见 saveToolCallGroup)。
|
|
683
|
-
const resultImages = executed.images;
|
|
684
|
-
// Hook: agent:tool:after — 插件可以处理工具执行结果
|
|
685
|
-
const toolAfterData = { name: toolBeforeData.name, result, toolCallContext: toolCtx };
|
|
686
|
-
await this.caps.hooks.run('agent:tool:after', toolAfterData);
|
|
687
|
-
result = toolAfterData.result;
|
|
688
|
-
// 工具结果截断:按上下文窗口比例限制单条工具结果长度
|
|
689
|
-
if (result.length > toolResultMaxChars) {
|
|
690
|
-
this.logger.info(`工具结果过长 (${result.length} 字符),截断至 ${toolResultMaxChars} 字符: ${toolBeforeData.name}`);
|
|
691
|
-
result = truncateChars(result, toolResultMaxChars, `\n... [工具输出已截断,原始长度 ${result.length} 字符]`);
|
|
692
|
-
}
|
|
693
|
-
const toolEndTime = Date.now();
|
|
694
|
-
this.logger.debug(`工具完成: ${toolBeforeData.name} (${toolEndTime - toolT0}ms) 结果=${result}`);
|
|
695
|
-
// 通知平台:工具执行完成
|
|
696
|
-
await this.caps.events.emit('tool:execute', {
|
|
697
|
-
sessionId: incoming.sessionId,
|
|
698
|
-
platform: incoming.platform,
|
|
699
|
-
toolName: toolBeforeData.name,
|
|
700
|
-
args: toolBeforeData.args,
|
|
701
|
-
phase: 'end',
|
|
702
|
-
result,
|
|
703
|
-
});
|
|
704
|
-
return {
|
|
705
|
-
toolCall,
|
|
706
|
-
result,
|
|
707
|
-
resultImages,
|
|
708
|
-
toolName: toolBeforeData.name,
|
|
709
|
-
toolArgs: toolBeforeData.args,
|
|
710
|
-
startTime: toolT0,
|
|
711
|
-
endTime: toolEndTime,
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
catch (err) {
|
|
715
|
-
// 执行后的失败不回退到原始结果(fail-closed):after 钩子可能是脱敏钩子,失败时原始输出不能漏给模型
|
|
716
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
717
|
-
this.logger.warn(`工具${executeDone ? '已执行,但结果处理' : '调用'}失败: ${toolBeforeData.name} (${reason})`);
|
|
718
|
-
const failedAt = Date.now();
|
|
719
|
-
const result = JSON.stringify({
|
|
720
|
-
error: executeDone ? `工具已执行,但结果处理失败:${reason}` : `工具调用失败:${reason}`,
|
|
721
|
-
});
|
|
722
|
-
// start 已发出就补发配对的 end(emit 恒 resolve):否则 WebUI 流式缓冲里这一段一直显示执行中
|
|
723
|
-
if (toolT0 !== undefined) {
|
|
724
|
-
await this.caps.events.emit('tool:execute', {
|
|
725
|
-
sessionId: incoming.sessionId,
|
|
726
|
-
platform: incoming.platform,
|
|
727
|
-
toolName: toolBeforeData.name,
|
|
728
|
-
args: toolBeforeData.args,
|
|
729
|
-
phase: 'end',
|
|
730
|
-
result,
|
|
731
|
-
});
|
|
732
|
-
}
|
|
733
|
-
return {
|
|
734
|
-
toolCall,
|
|
735
|
-
result,
|
|
736
|
-
resultImages: undefined,
|
|
737
|
-
toolName: toolBeforeData.name,
|
|
738
|
-
toolArgs: toolBeforeData.args,
|
|
739
|
-
startTime: toolT0 ?? failedAt,
|
|
740
|
-
endTime: failedAt,
|
|
741
|
-
};
|
|
742
|
-
}
|
|
743
|
-
}));
|
|
744
|
-
// 工具交出的图只服务紧接着的这一次请求:上一轮工具结果里的图在这里剥掉,否则多轮
|
|
745
|
-
// 工具迭代会让图片在上下文里累积(每张数百 token,且不受文本裁剪约束)。
|
|
746
|
-
for (let i = 0; i < llmBeforeData.messages.length; i++) {
|
|
747
|
-
const m = llmBeforeData.messages[i];
|
|
748
|
-
if (m.role === 'tool' && m.images) {
|
|
749
|
-
const { images: _spent, ...rest } = m;
|
|
750
|
-
llmBeforeData.messages[i] = rest;
|
|
751
|
-
}
|
|
811
|
+
if ('error' in parsed) {
|
|
812
|
+
// 参数不是合法 JSON 对象:跳过本次执行,把错误当工具结果回给模型重新生成 arguments。
|
|
813
|
+
// 不能按空参数执行:「无必填参数」的工具会真的空参跑一遍(副作用已发生,模型却不知情)。
|
|
814
|
+
this.logger.warn(`工具参数解析失败: ${toolCall.function.name}: ${parsed.error}`);
|
|
815
|
+
const failedAt = Date.now();
|
|
816
|
+
const result = JSON.stringify({ error: parsed.error });
|
|
817
|
+
await announce('start', toolCall.function.name, {});
|
|
818
|
+
await announce('end', toolCall.function.name, {}, result);
|
|
819
|
+
return {
|
|
820
|
+
toolCall,
|
|
821
|
+
callId,
|
|
822
|
+
result,
|
|
823
|
+
resultImages: undefined,
|
|
824
|
+
toolName: toolCall.function.name,
|
|
825
|
+
toolArgs: {},
|
|
826
|
+
startTime: failedAt,
|
|
827
|
+
endTime: failedAt,
|
|
828
|
+
};
|
|
752
829
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
830
|
+
const callCtx = { ...toolCtx, callId };
|
|
831
|
+
// Hook: agent:tool:before — 插件可以拦截或修改工具调用
|
|
832
|
+
const toolBeforeData = { name: toolCall.function.name, args: parsed.args, toolCallContext: callCtx };
|
|
833
|
+
// 单个工具的链路异常(钩子 / 守卫 / 结果处理抛错)只算该工具失败,转成它自己的错误结果:
|
|
834
|
+
// 整批照常落定、整组落库,兄弟工具已发生的副作用不会因连坐而从历史里消失。
|
|
835
|
+
// executeDone 区分失败发生在执行前还是执行后——后者须明说「已执行」,免得模型重做有副作用的操作。
|
|
836
|
+
let executeDone = false;
|
|
837
|
+
let toolT0;
|
|
838
|
+
try {
|
|
839
|
+
await this.caps.hooks.use.run('agent:tool:before', toolBeforeData);
|
|
840
|
+
// 通知平台:工具开始执行
|
|
841
|
+
await announce('start', toolBeforeData.name, toolBeforeData.args);
|
|
842
|
+
// 紧随 start 赋值:catch 据此判断 start 已发出
|
|
843
|
+
toolT0 = Date.now();
|
|
844
|
+
this.logger.debug(`工具执行: ${toolBeforeData.name} 参数=${formatToolArgsForLog(toolBeforeData.args)}`);
|
|
845
|
+
const executed = await (this.caps.tools.present
|
|
846
|
+
? this.caps.tools.use.execute(toolBeforeData.name, toolBeforeData.args, callCtx)
|
|
847
|
+
: Promise.resolve({ content: JSON.stringify({ error: 'tools 服务不可用' }) }));
|
|
848
|
+
executeDone = true;
|
|
849
|
+
let result = executed.content;
|
|
850
|
+
// 工具交给主模型看的图:主模型能看图时才随本回合的 tool 消息走(出口由 prepareLLMMessages 编码),
|
|
851
|
+
// 不能看时工具硬塞的也不交,模型只读文字结果;不进钩子/事件/时间线(那些面都是文本),
|
|
852
|
+
// 也不落库(见 saveToolCallGroup)。
|
|
853
|
+
const resultImages = acceptsImages ? executed.images : undefined;
|
|
854
|
+
// Hook: agent:tool:after — 插件可以处理工具执行结果,写进 metadata 的随结果落库
|
|
855
|
+
const toolAfterData = {
|
|
856
|
+
name: toolBeforeData.name,
|
|
857
|
+
result,
|
|
858
|
+
toolCallContext: callCtx,
|
|
859
|
+
metadata: {},
|
|
762
860
|
};
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
861
|
+
await this.caps.hooks.use.run('agent:tool:after', toolAfterData);
|
|
862
|
+
result = toolAfterData.result;
|
|
863
|
+
// 工具结果截断:按上下文窗口比例限制单条工具结果长度
|
|
864
|
+
if (result.length > toolResultMaxChars) {
|
|
865
|
+
this.logger.info(`工具结果过长 (${result.length} 字符),截断至 ${toolResultMaxChars} 字符: ${toolBeforeData.name}`);
|
|
866
|
+
result = truncateChars(result, toolResultMaxChars, `\n... [工具输出已截断,原始长度 ${result.length} 字符]`);
|
|
867
|
+
}
|
|
868
|
+
const toolEndTime = Date.now();
|
|
869
|
+
this.logger.debug(`工具完成: ${toolBeforeData.name} (${toolEndTime - toolT0}ms) 结果=${result}`);
|
|
870
|
+
// 通知平台:工具执行完成
|
|
871
|
+
await announce('end', toolBeforeData.name, toolBeforeData.args, result);
|
|
872
|
+
return {
|
|
873
|
+
toolCall,
|
|
874
|
+
callId,
|
|
770
875
|
result,
|
|
771
|
-
|
|
772
|
-
|
|
876
|
+
resultImages,
|
|
877
|
+
resultMetadata: toolAfterData.metadata,
|
|
878
|
+
toolName: toolBeforeData.name,
|
|
879
|
+
toolArgs: toolBeforeData.args,
|
|
880
|
+
startTime: toolT0,
|
|
881
|
+
endTime: toolEndTime,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
catch (err) {
|
|
885
|
+
// 执行后的失败不回退到原始结果(fail-closed):after 钩子可能是脱敏钩子,失败时原始输出不能漏给模型
|
|
886
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
887
|
+
this.logger.warn(`工具${executeDone ? '已执行,但结果处理' : '调用'}失败: ${toolBeforeData.name} (${reason})`);
|
|
888
|
+
const failedAt = Date.now();
|
|
889
|
+
const result = JSON.stringify({
|
|
890
|
+
error: executeDone ? `工具已执行,但结果处理失败:${reason}` : `工具调用失败:${reason}`,
|
|
773
891
|
});
|
|
892
|
+
// 执行前就失败的补发开始,再发配对的结束(emit 恒 resolve)
|
|
893
|
+
if (toolT0 === undefined)
|
|
894
|
+
await announce('start', toolBeforeData.name, toolBeforeData.args);
|
|
895
|
+
await announce('end', toolBeforeData.name, toolBeforeData.args, result);
|
|
896
|
+
return {
|
|
897
|
+
toolCall,
|
|
898
|
+
callId,
|
|
899
|
+
result,
|
|
900
|
+
resultImages: undefined,
|
|
901
|
+
toolName: toolBeforeData.name,
|
|
902
|
+
toolArgs: toolBeforeData.args,
|
|
903
|
+
startTime: toolT0 ?? failedAt,
|
|
904
|
+
endTime: failedAt,
|
|
905
|
+
};
|
|
774
906
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
triggerType: incoming.triggerType,
|
|
784
|
-
};
|
|
785
|
-
// 已物化的贡献按全局键跳过;本轮工具调用新注册的贡献(如新激活技能)增量落位
|
|
786
|
-
await assemblePromptContributions(this.caps, nextLlmData, {
|
|
787
|
-
buildTimeoutMs: this.promptBuildTimeoutMs,
|
|
788
|
-
signal,
|
|
789
|
-
});
|
|
790
|
-
await this.caps.hooks.run('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
|
|
791
|
-
// 裁剪消息以确保不超过上下文窗口
|
|
792
|
-
nextLlmData.messages = this.trimMessages(nextLlmData.messages, tokenBudget);
|
|
793
|
-
// 推送 token 使用量统计
|
|
794
|
-
this.emitTokenUsage(incoming.sessionId, incoming.platform, nextLlmData.messages, nextLlmData.tools, contextLength, maxTokens, tokenBudget);
|
|
795
|
-
const tN = Date.now();
|
|
796
|
-
const nextResult = await this.consumeStream(llm, {
|
|
797
|
-
messages: nextLlmData.messages,
|
|
798
|
-
tools: nextLlmData.tools.length > 0 ? nextLlmData.tools : undefined,
|
|
799
|
-
maxTokens,
|
|
800
|
-
signal,
|
|
801
|
-
...(thinkOverride !== undefined ? { think: thinkOverride } : {}),
|
|
802
|
-
}, incoming.sessionId, incoming.platform);
|
|
803
|
-
turnSegments.push(...nextResult.segments);
|
|
804
|
-
response = nextResult;
|
|
805
|
-
accUsage(response.usage);
|
|
806
|
-
this.debugLogResponse(response, Date.now() - tN, iterations);
|
|
807
|
-
const nextLlmAfterData = { response, messages: nextLlmData.messages };
|
|
808
|
-
await this.caps.hooks.run('agent:llm:after', nextLlmAfterData);
|
|
809
|
-
response = nextLlmAfterData.response;
|
|
810
|
-
if (response.reasoningContent) {
|
|
811
|
-
allReasoning.push(response.reasoningContent);
|
|
907
|
+
}));
|
|
908
|
+
// 工具交出的图只服务紧接着的这一次请求:上一轮工具结果里的图在这里剥掉,否则多轮
|
|
909
|
+
// 工具迭代会让图片在上下文里累积(每张数百 token,且不受文本裁剪约束)。
|
|
910
|
+
for (let i = 0; i < llmBeforeData.messages.length; i++) {
|
|
911
|
+
const m = llmBeforeData.messages[i];
|
|
912
|
+
if (m.role === 'tool' && m.images) {
|
|
913
|
+
const { images: _spent, ...rest } = m;
|
|
914
|
+
llmBeforeData.messages[i] = rest;
|
|
812
915
|
}
|
|
813
916
|
}
|
|
814
|
-
//
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
while (responseData.retryRequested && attempt < maxRetries) {
|
|
840
|
-
attempt++;
|
|
841
|
-
this.logger.debug(`agent:reply:before 请求重试 (attempt=${attempt}/${maxRetries}, session=${incoming.sessionId}): ${responseData.retryFeedback ?? '(无反馈)'}`);
|
|
842
|
-
if (rawLlmContent.length > 0)
|
|
843
|
-
llmBeforeData.messages.push({ role: 'assistant', content: rawLlmContent });
|
|
844
|
-
llmBeforeData.messages.push({
|
|
845
|
-
role: 'system',
|
|
846
|
-
content: responseData.retryFeedback ?? '上一次回复未能通过格式校验,请严格按照系统提示中规定的格式重新输出。',
|
|
917
|
+
// 按原始 toolCalls 顺序将结果推入消息列表 + 时间线
|
|
918
|
+
for (const { toolCall, callId, result, resultImages, resultMetadata, toolName, toolArgs, startTime, endTime, } of parallelResults) {
|
|
919
|
+
// callId 随工具结果与时间线落库:没回复就收尾的一轮只落工具调用组,历史里的工具卡靠它对回这次调用
|
|
920
|
+
const toolMessage = {
|
|
921
|
+
role: 'tool',
|
|
922
|
+
content: result,
|
|
923
|
+
toolCallId: toolCall.id,
|
|
924
|
+
...(resultImages && resultImages.length > 0 ? { images: resultImages } : {}),
|
|
925
|
+
metadata: {
|
|
926
|
+
...resultMetadata,
|
|
927
|
+
[WellKnownMetadataKeys.TurnId]: turnId,
|
|
928
|
+
[WellKnownMetadataKeys.CallId]: callId,
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
llmBeforeData.messages.push(toolMessage);
|
|
932
|
+
toolMessages.push(toolMessage);
|
|
933
|
+
// 工具调用追加到本回合的统一时间线
|
|
934
|
+
turnSegments.push({
|
|
935
|
+
type: 'tool_call',
|
|
936
|
+
name: toolName,
|
|
937
|
+
args: toolArgs,
|
|
938
|
+
result,
|
|
939
|
+
startTime,
|
|
940
|
+
endTime,
|
|
941
|
+
callId,
|
|
847
942
|
});
|
|
848
|
-
const retryTrimmed = this.trimMessages(llmBeforeData.messages, tokenBudget);
|
|
849
|
-
const retryResult = await this.consumeStream(llm, {
|
|
850
|
-
messages: retryTrimmed,
|
|
851
|
-
tools: undefined,
|
|
852
|
-
maxTokens,
|
|
853
|
-
signal,
|
|
854
|
-
...(thinkOverride !== undefined ? { think: thinkOverride } : {}),
|
|
855
|
-
}, incoming.sessionId, incoming.platform);
|
|
856
|
-
turnSegments.push(...retryResult.segments);
|
|
857
|
-
response = retryResult;
|
|
858
|
-
accUsage(response.usage);
|
|
859
|
-
rawLlmContent = response.content ?? '';
|
|
860
|
-
if (response.reasoningContent)
|
|
861
|
-
allReasoning.push(response.reasoningContent);
|
|
862
|
-
// 用新输出再次跑 hook;hook 端根据 attempt 决定继续重试或走兜底(静默丢弃)
|
|
863
|
-
responseData.content = rawLlmContent;
|
|
864
|
-
responseData.archiveContent = undefined;
|
|
865
|
-
responseData.visibleContent = undefined;
|
|
866
|
-
responseData.retryRequested = false;
|
|
867
|
-
responseData.retryFeedback = undefined;
|
|
868
|
-
responseData.attempt = attempt;
|
|
869
|
-
await this.caps.hooks.run('agent:reply:before', responseData);
|
|
870
|
-
}
|
|
871
|
-
// 双保险:循环结束后若 hook 仍标记 retryRequested(理论上 persona 已在用尽时自动走兜底),
|
|
872
|
-
// 强制把 content 置空,避免原始未校验内容被外发。
|
|
873
|
-
if (responseData.retryRequested) {
|
|
874
|
-
this.logger.warn(`agent:reply:before 重试用尽但 retryRequested 仍为 true,强制丢弃回复内容 (session=${incoming.sessionId})`);
|
|
875
|
-
responseData.content = '';
|
|
876
|
-
responseData.retryRequested = false;
|
|
877
|
-
}
|
|
878
|
-
replyContent = responseData.content;
|
|
879
|
-
const archiveContent = responseData.archiveContent ?? rawLlmContent;
|
|
880
|
-
const visibleContent = responseData.visibleContent ?? replyContent;
|
|
881
|
-
// 重复检测:如果回复与最近一条 assistant 消息完全相同,视为模型"卡壳",静默跳过。
|
|
882
|
-
// 比的是 archiveContent(与落库同源口径)——启用 outputFormat 时历史里存的是整串 JSON,
|
|
883
|
-
// 拿解码后的纯文本去比永远不成立。
|
|
884
|
-
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
|
|
885
|
-
if (archiveContent && lastAssistant?.content && archiveContent === lastAssistant.content) {
|
|
886
|
-
this.logger.warn(`检测到重复回复,跳过发送 (session=${incoming.sessionId})`);
|
|
887
|
-
replyContent = '';
|
|
888
943
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
944
|
+
historyMessages += 1 + toolMessages.length;
|
|
945
|
+
await this.saveToolCallGroup(sessionId, assistantToolMessage, toolMessages);
|
|
946
|
+
savedGroups = true;
|
|
947
|
+
// 继续请求 LLM (再次经过 hooks),使用原始完整工具列表而非被上一轮 hooks 修改过的列表
|
|
948
|
+
const nextLlmData = {
|
|
949
|
+
messages: llmBeforeData.messages,
|
|
950
|
+
tools: [...originalTools],
|
|
951
|
+
acceptsImages,
|
|
952
|
+
sessionId,
|
|
953
|
+
platform,
|
|
954
|
+
source: first.source,
|
|
955
|
+
};
|
|
956
|
+
// 已物化的贡献按全局键跳过;本轮工具调用新注册的贡献(如新激活技能)增量落位
|
|
957
|
+
await assemblePromptContributions(this.caps, nextLlmData, inputs, {
|
|
958
|
+
buildTimeoutMs: this.promptBuildTimeoutMs,
|
|
959
|
+
signal,
|
|
899
960
|
});
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
961
|
+
await this.caps.hooks.use.run('agent:llm:before', nextLlmData, undefined, { warnOnStall: true });
|
|
962
|
+
await insertJoined(nextLlmData);
|
|
963
|
+
// 裁剪消息以确保不超过上下文窗口
|
|
964
|
+
const nextSent = trimForCall(nextLlmData.messages);
|
|
965
|
+
nextLlmData.messages = nextSent.messages;
|
|
966
|
+
const tN = Date.now();
|
|
967
|
+
const nextResult = await this.consumeStream(llm, {
|
|
968
|
+
messages: nextLlmData.messages,
|
|
969
|
+
tools: nextLlmData.tools.length > 0 ? nextLlmData.tools : undefined,
|
|
970
|
+
maxTokens,
|
|
971
|
+
signal,
|
|
972
|
+
...thinking,
|
|
973
|
+
}, turnId, sessionId, platform);
|
|
974
|
+
await reportUsage(nextSent, nextLlmData.tools, nextResult);
|
|
975
|
+
turnSegments.push(...nextResult.segments);
|
|
976
|
+
response = nextResult;
|
|
977
|
+
generating = nextResult.generating;
|
|
978
|
+
accUsage(response.usage);
|
|
979
|
+
this.debugLogResponse(response, Date.now() - tN, iterations);
|
|
980
|
+
const nextLlmAfterData = { response, messages: nextLlmData.messages };
|
|
981
|
+
await this.caps.hooks.use.run('agent:llm:after', nextLlmAfterData);
|
|
982
|
+
response = nextLlmAfterData.response;
|
|
983
|
+
}
|
|
984
|
+
// 最后一次模型调用返回了最后的回复:这一轮不再收输入,还没插进去的转为排队
|
|
985
|
+
this.queues.close(turn);
|
|
986
|
+
// 检测是否因工具调用次数达到上限而退出循环
|
|
987
|
+
const toolLimitReached = !withinLimit() && response.toolCalls != null && response.toolCalls.length > 0;
|
|
988
|
+
if (toolLimitReached) {
|
|
989
|
+
this.logger.warn(`工具调用达到上限 (${maxToolIterations}),session=${sessionId}`);
|
|
990
|
+
}
|
|
991
|
+
// 保留原始 LLM 输出:没有中间件改写落库内容时,落库的就是它
|
|
992
|
+
let rawLlmContent = response.content ?? '';
|
|
993
|
+
let replyContent = rawLlmContent;
|
|
994
|
+
// Hook: agent:reply:before — 插件可以修改最终回复
|
|
995
|
+
// JSON 解析/修复统一由 persona 的 agent:reply:before 钩子处理
|
|
996
|
+
const responseData = {
|
|
997
|
+
content: replyContent,
|
|
998
|
+
sessionId,
|
|
999
|
+
platform,
|
|
1000
|
+
attempt: 0,
|
|
1001
|
+
};
|
|
1002
|
+
const validateReply = async () => {
|
|
1003
|
+
const attemptedTools = reportOnly && !!response.toolCalls?.length;
|
|
1004
|
+
if (attemptedTools)
|
|
1005
|
+
responseData.content = '';
|
|
1006
|
+
await this.caps.hooks.use.run('agent:reply:before', responseData);
|
|
1007
|
+
// 误调用工具或静默先给模型一次纠正机会,复用格式重试预算,不能无限尝试或执行工具。
|
|
1008
|
+
const replyLinks = reportOnly ? (responseData.content.match(NOTICE_URL) ?? []) : [];
|
|
1009
|
+
const missingLink = noticeLinks.some(link => !replyLinks.some(candidate => candidate === link || candidate.replace(/[.!;:]+$/u, '') === link));
|
|
1010
|
+
if (reportOnly && (attemptedTools || !responseData.content.trim() || missingLink)) {
|
|
1011
|
+
responseData.content = '';
|
|
1012
|
+
responseData.maxRetries = Math.max(1, responseData.maxRetries ?? 0);
|
|
1013
|
+
responseData.retryRequested = true;
|
|
1014
|
+
responseData.retryFeedback =
|
|
1015
|
+
`${responseData.retryFeedback ?? ''}\n本轮只需转述已给出的宿主结果,不要调用工具或查询状态。` +
|
|
1016
|
+
'请沿用当前人设和规定的回复格式,给出包含结果与已有链接的非空回复。';
|
|
944
1017
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1018
|
+
};
|
|
1019
|
+
await validateReply();
|
|
1020
|
+
// 重试循环:当 hook(如 persona 的 outputFormat 解析)报告 retryRequested 时,
|
|
1021
|
+
// 把失败的 assistant 输出 + 系统反馈追加到消息列表,重新请求 LLM;最多按 maxRetries 次。
|
|
1022
|
+
// maxRetries 由 hook 端写入(plugin-persona 从 outputFormat.retries 读取,默认 1)。
|
|
1023
|
+
// 上游吐空流同样会被判不合格:此时没有可回放的失败输出,只追加系统反馈再请求一次。
|
|
1024
|
+
const maxRetries = Math.max(0, responseData.maxRetries ?? 0);
|
|
1025
|
+
let attempt = 0;
|
|
1026
|
+
while (responseData.retryRequested && attempt < maxRetries) {
|
|
1027
|
+
attempt++;
|
|
1028
|
+
this.logger.debug(`agent:reply:before 请求重试 (attempt=${attempt}/${maxRetries}, session=${sessionId}): ${responseData.retryFeedback ?? '(无反馈)'}`);
|
|
1029
|
+
if (rawLlmContent.length > 0)
|
|
1030
|
+
llmBeforeData.messages.push({ role: 'assistant', content: rawLlmContent });
|
|
1031
|
+
llmBeforeData.messages.push({
|
|
1032
|
+
role: 'system',
|
|
1033
|
+
content: responseData.retryFeedback ?? '上一次回复未能通过格式校验,请严格按照系统提示中规定的格式重新输出。',
|
|
953
1034
|
});
|
|
1035
|
+
const retrySent = trimForCall(llmBeforeData.messages);
|
|
1036
|
+
const retryResult = await this.consumeStream(llm, {
|
|
1037
|
+
messages: retrySent.messages,
|
|
1038
|
+
tools: undefined,
|
|
1039
|
+
maxTokens,
|
|
1040
|
+
signal,
|
|
1041
|
+
...thinking,
|
|
1042
|
+
}, turnId, sessionId, platform, !reportOnly);
|
|
1043
|
+
await reportUsage(retrySent, [], retryResult);
|
|
1044
|
+
turnSegments.push(...retryResult.segments);
|
|
1045
|
+
response = retryResult;
|
|
1046
|
+
accUsage(response.usage);
|
|
1047
|
+
rawLlmContent = response.content ?? '';
|
|
1048
|
+
// 用新输出再次跑 hook;hook 端根据 attempt 决定继续重试或走兜底(静默丢弃)
|
|
1049
|
+
responseData.content = rawLlmContent;
|
|
1050
|
+
responseData.archiveContent = undefined;
|
|
1051
|
+
responseData.visibleContent = undefined;
|
|
1052
|
+
responseData.retryRequested = false;
|
|
1053
|
+
responseData.retryFeedback = undefined;
|
|
1054
|
+
responseData.attempt = attempt;
|
|
1055
|
+
await validateReply();
|
|
954
1056
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1057
|
+
// 双保险:循环结束后若 hook 仍标记 retryRequested(理论上 persona 已在用尽时自动走兜底),
|
|
1058
|
+
// 强制把 content 置空,避免原始未校验内容被外发。
|
|
1059
|
+
if (responseData.retryRequested) {
|
|
1060
|
+
this.logger.warn(`agent:reply:before 重试用尽但 retryRequested 仍为 true,强制丢弃回复内容 (session=${sessionId})`);
|
|
1061
|
+
responseData.content = '';
|
|
1062
|
+
responseData.retryRequested = false;
|
|
1063
|
+
}
|
|
1064
|
+
replyContent = responseData.content;
|
|
1065
|
+
const archiveContent = responseData.archiveContent ?? rawLlmContent;
|
|
1066
|
+
const visibleContent = responseData.visibleContent ?? replyContent;
|
|
1067
|
+
// 提交点:此后的流结束标记、落库与外发成组执行。流结束后到这里之间(llm:after / 工具循环退出 /
|
|
1068
|
+
// reply:before / 重试)被停下的回合(停止键、禁言、停机等),走下方 AbortError 收尾,不落库、不外发。
|
|
1069
|
+
if (signal.aborted)
|
|
1070
|
+
throw new DOMException('Generation aborted', 'AbortError');
|
|
1071
|
+
// 发出流结束标记
|
|
1072
|
+
await this.caps.events.use.emit('outbound:stream', {
|
|
1073
|
+
sessionId,
|
|
1074
|
+
turnId,
|
|
1075
|
+
platform,
|
|
1076
|
+
done: true,
|
|
1077
|
+
toolLimitReached,
|
|
1078
|
+
});
|
|
1079
|
+
// 空回复(outputFormat 中 reply 字段为空字符串或仅空白)时静默,不发送消息
|
|
1080
|
+
if (replyContent.trim().length === 0) {
|
|
1081
|
+
this.logger.debug(`空回复,跳过发送 (session=${sessionId})`);
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
const visibleSegments = !reportOnly && turnSegments.length > 0 ? turnSegments : undefined;
|
|
1085
|
+
// 在 assistant 最终持久化时记录 modelInfo:前端从消息历史可还原 "这条回复
|
|
1086
|
+
// 由哪个 model 生成、用了多少 token",方便用户验证模型切换是否生效。
|
|
1087
|
+
const turnModelInfo = {
|
|
1088
|
+
provider: llm.providerId,
|
|
1089
|
+
model: llm.id,
|
|
1090
|
+
promptTokens: turnUsageAcc.promptTokens || undefined,
|
|
1091
|
+
completionTokens: turnUsageAcc.completionTokens || undefined,
|
|
1092
|
+
};
|
|
1093
|
+
const finalAssistantMetadata = {
|
|
1094
|
+
...(assistantMetadata ?? {}),
|
|
1095
|
+
[WellKnownMetadataKeys.ModelInfo]: turnModelInfo,
|
|
1096
|
+
[WellKnownMetadataKeys.TurnRecord]: recordOf('replied'),
|
|
1097
|
+
};
|
|
1098
|
+
// 落库的是信封(如 outputFormat 的整串 JSON)时,另带解码后的可见正文,供检索、摘要等按语义读历史的一方用
|
|
1099
|
+
if (visibleContent !== archiveContent) {
|
|
1100
|
+
finalAssistantMetadata[WellKnownMetadataKeys.VisibleContent] = visibleContent;
|
|
990
1101
|
}
|
|
991
|
-
|
|
992
|
-
this.
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1102
|
+
// 保存最终 assistant 回复:落库存信封原文,交给模型的历史取可见正文(historyForModel)
|
|
1103
|
+
await this.saveToMemory(sessionId, {
|
|
1104
|
+
role: 'assistant',
|
|
1105
|
+
content: archiveContent,
|
|
1106
|
+
reasoningContent: reportOnly ? undefined : response.reasoningContent,
|
|
1107
|
+
timestamp: Date.now(),
|
|
1108
|
+
metadata: finalAssistantMetadata,
|
|
1109
|
+
segments: visibleSegments,
|
|
999
1110
|
});
|
|
1111
|
+
recorded = true;
|
|
1112
|
+
// 外发给平台:只带正文与这条回复的模型和 token,思考与时间线只在上面落库的消息上;
|
|
1113
|
+
// 正文是她写的,其中的 @、媒体与引用标记交适配器解析
|
|
1000
1114
|
await this.dispatchOutbound({
|
|
1001
|
-
content:
|
|
1002
|
-
sessionId
|
|
1003
|
-
platform
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
message: incoming,
|
|
1011
|
-
reply: '',
|
|
1012
|
-
outcome: 'error',
|
|
1013
|
-
sessionId: incoming.sessionId,
|
|
1014
|
-
metadata: msgHookData.metadata,
|
|
1115
|
+
content: replyContent,
|
|
1116
|
+
sessionId,
|
|
1117
|
+
platform,
|
|
1118
|
+
...(first.hostNotice ? { hostNotice: { kind: first.hostNotice.kind, id: first.hostNotice.id } } : {}),
|
|
1119
|
+
...(first.hostNotice?.replyTo ? { replyTo: first.hostNotice.replyTo } : {}),
|
|
1120
|
+
markup: true,
|
|
1121
|
+
source: 'agent',
|
|
1122
|
+
turnId,
|
|
1123
|
+
modelInfo: turnModelInfo,
|
|
1015
1124
|
});
|
|
1016
1125
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1126
|
+
// Hook: agent:turn:after — 插件可以在完整消息周期结束后做后处理;在 try 里等它,它的中间件出错同样按出错收尾
|
|
1127
|
+
return await end(replyContent.trim().length === 0 ? 'silent' : 'replied', { reply: replyContent });
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
// 中止错误 — 静默退出,前端通过 outbound:stream done 清理 buffer。
|
|
1131
|
+
//
|
|
1132
|
+
// 被停下的一轮里已完成的工具调用组留在历史里,不回滚:saveToolCallGroup 只在并行工具全部落定后整组写入(单个工具的
|
|
1133
|
+
// 异常已在闭包内转成它自己的错误结果),不存在写了一半的组,留下的只可能是「已落定、副作用已发生」的工具调用对;删掉
|
|
1134
|
+
// 它们等于让 agent 忘记自己刚做过的有副作用的事(戳一戳、发送消息、调度任务等),下一轮会重复调用。不完整的组
|
|
1135
|
+
// (assistant tool_calls 缺 tool result)由 sanitizeToolCallHistory 在装载历史时兜底过滤。
|
|
1136
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
1137
|
+
await this.caps.events.use.emit('outbound:stream', { sessionId, turnId, platform, done: true });
|
|
1138
|
+
// 中止同样是回合终态:发 agent:turn:after(outcome=aborted) 让生命周期订阅方收尾——
|
|
1139
|
+
// 编排的 agent 节点在等这一轮的结果,不发它就要等到超时才失败。
|
|
1140
|
+
return end('aborted');
|
|
1141
|
+
}
|
|
1142
|
+
const error = textOf(err);
|
|
1143
|
+
this.logger.error(`处理消息失败: ${toSource(error)}`);
|
|
1144
|
+
// 异常同样要结束流:不发 done,会话里流式输出的条目不结束。顺序与正常收尾一致——先 done 再发消息。
|
|
1145
|
+
await this.caps.events.use.emit('outbound:stream', { sessionId, turnId, platform, done: true });
|
|
1146
|
+
if (!reportOnly) {
|
|
1147
|
+
const text = t('turnError', { error });
|
|
1148
|
+
await this.dispatchOutbound({ content: toSource(text), text, sessionId, platform, source: 'system', turnId });
|
|
1149
|
+
}
|
|
1150
|
+
// 异常也是回合终态:同样发 turn:after(outcome=error),等这一轮结果的订阅方才收得了尾。
|
|
1151
|
+
return end('error', { error });
|
|
1025
1152
|
}
|
|
1026
1153
|
}
|
|
1027
1154
|
/**
|
|
1028
|
-
* 构建发送给 LLM
|
|
1155
|
+
* 构建发送给 LLM 的消息列表。历史按会话的条数线成段丢弃(见 readHistory):historyLimit 为 0 或没设置时发全部活跃历史,
|
|
1156
|
+
* 跳过这一轮各条输入的归档。historyMessages 是会话活跃历史里对话消息的条数(事件标记不算;含已归档的这一轮的输入与没发给
|
|
1157
|
+
* 模型的部分),与之后的清洗、裁剪无关;history 是发出的历史那一段的消息对象,硬裁剪按它认出历史。历史之后依次是易变块、
|
|
1158
|
+
* 由排着的真人消息开的一轮的说明、多方房间里有被点名的输入时的焦点指引、各条输入。acceptsImages 是这一轮的主模型能不能看图,
|
|
1159
|
+
* media 缺席时由它定当轮的图交不交
|
|
1029
1160
|
*/
|
|
1030
|
-
async buildMessages(
|
|
1161
|
+
async buildMessages(turn, inputs, historyLimit, acceptsImages, multiParty, personaOpts) {
|
|
1031
1162
|
const messages = [];
|
|
1163
|
+
let historyMessages = 0;
|
|
1164
|
+
const { sessionId } = turn;
|
|
1032
1165
|
// 1. 系统提示
|
|
1033
1166
|
const systemPrompt = this.buildSystemPrompt(personaOpts);
|
|
1034
1167
|
messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
|
|
1035
|
-
// 2.
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
if (archivedIncoming && this.isSameMessage(m, archivedIncoming))
|
|
1043
|
-
continue;
|
|
1044
|
-
if (CONTROL_KINDS.includes(m.kind ?? ''))
|
|
1045
|
-
continue;
|
|
1046
|
-
// 为用户消息注入时间标注,帮助 LLM 理解时间先后
|
|
1047
|
-
if (m.role === 'user' && m.timestamp && m.content) {
|
|
1048
|
-
const timeLabel = formatTimeLabel(m.timestamp, now);
|
|
1049
|
-
if (timeLabel && !m.content.startsWith(`(${timeLabel})`)) {
|
|
1050
|
-
m.content = `(${timeLabel}) ${m.content}`;
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
messages.push(m);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
catch (err) {
|
|
1057
|
-
this.logger.warn('获取历史消息失败:', err);
|
|
1168
|
+
// 2. 历史消息:跳过这一轮已归档的输入
|
|
1169
|
+
try {
|
|
1170
|
+
const archived = inputs.flatMap(input => (input.archived ? [input.archived] : []));
|
|
1171
|
+
const past = await this.modelHistory(sessionId, historyLimit, archived);
|
|
1172
|
+
historyMessages = past.count;
|
|
1173
|
+
if (past.dropped > 0) {
|
|
1174
|
+
this.logger.warn(`历史消息中发现不完整工具调用组,已跳过 ${past.dropped} 条 (session=${sessionId})`);
|
|
1058
1175
|
}
|
|
1176
|
+
messages.push(...past.messages);
|
|
1059
1177
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
//
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1178
|
+
catch (err) {
|
|
1179
|
+
this.logger.warn('获取历史消息失败:', err);
|
|
1180
|
+
}
|
|
1181
|
+
const history = new Set(messages.slice(1));
|
|
1182
|
+
// 3. 这一轮的输入。带来源的注入各自成一轮,宿主通知与委派只有这一条
|
|
1183
|
+
const first = inputs[0].message;
|
|
1184
|
+
// 宿主通知分支:宿主撰写的事件通知不是任何人的发言,与 proactive 一样以 system 呈现、
|
|
1185
|
+
// 不推当前 user 消息与易变块。不可信段只在当轮接在宿主正文之后(归档只留正文),
|
|
1186
|
+
// 之后的回合从历史里只看得到宿主正文。
|
|
1187
|
+
if (first.hostNotice) {
|
|
1188
|
+
const { untrusted } = first.hostNotice;
|
|
1189
|
+
messages.push({
|
|
1190
|
+
role: 'system',
|
|
1191
|
+
content: `[宿主通知]\n${first.content}${untrusted ? `\n\n${untrusted}` : ''}`,
|
|
1192
|
+
metadata: { injector: WellKnownKinds.HostNotice },
|
|
1193
|
+
});
|
|
1194
|
+
return { messages, historyMessages, history };
|
|
1195
|
+
}
|
|
1196
|
+
// 3a. proactive 分支:系统代发给本会话的任务指令(如 workflow 的 agent 节点)
|
|
1075
1197
|
//
|
|
1076
|
-
//
|
|
1077
|
-
//
|
|
1078
|
-
//
|
|
1079
|
-
if (
|
|
1080
|
-
const sourceMatch = incoming.source?.match(/^proactive:from:(.+)$/);
|
|
1081
|
-
const sourceSessionId = sourceMatch?.[1];
|
|
1082
|
-
const sourceLine = sourceSessionId ? `源会话 ID: ${sourceSessionId}\n` : '';
|
|
1083
|
-
// 不在 hint 里写死 limit,让 LLM 按 plugin-tool-session 的 defaultLimit / 自身判断决定
|
|
1084
|
-
const hintLine = sourceSessionId
|
|
1085
|
-
? `如需了解源会话上下文(例如「按之前讨论的方案」之类的引用),调用 \`session_get_history(sessionId="${sourceSessionId}")\` 自行查阅(可按需附加 limit)。\n`
|
|
1086
|
-
: '';
|
|
1198
|
+
// 这条消息不是用户请求,必须作为 system 指令呈现给 LLM,否则 agent 会把它当成
|
|
1199
|
+
// 真实用户在指挥(典型 BUG:agent 看到 user 角色的任务指令,回复时使用「您」
|
|
1200
|
+
// 「您好」等措辞,把派发方当成了用户)。
|
|
1201
|
+
if (first.triggerType === 'proactive') {
|
|
1087
1202
|
messages.push({
|
|
1088
1203
|
role: 'system',
|
|
1089
1204
|
content: `[跨会话委派 — 非用户消息]\n` +
|
|
1090
|
-
|
|
1091
|
-
`任务: ${incoming.content}\n\n` +
|
|
1205
|
+
`任务: ${first.content}\n\n` +
|
|
1092
1206
|
`说明: 这是你(作为同一 agent 在另一会话的实例)派发给本会话的任务指令,` +
|
|
1093
|
-
`不是用户请求。处理时不要使用「您」「请问」等面向用户的措辞,按指令直接执行并简明回报结果。\n
|
|
1094
|
-
|
|
1095
|
-
metadata: { injector: WellKnownKinds.CrossSessionDelegation, sourceSessionId },
|
|
1207
|
+
`不是用户请求。处理时不要使用「您」「请问」等面向用户的措辞,按指令直接执行并简明回报结果。\n`,
|
|
1208
|
+
metadata: { injector: WellKnownKinds.CrossSessionDelegation },
|
|
1096
1209
|
});
|
|
1097
|
-
return messages;
|
|
1210
|
+
return { messages, historyMessages, history };
|
|
1098
1211
|
}
|
|
1099
1212
|
// 3b. 普通用户消息分支
|
|
1100
|
-
//
|
|
1101
|
-
|
|
1102
|
-
if (hasPreprocessed) {
|
|
1213
|
+
// 本轮附件有描述正文(媒体识别的描述、文件说明)时,引导 LLM 综合作答而非逐项转述
|
|
1214
|
+
if (inputs.some(input => hasAttachmentDescriptions(input.message))) {
|
|
1103
1215
|
messages.push({
|
|
1104
1216
|
role: 'system',
|
|
1105
1217
|
content: '用户消息中包含系统预处理的附件描述(图片识别结果和/或文件内容提取)。' +
|
|
@@ -1108,28 +1220,60 @@ class DefaultAgent {
|
|
|
1108
1220
|
metadata: { injector: 'system-other' },
|
|
1109
1221
|
});
|
|
1110
1222
|
}
|
|
1223
|
+
// 易变上下文(当前时间 / 会话环境 / 上一轮状态)落在历史**之后**:
|
|
1224
|
+
// provider 的前缀缓存从第一个 token 起逐位比对,这几块原本混在人设卡开头,
|
|
1225
|
+
// 秒级时间戳一变,人设 + 记忆 + 历史近 7 万 token 的前缀全部作废——实测每个
|
|
1226
|
+
// 新回合命中率恒为 0,只有同回合内的工具迭代(prompt 不重建)才命中。
|
|
1227
|
+
// 放到这里语义也更顺:它们本就是「此刻的事实」,紧挨当前消息说更自然。
|
|
1228
|
+
const persona = this.caps.persona.present ? this.caps.persona.use : undefined;
|
|
1229
|
+
const volatileCtx = persona?.getVolatilePrompt?.(personaOpts);
|
|
1230
|
+
if (volatileCtx) {
|
|
1231
|
+
messages.push({ role: 'system', content: volatileCtx, metadata: { injector: VOLATILE_INJECTOR } });
|
|
1232
|
+
}
|
|
1233
|
+
// 由排着的真人消息开的一轮:说明它们是上一轮进行期间到的;多方房间里全是没点名她的插话时,没有要接的可以不说话
|
|
1234
|
+
if (turn.queued && turn.human)
|
|
1235
|
+
messages.push(queuedNote(multiParty && !turn.batch.some(wasAddressed)));
|
|
1236
|
+
// 群聊焦点指引:多方房间里有被点名的输入时,紧贴在这一轮的输入之前,逐条列出被点名的那几条
|
|
1237
|
+
const focus = multiParty ? this.focusOf(inputs) : null;
|
|
1238
|
+
if (focus)
|
|
1239
|
+
messages.push(focus);
|
|
1240
|
+
for (const input of inputs)
|
|
1241
|
+
messages.push(await this.inputMessage(input, acceptsImages));
|
|
1242
|
+
return { messages, historyMessages, history };
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* 一条输入发给模型的写法:`(时间) <归档正文>`。正文由 message-archive 烘焙(发送者前缀、引用回复、附件描述);存档不在时
|
|
1246
|
+
* 按同一份拼法与同一条说话人规则自己拼(schema-message 的 buildIncomingContent、api-message-archive 的 speakerOf)。时间取
|
|
1247
|
+
* 归档时间戳,即进入这一轮的时刻:之后进历史时是同一个标注,前缀不变。带图的挂上它自己的图。空闲开话题的提示以 system 交出
|
|
1248
|
+
*/
|
|
1249
|
+
async inputMessage({ message, archived }, acceptsImages) {
|
|
1250
|
+
const body = archived?.content ?? buildIncomingContent(message, this.speakerOf(message));
|
|
1251
|
+
const at = archived?.timestamp ?? Date.now();
|
|
1252
|
+
const content = `(${formatTimeLabel(at, Date.now())}) ${body}`;
|
|
1253
|
+
// 空闲提示是宿主写给模型的指令,不是谁的发言:标成本轮的指令块,锚位、裁剪与 DeepSeek 的 system 归位都按它认
|
|
1254
|
+
if (message.triggerType === 'idle') {
|
|
1255
|
+
return { role: 'system', content, metadata: { injector: WellKnownKinds.IdlePrompt } };
|
|
1256
|
+
}
|
|
1111
1257
|
const userMessage = {
|
|
1112
1258
|
role: 'user',
|
|
1113
|
-
content
|
|
1114
|
-
name: getMessageName(
|
|
1115
|
-
timestamp:
|
|
1259
|
+
content,
|
|
1260
|
+
name: getMessageName(message.userId),
|
|
1261
|
+
timestamp: at,
|
|
1116
1262
|
};
|
|
1117
1263
|
// 多模态:把 attachments 中的 image 项传递给 LLM(视觉模型多模态字段)
|
|
1118
|
-
const imageAtts =
|
|
1264
|
+
const imageAtts = message.attachments?.filter(a => a.kind === 'image') ?? [];
|
|
1119
1265
|
if (imageAtts.length > 0) {
|
|
1120
|
-
if (this.caps.media.
|
|
1266
|
+
if (this.caps.media.present) {
|
|
1121
1267
|
// media 在场:原样透传。出口形态由 media 的 agent:llm:before 中间件规范化
|
|
1122
1268
|
//(describe 剥离 / passthrough 物化),此处不做任何转换——它的描述缓存键与
|
|
1123
1269
|
// 动图提示都按 attachment.data 原串命中,改形态会打散。
|
|
1124
1270
|
userMessage.images = imageAtts.map(a => a.data);
|
|
1125
1271
|
}
|
|
1126
|
-
else {
|
|
1127
|
-
// media
|
|
1128
|
-
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
// 就丢弃该图。其余一切形态(data:/http(s)/file:// 等)原样透传,与 media 出现
|
|
1132
|
-
// 之前的行为一致,各 provider 按自己的解析器处置。
|
|
1272
|
+
else if (acceptsImages) {
|
|
1273
|
+
// media 缺席兜底(主模型不能看图时当轮的图不交,正文里的占位照旧):存储地址(适配器落盘的附件引用)provider 解不了(当 base64 校验,整轮请求被拒,见
|
|
1274
|
+
// test/plugins/media-model-images.test.ts 抬头的 400 事故),经 storage 读出转 data URI,保住「视觉主模型
|
|
1275
|
+
// 直通」的基础体验;读不出(storage 缺席/读失败)就丢弃该图。其余形态(data URI、http(s)、file:// 等)原样
|
|
1276
|
+
// 交给 provider,各按自己的解析器处置。
|
|
1133
1277
|
const images = [];
|
|
1134
1278
|
for (const a of imageAtts) {
|
|
1135
1279
|
const img = await this.materializeImageWithoutMedia(a.data, a.mimeType);
|
|
@@ -1142,23 +1286,31 @@ class DefaultAgent {
|
|
|
1142
1286
|
userMessage.images = images;
|
|
1143
1287
|
}
|
|
1144
1288
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1289
|
+
return userMessage;
|
|
1290
|
+
}
|
|
1291
|
+
/** 一条输入的说话人:与存档的发送者前缀同一条规则(api-message-archive 的 speakerOf) */
|
|
1292
|
+
speakerOf(message) {
|
|
1293
|
+
return speakerOf(message, {
|
|
1294
|
+
sessionManager: this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined,
|
|
1295
|
+
authority: this.caps.authority.present ? this.caps.authority.use : undefined,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
/** 停下标记里停下的人的称呼:与发送者前缀同一条规则,当他在这个会话里说了一句话;没有停下的人时没有 */
|
|
1299
|
+
stopperLabel(sessionId, by) {
|
|
1300
|
+
const speaker = by && this.speakerOf({ content: '', sessionId, platform: by.platform, userId: by.userId });
|
|
1301
|
+
return getSenderLabel(speaker?.nickname, speaker?.userId);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* 这一轮被点名的输入的焦点指引:按时间与发送者账号逐条列出(名字由各条正文的发送者前缀给,群友自己设的名字不进系统消息);
|
|
1305
|
+
* 没有被点名的为 null
|
|
1306
|
+
*/
|
|
1307
|
+
focusOf(inputs) {
|
|
1308
|
+
return buildFocusGuidance(inputs.flatMap(({ message, archived }) => {
|
|
1309
|
+
if (!wasAddressed(message))
|
|
1310
|
+
return [];
|
|
1311
|
+
const time = formatTimeLabel(archived?.timestamp ?? Date.now(), Date.now());
|
|
1312
|
+
return [message.userId ? `(${time}) ${message.userId}` : `(${time})`];
|
|
1313
|
+
}));
|
|
1162
1314
|
}
|
|
1163
1315
|
/**
|
|
1164
1316
|
* 裁剪预算 = 上下文长度 × trimThresholdRatio − 最大输出 token − 512 安全余量(下限 1024)。
|
|
@@ -1172,7 +1324,7 @@ class DefaultAgent {
|
|
|
1172
1324
|
* 构建系统提示词
|
|
1173
1325
|
*/
|
|
1174
1326
|
buildSystemPrompt(personaOpts) {
|
|
1175
|
-
const persona = this.caps.persona.
|
|
1327
|
+
const persona = this.caps.persona.present ? this.caps.persona.use : undefined;
|
|
1176
1328
|
const base = persona
|
|
1177
1329
|
? this.systemPrompt
|
|
1178
1330
|
? `${persona.getSystemPrompt(personaOpts)}\n\n${this.systemPrompt}`
|
|
@@ -1187,11 +1339,10 @@ class DefaultAgent {
|
|
|
1187
1339
|
return estimateTokens(messages);
|
|
1188
1340
|
}
|
|
1189
1341
|
/**
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
* 包含各维度的 token 使用分解,供前端展示和自动压缩判断。
|
|
1342
|
+
* 发 token:usage:各维度的拆分按消息与工具定义估算,used 取模型服务返回的输入 token,没有时取估算。
|
|
1343
|
+
* 等全部监听者处理完才返回:摘要插件在回合结束时按这一轮最后一条用量判定,不能让它晚于回合结束
|
|
1193
1344
|
*/
|
|
1194
|
-
emitTokenUsage(sessionId, platform, messages, tools,
|
|
1345
|
+
async emitTokenUsage({ sessionId, platform, turnId, contextWindow, maxTokens, tokenBudget, messages, tools, trimmed, untrimmedHistory, historyMessages, historyLimit, promptTokens, }) {
|
|
1195
1346
|
// 按来源分类统计各消息的 token 占用
|
|
1196
1347
|
let historyTokens = 0;
|
|
1197
1348
|
let toolResultTokens = 0;
|
|
@@ -1250,11 +1401,11 @@ class DefaultAgent {
|
|
|
1250
1401
|
personaTokens += t;
|
|
1251
1402
|
}
|
|
1252
1403
|
}
|
|
1253
|
-
else {
|
|
1404
|
+
else if (rawSource) {
|
|
1254
1405
|
// 其余注入者(memory-history/user-profile/user-relation/自定义插件等):
|
|
1255
|
-
// 计入 systemOther
|
|
1406
|
+
// 计入 systemOther 汇总之余按全局键点名,预算争抢时能看出是谁在吃;界面按键里的实例前缀合并到插件
|
|
1256
1407
|
systemOtherTokens += t;
|
|
1257
|
-
injectorTokens[
|
|
1408
|
+
injectorTokens[rawSource] = (injectorTokens[rawSource] ?? 0) + t;
|
|
1258
1409
|
}
|
|
1259
1410
|
}
|
|
1260
1411
|
else if (msg.role === 'tool') {
|
|
@@ -1273,17 +1424,24 @@ class DefaultAgent {
|
|
|
1273
1424
|
platformTokens +
|
|
1274
1425
|
subtaskTokens +
|
|
1275
1426
|
systemOtherTokens;
|
|
1276
|
-
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1427
|
+
// 服务商回的数没人核过:不是正的有限数(缺省、0、NaN)时按估算,一次调用的输入不会是 0 token
|
|
1428
|
+
const used = promptTokens !== undefined && promptTokens > 0 && Number.isFinite(promptTokens)
|
|
1429
|
+
? promptTokens
|
|
1430
|
+
: systemTokens + historyTokens + toolResultTokens + toolDefsTokens;
|
|
1431
|
+
const usageRatio = contextWindow > 0 ? used / contextWindow : 0;
|
|
1432
|
+
await this.caps.events.use.emit('token:usage', {
|
|
1280
1433
|
sessionId,
|
|
1434
|
+
turnId,
|
|
1281
1435
|
platform,
|
|
1282
|
-
contextWindow
|
|
1436
|
+
contextWindow,
|
|
1283
1437
|
maxTokens,
|
|
1284
1438
|
tokenBudget,
|
|
1285
|
-
used
|
|
1439
|
+
used,
|
|
1286
1440
|
usageRatio,
|
|
1441
|
+
trimmed,
|
|
1442
|
+
untrimmedHistory,
|
|
1443
|
+
historyMessages,
|
|
1444
|
+
historyLimit,
|
|
1287
1445
|
breakdown: {
|
|
1288
1446
|
system: systemTokens,
|
|
1289
1447
|
persona: personaTokens,
|
|
@@ -1299,8 +1457,19 @@ class DefaultAgent {
|
|
|
1299
1457
|
toolDefs: toolDefsTokens,
|
|
1300
1458
|
reservedForReply: maxTokens,
|
|
1301
1459
|
},
|
|
1302
|
-
|
|
1303
|
-
|
|
1460
|
+
parts: [
|
|
1461
|
+
{ label: t('partHistory'), tokens: historyTokens },
|
|
1462
|
+
{ label: t('partToolResults'), tokens: toolResultTokens },
|
|
1463
|
+
{ label: t('partToolDefs'), tokens: toolDefsTokens },
|
|
1464
|
+
{ label: t('partPersona'), tokens: personaTokens },
|
|
1465
|
+
{ label: t('partMemorySummary'), tokens: memorySummaryTokens },
|
|
1466
|
+
{ label: t('partMemoryVector'), tokens: memoryVectorTokens },
|
|
1467
|
+
{ label: t('partSkills'), tokens: skillsTokens },
|
|
1468
|
+
{ label: t('partPlatform'), tokens: platformTokens },
|
|
1469
|
+
{ label: t('partSubtask'), tokens: subtaskTokens },
|
|
1470
|
+
{ label: t('partSystemOther'), tokens: systemOtherTokens, injectors: injectorTokens },
|
|
1471
|
+
],
|
|
1472
|
+
});
|
|
1304
1473
|
// 节流日志:与 WebUI 看到的同一份数据,让 CLI / 文件日志使用者也能看见预算消耗。
|
|
1305
1474
|
// 跨过 0.5 / 0.7 / 0.85 阈值必输出,否则每 10 轮一次。
|
|
1306
1475
|
const bucket = usageRatio >= 0.85 ? 3 : usageRatio >= 0.7 ? 2 : usageRatio >= 0.5 ? 1 : 0;
|
|
@@ -1309,7 +1478,7 @@ class DefaultAgent {
|
|
|
1309
1478
|
const crossedBucket = bucket !== st.lastRatioBucket;
|
|
1310
1479
|
if (crossedBucket || st.count % 10 === 1) {
|
|
1311
1480
|
const tag = bucket >= 3 ? 'CRITICAL' : bucket >= 2 ? 'WARN' : bucket >= 1 ? 'INFO' : 'OK';
|
|
1312
|
-
this.logger.info(`[token-usage:${tag}] ${sessionId} ${
|
|
1481
|
+
this.logger.info(`[token-usage:${tag}] ${sessionId} ${used}/${contextWindow} (${(usageRatio * 100).toFixed(1)}%) ` +
|
|
1313
1482
|
`sys=${systemTokens}(persona=${personaTokens} mem=${memorySummaryTokens + memoryVectorTokens} ` +
|
|
1314
1483
|
`skills=${skillsTokens} subtask=${subtaskTokens} other=${systemOtherTokens}) ` +
|
|
1315
1484
|
`hist=${historyTokens} tools=${toolResultTokens}+${toolDefsTokens}def reserve=${maxTokens}`);
|
|
@@ -1319,24 +1488,19 @@ class DefaultAgent {
|
|
|
1319
1488
|
}
|
|
1320
1489
|
/**
|
|
1321
1490
|
* media 缺席时的图片物化兜底(仅 buildMessages 的 images 拷贝点使用)。
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*(data:/http(s)/file:// 等,各 provider 自行解析)——刻意不做更宽的判别:
|
|
1326
|
-
* 透传即维持 media 出现之前的既有行为,改写范围只覆盖已知必炸的那一种。
|
|
1491
|
+
* 只翻译存储地址(适配器落盘的附件引用):provider 当 base64 校验 → 400 整轮被拒,经 storage 读出转 data URI;
|
|
1492
|
+
* storage 缺席/读失败返回 null 由调用方丢弃该图。**其余形态原样交给 provider**(data URI、http(s)、file:// 等,各 provider
|
|
1493
|
+
* 自行解析)。
|
|
1327
1494
|
*/
|
|
1328
1495
|
async materializeImageWithoutMedia(data, mimeType) {
|
|
1329
1496
|
const t = data.trim();
|
|
1330
|
-
|
|
1331
|
-
if (!rel)
|
|
1497
|
+
if (!isStorageUri(t))
|
|
1332
1498
|
return t;
|
|
1333
|
-
//
|
|
1334
|
-
|
|
1335
|
-
const storage = createStorageGateway(this.caps.storage);
|
|
1499
|
+
// storage 按 URI 的根名路由;根不认识(含 storage 整体缺席)时抛错,落到下面的 catch
|
|
1500
|
+
const storage = this.caps.storage.use;
|
|
1336
1501
|
try {
|
|
1337
|
-
const raw = (await storage.readFile(
|
|
1338
|
-
// mime 优先取附件自带的 mimeType;缺省按 basename
|
|
1339
|
-
// png/jpg/gif/webp 四种,见 attachment-cache 的 detectExtensionFromBuffer)
|
|
1502
|
+
const raw = (await storage.readFile(t));
|
|
1503
|
+
// mime 优先取附件自带的 mimeType;缺省按 basename 扩展名推断(适配器落盘的扩展名取文件头判定的格式)
|
|
1340
1504
|
const base = t.slice(t.lastIndexOf('/') + 1);
|
|
1341
1505
|
const dot = base.lastIndexOf('.');
|
|
1342
1506
|
const ext = dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
|
|
@@ -1349,243 +1513,183 @@ class DefaultAgent {
|
|
|
1349
1513
|
}
|
|
1350
1514
|
}
|
|
1351
1515
|
/**
|
|
1352
|
-
*
|
|
1516
|
+
* 硬裁剪这次要发出的列表,使估算回到预算内;trimmed 报告进入了硬裁剪:入口估算超过了预算(不一定真裁掉了内容),或历史超过
|
|
1517
|
+
* 它的可用量、丢了一段。不记日志:token:request 的快照同样走这里,删了消息的日志由真实回合的调用点记。没改动的消息原样交出
|
|
1518
|
+
* (同一个对象:首次调用的结果写回底稿之后,history 照样认得出历史),改动的换成副本。
|
|
1353
1519
|
*
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1520
|
+
* 列表分三段:头部(第一条非 system 之前:人设、技能、摘要等;属于历史的 system 消息不算)、历史(history 里的消息对象,即
|
|
1521
|
+
* 读出、清洗后发出的那段)、本轮(其余:易变块、本轮材料与提示、本轮的输入、本轮追加的工具调用与结果)。依次裁,第 1 步总做,
|
|
1522
|
+
* 之后任一步回到预算内即停:
|
|
1523
|
+
* 1. 历史按 token 成段丢:单位 U = ⌈预算 / 4⌉,历史可用 H = 预算 − 头部 − U(本轮固定留一个单位);历史超过 H 时(不看本轮
|
|
1524
|
+
* 多大)从第一条起丢到累计第一次达到 ⌈(历史 − H) / U⌉·U 的那一条,切点落在工具调用组中间时组里剩下的结果一起丢(对齐同
|
|
1525
|
+
* readHistory)。切点只由头部与历史定:头部两次压缩之间不变、历史只追加,切点只在历史跨过一个单位时前移,两次丢弃之间
|
|
1526
|
+
* 发出的开头逐字不变,前缀缓存才命中
|
|
1527
|
+
* 2. 只动本轮追加的:截短过长的工具结果、缩推理内容、把较早的工具调用组压成摘要。历史里的不动(会改开头),超了按第 1 步
|
|
1528
|
+
* 整段丢
|
|
1529
|
+
* 3. 本轮超过一个单位时仍超:历史接着按同一套单位往后丢,丢到累计第一次达到下一个单位整数倍的那一条(本轮在同一个单位内
|
|
1530
|
+
* 变大时切点不动)或丢完;会改开头
|
|
1531
|
+
* 4. 会改开头:注入的 system 块合计超过 memoryTokenBudget 时按比例缩,仍超时从最靠前的删起
|
|
1532
|
+
* 5. 最后手段:最后一组工具调用也截短过长的结果、缩推理内容,请求不超窗发出
|
|
1533
|
+
* 始终不动:首条 system、本轮的输入(user 消息,与宿主通知、委派、空闲开话题那一轮的指令块)。不删不压、只在第 5 步截短:
|
|
1534
|
+
* 最后一组工具调用、末条消息(格式重试的反馈)
|
|
1361
1535
|
*/
|
|
1362
|
-
trimMessages(messages, budget) {
|
|
1363
|
-
|
|
1536
|
+
trimMessages(messages, budget, history) {
|
|
1537
|
+
let result = [...messages];
|
|
1364
1538
|
let estimated = this.estimateTokens(result);
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
const
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
return indices;
|
|
1375
|
-
};
|
|
1376
|
-
// === Phase 1: 缩减超出预留额度的 system 消息 ===
|
|
1377
|
-
{
|
|
1378
|
-
const sysIdx = findSystemIndices();
|
|
1379
|
-
const sysTokens = sysIdx.reduce((s, i) => s + estimateMsgTokens(result[i]), 0);
|
|
1380
|
-
if (sysTokens > this.memoryTokenBudget && sysIdx.length > 0) {
|
|
1381
|
-
const ratio = this.memoryTokenBudget / sysTokens;
|
|
1382
|
-
for (const idx of sysIdx) {
|
|
1383
|
-
const msg = result[idx];
|
|
1384
|
-
if (msg.content && msg.content.length > 200) {
|
|
1385
|
-
const oldTokens = estimateMsgTokens(msg);
|
|
1386
|
-
const targetLen = Math.max(200, Math.floor(msg.content.length * ratio));
|
|
1387
|
-
msg.content = truncateChars(msg.content, targetLen, '\n... [记忆内容已缩减]');
|
|
1388
|
-
estimated -= oldTokens - estimateMsgTokens(msg);
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
if (estimated <= budget)
|
|
1394
|
-
return result;
|
|
1395
|
-
// === Phase 2: 截断过长的 tool 输出 ===
|
|
1396
|
-
for (let i = 1; i < result.length - 1; i++) {
|
|
1397
|
-
if (estimated <= budget)
|
|
1539
|
+
const over = estimated > budget;
|
|
1540
|
+
const directive = (m) => DIRECTIVE_KINDS.includes(String(m.metadata?.injector));
|
|
1541
|
+
// === 1. 历史按 token 成段丢 ===
|
|
1542
|
+
const unit = Math.ceil(budget / 4);
|
|
1543
|
+
/** 丢到不少于 tokens 的下一个单位整数倍 */
|
|
1544
|
+
const onGrid = (tokens) => Math.ceil(tokens / unit) * unit;
|
|
1545
|
+
let head = 0;
|
|
1546
|
+
for (const m of result) {
|
|
1547
|
+
if (m.role !== 'system')
|
|
1398
1548
|
break;
|
|
1399
|
-
if (
|
|
1400
|
-
|
|
1401
|
-
result[i].content = truncateChars(result[i].content, 500, '\n... [工具输出已截断]');
|
|
1402
|
-
estimated -= oldTokens - estimateMsgTokens(result[i]);
|
|
1403
|
-
}
|
|
1549
|
+
if (!history.has(m))
|
|
1550
|
+
head += estimateMsgTokens(m);
|
|
1404
1551
|
}
|
|
1552
|
+
const past = result.filter(m => history.has(m));
|
|
1553
|
+
let cut = 0;
|
|
1554
|
+
let dropped = 0;
|
|
1555
|
+
/** 从历史开头起丢到累计估算达到 tokens 的那一条(含);切点落在工具调用组中间时,组里剩下的结果一起丢 */
|
|
1556
|
+
const dropPast = (tokens) => {
|
|
1557
|
+
const from = cut;
|
|
1558
|
+
const before = dropped;
|
|
1559
|
+
while (cut < past.length && dropped < tokens)
|
|
1560
|
+
dropped += estimateMsgTokens(past[cut++]);
|
|
1561
|
+
while (cut < past.length && past[cut].role === 'tool')
|
|
1562
|
+
dropped += estimateMsgTokens(past[cut++]);
|
|
1563
|
+
const gone = new Set(past.slice(from, cut));
|
|
1564
|
+
result = result.filter(m => !gone.has(m));
|
|
1565
|
+
estimated -= dropped - before;
|
|
1566
|
+
};
|
|
1567
|
+
const room = budget - head - unit;
|
|
1568
|
+
const pastTokens = past.reduce((sum, m) => sum + estimateMsgTokens(m), 0);
|
|
1569
|
+
if (pastTokens > room)
|
|
1570
|
+
dropPast(onGrid(pastTokens - room));
|
|
1405
1571
|
if (estimated <= budget)
|
|
1406
|
-
return result;
|
|
1407
|
-
// ===
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
rcIndices.push(i);
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1418
|
-
// 从最旧开始,先截断非最后一条的推理,仍不够时截断最后一条
|
|
1419
|
-
// 注意:DeepSeek 思考模式要求历史中凡有 reasoning_content 的消息必须原样带回,
|
|
1420
|
-
// 不能将字段设为 undefined,否则 API 返回 400。因此只截断,不删除。
|
|
1421
|
-
for (let k = 0; k < rcIndices.length && estimated > budget; k++) {
|
|
1422
|
-
const idx = rcIndices[k];
|
|
1423
|
-
const msg = result[idx];
|
|
1424
|
-
const oldTokens = estimateMsgTokens(msg);
|
|
1425
|
-
// 所有条目统一保留头部 200 字符(最新条保留稍多以保持上下文连贯性)
|
|
1426
|
-
const keepLen = k < rcIndices.length - 1 ? 200 : 400;
|
|
1427
|
-
msg.reasoningContent = truncateChars(msg.reasoningContent, keepLen, '\n... [推理内容已缩减]');
|
|
1428
|
-
estimated -= oldTokens - estimateMsgTokens(msg);
|
|
1429
|
-
}
|
|
1572
|
+
return { messages: result, trimmed: over || cut > 0 };
|
|
1573
|
+
// === 2. 只动本轮追加的,最后一组工具调用(本轮最后一条带调用的 assistant 与紧跟的结果)不动 ===
|
|
1574
|
+
const last = new Set();
|
|
1575
|
+
for (let i = result.length - 1; i >= 0; i--) {
|
|
1576
|
+
if (history.has(result[i]) || !result[i].toolCalls?.length)
|
|
1577
|
+
continue;
|
|
1578
|
+
for (let j = i; j < result.length && (j === i || result[j].role === 'tool'); j++)
|
|
1579
|
+
last.add(result[j]);
|
|
1580
|
+
break;
|
|
1430
1581
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
end++;
|
|
1442
|
-
groups.push({ start: j, end });
|
|
1443
|
-
j = end - 1;
|
|
1444
|
-
}
|
|
1582
|
+
const appended = (m) => !history.has(m) && !last.has(m);
|
|
1583
|
+
/** 截短 pick 选中的过长工具结果(1500 字以上的留 500) */
|
|
1584
|
+
const shortenResults = (pick) => {
|
|
1585
|
+
for (let i = 0; i < result.length && estimated > budget; i++) {
|
|
1586
|
+
const m = result[i];
|
|
1587
|
+
if (m.role !== 'tool' || !pick(m) || !m.content || m.content.length <= 1500)
|
|
1588
|
+
continue;
|
|
1589
|
+
const shorter = { ...m, content: truncateChars(m.content, 500, '\n... [工具输出已截断]') };
|
|
1590
|
+
estimated -= estimateMsgTokens(m) - estimateMsgTokens(shorter);
|
|
1591
|
+
result[i] = shorter;
|
|
1445
1592
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
const
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
// 构建紧凑摘要
|
|
1463
|
-
const aMsg = result[start];
|
|
1464
|
-
const names = aMsg.toolCalls.map(tc => tc.function.name);
|
|
1465
|
-
const parts = names.map((n, idx) => `${n} → ${toolPreviews[idx] ?? '(无结果)'}`);
|
|
1466
|
-
let text = `[历史工具调用] ${parts.join(' | ')}`;
|
|
1467
|
-
if (aMsg.content)
|
|
1468
|
-
text = `${aMsg.content}\n${text}`;
|
|
1469
|
-
const summaryMsg = { role: 'assistant', content: text };
|
|
1470
|
-
const summaryTokens = estimateMsgTokens(summaryMsg);
|
|
1471
|
-
// 仅在确实能节省 token 时压缩
|
|
1472
|
-
if (summaryTokens < groupTokens) {
|
|
1473
|
-
result.splice(start, groupLen, summaryMsg);
|
|
1474
|
-
estimated -= groupTokens - summaryTokens;
|
|
1475
|
-
offset += groupLen - 1;
|
|
1476
|
-
}
|
|
1593
|
+
};
|
|
1594
|
+
/**
|
|
1595
|
+
* 缩 pick 选中的推理内容:从旧到新,旧的留 200 字,最新一条留 400。DeepSeek 思考模式要求历史中凡有 reasoning_content 的消息
|
|
1596
|
+
* 原样带回这个字段,设成 undefined 会被拒(400),所以只截断、不删除
|
|
1597
|
+
*/
|
|
1598
|
+
const shrinkReasoning = (pick) => {
|
|
1599
|
+
const reasoned = result.flatMap((m, i) => m.role === 'assistant' && pick(m) && (m.reasoningContent?.length ?? 0) > 200 ? [i] : []);
|
|
1600
|
+
for (let k = 0; k < reasoned.length && estimated > budget; k++) {
|
|
1601
|
+
const m = result[reasoned[k]];
|
|
1602
|
+
const keep = k < reasoned.length - 1 ? 200 : 400;
|
|
1603
|
+
const shorter = {
|
|
1604
|
+
...m,
|
|
1605
|
+
reasoningContent: truncateChars(m.reasoningContent ?? '', keep, '\n... [推理内容已缩减]'),
|
|
1606
|
+
};
|
|
1607
|
+
estimated -= estimateMsgTokens(m) - estimateMsgTokens(shorter);
|
|
1608
|
+
result[reasoned[k]] = shorter;
|
|
1477
1609
|
}
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1610
|
+
};
|
|
1611
|
+
shortenResults(appended);
|
|
1612
|
+
shrinkReasoning(appended);
|
|
1613
|
+
// 较早的工具调用组从最旧的起压成一条摘要(确实省 token 时)
|
|
1614
|
+
for (let i = 0; i < result.length && estimated > budget; i++) {
|
|
1615
|
+
const call = result[i];
|
|
1616
|
+
if (call.role !== 'assistant' || !call.toolCalls?.length || !appended(call))
|
|
1617
|
+
continue;
|
|
1618
|
+
let end = i + 1;
|
|
1619
|
+
while (end < result.length && result[end].role === 'tool')
|
|
1620
|
+
end++;
|
|
1621
|
+
const outputs = result.slice(i + 1, end);
|
|
1622
|
+
const before = [call, ...outputs].reduce((sum, m) => sum + estimateMsgTokens(m), 0);
|
|
1623
|
+
const previews = outputs.map(({ content }) => {
|
|
1624
|
+
const output = content ?? '';
|
|
1625
|
+
return output.length > 100 ? `${output.slice(0, 100)}...` : output;
|
|
1626
|
+
});
|
|
1627
|
+
const parts = call.toolCalls.map((tc, idx) => `${tc.function.name} → ${previews[idx] ?? '(无结果)'}`);
|
|
1628
|
+
const text = `[历史工具调用] ${parts.join(' | ')}`;
|
|
1629
|
+
const summary = { role: 'assistant', content: call.content ? `${call.content}\n${text}` : text };
|
|
1630
|
+
const after = estimateMsgTokens(summary);
|
|
1631
|
+
if (after < before) {
|
|
1632
|
+
result.splice(i, end - i, summary);
|
|
1633
|
+
estimated -= before - after;
|
|
1482
1634
|
}
|
|
1483
|
-
return result;
|
|
1484
1635
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
}
|
|
1500
|
-
// 保护最新的 user 消息(用户发起任务的请求,删掉会导致模型丢失任务上下文)
|
|
1501
|
-
let lastUserIdx = -1;
|
|
1502
|
-
for (let j = result.length - 1; j >= 1; j--) {
|
|
1503
|
-
if (result[j].role === 'user') {
|
|
1504
|
-
lastUserIdx = j;
|
|
1505
|
-
break;
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
const adjustAfterSplice = (splicedAt) => {
|
|
1509
|
-
const updated = new Set();
|
|
1510
|
-
for (const idx of lastGroupIndices)
|
|
1511
|
-
updated.add(idx > splicedAt ? idx - 1 : idx);
|
|
1512
|
-
lastGroupIndices.clear();
|
|
1513
|
-
for (const idx of updated)
|
|
1514
|
-
lastGroupIndices.add(idx);
|
|
1515
|
-
if (lastUserIdx > splicedAt)
|
|
1516
|
-
lastUserIdx--;
|
|
1517
|
-
};
|
|
1518
|
-
let i = 1;
|
|
1519
|
-
while (estimated > budget && i < result.length - 1) {
|
|
1520
|
-
if (result[i].role === 'system' || lastGroupIndices.has(i) || i === lastUserIdx) {
|
|
1521
|
-
i++;
|
|
1522
|
-
continue;
|
|
1523
|
-
}
|
|
1524
|
-
// assistant(含toolCalls) + 紧跟的 tool 消息成组删除
|
|
1525
|
-
if (result[i].role === 'assistant' && result[i].toolCalls?.length) {
|
|
1526
|
-
estimated -= estimateMsgTokens(result[i]);
|
|
1527
|
-
result.splice(i, 1);
|
|
1528
|
-
adjustAfterSplice(i);
|
|
1529
|
-
while (i < result.length - 1 && result[i].role === 'tool') {
|
|
1530
|
-
if (lastGroupIndices.has(i))
|
|
1531
|
-
break;
|
|
1532
|
-
estimated -= estimateMsgTokens(result[i]);
|
|
1533
|
-
result.splice(i, 1);
|
|
1534
|
-
adjustAfterSplice(i);
|
|
1535
|
-
}
|
|
1636
|
+
if (estimated <= budget)
|
|
1637
|
+
return { messages: result, trimmed: true };
|
|
1638
|
+
// === 3. 本轮超过一个单位:历史接着按单位丢 ===
|
|
1639
|
+
dropPast(onGrid(dropped + estimated - budget));
|
|
1640
|
+
if (estimated <= budget)
|
|
1641
|
+
return { messages: result, trimmed: true };
|
|
1642
|
+
// === 4. 注入的 system 块:首条与末条之间、不是本轮指令块的 ===
|
|
1643
|
+
const injected = () => result.flatMap((m, i) => (i > 0 && i < result.length - 1 && m.role === 'system' && !directive(m) ? [i] : []));
|
|
1644
|
+
const total = injected().reduce((sum, i) => sum + estimateMsgTokens(result[i]), 0);
|
|
1645
|
+
if (total > this.memoryTokenBudget) {
|
|
1646
|
+
const ratio = this.memoryTokenBudget / total;
|
|
1647
|
+
for (const i of injected()) {
|
|
1648
|
+
const m = result[i];
|
|
1649
|
+
if (!m.content || m.content.length <= 200)
|
|
1536
1650
|
continue;
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1651
|
+
const targetLen = Math.max(200, Math.floor(m.content.length * ratio));
|
|
1652
|
+
const shorter = { ...m, content: truncateChars(m.content, targetLen, '\n... [记忆内容已缩减]') };
|
|
1653
|
+
estimated -= estimateMsgTokens(m) - estimateMsgTokens(shorter);
|
|
1654
|
+
result[i] = shorter;
|
|
1541
1655
|
}
|
|
1542
1656
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
{
|
|
1555
|
-
const sysIdx = findSystemIndices();
|
|
1556
|
-
let removed = 0;
|
|
1557
|
-
for (let j = 0; j < sysIdx.length && estimated > budget; j++) {
|
|
1558
|
-
const idx = sysIdx[j] - removed;
|
|
1559
|
-
estimated -= estimateMsgTokens(result[idx]);
|
|
1560
|
-
result.splice(idx, 1);
|
|
1561
|
-
removed++;
|
|
1562
|
-
}
|
|
1657
|
+
// 仍超时从最靠前的块删起。头部只有技能正文/会话摘要、档案/记忆类材料都在尾部——正序扫描意味着极端
|
|
1658
|
+
// 裁剪时**先牺牲头部稳定块**、可再生的尾部材料反而最后动,与理想优先级相反。维持正序是刻意取舍:这是罕见的最后手段,且
|
|
1659
|
+
// 反转方向会先删掉紧邻当前消息的当轮提示(focus/平台事件说明),代价更直观;按 injector 分级则是为极端路径引入一张需要
|
|
1660
|
+
// 长期维护的优先级表。
|
|
1661
|
+
let removed = 0;
|
|
1662
|
+
for (const i of injected()) {
|
|
1663
|
+
if (estimated <= budget)
|
|
1664
|
+
break;
|
|
1665
|
+
estimated -= estimateMsgTokens(result[i - removed]);
|
|
1666
|
+
result.splice(i - removed, 1);
|
|
1667
|
+
removed++;
|
|
1563
1668
|
}
|
|
1564
|
-
//
|
|
1669
|
+
// === 5. 最后一组也截短过长的结果、缩推理内容 ===
|
|
1670
|
+
const inLast = (m) => last.has(m);
|
|
1671
|
+
shortenResults(inLast);
|
|
1672
|
+
shrinkReasoning(inLast);
|
|
1673
|
+
// 大幅裁剪后注入继续执行提示,防止模型因上下文缺失而中止任务:放在本轮最后一条输入之后,没有输入(快照)时放在末尾
|
|
1565
1674
|
if (messages.length - result.length >= 6) {
|
|
1566
1675
|
const hint = {
|
|
1567
1676
|
role: 'system',
|
|
1568
1677
|
content: '[系统提示] 由于上下文长度限制,部分历史消息已被压缩或移除。请基于当前可见的上下文和最新用户请求继续完成任务,不要因为看不到之前的细节而停止工作。如果你之前有正在执行的多步骤任务或计划,请查看对话摘要和 todo-list 工具确认当前进度,然后继续未完成的步骤。',
|
|
1569
1678
|
metadata: { injector: 'system-other' },
|
|
1570
1679
|
};
|
|
1571
|
-
|
|
1572
|
-
let
|
|
1573
|
-
|
|
1574
|
-
if (result[j].role === 'user') {
|
|
1680
|
+
let insertIdx = result.length;
|
|
1681
|
+
for (let j = result.length - 1; j >= 0; j--) {
|
|
1682
|
+
if (!history.has(result[j]) && (result[j].role === 'user' || directive(result[j]))) {
|
|
1575
1683
|
insertIdx = j + 1;
|
|
1576
1684
|
break;
|
|
1577
1685
|
}
|
|
1578
1686
|
}
|
|
1579
1687
|
result.splice(insertIdx, 0, hint);
|
|
1580
|
-
estimated += estimateMsgTokens(hint);
|
|
1581
|
-
}
|
|
1582
|
-
if (result.length < messages.length) {
|
|
1583
|
-
this.logger.info(`上下文截断: ${messages.length} → ${result.length} 条消息 (约 ${estimated} tokens)`);
|
|
1584
1688
|
}
|
|
1585
|
-
return result;
|
|
1689
|
+
return { messages: result, trimmed: true };
|
|
1586
1690
|
}
|
|
1587
1691
|
getArchiveForWrite() {
|
|
1588
|
-
const archive = this.caps.messageArchive.
|
|
1692
|
+
const archive = this.caps.messageArchive.present ? this.caps.messageArchive.use : undefined;
|
|
1589
1693
|
if (!archive && !this.archiveUnavailableWarned) {
|
|
1590
1694
|
this.archiveUnavailableWarned = true;
|
|
1591
1695
|
this.logger.warn('message-archive 服务不可用,缺席期间的新消息不会写入记忆,对话继续。请加载 @aalis/plugin-message-archive 及其 memory 依赖。');
|
|
@@ -1600,106 +1704,96 @@ class DefaultAgent {
|
|
|
1600
1704
|
await archive.saveMessage(sessionId, message);
|
|
1601
1705
|
}
|
|
1602
1706
|
catch (err) {
|
|
1603
|
-
this.logger.warn(
|
|
1707
|
+
this.logger.warn(`保存消息到记忆失败 (session=${sessionId}):`, err);
|
|
1604
1708
|
}
|
|
1605
1709
|
}
|
|
1606
1710
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1711
|
+
/**
|
|
1712
|
+
* 这一轮落库的 assistant 消息的元数据;带 turnId,会话的客户端据此把存档归回它所在的回合。自身账号随窗口:子会话没有
|
|
1713
|
+
* 窗口,取根会话的
|
|
1714
|
+
*/
|
|
1715
|
+
buildAssistantMetadata(incoming, turnId) {
|
|
1716
|
+
const sm = this.caps.sessionManager.present ? this.caps.sessionManager.use : undefined;
|
|
1717
|
+
const root = sm && rootSessionOf(sm.getSession(incoming.sessionId), id => sm.getSession(id));
|
|
1718
|
+
const identity = getPlatformSelfIdentity(this.caps.platform, incoming.platform, root?.id ?? incoming.sessionId);
|
|
1609
1719
|
const metadata = {
|
|
1610
|
-
|
|
1720
|
+
[WellKnownMetadataKeys.Platform]: incoming.platform,
|
|
1611
1721
|
senderType: 'assistant',
|
|
1722
|
+
[WellKnownMetadataKeys.TurnId]: turnId,
|
|
1612
1723
|
};
|
|
1613
1724
|
if (identity?.selfId)
|
|
1614
|
-
metadata.
|
|
1725
|
+
metadata[WellKnownMetadataKeys.UserId] = identity.selfId;
|
|
1615
1726
|
if (identity?.nickname)
|
|
1616
|
-
metadata.
|
|
1727
|
+
metadata[WellKnownMetadataKeys.Nickname] = identity.nickname;
|
|
1617
1728
|
if (incoming.groupId)
|
|
1618
|
-
metadata.
|
|
1729
|
+
metadata[WellKnownMetadataKeys.GroupId] = incoming.groupId;
|
|
1619
1730
|
if (incoming.groupName)
|
|
1620
|
-
metadata.
|
|
1731
|
+
metadata[WellKnownMetadataKeys.GroupName] = incoming.groupName;
|
|
1621
1732
|
if (incoming.sessionType)
|
|
1622
|
-
metadata.
|
|
1733
|
+
metadata[WellKnownMetadataKeys.SessionType] = incoming.sessionType;
|
|
1623
1734
|
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
|
1624
1735
|
}
|
|
1625
|
-
async saveToolCallGroup(sessionId, assistantMessage, toolMessages
|
|
1736
|
+
async saveToolCallGroup(sessionId, assistantMessage, toolMessages) {
|
|
1626
1737
|
const timestamp = Date.now();
|
|
1627
1738
|
await this.saveToMemory(sessionId, { ...assistantMessage, timestamp });
|
|
1628
|
-
persistedTimestamps?.push(timestamp);
|
|
1629
1739
|
for (let i = 0; i < toolMessages.length; i++) {
|
|
1630
|
-
const ts = timestamp + i + 1;
|
|
1631
1740
|
// 工具交出的图只服务本回合的主模型(data URI 体积大、历史回看也不再需要它),不落库
|
|
1632
1741
|
const { images: _transient, ...persisted } = toolMessages[i];
|
|
1633
|
-
await this.saveToMemory(sessionId, { ...persisted, timestamp:
|
|
1634
|
-
persistedTimestamps?.push(ts);
|
|
1742
|
+
await this.saveToMemory(sessionId, { ...persisted, timestamp: timestamp + i + 1 });
|
|
1635
1743
|
}
|
|
1636
1744
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1745
|
+
/**
|
|
1746
|
+
* 交给模型的会话历史,真实回合与 token:request 的快照共用:按条数线读出的那一段(见 readHistory)清洗工具调用组,跳过控制类
|
|
1747
|
+
* 与 skip 里的各条,用户消息加时间标注,她的回复取可见正文(historyForModel)。改动都在副本上:记忆服务可能直接交出存着的
|
|
1748
|
+
* 消息对象。count 同 readHistory,dropped 是清洗跳过的条数;没有记忆服务时为空。不记日志:快照同样走这里,跳过的条数由真实
|
|
1749
|
+
* 回合的调用点记
|
|
1750
|
+
*/
|
|
1751
|
+
async modelHistory(sessionId, historyLimit, skip = []) {
|
|
1752
|
+
if (!this.caps.memory.present)
|
|
1753
|
+
return { messages: [], count: 0, dropped: 0 };
|
|
1754
|
+
const stored = await readHistory(this.caps.memory.use, sessionId, historyLimit);
|
|
1755
|
+
const { kept, dropped } = this.sanitizeToolCallHistory(stored.messages);
|
|
1756
|
+
const now = Date.now();
|
|
1757
|
+
const messages = [];
|
|
1758
|
+
for (const m of kept) {
|
|
1759
|
+
if (skip.some(input => isSameMessage(m, input)))
|
|
1644
1760
|
continue;
|
|
1645
|
-
|
|
1646
|
-
if (message.role === 'assistant' && message.toolCalls?.length) {
|
|
1647
|
-
const expectedIds = new Set(message.toolCalls.map(tc => tc.id));
|
|
1648
|
-
const seenIds = new Set();
|
|
1649
|
-
const tools = [];
|
|
1650
|
-
let j = i + 1;
|
|
1651
|
-
while (j < history.length && history[j].role === 'tool') {
|
|
1652
|
-
const toolMessage = history[j];
|
|
1653
|
-
const id = toolMessage.toolCallId;
|
|
1654
|
-
if (!id || !expectedIds.has(id) || seenIds.has(id))
|
|
1655
|
-
break;
|
|
1656
|
-
tools.push(toolMessage);
|
|
1657
|
-
seenIds.add(id);
|
|
1658
|
-
j++;
|
|
1659
|
-
}
|
|
1660
|
-
if (seenIds.size === expectedIds.size) {
|
|
1661
|
-
result.push(message, ...tools);
|
|
1662
|
-
}
|
|
1663
|
-
else {
|
|
1664
|
-
dropped += 1 + tools.length;
|
|
1665
|
-
}
|
|
1666
|
-
i = j - 1;
|
|
1761
|
+
if (CONTROL_KINDS.includes(m.kind ?? ''))
|
|
1667
1762
|
continue;
|
|
1763
|
+
// 为用户消息注入时间标注,帮助 LLM 理解时间先后
|
|
1764
|
+
if (m.role === 'user' && m.timestamp && m.content) {
|
|
1765
|
+
const timeLabel = formatTimeLabel(m.timestamp, now);
|
|
1766
|
+
if (timeLabel && !m.content.startsWith(`(${timeLabel})`)) {
|
|
1767
|
+
messages.push({ ...m, content: `(${timeLabel}) ${m.content}` });
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1668
1770
|
}
|
|
1669
|
-
|
|
1670
|
-
}
|
|
1671
|
-
if (dropped > 0) {
|
|
1672
|
-
this.logger.warn(`历史消息中发现不完整工具调用组,已跳过 ${dropped} 条 (session=${sessionId})`);
|
|
1771
|
+
messages.push(historyForModel(m));
|
|
1673
1772
|
}
|
|
1674
|
-
return
|
|
1773
|
+
return { messages, count: stored.count, dropped };
|
|
1675
1774
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
if (this.archiveQueues.get(lane) === tail) {
|
|
1689
|
-
this.archiveQueues.delete(lane);
|
|
1690
|
-
}
|
|
1775
|
+
/**
|
|
1776
|
+
* 装载历史时清洗工具调用组:按 schema-message 的 groupToolCalls 归组(夹进调用与结果之间的别的消息挪到组后),结果不齐的组
|
|
1777
|
+
* 连同找到的结果跳过,不在任何组里的结果跳过。dropped 是跳过的条数
|
|
1778
|
+
*/
|
|
1779
|
+
sanitizeToolCallHistory(history) {
|
|
1780
|
+
const kept = [];
|
|
1781
|
+
let dropped = 0;
|
|
1782
|
+
for (const { at, shape } of groupToolCalls(history)) {
|
|
1783
|
+
if (shape === 'partial' || shape === 'loose')
|
|
1784
|
+
dropped += at.length;
|
|
1785
|
+
else
|
|
1786
|
+
kept.push(...at.map(i => history[i]));
|
|
1691
1787
|
}
|
|
1788
|
+
return { kept, dropped };
|
|
1692
1789
|
}
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
if (incoming.source === 'idle-trigger')
|
|
1696
|
-
return undefined;
|
|
1790
|
+
/** 归档一条输入:进了一轮的带回合 id 与进入方式(开轮 opened,插进来 joined),放掉的不带 */
|
|
1791
|
+
async archiveIncomingMessage(message, turn) {
|
|
1697
1792
|
const archive = this.getArchiveForWrite();
|
|
1698
1793
|
if (!archive)
|
|
1699
1794
|
return undefined;
|
|
1700
1795
|
try {
|
|
1701
|
-
|
|
1702
|
-
return result.message;
|
|
1796
|
+
return (await archive.archiveIncoming(message, turn))?.message;
|
|
1703
1797
|
}
|
|
1704
1798
|
catch (err) {
|
|
1705
1799
|
this.logger.warn('归档用户消息失败:', err);
|
|
@@ -1719,350 +1813,223 @@ class DefaultAgent {
|
|
|
1719
1813
|
* 生产部署务必确保 plugin-gateway 已加载,否则中间件链会被跳过。
|
|
1720
1814
|
*/
|
|
1721
1815
|
async dispatchOutbound(message) {
|
|
1722
|
-
const gateway = this.caps.gateway.
|
|
1816
|
+
const gateway = this.caps.gateway.present ? this.caps.gateway.use : undefined;
|
|
1723
1817
|
if (gateway) {
|
|
1724
1818
|
await gateway.dispatchOutbound(message);
|
|
1725
1819
|
return;
|
|
1726
1820
|
}
|
|
1727
1821
|
this.logger.warn('Gateway 服务不可用,回退至 outbound:message 事件(中间件链被跳过)');
|
|
1728
|
-
await this.caps.events.emit('outbound:message', message);
|
|
1822
|
+
await this.caps.events.use.emit('outbound:message', message);
|
|
1729
1823
|
}
|
|
1730
1824
|
}
|
|
1731
1825
|
// ----- 插件导出 -----
|
|
1732
1826
|
const configSchema = defineConfig({
|
|
1733
|
-
defaultLLM: {
|
|
1734
|
-
type: 'llm-ref',
|
|
1735
|
-
label: '默认对话模型',
|
|
1736
|
-
description: '全局默认 LLM。apply() 时经 services.prefer 锁定 llm 服务的偏好提供者。会话 / 平台 profile 未覆盖时生效。',
|
|
1737
|
-
},
|
|
1738
1827
|
systemPrompt: {
|
|
1739
1828
|
type: 'textarea',
|
|
1740
|
-
label: '
|
|
1741
|
-
description: '
|
|
1829
|
+
label: t('systemPromptLabel'),
|
|
1830
|
+
description: t('systemPromptDescription'),
|
|
1742
1831
|
default: '',
|
|
1743
1832
|
},
|
|
1744
1833
|
memoryTokenBudget: {
|
|
1745
1834
|
type: 'number',
|
|
1746
|
-
label: '
|
|
1835
|
+
label: t('memoryTokenBudgetLabel'),
|
|
1747
1836
|
default: 4096,
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
historyLimit: {
|
|
1751
|
-
type: 'number',
|
|
1752
|
-
label: '历史消息条数',
|
|
1753
|
-
default: 50,
|
|
1754
|
-
description: '从记忆中加载的最近对话历史条数',
|
|
1755
|
-
},
|
|
1756
|
-
maxToolIterations: {
|
|
1757
|
-
type: 'number',
|
|
1758
|
-
label: '最大工具迭代',
|
|
1759
|
-
default: 30,
|
|
1760
|
-
description: '工具调用循环的最大迭代次数',
|
|
1837
|
+
secret: false,
|
|
1838
|
+
description: t('memoryTokenBudgetDescription'),
|
|
1761
1839
|
},
|
|
1762
1840
|
promptBuildTimeoutMs: {
|
|
1763
1841
|
type: 'number',
|
|
1764
|
-
label: '
|
|
1842
|
+
label: t('promptBuildTimeoutMsLabel'),
|
|
1765
1843
|
default: 10000,
|
|
1766
|
-
description: '
|
|
1844
|
+
description: t('promptBuildTimeoutMsDescription'),
|
|
1767
1845
|
},
|
|
1768
1846
|
toolResultMaxRatio: {
|
|
1769
1847
|
type: 'number',
|
|
1770
|
-
label: '
|
|
1848
|
+
label: t('toolResultMaxRatioLabel'),
|
|
1771
1849
|
default: 0.15,
|
|
1772
|
-
description: '
|
|
1850
|
+
description: t('toolResultMaxRatioDescription'),
|
|
1773
1851
|
},
|
|
1774
1852
|
trimThresholdRatio: {
|
|
1775
1853
|
type: 'number',
|
|
1776
|
-
label: '
|
|
1854
|
+
label: t('trimThresholdRatioLabel'),
|
|
1777
1855
|
default: 1.0,
|
|
1778
|
-
description: '
|
|
1856
|
+
description: t('trimThresholdRatioDescription'),
|
|
1857
|
+
},
|
|
1858
|
+
// ----- 按会话字段:各层可覆盖,每一轮按会话读 -----
|
|
1859
|
+
llm: {
|
|
1860
|
+
type: 'llm-ref',
|
|
1861
|
+
label: t('llmLabel'),
|
|
1862
|
+
description: t('llmDescription'),
|
|
1863
|
+
...MODEL_REF_TEXT,
|
|
1864
|
+
// 全局层不在插件配置里:是本插件的 llm 偏好
|
|
1865
|
+
session: { prominence: 'composer', preference: 'llm', unset: t('llmUnset'), minLevel: 1 },
|
|
1866
|
+
},
|
|
1867
|
+
think: {
|
|
1868
|
+
type: 'select',
|
|
1869
|
+
label: t('thinkLabel'),
|
|
1870
|
+
description: t('thinkDescription'),
|
|
1871
|
+
// 可选项是「模型」这一项生效的那个模型声明的档位
|
|
1872
|
+
dynamicOptions: 'llm-thinking',
|
|
1873
|
+
session: { prominence: 'common', order: 20, dependsOn: 'llm', unset: t('thinkUnset'), minLevel: 1 },
|
|
1874
|
+
},
|
|
1875
|
+
maxToolIterations: {
|
|
1876
|
+
type: 'number',
|
|
1877
|
+
label: t('maxToolIterationsLabel'),
|
|
1878
|
+
description: t('maxToolIterationsDescription'),
|
|
1879
|
+
integer: true,
|
|
1880
|
+
min: 1,
|
|
1881
|
+
session: { prominence: 'advanced', unset: t('maxToolIterationsUnset') },
|
|
1882
|
+
},
|
|
1883
|
+
historyLimit: {
|
|
1884
|
+
type: 'number',
|
|
1885
|
+
label: t('historyLimitLabel'),
|
|
1886
|
+
description: t('historyLimitDescription'),
|
|
1887
|
+
integer: true,
|
|
1888
|
+
min: 0,
|
|
1889
|
+
default: 0,
|
|
1890
|
+
session: { prominence: 'advanced' },
|
|
1779
1891
|
},
|
|
1780
1892
|
});
|
|
1781
1893
|
function run(caps) {
|
|
1782
|
-
const cfg = parseConfig(configSchema, caps.config, caps.logger);
|
|
1783
|
-
const agentImpl = new DefaultAgent(caps, cfg);
|
|
1784
|
-
caps.provide(agentService, agentImpl);
|
|
1785
|
-
//
|
|
1786
|
-
//
|
|
1894
|
+
const cfg = parseConfig(configSchema, caps.config.use, caps.logger.use);
|
|
1895
|
+
const agentImpl = new DefaultAgent(caps, cfg, caps.lifecycle.use.id);
|
|
1896
|
+
caps.provide.use(agentService, agentImpl);
|
|
1897
|
+
// 收尾段停下全部在跑的一轮、放掉排着的(归档;按 core 的关停编排 agent 关完之前存档还在),等它们以 AbortError 收尾落定;
|
|
1898
|
+
// 之后到的输入一律放掉。第一方栈由 core 关停编排(optional 环成员先全部 drain)与 agent:turn:after 收口;
|
|
1787
1899
|
// session-manager 的 onDrain 兜的是未装 agent 或钩子未挂上的路径。
|
|
1788
1900
|
// 超时沿用 core 对 onDrain 的 disposeTimeoutMs,不另加配置键。
|
|
1789
|
-
caps.lifecycle.onDrain(() => agentImpl.
|
|
1790
|
-
//
|
|
1791
|
-
caps.lifecycle.onDispose(() => agentImpl.
|
|
1792
|
-
//
|
|
1793
|
-
caps.events.on('session:deleted', sessionId => agentImpl.
|
|
1901
|
+
caps.lifecycle.use.onDrain(() => agentImpl.queues.shutdown(), '停下在跑的回合');
|
|
1902
|
+
// 清理段再停一次:drain 超时或未走到收尾时的兜底,不再等落定;已停下的是空操作。
|
|
1903
|
+
caps.lifecycle.use.onDispose(() => void agentImpl.queues.shutdown(), '停下在跑的回合(兜底)');
|
|
1904
|
+
// 会话删除:停下这个会话(见 sessionDeleted)
|
|
1905
|
+
caps.events.use.on('session:deleted', sessionId => agentImpl.sessionDeleted(sessionId));
|
|
1794
1906
|
const agent = agentImpl;
|
|
1795
|
-
//
|
|
1796
|
-
// 偏好的持久化在宿主配置文档(servicePreferences 字段,WebUI 切换时写入);这里只是开机时按 agent
|
|
1797
|
-
// 自己的 cfg.defaultLLM 覆写一次,便于纯文件配置流(无 webui 干预)也能生效。
|
|
1798
|
-
const defaultLLM = cfg.defaultLLM;
|
|
1799
|
-
if (defaultLLM?.provider && defaultLLM?.model) {
|
|
1800
|
-
caps.services.prefer(llmService, `${defaultLLM.provider}/${defaultLLM.model}`);
|
|
1801
|
-
}
|
|
1802
|
-
function resolutionLines(sessionId, platform, label, pick) {
|
|
1803
|
-
const smSvc = caps.sessionManager.current;
|
|
1804
|
-
if (!smSvc)
|
|
1805
|
-
return [`${label}: (session-manager 不可用)`];
|
|
1806
|
-
const session = smSvc.getSession(sessionId);
|
|
1807
|
-
const own = pick(session?.config);
|
|
1808
|
-
const parent = session?.parentId ? smSvc.getSession(session.parentId) : undefined;
|
|
1809
|
-
const parentDefaults = pick(parent?.config?.sessionDefaults);
|
|
1810
|
-
const profile = pick(smSvc.getPlatformProfiles()?.[platform || 'webui']);
|
|
1811
|
-
const resolved = pick(smSvc.resolveConfig(sessionId, platform));
|
|
1812
|
-
let source = '默认';
|
|
1813
|
-
if (own)
|
|
1814
|
-
source = '会话覆盖';
|
|
1815
|
-
else if (parentDefaults)
|
|
1816
|
-
source = '父会话 sessionDefaults';
|
|
1817
|
-
else if (profile)
|
|
1818
|
-
source = `平台 profile (${platform})`;
|
|
1819
|
-
const lines = [`${label}: ${resolved ?? '(默认)'} [来源: ${source}]`];
|
|
1820
|
-
const chain = [];
|
|
1821
|
-
if (own)
|
|
1822
|
-
chain.push(`会话: ${own}`);
|
|
1823
|
-
if (parentDefaults)
|
|
1824
|
-
chain.push(`父 sessionDefaults: ${parentDefaults}`);
|
|
1825
|
-
if (profile)
|
|
1826
|
-
chain.push(`平台 profile: ${profile}`);
|
|
1827
|
-
if (chain.length > 0) {
|
|
1828
|
-
lines.push(' 解析链(高优先级在前):');
|
|
1829
|
-
for (const c of chain)
|
|
1830
|
-
lines.push(` - ${c}`);
|
|
1831
|
-
}
|
|
1832
|
-
return lines;
|
|
1833
|
-
}
|
|
1834
|
-
const fmtModel = (c) => (c?.llm ? `${c.llm.provider}/${c.llm.model}` : undefined);
|
|
1835
|
-
const fmtPersona = (c) => c?.persona;
|
|
1836
|
-
// null 与 undefined 同义(BSON 持久化读回 null,契约见 session-manager stripUndefined)
|
|
1837
|
-
const fmtThink = (c) => (c?.think == null ? undefined : c.think ? 'on' : 'off');
|
|
1907
|
+
// /model 列可用模型、/persona 列可用人设;会话设置的查看与修改是会话设置插件的 /session 系列
|
|
1838
1908
|
// 分页渲染 helper:限制每页 PAGE_SIZE 条 + 关键词过滤 + 翻页提示(/model 与 /persona 共用)。
|
|
1839
1909
|
const PAGE_SIZE = 20;
|
|
1840
|
-
function renderPaged(all, keyword, page, label,
|
|
1910
|
+
function renderPaged(all, keyword, page, label, command) {
|
|
1841
1911
|
const kw = keyword.trim().toLowerCase();
|
|
1842
1912
|
const list = kw ? all.filter(x => x.toLowerCase().includes(kw)) : all;
|
|
1843
1913
|
if (list.length === 0)
|
|
1844
|
-
return kw ?
|
|
1914
|
+
return kw ? t('pagedNoMatch', { keyword, label }) : t('pagedNone', { label });
|
|
1845
1915
|
const pages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
|
1846
1916
|
const p = Math.min(Math.max(1, Math.trunc(page) || 1), pages);
|
|
1847
1917
|
const body = list.slice((p - 1) * PAGE_SIZE, p * PAGE_SIZE).map(x => `- ${x}`);
|
|
1848
|
-
const
|
|
1918
|
+
const counts = { label, count: list.length, page: p, pages };
|
|
1919
|
+
const lines = [kw ? t('pagedHeaderMatching', { ...counts, keyword }) : t('pagedHeader', counts), ...body];
|
|
1849
1920
|
if (pages > 1) {
|
|
1850
1921
|
const next = p < pages ? p + 1 : 1;
|
|
1851
|
-
lines.push(
|
|
1922
|
+
lines.push(kw ? t('pagedHintMatching', { command, keyword, next }) : t('pagedHint', { command, next }));
|
|
1852
1923
|
}
|
|
1853
|
-
return lines
|
|
1924
|
+
return textLines(lines);
|
|
1854
1925
|
}
|
|
1855
|
-
caps.commands
|
|
1856
|
-
.command('model [keyword:string]', '
|
|
1857
|
-
.option('page', '-p <n:number>', { description: '
|
|
1926
|
+
caps.commands.use
|
|
1927
|
+
.command('model [keyword:string]', t('modelDescription'))
|
|
1928
|
+
.option('page', '-p <n:number>', { description: t('pageOptionDescription') })
|
|
1858
1929
|
.action(async (argv, keyword) => {
|
|
1859
1930
|
const seen = new Set();
|
|
1860
1931
|
const ids = [];
|
|
1861
1932
|
for (const e of listLLMModels(caps.llm, { caps: ['chat'] })) {
|
|
1862
|
-
if (seen.has(e.
|
|
1933
|
+
if (seen.has(e.id))
|
|
1863
1934
|
continue;
|
|
1864
|
-
seen.add(e.
|
|
1865
|
-
ids.push(e.
|
|
1935
|
+
seen.add(e.id);
|
|
1936
|
+
ids.push(e.id);
|
|
1866
1937
|
}
|
|
1867
1938
|
const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
|
|
1868
|
-
return renderPaged(ids, String(keyword ?? ''), page, '
|
|
1939
|
+
return renderPaged(ids, String(keyword ?? ''), page, t('modelsLabel'), '/model');
|
|
1869
1940
|
});
|
|
1870
|
-
caps.commands
|
|
1871
|
-
.command('persona [keyword:string]', '
|
|
1872
|
-
.option('page', '-p <n:number>', { description: '
|
|
1941
|
+
caps.commands.use
|
|
1942
|
+
.command('persona [keyword:string]', t('personaDescription'))
|
|
1943
|
+
.option('page', '-p <n:number>', { description: t('pageOptionDescription') })
|
|
1873
1944
|
.action(async (argv, keyword) => {
|
|
1874
|
-
const persona = caps.persona.
|
|
1945
|
+
const persona = caps.persona.present ? caps.persona.use : undefined;
|
|
1875
1946
|
const names = persona?.listModels ? await persona.listModels() : [];
|
|
1876
1947
|
const page = typeof argv.options.page === 'number' ? argv.options.page : 1;
|
|
1877
|
-
return renderPaged(names, String(keyword ?? ''), page, '
|
|
1948
|
+
return renderPaged(names, String(keyword ?? ''), page, t('personasLabel'), '/persona');
|
|
1878
1949
|
});
|
|
1879
|
-
//
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
const
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
const
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
session ? '' : '(本对话尚无独立配置记录,全部继承默认)',
|
|
1894
|
-
'────',
|
|
1895
|
-
'改配置: /session.set -m <模型> -p <人设> -t <on|off> [-n <名>] (/model 看可用模型/人设名错时也会列出)',
|
|
1896
|
-
'复位: /session.reset (-m 仅模型 / -p 仅人设 / -t 仅 thinking)',
|
|
1897
|
-
]
|
|
1898
|
-
.filter(Boolean)
|
|
1899
|
-
.join('\n');
|
|
1900
|
-
});
|
|
1901
|
-
caps.commands
|
|
1902
|
-
.command('session.set', '设定当前对话的模型 / 人设 / thinking / 显示名(会话级覆盖,持久化,重启不丢)', {
|
|
1903
|
-
risk: 'sensitive',
|
|
1904
|
-
examples: [
|
|
1905
|
-
'/session.set -m @aalis/plugin-llm-openai:main/gpt-4o -p catgirl',
|
|
1906
|
-
'/session.set -p strict-reviewer',
|
|
1907
|
-
'/session.set -t off',
|
|
1908
|
-
'/session.set -n 深夜助手',
|
|
1909
|
-
],
|
|
1910
|
-
})
|
|
1911
|
-
.option('model', '-m <ref:string>', { description: '模型 provider/model(LLM entry 的 contextId;/model 可列出)' })
|
|
1912
|
-
.option('persona', '-p <name:string>', { description: '人设卡名(不含后缀)' })
|
|
1913
|
-
.option('think', '-t <mode:string>', {
|
|
1914
|
-
description: 'thinking 开关:on|off(会话级覆盖;/session.reset 后回落全局配置)',
|
|
1915
|
-
})
|
|
1916
|
-
.option('name', '-n <label:string>', { description: '会话显示名(可选)' })
|
|
1917
|
-
.action(async (argv) => {
|
|
1918
|
-
const smSvc = caps.sessionManager.current;
|
|
1919
|
-
if (!smSvc)
|
|
1920
|
-
return 'session-manager 服务不可用';
|
|
1921
|
-
const modelRef = typeof argv.options.model === 'string' ? argv.options.model.trim() : '';
|
|
1922
|
-
const personaName = typeof argv.options.persona === 'string' ? argv.options.persona.trim() : '';
|
|
1923
|
-
const thinkRaw = typeof argv.options.think === 'string' ? argv.options.think.trim().toLowerCase() : '';
|
|
1924
|
-
const displayName = typeof argv.options.name === 'string' ? argv.options.name.trim() : '';
|
|
1925
|
-
if (!modelRef && !personaName && !thinkRaw && !displayName) {
|
|
1926
|
-
return '用法: /session.set -m <provider/model> -p <人设名> -t <on|off> [-n <显示名>](至少给一项)';
|
|
1927
|
-
}
|
|
1928
|
-
if (thinkRaw && thinkRaw !== 'on' && thinkRaw !== 'off') {
|
|
1929
|
-
return `thinking 取值只能是 on 或 off(收到 "${thinkRaw}")。例:/session.set -t off`;
|
|
1930
|
-
}
|
|
1931
|
-
const config = {};
|
|
1932
|
-
if (thinkRaw)
|
|
1933
|
-
config.think = thinkRaw === 'on';
|
|
1934
|
-
// 模型:校验存在再落,避免错名静默回退默认
|
|
1935
|
-
if (modelRef) {
|
|
1936
|
-
const lastSlash = modelRef.lastIndexOf('/');
|
|
1937
|
-
if (lastSlash <= 0 || lastSlash === modelRef.length - 1) {
|
|
1938
|
-
return '模型格式错误。请用 `provider/model`,例如 `@aalis/plugin-llm-openai:main/gpt-4o`';
|
|
1939
|
-
}
|
|
1940
|
-
const chatModels = listLLMModels(caps.llm, { caps: ['chat'] });
|
|
1941
|
-
if (!chatModels.some(e => e.contextId === modelRef)) {
|
|
1942
|
-
return `未知模型 "${modelRef}"。用 /model <关键词> 搜索(如 /model grok),共 ${chatModels.length} 个可用。`;
|
|
1943
|
-
}
|
|
1944
|
-
config.llm = { provider: modelRef.slice(0, lastSlash), model: modelRef.slice(lastSlash + 1) };
|
|
1945
|
-
}
|
|
1946
|
-
// 人设:listModels 可选,缺失则不校验(persona 插件缺席时不误拒)
|
|
1947
|
-
if (personaName) {
|
|
1948
|
-
const persona = caps.persona.current;
|
|
1949
|
-
if (persona?.listModels) {
|
|
1950
|
-
const names = await persona.listModels();
|
|
1951
|
-
if (!names.includes(personaName)) {
|
|
1952
|
-
return `未知人设 "${personaName}"。用 /persona 查看可用人设(共 ${names.length} 个)。`;
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
config.persona = personaName;
|
|
1956
|
-
}
|
|
1957
|
-
await smSvc.ensureSession(argv.session.sessionId, {
|
|
1958
|
-
config,
|
|
1959
|
-
createdBy: 'user',
|
|
1960
|
-
...(displayName ? { name: displayName } : {}),
|
|
1961
|
-
});
|
|
1962
|
-
const lines = ['本对话已切换 →'];
|
|
1963
|
-
if (config.llm)
|
|
1964
|
-
lines.push(`· 模型: ${config.llm.provider}/${config.llm.model}`);
|
|
1965
|
-
if (config.persona)
|
|
1966
|
-
lines.push(`· 人设: ${config.persona}`);
|
|
1967
|
-
if (config.think !== undefined)
|
|
1968
|
-
lines.push(`· thinking: ${config.think ? 'on' : 'off'}`);
|
|
1969
|
-
if (displayName)
|
|
1970
|
-
lines.push(`· 名称: ${displayName}`);
|
|
1971
|
-
lines.push('后续消息持续生效(已持久化,重启不丢)。/session.reset 可复位。');
|
|
1972
|
-
return lines.join('\n');
|
|
1973
|
-
});
|
|
1974
|
-
caps.commands
|
|
1975
|
-
.command('session.reset', '复位当前对话的会话级覆盖(默认清模型+人设+thinking;-m/-p/-t 单独清对应项)', {
|
|
1976
|
-
risk: 'sensitive',
|
|
1977
|
-
examples: ['/session.reset', '/session.reset -m', '/session.reset -p', '/session.reset -t'],
|
|
1978
|
-
})
|
|
1979
|
-
.option('model', '-m', { description: '仅复位模型覆盖' })
|
|
1980
|
-
.option('persona', '-p', { description: '仅复位人设覆盖' })
|
|
1981
|
-
.option('think', '-t', { description: '仅复位 thinking 覆盖' })
|
|
1982
|
-
.action(async (argv) => {
|
|
1983
|
-
const smSvc = caps.sessionManager.current;
|
|
1984
|
-
if (!smSvc)
|
|
1985
|
-
return 'session-manager 服务不可用';
|
|
1986
|
-
const mFlag = argv.options.model === true;
|
|
1987
|
-
const pFlag = argv.options.persona === true;
|
|
1988
|
-
const tFlag = argv.options.think === true;
|
|
1989
|
-
// 都不给 = 全清;给了任意 flag = 只清点名的那些
|
|
1990
|
-
const noFlags = !mFlag && !pFlag && !tFlag;
|
|
1991
|
-
const clearModel = mFlag || noFlags;
|
|
1992
|
-
const clearPersona = pFlag || noFlags;
|
|
1993
|
-
const clearThink = tFlag || noFlags;
|
|
1994
|
-
const sid = argv.session.sessionId;
|
|
1995
|
-
const session = smSvc.getSession(sid);
|
|
1996
|
-
const willClear = (clearModel && session?.config?.llm) ||
|
|
1997
|
-
(clearPersona && session?.config?.persona) ||
|
|
1998
|
-
(clearThink && session?.config?.think != null);
|
|
1999
|
-
if (session?.config && willClear) {
|
|
2000
|
-
// 写 undefined → resolveConfig 的 stripUndefined 使其回落默认级联
|
|
2001
|
-
const patch = { ...session.config };
|
|
2002
|
-
if (clearModel)
|
|
2003
|
-
patch.llm = undefined;
|
|
2004
|
-
if (clearPersona)
|
|
2005
|
-
patch.persona = undefined;
|
|
2006
|
-
if (clearThink)
|
|
2007
|
-
patch.think = undefined;
|
|
2008
|
-
await smSvc.ensureSession(sid, { config: patch });
|
|
2009
|
-
}
|
|
2010
|
-
const eff = smSvc.resolveConfig(sid, argv.session.platform);
|
|
2011
|
-
const thinkStr = eff.think == null ? '(默认)' : eff.think ? 'on' : 'off';
|
|
2012
|
-
return `已复位 → 模型: ${eff.llm ? `${eff.llm.provider}/${eff.llm.model}` : '(默认)'},人设: ${eff.persona ?? '(默认)'},thinking: ${thinkStr}`;
|
|
1950
|
+
// /stop 同停止键:只停本会话在跑的一轮,排着的照常开下一轮。不看等级:发出者是这一轮的参与者(进了这一轮的各条输入按
|
|
1951
|
+
// originTurnOf 算:真人的话是发言者,续做与子任务是起它的那一轮的参与者,定时任务与编排是它的授权身份),或是 owner;排队与
|
|
1952
|
+
// 待插入的不算,没有账号的不算任何人
|
|
1953
|
+
caps.commands.use.command('stop', t('stopDescription'), { risk: 'safe' }).action(({ session }) => {
|
|
1954
|
+
const { sessionId, platform, userId } = session;
|
|
1955
|
+
const inputs = agentImpl.queues.inputs(sessionId);
|
|
1956
|
+
if (!inputs)
|
|
1957
|
+
return t('stopIdle');
|
|
1958
|
+
const { participants } = originTurnOf(inputs, agentImpl.isOwner);
|
|
1959
|
+
const inTurn = Boolean(userId) && participants.some(who => who.platform === platform && who.userId === userId);
|
|
1960
|
+
if (!inTurn && !agentImpl.isOwner(platform, userId))
|
|
1961
|
+
return t('stopDenied');
|
|
1962
|
+
agentImpl.abort(sessionId, { reason: '/stop', ...(userId ? { by: { platform, userId } } : {}) });
|
|
1963
|
+
return t('stopped');
|
|
2013
1964
|
});
|
|
2014
1965
|
// 监听 token:request 事件 — 客户端刷新/重连时主动请求 token 用量
|
|
2015
|
-
caps.events.on('token:request', async (data) => {
|
|
1966
|
+
caps.events.use.on('token:request', async (data) => {
|
|
2016
1967
|
if (!data?.sessionId)
|
|
2017
1968
|
return;
|
|
2018
|
-
//
|
|
2019
|
-
|
|
1969
|
+
// platform 是调用方的入口平台(owner 面会话的快照按它选档);会话服务对房间不填、其余填连接的平台。
|
|
1970
|
+
// 缺省时 IM 房间按会话记下的出生平台(子会话取根会话的)、其余按 'webui':它还是提示词贡献方的「当前平台」
|
|
1971
|
+
//(按平台取跨会话料),不能缺省成空
|
|
1972
|
+
const sm = caps.sessionManager.present ? caps.sessionManager.use : undefined;
|
|
1973
|
+
const origin = sm && rootSessionOf(sm.getSession(data.sessionId), id => sm.getSession(id))?.originPlatform;
|
|
1974
|
+
const platform = data.platform ?? origin ?? 'webui';
|
|
2020
1975
|
try {
|
|
2021
|
-
|
|
1976
|
+
// 与真实回合同一份会话读法:模型、条数线;人设覆盖、结构化输出开关、额外提示由人设服务按会话读,都影响系统提示长度
|
|
1977
|
+
const settings = agent.fields.get(data.sessionId, { platform });
|
|
1978
|
+
const resolved = agent.resolveLLM(settings.llm);
|
|
2022
1979
|
if (!resolved)
|
|
2023
1980
|
return;
|
|
2024
|
-
const llm = resolved.
|
|
1981
|
+
const llm = resolved.use;
|
|
2025
1982
|
const contextLength = llm.contextLength;
|
|
2026
1983
|
const maxTokens = getModelMaxOutput(llm);
|
|
2027
1984
|
const tokenBudget = agent.computeTokenBudget(contextLength, maxTokens);
|
|
2028
|
-
//
|
|
2029
|
-
const sm = caps.sessionManager.current;
|
|
2030
|
-
const sessionResolved = sm ? sm.resolveConfig(data.sessionId, platform) : undefined;
|
|
2031
|
-
// 获取历史消息并构建基础消息列表
|
|
2032
|
-
const memory = caps.memory.current;
|
|
1985
|
+
// 构建基础消息列表
|
|
2033
1986
|
const messages = [];
|
|
2034
1987
|
// 系统提示
|
|
2035
|
-
const systemPrompt = agent.buildSystemPrompt(
|
|
1988
|
+
const systemPrompt = agent.buildSystemPrompt({ sessionId: data.sessionId, platform });
|
|
2036
1989
|
messages.push({ role: 'system', content: systemPrompt, metadata: { injector: 'persona' } });
|
|
2037
|
-
//
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
messages.push(...history.filter(m => !CONTROL_KINDS.includes(m.kind ?? '')));
|
|
2041
|
-
}
|
|
1990
|
+
// 历史消息:与真实回合同一个读法
|
|
1991
|
+
const history = await agent.modelHistory(data.sessionId, settings.historyLimit);
|
|
1992
|
+
messages.push(...history.messages);
|
|
2042
1993
|
// 与真实回合同序:先组装 agent:prompt 贡献(摘要/向量记忆/档案等物化为 system 块),
|
|
2043
1994
|
// 再跑 agent:llm:before(工具搜索层过滤等拦截职责),使快照贴近实际送入 LLM 的形态
|
|
2044
|
-
const
|
|
2045
|
-
|
|
1995
|
+
const tools = caps.tools.present
|
|
1996
|
+
? caps.tools.use.getDefinitions({ groups: caps.tools.use.sessionGroups(data.sessionId, platform) })
|
|
1997
|
+
: [];
|
|
2046
1998
|
const llmBeforeData = {
|
|
2047
1999
|
messages,
|
|
2048
2000
|
tools,
|
|
2001
|
+
acceptsImages: llm.capabilities.includes(LLMCapabilities.Vision),
|
|
2049
2002
|
sessionId: data.sessionId,
|
|
2050
|
-
userId: '',
|
|
2051
2003
|
platform,
|
|
2052
2004
|
dryRun: true, // 纯统计路径:昂贵注入者(向量检索/档案加载)据此跳过副作用
|
|
2053
2005
|
};
|
|
2054
|
-
|
|
2055
|
-
await caps
|
|
2056
|
-
|
|
2006
|
+
// 不在回合里:没有输入,各贡献按没有输入处理
|
|
2007
|
+
await assemblePromptContributions(caps, llmBeforeData, [], { buildTimeoutMs: agent.promptBuildTimeoutMs });
|
|
2008
|
+
await caps.hooks.use.run('agent:llm:before', llmBeforeData, undefined, { warnOnStall: true });
|
|
2009
|
+
// 与真实回合同样过硬裁剪,拆分按裁剪之后的消息算
|
|
2010
|
+
const sent = agent.trimMessages(llmBeforeData.messages, tokenBudget, new Set(history.messages));
|
|
2011
|
+
await agent.emitTokenUsage({
|
|
2012
|
+
sessionId: data.sessionId,
|
|
2013
|
+
platform,
|
|
2014
|
+
contextWindow: contextLength,
|
|
2015
|
+
maxTokens,
|
|
2016
|
+
tokenBudget,
|
|
2017
|
+
messages: sent.messages,
|
|
2018
|
+
tools: llmBeforeData.tools,
|
|
2019
|
+
trimmed: sent.trimmed,
|
|
2020
|
+
untrimmedHistory: estimateHistoryTokens(llmBeforeData.messages),
|
|
2021
|
+
historyMessages: history.count,
|
|
2022
|
+
historyLimit: settings.historyLimit,
|
|
2023
|
+
});
|
|
2057
2024
|
}
|
|
2058
2025
|
catch (err) {
|
|
2059
|
-
caps.logger.debug('token:request 处理失败:', err);
|
|
2026
|
+
caps.logger.use.debug('token:request 处理失败:', err);
|
|
2060
2027
|
}
|
|
2061
2028
|
});
|
|
2062
2029
|
}
|
|
2063
2030
|
export default definePlugin({
|
|
2064
2031
|
name: '@aalis/plugin-agent',
|
|
2065
|
-
displayName: '
|
|
2032
|
+
displayName: t('displayName'),
|
|
2066
2033
|
subsystem: 'agent',
|
|
2067
2034
|
configSchema,
|
|
2068
2035
|
provides: [agentService],
|