diff --git a/src/ClawdDotNet.Core/Api/Models/ChatMessage.cs b/src/ClawdDotNet.Core/Api/Models/ChatMessage.cs index 2d5007e..2eda2cd 100644 --- a/src/ClawdDotNet.Core/Api/Models/ChatMessage.cs +++ b/src/ClawdDotNet.Core/Api/Models/ChatMessage.cs @@ -28,14 +28,45 @@ public sealed class ChatMessage [JsonIgnore] public bool CacheBreakpoint { get; set; } + /// + /// Der Denkverlauf als Klartext, so wie der Anbieter ihn geliefert hat. + /// + public string? Reasoning { get; set; } + + /// + /// Der strukturierte Denkverlauf, unverändert. + /// + /// Wird bei Werkzeugnutzung in der folgenden Anfrage zurückgeschickt: Das Modell + /// setzt seine begonnene Antwort fort, statt eine neue zu beginnen. Die Abfolge + /// muss dabei genau der gelieferten entsprechen — umstellen oder verändern macht + /// sie unbrauchbar, und bei signierten Formaten kann der Anbieter sie dann nicht + /// mehr zuordnen. Deshalb wird hier rohes JSON gehalten und nichts ausgelegt. + /// + public JsonElement? ReasoningDetails { get; set; } + public static ChatMessage System(string content) => new() { Role = "system", Content = content }; public static ChatMessage User(string content) => new() { Role = "user", Content = content }; public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content }; - public static ChatMessage AssistantWithToolCalls(List toolCalls) => new() + /// + /// Baut die assistant-Nachricht für einen Zug mit Werkzeugaufrufen. + /// + /// ist der Text, den das Modell neben die Aufrufe + /// geschrieben hat („Ich sehe zuerst in der Konfiguration nach"). Er gehört in den + /// Verlauf: Ohne ihn verliert das Modell im nächsten Schritt seinen eigenen Plan, + /// und im Audit steht, dass ein Werkzeug lief, nicht warum. + /// + public static ChatMessage AssistantWithToolCalls( + List toolCalls, + string? content = null, + string? reasoning = null, + JsonElement? reasoningDetails = null) => new() { Role = "assistant", - ToolCalls = toolCalls + Content = string.IsNullOrEmpty(content) ? null : content, + ToolCalls = toolCalls, + Reasoning = reasoning, + ReasoningDetails = reasoningDetails }; public static ChatMessage ToolResponse(string toolCallId, string content) => new() @@ -95,6 +126,24 @@ public sealed class ChatMessageConverter : JsonConverter message.ToolCallId = reader.GetString(); break; + // Der Denkverlauf wird durchgereicht, nicht ausgelegt. "reasoning_content" + // ist derselbe Inhalt unter anderem Namen; wer zuerst kommt, gewinnt. + case "reasoning": + case "reasoning_content": + message.Reasoning ??= reader.TokenType == JsonTokenType.String + ? reader.GetString() + : null; + if (reader.TokenType is not JsonTokenType.String and not JsonTokenType.Null) + reader.Skip(); + break; + + case "reasoning_details": + // Clone(), weil der Reader nach dieser Schleife nicht mehr gilt. + message.ReasoningDetails = reader.TokenType == JsonTokenType.Null + ? null + : JsonDocument.ParseValue(ref reader).RootElement.Clone(); + break; + default: reader.Skip(); break; @@ -175,6 +224,18 @@ public sealed class ChatMessageConverter : JsonConverter if (value.ToolCallId is not null) writer.WriteString("tool_call_id", value.ToolCallId); + // Unverändert zurück. Das strukturierte Feld hat Vorrang, weil nur es Signatur + // und Reihenfolge trägt; der Klartext geht mit, wenn es keines gibt. + if (value.ReasoningDetails is { } details) + { + writer.WritePropertyName("reasoning_details"); + details.WriteTo(writer); + } + else if (value.Reasoning is not null) + { + writer.WriteString("reasoning", value.Reasoning); + } + writer.WriteEndObject(); } } diff --git a/src/ClawdDotNet.Core/Api/Models/ChatRequest.cs b/src/ClawdDotNet.Core/Api/Models/ChatRequest.cs index 3190d4a..c28be65 100644 --- a/src/ClawdDotNet.Core/Api/Models/ChatRequest.cs +++ b/src/ClawdDotNet.Core/Api/Models/ChatRequest.cs @@ -29,4 +29,98 @@ public sealed class ChatRequest [JsonPropertyName("stream")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool Stream { get; set; } + + /// + /// Steuert die Anbieterauswahl des Gateways. + /// + /// Ohne diese Angabe gilt die Voreinstellung des Gateways, und die lautet: + /// nicht unterstützte Parameter werden vom Anbieter stillschweigend ignoriert. + /// Für einen Agenten-Harness ist das der unangenehmste Fehlermodus überhaupt — + /// die Fähigkeit verschwindet, ohne dass die Antwort anders aussieht. + /// + [JsonPropertyName("provider")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ProviderPreferences? Provider { get; set; } + + /// + /// Fordert die Abrechnungsdaten des Gateways an. Ohne das bleibt die + /// Kostenrechnung eine Eigenrechnung aus Preisliste mal Tokenzahl. + /// + [JsonPropertyName("usage")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public UsageRequest? Usage { get; set; } + + /// + /// Denktiefe. Ohne diese Angabe entsteht bei den meisten Modellen gar kein + /// Reasoning — der Parameter schaltet es überhaupt erst ein. + /// + [JsonPropertyName("reasoning")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ReasoningRequest? Reasoning { get; set; } +} + +/// +/// Anbietervorgaben für das Gateway. Hier steht bewusst nur das, was diese Anwendung +/// tatsächlich setzt — die Schnittstelle kennt deutlich mehr Felder. +/// +public sealed class ProviderPreferences +{ + /// + /// Nur an Anbieter weiterreichen, die alle mitgeschickten Parameter + /// beherrschen. + /// + /// Das ist die eigentliche Härtung: Ohne sie darf ein Anbieter + /// cache_control, reasoning oder tools ignorieren, und der + /// Lauf liefert eine gewöhnliche Textantwort, wo ein Werkzeugaufruf hingehört + /// hätte. Mit ihr scheitert die Anfrage stattdessen sichtbar. + /// + [JsonPropertyName("require_parameters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? RequireParameters { get; set; } + + /// + /// Ob bei Nichtverfügbarkeit auf einen anderen Anbieter ausgewichen werden darf. + /// Null lässt die Voreinstellung des Gateways gelten. + /// + [JsonPropertyName("allow_fallbacks")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? AllowFallbacks { get; set; } + + /// + /// deny schließt Anbieter aus, die Eingaben zu eigenen Zwecken speichern. + /// + [JsonPropertyName("data_collection")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DataCollection { get; set; } +} + +public sealed class UsageRequest +{ + [JsonPropertyName("include")] + public bool Include { get; set; } +} + +/// +/// Denktiefe. und schließen einander +/// nicht formal aus, werden aber von den Anbietern verschieden ausgelegt — deshalb +/// setzt immer nur eines von beiden. +/// +public sealed class ReasoningRequest +{ + /// max, xhigh, high, medium, low, minimal, none. + [JsonPropertyName("effort")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Effort { get; set; } + + [JsonPropertyName("max_tokens")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? MaxTokens { get; set; } + + /// + /// Denken ja, aber nicht mitliefern. Spart Übertragung — kostet aber die + /// Fortsetzbarkeit über Werkzeugaufrufe hinweg, weil dann nichts zurückzugeben ist. + /// + [JsonPropertyName("exclude")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Exclude { get; set; } } diff --git a/src/ClawdDotNet.Core/Api/Models/ChatResponse.cs b/src/ClawdDotNet.Core/Api/Models/ChatResponse.cs index f10b057..d9a1772 100644 --- a/src/ClawdDotNet.Core/Api/Models/ChatResponse.cs +++ b/src/ClawdDotNet.Core/Api/Models/ChatResponse.cs @@ -46,6 +46,14 @@ public sealed class Usage [JsonPropertyName("prompt_tokens_details")] public PromptTokensDetails? PromptTokensDetails { get; set; } + /// + /// Der vom Gateway tatsächlich abgerechnete Betrag. Kommt nur, wenn die Anfrage + /// usage.include gesetzt hat. Null heißt „nicht geliefert" — dann bleibt + /// es bei der Eigenrechnung aus Preisliste mal Tokenzahl. + /// + [JsonPropertyName("cost")] + public decimal? Cost { get; set; } + /// /// Anteil der Prompt-Tokens, der aus dem Cache gelesen wurde. Nur damit lässt sich /// belegen, ob das Prompt-Caching tatsächlich greift — ein still wirkungsloser diff --git a/src/ClawdDotNet.Core/Config/AgentConfig.cs b/src/ClawdDotNet.Core/Config/AgentConfig.cs index b7ae115..c45894c 100644 --- a/src/ClawdDotNet.Core/Config/AgentConfig.cs +++ b/src/ClawdDotNet.Core/Config/AgentConfig.cs @@ -107,8 +107,60 @@ public sealed class AgentConfig [JsonPropertyName("maxToolResultChars")] public int MaxToolResultChars { get; set; } = 16_000; + /// + /// Wie viele als nebenläufig gekennzeichnete Werkzeuge eines Zuges gleichzeitig + /// laufen dürfen. 1 heißt: nacheinander, wie bisher. + /// + /// Die Grenze ist keine Leistungsstellschraube, sondern eine Rücksichtnahme — + /// zwanzig gleichzeitige Abrufe gegen dieselbe Gegenstelle sind aus deren Sicht + /// nicht von einem Angriff zu unterscheiden. + /// + [JsonPropertyName("maxParallelToolCalls")] + public int MaxParallelToolCalls { get; set; } = 4; + + /// + /// Denktiefe: "off" (Vorgabe), "minimal", "low", "medium", "high", "xhigh", "max" + /// — oder eine Tokenzahl als Zeichenkette, etwa "4096". + /// + /// Vorgabe ist "off", damit sich für bestehende Agenten nichts ändert: Denk-Tokens + /// zählen als Ausgabe-Tokens und damit gegen + /// wie gegen das Budget. + /// + [JsonPropertyName("reasoningEffort")] + public string ReasoningEffort { get; set; } = "off"; + + /// + /// Ob das Gateway nur an Anbieter weiterreichen darf, die alle mitgeschickten + /// Parameter beherrschen. + /// + /// Vorgabe true. Ohne das darf ein Anbieter cache_control, + /// reasoning oder sogar tools stillschweigend übergehen — der Lauf + /// liefert dann eine gewöhnliche Textantwort, wo ein Werkzeugaufruf hingehört + /// hätte, und nichts weist darauf hin. Lieber eine sichtbare Absage als ein + /// unbemerkt entwerteter Lauf. + /// + [JsonPropertyName("requireProviderParameters")] + public bool RequireProviderParameters { get; set; } = true; + [JsonPropertyName("budget")] public AgentBudget Budget { get; set; } = new(); + + /// + /// Übersetzt in das Anfragefeld. Null heißt: + /// nichts mitschicken, das Modell denkt wie voreingestellt. + /// + public Api.Models.ReasoningRequest? BuildReasoningRequest() + { + var value = (ReasoningEffort ?? "").Trim(); + + if (value.Length == 0 || value.Equals("off", StringComparison.OrdinalIgnoreCase)) + return null; + + if (int.TryParse(value, out var tokens)) + return tokens > 0 ? new Api.Models.ReasoningRequest { MaxTokens = tokens } : null; + + return new Api.Models.ReasoningRequest { Effort = value.ToLowerInvariant() }; + } } /// diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 4198804..e96faa0 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -197,6 +197,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut messages.Add(ChatMessage.User(userMessage)); string? finalMessage = null; + var truncated = false; var tally = new TokenTally(); var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model); @@ -216,16 +217,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut if (cachingEnabled) PromptCache.ApplyBreakpoints(messages); - var request = new ChatRequest - { - Model = agentConfig.Model, - Messages = messages, - Tools = toolDefinitions.Count > 0 ? toolDefinitions : null, - // B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen. - MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0 - ? agentConfig.LoopGuard.MaxResponseTokens - : null - }; + var request = BuildRequest(agentConfig, messages, toolDefinitions); var estimated = _compactor.EstimateFor(agentConfig.Model, messages); @@ -249,15 +241,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut if (assistantMessage.ToolCalls is { Count: > 0 }) { - messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls)); + // #547: Der Text neben den Aufrufen geht mit. Er ist der Plan, den + // das Modell sich selbst geschrieben hat — ohne ihn sieht es im + // nächsten Schritt nur noch, DASS es etwas getan hat. + // #549: Der Denkverlauf ebenso, sonst beginnt er jedes Mal von vorn. + messages.Add(ChatMessage.AssistantWithToolCalls( + assistantMessage.ToolCalls, + assistantMessage.Content, + assistantMessage.Reasoning, + assistantMessage.ReasoningDetails)); - foreach (var toolCall in assistantMessage.ToolCalls) - { - var toolResult = await ExecuteToolCallAsync( - toolCall, agentConfig, instanceId, tools, logger, ct, runId, source, chain); + var toolResults = await ExecuteToolCallsAsync( + assistantMessage.ToolCalls, agentConfig, instanceId, tools, + logger, ct, runId, source, chain); - messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult)); - } + for (var i = 0; i < assistantMessage.ToolCalls.Count; i++) + messages.Add(ChatMessage.ToolResponse(assistantMessage.ToolCalls[i].Id, toolResults[i])); OnStepCompleted?.Invoke(agentConfig.AgentId, $"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed"); @@ -265,18 +264,28 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut else { finalMessage = assistantMessage.Content ?? ""; + truncated = IsTruncated(choice.FinishReason); break; } } sw.Stop(); + + if (truncated) + { + logger.LogWarning( + "Agent run truncated: {AgentId} stieß nach {Steps} Schritten an die Ausgabegrenze " + + "von {Limit} Tokens. Das Ergebnis ist ein Teilergebnis.", + agentConfig.AgentId, loopGuard.Steps, agentConfig.LoopGuard.MaxResponseTokens); + } + logger.LogInformation( "Agent run completed: {AgentId}, steps={Steps}, tokens={Tokens} (davon {Cached} aus Cache), duration={Duration}ms", agentConfig.AgentId, loopGuard.Steps, tally.Total, tally.Cached, sw.ElapsedMilliseconds); var result = new AgentRunResult( agentConfig.AgentId, - AgentRunStatus.Completed, + truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed, finalMessage, loopGuard.Steps, tally.Total, @@ -284,7 +293,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, - CachedTokens = tally.Cached + CachedTokens = tally.Cached, + ReportedCostUsd = tally.ReportedCost }; OnRunCompleted?.Invoke(agentConfig.Model, result); return result; @@ -453,6 +463,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut AddChatEntry(agentConfig.AgentId, "user", userMessage, source); string? finalMessage = null; + var truncated = false; var tally = new TokenTally(); var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model); @@ -474,16 +485,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut if (cachingEnabled) PromptCache.ApplyBreakpoints(messages); - var request = new ChatRequest - { - Model = agentConfig.Model, - Messages = messages, - Tools = toolDefinitions.Count > 0 ? toolDefinitions : null, - // B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen. - MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0 - ? agentConfig.LoopGuard.MaxResponseTokens - : null - }; + var request = BuildRequest(agentConfig, messages, toolDefinitions); // Was die Schätzung für genau diese Anfrage sagt — gleich neben dem, was // die API dafür wirklich berechnet. Der Vergleich der beiden ist die @@ -510,14 +512,20 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut if (assistantMessage.ToolCalls is { Count: > 0 }) { - messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls)); + // #547 / #549 — siehe RunCoreAsync: Begleittext und Denkverlauf + // gehören in den Verlauf, nicht in den Papierkorb. + messages.Add(ChatMessage.AssistantWithToolCalls( + assistantMessage.ToolCalls, + assistantMessage.Content, + assistantMessage.Reasoning, + assistantMessage.ReasoningDetails)); - foreach (var toolCall in assistantMessage.ToolCalls) - { - var toolResult = await ExecuteToolCallAsync( - toolCall, agentConfig, instanceId, tools, logger, ct, runId, source, chain); - messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult)); - } + 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])); OnStepCompleted?.Invoke(agentConfig.AgentId, $"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)"); @@ -525,6 +533,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut else { finalMessage = assistantMessage.Content ?? ""; + truncated = IsTruncated(choice.FinishReason); messages.Add(ChatMessage.Assistant(finalMessage)); AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source); break; @@ -532,13 +541,25 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut } sw.Stop(); + + if (truncated) + { + logger.LogWarning( + "Chat truncated: {AgentId} stieß an die Ausgabegrenze von {Limit} Tokens. " + + "Die Antwort ist ein Teilergebnis.", + agentConfig.AgentId, agentConfig.LoopGuard.MaxResponseTokens); + } + var result = new AgentRunResult( - agentConfig.AgentId, AgentRunStatus.Completed, finalMessage, + agentConfig.AgentId, + truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed, + finalMessage, loopGuard.Steps, tally.Total, sw.Elapsed) { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, - CachedTokens = tally.Cached + CachedTokens = tally.Cached, + ReportedCostUsd = tally.ReportedCost }; OnRunCompleted?.Invoke(agentConfig.Model, result); return result; @@ -1025,6 +1046,132 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut } } + /// + /// Ob das Modell abgebrochen wurde, statt fertig zu werden (#548). + /// + /// Der Grund steht in finish_reason. Anbieter schreiben ihn verschieden: + /// length ist die verbreitete Form, max_tokens kommt bei einigen + /// vor. Beides meint dasselbe — die Ausgabegrenze war erreicht, bevor der Gedanke + /// zu Ende war. + /// + /// Fehlt das Feld ganz, wird nicht von Abbruch ausgegangen. Ein Anbieter, der + /// nichts meldet, soll keine Warnung auslösen, die es nicht gibt. + /// + internal static bool IsTruncated(string? finishReason) => + finishReason is not null && + (finishReason.Equals("length", StringComparison.OrdinalIgnoreCase) || + finishReason.Equals("max_tokens", StringComparison.OrdinalIgnoreCase)); + + /// + /// Baut die Anfrage für einen Schritt. Eine Stelle für beide Schleifen — als das + /// hier noch zweimal dastand, war der Unterschied zwischen ihnen niemandes Absicht, + /// sondern das Ergebnis davon, dass eine Änderung nur an einer Stelle ankam. + /// + private static ChatRequest BuildRequest( + AgentConfig agentConfig, + List messages, + List toolDefinitions) => new() + { + Model = agentConfig.Model, + Messages = messages, + Tools = toolDefinitions.Count > 0 ? toolDefinitions : null, + + // B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen. + MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0 + ? agentConfig.LoopGuard.MaxResponseTokens + : null, + + // #550: Nur an Anbieter weiterreichen, die alles beherrschen, was mitgeht. + Provider = agentConfig.RequireProviderParameters + ? new ProviderPreferences { RequireParameters = true } + : null, + + // #550: Den abgerechneten Betrag mitliefern lassen, statt ihn zu schätzen. + Usage = new UsageRequest { Include = true }, + + Reasoning = agentConfig.BuildReasoningRequest() + }; + + /// + /// Führt die Werkzeugaufrufe eines Zuges aus und liefert die Ergebnisse + /// in der Reihenfolge der Aufrufe zurück — unabhängig davon, welcher zuerst + /// fertig wurde. Daran hängt zweierlei: die Zuordnung über die tool_call_id und der + /// Prompt-Cache, dessen Präfix bei wechselnder Reihenfolge jedes Mal neu wäre. + /// + /// Als nebenläufig gekennzeichnete Aufrufe laufen gemeinsam, alle übrigen + /// 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( + IReadOnlyList toolCalls, + AgentConfig agentConfig, + string instanceId, + IReadOnlyList availableTools, + ILogger logger, + CancellationToken ct, + string runId, + 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. + // Ein unbekannter Name gilt als nicht freigegeben — ExecuteToolCallAsync macht + // daraus ohnehin einen Fehler, und der gehört in die serielle Reihe. + var parallel = new List(); + var serial = new List(); + + for (var i = 0; i < toolCalls.Count; i++) + { + var tool = availableTools.FirstOrDefault(t => t.Name == toolCalls[i].Function.Name); + (tool?.IsParallelSafe == true && limit > 1 ? parallel : serial).Add(i); + } + + if (parallel.Count == 1) + { + // Eine einzelne Aufgabe nebenläufig zu starten, kostet nur Umstand. + serial.Insert(0, parallel[0]); + parallel.Clear(); + } + + if (parallel.Count > 0) + { + logger.LogDebug("{Count} Werkzeuge laufen gemeinsam (Grenze {Limit})", parallel.Count, limit); + + using var slots = new SemaphoreSlim(limit, limit); + + // WhenAll statt einer Schleife mit Abbruch: Jede Aufgabe muss abgewartet + // werden, auch wenn eine andere bereits geworfen hat. Sonst läuft ein + // Werkzeug im Hintergrund weiter, während der Lauf sich schon beendet hat, + // und seine Ausnahme taucht später aus dem Nichts auf. + await Task.WhenAll(parallel.Select(async index => + { + await slots.WaitAsync(ct); + try + { + results[index] = await ExecuteToolCallAsync( + toolCalls[index], agentConfig, instanceId, availableTools, + logger, ct, runId, source, chain); + } + finally + { + slots.Release(); + } + })); + } + + foreach (var index in serial) + { + results[index] = await ExecuteToolCallAsync( + toolCalls[index], agentConfig, instanceId, availableTools, + logger, ct, runId, source, chain); + } + + return results; + } + private async Task ExecuteToolCallAsync( ToolCall toolCall, AgentConfig agentConfig, @@ -1234,6 +1381,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut { var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens); + // #550: Der gemeldete Betrag schlägt die Eigenrechnung. Er weiß von + // Cache-Aufschlägen und anbieterabhängigen Preisen, die eine Preisliste + // nicht abbildet. Fehlt er, bleibt die Schätzung als Rückfallebene — + // deshalb wird sie weiterhin unverändert berechnet. + var reported = result.ReportedCostUsd; + await _usageRepository.RecordAsync(new RunUsage { AgentId = agentConfig.AgentId, @@ -1241,8 +1394,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut PromptTokens = result.PromptTokens, CompletionTokens = result.CompletionTokens, CachedTokens = result.CachedTokens, - CostUsd = estimate?.Usd ?? 0m, - CostIsKnown = estimate?.IsKnown ?? false, + CostUsd = reported ?? estimate?.Usd ?? 0m, + CostIsKnown = reported is not null || (estimate?.IsKnown ?? false), Status = result.Status.ToString(), StepCount = result.StepCount, DurationMs = (long)result.Duration.TotalMilliseconds, @@ -1343,6 +1496,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut public int Completion { get; private set; } public int Cached { get; private set; } + /// + /// Summe der vom Gateway gemeldeten Beträge (#550). Null heißt: keiner der + /// Schritte hat einen geliefert — dann bleibt es bei der Eigenrechnung. + /// + /// Bewusst nur dann gesetzt, wenn wirklich etwas kam. Ein stillschweigend + /// als 0 gezählter Schritt wäre schlimmer als gar keine Angabe: Er sähe aus wie + /// ein kostenloser Lauf. + /// + public decimal? ReportedCost { get; private set; } + // Voll qualifiziert: "Usage" ist auch ein Namespace (ClawdDotNet.Core.Accounting). public void Add(Api.Models.Usage usage) { @@ -1350,6 +1513,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut Prompt += usage.PromptTokens; Completion += usage.CompletionTokens; Cached += usage.CachedTokens; + + if (usage.Cost is { } cost) + ReportedCost = (ReportedCost ?? 0m) + cost; } } diff --git a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs index 4462e5a..e875c8d 100644 --- a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs +++ b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs @@ -21,6 +21,15 @@ public sealed record AgentRunResult( /// in enthalten, kosten aber nur einen Bruchteil. /// public int CachedTokens { get; init; } + + /// + /// Der vom Gateway gemeldete Betrag über alle Schritte (#550), falls geliefert. + /// + /// Er ist der Eigenrechnung vorzuziehen, weil er das abbildet, was tatsächlich + /// abgerechnet wurde — einschließlich dessen, was eine Preisliste nicht kennt: + /// Cache-Schreibaufschläge, abweichende Preise je Anbieter, Rabatte. + /// + public decimal? ReportedCostUsd { get; init; } } public enum AgentRunStatus @@ -47,5 +56,16 @@ public enum AgentRunStatus /// Andersherum ginge die Zahl der offenen Läufe nie auf null, weil ständig neue /// dazukämen — die Instanz käme nie zum Ende. /// - ShuttingDown + ShuttingDown, + + /// + /// Das Modell stieß an die Ausgabegrenze, bevor es fertig war (#548). + /// + /// Der Lauf ist damit nicht gescheitert — es liegt ein Teilergebnis vor, und + /// das steht in . Er ist aber auch nicht + /// : Was dort steht, ist ein abgeschnittener Gedanke, kein + /// Schluss. Der Unterschied muss sichtbar sein, weil ein geplanter Lauf im + /// Hintergrund sonst ein halbes Ergebnis weiterreicht, als wäre es ein ganzes. + /// + Truncated } diff --git a/src/ClawdDotNet.Core/Tools/IAgentTool.cs b/src/ClawdDotNet.Core/Tools/IAgentTool.cs index 43175be..a49fe4b 100644 --- a/src/ClawdDotNet.Core/Tools/IAgentTool.cs +++ b/src/ClawdDotNet.Core/Tools/IAgentTool.cs @@ -8,6 +8,21 @@ public interface IAgentTool string Description { get; } JsonElement InputSchema { get; } + /// + /// Ob dieses Werkzeug gefahrlos gleichzeitig mit anderen laufen darf. + /// + /// Die Vorgabe ist false — nicht aus Vorsicht um ihrer selbst willen, + /// sondern weil die falsche Antwort hier nicht auffällt: Zwei schreibende + /// Aufrufe, die sich um dieselbe Datei oder denselben Datensatz streiten, + /// scheitern nicht zuverlässig, sondern gelegentlich. Ein Werkzeug gibt sich + /// deshalb ausdrücklich frei, und nur dann, wenn es nichts verändert. + /// + /// „Nichts verändert" heißt: kein Schreiben in Dateien, Datenbank oder Fremdsystem + /// und keine Abhängigkeit davon, was ein anderer Aufruf desselben Zuges tut. + /// Ein reiner Abruf erfüllt das, eine Ablage nicht. + /// + bool IsParallelSafe => false; + Task ExecuteAsync( JsonElement input, AgentToolContext context, diff --git a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs index 5765e15..6f54581 100644 --- a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs +++ b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs @@ -17,6 +17,18 @@ public sealed class WebFetchTool : IAgentTool Aktionen: fetch, rss """; + /// + /// Freigegeben: Dieses Werkzeug liest ausschließlich. Beide Aktionen enden in einem + /// GetAsync; es gibt keinen Schreibzugriff — weder auf Dateien noch auf den + /// StateStore. Drei Abrufe gleichzeitig unterscheiden sich im Ergebnis durch nichts + /// von drei Abrufen nacheinander. + /// + /// Zum Vergleich das Nachbarwerkzeug WebMonitor: Es sieht ähnlich aus, merkt + /// sich aber den zuletzt gesehenen Stand über StateStore.SetAsync und bleibt + /// deshalb seriell. + /// + public bool IsParallelSafe => true; + public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", diff --git a/tests/ClawdDotNet.Core.Tests/Engine/HarnessHaertungTests.cs b/tests/ClawdDotNet.Core.Tests/Engine/HarnessHaertungTests.cs new file mode 100644 index 0000000..c94c286 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Engine/HarnessHaertungTests.cs @@ -0,0 +1,402 @@ +using System.Diagnostics; +using System.Text.Json; +using ClawdDotNet.Core.Api.Models; +using ClawdDotNet.Core.Engine; +using ClawdDotNet.Core.Tests.Infrastructure; + +namespace ClawdDotNet.Core.Tests.Engine; + +/// +/// Die Härtung des Harness gegen stillen Verlust: #547 bis #551. +/// +/// Was diese fünf Befunde verbindet, ist nicht der Ort im Code, sondern der +/// Fehlermodus — keiner von ihnen erzeugt eine Fehlermeldung. Der Lauf wird fertig, +/// das Ergebnis sieht plausibel aus, und nur die Qualität ist schlechter. Genau +/// deshalb braucht jeder einen Test: Von Hand fällt so etwas nicht auf. +/// +public class HarnessHaertungTests +{ + // ─────────────────────────── #547 ─────────────────────────── + + [Fact] + public async Task Der_Begleittext_zum_Werkzeugaufruf_bleibt_im_Verlauf() + { + var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis")); + var agent = fixture.AddAgent("a1", "TestTool"); + + fixture.Client + .RespondsWithToolCallAndContent("TestTool", "Ich sehe zuerst in der Konfiguration nach.") + .RespondsWithText("Fertig."); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + // Die zweite Anfrage trägt den Verlauf, den die erste erzeugt hat. + var verlauf = fixture.Client.ReceivedRequests[1].Messages; + var mitAufruf = verlauf.Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }); + + Assert.Equal("Ich sehe zuerst in der Konfiguration nach.", mitAufruf.Content); + } + + [Fact] + public async Task Ohne_Begleittext_bleibt_die_Nachricht_ohne_Inhalt() + { + // Gegenprobe: Ein leerer Text darf nicht als "" mitgehen. Manche Anbieter + // lehnen eine assistant-Nachricht mit leerem Inhalt und tool_calls ab. + var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis")); + var agent = fixture.AddAgent("a1", "TestTool"); + + fixture.Client + .RespondsWithToolCall("TestTool") + .RespondsWithText("Fertig."); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + var mitAufruf = fixture.Client.ReceivedRequests[1].Messages + .Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }); + + Assert.Null(mitAufruf.Content); + } + + // ─────────────────────────── #548 ─────────────────────────── + + [Theory] + [InlineData("length")] + [InlineData("max_tokens")] + [InlineData("LENGTH")] + public void Ein_Abbruch_an_der_Ausgabegrenze_wird_erkannt(string grund) + => Assert.True(AgentEngine.IsTruncated(grund)); + + [Theory] + [InlineData("stop")] + [InlineData("tool_calls")] + [InlineData("end_turn")] + [InlineData(null)] + public void Ein_regulaeres_Ende_gilt_nicht_als_Abbruch(string? grund) + => Assert.False(AgentEngine.IsTruncated(grund)); + + [Fact] + public async Task Eine_abgeschnittene_Antwort_wird_nicht_als_fertig_gemeldet() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); + + fixture.Client.RespondsWithTruncatedText("Der Bericht beginnt mit"); + + var result = await fixture.Engine.RunAsync(agent, "Bericht", "inst", CancellationToken.None); + + Assert.Equal(AgentRunStatus.Truncated, result.Status); + + // Das Teilergebnis geht trotzdem mit — es wegzuwerfen wäre schlimmer, als es + // gekennzeichnet weiterzureichen. + Assert.Equal("Der Bericht beginnt mit", result.FinalMessage); + } + + [Fact] + public async Task Eine_vollstaendige_Antwort_bleibt_Completed() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); + + fixture.Client.RespondsWithText("Fertiger Bericht."); + + var result = await fixture.Engine.RunAsync(agent, "Bericht", "inst", CancellationToken.None); + + Assert.Equal(AgentRunStatus.Completed, result.Status); + } + + // ─────────────────────────── #549 ─────────────────────────── + + [Fact] + public async Task Der_Denkverlauf_geht_unveraendert_zurueck() + { + const string details = """ + [{"type":"reasoning.text","text":"Erst die Konfiguration prüfen.","id":"r1","format":"anthropic-claude-v1","index":0,"signature":"sha256:abc"}] + """; + + var fixture = new EngineFixture().WithTool(FakeTool.Returning("Ergebnis")); + var agent = fixture.AddAgent("a1", "TestTool"); + + fixture.Client + .RespondsWithToolCallAndContent("TestTool", "Ich prüfe.", reasoningDetailsJson: details) + .RespondsWithText("Fertig."); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + var mitAufruf = fixture.Client.ReceivedRequests[1].Messages + .Single(m => m.Role == "assistant" && m.ToolCalls is { Count: > 0 }); + + Assert.NotNull(mitAufruf.ReasoningDetails); + + // Signatur und Reihenfolge müssen erhalten sein — daran erkennt der Anbieter + // seinen eigenen Block wieder. + var block = mitAufruf.ReasoningDetails!.Value[0]; + Assert.Equal("reasoning.text", block.GetProperty("type").GetString()); + Assert.Equal("sha256:abc", block.GetProperty("signature").GetString()); + } + + [Fact] + public void Der_Denkverlauf_ueberlebt_Serialisierung_und_Rueckweg() + { + // Der Verlauf wird auf die Platte geschrieben und beim Start wieder geladen. + // Überlebt der Denkverlauf das nicht, ist er nach einem Neustart weg. + const string details = """ + [{"type":"reasoning.encrypted","data":"AAAA","id":"r1","index":0}] + """; + + var original = ChatMessage.AssistantWithToolCalls( + [new ToolCall { Id = "c1", Type = "function", Function = new ToolCallFunction { Name = "T", Arguments = "{}" } }], + content: "Text", + reasoningDetails: JsonDocument.Parse(details).RootElement.Clone()); + + var json = JsonSerializer.Serialize(original); + var zurueck = JsonSerializer.Deserialize(json)!; + + Assert.Equal("Text", zurueck.Content); + Assert.NotNull(zurueck.ReasoningDetails); + Assert.Equal("reasoning.encrypted", zurueck.ReasoningDetails!.Value[0].GetProperty("type").GetString()); + Assert.Equal("AAAA", zurueck.ReasoningDetails!.Value[0].GetProperty("data").GetString()); + } + + [Fact] + public void Ohne_Denkverlauf_steht_kein_leeres_Feld_in_der_Anfrage() + { + var ohne = ChatMessage.Assistant("Nur Text"); + var json = JsonSerializer.Serialize(ohne); + + Assert.DoesNotContain("reasoning", json); + } + + // ─────────────────────────── #550 ─────────────────────────── + + [Fact] + public async Task Die_Anfrage_verlangt_einen_Anbieter_der_alles_beherrscht() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); + fixture.Client.RespondsWithText("ok"); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + var request = fixture.Client.ReceivedRequests[0]; + + Assert.True(request.Provider?.RequireParameters); + Assert.True(request.Usage?.Include); + } + + [Fact] + public async Task Die_Anbieterbindung_laesst_sich_je_Agent_abschalten() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); + agent.RequireProviderParameters = false; + fixture.Client.RespondsWithText("ok"); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + Assert.Null(fixture.Client.ReceivedRequests[0].Provider); + } + + [Fact] + public async Task Ohne_Denktiefe_geht_kein_reasoning_Feld_mit() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); // Vorgabe ist "off" + fixture.Client.RespondsWithText("ok"); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + Assert.Null(fixture.Client.ReceivedRequests[0].Reasoning); + } + + [Theory] + [InlineData("high", "high", null)] + [InlineData("MAX", "max", null)] + [InlineData("4096", null, 4096)] + public async Task Die_Denktiefe_wird_richtig_uebersetzt(string einstellung, string? effort, int? tokens) + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); + agent.ReasoningEffort = einstellung; + fixture.Client.RespondsWithText("ok"); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + var reasoning = fixture.Client.ReceivedRequests[0].Reasoning; + Assert.NotNull(reasoning); + Assert.Equal(effort, reasoning!.Effort); + Assert.Equal(tokens, reasoning.MaxTokens); + } + + [Fact] + public async Task Der_gemeldete_Betrag_wird_uebernommen_statt_geschaetzt() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); + + fixture.Client.RespondsWithText("ok", new Usage + { + PromptTokens = 100, + CompletionTokens = 20, + TotalTokens = 120, + Cost = 0.0042m + }); + + var result = await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + Assert.Equal(0.0042m, result.ReportedCostUsd); + } + + [Fact] + public async Task Ohne_gemeldeten_Betrag_bleibt_das_Feld_leer() + { + // Wichtig: nicht 0. Ein als kostenlos verbuchter Lauf wäre schlimmer als + // gar keine Angabe, weil er die Budgetrechnung still verfälscht. + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("a1"); + fixture.Client.RespondsWithText("ok"); + + var result = await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + Assert.Null(result.ReportedCostUsd); + } + + // ─────────────────────────── #551 ─────────────────────────── + + [Fact] + public async Task Freigegebene_Werkzeuge_laufen_tatsaechlich_gleichzeitig() + { + var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(300)); + var fixture = new EngineFixture().WithTool(tool); + var agent = fixture.AddAgent("a1", "ParallelTool"); + + fixture.Client + .RespondsWithToolCalls( + ("ParallelTool", "{}", "c1"), + ("ParallelTool", "{}", "c2"), + ("ParallelTool", "{}", "c3")) + .RespondsWithText("Fertig."); + + var sw = Stopwatch.StartNew(); + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + sw.Stop(); + + // Der belastbare Nachweis: Aufrufe haben sich überlappt. + Assert.True(tool.MaxConcurrent > 1, + $"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}."); + + // Die Laufzeit als zweite, schwächere Bestätigung — seriell wären es 900 ms. + Assert.True(sw.ElapsedMilliseconds < 750, + $"Erwartet: deutlich unter 900 ms. Gemessen: {sw.ElapsedMilliseconds} ms."); + } + + [Fact] + public async Task Nicht_freigegebene_Werkzeuge_bleiben_seriell() + { + var tool = FakeTool.SlowSerial(TimeSpan.FromMilliseconds(80)); + var fixture = new EngineFixture().WithTool(tool); + var agent = fixture.AddAgent("a1", "SerialTool"); + + fixture.Client + .RespondsWithToolCalls( + ("SerialTool", "{}", "c1"), + ("SerialTool", "{}", "c2"), + ("SerialTool", "{}", "c3")) + .RespondsWithText("Fertig."); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + Assert.Equal(1, tool.MaxConcurrent); + } + + [Fact] + public async Task Die_Reihenfolge_der_Ergebnisse_folgt_den_Aufrufen() + { + // Der langsamste Aufruf kommt zuerst. Würde die Engine nach Fertigstellung + // einsortieren, stünde er am Ende — und jede tool_call_id wäre falsch zugeordnet. + var tool = new StaffelTool(); + var fixture = new EngineFixture().WithTool(tool); + var agent = fixture.AddAgent("a1", "StaffelTool"); + + fixture.Client + .RespondsWithToolCalls( + ("StaffelTool", """{"delay":250,"marke":"erste"}""", "c1"), + ("StaffelTool", """{"delay":10,"marke":"zweite"}""", "c2"), + ("StaffelTool", """{"delay":120,"marke":"dritte"}""", "c3")) + .RespondsWithText("Fertig."); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + var antworten = fixture.Client.ReceivedRequests[1].Messages + .Where(m => m.Role == "tool") + .ToList(); + + Assert.Equal(["c1", "c2", "c3"], antworten.Select(m => m.ToolCallId)); + Assert.Equal(["erste", "zweite", "dritte"], antworten.Select(m => m.Content)); + } + + [Fact] + public async Task Die_Grenze_der_Nebenlaeufigkeit_wird_eingehalten() + { + var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(120)); + var fixture = new EngineFixture().WithTool(tool); + var agent = fixture.AddAgent("a1", "ParallelTool"); + agent.MaxParallelToolCalls = 2; + + fixture.Client + .RespondsWithToolCalls( + ("ParallelTool", "{}", "c1"), + ("ParallelTool", "{}", "c2"), + ("ParallelTool", "{}", "c3"), + ("ParallelTool", "{}", "c4")) + .RespondsWithText("Fertig."); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + Assert.True(tool.MaxConcurrent <= 2, + $"Grenze war 2, gleichzeitig liefen aber {tool.MaxConcurrent}."); + } + + [Fact] + public async Task Bei_Grenze_eins_bleibt_alles_seriell() + { + var tool = FakeTool.SlowParallelSafe(TimeSpan.FromMilliseconds(60)); + var fixture = new EngineFixture().WithTool(tool); + var agent = fixture.AddAgent("a1", "ParallelTool"); + agent.MaxParallelToolCalls = 1; + + fixture.Client + .RespondsWithToolCalls( + ("ParallelTool", "{}", "c1"), + ("ParallelTool", "{}", "c2")) + .RespondsWithText("Fertig."); + + await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None); + + Assert.Equal(1, tool.MaxConcurrent); + } +} + +/// +/// Werkzeug, das nach einer im Aufruf angegebenen Zeit eine Marke zurückgibt. +/// Damit lässt sich prüfen, ob die Reihenfolge der Ergebnisse den Aufrufen folgt +/// und nicht der Fertigstellung. +/// +internal sealed class StaffelTool : ClawdDotNet.Core.Tools.IAgentTool +{ + public string Name => "StaffelTool"; + public string Description => "Wartet und meldet eine Marke."; + public bool IsParallelSafe => true; + + public JsonElement InputSchema { get; } = JsonDocument.Parse(""" + { "type": "object", "properties": { "delay": { "type": "integer" }, "marke": { "type": "string" } } } + """).RootElement.Clone(); + + public async Task ExecuteAsync( + JsonElement input, ClawdDotNet.Core.Tools.AgentToolContext context, CancellationToken ct) + { + var delay = input.GetProperty("delay").GetInt32(); + var marke = input.GetProperty("marke").GetString()!; + await Task.Delay(delay, ct); + return ClawdDotNet.Core.Tools.ToolResult.Ok(marke); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs index 2c696bc..3c8ba12 100644 --- a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeChatClient.cs @@ -76,6 +76,52 @@ internal sealed class FakeChatClient : IChatCompletionClient return this; } + /// + /// Antwortet mit einem an der Ausgabegrenze abgeschnittenen Text (#548). + /// + public FakeChatClient RespondsWithTruncatedText(string text, string finishReason = "length") + { + _responses.Enqueue(_ => new ChatResponse + { + Id = "resp", + Choices = + [ + new Choice + { + Index = 0, + Message = new ChatMessage { Role = "assistant", Content = text }, + FinishReason = finishReason + } + ], + Usage = new Usage { PromptTokens = 100, CompletionTokens = 20, TotalTokens = 120 } + }); + return this; + } + + /// + /// Antwortet mit Werkzeugaufrufen UND Begleittext bzw. Denkverlauf — der Fall, den + /// #547 und #549 betreffen. + /// + public FakeChatClient RespondsWithToolCallAndContent( + string toolName, + string content, + string? reasoning = null, + string? reasoningDetailsJson = null, + string argumentsJson = "{}") + { + _responses.Enqueue(_ => + { + var response = ToolCallResponse([(toolName, argumentsJson, null)]); + var message = response.Choices[0].Message; + message.Content = content; + message.Reasoning = reasoning; + if (reasoningDetailsJson is not null) + message.ReasoningDetails = JsonDocument.Parse(reasoningDetailsJson).RootElement.Clone(); + return response; + }); + return this; + } + /// Antwort für alle Aufrufe, die über die programmierte Folge hinausgehen. public FakeChatClient AlwaysRespondsWithText(string text) { @@ -143,6 +189,11 @@ internal sealed class FakeChatClient : IChatCompletionClient MaxTokens = request.MaxTokens, ToolChoice = request.ToolChoice, Tools = request.Tools?.ToList(), + // Die Gateway-Felder gehören mitkopiert, sonst prüft ein Test die Härtung + // am Wire-Format vorbei — dieselbe Falle wie bei CacheBreakpoint. + Provider = request.Provider, + Usage = request.Usage, + Reasoning = request.Reasoning, Messages = request.Messages.Select(CloneMessage).ToList() }; @@ -153,6 +204,8 @@ internal sealed class FakeChatClient : IChatCompletionClient ToolCallId = m.ToolCallId, // Muss mitkopiert werden, sonst prüfen Caching-Tests am Wire-Format vorbei. CacheBreakpoint = m.CacheBreakpoint, + Reasoning = m.Reasoning, + ReasoningDetails = m.ReasoningDetails, ToolCalls = m.ToolCalls?.Select(tc => new ToolCall { Id = tc.Id, diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs index 1e9c190..e8d9980 100644 --- a/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/FakeTool.cs @@ -22,12 +22,47 @@ internal sealed class FakeTool : IAgentTool /// Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id. public ConcurrentBag Invocations { get; } = new(); + /// Ob das Werkzeug sich für gleichzeitige Ausführung freigibt (#551). + public bool IsParallelSafe { get; private init; } + + /// + /// Höchste Zahl gleichzeitig laufender Aufrufe, die dieses Werkzeug gesehen hat. + /// + /// Das ist der einzige Nachweis, der zählt: Eine kürzere Gesamtlaufzeit könnte auch + /// von einer schnellen Maschine kommen, aber ein Höchststand von 2 kann nur + /// entstehen, wenn zwei Aufrufe einander tatsächlich überlappt haben. + /// + public int MaxConcurrent => _maxConcurrent; + + private int _current; + private int _maxConcurrent; + private FakeTool(string name, Func> handler) { Name = name; _handler = handler; } + /// + /// Ein langsames Werkzeug, das sich für Nebenläufigkeit freigibt und mitzählt, + /// wie viele Aufrufe sich überlappt haben. + /// + public static FakeTool SlowParallelSafe(TimeSpan delay, string result = "ok", string name = "ParallelTool") + => new(name, async (_, _, ct) => + { + await Task.Delay(delay, ct); + return ToolResult.Ok(result); + }) + { IsParallelSafe = true }; + + /// Dasselbe, aber ohne Freigabe — muss seriell bleiben. + public static FakeTool SlowSerial(TimeSpan delay, string result = "ok", string name = "SerialTool") + => new(name, async (_, _, ct) => + { + await Task.Delay(delay, ct); + return ToolResult.Ok(result); + }); + public static FakeTool Returning(string result, string name = "TestTool") => new(name, (_, _, _) => Task.FromResult(ToolResult.Ok(result))); @@ -42,10 +77,26 @@ internal sealed class FakeTool : IAgentTool public static FakeTool Throwing(Exception ex, string name = "TestTool") => new(name, (_, _, _) => throw ex); - public Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) + public async Task ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { Invocations.Add(context.AgentId); - return _handler(input, context, ct); + + var now = Interlocked.Increment(ref _current); + + // Höchststand fortschreiben, ohne ihn je zu senken: Vergleichen und Tauschen, + // bis der gespeicherte Wert nicht mehr kleiner ist. + int seen; + while (now > (seen = Volatile.Read(ref _maxConcurrent))) + Interlocked.CompareExchange(ref _maxConcurrent, now, seen); + + try + { + return await _handler(input, context, ct); + } + finally + { + Interlocked.Decrement(ref _current); + } } }