Files
WechatExplorer/src/main/services/query-agent-service.ts
T
Wxw-Gu ab3b3731b7 feat: 重构问问微信并完善知识库增量检索
统一问问微信与 Agent Hub 的查询链路
完善查询覆盖度、新鲜度和部分结果表达,避免索引滞后产生错误结论
基于会话实现真正的增量追新与历史补齐
支持后台同步、取消恢复、重启续传以及同步期间继续查询
优化知识库跨会话检索、同步状态、进度展示和侧栏布局
2026-09-11 17:44:48 +08:00

984 lines
51 KiB
TypeScript
Raw 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.
/**
* 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<string, ToolSchema>
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<Record<string, unknown>>,
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<string, unknown>
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<string, unknown>
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<string, unknown>,
context?: QueryAgentToolContext
) => Promise<QueryAgentToolResult>
/**
* 本次回答实际依据的证据(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<string, unknown>): Record<string, unknown> {
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<string, unknown>)
return value
}
const output: Record<string, unknown> = {}
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<string, unknown>).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<string, unknown>
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<string, unknown>, now: Date): { value?: Record<string, unknown>; 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<string, unknown>,
now: Date,
question: string
): { input?: Record<string, unknown>; temporalBasis?: CanonicalTemporalBasis; error?: ToolArgumentValidationError } {
const output: Record<string, unknown> = { ...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<string, unknown>
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<string, unknown>, 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<string, unknown> }
}
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, unknown>): string | undefined {
const range = input.timeRange
if (!range || typeof range !== 'object' || Array.isArray(range)) return undefined
const kind = (range as Record<string, unknown>).kind
return typeof kind === 'string' ? kind : undefined
}
/**
* constraint 时间边界锁定:时间来源由 LLM 判断(temporalBasis),
* 但"不得扩大"由 Host 结构性保证,不依赖 prompt。
*/
function lockConstraintTimeRange(
name: string,
temporalBasis: CanonicalTemporalBasis | undefined,
input: Record<string, unknown>,
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<string, unknown>) ?? {} }
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<string, unknown>,
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, unknown>): string {
const target = input.target
const query = target && typeof target === 'object' ? (target as Record<string, unknown>).query : undefined
return typeof query === 'string' ? query.trim().toLowerCase() : ''
}
/** 只覆盖“实质条件”:忽略 limit/order/excludeSystem 这类不改变检索语义的字段。 */
function retrySignature(name: string, input: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>, 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<string, unknown>; 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<string, unknown>, 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<string, unknown>
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<string, unknown>
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<string, QueryAgentEvidenceItem>()
addFromToolResult(toolName: string, result: QueryAgentToolResult): void {
const target = result.target && typeof result.target === 'object' ? (result.target as Record<string, unknown>) : 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<string, unknown>) : 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<string, unknown>
for (const key of Object.keys(item)) {
if (key === 'source') continue
const value = (item as unknown as Record<string, unknown>)[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<string, unknown>
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<string, unknown>)
: 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<QueryAgentResult> {
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<QueryAgentResult> {
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<Record<string, unknown>> = [
{ 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<string, unknown> = {}
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' }
}
}