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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user