Abbruch hinterlaesst keinen unbeantworteten Werkzeugaufruf mehr (#552)

Wurde ein Chat abgebrochen, waehrend ein Werkzeug lief, blieb im Kontext
eine assistant-Nachricht mit tool_calls stehen, auf die keine Antwort
folgte. Diese Folge lehnt die Schnittstelle mit HTTP 400 ab - der Agent
war danach nicht mehr ansprechbar.

Der Schaden wurde dabei dauerhaft gemacht: Jeder catch-Block ruft
AddChatEntry, das ruft PersistChatState, und das schreibt den Kontext
atomar auf die Platte. Das atomare Schreiben war hier keine Rettung,
sondern die Garantie, dass die kaputte Fassung heil ankommt. Ein Neustart
half nicht, weil LoadPersistedChats sie zurueckholte.

Ausloeser war durchweg Normalbetrieb: AbortChat durch den Benutzer,
LoopGuard.Timeout, oder eine Instanz, die waehrend eines Werkzeuglaufs
geordnet herunterfaehrt.

Die Antworten gehen jetzt aus einem finally raus. Auf jedem Ausgangspfad
bekommt jeder Aufruf seine Antwort, bei Abbruch eine Ersatzantwort, die
auch sagt, was passiert ist - eine leere waere formal gueltig, liesse das
Modell aber raetseln.

Zwei Tests: einer auf die Form des Kontexts, einer auf das, worauf es
ankommt - dass ein zweiter Chat danach wieder durchlaeuft. Der erste
schlug vor der Behebung fehl.

543 Tests in Core, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-12 13:39:43 +02:00
co-authored by Claude Opus 5
parent ea3844006f
commit fb4b218e45
2 changed files with 147 additions and 16 deletions
+55 -16
View File
@@ -251,12 +251,20 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
assistantMessage.Reasoning,
assistantMessage.ReasoningDetails));
var toolResults = await ExecuteToolCallsAsync(
assistantMessage.ToolCalls, agentConfig, instanceId, tools,
logger, ct, runId, source, chain);
for (var i = 0; i < assistantMessage.ToolCalls.Count; i++)
messages.Add(ChatMessage.ToolResponse(assistantMessage.ToolCalls[i].Id, toolResults[i]));
// #552: Die Antworten gehen im finally raus. Auf jedem Ausgangspfad
// — fertig, abgebrochen, gescheitert — bekommt jeder Aufruf seine
// Antwort, sonst bleibt eine Folge zurueck, die keine API annimmt.
var toolResults = new string?[assistantMessage.ToolCalls.Count];
try
{
await ExecuteToolCallsAsync(
assistantMessage.ToolCalls, toolResults, agentConfig, instanceId, tools,
logger, ct, runId, source, chain);
}
finally
{
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
}
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed");
@@ -520,12 +528,20 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
assistantMessage.Reasoning,
assistantMessage.ReasoningDetails));
var toolResults = await ExecuteToolCallsAsync(
assistantMessage.ToolCalls, agentConfig, instanceId, tools,
logger, ct, runId, source, chain);
for (var i = 0; i < assistantMessage.ToolCalls.Count; i++)
messages.Add(ChatMessage.ToolResponse(assistantMessage.ToolCalls[i].Id, toolResults[i]));
// #552: Die Antworten gehen im finally raus. Auf jedem Ausgangspfad
// — fertig, abgebrochen, gescheitert — bekommt jeder Aufruf seine
// Antwort, sonst bleibt eine Folge zurueck, die keine API annimmt.
var toolResults = new string?[assistantMessage.ToolCalls.Count];
try
{
await ExecuteToolCallsAsync(
assistantMessage.ToolCalls, toolResults, agentConfig, instanceId, tools,
logger, ct, runId, source, chain);
}
finally
{
AppendToolResponses(messages, assistantMessage.ToolCalls, toolResults);
}
OnStepCompleted?.Invoke(agentConfig.AgentId,
$"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)");
@@ -1102,8 +1118,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// nacheinander. Gemischt heißt: erst die gemeinsame Gruppe, dann der Rest — ein
/// schreibendes Werkzeug sieht damit nie einen halbfertigen Zustand eines anderen.</para>
/// </summary>
private async Task<string[]> ExecuteToolCallsAsync(
/// <summary>
/// Ersatzantwort für einen Aufruf, der nie zu Ende kam (#552). Ohne sie bliebe der
/// Aufruf unbeantwortet, und eine solche Folge nimmt die Schnittstelle nicht an.
/// </summary>
internal const string AbgebrochenerAufruf =
"""{"error":"Aufruf abgebrochen, bevor ein Ergebnis vorlag."}""";
private async Task ExecuteToolCallsAsync(
IReadOnlyList<ToolCall> toolCalls,
string?[] results,
AgentConfig agentConfig,
string instanceId,
IReadOnlyList<IAgentTool> availableTools,
@@ -1113,8 +1137,6 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
string? source,
DelegationChain chain)
{
var results = new string[toolCalls.Count];
var limit = Math.Max(1, agentConfig.MaxParallelToolCalls);
// Nebenläufig ist ein Aufruf nur, wenn das Werkzeug sich dafür freigegeben hat.
@@ -1168,8 +1190,25 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
toolCalls[index], agentConfig, instanceId, availableTools,
logger, ct, runId, source, chain);
}
}
return results;
/// <summary>
/// Hängt für <b>jeden</b> Aufruf eine Antwort an — auch für den, der nie zu Ende kam
/// (#552).
///
/// <para>Wird aus einem <c>finally</c> gerufen. Der Kontext ist bei einem Chat die
/// geteilte, an Ort und Stelle veränderte Liste; bliebe hier eine Lücke, stünde
/// darin dauerhaft eine Folge, die die Schnittstelle mit HTTP 400 ablehnt — und
/// über <c>PersistChatState</c> wanderte sie zuverlässig auf die Platte. Der Agent
/// wäre danach auch nach einem Neustart nicht mehr ansprechbar.</para>
/// </summary>
private static void AppendToolResponses(
List<ChatMessage> messages,
IReadOnlyList<ToolCall> toolCalls,
string?[] results)
{
for (var i = 0; i < toolCalls.Count; i++)
messages.Add(ChatMessage.ToolResponse(toolCalls[i].Id, results[i] ?? AbgebrochenerAufruf));
}
private async Task<string> ExecuteToolCallAsync(