/** * Shared Query Agent runtime used by Ask WeChat, Agent Hub, and the CLI harness. * Query semantics and tool orchestration are centralized here — prompt, tool mapping, * validation, temporal policy, retry/stopping, and the bounded model loop — so every * entry point behaves consistently; they differ only by injected tool executor and adapter. */ import type { AIChatToolCall, AIChatToolDefinition } from './ai-provider-service' import { LOCAL_QUERY_TOOL_DEFINITIONS, type QueryCorpusScope, type QuerySearchTimings, type QueryTemporalBasisKind } from '../../shared/local-query-api' // 进度事件定义在 shared(renderer 也要用),这里只是把它带进本文件作用域。 import type { QueryAgentProgressEvent, QueryAgentProgressStage } from '../../shared/query-agent' const MAX_TOOL_CALLS = 5 const FORBIDDEN_INPUT_KEYS = new Set(['apiKey', 'authorization', 'token', 'databasePath', 'sql', 'wxid', 'md5']) // 每个工具在“首次执行但结果为 0”之后允许的额外重试次数上限。 const ZERO_RESULT_RETRY_LIMIT = 1 // absolute 时间契约:LLM 只能给带时区的 ISO-8601 字符串,Host 负责换算成 Local Query API 的 epoch seconds。 const ISO_ABSOLUTE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|[+-]\d{2}:\d{2})$/ // sanity 窗口:聊天记录不可能早于 2000 年,也不允许查询明显属于未来的区间。 const ABSOLUTE_MIN_MS = Date.UTC(2000, 0, 1) const ABSOLUTE_MAX_FUTURE_MS = 366 * 24 * 60 * 60 * 1000 const ISO_ABSOLUTE_HINT = '带时区偏移的 ISO-8601,例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z' interface ToolSchema { type?: string description?: string required?: string[] additionalProperties?: boolean properties?: Record items?: ToolSchema enum?: unknown[] minLength?: number minimum?: number maximum?: number minItems?: number maxItems?: number } export interface ToolArgumentValidationError { status: 'invalid_tool_arguments' field: string constraint: string expected?: unknown actual?: unknown [key: string]: unknown } export interface QueryAgentProvider { getRuntimeConfig(): { configured: boolean; providerName: string; model: string; modelName: string } chatWithTools( messages: Array>, tools: AIChatToolDefinition[] ): Promise<{ success: boolean data?: string toolCalls?: AIChatToolCall[] usage?: { input?: number; output?: number; total?: number; estimated?: boolean } error?: string /** 以下为诊断字段(additive,不参与业务语义) */ elapsedMs?: number errorStatus?: number errorCode?: string errorType?: string errorContentType?: string timedOut?: boolean htmlInsteadOfJson?: boolean }> } export interface QueryAgentToolResult { status: string [key: string]: unknown } export interface QueryAgentTraceItem { toolName: string input: Record durationMs: number status: string resultCount?: number evidenceCount?: number /** 会话概览覆盖的源消息条数(additive,用于 UI 顶部真实统计)。 */ sourceMessageCount?: number /** LLM 声明的 temporalBasis。Host 消费它决定 policy,但不会传给 Local Query API。 */ temporalBasis?: { kind: QueryTemporalBasisKind; sourceText?: string } /** Host 自动执行的扩大查询(当前仅 temporalBasis.kind=recall_hint + 有界范围 + 0 结果)。 */ autoFallback?: { reason: 'soft_temporal_hint_zero_result' timeRange: Record status: string durationMs: number resultCount?: number evidenceCount?: number } /** * Engine 侧的真实耗时分解(ADDITIVE 诊断)。 * * 由 `search_messages` 的 Tool Result 携带,**不会进入模型上下文**(`toolResultForModel` * 会剥离)。用途:把不透明的 Tool 总耗时拆成 scope / freshness / 每个 probe / 合并 / 证据补全。 */ searchTimings?: QuerySearchTimings } export interface QueryAgentModelCallDiagnostic { index: number elapsedMs: number status?: number contentType?: string timedOut?: boolean htmlInsteadOfJson?: boolean errorCode?: string errorType?: string error?: string } /** * 失败分类(additive 诊断字段,供 Adapter 决定展示与是否允许 Legacy fallback)。 * * 'provider_unavailable' = Provider 未配置;'provider_failure' = 模型请求本身失败 * (网络 / 上游 / 超时)。未分类的异常由 Adapter 归类为 runtime_error。 */ export type QueryAgentErrorKind = 'invalid_question' | 'provider_unavailable' | 'provider_failure' | 'tool_limit' /** * 多轮澄清所需的最小历史。**由 Adapter 提供**,Runtime 只负责按顺序放进 messages。 * Runtime 自身仍然是无状态单轮执行器;历史长度 / 保留时间的边界由调用方负责。 */ export interface QueryAgentHistoryTurn { question: string answer: string } export interface QueryAgentResult { question: string provider: string model: string modelCallCount: number toolCallCount: number firstModelMs?: number toolTotalMs: number finalModelMs?: number totalMs: number /** 每次模型调用的耗时(含失败的那次),按调用顺序;additive 诊断字段 */ modelDurationsMs: number[] /** 每次模型调用的请求级诊断;additive 诊断字段 */ modelDiagnostics: QueryAgentModelCallDiagnostic[] traces: QueryAgentTraceItem[] answer?: string error?: string /** 失败分类;additive 诊断字段,成功时为 undefined */ errorKind?: QueryAgentErrorKind /** 本次回答实际依据的证据(additive);按首次命中顺序去重。 */ evidence?: QueryAgentEvidenceItem[] } /** * 语料边界(conversation scope):由 UI / Adapter 传入,**不是** LLM 的输入。 * `label` 只用于给模型描述"当前范围是什么",强制逻辑完全在 Engine(target 越界会被拒绝)。 */ export interface QueryAgentConversationScope { scope: QueryCorpusScope label?: string } /** 单次 Tool 执行的上下文(由 Runtime 注入,LLM 无法提供)。 */ export interface QueryAgentToolContext { conversationScope?: QueryCorpusScope } export type QueryAgentToolExecutor = ( name: string, input: Record, context?: QueryAgentToolContext ) => Promise /** * 本次回答实际依据的证据(ADDITIVE,供 Adapter 展示)。 * 只保留可展示字段;messageRef 仍是 opaque 引用。 */ export interface QueryAgentEvidenceItem { messageRef: string conversationName?: string conversationType?: 'user' | 'group' sender?: string /** epoch ms */ timestamp?: number messageType?: string text?: string attachment?: { kind?: string; name?: string; url?: string; sizeBytes?: number } /** 产生这条证据的 Tool 名(诊断 / UI 分组用)。 */ source: string } /** 一次回答最多带出多少条证据(IPC 体积与 UI 噪声控制)。 */ const MAX_EVIDENCE_ITEMS = 40 const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使用提供的四个 Query Tool 获取事实,最终回答只基于 Tool Result。 规划原则: - 先判断问题需要哪种证据,再调用最少的 Tool。每次收到 Tool Result 后都判断“当前 Evidence 是否已经足以给出有边界的回答”;足够就立即回答,不为追求绝对完整继续调查。 - query_messages 是精确事实查询,适用于能用联系人、时间、方向、消息类型、顺序等结构条件表达的问题。earliest/latest 等时间边界也是结构条件,必须使用 order 与 limit 精确查询,不能使用抽样 overview。每次调用都必须如实声明 temporalBasis。结果已经回答问题时,不要追加 conversation_overview。 - 需要绝对时间范围时,startTime/endTime 必须使用带时区偏移的 ISO-8601 字符串(例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z)。不要传 epoch 数字,也不要传没有时区的裸本地时间。 - search_messages 是关键词检索,适用于结构条件无法确定答案的问题。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话塞进同一项。首次最多 4 项。检索到 Evidence 后直接判断;只有本次完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。 - conversation_overview 只用于真正需要理解一个时间范围内整体聊了什么、主要话题或整体互动的 broad summary。它返回 temporal coverage sample,不代表完整聊天,也不是检索不足时的默认 fallback。 - message_context 只用于已经找到一条有价值 Evidence、但单条内容缺少前后语境而无法判断真实含义的情况。不要把它当作默认确认步骤;上下文足够后立即回答。 - 普通聊天查询不是 exhaustive investigation。经过合理的检索或可选 context 仍不足以形成强结论时,直接说明证据范围和不确定性,不要循环调用 search、overview、context。 事实边界:不得编造未返回的消息、猜测联系人、修改 resolvedTimeRange,或把 partial/unknown 当作 complete。coverage complete 且结果为 0 时,可以说明当前可读取的完整范围没有找到;coverage partial/unknown 且结果为 0 时,必须说明无法确认绝对不存在。不要把 sampled Evidence 当作完整聊天,也不要把 source message count 和 selected evidence count 混为一谈。 索引新鲜度:search_messages 的 indexLatestAt / sourceLatestAt 是**结构化事实**,indexCoverage 是 Engine 给出的结论句。规则: - 覆盖边界只能引用 indexCoverage(含本地时间与结论),**不要自己换算时间,也不要把 epoch 数字写进回答**。 - indexCoverage.covered 为 false 时,说明这段时间还没进索引:此时即使结果为 0 也只能说"索引尚未覆盖这段时间,暂时无法确认",**绝不能**说成"没有"。必须如实引用结论里的索引更新时间。 - 已经检索到 Evidence 时,只有当这个覆盖边界真的会影响结论时才补一句说明,不要机械附加警告。 - 只有 coverage.state 为 complete(indexCoverage.covered 为 true)且结果为 0,才可以下"没有找到"的结论。不要自己把 partial 说成 complete。 缺少必要信息时用自然语言澄清;超出工具能力时说明不能可靠完成,并给出当前工具可以执行的替代方向。` function toolDefinitions(): AIChatToolDefinition[] { return LOCAL_QUERY_TOOL_DEFINITIONS.map((tool) => ({ type: 'function' as const, function: { name: tool.name, description: tool.description, parameters: tool.parameters } })) } function toolDefinition(name: string): AIChatToolDefinition[] { return toolDefinitions().filter((tool) => tool.function.name === name) } function sanitizeInput(input: Record): Record { const sanitizeValue = (value: unknown): unknown => { if (typeof value === 'string') return value.length > 240 ? `${value.slice(0, 240)}...` : value if (Array.isArray(value)) return value.slice(0, 8).map(sanitizeValue) if (value && typeof value === 'object') return sanitizeInput(value as Record) return value } const output: Record = {} for (const [key, value] of Object.entries(input)) { if (key === 'messageRef') output[key] = '[opaque-message-ref]' else if (!FORBIDDEN_INPUT_KEYS.has(key)) output[key] = sanitizeValue(value) } return output } function containsForbiddenKey(value: unknown): boolean { if (Array.isArray(value)) return value.some(containsForbiddenKey) if (!value || typeof value !== 'object') return false return Object.entries(value as Record).some(([key, child]) => FORBIDDEN_INPUT_KEYS.has(key) || containsForbiddenKey(child)) } function actualType(value: unknown): string { if (value === null) return 'null' if (Array.isArray(value)) return 'array' return typeof value } function schemaTypeMatches(value: unknown, type: string): boolean { if (type === 'object') return Boolean(value && typeof value === 'object' && !Array.isArray(value)) if (type === 'integer') return typeof value === 'number' && Number.isInteger(value) if (type === 'number') return typeof value === 'number' && Number.isFinite(value) return actualType(value) === type } function validateSchema(value: unknown, schema: ToolSchema, field = '$'): ToolArgumentValidationError | undefined { if (schema.type && !schemaTypeMatches(value, schema.type)) { return { status: 'invalid_tool_arguments', field, constraint: 'type', expected: schema.type, actual: actualType(value) } } if (schema.enum && !schema.enum.some((allowed) => Object.is(allowed, value))) { return { status: 'invalid_tool_arguments', field, constraint: 'enum', expected: schema.enum, actual: value } } if (typeof value === 'string') { if (schema.minLength !== undefined && value.length < schema.minLength) { return { status: 'invalid_tool_arguments', field, constraint: 'minLength', expected: schema.minLength, actual: value.length } } } if (typeof value === 'number') { if (schema.minimum !== undefined && value < schema.minimum) { return { status: 'invalid_tool_arguments', field, constraint: 'minimum', expected: schema.minimum, actual: value } } if (schema.maximum !== undefined && value > schema.maximum) { return { status: 'invalid_tool_arguments', field, constraint: 'maximum', expected: schema.maximum, actual: value } } } if (Array.isArray(value)) { if (schema.minItems !== undefined && value.length < schema.minItems) { return { status: 'invalid_tool_arguments', field, constraint: 'minItems', expected: schema.minItems, actual: value.length } } if (schema.maxItems !== undefined && value.length > schema.maxItems) { return { status: 'invalid_tool_arguments', field, constraint: 'maxItems', expected: schema.maxItems, actual: value.length } } if (schema.items) { for (let index = 0; index < value.length; index += 1) { const error = validateSchema(value[index], schema.items, `${field}[${index}]`) if (error) return error } } } if (value && typeof value === 'object' && !Array.isArray(value)) { const objectValue = value as Record for (const required of schema.required || []) { if (!(required in objectValue)) { return { status: 'invalid_tool_arguments', field: field === '$' ? required : `${field}.${required}`, constraint: 'required', expected: true, actual: false } } } const properties = schema.properties || {} if (schema.additionalProperties === false) { for (const key of Object.keys(objectValue)) { if (!(key in properties)) { return { status: 'invalid_tool_arguments', field: field === '$' ? key : `${field}.${key}`, constraint: 'additionalProperties', expected: false, actual: true } } } } for (const [key, childSchema] of Object.entries(properties)) { if (key in objectValue) { const error = validateSchema(objectValue[key], childSchema, field === '$' ? key : `${field}.${key}`) if (error) return error } } } return undefined } function argError(field: string, constraint: string, expected?: unknown, actual?: unknown, hint?: string): ToolArgumentValidationError { return { status: 'invalid_tool_arguments', field, constraint, ...(expected === undefined ? {} : { expected }), ...(actual === undefined ? {} : { actual }), ...(hint ? { hint } : {}) } } /** * 解析 LLM 提供的绝对时间。只接受带显式时区偏移(或 Z)的 ISO-8601; * 无时区、epoch 数字、非法日历日一律返回 undefined,由调用方转成可修正的 invalid_tool_arguments。 */ function parseIsoInstant(value: string): number | undefined { const match = ISO_ABSOLUTE_PATTERN.exec(value.trim()) if (!match) return undefined const year = Number(match[1]) const month = Number(match[2]) const day = Number(match[3]) const hour = Number(match[4]) const minute = Number(match[5]) const second = Number(match[6]) if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return undefined // 先在 UTC 语义下校验字面日期真实存在,再套用时区偏移,避免 2 月 31 日被静默进位。 const naiveMs = Date.UTC(year, month - 1, day, hour, minute, second) const naive = new Date(naiveMs) if (naive.getUTCFullYear() !== year || naive.getUTCMonth() !== month - 1 || naive.getUTCDate() !== day) return undefined const offset = match[7] if (offset === 'Z') return naiveMs const sign = offset.startsWith('-') ? -1 : 1 const offsetHour = Number(offset.slice(1, 3)) const offsetMinute = Number(offset.slice(4, 6)) if (offsetHour > 23 || offsetMinute > 59) return undefined return naiveMs - sign * (offsetHour * 60 + offsetMinute) * 60000 } function canonicalizeTimeRange(timeRange: Record, now: Date): { value?: Record; error?: ToolArgumentValidationError } { const startTime = timeRange.startTime const endTime = timeRange.endTime if (timeRange.kind !== 'absolute') { // 非 absolute 语义下 startTime/endTime 无意义,直接丢弃,避免残留数值进入 Query API。 const { startTime: _startTime, endTime: _endTime, ...rest } = timeRange return { value: rest } } if (typeof startTime !== 'string' || typeof endTime !== 'string') { return { error: argError('timeRange.startTime', 'required', ISO_ABSOLUTE_HINT, actualType(startTime ?? endTime), ISO_ABSOLUTE_HINT) } } const startMs = parseIsoInstant(startTime) if (startMs === undefined) return { error: argError('timeRange.startTime', 'format', ISO_ABSOLUTE_HINT, startTime, ISO_ABSOLUTE_HINT) } const endMs = parseIsoInstant(endTime) if (endMs === undefined) return { error: argError('timeRange.endTime', 'format', ISO_ABSOLUTE_HINT, endTime, ISO_ABSOLUTE_HINT) } if (endMs < startMs) return { error: argError('timeRange.endTime', 'range_order', 'endTime 不得早于 startTime', endTime) } const latestMs = now.getTime() + ABSOLUTE_MAX_FUTURE_MS const sanityWindow = `2000-01-01 至 ${new Date(latestMs).toISOString()}` if (startMs < ABSOLUTE_MIN_MS || startMs > latestMs) return { error: argError('timeRange.startTime', 'range_sanity', sanityWindow, startTime) } if (endMs < ABSOLUTE_MIN_MS || endMs > latestMs) return { error: argError('timeRange.endTime', 'range_sanity', sanityWindow, endTime) } // 通过全部校验后才换算成 Local Query API 使用的 epoch seconds。 return { value: { kind: 'absolute', startTime: Math.floor(startMs / 1000), endTime: Math.floor(endMs / 1000) } } } /** canonical(已剥离、已校验)的 temporalBasis。 */ interface CanonicalTemporalBasis { kind: QueryTemporalBasisKind sourceText?: string } /** * LLM Tool Adapter 的 canonicalization:剥离 LLM-facing 元数据(temporalBasis)、把 absolute 的 * ISO-8601 换成 Local Query API 的 epoch seconds、把 `queries[]` 摊平成 query + variants。 * * 这里只做**纯 lexical** 校验(sourceText 是否为用户问题子串、kind 与 timeRange 是否自洽), * 不解释时间短语的意思。 */ function canonicalizeToolInput( name: string, input: Record, now: Date, question: string ): { input?: Record; temporalBasis?: CanonicalTemporalBasis; error?: ToolArgumentValidationError } { const output: Record = { ...input } const rawBasis = output.temporalBasis delete output.temporalBasis let temporalBasis: CanonicalTemporalBasis | undefined if (rawBasis && typeof rawBasis === 'object' && !Array.isArray(rawBasis)) { const record = rawBasis as Record const kind = record.kind if (kind === 'constraint' || kind === 'recall_hint' || kind === 'none') { const sourceText = typeof record.sourceText === 'string' ? record.sourceText.trim() : undefined if (kind === 'none') { if (sourceText) { return { error: argError('temporalBasis.sourceText', 'forbidden_for_none', null, sourceText, 'kind=none 表示问题里没有任何时间表达,此时不要提供 sourceText。') } } } else if (!sourceText) { return { error: argError('temporalBasis.sourceText', 'required', '用户原问题中的时间原文片段', undefined, `kind=${kind} 时必须给出用户原问题中实际出现的时间片段。`) } } else if (!question.includes(sourceText)) { return { error: argError('temporalBasis.sourceText', 'source_not_in_question', question, sourceText, 'sourceText 必须是用户原问题中逐字出现的片段,不要改写、翻译或补全。') } } temporalBasis = { kind, ...(sourceText ? { sourceText } : {}) } } } if (output.timeRange && typeof output.timeRange === 'object' && !Array.isArray(output.timeRange)) { const canonical = canonicalizeTimeRange(output.timeRange as Record, now) if (canonical.error) return { error: canonical.error } output.timeRange = canonical.value } // 用户没给任何时间表达时,不得凭空造一个有界范围(earliest/latest 用 order/limit 表达)。 if (temporalBasis?.kind === 'none') { const kind = rangeKind(output) if (kind && kind !== 'all') { return { error: argError('timeRange', 'temporal_basis_mismatch', 'timeRange.kind=all', output.timeRange, '用户没有给出任何时间表达(temporalBasis.kind=none)。请改用 timeRange.kind=all;如果需要 earliest/latest 这类边界,用 order 与 limit 表达。') } } } if (name === 'search_messages') { const raw = Array.isArray(output.queries) ? (output.queries as unknown[]) : [] const probes = raw.filter((value): value is string => typeof value === 'string').map((value) => value.trim()).filter(Boolean) if (!probes.length) return { error: argError('queries', 'required', '至少一个非空检索项') } const [first, ...rest] = probes delete output.queries output.query = first if (rest.length) output.variants = rest } return { input: output, temporalBasis } } interface ZeroResultRetryState { searchAttempts: number searchSignatures: string[] queryAttempts: number querySignatures: string[] /** * temporalBasis.kind=constraint 的 query_messages 一旦执行,就锁定其 canonical timeRange。 * 后续 retry 若替换时间范围会被拒绝 —— 用户明确给出的时间边界不得扩大。 */ lockedConstraintRange?: { signature: string; timeRange: Record } } function newRetryState(): ZeroResultRetryState { return { searchAttempts: 0, searchSignatures: [], queryAttempts: 0, querySignatures: [] } } /** Host 自动执行的扩大查询原因(当前只有一种)。 */ const AUTO_FALLBACK_REASON = 'soft_temporal_hint_zero_result' as const function rangeKind(input: Record): string | undefined { const range = input.timeRange if (!range || typeof range !== 'object' || Array.isArray(range)) return undefined const kind = (range as Record).kind return typeof kind === 'string' ? kind : undefined } /** * constraint 时间边界锁定:时间来源由 LLM 判断(temporalBasis), * 但"不得扩大"由 Host 结构性保证,不依赖 prompt。 */ function lockConstraintTimeRange( name: string, temporalBasis: CanonicalTemporalBasis | undefined, input: Record, state: ZeroResultRetryState ): ToolArgumentValidationError | undefined { if (name !== 'query_messages' || temporalBasis?.kind !== 'constraint') return undefined const signature = JSON.stringify(input.timeRange ?? null) if (!state.lockedConstraintRange) { state.lockedConstraintRange = { signature, timeRange: (input.timeRange as Record) ?? {} } return undefined } if (state.lockedConstraintRange.signature === signature) return undefined return argError('timeRange', 'constraint_time_range_immutable', state.lockedConstraintRange.timeRange, input.timeRange, '用户明确给出的时间范围不得改变;只能放宽 direction / messageTypes 等非时间条件。') } /** * 只有"LLM 自己推断的近似时间范围(recall_hint)+ 有界范围 + 首次 0 结果"才自动扩大。 * 已经是 all 时无需扩大;constraint 一律不扩大(由 lockConstraintTimeRange 保证)。 */ function shouldAutoBroaden( name: string, temporalBasis: CanonicalTemporalBasis | undefined, input: Record, result: QueryAgentToolResult ): boolean { if (name !== 'query_messages') return false if (temporalBasis?.kind !== 'recall_hint') return false if (resultCount(result).resultCount !== 0) return false return rangeKind(input) !== 'all' } function normalizedTarget(input: Record): string { const target = input.target const query = target && typeof target === 'object' ? (target as Record).query : undefined return typeof query === 'string' ? query.trim().toLowerCase() : '' } /** 只覆盖“实质条件”:忽略 limit/order/excludeSystem 这类不改变检索语义的字段。 */ function retrySignature(name: string, input: Record): string { if (name === 'search_messages') { const probes = [input.query, ...(Array.isArray(input.variants) ? input.variants : [])] .filter((value): value is string => typeof value === 'string') .map((value) => value.trim().toLowerCase()) return JSON.stringify({ target: normalizedTarget(input), timeRange: input.timeRange ?? null, probes: Array.from(new Set(probes)).sort() }) } const messageTypes = Array.isArray(input.messageTypes) ? [...(input.messageTypes as string[])].sort() : [] return JSON.stringify({ target: normalizedTarget(input), timeRange: input.timeRange ?? null, direction: input.direction ?? null, messageTypes }) } function duplicateRetry(name: string, input: Record, state: ZeroResultRetryState): boolean { const signature = retrySignature(name, input) if (name === 'search_messages') return state.searchSignatures.includes(signature) if (name === 'query_messages') return state.querySignatures.includes(signature) return false } function recordAttempt(name: string, input: Record, state: ZeroResultRetryState): void { if (name === 'search_messages') { state.searchAttempts += 1; state.searchSignatures.push(retrySignature(name, input)) } else if (name === 'query_messages') { state.queryAttempts += 1; state.querySignatures.push(retrySignature(name, input)) } } function retryNote(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState): string | undefined { if (result.constraint === 'duplicate_retry') return '本次重试的条件与上一次完全相同,已被拒绝;请改用实质不同的条件,或直接基于现有结果作答。' if (result.status !== 'completed') return undefined const counts = resultCount(result) if (name === 'search_messages' && !counts.evidenceCount && state.searchAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次检索没有任何 Evidence。允许再执行一次 search_messages,但每一项都必须与上一次实质不同;完全相同的检索会被拒绝。' if (name === 'query_messages' && counts.resultCount === 0 && !result.fallbackLookup && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次精确查询返回 0 条。允许再执行一次 query_messages,用于放宽 direction 或 messageTypes 等非时间条件;改变时间范围会被拒绝。' return undefined } export function validateToolArguments( name: string, value: unknown, now: Date = new Date(), question = '' ): { input?: Record; temporalBasis?: CanonicalTemporalBasis; error?: ToolArgumentValidationError } { const definition = LOCAL_QUERY_TOOL_DEFINITIONS.find((tool) => tool.name === name) if (!definition) return { error: argError('$', 'tool', 'supported tool', name) } if (!value || typeof value !== 'object' || Array.isArray(value)) { return { error: argError('$', 'type', 'object', actualType(value)) } } if (containsForbiddenKey(value)) { return { error: argError('$', 'forbidden_field') } } const error = validateSchema(value, definition.parameters as ToolSchema) if (error) return { error } return canonicalizeToolInput(name, value as Record, now, question) } function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number; sourceMessageCount?: number } { return { resultCount: typeof result.returnedCount === 'number' ? result.returnedCount : undefined, evidenceCount: typeof result.evidenceCount === 'number' ? result.evidenceCount : Array.isArray(result.evidence) ? result.evidence.length : undefined, // 会话概览用它说明"覆盖了多少条源消息",UI 顶部统计需要真实数字。 sourceMessageCount: typeof result.sourceMessageCount === 'number' ? result.sourceMessageCount : undefined } } function messageRecordForModel(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value const record = value as Record return record.messageType || !record.sourceKind ? record : { ...record, messageType: record.sourceKind } } function toolResultForModel(name: string, result: QueryAgentToolResult, callsUsed: number, nextTools: AIChatToolDefinition[], note?: string): QueryAgentToolResult { const visible: QueryAgentToolResult = { ...result } if (Array.isArray(result.messages)) visible.messages = result.messages.map(messageRecordForModel) if (Array.isArray(result.evidence)) visible.evidence = result.evidence.map(messageRecordForModel) if (result.anchor) visible.anchor = messageRecordForModel(result.anchor) if (Array.isArray(result.before)) visible.before = result.before.map(messageRecordForModel) if (Array.isArray(result.after)) visible.after = result.after.map(messageRecordForModel) if (result.fallbackLookup && typeof result.fallbackLookup === 'object') { const fallback = result.fallbackLookup as Record visible.fallbackLookup = { ...fallback, ...(Array.isArray(fallback.messages) ? { messages: fallback.messages.map(messageRecordForModel) } : {}) } } visible._agent = { toolName: name, toolCallsUsed: callsUsed, toolCallsRemaining: Math.max(0, MAX_TOOL_CALLS - callsUsed), availableNextTools: nextTools.map((tool) => tool.function.name), ...(note ? { note } : {}), instruction: [ nextTools.length ? '先判断当前 Evidence 是否足以回答;足够就立即回答,只在含义仍有明确歧义时使用当前可用 Tool。' : '工具阶段已经结束。必须直接给出有边界的最终回答,不得再调用 Tool。', result.fallbackLookup ? '本次结果有两个 scope:顶层是原查询,fallbackLookup 是系统自动扩大到全部历史后的结果。回答时必须分别说明这两个范围,不要让用户以为原问题就是按“全部历史”提出的。' : undefined, note ].filter(Boolean).join(' ') } return visible } function nextToolDefinitions(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState, rangeWasAll = false): AIChatToolDefinition[] { // 重复重试已被拒绝,不再开放工具,避免用有限的 tool budget 反复试同一条件。 if (result.constraint === 'duplicate_retry') return [] if (result.status === 'invalid_tool_arguments') return toolDefinition(name) if (result.status !== 'completed') return [] const counts = resultCount(result) if (name === 'search_messages') { // 有 Evidence 时保持原有高效路径:只允许补一次上下文。 if (counts.evidenceCount) return toolDefinition('message_context') // 首次检索完全没有 Evidence:允许一次实质不同的重试,之后关闭。 return state.searchAttempts <= ZERO_RESULT_RETRY_LIMIT ? toolDefinition('search_messages') : [] } if (name === 'query_messages') { // Host 已经自动执行过一次扩大查询:不再开放 retry,避免出现第三次查询。 if (result.fallbackLookup) return [] // 已经查了全部历史且 0 结果:再换时间范围毫无意义(更窄只会更少)。 if (rangeWasAll && counts.resultCount === 0) return [] // 只有 0 结果才开放一次重试;有结果时保持原有 stopping。 return counts.resultCount === 0 && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT ? toolDefinition('query_messages') : [] } return [] } /** 进度阶段的类型定义在 `src/shared/query-agent.ts`(renderer 也要消费它,放这里会让 renderer 反向依赖 main)。 */ export type { QueryAgentProgressEvent, QueryAgentProgressStage } from '../../shared/query-agent' export interface QueryAgentRunOptions { /** * 最小多轮澄清支持:前几轮(问 + 答)的问答对,由 Adapter 负责长度 / 时间 / 隐私边界。 * 不传时 messages = [system, user]。 */ history?: QueryAgentHistoryTurn[] /** * 语料边界(搜索范围)。由 UI 决定;Runtime 负责把它传给 Engine 并在 prompt 里说明, * 但**强制**发生在 Engine(target 越界 → 可修正的 invalid_tool_arguments)。 * 不传则不限制范围、不加范围说明。 */ conversationScope?: QueryAgentConversationScope /** * 真实进度回调(ADDITIVE)。由 Adapter 转发给 UI;不传则零额外开销。 * 回调抛出的异常不会影响查询本身。 */ onProgress?: (event: QueryAgentProgressEvent) => void } /** 给模型的范围说明:只描述边界,不做"请遵守"的祈祷式约束(约束由 Engine 强制)。 */ function conversationScopeNote(input: QueryAgentConversationScope): string { const { scope } = input const label = input.label?.trim() const header = label ? `当前搜索范围(由应用界面决定):${label}。` : '当前搜索范围由应用界面决定。' const rules = [ '所有工具调用都会被强制限制在这个范围内;target 若不在范围内会被拒绝,被拒绝时请如实说明范围限制,不要试图绕过。', '范围之外还有别的会话,但你**看不到**它们,也不要在回答里声称它们的情况。' ] if (scope.kind === 'groups') { rules.push( '范围是群聊专属:包含全部群会话以及群成员实际发送的消息。问"谁聊过某话题"时应省略 search_messages 的 target 做跨群检索,并在回答里保留群名与发送者。' ) } else if (scope.kind === 'contact' || scope.kind === 'current') { rules.push( '范围只有一个会话:所有工具都可以省略 target(query_messages 也可以),省略即在该会话内检索;不要猜会话名,也不要指定其他会话。' ) } return `${header}\n${rules.map((rule) => `- ${rule}`).join('\n')}` } /** * 证据收集器(Host 侧)。 * * 从 Tool Result 中提取**真实**证据供 UI 展示 —— UI 不允许从回答文本里反解析证据。 * - 按 `messageRef` 去重(search 与 context 命中同一条消息只显示一次); * - 顺序 = 首次命中顺序;上限 MAX_EVIDENCE_ITEMS; * - 只保留展示字段,不携带 wxid / md5 / DB id / raw Tool JSON。 */ class EvidenceCollector { private readonly items = new Map() addFromToolResult(toolName: string, result: QueryAgentToolResult): void { const target = result.target && typeof result.target === 'object' ? (result.target as Record) : undefined const defaults: { conversationName?: string; conversationType?: 'user' | 'group' } = { conversationName: typeof target?.displayName === 'string' ? target.displayName : undefined, conversationType: target?.type === 'user' || target?.type === 'group' ? target.type : undefined } const push = (value: unknown): void => { const item = this.normalize(value, toolName, defaults) if (item) this.merge(item) } if (Array.isArray(result.messages)) result.messages.forEach(push) if (Array.isArray(result.evidence)) result.evidence.forEach(push) // message_context:只收 anchor(被补充语境的那条证据),前后文不是本次结论的依据。 if (result.anchor) push(result.anchor) // recall_hint 自动扩大的那次查询也是真实证据。 const fallback = result.fallbackLookup && typeof result.fallbackLookup === 'object' ? (result.fallbackLookup as Record) : undefined if (Array.isArray(fallback?.messages)) fallback.messages.forEach(push) } list(): QueryAgentEvidenceItem[] { return Array.from(this.items.values()).slice(0, MAX_EVIDENCE_ITEMS) } private merge(item: QueryAgentEvidenceItem): void { const existing = this.items.get(item.messageRef) if (!existing) { this.items.set(item.messageRef, item) return } // 同一消息被不同 Tool 命中:补齐缺失字段,保留首次的 source。 const merged = existing as unknown as Record for (const key of Object.keys(item)) { if (key === 'source') continue const value = (item as unknown as Record)[key] if (value !== undefined && merged[key] === undefined) merged[key] = value } } private normalize( value: unknown, source: string, defaults: { conversationName?: string; conversationType?: 'user' | 'group' } ): QueryAgentEvidenceItem | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined const record = value as Record const messageRef = typeof record.messageRef === 'string' ? record.messageRef : undefined if (!messageRef) return undefined const attachment = record.attachment && typeof record.attachment === 'object' && !Array.isArray(record.attachment) ? (record.attachment as Record) : undefined const attachmentView = attachment ? { ...(typeof attachment.kind === 'string' ? { kind: attachment.kind } : {}), ...(typeof attachment.name === 'string' ? { name: attachment.name } : {}), ...(typeof attachment.url === 'string' ? { url: attachment.url } : {}), ...(typeof attachment.sizeBytes === 'number' ? { sizeBytes: attachment.sizeBytes } : {}) } : undefined return { messageRef, ...(typeof record.conversationName === 'string' ? { conversationName: record.conversationName } : defaults.conversationName ? { conversationName: defaults.conversationName } : {}), ...(record.conversationType === 'user' || record.conversationType === 'group' ? { conversationType: record.conversationType } : defaults.conversationType ? { conversationType: defaults.conversationType } : {}), ...(typeof record.sender === 'string' ? { sender: record.sender } : {}), ...(typeof record.timestamp === 'number' ? { timestamp: record.timestamp } : {}), ...(typeof record.messageType === 'string' ? { messageType: record.messageType } : typeof record.sourceKind === 'string' ? { messageType: record.sourceKind } : {}), ...(typeof record.text === 'string' && record.text ? { text: record.text } : {}), ...(attachmentView && Object.keys(attachmentView).length ? { attachment: attachmentView } : {}), source } } } export class QueryAgentService { constructor( private readonly provider: QueryAgentProvider, private readonly executeTool: QueryAgentToolExecutor, private readonly nowProvider: () => Date = () => new Date() ) {} async run(question: string, options: QueryAgentRunOptions = {}): Promise { const startedAt = Date.now() // 计数用可变持有者:`completed` 由 finally 发出,那时 result 已经离开作用域。 const counters = { modelCallCount: 0, toolCallCount: 0 } const emit = (stage: QueryAgentProgressStage, extra: { toolName?: string } = {}): void => { const onProgress = options.onProgress if (!onProgress) return const at = Date.now() try { onProgress({ stage, elapsedMs: at - startedAt, at, ...(extra.toolName ? { toolName: extra.toolName } : {}), modelCallCount: counters.modelCallCount, toolCallCount: counters.toolCallCount }) } catch { // 进度上报失败绝不能影响查询本身。 } } try { return await this.runLoop(question, options, emit, counters) } finally { // 成功、Provider 失败、tool_limit、异常 —— 一律以 completed 收尾, // 避免 UI 永远停在某个中间阶段。 emit('completed') } } private async runLoop( question: string, options: QueryAgentRunOptions, emit: (stage: QueryAgentProgressStage, extra?: { toolName?: string }) => void, counters: { modelCallCount: number; toolCallCount: number } ): Promise { const trimmed = question.trim() const startedAt = Date.now() const runtime = this.provider.getRuntimeConfig() const result: QueryAgentResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [], modelDurationsMs: [], modelDiagnostics: [] } if (!trimmed) return { ...result, error: '请输入查询问题', errorKind: 'invalid_question', totalMs: Date.now() - startedAt } if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', errorKind: 'provider_unavailable', totalMs: Date.now() - startedAt } const history = options.history || [] const scopeNote = options.conversationScope ? conversationScopeNote(options.conversationScope) : undefined const messages: Array> = [ { role: 'system', content: SYSTEM_PROMPT }, // 范围说明是**上下文**,不是强制执行手段:真正的边界由 Engine 拒绝越界 target 来保证。 ...(scopeNote ? [{ role: 'system', content: scopeNote }] : []), ...history.flatMap((turn) => [ { role: 'user', content: turn.question }, { role: 'assistant', content: turn.answer } ]), { role: 'user', content: trimmed } ] const toolContext: QueryAgentToolContext = options.conversationScope ? { conversationScope: options.conversationScope.scope } : {} const evidence = new EvidenceCollector() let tools = toolDefinitions() const retry = newRetryState() const now = this.nowProvider() let firstModelAt: number | undefined let finalModelDuration: number | undefined while (result.toolCallCount < MAX_TOOL_CALLS) { // 真实生命周期边界:还没有任何 Tool 结果 → 这次模型调用是"理解问题"; // 已经有结果 → 这次是在消化证据并**生成回答**。不用定时器、不猜进度。 emit(result.toolCallCount === 0 ? 'understanding' : 'generating_answer') const modelStartedAt = Date.now() const model = await this.provider.chatWithTools(messages, tools) result.modelCallCount += 1 const modelDuration = Date.now() - modelStartedAt // 无论成功失败都记录本次调用耗时与请求级诊断,便于区分模型慢 / 工具慢 / 上游错误。 result.modelDurationsMs.push(modelDuration) result.modelDiagnostics.push({ index: result.modelCallCount, elapsedMs: typeof model.elapsedMs === 'number' ? model.elapsedMs : modelDuration, ...(model.errorStatus !== undefined ? { status: model.errorStatus } : {}), ...(model.errorContentType ? { contentType: model.errorContentType } : {}), ...(model.timedOut ? { timedOut: true } : {}), ...(model.htmlInsteadOfJson ? { htmlInsteadOfJson: true } : {}), ...(model.errorCode ? { errorCode: model.errorCode } : {}), ...(model.errorType ? { errorType: model.errorType } : {}), ...(model.success ? {} : { error: model.error || '模型调用失败' }) }) if (firstModelAt === undefined) firstModelAt = Date.now() if (!model.success) return { ...result, error: model.error || '模型调用失败', errorKind: 'provider_failure', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } const calls = model.toolCalls || [] if (calls.length === 0) { finalModelDuration = modelDuration result.answer = model.data?.trim() || '模型未返回答案' result.firstModelMs = firstModelAt - startedAt result.finalModelMs = finalModelDuration result.totalMs = Date.now() - startedAt return result } if (result.toolCallCount + calls.length > MAX_TOOL_CALLS) { return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt } } messages.push({ role: 'assistant', content: model.data || '', tool_calls: calls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } })) }) for (const call of calls) { const inputStartedAt = Date.now() let toolResult: QueryAgentToolResult | undefined let traceInput: Record = {} let temporalBasis: CanonicalTemporalBasis | undefined let autoFallback: QueryAgentTraceItem['autoFallback'] try { if (!tools.some((tool) => tool.function.name === call.name)) { toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'tool_availability', expected: tools.map((tool) => tool.function.name), actual: call.name } } if (!toolResult) { let parsed: unknown try { parsed = JSON.parse(call.arguments || '{}') } catch { toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'json' } } if (!toolResult) { const validated = validateToolArguments(call.name, parsed, now, trimmed) if (validated.error) { toolResult = validated.error } else { traceInput = validated.input || {} temporalBasis = validated.temporalBasis // constraint 时间边界由 Host 结构性锁定:不依赖 prompt,也不静默改写用户问题。 const constraintViolation = lockConstraintTimeRange(call.name, temporalBasis, traceInput, retry) if (constraintViolation) { toolResult = constraintViolation } else if (duplicateRetry(call.name, traceInput, retry)) { // 明确拒绝“换关键词重搜”里的 identical retry,让模型改用实质不同的条件。 toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'duplicate_retry', expected: '与上一次实质不同的条件', actual: '与上一次完全相同的条件' } } else { recordAttempt(call.name, traceInput, retry) // Tool 真正开始执行 = "在搜索聊天记录"。跨会话范围会明显更慢, // UI 用范围(不是调用次数)决定副提示文案。 emit('searching', { toolName: call.name }) const primary = await this.executeTool(call.name, traceInput, toolContext) toolResult = primary // recall_hint + 有界范围 + 0 结果 → Host 自动做一次“全部历史”corrective lookup。 // 这是一次本地 Query API 调用:不增加 LLM 往返,也不占用 MAX_TOOL_CALLS。 // 注意:自动补查必须沿用同一个语料边界,不能借它逃出当前搜索范围。 if (shouldAutoBroaden(call.name, temporalBasis, traceInput, primary)) { const fallbackStartedAt = Date.now() const fallbackResult = await this.executeTool('query_messages', { ...traceInput, timeRange: { kind: 'all' } }, toolContext) const fallbackCounts = resultCount(fallbackResult) autoFallback = { reason: AUTO_FALLBACK_REASON, timeRange: { kind: 'all' }, status: fallbackResult.status, durationMs: Date.now() - fallbackStartedAt, ...fallbackCounts } toolResult = { ...primary, fallbackLookup: { reason: AUTO_FALLBACK_REASON, explanation: '你的 temporalBasis.kind=recall_hint 表示该时间范围只是你推断的回忆线索,并非用户给出的硬边界;首次查询为 0 条,系统已自动在全部历史中再查一次。请分别说明这两个范围的结果。', timeRange: { kind: 'all' }, status: fallbackResult.status, resolvedTimeRange: fallbackResult.resolvedTimeRange, coverage: fallbackResult.coverage, returnedCount: fallbackResult.returnedCount, messages: fallbackResult.messages } } } } } } } } catch (error) { if (!toolResult) toolResult = { status: 'invalid_request', error: error instanceof Error ? error.message : '工具调用失败' } } const completedToolResult = toolResult || { status: 'invalid_request', error: '工具调用失败' } const durationMs = Date.now() - inputStartedAt result.toolCallCount += 1 counters.toolCallCount = result.toolCallCount result.toolTotalMs += durationMs // Tool Result 已经拿到 → 真实进入"整理证据"阶段。 emit('organizing_evidence', { toolName: call.name }) // 收集真实证据(去重、限量),供 UI 展示;不进入模型上下文。 evidence.addFromToolResult(call.name, completedToolResult) result.evidence = evidence.list() const counts = resultCount(completedToolResult) // 引擎耗时分解留在 Host 侧(诊断 / UI),不进入模型上下文。 const rawTimings = completedToolResult.timings const searchTimings: QuerySearchTimings | undefined = rawTimings && typeof rawTimings === 'object' && !Array.isArray(rawTimings) ? (rawTimings as QuerySearchTimings) : undefined result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts, ...(temporalBasis ? { temporalBasis } : {}), ...(autoFallback ? { autoFallback } : {}), ...(searchTimings ? { searchTimings } : {}) }) const nextTools = completedToolResult.constraint === 'tool_availability' ? tools : nextToolDefinitions(call.name, completedToolResult, retry, rangeKind(traceInput) === 'all') const note = retryNote(call.name, completedToolResult, retry) messages.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: JSON.stringify(toolResultForModel(call.name, completedToolResult, result.toolCallCount, nextTools, note)) }) tools = nextTools } } result.firstModelMs = firstModelAt ? firstModelAt - startedAt : undefined result.totalMs = Date.now() - startedAt return { ...result, error: `超过最大工具调用次数(${MAX_TOOL_CALLS})`, errorKind: 'tool_limit' } } }