fix: preserve remote task success after recoverable tool errors

Align remote ACP tool recovery with local runtime behavior when the model responds after a tool failure and completes normally. Preserve original errors and derive recovery state from committed remote events so reconnecting safely replays partial terminal events.

Validation: 461 focused tests, typecheck, lint, and shared Linux Host recovery scenarios passed. Full-suite validation has two pre-existing heartbeat migration fixture failures involving the pinned column.

Release note: 修复远端 Agent 在读取等工具失败后已正常回复,任务仍被误判为失败的问题,并修复断线重连时的工具状态恢复。
This commit is contained in:
mesalogo
2026-09-16 12:49:17 +08:00
parent eaf71ce16f
commit 6c045bfe2e
8 changed files with 465 additions and 14 deletions
+62 -2
View File
@@ -147,6 +147,8 @@ export class RemotePromptCancelledError extends Error {
}
type ActivePrompt = {
failedTools?: Map<string, Extract<RuntimePublicEvent, { type: 'tool' }>>
hasResponseTextAfterToolFailure?: boolean
reportedQuestions?: Set<string>
subagentProgress?: OpenCodeSubagentProgress
requestId: string
@@ -2110,6 +2112,46 @@ export class AcpRemoteRuntime implements AgentRuntime {
private mapUpdate(
prompt: ActivePrompt,
update: SessionUpdate
): RuntimePublicEvent | undefined {
const mapped = this.mapSessionUpdate(prompt, update)
if (!mapped) return undefined
const event = this.limitEvent(mapped)
if (event.type === 'text' && event.delta.trim()) {
prompt.hasResponseTextAfterToolFailure = true
} else if (event.type === 'tool') {
if (event.state === 'failed') {
prompt.failedTools ??= new Map()
prompt.failedTools.set(event.callId, event)
prompt.hasResponseTextAfterToolFailure = false
} else {
prompt.failedTools?.delete(event.callId)
}
} else if (event.type === 'subagent' && event.runtimeCallId) {
prompt.failedTools?.delete(event.runtimeCallId)
}
return event
}
private recoveredToolEvents(prompt: ActivePrompt): RuntimePublicEvent[] {
if (!prompt.hasResponseTextAfterToolFailure ||
[...prompt.toolCalls.values()].some(tool =>
tool.status === 'pending' || tool.status === 'in_progress')) {
return []
}
// Re-emit already recovered tools too: a terminal may be only partly
// committed. Sorting keeps every event index stable across reattachment.
return [...(prompt.failedTools ?? [])]
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
.map(([, tool]) => ({
...tool,
state: 'recoverable' as const,
summary: `远端 Runtime 已在后续响应中处理工具失败:${tool.name}`
}))
}
private mapSessionUpdate(
prompt: ActivePrompt,
update: SessionUpdate
): RuntimePublicEvent | undefined {
const extension = update._meta?.goodbuddyQuestion
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
@@ -2432,11 +2474,26 @@ export class AcpRemoteRuntime implements AgentRuntime {
pendingUpdateBytes: 0,
inboundPaused: false,
toolCalls: new Map(),
hasResponseTextAfterToolFailure: request.remoteHasResponseTextAfterToolFailure,
failedTools: new Map(),
open: true,
completed: false,
context
}
for (const tool of request.remoteRecoveredTools ?? []) {
if (tool.state === 'failed' || tool.state === 'recoverable') {
prompt.failedTools!.set(tool.callId, {
requestId: request.requestId,
type: 'tool',
callId: tool.callId,
name: tool.name,
state: tool.state,
summary: tool.summary,
...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {})
})
}
prompt.toolCalls.set(tool.callId.slice(0, 256), {
title: tool.name.slice(0, 200),
status:
@@ -2457,6 +2514,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
if (!subagent.runtimeCallId) {
continue
}
prompt.failedTools?.delete(subagent.runtimeCallId)
const subagentTitle =
'actor' in subagent
? subagent.actor.label
@@ -2631,7 +2689,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
const mapped = page.pendingQuestions !== undefined && notification.update._meta?.goodbuddyQuestion
? undefined : this.mapUpdate(prompt, notification.update)
if (mapped !== undefined) {
publicEvents.push(this.limitEvent(mapped))
publicEvents.push(mapped)
}
} else if (transcriptEvent.kind === 'prompt-terminal') {
const terminal = transcriptTerminal(
@@ -2650,6 +2708,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
}
}
if (terminal.state === 'completed') {
publicEvents.push(...this.recoveredToolEvents(prompt))
publicEvents.push({
requestId: request.requestId,
type: 'done',
@@ -2979,7 +3038,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
}
const event = this.mapUpdate(prompt, update)
if (event) {
yield this.limitEvent(event)
yield event
}
}
await this.awaitOperation(
@@ -3025,6 +3084,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
signal
)
session.binding = binding
yield* this.recoveredToolEvents(prompt)
yield {
requestId: request.requestId,
type: 'done',