diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index e96faa0..9b3de91 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -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. /// - private async Task ExecuteToolCallsAsync( + /// + /// 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. + /// + internal const string AbgebrochenerAufruf = + """{"error":"Aufruf abgebrochen, bevor ein Ergebnis vorlag."}"""; + + private async Task ExecuteToolCallsAsync( IReadOnlyList toolCalls, + string?[] results, AgentConfig agentConfig, string instanceId, IReadOnlyList 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; + /// + /// Hängt für jeden Aufruf eine Antwort an — auch für den, der nie zu Ende kam + /// (#552). + /// + /// Wird aus einem finally 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 PersistChatState wanderte sie zuverlässig auf die Platte. Der Agent + /// wäre danach auch nach einem Neustart nicht mehr ansprechbar. + /// + private static void AppendToolResponses( + List messages, + IReadOnlyList toolCalls, + string?[] results) + { + for (var i = 0; i < toolCalls.Count; i++) + messages.Add(ChatMessage.ToolResponse(toolCalls[i].Id, results[i] ?? AbgebrochenerAufruf)); } private async Task ExecuteToolCallAsync( diff --git a/tests/ClawdDotNet.Core.Tests/Engine/AbbruchKontextTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/AbbruchKontextTests.cs new file mode 100644 index 0000000..e5dd2ce --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Engine/AbbruchKontextTests.cs @@ -0,0 +1,92 @@ +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Tests.Infrastructure; + +namespace ClawdDotNet.Core.Tests.Engine; + +/// +/// Was ein Abbruch mitten im Zug mit dem Gesprächskontext macht. +/// +/// Der Kontext ist eine geteilte, an Ort und Stelle veränderte Liste. Bricht ein Lauf +/// zwischen dem Werkzeugaufruf und seiner Antwort ab, bleibt eine assistant-Nachricht +/// mit tool_calls stehen, auf die keine Antwort folgt — eine Nachrichtenfolge, die die +/// Schnittstelle nicht annimmt. +/// +public class AbbruchKontextTests +{ + [Fact] + public async Task Ein_Abbruch_waehrend_des_Werkzeugs_hinterlaesst_keinen_unbeantworteten_Aufruf() + { + var fixture = new EngineFixture().WithTool( + FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool")); + + var agent = fixture.AddAgent("a1", "LangsamTool"); + + fixture.Client + .RespondsWithToolCall("LangsamTool") + .AlwaysRespondsWithText("Fertig."); + + var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None); + + // Warten, bis der Aufruf sicher steht und das Werkzeug läuft. + await Task.Delay(300); + fixture.Engine.AbortChat("a1"); + + var result = await lauf; + Assert.Equal(AgentRunStatus.Cancelled, result.Status); + + var kontext = fixture.Engine.GetChatContext("a1"); + + var offeneAufrufe = kontext + .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) + .SelectMany(m => m.ToolCalls!) + .Select(tc => tc.Id) + .Where(id => !kontext.Any(m => m.Role == "tool" && m.ToolCallId == id)) + .ToList(); + + Assert.True(offeneAufrufe.Count == 0, + $"Unbeantwortete Werkzeugaufrufe im Kontext: {string.Join(", ", offeneAufrufe)}. " + + "Eine solche Folge lehnt die Schnittstelle mit HTTP 400 ab — der Agent ist " + + "danach dauerhaft blockiert."); + + // Die Ersatzantwort sagt auch, was passiert ist. Eine leere Antwort wäre formal + // gültig, ließe das Modell aber rätseln, warum sein Aufruf nichts ergab. + var ersatz = kontext.Last(m => m.Role == "tool"); + Assert.Contains("abgebrochen", ersatz.Content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Nach_einem_Abbruch_laesst_sich_der_Agent_wieder_ansprechen() + { + // Der eigentliche Punkt: Nicht die Form des Kontexts zählt, sondern ob der + // Agent danach noch arbeitet. Ein zweiter Chat muss durchlaufen. + var fixture = new EngineFixture().WithTool( + FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool")); + + var agent = fixture.AddAgent("a1", "LangsamTool"); + + fixture.Client + .RespondsWithToolCall("LangsamTool") + .AlwaysRespondsWithText("Zweite Antwort."); + + var erster = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None); + await Task.Delay(300); + fixture.Engine.AbortChat("a1"); + await erster; + + var zweiter = await fixture.Engine.ChatAsync(agent, "Und jetzt?", "inst", CancellationToken.None); + + Assert.Equal(AgentRunStatus.Completed, zweiter.Status); + Assert.Equal("Zweite Antwort.", zweiter.FinalMessage); + + // Und die Folge, die dabei gesendet wurde, war vollständig beantwortet. + var gesendet = fixture.Client.ReceivedRequests[^1].Messages; + var offen = gesendet + .Where(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }) + .SelectMany(m => m.ToolCalls!) + .Select(tc => tc.Id) + .Where(id => !gesendet.Any(m => m.Role == "tool" && m.ToolCallId == id)) + .ToList(); + + Assert.Empty(offen); + } +}