Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ToolName } from "../tool/tool";
import Tool from "../tool/tool";
import { ToolInfo } from "../tool/types";
import { requestLimiter } from "../utils/concurrency";
import { handleMessages } from "../utils/message";
import { buildSystemMessage, handleMessages } from "../utils/message";
import { checkRepeat, handleReply } from "../utils/string";
import { revive, TypeDescriptor } from "../utils/utils";

Expand Down Expand Up @@ -107,12 +107,16 @@ export default class Agent {
const toolInfos = Tool.getToolsInfo(session);
const trace = new AgentRunContext();

// system prompt 在同一轮工具循环内复用:避免每轮工具回调后重复做记忆检索/嵌入,
// 只在工具回调后更新 context messages(上下文仍随工具结果增长)。
const systemMessage = await buildSystemMessage(ctx, session);

let result: { contextArray: string[], replyArray: string[], images: Image[] } = { contextArray: [], replyArray: [], images: [] };
const MaxRetry = 3;

for (let retry = 1; retry <= MaxRetry; retry++) {
trace.beginTurn();
const messages = await handleMessages(ctx, session, this.isMultimodalChat(session));
const messages = await handleMessages(ctx, session, this.isMultimodalChat(session), toolInfos || [], systemMessage);
const { content: raw_reply, tool_calls } = await streamService.sendChatRequest(messages, toolInfos || [], tool_choice || 'auto', session.setting.modelName);
// 提示词工程模式下模型可能返回 ```function ... ``` 代码块包裹的工具调用:
// 发送前先剥离该块,避免代码块原文进入回复/上下文;调用内容仍以 match[0] 原样记录
Expand Down
4 changes: 1 addition & 3 deletions src/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,8 @@ export interface AgentGlobalApi {
registerTool(info: ToolInfo, options?: RegisterToolOptions): boolean;
}

/** 把智能体 API 挂载到 globalThis,重复加载/重载时保持幂等 */
/** 把智能体 API 挂载到 globalThis;JS 重载时直接覆盖为新版本对象,避免外部插件拿到旧 API */
export function registerAgentApi(): void {
if ((globalThis as any)[AGENT_GLOBAL_NAME]) return;

const api: AgentGlobalApi = {
name: NAME,
version: VERSION,
Expand Down
66 changes: 55 additions & 11 deletions src/agent/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,70 @@ import Model from "../model/model";
import { requestModel } from "../model/provider";
import { ToolCall } from "../tool/types";
import { UsageManager } from "../usage";
import { RequestMessage } from "../utils/message";
import { estimateTextTokens, RequestMessage } from "../utils/message";
import { withTimeout } from "../utils/utils";

/**
* 请求体消息净化(防御层):剔除空 tool_calls 数组与缺少 tool_call_id 的 tool 消息。
* 请求体消息净化(防御层):一次遍历同时完成——
* 1) 删除空 tool_calls 数组;
* 2) 删除没有匹配 assistant tool_calls 的 tool 消息(缺 tool_call_id 或引用不存在的调用);
* 3) 删除 assistant tool_calls 中引用不存在 tool 结果的调用项。
* handleMessages 已保证正常路径不产生这些脏数据,此处兜底外部调用(如其他插件经
* globalThis.aiplugin4.chatMessages 传入的 messages)与历史持久化数据。
*/
function sanitizeRequestMessages(messages: any[]): any[] {
return (messages || []).filter(m => {
if (m && m.role === 'tool' && !m.tool_call_id) {
logger.warning('剔除缺少 tool_call_id 的 tool 消息(避免请求报错)');
return false;
const list = (messages || []).filter(m => m && typeof m === 'object');
if (list.length === 0) return [];

// 先收集两侧引用:tool 结果携带的 tool_call_id 与 assistant 声明的 tool_call id
const toolResultIds = new Set<string>();
const assistantCallIds = new Set<string>();
for (const m of list) {
if (m.role === 'tool' && m.tool_call_id) toolResultIds.add(m.tool_call_id);
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) if (tc && tc.id) assistantCallIds.add(tc.id);
}
return true;
}).map(m => {
if (!m || typeof m !== 'object') return m;
}

const result: any[] = [];
for (const m of list) {
const out = { ...m };
if (Array.isArray(out.tool_calls) && out.tool_calls.length === 0) delete out.tool_calls;
return out;
});

if (out.role === 'tool') {
const id = out.tool_call_id;
if (!id || !assistantCallIds.has(id)) {
logger.warning('剔除没有匹配 assistant tool_calls 的 tool 消息');
continue;
}
result.push(out);
continue;
}

if (out.role === 'assistant' && Array.isArray(out.tool_calls) && out.tool_calls.length > 0) {
const kept = out.tool_calls.filter((tc: any) => tc && tc.id && toolResultIds.has(tc.id));
if (kept.length === 0) {
logger.warning('剔除引用不存在 tool 结果的 assistant tool_calls');
delete out.tool_calls;
} else if (kept.length < out.tool_calls.length) {
logger.warning('剔除部分引用不存在 tool 结果的 assistant tool_call');
out.tool_calls = kept;
}
}
result.push(out);
}
return result;
}

/** 发送前校验整包预算:估算 messages + tools 的 token 总量,超出「上下文最大token」时告警 */
function checkRequestBudget(messages: any[], tools: any[]): void {
const { MAX_CONTEXT_TOKENS: maxTokens } = Config.message;
if (maxTokens <= 0) return;
const toolsEstimate = tools && tools.length > 0 ? estimateTextTokens(JSON.stringify(tools)) : 0;
const estimate = estimateTextTokens(JSON.stringify(messages || [])) + toolsEstimate;
if (estimate > maxTokens) {
logger.warning(`请求体估算 token(含 tools JSON)超出「上下文最大token」预算: ${estimate} / ${maxTokens}`);
}
}

export class streamService {
Expand Down Expand Up @@ -118,6 +161,7 @@ export class streamService {
if (tools && tools.length > 0) body.tools = tools;
body.tool_choice = tool_choice;
}
checkRequestBudget(body.messages, tools || []);
logger.printRequestMessages(body.messages);

const time = Date.now();
Expand Down
4 changes: 4 additions & 0 deletions src/cmd/sub_cmd/ctxn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// .ai ctxn:上下文内名字自动修改相关
import { logger } from "../../logger";
import { aliasToCmd } from "../../utils/utils";
import { I, U } from "../privilege";
import { SubCmd, SubCmdContext } from "../root_cmd";
Expand Down Expand Up @@ -40,6 +41,9 @@ export function registerCmdCtxn() {
const promises = session.context.userInfoList.map(ui => session.context.setName(epId, gid, ui.id, mod));
Promise.all(promises).then(() => {
seal.replyToSender(ctx, msg, `设置完成,上下文里的名字有:\n${session.context.userInfoList.map(uni => `${uni.name}(${uni.id})`).join('\n')}`);
}).catch((e: any) => {
logger.error(`批量设置上下文名字出错,错误信息:${e instanceof Error ? e.message : String(e)}`);
seal.replyToSender(ctx, msg, '批量设置名字失败,请查看日志');
});
return ret;
}
Expand Down
20 changes: 18 additions & 2 deletions src/config/configs/message.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// 对话配置:角色设定/示例对话/轮数/插入间隔/压缩阈值
import { logger } from "../../logger";
import { ext } from "../config";
export default class MessageConfig {

Expand All @@ -17,21 +18,36 @@ export default class MessageConfig {

static get() {
const INSTRUCTIONS = seal.ext.getTemplateConfig(ext, "角色扮演设定");
const MAX_ROUNDS = seal.ext.getIntConfig(ext, "对话保存轮数");
const INSERT_COUNT = normalizeInsertCount(seal.ext.getIntConfig(ext, "插入system message间隔轮数"), MAX_ROUNDS);
return {
INSTRUCTIONS,
ROLE_NAMES: parseRoleNames(INSTRUCTIONS),
ROLE_SETTINGS: parseRoleSettings(INSTRUCTIONS),
SAMPLE_MESSAGES: seal.ext.getTemplateConfig(ext, "示例对话"),
MAX_ROUNDS: seal.ext.getIntConfig(ext, "对话保存轮数"),
MAX_ROUNDS,
MAX_CONTEXT_TOKENS: seal.ext.getIntConfig(ext, "上下文最大token"),
INSERT_COUNT: seal.ext.getIntConfig(ext, "插入system message间隔轮数"),
INSERT_COUNT,
COMPRESS_THRESHOLD: seal.ext.getIntConfig(ext, "消息压缩阈值")
}
}
}

const ROLE_NAME_MAX_LENGTH = 20;

/**
* 插入 system message 间隔轮数校验:必须 > 0 且小于「对话保存轮数」的一半才生效,
* 否则按关闭(0)处理并告警,避免插入频率超过历史窗口导致 system 消息占满上下文。
*/
function normalizeInsertCount(raw: number, maxRounds: number): number {
if (raw <= 0) return 0;
if (maxRounds > 0 && raw * 2 >= maxRounds) {
logger.warning(`「插入system message间隔轮数」${raw} 未小于「对话保存轮数」${maxRounds} 的二分之一,已按关闭处理`);
return 0;
}
return raw;
}

/**
* 解析单个角色设定条目:第一行为角色设定名称(超过 20 字符自动截断),其余为设定内容。
* 兼容旧格式:整条只有一行时,整条内容作为设定,首行截断作为名称。
Expand Down
7 changes: 7 additions & 0 deletions src/memory/knowledge_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ export class KnowledgeBaseService {
return this.chunks.length === 0;
}

/** prompt 缓存版本:基于当前配置开关/阈值/全部条目内容生成,配置变化后自然产生新 key */
getCacheVersion(): string {
const items = Array.isArray(Config.memory.KNOWLEDGE_ITEMS) ? Config.memory.KNOWLEDGE_ITEMS : [];
const signature = items.map(item => hashString(item || '')).join(',');
return `${Config.memory.KNOWLEDGE ? '1' : '0'}|${Config.memory.KNOWLEDGE_INJECT_THRESHOLD}|${items.length}|${signature}`;
}

/** 全部条目索引(id/标题/小节) */
list(): KnowledgeChunk[] {
return this.chunks;
Expand Down
21 changes: 19 additions & 2 deletions src/memory/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { stripInternalTags } from "../utils/string";
import { generateId, getCommonItem, revive, TypeDescriptor } from "../utils/utils";

import MemoryItem from "./memory_item";
import { bumpMemoryRevision } from "./revision";
import { MemorySource, searchOptions } from "./types";

export default class MemoryService {
Expand Down Expand Up @@ -111,11 +112,18 @@ export default class MemoryService {

clearMemory() {
this.memoryMap = {};
bumpMemoryRevision();
}

deleteMemory(ids: string[] = [], kws: string[] = []) {
const before = this.memories.length;
// 按 id 精确删除(复用 deleteMemories 的严格匹配路径)
if (ids.length > 0) this.deleteMemories(ids);
let bumpedByDeleteMemories = false;
if (ids.length > 0) {
const beforeIds = this.memories.length;
this.deleteMemories(ids);
bumpedByDeleteMemories = this.memories.length !== beforeIds;
}
// 按关键词宽松删除:命中任一关键词即删除
if (kws.length > 0) {
for (const id in this.memoryMap) {
Expand All @@ -124,6 +132,7 @@ export default class MemoryService {
}
}
}
if (!bumpedByDeleteMemories && this.memories.length !== before) bumpMemoryRevision();
}

async addMemory(_ctx: seal.MsgContext | null, session: Session, ul: UserInfo[], gl: GroupInfo[], kws: string[], images: Image[], text: string, visibility: 'public' | 'private' = 'public') {
Expand All @@ -149,6 +158,7 @@ export default class MemoryService {
await m.updateVector();
this.limitMemory();
this.memoryMap[id] = m;
bumpMemoryRevision();
}

limitMemory() {
Expand All @@ -161,7 +171,10 @@ export default class MemoryService {
const listText = ml.map((m, i) =>
(i + 1) + '. [' + m.id + '] ' + m.content
).join('\n');
return '私聊:' + si.isPrivate + '\n群聊名称:' + si.name + '\n记忆列表:\n' + listText;
if (si.isPrivate) {
return '记忆类型:个人记忆\n记忆列表:\n' + listText;
}
return '记忆类型:群聊记忆\n群聊名称:' + si.name + '\n记忆列表:\n' + listText;
}

getLatestMemoryListText(si: SessionInfo, p: number = 1): string {
Expand Down Expand Up @@ -266,6 +279,7 @@ export default class MemoryService {
await Promise.all(memoriesToAdd.map(async m => await m.updateVector()));
this.limitMemories(memoriesToAdd.length);
memoriesToAdd.forEach(m => this.memoryMap[m.id] = m);
bumpMemoryRevision();
}

/**
Expand All @@ -279,6 +293,7 @@ export default class MemoryService {
*/
deleteMemories(ids: string[] = [], tags: string[] = [], relatedMemories: string[] = [], users: string[] = [], groups: string[] = []) {
if (ids.length === 0 && tags.length === 0 && relatedMemories.length === 0 && users.length === 0 && groups.length === 0) return;
const before = this.memories.length;

if (ids.length > 0) {
ids.forEach(id => {
Expand All @@ -303,6 +318,7 @@ export default class MemoryService {
) delete this.memoryMap[id];
}
}
if (this.memories.length !== before) bumpMemoryRevision();
}

limitMemories(vacancy: number) {
Expand All @@ -323,6 +339,7 @@ export default class MemoryService {

clearMemories() {
this.memoryMap = {};
bumpMemoryRevision();
}

private static lastEmbeddingWarnAt = 0;
Expand Down
24 changes: 24 additions & 0 deletions src/memory/revision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// 记忆缓存版本:记忆/总结内容变更后自增,并主动失效对应 prompt 缓存。
// 使用全局版本而非逐实例版本,因为一次总结可能写入多个会话的记忆,任何写入都应刷新所有相关缓存。
import { invalidateCachedPrefix } from "../prompt/prompt_cache";

let memoryRevision = 0;
let summaryRevision = 0;

export function getMemoryRevision(): number {
return memoryRevision;
}

export function bumpMemoryRevision(): void {
memoryRevision++;
invalidateCachedPrefix('prompt:memory:');
}

export function getSummaryRevision(): number {
return summaryRevision;
}

export function bumpSummaryRevision(): void {
summaryRevision++;
invalidateCachedPrefix('prompt:summary:');
}
3 changes: 3 additions & 0 deletions src/memory/session_memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { TypeDescriptor } from "../utils/utils";

import MemoryService from "./memory";
import MemoryItem from "./memory_item";
import { bumpSummaryRevision } from "./revision";

export default class SessionMemoryService extends MemoryService {
static validKeysMap: { [key in keyof SessionMemoryService]?: TypeDescriptor<SessionMemoryService[key]> } = {
Expand Down Expand Up @@ -110,6 +111,7 @@ export default class SessionMemoryService extends MemoryService {
// 同时写入总结记忆,供 buildSummaryPrompt 使用
this.summaries.push(summaryContent);
this.limitSummaries();
bumpSummaryRevision();

// 与 add_memory 工具一致:按 memory_type/name 决定记忆归属(个人→目标用户会话,群聊→目标群会话)。
// 模型不保证遵守模板:memories 缺失/非数组时跳过落库(摘要仍保留),逐条定位失败仅跳过该条,
Expand Down Expand Up @@ -210,6 +212,7 @@ export default class SessionMemoryService extends MemoryService {

clearSummaries() {
this.summaries = [];
bumpSummaryRevision();
}

buildSummaryPrompt(): string {
Expand Down
14 changes: 13 additions & 1 deletion src/model/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ function buildAnthropicMessages(messages: any[]): { system: string, messages: an
for (const msg of messages || []) {
const role = msg.role;
if (role === 'system') {
const text = typeof msg.content === 'string' ? msg.content : '';
const text = extractSystemText(msg.content);
if (text) systemParts.push(text);
continue;
}
Expand Down Expand Up @@ -153,6 +153,18 @@ function buildAnthropicMessages(messages: any[]): { system: string, messages: an
return { system: systemParts.join('\n'), messages: merged };
}

/** 提取 system 消息文本:支持纯字符串与 OpenAI 多模态内容块数组(仅拼接 text 块) */
function extractSystemText(content: any): string {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter((b: any) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string')
.map((b: any) => b.text)
.join('\n');
}
return '';
}

function mergeAnthropicContent(a: any, b: any): any {
const toBlocks = (c: any): any[] => typeof c === 'string' ? [{ type: 'text', text: c }] : (Array.isArray(c) ? c : []);
return toBlocks(a).concat(toBlocks(b));
Expand Down
Loading
Loading