mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 12:34:35 +08:00
fix: 完善查询agent时间与检索契约
增加调试与诊断
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Query Agent POC 的 CLI 参数解析。
|
||||
*
|
||||
* 单独抽成纯函数,便于单测;避免 entry 文件的副作用(app bootstrap / app.whenReady)影响测试。
|
||||
*/
|
||||
export function parsePocQuestion(argv: readonly string[]): string {
|
||||
const args = [...argv]
|
||||
// npm / pnpm 在复合 script(`build && electron ...`)里会把 `--` 一并追加到命令末尾,
|
||||
// 于是 separator 会落到 POC 的 argv 里,污染问题正文。
|
||||
// 只移除开头的这一个独立 separator;问题正文中间的合法 `--` 必须保留。
|
||||
if (args[0] === '--') args.shift()
|
||||
return args.join(' ').trim()
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import { app } from 'electron'
|
||||
import { apiTokenStore } from './api-token-store'
|
||||
import { AIProviderService } from './services/ai-provider-service'
|
||||
import { QueryAgentPocService, type QueryAgentToolResult } from './services/query-agent-poc-service'
|
||||
import { parsePocQuestion } from './query-agent-poc-cli'
|
||||
import { formatProviderDiagnostics, formatTiming } from './query-agent-poc-report'
|
||||
|
||||
const baseUrl = (process.env.TRACEMEMO_QUERY_API_BASE || 'http://127.0.0.1:6131/api/v1').replace(/\/+$/, '')
|
||||
const question = process.argv.slice(2).join(' ').trim()
|
||||
const question = parsePocQuestion(process.argv.slice(2))
|
||||
|
||||
async function callQueryApi(name: string, input: Record<string, unknown>): Promise<QueryAgentToolResult> {
|
||||
const paths: Record<string, string> = {
|
||||
@@ -26,9 +28,13 @@ async function callQueryApi(name: string, input: Record<string, unknown>): Promi
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await app.whenReady()
|
||||
const service = new QueryAgentPocService(new AIProviderService(), callQueryApi)
|
||||
const provider = new AIProviderService()
|
||||
const service = new QueryAgentPocService(provider, callQueryApi)
|
||||
const result = await service.run(question)
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
||||
// 诊断摘要写 stderr,保持 stdout 仍是纯 JSON,方便管道与脚本消费。
|
||||
process.stderr.write(formatTiming(result))
|
||||
process.stderr.write(formatProviderDiagnostics(result, provider.getRuntimeEndpointHost()))
|
||||
app.quit()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { QueryAgentPocResult } from './services/query-agent-poc-service'
|
||||
|
||||
const WIDTH = 24
|
||||
|
||||
function row(label: string, value: string): string {
|
||||
return `${label.padEnd(14)}${value.padStart(10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 人类可读的耗时摘要。
|
||||
*
|
||||
* 只做展示,不改变 JSON 输出契约。需要能正确处理:
|
||||
* - 0 次模型调用(配置缺失等提前返回)
|
||||
* - 1 次模型调用 + 0 次工具
|
||||
* - 2 次模型调用 + N 次工具(此时可准确还原"模型 → 工具 → 模型"顺序)
|
||||
* - 3 次以上(无法逐段归属工具耗时时,明确标注为聚合)
|
||||
* - 首次模型调用失败 / 末尾模型调用失败
|
||||
*/
|
||||
export function formatTiming(result: QueryAgentPocResult): string {
|
||||
const durations = result.modelDurationsMs || []
|
||||
const toolTotalMs = result.toolTotalMs || 0
|
||||
const toolCount = result.toolCallCount || 0
|
||||
const lines: string[] = ['', '[Timing]']
|
||||
|
||||
if (durations.length === 0) {
|
||||
lines.push('(未发生模型调用)')
|
||||
} else if (durations.length <= 2) {
|
||||
lines.push(row('Model #1', `${durations[0]} ms`))
|
||||
if (toolCount > 0) lines.push(row(`TM Tools(${toolCount})`, `${toolTotalMs} ms`))
|
||||
if (durations[1] !== undefined) lines.push(row('Model #2', `${durations[1]} ms`))
|
||||
} else {
|
||||
durations.forEach((duration, index) => lines.push(row(`Model #${index + 1}`, `${duration} ms`)))
|
||||
lines.push(row(`TM Tools(${toolCount})`, `${toolTotalMs} ms`))
|
||||
lines.push('(3 次以上模型调用:工具耗时按总计展示)')
|
||||
}
|
||||
|
||||
const modelTotalMs = durations.reduce((sum, value) => sum + value, 0)
|
||||
const totalMs = result.totalMs || 0
|
||||
const share = (value: number): string =>
|
||||
totalMs > 0 ? `${((value / totalMs) * 100).toFixed(1)}%` : 'n/a'
|
||||
|
||||
lines.push('-'.repeat(WIDTH))
|
||||
lines.push(row('Total', `${totalMs} ms`))
|
||||
lines.push('')
|
||||
lines.push(row('Model total', `${modelTotalMs} ms (${share(modelTotalMs)})`))
|
||||
lines.push(row('TM tool total', `${toolTotalMs} ms (${share(toolTotalMs)})`))
|
||||
if (result.error) lines.push(`error: ${result.error}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求级诊断。
|
||||
* 只输出 host 与状态字段;绝不输出 API key / Authorization / 完整 URL / 响应正文。
|
||||
*/
|
||||
export function formatProviderDiagnostics(result: QueryAgentPocResult, host?: string): string {
|
||||
const lines = [
|
||||
'',
|
||||
'[Provider]',
|
||||
`provider ${result.provider}`,
|
||||
`model ${result.model}`,
|
||||
`host ${host || '(unknown)'}`,
|
||||
`model calls ${result.modelCallCount}`,
|
||||
`tool calls ${result.toolCallCount}`
|
||||
]
|
||||
for (const entry of result.modelDiagnostics || []) {
|
||||
const parts = [`elapsedMs=${entry.elapsedMs}`]
|
||||
if (entry.status !== undefined) parts.push(`status=${entry.status}`)
|
||||
if (entry.contentType) parts.push(`contentType=${entry.contentType}`)
|
||||
if (entry.timedOut) parts.push('timedOut=true')
|
||||
if (entry.htmlInsteadOfJson) parts.push('htmlInsteadOfJson=true')
|
||||
if (entry.errorCode) parts.push(`code=${entry.errorCode}`)
|
||||
if (entry.errorType) parts.push(`type=${entry.errorType}`)
|
||||
if (entry.status !== undefined && [502, 503, 504].includes(entry.status)) parts.push('upstreamGateway=true')
|
||||
lines.push(`attempt #${entry.index} ${parts.join(' ')}`)
|
||||
if (entry.error) lines.push(` error: ${entry.error}`)
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
@@ -86,10 +86,16 @@ export class AIProviderRequestError extends Error {
|
||||
readonly code?: string
|
||||
readonly type?: string
|
||||
readonly responseBody?: unknown
|
||||
/** 诊断字段:上游响应 content-type(例如 502 返回的 text/html) */
|
||||
readonly contentType?: string
|
||||
/** 诊断字段:本次请求从发出到响应处理结束的耗时 */
|
||||
readonly elapsedMs?: number
|
||||
/** 诊断字段:上游返回网页而不是 JSON */
|
||||
readonly htmlInsteadOfJson?: boolean
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
details: { status?: number; code?: unknown; type?: unknown; responseBody?: unknown } = {}
|
||||
details: { status?: number; code?: unknown; type?: unknown; responseBody?: unknown; contentType?: string; elapsedMs?: number; htmlInsteadOfJson?: boolean } = {}
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AIProviderRequestError'
|
||||
@@ -97,6 +103,9 @@ export class AIProviderRequestError extends Error {
|
||||
this.code = typeof details.code === 'string' ? details.code : undefined
|
||||
this.type = typeof details.type === 'string' ? details.type : undefined
|
||||
this.responseBody = details.responseBody
|
||||
this.contentType = details.contentType
|
||||
this.elapsedMs = details.elapsedMs
|
||||
this.htmlInsteadOfJson = details.htmlInsteadOfJson
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +145,19 @@ export class AIProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断用:当前默认 Provider 的 endpoint host。
|
||||
* 只返回 hostname(不含路径与 query),避免把凭据或敏感 query 带进日志/终端。
|
||||
*/
|
||||
getRuntimeEndpointHost(): string | undefined {
|
||||
try {
|
||||
const resolved = this.resolveProvider(undefined)
|
||||
return endpointHost(resolved.provider.baseUrl)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
getVisionRuntimeConfig(): AIVisionRuntimeConfig {
|
||||
const result = this.list()
|
||||
const defaultProvider = result.providers.find((item) => item.id === result.defaultProviderId)
|
||||
@@ -324,6 +346,13 @@ export class AIProviderService {
|
||||
toolCalls?: AIChatToolCall[]
|
||||
usage?: { input?: number; output?: number; total?: number; estimated?: boolean }
|
||||
error?: string
|
||||
errorStatus?: number
|
||||
errorCode?: string
|
||||
errorType?: string
|
||||
errorContentType?: string
|
||||
elapsedMs?: number
|
||||
timedOut?: boolean
|
||||
htmlInsteadOfJson?: boolean
|
||||
}> {
|
||||
try {
|
||||
if (options?.apiKey) throw new Error('Tool Calling 不支持 legacy provider 配置')
|
||||
@@ -342,7 +371,22 @@ export class AIProviderService {
|
||||
return { success: true, ...result }
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error
|
||||
return { success: false, error: safeAIError(error) }
|
||||
const timedOut = error instanceof DOMException && error.name === 'TimeoutError'
|
||||
return {
|
||||
success: false,
|
||||
error: safeAIError(error),
|
||||
timedOut,
|
||||
htmlInsteadOfJson: error instanceof AIProviderRequestError ? Boolean(error.htmlInsteadOfJson) : false,
|
||||
...(error instanceof AIProviderRequestError
|
||||
? {
|
||||
...(error.status !== undefined ? { errorStatus: error.status } : {}),
|
||||
...(error.code ? { errorCode: error.code } : {}),
|
||||
...(error.type ? { errorType: error.type } : {}),
|
||||
...(error.contentType ? { errorContentType: error.contentType } : {}),
|
||||
...(error.elapsedMs !== undefined ? { elapsedMs: error.elapsedMs } : {})
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +642,15 @@ function deepSeekProvider(baseUrl?: string, model?: string): AIProviderSummary {
|
||||
}
|
||||
}
|
||||
|
||||
/** 诊断用:只取 endpoint 的 hostname,不保留路径与 query。 */
|
||||
function endpointHost(baseUrl: string): string | undefined {
|
||||
try {
|
||||
return new URL(baseUrl).hostname || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function stripRuntimeFields(
|
||||
provider: AIProviderSummary
|
||||
): Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'> {
|
||||
@@ -912,7 +965,7 @@ async function requestOpenAICompatibleWithTools(
|
||||
messages: Array<Record<string, unknown>>,
|
||||
tools: AIChatToolDefinition[],
|
||||
signal?: AbortSignal
|
||||
): Promise<{ data: string; toolCalls: AIChatToolCall[]; usage?: AIRequestResult['usage'] }> {
|
||||
): Promise<{ data: string; toolCalls: AIChatToolCall[]; usage?: AIRequestResult['usage']; elapsedMs?: number }> {
|
||||
const endpoint = provider.baseUrl.endsWith('/chat/completions')
|
||||
? provider.baseUrl
|
||||
: `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions`
|
||||
@@ -932,16 +985,33 @@ async function requestOpenAICompatibleWithTools(
|
||||
},
|
||||
provider.advanced.timeoutMs,
|
||||
signal,
|
||||
async (response) => {
|
||||
const payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||
if (!response.ok) {
|
||||
throw new AIProviderRequestError(payload.error?.message || `AI 请求失败 (${response.status})`, {
|
||||
status: response.status,
|
||||
code: payload.error?.code,
|
||||
type: payload.error?.type,
|
||||
responseBody: payload.error
|
||||
})
|
||||
async (response, timing) => {
|
||||
// 先尝试解析 body:非 2xx 时上游可能返回 JSON 错误体,也可能是 HTML 错误页。
|
||||
// 无论哪种,都要把 status / content-type / elapsed 带进错误,避免丢失上下文。
|
||||
let payload: OpenAIResponsePayload | undefined
|
||||
let parseError: unknown
|
||||
try {
|
||||
payload = await parseJsonResponse<OpenAIResponsePayload>(response)
|
||||
} catch (error) {
|
||||
parseError = error
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new AIProviderRequestError(
|
||||
payload?.error?.message || (parseError ? safeAIError(parseError) : `AI 请求失败 (${response.status})`),
|
||||
{
|
||||
status: timing.status,
|
||||
contentType: timing.contentType,
|
||||
elapsedMs: timing.elapsedMs,
|
||||
htmlInsteadOfJson: isHtmlParseError(parseError),
|
||||
code: payload?.error?.code,
|
||||
type: payload?.error?.type,
|
||||
responseBody: payload?.error
|
||||
}
|
||||
)
|
||||
}
|
||||
// 2xx 但 body 无法解析:抛出原始解析错误(含清晰的中文提示)。
|
||||
if (!payload) throw parseError ?? new Error('AI 响应格式异常')
|
||||
if (parseError) throw parseError
|
||||
const message = payload.choices?.[0]?.message
|
||||
return {
|
||||
data: openAIMessageText(message?.content),
|
||||
@@ -950,7 +1020,8 @@ async function requestOpenAICompatibleWithTools(
|
||||
if (!name) return []
|
||||
return [{ id: call.id || `tool-call-${index + 1}`, name, arguments: call.function?.arguments || '{}' }]
|
||||
}),
|
||||
usage: toOpenAIUsage(payload.usage)
|
||||
usage: toOpenAIUsage(payload.usage),
|
||||
elapsedMs: timing.elapsedMs
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1029,12 +1100,19 @@ async function requestAnthropic(
|
||||
)
|
||||
}
|
||||
|
||||
/** 请求级诊断:不包含任何凭据,仅用于区分「上游慢 / 上游错误 / 本地解析」。 */
|
||||
interface AIRequestTiming {
|
||||
elapsedMs: number
|
||||
status: number
|
||||
contentType: string
|
||||
}
|
||||
|
||||
async function fetchWithTimeout<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal | undefined,
|
||||
consume: (response: Response) => Promise<T>
|
||||
consume: (response: Response, timing: AIRequestTiming) => Promise<T>
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
let timedOut = false
|
||||
@@ -1049,9 +1127,16 @@ async function fetchWithTimeout<T>(
|
||||
},
|
||||
Math.max(1_000, timeoutMs || 120_000)
|
||||
)
|
||||
const startedAt = Date.now()
|
||||
let responseReceivedAt = startedAt
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: controller.signal })
|
||||
return await consume(response)
|
||||
responseReceivedAt = Date.now()
|
||||
return await consume(response, {
|
||||
elapsedMs: responseReceivedAt - startedAt,
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type') || ''
|
||||
})
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new DOMException('AI request cancelled', 'AbortError')
|
||||
if (timedOut) throw new DOMException('AI request timed out', 'TimeoutError')
|
||||
@@ -1295,6 +1380,11 @@ function safeAIError(error: unknown): string {
|
||||
return message.replace(/sk-[a-z0-9_-]+/gi, '***').slice(0, 300)
|
||||
}
|
||||
|
||||
/** 判断解析失败是否因为上游返回了 HTML(例如网关 502 错误页)。 */
|
||||
function isHtmlParseError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.includes('网页而不是 JSON')
|
||||
}
|
||||
|
||||
function aiProviderErrorDetails(error: unknown): {
|
||||
errorCode?: string
|
||||
errorStatus?: number
|
||||
|
||||
@@ -3,6 +3,14 @@ import { LOCAL_QUERY_TOOL_DEFINITIONS } from '../../shared/local-query-api'
|
||||
|
||||
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
|
||||
@@ -33,7 +41,21 @@ export interface QueryAgentProvider {
|
||||
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 }>
|
||||
): 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 {
|
||||
@@ -50,6 +72,18 @@ export interface QueryAgentTraceItem {
|
||||
evidenceCount?: number
|
||||
}
|
||||
|
||||
export interface QueryAgentModelCallDiagnostic {
|
||||
index: number
|
||||
elapsedMs: number
|
||||
status?: number
|
||||
contentType?: string
|
||||
timedOut?: boolean
|
||||
htmlInsteadOfJson?: boolean
|
||||
errorCode?: string
|
||||
errorType?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface QueryAgentPocResult {
|
||||
question: string
|
||||
provider: string
|
||||
@@ -60,6 +94,10 @@ export interface QueryAgentPocResult {
|
||||
toolTotalMs: number
|
||||
finalModelMs?: number
|
||||
totalMs: number
|
||||
/** 每次模型调用的耗时(含失败的那次),按调用顺序;additive 诊断字段 */
|
||||
modelDurationsMs: number[]
|
||||
/** 每次模型调用的请求级诊断;additive 诊断字段 */
|
||||
modelDiagnostics: QueryAgentModelCallDiagnostic[]
|
||||
traces: QueryAgentTraceItem[]
|
||||
answer?: string
|
||||
error?: string
|
||||
@@ -74,11 +112,12 @@ const SYSTEM_PROMPT = `你是 TraceMemo 的本地聊天查询助手,只能使
|
||||
|
||||
规划原则:
|
||||
- 先判断问题需要哪种证据,再调用最少的 Tool。每次收到 Tool Result 后都判断“当前 Evidence 是否已经足以给出有边界的回答”;足够就立即回答,不为追求绝对完整继续调查。
|
||||
- query_messages 是精确事实查询,适用于能用联系人、时间、方向、消息类型、顺序等结构条件表达的问题。earliest/latest 等时间边界也是结构条件,必须使用 order 与 limit 精确查询,不能使用抽样 overview。结果已经回答问题时,不要追加 conversation_overview。
|
||||
- search_messages 是语义 Evidence 检索,适用于结构条件无法确定答案的问题。第一次尽量在一个调用中给出高质量 query 和最多 4 个 variants;调用前确认 variants 数组长度不超过 4。一次有效 search 后不得再次 search,已有相关 Evidence 就直接判断。
|
||||
- conversation_overview 只用于真正需要理解一个时间范围内整体聊了什么、主要话题或整体互动的 broad summary。它返回 temporal coverage sample,不代表完整聊天,也不是语义检索不足时的默认 fallback。
|
||||
- query_messages 是精确事实查询,适用于能用联系人、时间、方向、消息类型、顺序等结构条件表达的问题。earliest/latest 等时间边界也是结构条件,必须使用 order 与 limit 精确查询,不能使用抽样 overview。结果已经回答问题时,不要追加 conversation_overview。若返回 0 条且你判断是时间范围或结构条件不合适,允许再查一次并合理扩大或更换条件,但必须与上一次实质不同。
|
||||
- 需要绝对时间范围时,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。经过合理的 search 或可选 context 仍不足以形成强结论时,直接说明证据范围和不确定性,不要循环调用 search、overview、context。
|
||||
- 普通聊天查询不是 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 混为一谈。
|
||||
缺少必要信息时用自然语言澄清;超出工具能力时说明不能可靠完成,并给出当前工具可以执行的替代方向。`
|
||||
@@ -187,17 +226,147 @@ function validateSchema(value: unknown, schema: ToolSchema, field = '$'): ToolAr
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function validateToolArguments(name: string, value: unknown): { input?: Record<string, unknown>; error?: ToolArgumentValidationError } {
|
||||
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) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM Tool Adapter 的 canonicalization:
|
||||
* - absolute 的 ISO-8601 → Local Query API 的 epoch seconds
|
||||
* - search_messages 的 queries[] → Local Query API 的 query + variants
|
||||
*/
|
||||
function canonicalizeToolInput(name: string, input: Record<string, unknown>, now: Date): { input?: Record<string, unknown>; error?: ToolArgumentValidationError } {
|
||||
const output: Record<string, unknown> = { ...input }
|
||||
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
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
interface ZeroResultRetryState {
|
||||
searchAttempts: number
|
||||
searchSignatures: string[]
|
||||
queryAttempts: number
|
||||
querySignatures: string[]
|
||||
}
|
||||
|
||||
function newRetryState(): ZeroResultRetryState {
|
||||
return { searchAttempts: 0, searchSignatures: [], queryAttempts: 0, querySignatures: [] }
|
||||
}
|
||||
|
||||
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 && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT) return '本次精确查询返回 0 条。允许再执行一次 query_messages,用于合理扩大或更换时间范围、方向或消息类型;完全相同的条件会被拒绝。'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function validateToolArguments(name: string, value: unknown, now: Date = new Date()): { input?: Record<string, unknown>; error?: ToolArgumentValidationError } {
|
||||
const definition = LOCAL_QUERY_TOOL_DEFINITIONS.find((tool) => tool.name === name)
|
||||
if (!definition) return { error: { status: 'invalid_tool_arguments', field: '$', constraint: 'tool', expected: 'supported tool', actual: name } }
|
||||
if (!definition) return { error: argError('$', 'tool', 'supported tool', name) }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { error: { status: 'invalid_tool_arguments', field: '$', constraint: 'type', expected: 'object', actual: actualType(value) } }
|
||||
return { error: argError('$', 'type', 'object', actualType(value)) }
|
||||
}
|
||||
if (containsForbiddenKey(value)) {
|
||||
return { error: { status: 'invalid_tool_arguments', field: '$', constraint: 'forbidden_field' } }
|
||||
return { error: argError('$', 'forbidden_field') }
|
||||
}
|
||||
const error = validateSchema(value, definition.parameters as ToolSchema)
|
||||
return error ? { error } : { input: value as Record<string, unknown> }
|
||||
if (error) return { error }
|
||||
return canonicalizeToolInput(name, value as Record<string, unknown>, now)
|
||||
}
|
||||
|
||||
function resultCount(result: QueryAgentToolResult): { resultCount?: number; evidenceCount?: number } {
|
||||
@@ -213,7 +382,7 @@ function messageRecordForModel(value: unknown): unknown {
|
||||
return record.messageType || !record.sourceKind ? record : { ...record, messageType: record.sourceKind }
|
||||
}
|
||||
|
||||
function toolResultForModel(name: string, result: QueryAgentToolResult, callsUsed: number, nextTools: AIChatToolDefinition[]): QueryAgentToolResult {
|
||||
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)
|
||||
@@ -225,29 +394,48 @@ function toolResultForModel(name: string, result: QueryAgentToolResult, callsUse
|
||||
toolCallsUsed: callsUsed,
|
||||
toolCallsRemaining: Math.max(0, MAX_TOOL_CALLS - callsUsed),
|
||||
availableNextTools: nextTools.map((tool) => tool.function.name),
|
||||
instruction: nextTools.length
|
||||
? '先判断当前 Evidence 是否足以回答;足够就立即回答,只在含义仍有明确歧义时使用当前可用 Tool。'
|
||||
: '工具阶段已经结束。必须直接给出有边界的最终回答,不得再调用 Tool。'
|
||||
...(note ? { note } : {}),
|
||||
instruction: [
|
||||
nextTools.length
|
||||
? '先判断当前 Evidence 是否足以回答;足够就立即回答,只在含义仍有明确歧义时使用当前可用 Tool。'
|
||||
: '工具阶段已经结束。必须直接给出有边界的最终回答,不得再调用 Tool。',
|
||||
note
|
||||
].filter(Boolean).join(' ')
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function nextToolDefinitions(name: string, result: QueryAgentToolResult): AIChatToolDefinition[] {
|
||||
function nextToolDefinitions(name: string, result: QueryAgentToolResult, state: ZeroResultRetryState): AIChatToolDefinition[] {
|
||||
// 重复重试已被拒绝,不再开放工具,避免用有限的 tool budget 反复试同一条件。
|
||||
if (result.constraint === 'duplicate_retry') return []
|
||||
if (result.status === 'invalid_tool_arguments') return toolDefinition(name)
|
||||
if (name === 'search_messages' && result.status === 'completed' && resultCount(result).evidenceCount) {
|
||||
return toolDefinition('message_context')
|
||||
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') {
|
||||
// 只有 0 结果才开放一次重试;有结果时保持原有 stopping。
|
||||
return counts.resultCount === 0 && state.queryAttempts <= ZERO_RESULT_RETRY_LIMIT ? toolDefinition('query_messages') : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export class QueryAgentPocService {
|
||||
constructor(private readonly provider: QueryAgentProvider, private readonly executeTool: QueryAgentToolExecutor) {}
|
||||
constructor(
|
||||
private readonly provider: QueryAgentProvider,
|
||||
private readonly executeTool: QueryAgentToolExecutor,
|
||||
private readonly nowProvider: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
async run(question: string): Promise<QueryAgentPocResult> {
|
||||
const trimmed = question.trim()
|
||||
const startedAt = Date.now()
|
||||
const runtime = this.provider.getRuntimeConfig()
|
||||
const result: QueryAgentPocResult = { question: trimmed, provider: runtime.providerName, model: runtime.modelName || runtime.model, modelCallCount: 0, toolCallCount: 0, toolTotalMs: 0, totalMs: 0, traces: [] }
|
||||
const result: QueryAgentPocResult = { 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: '请输入查询问题', totalMs: Date.now() - startedAt }
|
||||
if (!runtime.configured) return { ...result, error: '当前 AI Provider 尚未配置', totalMs: Date.now() - startedAt }
|
||||
|
||||
@@ -256,6 +444,8 @@ export class QueryAgentPocService {
|
||||
{ role: 'user', content: trimmed }
|
||||
]
|
||||
let tools = toolDefinitions()
|
||||
const retry = newRetryState()
|
||||
const now = this.nowProvider()
|
||||
let firstModelAt: number | undefined
|
||||
let finalModelDuration: number | undefined
|
||||
while (result.toolCallCount < MAX_TOOL_CALLS) {
|
||||
@@ -263,6 +453,19 @@ export class QueryAgentPocService {
|
||||
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 || '模型调用失败', firstModelMs: firstModelAt - startedAt, totalMs: Date.now() - startedAt }
|
||||
const calls = model.toolCalls || []
|
||||
@@ -292,12 +495,18 @@ export class QueryAgentPocService {
|
||||
toolResult = { status: 'invalid_tool_arguments', field: '$', constraint: 'json' }
|
||||
}
|
||||
if (!toolResult) {
|
||||
const validated = validateToolArguments(call.name, parsed)
|
||||
const validated = validateToolArguments(call.name, parsed, now)
|
||||
if (validated.error) {
|
||||
toolResult = validated.error
|
||||
} else {
|
||||
traceInput = validated.input || {}
|
||||
toolResult = await this.executeTool(call.name, traceInput)
|
||||
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)
|
||||
toolResult = await this.executeTool(call.name, traceInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,8 +521,9 @@ export class QueryAgentPocService {
|
||||
result.traces.push({ toolName: call.name, input: sanitizeInput(traceInput), durationMs, status: completedToolResult.status, ...counts })
|
||||
const nextTools = completedToolResult.constraint === 'tool_availability'
|
||||
? tools
|
||||
: nextToolDefinitions(call.name, completedToolResult)
|
||||
messages.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: JSON.stringify(toolResultForModel(call.name, completedToolResult, result.toolCallCount, nextTools)) })
|
||||
: nextToolDefinitions(call.name, completedToolResult, retry)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,21 @@ export interface LocalQueryToolDefinition {
|
||||
parameters: Record<string, unknown>
|
||||
}
|
||||
const targetSchema = { type: 'object', properties: { query: { type: 'string', minLength: 1 } }, required: ['query'], additionalProperties: false }
|
||||
const timeRangeSchema = { type: 'object', properties: { kind: { enum: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'] }, startTime: { type: 'number' }, endTime: { type: 'number' } }, required: ['kind'], additionalProperties: false }
|
||||
// LLM-facing 绝对时间契约:只接受带时区偏移的 ISO-8601 字符串,避免 epoch 单位歧义。
|
||||
// Local Query API 内部仍使用 epoch seconds(见 QueryTimeRange),由 Host Adapter 负责转换。
|
||||
const timeRangeSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { enum: ['all', 'today', 'yesterday', 'this_week', 'last_7_days', 'this_month', 'previous_month', 'this_year', 'previous_year', 'absolute'] },
|
||||
startTime: { type: 'string', minLength: 1, description: '仅 kind=absolute 时必填。带时区偏移的 ISO-8601 字符串,例如 2026-08-01T00:00:00+08:00 或 2026-07-31T16:00:00Z。不接受 epoch 数字,也不接受无时区的裸本地时间。' },
|
||||
endTime: { type: 'string', minLength: 1, description: '仅 kind=absolute 时必填。含义与格式同 startTime,且不得早于 startTime。' }
|
||||
},
|
||||
required: ['kind'],
|
||||
additionalProperties: false
|
||||
}
|
||||
export const LOCAL_QUERY_TOOL_DEFINITIONS: LocalQueryToolDefinition[] = [
|
||||
{ name: 'query_messages', description: '精确读取符合联系人、时间、方向、消息类型、顺序等结构条件的消息;适合具体事实和 earliest/latest 等时间边界查询,边界查询使用 order 与 limit。', parameters: { type: 'object', required: ['target', 'timeRange'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, direction: { enum: ['any', 'from_target', 'to_target'] }, messageTypes: { type: 'array', items: { enum: ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] } }, order: { enum: ['asc', 'desc'] }, limit: { type: 'integer', minimum: 1, maximum: 200 }, excludeSystem: { type: 'boolean' } } } },
|
||||
{ name: 'search_messages', description: '在指定联系人和时间范围内按语义寻找相关 Evidence;首次调用应集中提供高质量 query 和 variants。已有相关 Evidence 时直接判断,不要重复搜索。', parameters: { type: 'object', required: ['target', 'timeRange', 'query'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, query: { type: 'string', minLength: 1 }, variants: { type: 'array', description: '可选的语义变体,数组元素总数不得超过 4;调用前必须自行计数。', maxItems: 4, items: { type: 'string' } }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } },
|
||||
{ name: 'query_messages', description: '精确读取符合联系人、时间、方向、消息类型、顺序等结构条件的消息;适合具体事实和 earliest/latest 等时间边界查询,边界查询使用 order 与 limit。若结果为 0 且条件明显不合适,可再查一次并合理扩大或更换条件,但条件必须与上一次实质不同。', parameters: { type: 'object', required: ['target', 'timeRange'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, direction: { enum: ['any', 'from_target', 'to_target'] }, messageTypes: { type: 'array', items: { enum: ['text', 'image', 'voice', 'video', 'file', 'link', 'sticker', 'system', 'other'] } }, order: { enum: ['asc', 'desc'] }, limit: { type: 'integer', minimum: 1, maximum: 200 }, excludeSystem: { type: 'boolean' } } } },
|
||||
{ name: 'search_messages', description: '在指定联系人和时间范围内做关键词检索并返回相关 Evidence。queries 的每一项都是一次独立的字面检索:一项只放一个简短关键词,不要把多个近义词或整句话放进同一项,也不要指望一项内部被拆词理解。首次最多 4 项;只有在本次检索完全没有 Evidence 时,才允许再检索一次,且每一项都必须与上一次实质不同。', parameters: { type: 'object', required: ['target', 'timeRange', 'queries'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema, queries: { type: 'array', description: '独立检索项列表,每项一个简短关键词,最多 4 项;每一项单独检索,不会组合成一句话理解。', minItems: 1, maxItems: 4, items: { type: 'string', minLength: 1 } }, limit: { type: 'integer', minimum: 1, maximum: 200 } } } },
|
||||
{ name: 'message_context', description: '补充已找到的单条有价值 Evidence 的前后消息;仅在该 Evidence 缺少语境、无法判断含义时使用,不是默认确认步骤。', parameters: { type: 'object', required: ['messageRef'], additionalProperties: false, properties: { messageRef: { type: 'string', minLength: 1 }, before: { type: 'integer', minimum: 0, maximum: 50 }, after: { type: 'integer', minimum: 0, maximum: 50 } } } },
|
||||
{ name: 'conversation_overview', description: '提取指定联系人和时间范围的整体聊天覆盖样本;只用于 broad summary,不是语义搜索 fallback,也不能确定 earliest/latest 等精确时间边界。', parameters: { type: 'object', required: ['target', 'timeRange'], additionalProperties: false, properties: { target: targetSchema, timeRange: timeRangeSchema } } }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user