chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import type { ChatEvent } from '@shared/types'
|
||||
import type { AgentMode, PermissionMode, PlanContent, QuestionItem, QuestionOption } from '@shared/conversation'
|
||||
import type { CapabilityToolSnapshot } from '../capabilities/runtime'
|
||||
import { agentMode, modeToolDenial } from './mode-policy'
|
||||
import type { InteractionBroker } from '../interaction-broker'
|
||||
import { executeTool, type ToolResult } from './tools'
|
||||
|
||||
export interface ConversationToolContext {
|
||||
runId: string
|
||||
toolCallId: string
|
||||
workspaceRoot: string
|
||||
permissionMode?: PermissionMode
|
||||
agentMode?: AgentMode
|
||||
signal: AbortSignal
|
||||
interactions?: InteractionBroker
|
||||
emit(event: ChatEvent): void
|
||||
}
|
||||
export interface ConversationToolResult extends ToolResult { metadata?: Record<string, unknown> }
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
}
|
||||
export function parseQuestions(value: unknown): QuestionItem[] {
|
||||
if (!Array.isArray(value) || !value.length) throw new Error('提问内容为空')
|
||||
return value.map((item, index) => {
|
||||
const question = record(item)
|
||||
if (typeof question.question !== 'string' || !question.question.trim()) throw new Error('问题内容无效')
|
||||
const options: Array<string | QuestionOption> = (Array.isArray(question.options) ? question.options : []).map((option) => {
|
||||
if (typeof option === 'string') return option
|
||||
const value = record(option)
|
||||
return Object.fromEntries(Object.entries(value).filter(([key, content]) => ['id', 'option', 'question', 'description', 'hint'].includes(key) && typeof content === 'string'))
|
||||
})
|
||||
return { id: String(question.id || `question-${index}`), question: question.question, options, multiSelect: question.multiSelect === true }
|
||||
})
|
||||
}
|
||||
|
||||
/** The native kernel and compatible gateway share the same host execution and interaction path. */
|
||||
export async function executeConversationTool(name: string, args: Record<string, unknown>, ctx: ConversationToolContext): Promise<ConversationToolResult> {
|
||||
if (ctx.signal.aborted) throw Object.assign(new Error('回合已取消'), { name: 'AbortError' })
|
||||
const denial = modeToolDenial(agentMode(ctx.agentMode), name)
|
||||
if (denial) return denial
|
||||
if (['ask_user_question', 'ask_followup_question', 'askfollowupquestion', 'ask-followup-question'].includes(name) && ctx.interactions) {
|
||||
const response = await ctx.interactions.question(ctx.toolCallId, String(args.title || '请回答以下问题'), parseQuestions(args.questions ?? [{ question: args.question, options: args.follow_up ?? args.options ?? [] }]))
|
||||
return { ok: true, content: response.skipped ? '用户跳过了问题' : JSON.stringify(response.answer), data: response.answer,
|
||||
metadata: { questionAnswer: response.answer, questionSkipped: response.skipped } }
|
||||
}
|
||||
if (name === 'todo_write' || name === 'create_plan') {
|
||||
const source = Array.isArray(args.todos) ? args.todos : Array.isArray(args.steps) ? args.steps : []
|
||||
const plan: PlanContent = { id: String(args.planId || 'current-plan'), title: String(args.title || '任务计划'), steps: source.map((item, index) => {
|
||||
const value = record(item)
|
||||
const rawStatus = String(value.status || 'pending')
|
||||
const status = ['pending', 'in_progress', 'completed', 'cancelled'].includes(rawStatus) ? rawStatus as PlanContent['steps'][number]['status'] : 'pending'
|
||||
return { id: String(value.id || index), text: typeof item === 'string' ? item : String(value.content || value.text || value.title || ''), status }
|
||||
}) }
|
||||
return { ok: true, content: JSON.stringify(plan), data: plan }
|
||||
}
|
||||
return executeTool(name, args, { workspaceRoot: ctx.workspaceRoot, signal: ctx.signal, permissionMode: ctx.permissionMode,
|
||||
approve: ctx.interactions ? async (title) => {
|
||||
const response = await ctx.interactions!.approval(ctx.toolCallId, title, [
|
||||
{ optionId: 'allow-once', name: '允许一次', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', name: '拒绝', kind: 'reject_once' }
|
||||
])
|
||||
return response.optionId === 'allow-once'
|
||||
} : undefined })
|
||||
}
|
||||
|
||||
/** One mode gate before both built-in and dynamic tools; hidden names are still rejected. */
|
||||
export async function executeTurnTool(name: string, args: Record<string, unknown>, ctx: ConversationToolContext, capabilities?: CapabilityToolSnapshot): Promise<ConversationToolResult> {
|
||||
if (ctx.signal.aborted) throw Object.assign(new Error('回合已取消'), { name: 'AbortError' })
|
||||
const denial = modeToolDenial(agentMode(ctx.agentMode), name)
|
||||
if (denial) return denial
|
||||
return await capabilities?.execute(name, args, ctx) ?? await executeConversationTool(name, args, ctx)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ChatMessage, ToolCallRecord } from '@shared/types'
|
||||
import { getMessageParts } from '@shared/message-parts'
|
||||
import type { WireMessage } from '../gateway'
|
||||
|
||||
/** Preserve ordered assistant/tool exchanges when using a stateless compatible gateway. */
|
||||
export function assistantWireHistory(message: ChatMessage): WireMessage[] {
|
||||
const messages: WireMessage[] = []
|
||||
const parts = getMessageParts(message)
|
||||
let text = ''
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
const part = parts[index]
|
||||
if (part.type === 'text') { text += part.text; continue }
|
||||
if (part.type !== 'tool') continue
|
||||
const tools: ToolCallRecord[] = []
|
||||
while (index < parts.length && parts[index].type === 'tool') {
|
||||
const candidate = parts[index] as { type: 'tool'; toolId: string }
|
||||
const tool = message.tools?.find((tool) => tool.id === candidate.toolId)
|
||||
if (tool) tools.push(tool)
|
||||
index++
|
||||
}
|
||||
index--
|
||||
if (!tools.length) continue
|
||||
messages.push({ role: 'assistant', content: text || null, tool_calls: tools.map((tool) => ({ id: tool.id, type: 'function', function: { name: tool.name, arguments: tool.argsRaw || '{}' } })) })
|
||||
text = ''
|
||||
for (const tool of tools) messages.push({ role: 'tool', tool_call_id: tool.id, content: tool.status === 'ok' ? tool.output ?? tool.summary ?? JSON.stringify(tool.result ?? '') : `ERROR (${tool.status}): ${tool.output ?? tool.summary ?? '工具未完成'}` })
|
||||
}
|
||||
if (text) messages.push({ role: 'assistant', content: text })
|
||||
return messages
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Agent 循环(最小闭环)。
|
||||
*
|
||||
* 一次「回合」= 用户发一条消息,到助手给出终态为止:
|
||||
* 模型流式输出 → 若产生工具调用则执行并回填 → 再让模型继续 → 直到无工具调用
|
||||
*
|
||||
* 分层约定:
|
||||
* - loop 只负责编排(拼消息、跑工具、发事件),不关心 UI 与持久化细节
|
||||
* - 所有对外输出都通过 emit(ChatEvent),渲染进程据此增量渲染
|
||||
*/
|
||||
|
||||
import type { ChatEvent, ChatMessage, ToolCallRecord } from '@shared/types'
|
||||
import { Gateway, GatewayError, type WireMessage } from '../gateway'
|
||||
import { toolSpecs } from './tools'
|
||||
import { executeTurnTool } from './conversation-tools'
|
||||
import type { InteractionBroker } from '../interaction-broker'
|
||||
import type { AgentMode, PermissionMode } from '@shared/conversation'
|
||||
import { agentMode, agentModeInstructions, modeToolSpecs } from './mode-policy'
|
||||
import type { CapabilityToolSnapshot } from '../capabilities/runtime'
|
||||
|
||||
/** 回填给模型的工具结果上限,避免撑爆上下文 */
|
||||
const TOOL_RESULT_LIMIT = 8000
|
||||
/** UI 展示用的摘要长度 */
|
||||
const TOOL_SUMMARY_LIMIT = 600
|
||||
|
||||
export interface RunTurnOptions {
|
||||
capabilityTools?: CapabilityToolSnapshot
|
||||
supportsToolCall?: boolean
|
||||
interactions?: InteractionBroker
|
||||
permissionMode?: PermissionMode
|
||||
agentMode?: AgentMode
|
||||
images?: Array<{ type: 'image'; data: string; mimeType: string }>
|
||||
wireHistory?: WireMessage[]
|
||||
runId: string
|
||||
/** 本回合之前的历史(不含本次用户消息) */
|
||||
history: ChatMessage[]
|
||||
userText: string
|
||||
signal: AbortSignal
|
||||
emit: (event: ChatEvent) => void
|
||||
|
||||
// ---- 依赖注入:让 loop 不依赖 electron,可被离线测试直接驱动 ----
|
||||
gateway: Gateway
|
||||
model: string
|
||||
maxToolRounds: number
|
||||
workspaceRoot: string
|
||||
}
|
||||
|
||||
export interface TurnResult {
|
||||
content: string
|
||||
tools: ToolCallRecord[]
|
||||
}
|
||||
|
||||
function systemPrompt(workspaceRoot: string, model: string): string {
|
||||
return [
|
||||
'你是 YCode,一个运行在用户本机的 AI 助手。',
|
||||
'',
|
||||
'## 工作方式',
|
||||
'- 需要了解文件内容或目录结构时,主动调用工具,不要凭空猜测。',
|
||||
'- 工具只能访问用户的工作目录内部,越界请求会被拒绝。',
|
||||
'- 任务完成后用简洁的中文回答,必要时用 Markdown。',
|
||||
'',
|
||||
'## 环境',
|
||||
`- 工作目录:${workspaceRoot}`,
|
||||
`- 当前模型:${model}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function runTurn(options: RunTurnOptions): Promise<TurnResult> {
|
||||
const mode = agentMode(options.agentMode)
|
||||
const { runId, history, userText, signal, emit, gateway, model, maxToolRounds, workspaceRoot } =
|
||||
options
|
||||
|
||||
const messages: WireMessage[] = [
|
||||
{ role: 'system', content: [systemPrompt(workspaceRoot, model), agentModeInstructions(mode)].filter(Boolean).join('\n\n') },
|
||||
...(options.wireHistory ?? history
|
||||
.filter((m) => m.role === 'user' || (m.role === 'assistant' && m.content))
|
||||
.map<WireMessage>((m) => ({ role: m.role, content: m.content }))),
|
||||
{ role: 'user', content: options.images?.length ? [{ type: 'text', text: userText }, ...options.images.map((image) => ({ type: 'image_url' as const, image_url: { url: `data:${image.mimeType};base64,${image.data}` } }))] : userText }
|
||||
]
|
||||
|
||||
const tools: ToolCallRecord[] = []
|
||||
let fullText = ''
|
||||
let inFlightTool: ToolCallRecord | null = null
|
||||
let inFlightStartedAt: number | undefined
|
||||
|
||||
try {
|
||||
for (let round = 0; round < maxToolRounds; round++) {
|
||||
let roundText = ''
|
||||
let reasoning = ''
|
||||
const pendingCalls: { id: string; name: string; argsRaw: string }[] = []
|
||||
|
||||
for await (const delta of gateway.stream({ messages, tools: options.supportsToolCall === false ? undefined : modeToolSpecs([...toolSpecs(), ...(options.capabilityTools?.specs ?? [])], mode), signal })) {
|
||||
if (delta.type === 'text') {
|
||||
roundText += delta.text
|
||||
fullText += delta.text
|
||||
emit({ runId, type: 'text', delta: delta.text })
|
||||
} else if (delta.type === 'reasoning') {
|
||||
reasoning += delta.text
|
||||
emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning } })
|
||||
} else if (delta.type === 'tool_calls') {
|
||||
for (const call of delta.calls) {
|
||||
pendingCalls.push({ id: call.id, name: call.function.name, argsRaw: call.function.arguments })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (reasoning) emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning, finished: true } })
|
||||
|
||||
if (pendingCalls.length === 0) {
|
||||
// 没有工具调用 → 本回合结束
|
||||
break
|
||||
}
|
||||
|
||||
// 助手这一轮的消息(含工具调用)要回填进上下文
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: roundText || null,
|
||||
tool_calls: pendingCalls.map((c) => ({
|
||||
id: c.id,
|
||||
type: 'function' as const,
|
||||
function: { name: c.name, arguments: c.argsRaw }
|
||||
}))
|
||||
})
|
||||
|
||||
for (const call of pendingCalls) {
|
||||
const record: ToolCallRecord = {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
args: safeParse(call.argsRaw),
|
||||
argsRaw: call.argsRaw,
|
||||
status: 'running'
|
||||
}
|
||||
tools.push(record)
|
||||
record.metadata = options.capabilityTools?.metadata?.(record.name)
|
||||
emit({ runId, type: 'tool_call', call: { ...record } })
|
||||
|
||||
const startedAt = Date.now()
|
||||
inFlightTool = record
|
||||
inFlightStartedAt = startedAt
|
||||
const args = asRecord(record.args)
|
||||
const ctx = { runId, toolCallId: call.id,
|
||||
workspaceRoot, signal, interactions: options.interactions, permissionMode: options.permissionMode, agentMode: mode, emit }
|
||||
const result = await executeTurnTool(call.name, args, ctx, options.capabilityTools)
|
||||
record.metadata = result.metadata ?? record.metadata
|
||||
record.result = result.data ?? { success: result.ok, content: result.content }
|
||||
record.status = signal.aborted ? 'cancelled' : result.ok ? 'ok' : 'error'
|
||||
record.durationMs = elapsedSince(startedAt)
|
||||
record.output = result.content
|
||||
record.summary = truncate(result.content, TOOL_SUMMARY_LIMIT)
|
||||
emit({ runId, type: 'tool_result', call: { ...record } })
|
||||
inFlightTool = null
|
||||
inFlightStartedAt = undefined
|
||||
|
||||
if (signal.aborted) {
|
||||
const error = new Error('回合已取消')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: call.id,
|
||||
content: result.ok
|
||||
? truncate(result.content, TOOL_RESULT_LIMIT)
|
||||
: `ERROR: ${result.content}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emit({ runId, type: 'done', content: fullText, tools })
|
||||
return { content: fullText, tools }
|
||||
} catch (err) {
|
||||
if (inFlightTool?.status === 'running') {
|
||||
const cancelled = (err as Error).name === 'AbortError' || signal.aborted
|
||||
inFlightTool.status = cancelled ? 'cancelled' : 'error'
|
||||
if (inFlightStartedAt !== undefined) {
|
||||
inFlightTool.durationMs = elapsedSince(inFlightStartedAt)
|
||||
}
|
||||
emit({ runId, type: 'tool_result', call: { ...inFlightTool } })
|
||||
inFlightTool = null
|
||||
inFlightStartedAt = undefined
|
||||
}
|
||||
if ((err as Error).name === 'AbortError' || signal.aborted) {
|
||||
emit({ runId, type: 'cancelled' })
|
||||
throw err
|
||||
}
|
||||
const message =
|
||||
err instanceof GatewayError
|
||||
? [err.message, err.body].filter(Boolean).join('\n')
|
||||
: (err as Error).message
|
||||
emit({ runId, type: 'error', message })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function safeParse(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function truncate(text: string, limit: number): string {
|
||||
return text.length <= limit ? text : `${text.slice(0, limit)}\n…(已截断,共 ${text.length} 字符)`
|
||||
}
|
||||
|
||||
function elapsedSince(startedAt: number): number {
|
||||
return Math.max(0, Date.now() - startedAt)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AgentMode } from '@shared/conversation'
|
||||
import type { ToolSpec } from '@shared/types'
|
||||
import type { SessionContext } from '@shared/workspaces'
|
||||
|
||||
const THINKING_LEVELS = new Set(['off', 'low', 'medium', 'high', 'xhigh', 'max'])
|
||||
/** Accepts only levels this host persists; OMP's `auto`/`minimal` are not offered by the selector. */
|
||||
export function thinkingLevel(value: unknown): import('@shared/workspaces').ThinkingLevel | undefined {
|
||||
if (value === undefined || value === null) return undefined
|
||||
if (typeof value === 'string' && THINKING_LEVELS.has(value)) return value as import('@shared/workspaces').ThinkingLevel
|
||||
throw new Error('无效的思考强度')
|
||||
}
|
||||
|
||||
/** Host adapter for the source craft/plan/ask UI contract; not an OMP engine mode. */
|
||||
export function agentMode(value: unknown): AgentMode {
|
||||
if (value === undefined) return 'craft'
|
||||
if (value === 'craft' || value === 'plan' || value === 'ask') return value
|
||||
throw new Error('无效的对话模式,仅支持 craft、plan、ask')
|
||||
}
|
||||
|
||||
/** Source ConfigCapability clears expert in plan/ask; selecting an expert restores craft. */
|
||||
export function agentModeContextPatch(previous: SessionContext, patch: SessionContext): SessionContext {
|
||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('会话配置无效')
|
||||
if (patch.expertId !== undefined && patch.expertId !== null && (typeof patch.expertId !== 'string' || !patch.expertId.trim())) throw new Error('专家标识无效')
|
||||
const mode = patch.agentMode === undefined ? agentMode(previous.agentMode) : agentMode(patch.agentMode)
|
||||
const next = { ...patch, agentMode: mode }
|
||||
if (patch.expertId) next.agentMode = 'craft'
|
||||
else if (patch.agentMode !== undefined && mode !== 'craft') next.expertId = null
|
||||
return next
|
||||
}
|
||||
|
||||
const READ_TOOLS = new Set(['list_dir', 'read_file', 'ask_user_question', 'ask_followup_question',
|
||||
'askfollowupquestion', 'ask-followup-question', 'ycode_load_skill', 'ycode_read_skill_resource', 'ycode_expert_resource'])
|
||||
const PLAN_TOOLS = new Set(['todo_write', 'create_plan'])
|
||||
export function modeAllowsTool(mode: AgentMode, name: string): boolean {
|
||||
return mode === 'craft' || READ_TOOLS.has(name) || mode === 'plan' && PLAN_TOOLS.has(name)
|
||||
}
|
||||
export function modeToolSpecs(specs: ToolSpec[], mode: AgentMode): ToolSpec[] {
|
||||
return mode === 'craft' ? specs : specs.filter(spec => modeAllowsTool(mode, spec.function.name))
|
||||
}
|
||||
export function modeToolDenial(mode: AgentMode, name: string): { ok: false; content: string } | undefined {
|
||||
if (modeAllowsTool(mode, name)) return undefined
|
||||
return { ok: false, content: `当前 ${mode} 模式不允许工具 ${name}。此模式只允许读取和提问${mode === 'plan' ? ',以及维护会话计划' : ''};写文件、命令执行、连接器和专家包修改需切换到 craft。` }
|
||||
}
|
||||
export function agentModeInstructions(mode: AgentMode): string {
|
||||
if (mode === 'craft') return '<ycode-agent-mode mode="craft">当前会话为 craft 执行模式;此前回合的 plan/ask 只读限制不再决定本回合模式。可按用户任务使用当前已提供的工具,仍须遵守本地权限和审批;此模式不自动授予完全访问。</ycode-agent-mode>'
|
||||
return `<ycode-agent-mode mode="${mode}">当前会话为 ${mode === 'plan' ? '规划' : '问答'}模式。${mode === 'plan'
|
||||
? '先读取资料、澄清需求并给出可执行计划,可以维护会话中的 todo/plan;不要实际实施代码修改。'
|
||||
: '只读取资料、解释问题并按需向用户提问;不要修改文件或维护执行计划。'}宿主会拒绝文件写入、shell、MCP 和专家包修改,即使用户开启完全访问也不放行;需要实施时请用户先切换 craft。该限制是 YCode 本地工具策略,不等同于 WorkBuddy 或 OMP 的全部原生模式行为;文件读取范围仍受当前本地权限配置约束。</ycode-agent-mode>`
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* 以 OMP 为内核运行一次对话回合。
|
||||
*
|
||||
* 与 `loop.ts` 的 `runTurn` 输出同一套 ChatEvent,渲染进程无需感知内核差异。
|
||||
*
|
||||
* 事件映射(docs/reference/omp/rpc.md Event Stream Schema):
|
||||
* - `message_update.assistantMessageEvent.type = "text_delta"` → ChatEvent `text`
|
||||
* - `tool_execution_start / _update / _end` → ChatEvent `tool_call` / `tool_result`
|
||||
* - `agent_end`(yielded 非假)→ ChatEvent `done`
|
||||
* - `prompt_result.status = "error"` → ChatEvent `error`
|
||||
* - 取消(abort)→ ChatEvent `cancelled`
|
||||
*/
|
||||
|
||||
import type { ChatEvent, ToolCallRecord } from '@shared/types'
|
||||
import { OmpKernel, type OmpFrame, type OmpToolInfo } from '../omp'
|
||||
import { toolSpecs } from './tools'
|
||||
import { executeTurnTool } from './conversation-tools'
|
||||
import type { AgentMode, PermissionMode } from '@shared/conversation'
|
||||
import { agentMode, agentModeInstructions, modeToolSpecs } from './mode-policy'
|
||||
import type { InteractionBroker } from '../interaction-broker'
|
||||
import type { CapabilityToolSnapshot } from '../capabilities/runtime'
|
||||
import type { ThinkingLevel } from '@shared/workspaces'
|
||||
|
||||
const TOOL_SUMMARY_LIMIT = 600
|
||||
const TOOL_JSON_FALLBACK_LIMIT = 16_000
|
||||
|
||||
export interface RunOmpTurnOptions {
|
||||
capabilityTools?: CapabilityToolSnapshot
|
||||
supportsToolCall?: boolean
|
||||
workspaceRoot?: string
|
||||
permissionMode?: PermissionMode
|
||||
agentMode?: AgentMode
|
||||
thinkingLevel?: ThinkingLevel
|
||||
interactions?: InteractionBroker
|
||||
images?: Array<{ type: 'image'; data: string; mimeType: string }>
|
||||
runId: string
|
||||
kernel: OmpKernel
|
||||
userText: string
|
||||
signal: AbortSignal
|
||||
emit: (event: ChatEvent) => void
|
||||
}
|
||||
|
||||
export interface OmpTurnResult {
|
||||
content: string
|
||||
tools: ToolCallRecord[]
|
||||
/** 终态:正常结束为 done;abort 后到达的 agent_end 记为 cancelled */
|
||||
status: 'done' | 'cancelled'
|
||||
}
|
||||
|
||||
export async function runOmpTurn(options: RunOmpTurnOptions): Promise<OmpTurnResult> {
|
||||
const { runId, kernel, signal, emit } = options
|
||||
const mode = agentMode(options.agentMode)
|
||||
const instructions = agentModeInstructions(mode)
|
||||
const userText = instructions && !options.userText.includes(instructions) ? `${options.userText}\n\n${instructions}` : options.userText
|
||||
|
||||
// 已取消的回合不能再向共享内核发送 prompt 或 abort。
|
||||
if (signal.aborted) {
|
||||
emit({ runId, type: 'cancelled' })
|
||||
const error = new Error('回合已取消')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
if (options.workspaceRoot) {
|
||||
const registration = await kernel.setHostTools(options.supportsToolCall === false ? [] : modeToolSpecs([...toolSpecs(), ...(options.capabilityTools?.specs ?? [])], mode).map((tool) => ({ ...tool.function, label: tool.function.name })))
|
||||
if (registration.success === false) throw new Error('OMP 宿主工具注册失败')
|
||||
}
|
||||
// Applied per turn so a level chosen mid-conversation reaches the very next request without a kernel restart.
|
||||
if (options.thinkingLevel) await kernel.setThinkingLevel(options.thinkingLevel)
|
||||
|
||||
const tools: ToolCallRecord[] = []
|
||||
let fullText = ''
|
||||
let settled = false
|
||||
/** 是否已请求取消:abort 后到达的 agent_end 一律按「已取消」收尾 */
|
||||
let aborted = false
|
||||
let cleanup = (): void => {}
|
||||
const reasoning = new Map<string, string>()
|
||||
const toolStartedAt = new Map<string, number>()
|
||||
const hostRequests = new Set<string>()
|
||||
const hostMetadata = new Map<string, Record<string, unknown>>()
|
||||
const hostResults = new Map<string, unknown>()
|
||||
let assistantSequence = 0
|
||||
|
||||
const onAbort = (): void => {
|
||||
if (settled) return
|
||||
aborted = true
|
||||
void kernel.abort().catch(() => undefined)
|
||||
}
|
||||
signal.addEventListener('abort', onAbort)
|
||||
|
||||
const finalizeRunning = (status: 'error' | 'cancelled'): void => {
|
||||
for (const record of tools) {
|
||||
if (record.status !== 'running') continue
|
||||
record.status = status
|
||||
const startedAt = toolStartedAt.get(record.id)
|
||||
if (startedAt !== undefined) record.durationMs = elapsedSince(startedAt)
|
||||
toolStartedAt.delete(record.id)
|
||||
emit({ runId, type: 'tool_result', call: { ...record } })
|
||||
}
|
||||
}
|
||||
|
||||
const finish = (
|
||||
event: ChatEvent,
|
||||
result: OmpTurnResult,
|
||||
inFlightStatus: 'error' | 'cancelled' = 'error'
|
||||
): OmpTurnResult => {
|
||||
if (settled) return result
|
||||
settled = true
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
cleanup()
|
||||
for (const [id, text] of reasoning) emit({ runId, type: 'part', part: { type: 'reasoning', id, text, finished: true } })
|
||||
finalizeRunning(inFlightStatus)
|
||||
emit(event)
|
||||
return result
|
||||
}
|
||||
|
||||
return new Promise<OmpTurnResult>((resolve, reject) => {
|
||||
const onEvent = (frame: OmpFrame): void => {
|
||||
if (settled) return
|
||||
|
||||
switch (frame.type) {
|
||||
case 'message_start':
|
||||
if (isRecord(frame.message) && frame.message.role === 'assistant') assistantSequence++
|
||||
break
|
||||
case 'host_tool_call': {
|
||||
const requestId = typeof frame.id === 'string' ? frame.id : ''
|
||||
if (!requestId || hostRequests.has(requestId)) break
|
||||
hostRequests.add(requestId)
|
||||
const toolCallId = typeof frame.toolCallId === 'string' ? frame.toolCallId : requestId
|
||||
const name = typeof frame.name === 'string' ? frame.name : typeof frame.toolName === 'string' ? frame.toolName : ''
|
||||
const rawArgs = frame.args ?? frame.arguments
|
||||
const parsedArgs = typeof rawArgs === 'string' ? safeParse(rawArgs) : rawArgs
|
||||
const args = isRecord(parsedArgs) ? parsedArgs : {}
|
||||
void (async () => {
|
||||
try {
|
||||
if (!options.workspaceRoot) throw new Error('宿主工具未连接')
|
||||
const ctx = { runId, toolCallId, workspaceRoot: options.workspaceRoot,
|
||||
permissionMode: options.permissionMode, agentMode: mode, interactions: options.interactions, signal, emit }
|
||||
const result = await executeTurnTool(name, args, ctx, options.capabilityTools)
|
||||
if (result.metadata) hostMetadata.set(toolCallId, result.metadata)
|
||||
hostResults.set(toolCallId, result.data ?? { success: result.ok, content: result.content })
|
||||
if (!settled) await kernel.respondToHostTool(requestId, result.content, !result.ok)
|
||||
} catch (error) {
|
||||
if (!settled) await kernel.respondToHostTool(requestId, error instanceof Error ? error.message : String(error), true).catch(fail)
|
||||
}
|
||||
})()
|
||||
break
|
||||
}
|
||||
case 'message_update': {
|
||||
const evt = frame.assistantMessageEvent as
|
||||
| { type?: string; delta?: string; contentIndex?: number }
|
||||
| undefined
|
||||
if (evt?.type === 'text_delta' && typeof evt.delta === 'string') {
|
||||
fullText += evt.delta
|
||||
emit({ runId, type: 'text', delta: evt.delta })
|
||||
} else if (evt?.type === 'thinking_delta' && typeof evt.delta === 'string') {
|
||||
const id = `thinking-${assistantSequence}-${String(evt.contentIndex ?? 0)}`
|
||||
const text = (reasoning.get(id) || '') + evt.delta
|
||||
reasoning.set(id, text)
|
||||
emit({ runId, type: 'part', part: { type: 'reasoning', id, text } })
|
||||
} else if (evt?.type === 'thinking_end') {
|
||||
const id = `thinking-${assistantSequence}-${String(evt.contentIndex ?? 0)}`
|
||||
emit({ runId, type: 'part', part: { type: 'reasoning', id, text: reasoning.get(id) || '', finished: true } })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'message_end': {
|
||||
const message = frame.message
|
||||
if (isRecord(message) && message.role === 'assistant' && message.stopReason === 'error') {
|
||||
failProvider(message.errorMessage)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_execution_start': {
|
||||
const info = readToolInfo(frame, 'start')
|
||||
if (!info) break
|
||||
const id = info.id ?? info.name
|
||||
if (!id) break
|
||||
const record: ToolCallRecord = {
|
||||
id,
|
||||
name: info.name ?? id,
|
||||
intent: info.intent,
|
||||
args: safeParse(info.argsRaw ?? '{}'),
|
||||
argsRaw: info.argsRaw ?? '{}',
|
||||
status: 'running'
|
||||
}
|
||||
record.metadata = options.capabilityTools?.metadata?.(record.name)
|
||||
if (info.output !== undefined) setOutput(record, info.output)
|
||||
toolStartedAt.set(id, Date.now())
|
||||
tools.push(record)
|
||||
emit({ runId, type: 'tool_call', call: { ...record } })
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_execution_update': {
|
||||
const info = readToolInfo(frame, 'update')
|
||||
if (!info?.id) break
|
||||
const record = tools.find((t) => t.id === info.id)
|
||||
if (record && info.output !== undefined) {
|
||||
setOutput(record, info.output)
|
||||
emit({ runId, type: 'tool_result', call: { ...record } })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool_execution_end': {
|
||||
const info = readToolInfo(frame, 'end')
|
||||
if (!info?.id) break
|
||||
const record = tools.find((t) => t.id === info.id)
|
||||
if (record) {
|
||||
record.result = hostResults.get(record.id) ?? frame.result ?? frame.output
|
||||
if (isRecord(frame.result) && isRecord(frame.result.details)) record.metadata = { ...record.metadata, ...frame.result.details }
|
||||
if (hostMetadata.has(record.id)) record.metadata = { ...record.metadata, ...hostMetadata.get(record.id) }
|
||||
if (info.output !== undefined) setOutput(record, info.output)
|
||||
record.status = info.isError || info.ok === false ? 'error' : 'ok'
|
||||
const startedAt = toolStartedAt.get(record.id)
|
||||
if (startedAt !== undefined) record.durationMs = elapsedSince(startedAt)
|
||||
toolStartedAt.delete(record.id)
|
||||
emit({ runId, type: 'tool_result', call: { ...record } })
|
||||
if (isRecord(frame.result) && Array.isArray(frame.result.content)) {
|
||||
frame.result.content.forEach((part, index) => {
|
||||
if (isRecord(part) && part.type === 'image' && typeof part.data === 'string' && typeof part.mimeType === 'string' && /^image\/(png|jpeg|gif|webp)$/.test(part.mimeType)) {
|
||||
emit({ runId, type: 'part', part: { type: 'image', id: `${record.id}-image-${index}`, url: `data:${part.mimeType};base64,${part.data}` } })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// A provider failure can precede a nonterminal automatic-retry frame.
|
||||
// Report the actual failure rather than leaving an empty streaming bubble.
|
||||
const message = Array.isArray(frame.messages)
|
||||
? frame.messages.findLast((item) => isRecord(item) && item.role === 'assistant')
|
||||
: undefined
|
||||
if (isRecord(message) && message.stopReason === 'error') {
|
||||
failProvider(message.errorMessage)
|
||||
break
|
||||
}
|
||||
// yielded === false 表示内核还在自行推进(重试/压缩),不算回合结束
|
||||
const yielded = frame.yielded === undefined ? frame.isTerminal !== false : frame.yielded
|
||||
if (yielded) {
|
||||
const result: OmpTurnResult = { content: fullText, tools, status: aborted ? 'cancelled' : 'done' }
|
||||
if (aborted) {
|
||||
resolve(finish({ runId, type: 'cancelled' }, result, 'cancelled'))
|
||||
} else {
|
||||
resolve(finish({ runId, type: 'done', content: fullText, tools }, result))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'prompt_result': {
|
||||
const status = frame.status
|
||||
if (status === 'error') {
|
||||
const message =
|
||||
(frame.error as { message?: string } | undefined)?.message ?? 'OMP 回合失败'
|
||||
fail(new Error(message))
|
||||
return
|
||||
}
|
||||
// aborted → 等待 abort 后的 agent_end;若内核直接结束则在此收尾
|
||||
if (status === 'aborted') {
|
||||
const result: OmpTurnResult = { content: fullText, tools, status: 'cancelled' }
|
||||
resolve(finish({ runId, type: 'cancelled' }, result, 'cancelled'))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'notice': {
|
||||
const level = (frame as { level?: string }).level
|
||||
const text = typeof frame.message === 'string' ? frame.message : ''
|
||||
if (level === 'error' && text) {
|
||||
fail(new Error(text))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (err: Error): void => {
|
||||
if (settled) return
|
||||
finish(
|
||||
{ runId, type: 'error', message: err.message },
|
||||
{ content: fullText, tools, status: 'done' },
|
||||
'error'
|
||||
)
|
||||
reject(err)
|
||||
}
|
||||
const failProvider = (message: unknown): void => {
|
||||
if (settled) return
|
||||
if (aborted || signal.aborted) {
|
||||
resolve(finish({ runId, type: 'cancelled' }, { content: fullText, tools, status: 'cancelled' }, 'cancelled'))
|
||||
return
|
||||
}
|
||||
// OMP otherwise retries the provider failure outside the failed host turn.
|
||||
// Abort only this failed prompt; successful long thinking has no first-byte deadline.
|
||||
void kernel.abort().catch(() => kernel.dispose()).catch(() => undefined)
|
||||
fail(new Error(providerFailureMessage(message)))
|
||||
}
|
||||
const onError = (err: Error): void => fail(err)
|
||||
const onExit = (): void => onError(new Error('OMP 进程已退出'))
|
||||
|
||||
kernel.on('event', onEvent)
|
||||
kernel.once('error', onError)
|
||||
kernel.once('exit', onExit)
|
||||
|
||||
// 完成 / 失败后解除监听
|
||||
cleanup = (): void => {
|
||||
kernel.off('event', onEvent)
|
||||
kernel.off('error', onError)
|
||||
kernel.off('exit', onExit)
|
||||
}
|
||||
|
||||
kernel
|
||||
.prompt(userText, options.images)
|
||||
.then((frame) => {
|
||||
if (settled) return
|
||||
// prompt 的 ack 不代表回合结束;真正的结束由 agent_end / prompt_result 驱动。
|
||||
// 但若 ack 失败,这里直接报错。
|
||||
if (frame.success === false) {
|
||||
const message =
|
||||
(frame.error as { message?: string } | undefined)?.message ?? 'OMP 命令被拒绝'
|
||||
fail(new Error(message))
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => fail(err))
|
||||
})
|
||||
}
|
||||
|
||||
function providerFailureMessage(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value.trim()) return 'OMP 模型服务请求失败'
|
||||
return value.trim()
|
||||
.replace(/https?:\/\/[^\s"'<>]+/gi, '[API 地址]')
|
||||
.replace(/\bBearer\s+[^\s"',}]+/gi, 'Bearer [已隐藏]')
|
||||
.replace(/\b(api[-_\s]?key|authorization)\b(["'\s:=]+)[^\s,"'}]+/gi, '$1$2[已隐藏]')
|
||||
.replace(/\bsk[-_][\w-]{8,}/g, '[已隐藏]')
|
||||
.slice(0, 1000)
|
||||
}
|
||||
|
||||
/** 从工具事件里尽力取出 id / name / args / output(协议文档未给全载荷,做兼容解析) */
|
||||
function readToolInfo(frame: OmpFrame, phase: 'start' | 'update' | 'end'): OmpToolInfo | null {
|
||||
const candidates: unknown[] = [
|
||||
frame.toolCall,
|
||||
frame.call,
|
||||
frame.tool,
|
||||
frame.data,
|
||||
frame
|
||||
]
|
||||
const objects = candidates.filter(isRecord)
|
||||
const id = firstString(...objects.flatMap((o) => [o.id, o.toolCallId, o.callId])) ?? undefined
|
||||
const name = firstString(...objects.flatMap((o) => [o.name, o.toolName, o.title])) ?? undefined
|
||||
const intent = firstString(...objects.flatMap((o) => [o.intent])) ?? undefined
|
||||
if (!id && !name) return null
|
||||
|
||||
const args = objects.flatMap((o) => [o.args, o.arguments, o.input]).find((value) => value !== undefined)
|
||||
const argsRaw = args === undefined ? undefined : serialize(args)
|
||||
const outputKeys =
|
||||
phase === 'update'
|
||||
? ['partialResult', 'partial', 'result', 'output', 'content', 'summary']
|
||||
: ['result', 'output', 'content', 'summary', 'partialResult', 'partial']
|
||||
let output: string | undefined
|
||||
for (const o of objects) {
|
||||
for (const key of outputKeys) {
|
||||
if (!(key in o)) continue
|
||||
output = extractOutput(o[key])
|
||||
if (output !== undefined) break
|
||||
}
|
||||
if (output !== undefined) break
|
||||
}
|
||||
|
||||
const successValues = objects.flatMap((o) => [
|
||||
o.success,
|
||||
isRecord(o.result) ? o.result.success : undefined,
|
||||
isRecord(o.partialResult) ? o.partialResult.success : undefined
|
||||
])
|
||||
const ok = successValues.includes(false) ? false : successValues.includes(true) ? true : undefined
|
||||
const isError = objects.some((o) =>
|
||||
[o.isError, isRecord(o.result) ? o.result.isError : undefined].includes(true)
|
||||
)
|
||||
|
||||
return { id, name, intent, argsRaw, output, ok, isError }
|
||||
}
|
||||
|
||||
function firstString(...values: unknown[]): string | null {
|
||||
for (const v of values) {
|
||||
if (typeof v === 'string' && v) return v
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeParse(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
function serialize(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractOutput(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value
|
||||
if (value === undefined || value === null) return undefined
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const text = textContent(value)
|
||||
if (text !== undefined) return text
|
||||
if (value.some(isContentPart)) return undefined
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
if (value.type === 'text' && typeof value.text === 'string') return value.text
|
||||
if ('content' in value) {
|
||||
if (Array.isArray(value.content)) return textContent(value.content)
|
||||
return extractOutput(value.content)
|
||||
}
|
||||
if (containsBinaryPayload(value)) return undefined
|
||||
}
|
||||
|
||||
const json = serialize(value)
|
||||
return json === undefined ? undefined : truncate(json, TOOL_JSON_FALLBACK_LIMIT)
|
||||
}
|
||||
|
||||
function textContent(parts: unknown[]): string | undefined {
|
||||
const textParts = parts
|
||||
.filter(isRecord)
|
||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||
.map((part) => part.text as string)
|
||||
// No local OMP contract defines separators between text blocks; preserve order with line boundaries.
|
||||
return textParts.length ? textParts.join('\n') : undefined
|
||||
}
|
||||
|
||||
function isContentPart(value: unknown): boolean {
|
||||
if (!isRecord(value) || typeof value.type !== 'string') return false
|
||||
const kind = value.type.toLowerCase()
|
||||
return /^(text|image|audio|video|binary|blob|file)([_-]|$)/.test(kind)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function containsBinaryPayload(value: unknown, depth = 0): boolean {
|
||||
if (depth > 4 || value === null || typeof value !== 'object') return false
|
||||
if (
|
||||
Buffer.isBuffer(value) ||
|
||||
value instanceof ArrayBuffer ||
|
||||
ArrayBuffer.isView(value)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => containsBinaryPayload(item, depth + 1))
|
||||
}
|
||||
const object = value as Record<string, unknown>
|
||||
const kind = typeof object.type === 'string' ? object.type.toLowerCase() : ''
|
||||
if (['image', 'audio', 'video', 'binary', 'blob'].includes(kind)) return true
|
||||
const mediaType = object.mimeType ?? object.mediaType
|
||||
if (typeof mediaType === 'string' && /^(image|audio|video)\//i.test(mediaType)) return true
|
||||
for (const [key, child] of Object.entries(object)) {
|
||||
if (['image', 'images', 'audio', 'video', 'binary', 'blob', 'bytes', 'buffer', 'base64'].includes(key.toLowerCase())) {
|
||||
return true
|
||||
}
|
||||
if (containsBinaryPayload(child, depth + 1)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function setOutput(record: ToolCallRecord, output: string): void {
|
||||
record.output = output
|
||||
record.summary = truncate(output)
|
||||
}
|
||||
|
||||
function elapsedSince(startedAt: number): number {
|
||||
return Math.max(0, Date.now() - startedAt)
|
||||
}
|
||||
|
||||
function truncate(text: string, limit = TOOL_SUMMARY_LIMIT): string {
|
||||
return text.length <= limit ? text : `${text.slice(0, limit)}\n…(已截断,共 ${text.length} 字符)`
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 宿主工具系统:路径约束、审批与取消由同一执行入口处理。
|
||||
*
|
||||
* 约定:
|
||||
* - 所有工具都在 workspaceRoot 内活动,越界一律拒绝(含符号链接解析后的真实路径)
|
||||
* - 工具结果统一为文本,便于回填给模型
|
||||
* - 工具实现不感知 UI;进度与结果由 loop.ts 转成 ChatEvent
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { dirname } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { ToolSpec } from '@shared/types'
|
||||
import type { PermissionMode } from '@shared/conversation'
|
||||
|
||||
export interface ToolContext {
|
||||
workspaceRoot: string
|
||||
signal?: AbortSignal
|
||||
permissionMode?: PermissionMode
|
||||
approve?: (title: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
ok: boolean
|
||||
content: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
run(args: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>
|
||||
}
|
||||
|
||||
class ToolError extends Error {}
|
||||
async function writeTextAtomically(path: string, content: string): Promise<void> {
|
||||
const temporary = join(dirname(path), `.ycode-write-${randomUUID()}.tmp`)
|
||||
try { await fs.writeFile(temporary, content, 'utf8'); await fs.rename(temporary, path) }
|
||||
finally { await fs.unlink(temporary).catch(() => undefined) }
|
||||
}
|
||||
|
||||
/** 把用户/模型给的路径解析到工作区内,越界即抛错 */
|
||||
async function resolveInWorkspace(input: unknown, ctx: ToolContext): Promise<string> {
|
||||
const raw = typeof input === 'string' && input.trim() ? input.trim() : '.'
|
||||
const rootReal = await fs.realpath(ctx.workspaceRoot).catch(() => resolve(ctx.workspaceRoot))
|
||||
const target = isAbsolute(raw) ? resolve(raw) : resolve(rootReal, raw)
|
||||
let existing = target
|
||||
const suffix: string[] = []
|
||||
let targetReal: string
|
||||
for (;;) {
|
||||
try { targetReal = join(await fs.realpath(existing), ...suffix); break }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
const parent = dirname(existing)
|
||||
if (parent === existing) throw error
|
||||
suffix.unshift(existing.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)))
|
||||
existing = parent
|
||||
}
|
||||
}
|
||||
const rel = relative(rootReal, targetReal)
|
||||
|
||||
if (rel === '') return rootReal
|
||||
if (ctx.permissionMode !== 'full-access' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
|
||||
throw new ToolError(`路径越界,仅允许在工作目录内操作:${raw}`)
|
||||
}
|
||||
return targetReal
|
||||
}
|
||||
|
||||
const listDir: Tool = {
|
||||
name: 'list_dir',
|
||||
description:
|
||||
'列出工作目录内的文件与子目录。返回名称、类型与大小。用于了解项目结构。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '相对于工作目录的路径,默认 "." 表示根目录' },
|
||||
description: { type: 'string', description: 'Optional short English action label for the conversation timeline, e.g. "List contents of src directory". Keep it concise.' }
|
||||
},
|
||||
required: []
|
||||
},
|
||||
async run(args, ctx) {
|
||||
const dir = await resolveInWorkspace(args.path, ctx)
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
if (entries.length === 0) return { ok: true, content: '(空目录)' }
|
||||
|
||||
const lines: string[] = []
|
||||
for (const entry of entries.slice(0, 500)) {
|
||||
if (entry.name === 'node_modules' || entry.name === '.git') continue
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
lines.push(`[dir] ${entry.name}/`)
|
||||
} else {
|
||||
const stat = await fs.stat(full).catch(() => null)
|
||||
const size = stat ? `${stat.size}B` : '?'
|
||||
lines.push(`[file] ${entry.name} ${size}`)
|
||||
}
|
||||
}
|
||||
if (entries.length > 500) lines.push(`... 共 ${entries.length} 项,已截断`)
|
||||
return { ok: true, content: lines.join('\n') || '(无内容)' }
|
||||
}
|
||||
}
|
||||
|
||||
const readFile: Tool = {
|
||||
name: 'read_file',
|
||||
description:
|
||||
'读取工作目录内某个文本文件的内容。大文件请配合 offset/limit 分段读取。返回带行号的文本。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: '相对于工作目录的文件路径' },
|
||||
description: { type: 'string', description: 'Optional short English action label for the conversation timeline, e.g. "Read file package.json". Keep it concise.' },
|
||||
offset: { type: 'number', description: '起始行号,从 1 开始,默认 1' },
|
||||
limit: { type: 'number', description: '读取行数,默认 400,最大 2000' }
|
||||
},
|
||||
required: ['path']
|
||||
},
|
||||
async run(args, ctx) {
|
||||
const file = await resolveInWorkspace(args.path, ctx)
|
||||
const stat = await fs.stat(file)
|
||||
if (stat.isDirectory()) throw new ToolError(`${args.path} 是目录,请用 list_dir`)
|
||||
if (stat.size > 4 * 1024 * 1024) throw new ToolError('文本文件超过 4 MB,请先缩小文件或使用其他读取方式')
|
||||
|
||||
const offset = Math.max(1, Number(args.offset) || 1)
|
||||
const limit = Math.min(2000, Math.max(1, Number(args.limit) || 400))
|
||||
|
||||
const text = await fs.readFile(file, 'utf8')
|
||||
const all = text.split(/\r?\n/)
|
||||
const slice = all.slice(offset - 1, offset - 1 + limit)
|
||||
const numbered = slice
|
||||
.map((line, i) => `${String(offset + i).padStart(5, ' ')}| ${line}`)
|
||||
.join('\n')
|
||||
|
||||
const header = `# ${args.path}(共 ${all.length} 行,显示 ${offset}-${offset + slice.length - 1})`
|
||||
return { ok: true, content: `${header}\n${numbered}`, data: { path: file, content: slice.join('\n'), totalLines: all.length, startLine: offset, endLine: offset + slice.length - 1 } }
|
||||
}
|
||||
}
|
||||
|
||||
async function authorizeWrite(ctx: ToolContext, description: string): Promise<void> {
|
||||
if (ctx.signal?.aborted) throw Object.assign(new Error('已取消'), { name: 'AbortError' })
|
||||
if (ctx.permissionMode !== 'full-access' && !(await ctx.approve?.(description))) throw new ToolError('用户未批准此操作')
|
||||
}
|
||||
|
||||
const writeFile: Tool = {
|
||||
name: 'write_file', description: '创建或更新文本文件;默认权限下会请求用户确认。',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
||||
async run(args, ctx) {
|
||||
if (typeof args.path !== 'string' || typeof args.content !== 'string') throw new ToolError('文件参数无效')
|
||||
let path = await resolveInWorkspace(args.path, ctx)
|
||||
const oldContent = await fs.readFile(path, 'utf8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
})
|
||||
await authorizeWrite(ctx, `写入文件 ${args.path}`)
|
||||
path = await resolveInWorkspace(args.path, ctx)
|
||||
const current = await fs.readFile(path, 'utf8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
})
|
||||
if (current !== oldContent) throw new ToolError('文件在等待审批时已变化,请重新读取后写入')
|
||||
await fs.mkdir(dirname(path), { recursive: true })
|
||||
path = await resolveInWorkspace(args.path, ctx)
|
||||
await writeTextAtomically(path, args.content)
|
||||
return { ok: true, content: `已写入 ${args.path}`, data: { path, oldContent, newContent: args.content } }
|
||||
}
|
||||
}
|
||||
const editFile: Tool = {
|
||||
name: 'edit_file', description: '精确替换文本文件中的唯一匹配内容;默认权限下会请求用户确认。',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
|
||||
async run(args, ctx) {
|
||||
if (typeof args.path !== 'string' || typeof args.oldText !== 'string' || !args.oldText || typeof args.newText !== 'string') throw new ToolError('编辑参数无效')
|
||||
let path = await resolveInWorkspace(args.path, ctx)
|
||||
const oldContent = await fs.readFile(path, 'utf8')
|
||||
if (oldContent.split(args.oldText).length !== 2) throw new ToolError('待替换内容必须唯一匹配')
|
||||
await authorizeWrite(ctx, `编辑文件 ${args.path}`)
|
||||
path = await resolveInWorkspace(args.path, ctx)
|
||||
if (await fs.readFile(path, 'utf8') !== oldContent) throw new ToolError('文件已发生变化,请重新读取后编辑')
|
||||
const newContent = oldContent.replace(args.oldText, args.newText)
|
||||
await writeTextAtomically(path, newContent)
|
||||
return { ok: true, content: `已编辑 ${args.path}`, data: { path, oldContent, newContent } }
|
||||
}
|
||||
}
|
||||
const command: Tool = {
|
||||
name: 'execute_command', description: '在工作目录执行 shell 命令;需要用户开启完全访问。',
|
||||
parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
|
||||
async run(args, ctx) {
|
||||
if (ctx.permissionMode !== 'full-access') throw new ToolError('当前系统沙盒不可用,命令执行需要完全访问权限')
|
||||
if (typeof args.command !== 'string' || !args.command.trim()) throw new ToolError('命令为空')
|
||||
return new Promise((resolveResult, reject) => {
|
||||
const child = spawn(args.command as string, { shell: true, cwd: ctx.workspaceRoot, windowsHide: true, detached: process.platform !== 'win32' })
|
||||
const onAbort = (): void => {
|
||||
if (!child.pid) return
|
||||
if (process.platform === 'win32') {
|
||||
const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
||||
killer.once('error', () => child.kill())
|
||||
} else {
|
||||
try { process.kill(-child.pid, 'SIGTERM') } catch { child.kill() }
|
||||
}
|
||||
}
|
||||
ctx.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (ctx.signal?.aborted) onAbort()
|
||||
let stdout = '', stderr = ''
|
||||
const retain = (current: string, chunk: Buffer): string => (current + chunk.toString()).slice(-2 * 1024 * 1024)
|
||||
child.stdout?.on('data', (chunk: Buffer) => { stdout = retain(stdout, chunk) })
|
||||
child.stderr?.on('data', (chunk: Buffer) => { stderr = retain(stderr, chunk) })
|
||||
child.once('error', (error) => { ctx.signal?.removeEventListener('abort', onAbort); reject(error) })
|
||||
child.once('close', (exitCode) => { ctx.signal?.removeEventListener('abort', onAbort); resolveResult({ ok: exitCode === 0, content: [stdout, stderr].filter(Boolean).join('\n'), data: { stdout, stderr, exitCode } }) })
|
||||
})
|
||||
}
|
||||
}
|
||||
const askQuestion: Tool = { name: 'ask_user_question', description: '暂停并向用户提出选项或自由文本问题。', parameters: { type: 'object', properties: { title: { type: 'string' }, questions: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, question: { type: 'string' }, options: { type: 'array', items: { type: 'string' } }, multiSelect: { type: 'boolean' } }, required: ['question'] } } }, required: ['questions'] }, async run() { return { ok: false, content: '问答宿主未连接' } } }
|
||||
const followupQuestion: Tool = { ...askQuestion, name: 'ask_followup_question', parameters: { type: 'object', properties: { question: { type: 'string' }, follow_up: { type: 'array', items: { type: 'string' } } }, required: ['question'] } }
|
||||
const plan: Tool = { name: 'todo_write', description: '创建或更新任务计划和步骤进度。', parameters: { type: 'object', properties: { title: { type: 'string' }, todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } }, required: ['content'] } } }, required: ['todos'] }, async run() { return { ok: false, content: '计划宿主未连接' } } }
|
||||
|
||||
export const ALL_TOOLS: Tool[] = [listDir, readFile, writeFile, editFile, command, askQuestion, followupQuestion, plan]
|
||||
|
||||
export function toolSpecs(): ToolSpec[] {
|
||||
return ALL_TOOLS.map((t) => ({
|
||||
type: 'function' as const,
|
||||
function: { name: t.name, description: t.description, parameters: t.parameters }
|
||||
}))
|
||||
}
|
||||
|
||||
export function findTool(name: string): Tool | undefined {
|
||||
return ALL_TOOLS.find((t) => t.name === name)
|
||||
}
|
||||
|
||||
/** 统一执行入口:把异常收敛成 ToolResult,避免中断整个回合 */
|
||||
export async function executeTool(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolContext
|
||||
): Promise<ToolResult> {
|
||||
const tool = findTool(name)
|
||||
if (!tool) return { ok: false, content: `未知工具:${name}` }
|
||||
try {
|
||||
return await tool.run(args, ctx)
|
||||
} catch (err) {
|
||||
return { ok: false, content: `工具执行失败:${(err as Error).message}` }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user