211 lines
7.4 KiB
JavaScript
211 lines
7.4 KiB
JavaScript
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;
|
||
}
|
||
}
|
||
}
|