chore: establish YCode replication baseline and TODO

This commit is contained in:
xiaohan.yuan committed 2026-10-17 02:43:49 +08:00
commit 6dc59f89ba
1118 files changed
+196002

No files matched your search

+72
View File
@@ -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)
}
+29
View File
@@ -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
}
+215
View File
@@ -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)
}
+49
View File
@@ -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>`
}
+492
View File
@@ -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} 字符)`
}
+243
View File
@@ -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}` }
}
}