diff --git a/docs/features/direct-model-agent/progress.md b/docs/features/direct-model-agent/progress.md index 9037b8fb..8d5e48b1 100644 --- a/docs/features/direct-model-agent/progress.md +++ b/docs/features/direct-model-agent/progress.md @@ -10,7 +10,24 @@ | 日期 | 2026-09-09 | | 关联入口 | [直连模型 Agent 能力](./README.md) | -## 当前结论 +## 2026-10-05 工具执行异常回传 + +- 普通工具执行异常也返回模型,保留失败活动与错误详情,由模型决定后续行动;规则见 + [失败恢复](./technical-design.md#读取与搜索失败恢复)。请求取消与 `AbortError` 仍停止循环。 +- 新增三协议回归使用真实 Provider、进程服务和文件系统:无效 cwd 产生 `ENOENT`, + 下一次模拟模型请求收到对应失败结果,再调用同一工具执行真实命令成功。另覆盖错误详情 + 有界处理和取消;未使用真实外部模型,不作为桌面 UI 或真实模型自主决策验收。 +- 修复验证中发现 `DOMException` 不一定满足 `instanceof Error`,改为按错误名称识别取消; + 取消测试在意外后续模型请求时立即失败,避免模拟响应无限重复耗尽内存。 +- `model-runtime.test.ts`、`model-tool-provider.test.ts`、`direct-model-process-service.test.ts`: + 161 项通过、1 项跳过。`npm run typecheck`、`npm run lint` 通过。 +- 全量 `npm test`:6,230 项通过、87 项跳过、1 项失败;失败为 + `obsidian-electron.test.ts:29` 要求临时目录在仓库外,与本次按仓库规范设置 + `TEMP`/`TMP` 到仓库 `temp/` 冲突。未修改该测试,全量不能记为通过。 +- 托管 SSH 使用远端 OpenCode/Continue 的工具循环,不运行本次修改的 `ModelAgentRuntime`; + 不涉及 gbagent 生产路径。临时目录创建、保留和清理逻辑未修改。 + +## 既有本机验收结论 本机生产路径已经完成源码接线:直连模型 Execute 可运行平台 Shell,Ask/Execute 均可按 父模式委派单层编程 Subagent;长输出可通过 `output_read` 续读。OpenCode、Continue、 diff --git a/docs/features/direct-model-agent/technical-design.md b/docs/features/direct-model-agent/technical-design.md index e07a9f0f..e7409e1a 100644 --- a/docs/features/direct-model-agent/technical-design.md +++ b/docs/features/direct-model-agent/technical-design.md @@ -98,8 +98,11 @@ type BuiltinModelToolSummary = { `EISDIR`、`EACCES`、`EPERM`、`ELOOP`、`ENAMETOOLONG` 等已列明的文件系统错误。 Runtime 向模型返回 `{ ok: false, recoverable: true, error, nextAction }` 的 JSON 文本工具结果, 工具活动记为 `recoverable`,继续工具循环,由模型修正路径、分页参数、glob 或搜索表达式; -提示不得原样重复失败参数。取消、远端断线、失效工作区、缺失 rg 可执行文件及未分类的内部 -错误继续向外传播,不转为可恢复结果。 +提示不得原样重复失败参数。其他工具执行异常(包括进程启动或输出文件的 `ENOENT`、 +远端断线、失效工作区、缺失 rg 可执行文件)由 Runtime 返回 `{ ok: false, error }`, +工具活动记为 `failed`,错误详情使用已有有界处理并保留 cause 信息。失败结果继续进入 +下一次模型请求,由模型决定重试、调整方法或说明失败;不自动重放工具,也不增加重试次数限制。 +Anthropic 的失败结果同时设置 `is_error: true`。请求取消及 `AbortError` 仍向外传播并停止循环。 `workspace_read_text` 仍只接受工作区内相对路径。搜索原样接受 rg 参数及账号可访问的路径, 不增加逐工具授权;保留 `--no-config` 默认参数、原生退出码、取消和分页,不提升系统权限。 diff --git a/docs/features/unified-execution/progress.md b/docs/features/unified-execution/progress.md index 844a0874..876daa51 100644 --- a/docs/features/unified-execution/progress.md +++ b/docs/features/unified-execution/progress.md @@ -259,3 +259,8 @@ SSH 专家保留父请求已解析的 OpenCode 或 Continue 选择,通过现 `deai-writing` 扫描九份文档,阻断项为 0。复核项主要为未验证状态、数据保留条件和字段枚举;结合技术文档语境保留这些限定,不将迁移计划改写为已完成事实。 `git diff --check -- docs/features/unified-execution docs/features/assistant-workbar/README.md` 通过已跟踪改动的空白检查。新建文档另经内容与链接核对;该命令不会把未跟踪文件当作已验证差异。文档检查不替代运行时验收,后者按用户故事与技术设计执行。 + +## 2026-10-05 直连模型工具错误回传 + +普通工具执行失败不再直接终止直连模型循环,错误结果继续交给模型处理,取消行为保留。 +实现、三协议回归及全量测试边界见[直连模型修复记录](../direct-model-agent/progress.md#2026-10-05-工具执行异常回传)。 diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index ad9c87ed..fea4ec54 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -4017,6 +4017,218 @@ describe('ModelAgentRuntime', () => { expect(events.at(-1)).toMatchObject({ type: 'done' }) }) + it.each(['openai-chat-completions', 'openai-responses', 'anthropic-messages'] as const)( + 'returns ordinary ENOENT to %s and lets the model retry the same production tool', + async (protocol) => { + const tempRoot = join(process.cwd(), 'temp') + await mkdir(tempRoot, { recursive: true }) + const workspace = await mkdtemp(join(tempRoot, 'model-tool-enoent-')) + const workspaceAccess = new LocalWorkspaceAccess(workspace) + const processService = new LocalDirectModelProcessService() + const toolProvider = new ModelToolProvider( + workspaceAccess, [], undefined, undefined, false, { processService } + ) + const marker = 'process-retry-succeeded' + const calls = ['missing-cwd', '.'].map((cwd, index) => ({ + id: `process-${index}`, + name: 'process_execute', + arguments: { command: `echo ${marker}`, cwd, timeoutMs: 10_000 } + })) + const responses = calls.map((call) => { + if (protocol === 'anthropic-messages') { + return { content: [{ type: 'tool_use', id: call.id, name: call.name, input: call.arguments }], stop_reason: 'tool_use' } + } + if (protocol === 'openai-responses') { + return { id: `response-${call.id}`, output: [{ + type: 'function_call', id: `fc-${call.id}`, call_id: call.id, + name: call.name, arguments: JSON.stringify(call.arguments) + }] } + } + return { choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ + id: call.id, type: 'function', + function: { name: call.name, arguments: JSON.stringify(call.arguments) } + }] } }] } + }) + const fetcher = vi.fn(async () => { + const response = responses.shift() + if (response) return Response.json(response) + if (protocol === 'anthropic-messages') { + return Response.json({ content: [{ type: 'text', text: 'Command completed.' }], stop_reason: 'end_turn' }) + } + if (protocol === 'openai-responses') { + return Response.json({ id: 'response-final', output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Command completed.' }] }] }) + } + return Response.json({ choices: [{ message: { role: 'assistant', content: 'Command completed.' } }] }) + }) + const runtime = new ModelAgentRuntime({ + baseUrl: 'https://example.test/v1', model: 'test-model', + protocol, authentication: 'none', fetcher, toolProvider, workspaceAccess + }) + const events: RuntimeEvent[] = [] + try { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId: `enoent-${protocol}`, + prompt: 'Run the command and correct the working directory if needed.' + }, new AbortController().signal)) events.push(event) + + expect(fetcher).toHaveBeenCalledTimes(3) + let failure: unknown + for (const requestIndex of [1, 2]) { + const body = JSON.parse(String(fetcher.mock.calls[requestIndex]![1]!.body)) + const entries = (body.input ?? body.messages) as Array> + for (let callIndex = 0; callIndex < requestIndex; callIndex++) { + const call = calls[callIndex]! + let text: string + if (protocol === 'anthropic-messages') { + const blocks = entries.flatMap((entry) => Array.isArray(entry.content) + ? entry.content as Array> : []) + expect(blocks.filter((block) => block.type === 'tool_use' && block.id === call.id)).toEqual([ + expect.objectContaining({ name: call.name, input: call.arguments }) + ]) + const results = blocks.filter((block) => block.type === 'tool_result' && block.tool_use_id === call.id) + expect(results).toHaveLength(1) + expect(results[0]!.is_error).toBe(callIndex === 0 ? true : undefined) + text = (results[0]!.content as Array<{ text: string }>)[0]!.text + } else if (protocol === 'openai-responses') { + expect(entries.filter((entry) => entry.type === 'function_call' && entry.call_id === call.id)).toEqual([ + expect.objectContaining({ name: call.name, arguments: JSON.stringify(call.arguments) }) + ]) + const results = entries.filter((entry) => entry.type === 'function_call_output' && entry.call_id === call.id) + expect(results).toHaveLength(1) + text = (results[0]!.output as Array<{ text: string }>)[0]!.text + } else { + const toolCalls = entries.flatMap((entry) => Array.isArray(entry.tool_calls) + ? entry.tool_calls as Array> : []) + expect(toolCalls.filter((entry) => entry.id === call.id)).toEqual([ + expect.objectContaining({ function: { name: call.name, arguments: JSON.stringify(call.arguments) } }) + ]) + const results = entries.filter((entry) => entry.role === 'tool' && entry.tool_call_id === call.id) + expect(results).toHaveLength(1) + text = results[0]!.content as string + } + const result = JSON.parse(text) + if (callIndex === 0) { + expect(result).toEqual({ ok: false, error: expect.stringContaining('ENOENT') }) + expect(result.error).toContain('missing-cwd') + expect(result.error.length).toBeLessThanOrEqual(2_000) + if (requestIndex === 1) failure = result + else expect(result).toEqual(failure) + } else { + expect(result).toMatchObject({ exitCode: 0, stdout: expect.stringContaining(marker), stderr: '' }) + } + } + } + const toolEvents = events.filter((event) => event.type === 'tool') + expect(toolEvents.map((event) => [event.callId, event.state])).toEqual([ + ['process-0', 'pending'], ['process-0', 'running'], ['process-0', 'failed'], + ['process-1', 'pending'], ['process-1', 'running'], ['process-1', 'completed'] + ]) + expect(toolEvents[2]).toMatchObject({ error: (failure as { error: string }).error }) + expect(events).toContainEqual(expect.objectContaining({ type: 'text', delta: 'Command completed.' })) + expect(events.at(-1)).toMatchObject({ type: 'done' }) + } finally { + try { + await runtime.dispose() + await processService.dispose() + } finally { + await rm(workspace, { recursive: true, force: true }) + } + } + } + ) + + it('returns bounded ordinary tool error detail without control characters or a stack', async () => { + const error = new Error(`ENOENT\u0000: missing cwd ${'x'.repeat(4_000)} END-OF-DETAIL`) + error.stack = 'PRIVATE-STACK-MARKER' + const toolProvider = createToolProvider({ + callTool: vi.fn(async () => { throw error }) + }) + const fetcher = vi.fn() + .mockResolvedValueOnce(Response.json({ choices: [{ message: { + role: 'assistant', content: null, tool_calls: [{ + id: 'bounded-error', type: 'function', + function: { name: 'workspace_read_text', arguments: '{"path":"README.md"}' } + }] + } }] })) + .mockResolvedValueOnce(Response.json({ choices: [{ message: { + role: 'assistant', content: 'Reported failure.' + } }] })) + const runtime = new ModelAgentRuntime({ + baseUrl: 'https://example.test/v1', model: 'test-model', + protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider + }) + const events: RuntimeEvent[] = [] + try { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId: 'bounded-tool-error', + prompt: 'Read the file.' + }, new AbortController().signal)) events.push(event) + + expect(fetcher).toHaveBeenCalledTimes(2) + const body = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) as { + messages: Array<{ role: string; tool_call_id?: string; content: string }> + } + const results = body.messages.filter((message) => message.role === 'tool') + expect(results).toHaveLength(1) + expect(results[0]!.tool_call_id).toBe('bounded-error') + const result = JSON.parse(results[0]!.content) + expect(result).toEqual({ ok: false, error: expect.stringContaining('ENOENT: missing cwd') }) + expect(result.error.length).toBeLessThanOrEqual(2_000) + expect(result.error).not.toContain('\u0000') + expect(result.error).not.toContain('END-OF-DETAIL') + expect(result.error).not.toContain('PRIVATE-STACK-MARKER') + expect(events.filter((event) => event.type === 'tool').map((event) => event.state)) + .toEqual(['pending', 'running', 'failed']) + expect(events).toContainEqual(expect.objectContaining({ + type: 'tool', callId: 'bounded-error', state: 'failed', error: result.error + })) + expect(events.at(-1)).toMatchObject({ type: 'done' }) + } finally { + await runtime.dispose() + } + }) + + it.each(['AbortSignal', 'AbortError'] as const)( + 'propagates %s during tool execution without sending a failure result to the model', + async (cancellation) => { + const controller = new AbortController() + const error = cancellation === 'AbortError' + ? new DOMException('Tool cancelled', 'AbortError') + : new Error('Tool cancelled by user') + const toolProvider = createToolProvider({ + callTool: vi.fn(async () => { + if (cancellation === 'AbortSignal') controller.abort(error) + throw error + }) + }) + const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ choices: [{ message: { + role: 'assistant', content: null, tool_calls: [{ + id: 'cancelled-call', type: 'function', + function: { name: 'workspace_read_text', arguments: '{"path":"README.md"}' } + }] + } }] })).mockRejectedValue(new Error('Unexpected model request after cancellation')) + const runtime = new ModelAgentRuntime({ + baseUrl: 'https://example.test/v1', model: 'test-model', + protocol: 'openai-chat-completions', authentication: 'none', fetcher, toolProvider + }) + const events: RuntimeEvent[] = [] + try { + const consume = async () => { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), conversationId: `tool-cancellation-${cancellation}`, + prompt: 'Read the file.' + }, controller.signal)) events.push(event) + } + await expect(consume()).rejects.toBe(error) + expect(toolProvider.callTool).toHaveBeenCalledOnce() + expect(fetcher).toHaveBeenCalledOnce() + expect(events).not.toContainEqual(expect.objectContaining({ type: 'done' })) + } finally { + await runtime.dispose() + } + } + ) + it('returns recoverable tool failures to the model instead of aborting the run', async () => { const responses = [ { diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index a3b275de..1705abc2 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -882,14 +882,18 @@ function getToolResultPreview(parts: ModelToolResultPart[]): string { .slice(0, 16_000) } -function createRecoverableToolErrorResult( - error: RecoverableModelToolError +function createToolErrorResult( + error: unknown ): ModelToolResult { const text = JSON.stringify({ ok: false, - recoverable: true, - error: error.message.slice(0, 1_000), - nextAction: error.nextAction.slice(0, 1_000) + ...(error instanceof RecoverableModelToolError + ? { + recoverable: true, + error: error.message.slice(0, 1_000), + nextAction: error.nextAction.slice(0, 1_000) + } + : { error: safeToolErrorDetail(error) ?? 'Tool execution failed' }) }) return { parts: [{ type: 'text', text }], @@ -3811,6 +3815,14 @@ export class ModelAgentRuntime implements AgentRuntime { } result = step.value } catch (error) { + signal.throwIfAborted() + if (typeof error === 'object' && error !== null && ( + ('name' in error && error.name === 'AbortError') || + ('cause' in error && typeof error.cause === 'object' && error.cause !== null && + 'name' in error.cause && error.cause.name === 'AbortError') + )) { + throw error + } const recoverable = error instanceof RecoverableModelToolError const detail = safeToolErrorDetail(error) if (showToolActivity) { @@ -3828,14 +3840,8 @@ export class ModelAgentRuntime implements AgentRuntime { ...(detail ? { error: detail } : {}) } } - if (recoverable) { - result = createRecoverableToolErrorResult(error) - toolFailed = true - } else { - throw new Error(`工具「${displayName}」执行失败`, { - cause: error - }) - } + result = createToolErrorResult(error) + toolFailed = true } roundContextBytes += validateToolResult(result) if (responses) {