Files
BaiLongma/src/llm.js
xiaoyuanda666-ship-it b5dd8632c4 🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级:
- 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环
- evo_loop 后台进程,无需手动触发
- consciousness 意识持久化
- ACUI 卡片组件系统
- MCP 工具生态扩展至50+工具
- 技能体系重构,4个活跃技能
- 身份升级为自由体
2026-05-24 22:06:37 +08:00

844 lines
35 KiB
JavaScript
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.
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
// 真正可靠的信号是 sentMessageline 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 }
}