foliko 2.0.9 → 2.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foliko",
3
- "version": "2.0.9",
3
+ "version": "2.0.11",
4
4
  "description": "简约的插件化 Agent 框架",
5
5
  "main": "src/index.js",
6
6
  "type": "commonjs",
@@ -999,7 +999,12 @@ class WorkflowPlugin extends Plugin {
999
999
  for (const file of files) {
1000
1000
  if (!file.endsWith('.json') && !file.endsWith('.js')) continue;
1001
1001
  const filePath = path.join(dir, file);
1002
- const workflowName = path.basename(file, path.extname(file));
1002
+ let workflowName = path.basename(file, path.extname(file));
1003
+ // 兼容 .flow.js / .flow.json 命名约定:去掉 .flow 后缀
1004
+ // (如 weather_card.flow.js → workflow_weather_card 而不是 workflow_weather_card.flow)
1005
+ if (workflowName.endsWith('.flow')) {
1006
+ workflowName = workflowName.slice(0, -'.flow'.length);
1007
+ }
1003
1008
  // 跳过已存在的工作流(先加载的优先)
1004
1009
  if (this._workflows.has(workflowName)) continue;
1005
1010
  try {
@@ -1064,7 +1069,10 @@ class WorkflowPlugin extends Plugin {
1064
1069
  */
1065
1070
  _registerWorkflowTools() {
1066
1071
  for (const [name, workflow] of this._workflows) {
1067
- const toolName = `workflow_${name}`;
1072
+ // 工具名 sanitize:把文件名里的 . - 空格等替换为 _,避免 LLM 把名字当成
1073
+ // "module.method" 调用语法(如 weather_card.flow.js → workflow_weather_card_flow)
1074
+ const safeName = name.replace(/[^a-zA-Z0-9_]/g, '_');
1075
+ const toolName = `workflow_${safeName}`;
1068
1076
  const description = workflow.description || `执行工作流: ${name}`;
1069
1077
  // 注意:framework.registerTool 不做去重,导致 reload 后会有两个同名
1070
1078
  // `workflow_<name>` 工具。getTool 返回第一个(旧的)。每次注册前先清掉。
package/src/agent/chat.js CHANGED
@@ -555,6 +555,9 @@ class AgentChatHandler extends EventEmitter {
555
555
  }
556
556
  }
557
557
  }
558
+ // 写入端 sanitize:AI SDK v6 响应里允许 error-text/error-json(工具失败标记),
559
+ // 但 ModelMessage 输入 schema 不接受;持久化前必须规范化
560
+ this._normalizeToolOutputs(finishMessages);
558
561
  messages.push(...finishMessages);
559
562
 
560
563
  // 获取或估算 token 用量
@@ -795,12 +798,56 @@ class AgentChatHandler extends EventEmitter {
795
798
  messages.push(userMessage);
796
799
  this._validateToolCalls(messages);
797
800
 
801
+ // 读取端 sanitize:修复历史 chat log 中可能存在的 error-text/error-json
802
+ this._normalizeToolOutputs(messages);
803
+
798
804
  // 最终兜底:暴力清理所有 orphaned tool 消息,确保 AI SDK 不报错
799
805
  this._stripOrphanedToolMessages(messages);
800
806
 
801
807
  return { messageStore, messages };
802
808
  }
803
809
 
810
+ /**
811
+ * 规范化 tool-result 的 output 字段。
812
+ * AI SDK v6 响应中允许 'error-text' / 'error-json'(表示工具执行失败),
813
+ * 但 ModelMessage 输入 schema(AI SDK 发送 prompt 时使用)不接受这两类。
814
+ * 必须在写入磁盘和读取发送前都做转换。
815
+ * @private
816
+ */
817
+ _normalizeToolOutputs(messages) {
818
+ const VALID_OUTPUT_TYPES = new Set(['text', 'json', 'execution-denied', 'content']);
819
+ let fixed = 0;
820
+ for (const msg of messages) {
821
+ if (msg.role !== 'tool' || !Array.isArray(msg.content)) continue;
822
+ for (const part of msg.content) {
823
+ if (!part || part.type !== 'tool-result') continue;
824
+ const out = part.output;
825
+ if (out === undefined || out === null || typeof out !== 'object') {
826
+ const v = out === undefined ? 'undefined' : (typeof out === 'string' ? out : JSON.stringify(out));
827
+ part.output = { type: 'text', value: String(v) };
828
+ fixed++;
829
+ continue;
830
+ }
831
+ if (VALID_OUTPUT_TYPES.has(out.type)) continue;
832
+ if (out.type === 'error-text' || out.type === 'error_text') {
833
+ part.output = { type: 'text', value: typeof out.value === 'string' ? out.value : String(out.value ?? '') };
834
+ fixed++;
835
+ continue;
836
+ }
837
+ if (out.type === 'error-json' || out.type === 'error_json') {
838
+ part.output = { type: 'json', value: out.value ?? null };
839
+ fixed++;
840
+ continue;
841
+ }
842
+ // 未知 type 降级为 text
843
+ try { part.output = { type: 'text', value: JSON.stringify(out) }; }
844
+ catch { part.output = { type: 'text', value: String(out) }; }
845
+ fixed++;
846
+ }
847
+ }
848
+ return fixed;
849
+ }
850
+
804
851
  /**
805
852
  * 更新消息存储的使用量
806
853
  * @private
package/src/agent/main.js CHANGED
@@ -401,6 +401,8 @@ class MainAgent extends BaseAgent {
401
401
  const sessionCtx = this.framework?.getSessionContext(sessionId);
402
402
  if (!messages?.length && sessionCtx) {
403
403
  messages = sessionCtx.getMessages();
404
+ // 同样规范化:避免压缩器处理到坏数据
405
+ require('../utils/message-validator').normalizeToolOutputs(messages);
404
406
  messageStore.messages = messages;
405
407
  }
406
408
  if (!messages?.length) throw new Error('No messages to compress');
@@ -10,6 +10,7 @@
10
10
 
11
11
  const { EventEmitter } = require('../common/events');
12
12
  const { logger } = require('../common/logger');
13
+ const { normalizeToolOutputs, filterOrphanToolCalls, validateMessagesPairing } = require('../utils/message-validator');
13
14
 
14
15
  /**
15
16
  * Session 作用域的事件监听器
@@ -222,7 +223,30 @@ class ChatSession extends EventEmitter {
222
223
  const messages = allMessages.length > MAX_INITIAL_LOAD
223
224
  ? allMessages.slice(-MAX_INITIAL_LOAD)
224
225
  : allMessages;
225
- messageStore.messages = messages;
226
+ // 聊天前过滤:AI SDK v6 响应中允许 error-text/error-json(工具失败),
227
+ // 但 ModelMessage 输入 schema 不接受。加载到内存时立即规范化,
228
+ // 这样显示/统计/压缩/持久化回写都看到干净数据。
229
+ const fixed = normalizeToolOutputs(messages);
230
+ if (fixed > 0) {
231
+ logger.info(`[loadHistory] 规范化 ${fixed} 条 tool-result(error-text/error-json → text/json)`);
232
+ }
233
+ // 聊天前过滤:把对"已不可用工具"的 tool-call 转成 text。
234
+ // 防止 LLM 在新会话里看到历史 tool-call 后继续重试不存在的工具
235
+ // (典型场景:工作流文件被删除/改名,chat log 保留了对它的引用)。
236
+ const availableToolNames = this.agent?.framework?.toolRegistry?.getNames?.() || [];
237
+ if (availableToolNames.length > 0) {
238
+ const orphans = filterOrphanToolCalls(messages, availableToolNames);
239
+ if (orphans > 0) {
240
+ logger.info(`[loadHistory] 转换 ${orphans} 条 orphan tool-call 为 text(工具已不可用)`);
241
+ }
242
+ }
243
+ // 转换 tool-call 后,相应的 tool-result 变成 orphan(没有对应 tool-call),
244
+ // 这里立即清理掉(和 _prepareSession 中的 _stripOrphanedToolMessages 等价)
245
+ const cleaned = validateMessagesPairing(messages, { log: false });
246
+ if (cleaned.length !== messages.length) {
247
+ logger.info(`[loadHistory] 清理 ${messages.length - cleaned.length} 条 orphan tool-result`);
248
+ }
249
+ messageStore.messages = cleaned;
226
250
  messageStore.historyLoaded = true;
227
251
  messageStore._hasMoreHistory = allMessages.length > MAX_INITIAL_LOAD;
228
252
  //logger.debug(`Loaded ${messages.length} msgs for session ${sessionId} (total: ${allMessages.length})`);
@@ -175,6 +175,103 @@ function validateAll(messages) {
175
175
  return validateMessagesPairing(messages);
176
176
  }
177
177
 
178
+ // AI SDK v6 ToolResultOutput 允许的类型(用于 ModelMessage[] 输入 schema)
179
+ const VALID_OUTPUT_TYPES = new Set(['text', 'json', 'execution-denied', 'content']);
180
+
181
+ function normalizeToolOutputs(messages) {
182
+ let fixed = 0;
183
+ for (const msg of messages) {
184
+ if (msg.role !== 'tool' || !Array.isArray(msg.content)) continue;
185
+ for (const part of msg.content) {
186
+ if (!part || part.type !== 'tool-result') continue;
187
+ const out = part.output;
188
+ // 缺 output 或 output 非对象:包装为 text
189
+ if (out === undefined || out === null || typeof out !== 'object') {
190
+ const v = out === undefined ? 'undefined' : (typeof out === 'string' ? out : JSON.stringify(out));
191
+ part.output = { type: 'text', value: String(v) };
192
+ fixed++;
193
+ continue;
194
+ }
195
+ // 已知合法 type:跳过
196
+ if (VALID_OUTPUT_TYPES.has(out.type)) continue;
197
+ // error-text → text(保留原 value 字符串)
198
+ if (out.type === 'error-text' || out.type === 'error_text') {
199
+ part.output = { type: 'text', value: typeof out.value === 'string' ? out.value : String(out.value ?? '') };
200
+ fixed++;
201
+ continue;
202
+ }
203
+ // error-json → json
204
+ if (out.type === 'error-json' || out.type === 'error_json') {
205
+ part.output = { type: 'json', value: out.value ?? null };
206
+ fixed++;
207
+ continue;
208
+ }
209
+ // 其它未知 type:降级为 text(保留 value 字符串或 JSON 序列化)
210
+ try {
211
+ part.output = { type: 'text', value: JSON.stringify(out) };
212
+ } catch {
213
+ part.output = { type: 'text', value: String(out) };
214
+ }
215
+ fixed++;
216
+ }
217
+ }
218
+ return fixed;
219
+ }
220
+
221
+ /**
222
+ * 把 assistant 消息里"已不可用工具"的 tool-call 转成 text,
223
+ * 防止 LLM 在新会话里看到历史 tool-call 后重试不存在的工具。
224
+ *
225
+ * 典型场景:
226
+ * - 工作流文件被删除/改名后,chat log 里仍保留对该工作流的 tool-call
227
+ * - 插件被禁用后,chat log 里保留该插件工具的 tool-call
228
+ *
229
+ * 转换后的 text 形如 "(工具 xxx 已不可用,原 tool-call 已跳过)",
230
+ * 紧接的 tool-result 如果是正常的成功结果会被保留;如果是失败结果
231
+ * (error-text 已 normalize 为 text),orphan 的 tool-result 会被
232
+ * `_stripOrphanedToolMessages` / `validateMessagesPairing` 自然清理。
233
+ *
234
+ * @param {Array} messages - 消息数组(会被修改)
235
+ * @param {Set<string>|Array<string>} availableToolNames - 当前可用的工具名集合
236
+ * @returns {number} 转换的 tool-call 数量
237
+ */
238
+ function filterOrphanToolCalls(messages, availableToolNames) {
239
+ if (!Array.isArray(messages) || !availableToolNames) return 0;
240
+ const available = availableToolNames instanceof Set
241
+ ? availableToolNames
242
+ : new Set(availableToolNames);
243
+ let converted = 0;
244
+
245
+ for (const msg of messages) {
246
+ if (msg.role !== 'assistant' || !Array.isArray(msg.content)) continue;
247
+ // 收集本条消息中所有 tool-call ID,便于在转换后保留
248
+ const newContent = [];
249
+ for (const part of msg.content) {
250
+ if (!part || (part.type !== 'tool-call' && part.type !== 'tool-use')) {
251
+ newContent.push(part);
252
+ continue;
253
+ }
254
+ if (part.toolName && available.has(part.toolName)) {
255
+ newContent.push(part);
256
+ continue;
257
+ }
258
+ // tool 不可用 → 转成 text 提示
259
+ converted++;
260
+ const reason = part.toolName
261
+ ? `(工具 ${part.toolName} 当前不可用,原 tool-call 已跳过)`
262
+ : `(工具调用参数不完整,已跳过)`;
263
+ newContent.push({
264
+ type: 'text',
265
+ text: reason,
266
+ });
267
+ // 注意:原 part 的 toolCallId 不再出现在新 content 里,
268
+ // _stripOrphanedToolMessages 会把对应的 tool-result 视为 orphan 并清理
269
+ }
270
+ msg.content = newContent;
271
+ }
272
+ return converted;
273
+ }
274
+
178
275
  /**
179
276
  * 过滤消息,保留配对的 tool-call → tool-result 链条
180
277
  * 用于上下文压缩后保留完整的工具调用链路
@@ -283,5 +380,7 @@ module.exports = {
283
380
  validateMessagesPairing,
284
381
  validateToolCalls,
285
382
  validateAll,
383
+ normalizeToolOutputs,
384
+ filterOrphanToolCalls,
286
385
  filterPairedMessages,
287
386
  };