Files
2026-09-13 11:40:42 +08:00

211 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dotenv/config';
import fs from 'fs';
import path from 'path';
import { createDeepAgent, FilesystemBackend, CompositeBackend } from "deepagents";
import { ChatOpenAI } from "@langchain/openai";
import { AIMessageChunk, ToolMessage } from "langchain";
import { HumanMessage } from "@langchain/core/messages";
import { ChatDeepSeek } from "@langchain/deepseek";
import config from '../../../config.js';
import logger from '../../../utils/logger.js';
// 密钥由 conf.json 统一提供;百度地图 skill 通过 env_get 读环境变量,此处桥接
process.env.TAVILY_API_KEY ??= config.agent.tavily?.apiKey;
process.env.BAIDU_MAP_AUTH_TOKEN ??= config.agent.baiduMap?.authToken;
import Prompts from "./prompts.js";
import { createRestrictedBackend } from "../../infra/restricted_fs_backend.js";
import { createHospitalInfoSubagent } from "../../subagent/hospital_info.js";
import {
getEnvTool, webFetchTool, webSearchTool, getCalendarInfoTool,
getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool, getLatLngTool,
httpGetTool, httpPostTool, createEscortRecordQueryTool
} from "../../infra/tools/index.js";
export default class EscortAgent {
// 历史消息字符数上限,超出时从头部丢弃完整轮次
static MAX_HISTORY_CHARS = config.agent.maxHistoryChars || 40000;
constructor() {
this.messages = [];
}
clearMessages() {
this.messages = [];
}
// 按字符数裁剪历史:只以 HumanMessage 为界从头部删除完整轮次,
// 避免切断 AI(tool_calls) -> Tool 的链式结构;永远保留最新一轮
_trimHistory() {
const size = (msgs) => msgs.reduce((sum, m) => sum + (m.text?.length ?? 0), 0);
if (size(this.messages) <= EscortAgent.MAX_HISTORY_CHARS) {
return;
}
const starts = [];
this.messages.forEach((m, i) => {
if (HumanMessage.isInstance(m)) starts.push(i);
});
if (starts.length < 2) {
return;
}
let cut = 0;
for (let i = 0; i < starts.length - 1; i++) {
cut = starts[i + 1];
if (size(this.messages.slice(cut)) <= EscortAgent.MAX_HISTORY_CHARS) {
break;
}
}
this.messages = this.messages.slice(cut);
logger.info(`History trimmed to ${this.messages.length} messages, ${size(this.messages)} chars`);
}
// msg: { ts: "2023-08-01 10:00:00", content: "你好" }
async streamChat(userInfo, msgs, callback) {
if (!msgs.length) {
return;
}
msgs.forEach(msg => {
if (msg.type === "clear") {
this.messages = [];
this.agent = null;
} else {
this.messages.push(new HumanMessage(`${msg.ts} - ${msg.content}`));
}
});
if (this.messages.length === 0) {
return;
}
this._trimHistory();
// 在处理完 clear 消息后再生成 agent,确保清空后使用新实例
const agent = this._genAgent(userInfo);
const INTERESTING_NODES = new Set(["model_request", "tools"]);
for await (const [namespace, mode, data] of await agent.stream(
{ messages: this.messages },
{
recursion_limit: 50,
streamMode: ["updates", "messages", "custom"], subgraphs: true,
configurable: {
thread_id: msgs[0].userId || msgs[0].appId
}
})) {
const isSubagent = namespace.some(s => s.startsWith("tools:"));
const source = isSubagent ? "subagent" : "main";
if (mode === "updates") {
// Main agent updates (empty namespace)
if (namespace.length === 0) {
for (const [nodeName, data_] of Object.entries(data)) {
if (!INTERESTING_NODES.has(nodeName)) continue;
if (nodeName === "tools") {
// 工具结果必须并入历史,否则下一轮会出现 tool_calls 缺少对应 tool 结果的断链
for (const msg of data_.messages ?? []) {
if (msg.type === "tool") {
this.messages.push(msg);
logger.info(`Tool complete: ${msg.name}, Result: ${String(msg.content).slice(0, 200)}`);
}
}
} else if (nodeName === "model_request") {
this.messages.push(...data_.messages);
}
}
} else {
// Subagent updates (non-empty namespace)
for (const nodeName of Object.keys(data)) {
if (!INTERESTING_NODES.has(nodeName)) continue;
logger.info(`[${namespace[0]}] step: ${nodeName}`);
}
}
} else if (mode === "messages") {
const [message, metadata] = data;
if (message.tool_call_chunks?.length) {
continue;
}
if (metadata?.lcSource === "summarization" || metadata?.lc_source === "summarization") {
continue;
}
if (AIMessageChunk.isInstance(message)) {
if (message.text) {
callback(source, "ai", message.text, message.id);
}
if (message.additional_kwargs.reasoning_content) {
callback(source, "reasoning", message.additional_kwargs.reasoning_content, message.id);
}
}
if (ToolMessage.isInstance(message) && message.text) {
callback(source, "tool", message.text, message.id);
}
} else if (mode === "custom") {
logger.info("custom: ", data);
}
}
}
_genAgent(userInfo) {
const rootDir = process.cwd();
// 外部数据(用户记忆等)存储目录,由 conf.json agent.dataDir 配置
const dataDir = path.resolve(rootDir, config.agent.dataDir ?? "data");
const memoryFile = userInfo
? path.join(dataDir, "users", userInfo._id, "memory.txt")
: null;
// 会话中记忆文件被更新后重建 agent,使 systemPrompt 中的用户记忆保持最新
if (this.agent && this._memoryMtime !== this._statMemory(memoryFile)) {
this.agent = null;
}
if (this.agent) {
return this.agent;
}
// 受限后端:锁定沙箱根并禁止读取 conf.json/.env/logs 及用户数据目录;
// 登录用户挂载 /memories/ ↔ data/users/{userId}//memories/memory.txt ↔ .../memory.txt
const backend = userInfo
? new CompositeBackend(
createRestrictedBackend({ rootDir, dataDir }),
{
"/memories/": new FilesystemBackend({
rootDir: path.join(dataDir, "users", userInfo._id),
virtualMode: true
})
},
)
: createRestrictedBackend({ rootDir, dataDir });
this.flashModel = new ChatDeepSeek({
model: config.agent.deepseek.flashModel,
apiKey: config.agent.deepseek.apiKey,
temperature: 0.0
});
this._memoryMtime = this._statMemory(memoryFile);
this.agent = createDeepAgent({
name: "deep-agent",
model: this.flashModel,
systemPrompt: Prompts.buildSystemPrompt(userInfo),
memory: ["./agent/bots/bot-escort/AGENTS.md"],
backend,
tools: [getEnvTool, webFetchTool, webSearchTool, getLatLngTool, httpGetTool, httpPostTool,
getCalendarInfoTool, getLunarCalendarInfoTool, getYearHolidaysTool, getYearTermsTool,
createEscortRecordQueryTool(userInfo)],
subagents: [createHospitalInfoSubagent(this.flashModel)],
skills: ["./agent/infra/skills/"],
});
return this.agent;
}
// 返回记忆文件 mtime,文件不存在时返回 null
_statMemory(memoryFile) {
if (!memoryFile) return null;
try {
return fs.statSync(memoryFile).mtimeMs;
} catch {
return null;
}
}
}