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);
+ }
}
}