🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级: - 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环 - evo_loop 后台进程,无需手动触发 - consciousness 意识持久化 - ACUI 卡片组件系统 - MCP 工具生态扩展至50+工具 - 技能体系重构,4个活跃技能 - 身份升级为自由体
This commit is contained in:
843
src/llm.js
Normal file
843
src/llm.js
Normal file
@@ -0,0 +1,843 @@
|
||||
import OpenAI from 'openai'
|
||||
import { config } from './config.js'
|
||||
import { executeTool } from './capabilities/executor.js'
|
||||
import { getToolSchemas } from './capabilities/schemas.js'
|
||||
import { recordUsage, shouldThrottle } from './quota.js'
|
||||
import { insertActionLog } from './db.js'
|
||||
|
||||
// 延迟创建 OpenAI 客户端:激活流程把 key 写入 config 后再调用这里,
|
||||
// 避免模块加载阶段就锁死尚未填入的 apiKey/baseURL。
|
||||
let client = null
|
||||
let clientKey = null
|
||||
function getClient() {
|
||||
const signature = `${config.provider}|${config.baseURL}|${config.apiKey}`
|
||||
if (client && clientKey === signature) return client
|
||||
if (!config.apiKey) {
|
||||
throw new Error('LLM 尚未激活,请先通过激活页填入 API Key')
|
||||
}
|
||||
client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseURL })
|
||||
clientKey = signature
|
||||
return client
|
||||
}
|
||||
|
||||
function shouldEnableDeepSeekThinking(thinking) {
|
||||
if (!thinking) return false
|
||||
if (config.model === 'deepseek-chat') return false
|
||||
return true
|
||||
}
|
||||
|
||||
// 单次流式调用,返回 { content, toolCalls, aborted }
|
||||
async function streamOnce({ messages, toolSchemas, temperature, topP, maxTokens, thinking = true, signal, onStream }) {
|
||||
const requestParams = {
|
||||
model: config.model,
|
||||
temperature,
|
||||
messages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
|
||||
if (typeof topP === 'number' && topP > 0) requestParams.top_p = topP
|
||||
if (config.provider === 'deepseek') {
|
||||
const thinkingEnabled = shouldEnableDeepSeekThinking(thinking)
|
||||
if (thinkingEnabled) {
|
||||
requestParams.reasoning_effort = 'high'
|
||||
requestParams.thinking = { type: 'enabled' }
|
||||
} else {
|
||||
// DeepSeek 拒绝 reasoning_effort 与 thinking.type='disabled' 组合
|
||||
requestParams.thinking = { type: 'disabled' }
|
||||
}
|
||||
} else {
|
||||
if (!thinking) requestParams.thinking = { type: 'disabled' }
|
||||
}
|
||||
if (maxTokens) requestParams.max_tokens = maxTokens
|
||||
if (toolSchemas.length > 0) {
|
||||
requestParams.tools = toolSchemas
|
||||
requestParams.tool_choice = 'auto'
|
||||
}
|
||||
|
||||
const stream = await getClient().chat.completions.create(requestParams, { signal })
|
||||
|
||||
let fullContent = ''
|
||||
let fullReasoningContent = ''
|
||||
let toolCallsMap = {}
|
||||
let inThink = false
|
||||
let thinkDone = false
|
||||
let streamStarted = false
|
||||
let usageTokens = 0
|
||||
let cacheHitTokens = 0
|
||||
let cacheMissTokens = 0
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (signal?.aborted) break
|
||||
if (chunk.usage?.total_tokens) {
|
||||
usageTokens = chunk.usage.total_tokens
|
||||
cacheHitTokens = chunk.usage.prompt_cache_hit_tokens || 0
|
||||
cacheMissTokens = chunk.usage.prompt_cache_miss_tokens || 0
|
||||
}
|
||||
const choice = chunk.choices?.[0]
|
||||
if (!choice) continue
|
||||
|
||||
const delta = choice.delta
|
||||
|
||||
// 工具调用增量
|
||||
if (delta?.tool_calls) {
|
||||
if (streamStarted) {
|
||||
onStream?.({ event: 'end' })
|
||||
streamStarted = false
|
||||
}
|
||||
for (const tc of delta.tool_calls) {
|
||||
const idx = tc.index ?? 0
|
||||
if (!toolCallsMap[idx]) {
|
||||
toolCallsMap[idx] = { id: tc.id || '', name: '', arguments: '' }
|
||||
}
|
||||
if (tc.id) toolCallsMap[idx].id = tc.id
|
||||
if (tc.function?.name) {
|
||||
const wasEmpty = toolCallsMap[idx].name === ''
|
||||
toolCallsMap[idx].name += tc.function.name
|
||||
// 第一次拿到完整 name 时通知上层 —— 此时流文本已 end,但工具尚未执行,
|
||||
// 没有这个信号 UI 会出现"思考动画停止 → 工具行出现"之间的死寂。
|
||||
if (wasEmpty && toolCallsMap[idx].name) {
|
||||
onStream?.({ event: 'tool_preparing', name: toolCallsMap[idx].name })
|
||||
}
|
||||
}
|
||||
if (tc.function?.arguments) toolCallsMap[idx].arguments += tc.function.arguments
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// DeepSeek reasoner 思考内容(独立字段,不在 content 里)
|
||||
const reasoningText = delta?.reasoning_content
|
||||
if (reasoningText) {
|
||||
fullReasoningContent += reasoningText
|
||||
if (!thinkDone) {
|
||||
inThink = true
|
||||
if (!streamStarted) { onStream?.({ event: 'start', mode: 'think' }); streamStarted = true }
|
||||
onStream?.({ event: 'chunk', text: reasoningText })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 文本增量
|
||||
const text = delta?.content
|
||||
if (!text) continue
|
||||
|
||||
// DeepSeek:思考流结束、进入正式回答时,先关闭 think 流
|
||||
if (inThink && !thinkDone) {
|
||||
inThink = false
|
||||
thinkDone = true
|
||||
if (streamStarted) { onStream?.({ event: 'end' }); streamStarted = false }
|
||||
}
|
||||
|
||||
fullContent += text
|
||||
|
||||
// 解析 <think> 标签流式推送
|
||||
if (!thinkDone) {
|
||||
if (!inThink && fullContent.includes('<think>')) {
|
||||
inThink = true
|
||||
const after = fullContent.split('<think>').slice(1).join('<think>')
|
||||
if (after.length > 0) {
|
||||
if (!streamStarted) { onStream?.({ event: 'start', mode: 'think' }); streamStarted = true }
|
||||
onStream?.({ event: 'chunk', text: after })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (inThink) {
|
||||
if (fullContent.includes('</think>')) {
|
||||
inThink = false
|
||||
thinkDone = true
|
||||
const chunkBeforeEnd = text.split('</think>')[0]
|
||||
if (chunkBeforeEnd) onStream?.({ event: 'chunk', text: chunkBeforeEnd })
|
||||
onStream?.({ event: 'end' })
|
||||
streamStarted = false
|
||||
const afterThink = fullContent.split('</think>').slice(1).join('</think>').trimStart()
|
||||
if (afterThink) {
|
||||
onStream?.({ event: 'start', mode: 'text' }); streamStarted = true
|
||||
onStream?.({ event: 'chunk', text: afterThink })
|
||||
}
|
||||
} else {
|
||||
if (!streamStarted) { onStream?.({ event: 'start', mode: 'think' }); streamStarted = true }
|
||||
onStream?.({ event: 'chunk', text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamStarted) { onStream?.({ event: 'start', mode: 'text' }); streamStarted = true }
|
||||
onStream?.({ event: 'chunk', text })
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError' || signal?.aborted) {
|
||||
if (streamStarted) onStream?.({ event: 'end' })
|
||||
return {
|
||||
content: fullContent,
|
||||
reasoningContent: fullReasoningContent,
|
||||
toolCalls: Object.values(toolCallsMap),
|
||||
aborted: true
|
||||
}
|
||||
}
|
||||
err.hadContent = fullContent.length > 0
|
||||
if (streamStarted) onStream?.({ event: 'end' })
|
||||
throw err
|
||||
}
|
||||
|
||||
if (streamStarted) onStream?.({ event: 'end' })
|
||||
if (usageTokens > 0) {
|
||||
recordUsage(usageTokens)
|
||||
const promptTotal = cacheHitTokens + cacheMissTokens
|
||||
const cacheStr = promptTotal > 0
|
||||
? ` (prompt cache: ${cacheHitTokens}/${promptTotal} = ${(cacheHitTokens/promptTotal*100).toFixed(1)}%)`
|
||||
: ''
|
||||
console.log(`[配额] 本轮 tokens: ${usageTokens}${cacheStr}`)
|
||||
}
|
||||
|
||||
return {
|
||||
content: fullContent,
|
||||
reasoningContent: fullReasoningContent,
|
||||
toolCalls: Object.values(toolCallsMap),
|
||||
aborted: false
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为瞬时错误(5xx / 网络抖动 / 超时),429 交给外层 setRateLimited
|
||||
function isTransientError(err) {
|
||||
const status = err.status ?? err.response?.status
|
||||
if (status && status >= 500 && status < 600) return true
|
||||
if (status === 408) return true
|
||||
const code = err.code || err.cause?.code
|
||||
if (code && ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'ENOTFOUND', 'EPIPE'].includes(code)) return true
|
||||
const msg = err.message || ''
|
||||
return /timeout|timed out|socket hang up|fetch failed|network error|upstream/i.test(msg)
|
||||
}
|
||||
|
||||
function abortableSleep(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
|
||||
const timer = setTimeout(resolve, ms)
|
||||
const onAbort = () => { clearTimeout(timer); reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })) }
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
// 包装 streamOnce:对瞬时错误做有限次退避重试;已流出内容时不重试避免 UI 重复
|
||||
async function streamOnceWithRetry(args) {
|
||||
const BACKOFFS_MS = [800, 2500]
|
||||
const MAX_ATTEMPTS = BACKOFFS_MS.length + 1
|
||||
let lastErr
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
if (args.signal?.aborted) throw Object.assign(new Error('Aborted'), { name: 'AbortError' })
|
||||
try {
|
||||
return await streamOnce(args)
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError' || args.signal?.aborted) throw err
|
||||
if (err.hadContent) throw err
|
||||
if (!isTransientError(err)) throw err
|
||||
lastErr = err
|
||||
if (attempt < MAX_ATTEMPTS - 1) {
|
||||
const delay = BACKOFFS_MS[attempt]
|
||||
args.onRetry?.({
|
||||
attempt: attempt + 1,
|
||||
nextAttempt: attempt + 2,
|
||||
maxAttempts: MAX_ATTEMPTS,
|
||||
delayMs: delay,
|
||||
error: err.message || String(err),
|
||||
})
|
||||
console.warn(`[LLM] 瞬时错误 "${(err.message || '').slice(0, 80)}",${delay}ms 后第 ${attempt + 2} 次尝试`)
|
||||
await abortableSleep(delay, args.signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
// XML 格式工具调用的参数名别名映射(某些模型使用不同参数名)
|
||||
const PARAM_ALIASES = {
|
||||
send_message: { to: 'target_id', message: 'content', text: 'content', recipient: 'target_id' },
|
||||
read_file: { file: 'path', filename: 'path', filepath: 'path' },
|
||||
write_file: { file: 'path', filename: 'path', filepath: 'path', text: 'content', data: 'content' },
|
||||
list_dir: { directory: 'path', dir: 'path', folder: 'path' },
|
||||
make_dir: { directory: 'path', dir: 'path', folder: 'path' },
|
||||
delete_file: { file: 'path', filename: 'path' },
|
||||
exec_command: { cmd: 'command', shell: 'command', bg: 'background' },
|
||||
web_search: { q: 'query', keyword: 'query', keywords: 'query', search: 'query' },
|
||||
fetch_url: { link: 'url', href: 'url', uri: 'url' },
|
||||
browser_read: { link: 'url', href: 'url', uri: 'url' },
|
||||
search_memory: { q: 'keyword', query: 'keyword', term: 'keyword' },
|
||||
}
|
||||
|
||||
function normalizeArgs(toolName, args) {
|
||||
const aliases = PARAM_ALIASES[toolName]
|
||||
if (!aliases) return args
|
||||
const normalized = { ...args }
|
||||
for (const [alias, canonical] of Object.entries(aliases)) {
|
||||
if (alias in normalized && !(canonical in normalized)) {
|
||||
normalized[canonical] = normalized[alias]
|
||||
delete normalized[alias]
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// 从文本内容中解析 XML 格式的工具调用(MiniMax 有时输出 XML 而非 JSON tool_calls)
|
||||
function parseXmlToolCalls(content) {
|
||||
const calls = []
|
||||
const invokeRegex = /<invoke\s+name="([^"]+)">([\s\S]*?)<\/invoke>/g
|
||||
let match
|
||||
while ((match = invokeRegex.exec(content)) !== null) {
|
||||
const name = match[1]
|
||||
const body = match[2]
|
||||
const xmlArgs = {}
|
||||
const paramRegex = /<parameter\s+name="([^"]+)">([\s\S]*?)<\/parameter>/g
|
||||
let param
|
||||
while ((param = paramRegex.exec(body)) !== null) {
|
||||
xmlArgs[param[1]] = param[2].trim()
|
||||
}
|
||||
calls.push({ id: `xml_${calls.length}`, name, arguments: JSON.stringify(xmlArgs), xmlArgs })
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
|
||||
function formatToolArgPreview(args = {}) {
|
||||
return Object.entries(args)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.slice(0, 3)
|
||||
.map(([key, value]) => `${key}=${String(value).slice(0, 80)}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function summarizeToolCall(name, args = {}) {
|
||||
switch (name) {
|
||||
case 'send_message':
|
||||
return `send_message -> ${args.target_id || '(unknown)'}`
|
||||
case 'read_file':
|
||||
return `read_file(${args.path || args.filename || args.file_path || '?'})`
|
||||
case 'list_dir':
|
||||
return `list_dir(${args.path || args.dir || args.directory || '.'})`
|
||||
case 'web_search':
|
||||
return `web_search(${String(args.query || args.q || args.keyword || '?').slice(0, 80)})`
|
||||
case 'fetch_url':
|
||||
return `fetch_url(${String(args.url || args.link || args.href || '?').slice(0, 80)})`
|
||||
case 'browser_read':
|
||||
return `browser_read(${String(args.url || args.link || args.href || '?').slice(0, 80)})`
|
||||
case 'search_memory': {
|
||||
if (Array.isArray(args.keywords)) {
|
||||
return `search_memory([${args.keywords.slice(0, 4).map(k => String(k).slice(0, 20)).join(', ')}])`
|
||||
}
|
||||
return `search_memory(${String(args.keyword || args.query || args.q || '?').slice(0, 60)})`
|
||||
}
|
||||
case 'upsert_memory': {
|
||||
const n = Array.isArray(args.memories) ? args.memories.length : 0
|
||||
const ids = (args.memories || []).slice(0, 3).map(m => m?.mem_id || '?').join(', ')
|
||||
return `upsert_memory(${n} 条: ${ids}${n > 3 ? '…' : ''})`
|
||||
}
|
||||
case 'skip_recognition':
|
||||
return `skip_recognition(${String(args.reason || '').slice(0, 40)})`
|
||||
case 'manage_reminder':
|
||||
case 'schedule_reminder': {
|
||||
const action = args.action || 'create'
|
||||
if (action === 'list') return 'manage_reminder(list)'
|
||||
if (action === 'cancel') return `manage_reminder(cancel #${args.id || '?'})`
|
||||
const kind = args.kind || 'once'
|
||||
const when = kind === 'once' ? (args.due_at || '?') : `${kind} ${args.time || '?'}`
|
||||
return `manage_reminder(create ${when}: ${String(args.task || '?').slice(0, 30)})`
|
||||
}
|
||||
case 'write_file':
|
||||
return `write_file(${args.path || args.filename || args.file_path || '?'})`
|
||||
case 'delete_file':
|
||||
return `delete_file(${args.path || args.filename || args.file_path || '?'})`
|
||||
case 'make_dir':
|
||||
return `make_dir(${args.path || args.dir || args.directory || '?'})`
|
||||
case 'exec_command':
|
||||
return `exec_command(${String(args.command || args.cmd || '?').slice(0, 80)})`
|
||||
default: {
|
||||
const preview = formatToolArgPreview(args)
|
||||
return preview ? `${name}(${preview})` : name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolLogDetail(args = {}, result = '') {
|
||||
const argPreview = formatToolArgPreview(args)
|
||||
const resultPreview = String(result || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
if (argPreview && resultPreview) return `${argPreview} | ${resultPreview}`
|
||||
return argPreview || resultPreview
|
||||
}
|
||||
|
||||
function shouldPersistActionLog(toolName) {
|
||||
return false
|
||||
}
|
||||
|
||||
const TOOL_LOOP_LIMITS = {
|
||||
maxRounds: 100,
|
||||
maxTotalCalls: 30,
|
||||
maxConsecutiveFailures: 3,
|
||||
maxSameFailures: 2,
|
||||
loopWindowSize: 8,
|
||||
loopUniqueThreshold: 2,
|
||||
}
|
||||
|
||||
const HIGH_RISK_TOOLS = new Set([
|
||||
'delete_file',
|
||||
'exec_command',
|
||||
'kill_process',
|
||||
'web_search',
|
||||
'fetch_url',
|
||||
'browser_read',
|
||||
'speak',
|
||||
'generate_lyrics',
|
||||
'generate_music',
|
||||
'generate_image',
|
||||
'ui_register',
|
||||
])
|
||||
|
||||
function stableStringify(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function buildToolFingerprint(name, args = {}) {
|
||||
return `${name}:${stableStringify(args || {})}`
|
||||
}
|
||||
|
||||
function isHighRiskTool(name) {
|
||||
return HIGH_RISK_TOOLS.has(name)
|
||||
}
|
||||
|
||||
const PARALLEL_SAFE_TOOLS = new Set([
|
||||
'read_file',
|
||||
'list_dir',
|
||||
'web_search',
|
||||
'fetch_url',
|
||||
'browser_read',
|
||||
'search_memory',
|
||||
'list_processes',
|
||||
])
|
||||
|
||||
function isParallelSafeTool(name, args = {}) {
|
||||
if (PARALLEL_SAFE_TOOLS.has(name)) return true
|
||||
if (name === 'manage_reminder') return args.action === 'list'
|
||||
if (name === 'manage_prefetch_task') return args.action === 'list'
|
||||
return false
|
||||
}
|
||||
|
||||
function isToolFailure(result) {
|
||||
const text = String(result || '').trim()
|
||||
if (!text) return false
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed?.ok === false) return true
|
||||
if (parsed?.error && parsed.ok !== true) return true
|
||||
return false
|
||||
} catch {}
|
||||
return /^(错误|请求失败|执行失败|命令超时|命令执行失败|閿欒|璇锋眰澶辫触|鎵ц澶辫触|鍛戒护瓒呮椂|鍛戒护鎵ц澶辫触)/.test(text)
|
||||
}
|
||||
|
||||
function createToolLoopState() {
|
||||
return {
|
||||
totalCalls: 0,
|
||||
consecutiveFailures: 0,
|
||||
sameFailureCounts: new Map(),
|
||||
recentFingerprints: [],
|
||||
}
|
||||
}
|
||||
|
||||
// send_message/express 是 agent 向用户"汇报 blocker"的唯一通道,必须绕开跨工具的全局熔断计数。
|
||||
// 否则当 exec_command/fetch_url 等连续失败触发熔断后,agent 想 send_message 解释失败也会被一并挡掉,
|
||||
// 出现"工具调不动 + 嘴也被堵住"的死锁(lessons-bailongma-silent-exit 的镜像问题)。
|
||||
// 同指纹反复失败仍由 sameFailureCounts / recentFingerprints 拦截,安全网完好。
|
||||
const REPORT_CHANNEL_TOOLS = new Set(['send_message', 'express'])
|
||||
|
||||
function getToolLoopStopReason(state, name, fingerprint) {
|
||||
const isReportChannel = REPORT_CHANNEL_TOOLS.has(name)
|
||||
if (!isReportChannel && state.consecutiveFailures >= TOOL_LOOP_LIMITS.maxConsecutiveFailures) {
|
||||
return `too many consecutive tool failures (${TOOL_LOOP_LIMITS.maxConsecutiveFailures})`
|
||||
}
|
||||
const sameFailures = state.sameFailureCounts.get(fingerprint) || 0
|
||||
if (sameFailures >= TOOL_LOOP_LIMITS.maxSameFailures) {
|
||||
return `same failing action repeated ${sameFailures} times`
|
||||
}
|
||||
const window = state.recentFingerprints.slice(-TOOL_LOOP_LIMITS.loopWindowSize)
|
||||
if (!isReportChannel && window.length >= TOOL_LOOP_LIMITS.loopWindowSize) {
|
||||
const unique = new Set(window).size
|
||||
if (unique <= TOOL_LOOP_LIMITS.loopUniqueThreshold) {
|
||||
return `stuck in a loop (only ${unique} unique action(s) in last ${TOOL_LOOP_LIMITS.loopWindowSize} calls)`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function makeToolLoopStoppedResult(name, reason) {
|
||||
return JSON.stringify({
|
||||
ok: false,
|
||||
tool: name,
|
||||
error: 'tool loop stopped',
|
||||
reason,
|
||||
hint: 'Stop retrying this action. Explain the blocker, ask for confirmation, or choose a materially different approach.',
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
function recordToolLoopOutcome(state, name, fingerprint, result) {
|
||||
state.totalCalls += 1
|
||||
state.recentFingerprints.push(fingerprint)
|
||||
|
||||
if (isToolFailure(result)) {
|
||||
state.consecutiveFailures += 1
|
||||
state.sameFailureCounts.set(fingerprint, (state.sameFailureCounts.get(fingerprint) || 0) + 1)
|
||||
} else {
|
||||
state.consecutiveFailures = 0
|
||||
state.sameFailureCounts.delete(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolLoopStopNudge(reason, lastToolResult) {
|
||||
const lastSummary = lastToolResult
|
||||
? `${lastToolResult.name}(${formatToolArgPreview(lastToolResult.args || {})}) -> ${String(lastToolResult.result || '').slice(0, 300)}`
|
||||
: 'No successful tool result is available.'
|
||||
return `Tool loop safety stop: ${reason}.\nLast tool result:\n${lastSummary}\n\nDo not keep retrying the same tool action. If enough information is available, call send_message and explain the outcome. If the task needs user confirmation or a different input, call send_message and ask clearly.`
|
||||
}
|
||||
|
||||
function requiresToolForRequest(text = '') {
|
||||
const input = String(text || '')
|
||||
const fileIntent = /(sandbox|文件|目录|创建|新建|写入|读取|删除|列出|保存|test-\d+|\.txt|\.json|\.md|\.js|\.html|\.css)/i.test(input)
|
||||
&& /(创建|新建|写入|读取|删除|列出|保存|改|修改|生成|create|write|read|delete|list|save)/i.test(input)
|
||||
const commandIntent = /(执行命令|运行命令|跑命令|exec|command|npm|node|git|powershell|cmd)/i.test(input)
|
||||
const webIntent = /(打开网页|抓取|联网|搜索|查询最新|fetch|url|https?:\/\/)/i.test(input)
|
||||
return fileIntent || commandIntent || webIntent
|
||||
}
|
||||
|
||||
function buildMissingToolNudge(userMessage = '') {
|
||||
return `The user's request requires a real tool call, not a textual claim. Do not say it is done unless the tool result proves it.\nUser request:\n${String(userMessage || '').slice(0, 600)}\n\nCall the appropriate tool now. For sandbox file creation or editing, call write_file with the exact path and content, then call send_message after the write_file result returns.`
|
||||
}
|
||||
|
||||
// 检测模型是否在文字中"描述"了工具调用而没有真正调用
|
||||
// 返回检测到的规范工具名,或 null
|
||||
function detectFakeToolCall(content, toolNames) {
|
||||
if (!content || !toolNames.length) return null
|
||||
|
||||
// 去掉下划线后做模糊匹配(处理模型写成 settickinterval 而非 set_tick_interval 的情况)
|
||||
const normalizedContent = content.toLowerCase().replace(/[_\s]/g, '')
|
||||
for (const name of toolNames) {
|
||||
if (name.length < 5) continue // 太短的名字容易误判
|
||||
if (normalizedContent.includes(name.toLowerCase().replace(/_/g, ''))) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// 检测中文动作括号伪调用,如 [心跳启动中] [调用成功] [执行中]
|
||||
if (/[\[【][^\]】]{2,20}(中|完成|成功|ing)[\]】]/.test(content)) {
|
||||
return '(action claim)'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildFakeToolCallNudge(toolName, toolSchemas = []) {
|
||||
const isGeneric = toolName === '(action claim)'
|
||||
const header = isGeneric
|
||||
? 'You wrote a bracketed action description (e.g. [xxx中]) but did not call any tool.'
|
||||
: `Your reply mentioned the tool "${toolName}" in text but did not invoke it through the function-call mechanism.`
|
||||
|
||||
let schemaHint = ''
|
||||
if (!isGeneric) {
|
||||
const schema = toolSchemas.find(s => s?.function?.name === toolName)
|
||||
if (schema) {
|
||||
const props = schema.function?.parameters?.properties || {}
|
||||
const required = schema.function?.parameters?.required || []
|
||||
const paramList = Object.entries(props)
|
||||
.map(([k, v]) => `${required.includes(k) ? k + '*' : k} (${v.type || 'any'})`)
|
||||
.join(', ')
|
||||
if (paramList) schemaHint = `\nRequired call format: ${toolName}({ ${paramList} }) (* = required)`
|
||||
}
|
||||
}
|
||||
|
||||
return `${header} Writing text about what a tool does has no effect on the system — the action did not happen.\n\nYou must now invoke the tool using the function-call interface, not describe it in prose.${schemaHint}`
|
||||
}
|
||||
|
||||
function throwIfAborted(signal) {
|
||||
if (!signal?.aborted) return
|
||||
const err = new Error(signal.reason || 'Aborted')
|
||||
err.name = 'AbortError'
|
||||
throw err
|
||||
}
|
||||
|
||||
// 主调用:agentic 循环,连续执行工具直到模型停止
|
||||
// 返回 { content: string, toolResult: { name, args, result } | null, aborted: bool }
|
||||
export async function callLLM({ systemPrompt, message, messages: inputMessages = null, temperature = 0.5, topP = 0.9, tools = [], maxTokens, thinking = true, signal, onToolCall, onToolExecute, onStream, onRetry, toolContext = {}, mustReply = false }) {
|
||||
const toolSchemas = getToolSchemas(tools)
|
||||
|
||||
const messages = Array.isArray(inputMessages) && inputMessages.length > 0
|
||||
? inputMessages.map(item => ({ ...item }))
|
||||
: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: message }
|
||||
]
|
||||
|
||||
if (shouldThrottle()) {
|
||||
console.log('[配额] 用量超过 95%,跳过本次调用')
|
||||
return { content: '(配额接近上限,等待窗口滚动)', toolResult: null, aborted: false }
|
||||
}
|
||||
|
||||
let allContent = ''
|
||||
let lastToolResult = null
|
||||
let sawToolCall = false
|
||||
let sentMessage = false
|
||||
let finalNudgeUsed = false
|
||||
let missingToolNudgeUsed = false
|
||||
let fakeToolNudgeUsed = false
|
||||
const toolLoopState = createToolLoopState()
|
||||
|
||||
for (let round = 0; round < TOOL_LOOP_LIMITS.maxRounds; round++) {
|
||||
throwIfAborted(signal)
|
||||
|
||||
const { content, reasoningContent, toolCalls, aborted } = await streamOnceWithRetry({
|
||||
messages,
|
||||
toolSchemas,
|
||||
temperature,
|
||||
topP,
|
||||
maxTokens,
|
||||
thinking,
|
||||
signal,
|
||||
onRetry,
|
||||
onStream, // 所有轮次均流式推送,让 UI 实时反映工具链执行过程中的模型输出
|
||||
})
|
||||
|
||||
if (aborted) {
|
||||
if (content) allContent += (allContent ? '\n' : '') + content
|
||||
break
|
||||
}
|
||||
|
||||
if (content) allContent += (allContent ? '\n' : '') + content
|
||||
|
||||
// 若无 JSON 工具调用,尝试从内容中解析 XML 格式工具调用(MiniMax 备用格式)
|
||||
let effectiveToolCalls = toolCalls
|
||||
if (toolCalls.length === 0 && content) {
|
||||
const xmlCalls = parseXmlToolCalls(content)
|
||||
if (xmlCalls.length > 0) {
|
||||
console.log(`[工具调用] 检测到 XML 格式工具调用,共 ${xmlCalls.length} 个`)
|
||||
effectiveToolCalls = xmlCalls
|
||||
// 从 allContent 中去掉 XML 调用块,避免污染 response
|
||||
allContent = allContent.replace(/<invoke[\s\S]*?<\/invoke>/g, '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
// 无工具调用:本轮结束;若工具后空回复,再补一轮明确的最终回复指令。
|
||||
if (effectiveToolCalls.length === 0) {
|
||||
if (!sawToolCall && requiresToolForRequest(message) && !missingToolNudgeUsed) {
|
||||
allContent = ''
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: buildMissingToolNudge(message),
|
||||
})
|
||||
missingToolNudgeUsed = true
|
||||
continue
|
||||
}
|
||||
// 检测伪工具调用:模型在文字里描述了调用但没有真正发起 function-call
|
||||
if (!fakeToolNudgeUsed && content) {
|
||||
const fakeToolName = detectFakeToolCall(content, tools)
|
||||
if (fakeToolName) {
|
||||
console.log(`[伪调用检测] 模型文字中发现 "${fakeToolName}",注入修正 nudge`)
|
||||
messages.push({ role: 'assistant', content })
|
||||
messages.push({ role: 'user', content: buildFakeToolCallNudge(fakeToolName, toolSchemas) })
|
||||
allContent = ''
|
||||
fakeToolNudgeUsed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 安全网:工具已结束、最近一次工具不是 send_message、且模型本轮也没继续动作。
|
||||
// 不再用 !allContent.trim() 做守卫——跨轮累积的旁白会让这个守卫错误地静默 break,
|
||||
// 真正可靠的信号是 sentMessage(line 691 在每个工具后维护)。
|
||||
if (mustReply && sawToolCall && !sentMessage && !finalNudgeUsed) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Tool results have returned, but you have not sent the user a final reply yet. Based on the available tool results, call send_message now to reply to the user. If information is insufficient, explain what was found, the failure source, and the limitations; do not end silently.',
|
||||
})
|
||||
finalNudgeUsed = true
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
sawToolCall = true
|
||||
|
||||
// 为没有 id 的工具调用分配 id(保证 assistant 消息与 tool 消息 id 一致)
|
||||
effectiveToolCalls.forEach((tc, i) => { if (!tc.id) tc.id = `tool_${round}_${i}` })
|
||||
|
||||
// 执行所有工具调用,收集结果。
|
||||
// 同一轮中连续的只读/查询类工具互不依赖,可以并发跑;有副作用的工具仍保持顺序。
|
||||
const toolResults = []
|
||||
let toolLoopStopReason = null
|
||||
const prepareToolCall = (tc) => {
|
||||
throwIfAborted(signal)
|
||||
let args
|
||||
try { args = JSON.parse(tc.arguments || '{}') } catch { args = {} }
|
||||
const hadEmptyArguments = !tc.arguments || tc.arguments === '{}'
|
||||
const normalizedArgs = normalizeArgs(tc.name, args)
|
||||
const fingerprint = buildToolFingerprint(tc.name, normalizedArgs)
|
||||
const stopReason = getToolLoopStopReason(toolLoopState, tc.name, fingerprint)
|
||||
return { tc, normalizedArgs, fingerprint, stopReason, hadEmptyArguments }
|
||||
}
|
||||
|
||||
const runPreparedToolCall = async ({ tc, normalizedArgs, fingerprint, stopReason, hadEmptyArguments }) => {
|
||||
console.log(`[工具调用] ${tc.name}`)
|
||||
if (hadEmptyArguments) {
|
||||
console.log(`[工具警告] ${tc.name} 参数为空`)
|
||||
}
|
||||
let result
|
||||
if (stopReason) {
|
||||
result = makeToolLoopStoppedResult(tc.name, stopReason)
|
||||
console.log(`[工具熔断] ${tc.name}: ${stopReason}`)
|
||||
// 熔断信号已经回传给模型,重置跨工具的全局连续失败计数,让 agent 有机会切换到完全不同的工具
|
||||
// (比如换 read_file 查日志、search_memory 找历史经验)。同指纹反复失败仍由 sameFailureCounts
|
||||
// 拦截,跨工具死循环仍由 recentFingerprints 的 unique threshold 拦截——安全网未失效。
|
||||
toolLoopState.consecutiveFailures = 0
|
||||
} else {
|
||||
// 真正开始执行前通知 UI —— 让用户知道当前停留在哪一步的工具上
|
||||
onToolExecute?.(tc.name, normalizedArgs)
|
||||
result = await executeTool(tc.name, normalizedArgs, { ...toolContext, signal })
|
||||
recordToolLoopOutcome(toolLoopState, tc.name, fingerprint, result)
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
// sentMessage 语义:最近一次工具动作是否就是 send_message。
|
||||
// 任何非 send_message 工具都把它清掉——意味着模型在 send_message 之后又做了新工作,
|
||||
// 那之前那次 send_message 只是过场("好,我去看看…"),还欠用户一次最终回复。
|
||||
// 这样 line ~641 的"沉默退出 nudge"才能在该补刀时正确触发。
|
||||
if (tc.name === 'send_message') sentMessage = true
|
||||
else sentMessage = false
|
||||
if (shouldPersistActionLog(tc.name)) {
|
||||
insertActionLog({
|
||||
timestamp: new Date().toISOString(),
|
||||
tool: tc.name,
|
||||
summary: summarizeToolCall(tc.name, normalizedArgs),
|
||||
detail: buildToolLogDetail(normalizedArgs, result),
|
||||
})
|
||||
}
|
||||
console.log(`[工具结果] ${tc.name}: ${result.slice(0, 100)}`)
|
||||
if (onToolCall) onToolCall(tc.name, normalizedArgs, result)
|
||||
lastToolResult = { name: tc.name, args: normalizedArgs, result }
|
||||
return { id: tc.id, name: tc.name, args: normalizedArgs, result, stopReason }
|
||||
}
|
||||
|
||||
for (let callIndex = 0; callIndex < effectiveToolCalls.length;) {
|
||||
const firstPrepared = prepareToolCall(effectiveToolCalls[callIndex])
|
||||
const canParallelize = isParallelSafeTool(firstPrepared.tc.name, firstPrepared.normalizedArgs)
|
||||
const remainingBudget = TOOL_LOOP_LIMITS.maxTotalCalls - toolLoopState.totalCalls
|
||||
|
||||
if (canParallelize && !firstPrepared.stopReason && remainingBudget > 1) {
|
||||
const preparedBatch = [firstPrepared]
|
||||
let nextIndex = callIndex + 1
|
||||
while (nextIndex < effectiveToolCalls.length && preparedBatch.length < remainingBudget) {
|
||||
const prepared = prepareToolCall(effectiveToolCalls[nextIndex])
|
||||
if (!isParallelSafeTool(prepared.tc.name, prepared.normalizedArgs)) break
|
||||
preparedBatch.push(prepared)
|
||||
nextIndex += 1
|
||||
}
|
||||
|
||||
if (preparedBatch.length > 1) {
|
||||
console.log(`[工具并行] ${preparedBatch.map(item => item.tc.name).join(', ')}`)
|
||||
const batchResults = await Promise.all(preparedBatch.map(item => runPreparedToolCall(item)))
|
||||
toolResults.push(...batchResults.map(({ id, name, result }) => ({ id, name, result })))
|
||||
const lastBatchResult = batchResults[batchResults.length - 1]
|
||||
if (lastBatchResult) {
|
||||
lastToolResult = {
|
||||
name: lastBatchResult.name,
|
||||
args: lastBatchResult.args,
|
||||
result: lastBatchResult.result,
|
||||
}
|
||||
}
|
||||
toolLoopStopReason = batchResults.find(item => item.stopReason)?.stopReason || null
|
||||
callIndex += preparedBatch.length
|
||||
} else {
|
||||
const result = await runPreparedToolCall(firstPrepared)
|
||||
toolResults.push({ id: result.id, name: result.name, result: result.result })
|
||||
toolLoopStopReason = result.stopReason
|
||||
callIndex += 1
|
||||
}
|
||||
} else {
|
||||
const result = await runPreparedToolCall(firstPrepared)
|
||||
toolResults.push({ id: result.id, name: result.name, result: result.result })
|
||||
toolLoopStopReason = result.stopReason
|
||||
callIndex += 1
|
||||
}
|
||||
|
||||
if (toolLoopStopReason) {
|
||||
for (const skipped of effectiveToolCalls.slice(callIndex)) {
|
||||
toolResults.push({
|
||||
id: skipped.id,
|
||||
name: skipped.name,
|
||||
result: makeToolLoopStoppedResult(skipped.name, `skipped because previous tool call stopped the loop: ${toolLoopStopReason}`),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
|
||||
// 将本轮 assistant 消息(含工具调用)加入对话
|
||||
// 若是 XML 解析的工具调用,assistant 消息用文本形式(避免 MiniMax 不支持 tool_calls 格式回放)
|
||||
const isXmlRound = toolCalls.length === 0 && effectiveToolCalls.length > 0
|
||||
if (isXmlRound) {
|
||||
// XML 工具调用:assistant 消息为纯文本,工具结果作为 user 消息注入
|
||||
if (content) messages.push({ role: 'assistant', content })
|
||||
const resultSummary = toolResults.map(tr =>
|
||||
`[Tool result] ${tr.name}: ${tr.result.slice(0, 300)}`
|
||||
).join('\n')
|
||||
// 同主路径:以 sentMessage(本轮最后一个动作是否是 send_message)为收尾依据,
|
||||
// 而不是只看本轮有没有出现过 send_message。
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: sentMessage
|
||||
? `Tool execution results:\n${resultSummary}\n\nMessage sent. If you still need to send additional separate messages, call send_message again now. Otherwise end this round.`
|
||||
: toolLoopStopReason
|
||||
? buildToolLoopStopNudge(toolLoopStopReason, lastToolResult)
|
||||
: `Tool execution results:\n${resultSummary}\n\nContinue completing the task. If this is a user message and the information is sufficient, call send_message to give the user a final reply. If a tool failed, explain the failure and available clues; do not end silently.`,
|
||||
})
|
||||
} else {
|
||||
const assistantMsg = {
|
||||
role: 'assistant',
|
||||
tool_calls: effectiveToolCalls.map(tc => ({
|
||||
id: tc.id,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: tc.arguments || '{}' }
|
||||
}))
|
||||
}
|
||||
if (content) assistantMsg.content = content
|
||||
if (reasoningContent) assistantMsg.reasoning_content = reasoningContent
|
||||
messages.push(assistantMsg)
|
||||
|
||||
// 将工具结果加入对话
|
||||
for (const tr of toolResults) {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: tr.id,
|
||||
content: String(tr.result)
|
||||
})
|
||||
}
|
||||
// "send_message 是不是本轮最后一个动作"才是判断"能不能收尾"的正确信号。
|
||||
// 旧逻辑只看 hasSendMessage(本轮任意位置出现过 send_message),
|
||||
// 会让 [send_message("我查一下..."), exec_command, exec_command] 这种"先说一句再去查"的链条
|
||||
// 在 exec_command 出结果后被错误地告知"可以结束了",导致模型静默退场、用户拿不到最终答复。
|
||||
if (toolLoopStopReason) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: buildToolLoopStopNudge(toolLoopStopReason, lastToolResult),
|
||||
})
|
||||
} else if (sentMessage) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Message sent. If you still need to send additional separate messages to the user, call send_message again now. Otherwise end this round.',
|
||||
})
|
||||
} else if (mustReply) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'Tool results have returned. Continue completing the user request based on the available results. If the information is sufficient, you must call send_message to send the final reply to the user. For files, directories, commands, or network requests, state only facts verified by tool results, such as ok/verified/path/bytes/exit_code/status. Do not claim completion of any action without tool evidence. If a tool failed or the data is insufficient, explain the limitation and next suggested step; do not end silently.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { content: allContent, toolResult: lastToolResult, aborted: signal?.aborted ?? false }
|
||||
}
|
||||
Reference in New Issue
Block a user