assistantOrchestrator.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. import type {
  2. AssistantResultTable,
  3. AssistantRunRequest,
  4. AssistantRunResponse,
  5. AssistantToolCallRecord,
  6. PriceComparisonRow
  7. } from "../../shared/types";
  8. import type { AgentRunner } from "../agent/runner";
  9. import { listTasks } from "../agent/tasks";
  10. import type {
  11. AssistantToolDescriptor,
  12. AssistantToolObservation,
  13. AssistantToolPlan
  14. } from "../deepseek/deepSeekClient";
  15. import type { LogStore } from "../agent/logStore";
  16. import type { McpClient, McpToolDescriptor } from "../mcp/mcpClient";
  17. export class AssistantOrchestrator {
  18. constructor(
  19. private readonly deps: {
  20. mcpClient: McpClient;
  21. runner: AgentRunner;
  22. logStore: LogStore;
  23. }
  24. ) {}
  25. async run(request: AssistantRunRequest): Promise<AssistantRunResponse> {
  26. const tools = await this.loadToolDescriptors();
  27. const byName = new Map(tools.map((tool) => [tool.name, tool]));
  28. const observations: AssistantToolObservation[] = [];
  29. const seenCalls = new Set<string>();
  30. const maxSteps = 6;
  31. for (let step = 0; step < maxSteps; step += 1) {
  32. const plan = await this.planViaMcp(request, tools, observations);
  33. if (plan.mode !== "tool" || plan.toolCalls.length === 0) {
  34. break;
  35. }
  36. const executableCalls = plan.toolCalls.filter((call) => {
  37. const signature = toolCallSignature(call.name, call.arguments);
  38. if (seenCalls.has(signature)) {
  39. this.deps.logStore.add("warn", `智能体跳过重复工具调用:${call.name}`);
  40. return false;
  41. }
  42. seenCalls.add(signature);
  43. return true;
  44. });
  45. if (executableCalls.length === 0) {
  46. break;
  47. }
  48. this.deps.logStore.add("info", "Agent loop 执行工具步骤", {
  49. step: step + 1,
  50. tools: executableCalls.map((call) => call.name).join(", ")
  51. });
  52. for (const call of executableCalls) {
  53. const resolvedName = resolveToolName(call.name, byName);
  54. const descriptor = resolvedName ? byName.get(resolvedName) : undefined;
  55. if (!descriptor) {
  56. observations.push({
  57. name: call.name,
  58. source: "local",
  59. arguments: call.arguments,
  60. ok: false,
  61. error: "DeepSeek 选择了未注册工具"
  62. });
  63. continue;
  64. }
  65. observations.push(await this.executeTool(descriptor, call.arguments));
  66. }
  67. }
  68. if (observations.length === 0) {
  69. const result = await this.chatViaMcp(request);
  70. return {
  71. content: result.content,
  72. model: result.model,
  73. usedMcp: true,
  74. toolCalls: []
  75. };
  76. }
  77. const toolCalls: AssistantToolCallRecord[] = observations.map((item) => ({
  78. name: item.name,
  79. source: item.source,
  80. arguments: item.arguments,
  81. ok: item.ok,
  82. result: item.result,
  83. error: item.error
  84. }));
  85. const tables = extractTables(toolCalls);
  86. const comparisonRows = extractComparisonRows(toolCalls);
  87. let content: string;
  88. let model = "tool-only";
  89. try {
  90. const summary = await this.summarizeViaMcp(request, observations);
  91. content = summary.content;
  92. model = summary.model;
  93. } catch (error) {
  94. const message = error instanceof Error ? error.message : String(error);
  95. this.deps.logStore.add("warn", `MCP DeepSeek 工具结果总结失败:${message}`);
  96. content = fallbackToolSummary(toolCalls, tables, comparisonRows);
  97. }
  98. return {
  99. content,
  100. model,
  101. usedMcp: toolCalls.some((item) => item.source === "mcp"),
  102. toolCalls,
  103. tables: tables.length ? tables : undefined,
  104. comparisonRows: comparisonRows.length ? comparisonRows : undefined
  105. };
  106. }
  107. private async loadToolDescriptors(): Promise<AssistantToolDescriptor[]> {
  108. const tools: AssistantToolDescriptor[] = [];
  109. try {
  110. const mcpTools = await this.deps.mcpClient.listTools();
  111. tools.push(...mcpTools.filter((tool) => !tool.name.startsWith("smqjh.ai.")).map(toMcpDescriptor));
  112. } catch (error) {
  113. const message = error instanceof Error ? error.message : String(error);
  114. this.deps.logStore.add("warn", `MCP 工具列表读取失败:${message}`);
  115. }
  116. const localTasks = listTasks();
  117. const localNames = tools.length > 0 ? new Set(["product.price.compare"]) : new Set(localTasks.map((task) => task.id));
  118. for (const task of localTasks) {
  119. if (!localNames.has(task.id)) {
  120. continue;
  121. }
  122. tools.push({
  123. name: task.id,
  124. source: "local",
  125. title: task.title,
  126. description: task.description,
  127. inputSchema: {
  128. type: "object",
  129. properties: Object.fromEntries(
  130. task.params.map((param) => [
  131. param.key,
  132. {
  133. type: "string",
  134. description: [param.label, param.placeholder].filter(Boolean).join(";")
  135. }
  136. ])
  137. ),
  138. required: task.params.filter((param) => param.required).map((param) => param.key),
  139. additionalProperties: false
  140. }
  141. });
  142. }
  143. return tools;
  144. }
  145. private async planViaMcp(
  146. request: AssistantRunRequest,
  147. tools: AssistantToolDescriptor[],
  148. observations: AssistantToolObservation[]
  149. ): Promise<AssistantToolPlan> {
  150. const result = await this.deps.mcpClient.callTool("smqjh.ai.tool.plan", {
  151. request,
  152. tools,
  153. observations
  154. });
  155. const record = asRecord(result.structuredContent);
  156. const rawCalls = Array.isArray(record.toolCalls) ? record.toolCalls : [];
  157. const toolCalls = rawCalls
  158. .map((item): AssistantToolPlan["toolCalls"][number] | undefined => {
  159. const call = asRecord(item);
  160. const name = typeof call.name === "string" ? call.name.trim() : "";
  161. if (!name) {
  162. return undefined;
  163. }
  164. return {
  165. name,
  166. arguments: repairToolArguments(name, asRecord(call.arguments), request.message)
  167. };
  168. })
  169. .filter((item): item is AssistantToolPlan["toolCalls"][number] => Boolean(item))
  170. .slice(0, 3);
  171. const mode = record.mode === "tool" && toolCalls.length > 0 ? "tool" : "chat";
  172. this.deps.logStore.add("info", "MCP DeepSeek 工具规划完成", {
  173. mode,
  174. toolCount: toolCalls.length,
  175. reason: typeof record.reason === "string" ? record.reason : ""
  176. });
  177. return {
  178. mode,
  179. toolCalls: mode === "tool" ? toolCalls : [],
  180. replyWhenNoTool: typeof record.replyWhenNoTool === "string" ? record.replyWhenNoTool : undefined,
  181. reason: typeof record.reason === "string" ? record.reason : undefined
  182. };
  183. }
  184. private async summarizeViaMcp(request: AssistantRunRequest, observations: AssistantToolObservation[]): Promise<{ content: string; model: string }> {
  185. const result = await this.deps.mcpClient.callTool("smqjh.ai.tool.summarize", {
  186. request,
  187. observations
  188. });
  189. const record = asRecord(result.structuredContent);
  190. return {
  191. content: typeof record.content === "string" ? record.content : "工具已执行,但 MCP DeepSeek 没有生成有效总结。",
  192. model: typeof record.model === "string" ? record.model : "mcp-deepseek"
  193. };
  194. }
  195. private async chatViaMcp(request: AssistantRunRequest): Promise<{ content: string; model: string }> {
  196. const result = await this.deps.mcpClient.callTool("smqjh.ai.chat", {
  197. request
  198. });
  199. const record = asRecord(result.structuredContent);
  200. return {
  201. content: typeof record.content === "string" ? record.content : "MCP DeepSeek 没有返回有效内容。",
  202. model: typeof record.model === "string" ? record.model : "mcp-deepseek"
  203. };
  204. }
  205. private async executeTool(descriptor: AssistantToolDescriptor, args: Record<string, unknown>): Promise<AssistantToolObservation> {
  206. try {
  207. if (descriptor.source === "mcp") {
  208. const result = await this.deps.mcpClient.callTool(descriptor.name, args);
  209. this.deps.logStore.add("info", `MCP 工具执行完成:${descriptor.name}`);
  210. return {
  211. name: descriptor.name,
  212. source: "mcp",
  213. arguments: args,
  214. ok: true,
  215. result: result.structuredContent ?? result
  216. };
  217. }
  218. const result = await this.deps.runner.run({
  219. taskId: descriptor.name,
  220. params: toStringParams(args)
  221. });
  222. return {
  223. name: descriptor.name,
  224. source: "local",
  225. arguments: args,
  226. ok: result.success,
  227. result,
  228. error: result.success ? undefined : result.message
  229. };
  230. } catch (error) {
  231. const message = error instanceof Error ? error.message : String(error);
  232. this.deps.logStore.add("error", `工具执行失败:${descriptor.name}:${message}`);
  233. return {
  234. name: descriptor.name,
  235. source: descriptor.source,
  236. arguments: args,
  237. ok: false,
  238. error: message
  239. };
  240. }
  241. }
  242. }
  243. function toMcpDescriptor(tool: McpToolDescriptor): AssistantToolDescriptor {
  244. return {
  245. name: tool.name,
  246. source: "mcp",
  247. title: tool.title,
  248. description: tool.description,
  249. inputSchema: tool.inputSchema
  250. };
  251. }
  252. function asRecord(value: unknown): Record<string, unknown> {
  253. return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
  254. }
  255. function resolveToolName(name: string, byName: Map<string, AssistantToolDescriptor>): string | undefined {
  256. const candidates = [
  257. name,
  258. name.startsWith("smqjh.") ? name.slice("smqjh.".length) : `smqjh.${name}`,
  259. name === "smqjh.product.price.compare" ? "product.price.compare" : "",
  260. name === "product.lookup.summary" ? "smqjh.product.lookup.summary" : "",
  261. name === "order.count.query" ? "smqjh.order.count.query" : "",
  262. name === "database.readonly.query" ? "smqjh.database.readonly.query" : "",
  263. name === "cloud.health" ? "smqjh.cloud.health" : ""
  264. ].filter(Boolean);
  265. return candidates.find((candidate) => byName.has(candidate));
  266. }
  267. function toStringParams(args: Record<string, unknown>): Record<string, string> {
  268. return Object.fromEntries(
  269. Object.entries(args).map(([key, value]) => [key, value === undefined || value === null ? "" : String(value)])
  270. );
  271. }
  272. function toolCallSignature(name: string, args: Record<string, unknown>): string {
  273. return `${name}:${stableStringify(args)}`;
  274. }
  275. function stableStringify(value: unknown): string {
  276. if (!value || typeof value !== "object") {
  277. return JSON.stringify(value);
  278. }
  279. if (Array.isArray(value)) {
  280. return `[${value.map(stableStringify).join(",")}]`;
  281. }
  282. const record = value as Record<string, unknown>;
  283. return `{${Object.keys(record)
  284. .sort()
  285. .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
  286. .join(",")}}`;
  287. }
  288. function repairToolArguments(name: string, args: Record<string, unknown>, message: string): Record<string, unknown> {
  289. if (!isProductTool(name)) {
  290. return args;
  291. }
  292. const current = typeof args.productKeyword === "string" ? args.productKeyword.trim() : "";
  293. const extracted = extractProductKeywordFromMessage(message);
  294. if (!current || (extracted && !productKeywordMatchesMessage(current, message))) {
  295. return {
  296. ...args,
  297. productKeyword: extracted || current
  298. };
  299. }
  300. return args;
  301. }
  302. function isProductTool(name: string): boolean {
  303. return name === "smqjh.product.lookup.summary" || name === "product.price.compare" || name === "smqjh.product.price.compare";
  304. }
  305. function productKeywordMatchesMessage(keyword: string, message: string): boolean {
  306. const normalizedKeyword = normalizeProductText(keyword).replace(/\d+(?:\.\d+)?(?:ml|毫升|l|升|g|克|kg|千克)?/g, "");
  307. const normalizedMessage = normalizeProductText(message);
  308. const meaningful = normalizedKeyword.replace(/[^\u4e00-\u9fa5a-z]/g, "");
  309. if (meaningful.length < 2) {
  310. return false;
  311. }
  312. if (normalizedMessage.includes(meaningful)) {
  313. return true;
  314. }
  315. const chars = Array.from(new Set(meaningful.split("")));
  316. const overlap = chars.filter((char) => normalizedMessage.includes(char)).length;
  317. return overlap >= Math.min(3, chars.length);
  318. }
  319. function extractProductKeywordFromMessage(message: string): string {
  320. let text = message
  321. .replace(/[,。!??;;::]/g, " ")
  322. .replace(/帮我|麻烦|查询一下|查一下|查询|查看|当前|业务系统|系统里面|系统里|后台|我方|我们的|商品库|商品表/g, " ")
  323. .replace(/和其它电商平台|和其他电商平台|其它电商平台|其他电商平台|淘宝|京东|拼多多|苏宁|慢慢买|价格对比|做个对比|对比/g, " ")
  324. .replace(/商品描述是什么|商品描述|描述是什么|描述|价格是多少|价格|定价|是多少|是什么|呢/g, " ")
  325. .replace(/\s+/g, " ")
  326. .trim();
  327. const specMatch = text.match(/[\u4e00-\u9fa5A-Za-z0-9()()_\-\s/*×x.]+?(?:\d+(?:\.\d+)?\s*(?:ml|mL|ML|毫升|L|l|升|g|克|kg|千克)\s*(?:[*×x/]\s*\d+\s*(?:瓶|罐|箱|盒|袋|件|包)?)?|瓶|罐|箱|盒|袋|件|包)/);
  328. if (specMatch?.[0]) {
  329. text = specMatch[0].trim();
  330. }
  331. return text
  332. .replace(/^(的|了|在|里|中|一下|这个|那个)\s*/g, "")
  333. .replace(/\s+(的|了|在|里|中|一下)$/g, "")
  334. .replace(/\s+/g, " ")
  335. .trim();
  336. }
  337. function normalizeProductText(value: string): string {
  338. return value
  339. .toLowerCase()
  340. .replace(/×/g, "*")
  341. .replace(/\s+/g, "")
  342. .trim();
  343. }
  344. function extractTables(toolCalls: AssistantToolCallRecord[]): AssistantResultTable[] {
  345. const tables: AssistantResultTable[] = [];
  346. for (const call of toolCalls) {
  347. tables.push(...extractTablesFromValue(toolTitle(call.name), call.result));
  348. }
  349. return tables.slice(0, 4);
  350. }
  351. function extractTablesFromValue(title: string, value: unknown): AssistantResultTable[] {
  352. const tables: AssistantResultTable[] = [];
  353. const seen = new WeakSet<object>();
  354. function visit(node: unknown, currentTitle: string, depth: number): void {
  355. if (!node || typeof node !== "object" || depth > 4) {
  356. return;
  357. }
  358. if (seen.has(node)) {
  359. return;
  360. }
  361. seen.add(node);
  362. const record = node as Record<string, unknown>;
  363. const rows = Array.isArray(record.rows) ? record.rows : undefined;
  364. const columns = Array.isArray(record.columns) ? record.columns.map((item) => String(item)) : undefined;
  365. if (rows && rows.every(isRecord)) {
  366. const rowRecords = rows.map((row) => normalizeRow(row as Record<string, unknown>));
  367. const rowColumns = columns?.length ? columns : Array.from(new Set(rowRecords.flatMap((row) => Object.keys(row))));
  368. if (rowColumns.length > 0) {
  369. tables.push({
  370. title: buildTableTitle(currentTitle, record),
  371. columns: rowColumns,
  372. rows: rowRecords
  373. });
  374. }
  375. }
  376. for (const key of ["data", "structuredContent", "result", "record"]) {
  377. if (record[key]) {
  378. visit(record[key], currentTitle, depth + 1);
  379. }
  380. }
  381. }
  382. visit(value, title, 0);
  383. return tables;
  384. }
  385. function extractComparisonRows(toolCalls: AssistantToolCallRecord[]): PriceComparisonRow[] {
  386. const rows: PriceComparisonRow[] = [];
  387. for (const call of toolCalls) {
  388. collectComparisonRows(call.result, rows, 0);
  389. }
  390. return rows;
  391. }
  392. function collectComparisonRows(value: unknown, rows: PriceComparisonRow[], depth: number): void {
  393. if (!value || typeof value !== "object" || depth > 4) {
  394. return;
  395. }
  396. const record = value as Record<string, unknown>;
  397. if (Array.isArray(record.comparisonRows)) {
  398. for (const row of record.comparisonRows) {
  399. if (isPriceComparisonRow(row)) {
  400. rows.push(row);
  401. }
  402. }
  403. }
  404. for (const key of ["data", "structuredContent", "result"]) {
  405. if (record[key]) {
  406. collectComparisonRows(record[key], rows, depth + 1);
  407. }
  408. }
  409. }
  410. function isPriceComparisonRow(value: unknown): value is PriceComparisonRow {
  411. if (!isRecord(value)) {
  412. return false;
  413. }
  414. return ["platform", "productName", "price", "status", "source", "note"].every((key) => typeof value[key] === "string");
  415. }
  416. function isRecord(value: unknown): value is Record<string, unknown> {
  417. return Boolean(value && typeof value === "object" && !Array.isArray(value));
  418. }
  419. function normalizeRow(row: Record<string, unknown>): Record<string, unknown> {
  420. return Object.fromEntries(
  421. Object.entries(row).map(([key, value]) => [key, value === undefined || value === null ? "" : String(value)])
  422. );
  423. }
  424. function buildTableTitle(baseTitle: string, record: Record<string, unknown>): string {
  425. const rowCount = record.rowCount === undefined ? "" : `(${record.rowCount} 行)`;
  426. return `${baseTitle}${rowCount}`;
  427. }
  428. function toolTitle(name: string): string {
  429. const titles: Record<string, string> = {
  430. "smqjh.database.readonly.query": "数据库只读查询结果",
  431. "smqjh.database.smart.query": "智能数据库查询结果",
  432. "smqjh.schema.search": "业务表搜索结果",
  433. "smqjh.schema.getTable": "业务表说明",
  434. "smqjh.schema.businessRules": "业务规则",
  435. "smqjh.order.count.query": "订单统计结果",
  436. "smqjh.product.lookup.summary": "商品资料查询结果",
  437. "smqjh.settlement.enterprise.list": "月结企业清单",
  438. "smqjh.settlement.monthly.plan": "企业月结计划",
  439. "product.price.compare": "商品价格对比"
  440. };
  441. return titles[name] ?? name;
  442. }
  443. function fallbackToolSummary(toolCalls: AssistantToolCallRecord[], tables: AssistantResultTable[], comparisonRows: PriceComparisonRow[]): string {
  444. const okCount = toolCalls.filter((item) => item.ok).length;
  445. const failed = toolCalls.filter((item) => !item.ok);
  446. const lines = [`已执行 ${toolCalls.length} 个工具,成功 ${okCount} 个。`];
  447. if (tables.length || comparisonRows.length) {
  448. lines.push("结果已整理为下方表格。");
  449. }
  450. if (failed.length) {
  451. lines.push(`失败工具:${failed.map((item) => `${item.name}(${item.error || "调用失败"})`).join(";")}`);
  452. }
  453. const sqlEvidence = findExecutedSql(toolCalls);
  454. if (sqlEvidence) {
  455. lines.push(`依据:${sqlEvidence}`);
  456. }
  457. return lines.join("\n\n");
  458. }
  459. function findExecutedSql(toolCalls: AssistantToolCallRecord[]): string {
  460. for (const call of toolCalls) {
  461. const sql = findKeyValue(call.result, "executedSql", 0);
  462. if (typeof sql === "string" && sql.trim()) {
  463. return sql.trim();
  464. }
  465. }
  466. return "";
  467. }
  468. function findKeyValue(value: unknown, key: string, depth: number): unknown {
  469. if (!value || typeof value !== "object" || depth > 4) {
  470. return undefined;
  471. }
  472. const record = value as Record<string, unknown>;
  473. if (record[key] !== undefined) {
  474. return record[key];
  475. }
  476. for (const child of Object.values(record)) {
  477. const result = findKeyValue(child, key, depth + 1);
  478. if (result !== undefined) {
  479. return result;
  480. }
  481. }
  482. return undefined;
  483. }