Files
easynode/server/app/ai/runtime.js
T

643 lines
23 KiB
JavaScript

/**
* Agent 运行时
*
* 消费 streamText 的 fullStream,把 AI SDK 的事件映射成 easynode 自己的
* socket 事件协议。之所以不用 AI SDK 的 HTTP data stream,是因为这里需要
* 双向通信:审批回传、中途停止、切换权限档位。
*
* 审批采用"在回调里 await"的方式而不是 SDK 的 'user-approval' 状态 ——
* 后者要求客户端按 HTTP 往返的方式续跑,在 socket 架构下反而更绕。
*/
import { streamText, stepCountIs } from 'ai'
import { createHash } from 'node:crypto'
import { resolveModel } from './provider.js'
import { buildTools } from './tools/index.js'
import { getToolSpec, requiresPlus } from './tools/spec.js'
import { checkRestrictedToolAccess } from './tools/executors.js'
import { buildSystemPrompt } from './prompt.js'
import { classifyCommand, Risk, primaryReason } from './safety.js'
import { DEFAULT_PRESET, Effect, Mode, isEffectAllowed, needsApproval, resolveEffectivePolicy } from './policy.js'
import { requestApproval } from './approval.js'
import { compactMessages, isContextLengthError } from './compaction.js'
import { resolveHostAccess, buildAllowedHostIds } from './host-access.js'
import { loadForModel } from './session-store.js'
import { writeAudit, ACTION } from './audit.js'
import { HostListDB } from '../utils/db-class.js'
import { requestTerminalDispatch } from './terminal-dispatch.js'
import { getScriptById } from '../script-library.js'
import { classifyReadPath, stricterDataRisk } from './data-policy.js'
import { buildWriteFilePreview } from './write-preview.js'
import { resolveRemotePath } from './remote-path.js'
import { isSensitiveMutationPath } from './file-mutation-policy.js'
const hostListDB = new HostListDB().getInstance()
/**
* 组装目标主机摘要。具体调用仍按目标主机自己的策略判定,多主机之间
* 不再互相收紧工具范围。
*/
async function loadHostSummaries(hostIds, sessionMode) {
const policy = resolveEffectivePolicy(sessionMode)
if (!hostIds?.length) return { hosts: [], policy }
const hosts = await hostListDB.findAsync({ _id: { $in: hostIds } })
const summaries = hosts.map((host) => {
const resolved = resolveEffectivePolicy(sessionMode, host.aiPolicy)
return {
hostId: host._id,
name: host.name,
host: host.host,
port: host.port,
username: host.username,
enabled: resolved.enabled,
mode: resolved.mode,
maxEffect: resolved.maxEffect,
clamped: resolved.clamped.mode || resolved.clamped.effect
}
})
policy.clamped = {
mode: summaries.some((item) => item.mode !== policy.mode),
effect: summaries.some((item) => item.maxEffect === Effect.READ)
}
policy.enabled = summaries.some((item) => item.enabled)
return { hosts: summaries, policy }
}
/**
* 构造 toolApproval 回调
*
* 四段判定,顺序不能变:
* 1. deny —— 硬拦截,任何档位都不放行,也不询问用户
* 2. 主机策略 —— 目标主机的操作上限
* 3. Plus —— 写入/删除在审批前即时验权
* 4. 审批 —— 自动放行或挂起等用户
*/
function createToolApproval(ctx) {
return async ({ toolCall }) => {
const spec = getToolSpec(toolCall.toolName)
if (!spec) return 'not-applicable'
const input = toolCall.input || {}
let approvalInput = input
let approvalPreview = null
let dataRisk = null
let approvedReadPath = null
let hostPolicy = ctx.policy
let hostName
if (input.hostId) {
try {
const access = await resolveHostAccess(input.hostId, ctx)
hostPolicy = access.policy
hostName = access.host.name
} catch (error) {
return denyToolCall(ctx, toolCall, input, error.message, '主机策略')
}
}
let verdict = null
let effect = spec.effect
let risk = Risk.NORMAL
let reason = null
let targets = []
let sensitiveDisclosure = false
let scriptHash = null
if (toolCall.toolName === 'run_script') {
const script = await getScriptById(input.scriptId)
if (!script?.command) {
return denyToolCall(ctx, toolCall, input, '脚本不存在或没有可执行内容,请先刷新脚本库后重试', '脚本库')
}
verdict = classifyCommand(script.command)
effect = verdict.effect
risk = verdict.risk
reason = primaryReason(verdict)
targets = verdict.targets
sensitiveDisclosure = hasSensitiveRead(verdict)
scriptHash = createHash('sha256').update(script.command).digest('hex')
approvalInput = {
...input,
scriptName: script.name,
command: script.command
}
ctx.toolMeta[toolCall.toolCallId] = { scriptName: script.name }
}
if (toolCall.toolName === 'read_file') {
const realPath = await resolveRemotePath(input.hostId, input.path)
dataRisk = stricterDataRisk(classifyReadPath(input.path), classifyReadPath(realPath))
approvedReadPath = realPath
effect = Effect.READ
risk = dataRisk.risk
targets = [realPath]
if (risk === Risk.HIGH) {
reason = dataRisk
sensitiveDisclosure = true
approvalPreview = { type: 'read_file', path: input.path, realPath, sensitiveDisclosure: true }
}
}
const isTerminalCommand = ctx.scope === 'terminal' && toolCall.toolName === 'terminal_command'
if ((toolCall.toolName === 'exec_command' || isTerminalCommand) && input.command) {
verdict = classifyCommand(
input.cwd ? `cd ${ input.cwd } && ${ input.command }` : String(input.command)
)
effect = verdict.effect
risk = verdict.risk
reason = primaryReason(verdict)
targets = verdict.targets
sensitiveDisclosure = hasSensitiveRead(verdict)
}
ctx.toolMeta[toolCall.toolCallId] = {
...(ctx.toolMeta[toolCall.toolCallId] || {}),
effect,
risk,
targets,
riskReason: reason?.reason,
riskCategory: reason?.category,
sensitiveDisclosure: sensitiveDisclosure || undefined
}
if (risk === Risk.DENY) {
writeAudit({
action: ACTION.DENIED,
sessionId: ctx.sessionId,
userId: ctx.userId,
hostId: input.hostId,
hostName,
tool: toolCall.toolName,
command: input.command || approvalInput.command,
risk,
reason: reason?.reason
})
ctx.toolMeta[toolCall.toolCallId].denied = true
ctx.emit({ type: 'tool_denied', toolCallId: toolCall.toolCallId, tool: toolCall.toolName,
reason: reason?.reason, category: reason?.category, command: input.command || approvalInput.command,
permanent: true })
return {
type: 'denied',
reason: `该命令被安全策略永久拒绝:${ reason?.reason || '命中拒绝规则' }。不要改写或拆分绕过;可以把原始命令展示给用户,由用户自行决定是否在终端执行。`
}
}
if (!isEffectAllowed(effect, hostPolicy.maxEffect)) {
return denyToolCall(ctx, toolCall, input,
`主机「${ hostName || input.hostId }」仅允许 AI 读取,不能执行${ effect === Effect.DELETE ? '删除' : '写入' }操作`,
'主机策略')
}
// Plus 是工具执行权限,不是会话启动时的能力快照。写操作在审批前
// 读取当前授权,避免让用户确认一个必然无法执行的操作。
if (requiresPlus(spec, effect)) {
const access = await checkRestrictedToolAccess(
ctx,
toolCall.toolName,
effect,
toolCall.toolCallId
)
if (!access.ok) {
ctx.toolMeta[toolCall.toolCallId] = {
...ctx.toolMeta[toolCall.toolCallId],
denied: true,
riskCategory: 'Plus 权限',
riskReason: access.error
}
return { type: 'denied', reason: access.error }
}
}
// write_file 的远端预览会建立 SFTP 连接,必须放在 Plus 校验之后。
if (toolCall.toolName === 'write_file') {
try {
approvalPreview = await buildWriteFilePreview(input.hostId, input)
approvalInput = { ...input }
delete approvalInput.content
targets = [approvalPreview.realPath]
if (isSensitiveMutationPath(approvalPreview.realPath)) {
risk = Risk.HIGH
reason = {
level: Risk.HIGH,
category: '敏感路径变更',
reason: '目标位于系统配置、账号或业务数据路径,需要单次确认'
}
}
ctx.toolMeta[toolCall.toolCallId] = {
...ctx.toolMeta[toolCall.toolCallId],
effect,
risk,
targets,
riskReason: reason?.reason,
riskCategory: reason?.category
}
} catch (error) {
return denyToolCall(ctx, toolCall, input, `无法准备文件写入:${ error.message }`, '文件写入')
}
}
const shouldApprove = needsApproval({
mode: hostPolicy.mode,
effect,
risk,
hostOperation: Boolean(input.hostId)
})
if (!shouldApprove) {
authorizePreparedCall(ctx, toolCall.toolCallId, {
approvalPreview,
approvedReadPath,
sensitiveDisclosure,
scriptHash
})
return 'not-applicable'
}
const result = await requestApproval({
sessionId: ctx.sessionId,
toolName: toolCall.toolName,
toolCallId: toolCall.toolCallId,
input: approvalInput,
mode: hostPolicy.mode,
effect,
targets,
riskLevel: risk,
risk: reason,
hostName: hostName || ctx.hosts.find((item) => item.hostId === input.hostId)?.name,
preview: approvalPreview,
sensitiveDisclosure,
grantable: hostPolicy.mode === Mode.ASSIST && effect === Effect.WRITE
&& risk === Risk.NORMAL && !isTerminalCommand,
emit: ctx.emit,
signal: ctx.signal
})
ctx.toolMeta[toolCall.toolCallId] = {
...ctx.toolMeta[toolCall.toolCallId],
approved: result.approved,
approvalScope: result.scope,
approvalCached: result.cached || undefined
}
if (result.approved) {
authorizePreparedCall(ctx, toolCall.toolCallId, {
approvalPreview,
approvedReadPath,
sensitiveDisclosure,
scriptHash
})
return { type: 'approved' }
}
return { type: 'denied', reason: result.reason || '用户拒绝了该操作' }
}
}
function hasSensitiveRead(verdict) {
return verdict?.hits.some((hit) => (
hit.id.endsWith('read-blocked-credentials') || hit.id.endsWith('read-credentials')
)) || false
}
function authorizePreparedCall(ctx, toolCallId, prepared) {
if (prepared.approvalPreview?.type === 'write_file') {
ctx.authorizedWrites.set(toolCallId, prepared.approvalPreview.snapshotHash)
}
if (prepared.approvedReadPath) ctx.approvedReads.set(toolCallId, prepared.approvedReadPath)
if (prepared.sensitiveDisclosure) ctx.sensitiveOutputs.add(toolCallId)
if (prepared.scriptHash) ctx.authorizedScripts.set(toolCallId, prepared.scriptHash)
}
function denyToolCall(ctx, toolCall, input, reason, category) {
ctx.toolMeta[toolCall.toolCallId] = {
...(ctx.toolMeta[toolCall.toolCallId] || {}),
denied: true,
riskReason: reason,
riskCategory: category
}
ctx.emit({ type: 'tool_denied', toolCallId: toolCall.toolCallId, tool: toolCall.toolName,
reason, category, command: input.command, permanent: false })
return { type: 'denied', reason }
}
/**
* 跑一个 turn。
*
* @param {object} params
* @param {string} params.sessionId
* @param {string} [params.userId]
* @param {Array} params.messages ModelMessage[]
* @param {string} [params.modelId]
* @param {string} [params.permission] 会话模式
* @param {string[]} [params.hostIds] 目标主机
* @param {AbortSignal} params.signal
* @param {(event: object) => void} params.emit 推送事件给前端
* @returns {Promise<{ finishReason: string, usage: object, text: string }>}
*/
export async function runTurn(params) {
const { sessionId, userId, userMessage, signal, emit } = params
const sessionMode = params.scope === 'terminal'
? (params.terminalPermission || DEFAULT_PRESET)
: (params.permission || DEFAULT_PRESET)
const { hosts, policy } = await loadHostSummaries(params.hostIds, sessionMode)
if (params.scope === 'terminal' && hosts.length !== 1) {
throw new Error('终端 AI 必须绑定一个有效的目标主机')
}
if (!policy.enabled) {
throw new Error('目标主机已被禁止 AI 操作,请在主机设置中开启后重试')
}
const { model, modelId, contextLimit, maxSteps } = await resolveModel({ modelId: params.modelId })
// 显式传入的优先,其次是 AI 配置里的,最后落到 compaction 的默认值
const effectiveContextLimit = params.contextLimit || contextLimit
// 工具调用的展示用信息(风险判定、审批结果、耗时),随会话一起落盘,
// 刷新页面后卡片才能还原成"已批准 / 被拦截"而不是一片空白
const toolMeta = {}
const ctx = {
sessionId,
userId,
policy,
sessionMode,
scope: params.scope || 'ops',
terminalHostId: params.terminalHostId,
terminalPermission: params.terminalPermission || DEFAULT_PRESET,
allowedHostIds: buildAllowedHostIds(params.hostIds),
hosts,
toolMeta,
authorizedWrites: new Map(),
authorizedScripts: new Map(),
approvedReads: new Map(),
sensitiveOutputs: new Set(),
signal,
emit,
requestTerminalDispatch: params.scope === 'terminal'
? async (input) => requestTerminalDispatch({
sessionId,
hostId: params.terminalHostId,
command: input.command,
explanation: input.explanation,
toolCallId: input.toolCallId,
emit,
signal
})
: null,
onToolEvent: (event) => {
if (event.toolCallId) {
toolMeta[event.toolCallId] = {
...toolMeta[event.toolCallId],
tool: event.tool,
durationMs: event.durationMs,
failed: event.phase === 'error' || undefined
}
}
emit({ type: 'tool_progress', ...event })
}
}
const tools = buildTools(ctx)
emit({
type: 'turn_start',
sessionId,
modelId,
policy: { mode: policy.mode, maxEffect: policy.maxEffect, preset: policy.preset },
clamped: policy.clamped,
availableTools: Object.keys(tools),
scope: ctx.scope,
terminalPermission: ctx.scope === 'terminal' ? ctx.terminalPermission : undefined
})
const system = buildSystemPrompt(ctx)
// 历史读取放在这里而不是 socket 层:主动压缩需要模型实例,而模型是这里
// 解析的。放到调用方会导致 resolveModel 被解析两次、两处配置还可能不一致。
// 主动压缩的落库在 session-store 内部完成(只有那里有稳定的消息下标)。
const history = await loadForModel(sessionId, {
model,
contextLimit: effectiveContextLimit,
signal
})
if (history.repaired) {
emit({ type: 'history_repaired', count: history.repaired })
}
if (history.compactedNow) {
emitCompaction(emit, history.compactedNow)
}
// 应急压缩:撞上厂商的上下文上限后强制压一次重试。这条不落库,
// 因为 token 估算不准是常态,没必要为一次意外改写会话。
const turnMessage = params.terminalContext
? withTerminalContext(userMessage, params.terminalContext, hosts[0])
: userMessage
let working = [...history.messages, turnMessage]
let attempt = 0
for (;;) {
try {
return await streamOnce({ model, system, messages: working, tools, ctx, maxSteps, toolMeta, emit, signal })
} catch (error) {
const retryable = attempt === 0 && !signal?.aborted && isContextLengthError(error)
if (!retryable) {
// streamOnce 在已有输出时会自行上报,避免重复推送 error 事件
if (!error.alreadyReported) {
logger.error(`[ai-runtime] turn 执行失败: ${ error.message }`)
emit({ type: 'error', message: error.message })
}
// 已经执行过工具的话,把这部分结果返回而不是抛出 ——
// 调用方据此落盘,历史才不会缺一块
if (error.partialResult) return { ...error.partialResult, error: error.message }
throw error
}
attempt += 1
logger.warn('[ai-runtime] 上下文超限,强制压缩后重试')
const forced = await compactMessages({
messages: working,
model,
contextLimit: effectiveContextLimit,
force: true,
keepRecentTurns: 1,
signal
})
if (!forced.compacted) {
// 已经压无可压,说明单轮内容本身就超限,如实报错
emit({ type: 'error', message: `上下文超出模型限制,且已无可压缩的历史:${ error.message }` })
throw error
}
working = forced.messages
emitCompaction(emit, forced)
}
}
}
/**
* 终端画面不作为模型记忆。真正可审计的命令输出由 terminal_command 的
* tool result 进入会话历史;这里仅标记当前用户正在使用哪一个 Web 终端。
*/
function withTerminalContext(userMessage, terminalContext, host) {
const output = String(terminalContext.output || '').trim()
const capturedAt = terminalContext.capturedAt ? new Date(terminalContext.capturedAt).toLocaleString('zh-CN') : '刚刚'
const text = [
typeof userMessage.content === 'string' ? userMessage.content : '',
'',
`[当前 Web 终端:${ host?.name || terminalContext.hostName || '未知主机' },连接状态正常,时间 ${ capturedAt }]`,
output ? `[用户显式附带的终端文本]\n${ output }` : ''
].join('\n')
return { ...userMessage, content: text }
}
function emitCompaction(emit, result) {
emit({
type: 'compacted',
droppedCount: result.droppedCount,
beforeTokens: result.beforeTokens,
afterTokens: result.afterTokens,
degraded: result.degraded || false,
reason: result.reason
})
}
/**
* 跑一次流式请求并把事件映射出去。
*
* 抽成独立函数是为了让上下文超限重试能整体重跑一次,而不必在事件循环里
* 判断"这次是不是重试"。
*/
async function streamOnce({ model, system, messages, tools, ctx, maxSteps, toolMeta, emit, signal }) {
let accumulated = ''
let produced = false
let awaitingModelAfterTool = null
const result = streamText({
model,
system,
messages,
tools,
toolApproval: createToolApproval(ctx),
stopWhen: stepCountIs(maxSteps),
abortSignal: signal
})
// 只有真正推给用户的内容才算"已产出"。start / finish-step 这类
// 控制片段不算,否则超限错误会因为收到过一个空片段而失去重试机会。
const VISIBLE_PARTS = new Set(['text-delta', 'reasoning-delta', 'tool-call', 'tool-result', 'tool-error'])
try {
for await (const part of result.fullStream) {
// finish-step 仅代表这一轮工具步骤收尾,随后 SDK 还会把工具结果
// 回传给模型并等待下一轮输出;不能在这里结束“分析工具结果”状态。
if (awaitingModelAfterTool && ['text-delta', 'reasoning-delta', 'tool-call'].includes(part.type)) {
const durationMs = Date.now() - awaitingModelAfterTool.startedAt
console.info('[ai-agent] 模型已处理工具结果', {
sessionId: ctx.sessionId,
toolCallId: awaitingModelAfterTool.toolCallId,
durationMs,
nextEvent: part.type
})
emit({ type: 'model_resumed', toolCallId: awaitingModelAfterTool.toolCallId, durationMs })
awaitingModelAfterTool = null
}
// 一旦有内容产出就不能再重试,否则前端会看到两遍
if (VISIBLE_PARTS.has(part.type)) produced = true
switch (part.type) {
case 'text-delta':
accumulated += part.text
emit({ type: 'text_delta', text: part.text })
break
case 'reasoning-delta':
emit({ type: 'reasoning_delta', text: part.text })
break
case 'tool-call':
emit({
type: 'tool_call',
toolCallId: part.toolCallId,
tool: part.toolName,
input: part.input
})
break
case 'tool-result':
emit({
type: 'tool_result',
toolCallId: part.toolCallId,
tool: part.toolName,
output: part.output
})
awaitingModelAfterTool = { toolCallId: part.toolCallId, startedAt: Date.now() }
emit({ type: 'awaiting_model', toolCallId: part.toolCallId })
break
case 'tool-error':
emit({
type: 'tool_result',
toolCallId: part.toolCallId,
tool: part.toolName,
error: String(part.error?.message || part.error)
})
awaitingModelAfterTool = { toolCallId: part.toolCallId, startedAt: Date.now() }
emit({ type: 'awaiting_model', toolCallId: part.toolCallId })
break
case 'finish-step':
emit({ type: 'step_finish', usage: part.usage, finishReason: part.finishReason })
break
case 'error':
// 流内的 error 片段不等于整轮失败(后续 step 可能仍会产出),
// 用独立类型上报,避免前端据此把工具卡片全标成失败
emit({ type: 'stream_error', message: String(part.error?.message || part.error) })
break
default:
// 其余事件(start / finish / source / file 等)暂不透传
break
}
}
const [finishReason, usage, responseMessages] = await Promise.all([
result.finishReason,
result.totalUsage,
result.responseMessages
])
emit({ type: 'finish', finishReason, usage, text: accumulated })
return { finishReason, usage, text: accumulated, responseMessages, toolMeta }
} catch (error) {
// 中断或失败时都要把已产生的消息交出去。丢掉的话,历史里会留下
// "有 tool-call 没有 tool-result"的残缺记录,更糟的是模型已经真的
// 在主机上执行过命令,却没有任何记录
const partial = await collectPartialMessages(result)
if (signal?.aborted) {
emit({ type: 'aborted', text: accumulated })
return { finishReason: 'abort', usage: null, text: accumulated, responseMessages: partial, toolMeta }
}
// 还没产出任何内容时把错误原样抛出,交给上层判断能否压缩重试;
// 已经产出过就不能重试(会重复输出),把部分结果带上一起抛,
// 让 socket 层仍然能把这一轮落盘
if (!produced) throw error
logger.error(`[ai-runtime] turn 执行失败: ${ error.message }`)
emit({ type: 'error', message: error.message })
throw Object.assign(error, {
alreadyReported: true,
partialResult: { finishReason: 'error', usage: null, text: accumulated, responseMessages: partial, toolMeta }
})
}
}
/** 尽力取出流已产生的消息,取不到就返回空数组,不能因此再抛错 */
async function collectPartialMessages(result) {
try {
return await result.responseMessages
} catch {
return []
}
}