Harness gegen stillen Verlust gehaertet (#547-#551)

Fuenf Befunde mit einem gemeinsamen Fehlermodus: Keiner erzeugt eine
Fehlermeldung. Der Lauf wird fertig, das Ergebnis sieht plausibel aus,
und nur die Qualitaet ist schlechter.

#547 Der Begleittext zum Werkzeugaufruf ging verloren. AssistantWithToolCalls
baute eine neue Nachricht aus nur den Aufrufen; der Text, den das Modell
daneben geschrieben hatte, fiel weg. Damit verlor das Modell zwischen zwei
Schritten seinen eigenen Plan, und im Audit stand, dass ein Werkzeug lief,
nicht warum.

#548 finish_reason wurde nirgends gelesen. Eine an der Ausgabegrenze
abgeschnittene Antwort galt als fertiges Ergebnis. Das trifft besonders
Laeufe im Hintergrund, wo niemand zusieht. Neuer Status Truncated: Das
Teilergebnis geht weiter, aber gekennzeichnet.

#549 reasoning_details wurde verworfen. Bei Werkzeugnutzung muss der
Denkverlauf unveraendert zurueck, sonst beginnt ein Thinking-Modell jedes
Mal von vorn. Wird jetzt als rohes JSON durchgereicht - ohne Auslegung,
weil Reihenfolge und Signatur daran haengen.

#550 Die Anfrage verlangt jetzt einen Anbieter, der alle mitgeschickten
Parameter beherrscht. Ohne das durfte ein Anbieter cache_control, reasoning
oder sogar tools stillschweigend uebergehen - der Lauf lieferte dann eine
gewoehnliche Textantwort, wo ein Werkzeugaufruf hingehoert haette. Dazu
usage.include: der abgerechnete Betrag schlaegt die Eigenrechnung, die als
Rueckfallebene bleibt. Denktiefe je Agent einstellbar, Vorgabe off.

#551 Werkzeuge eines Zuges laufen gleichzeitig, sofern sie sich dafuer
freigeben. Die Vorgabe von IsParallelSafe ist false, weil die falsche
Antwort hier nicht auffaellt: Zwei schreibende Aufrufe auf derselben Datei
scheitern nicht zuverlaessig, sondern gelegentlich. Freigegeben ist bisher
nur WebFetch - es liest ausschliesslich. Die Reihenfolge der Ergebnisse
folgt den Aufrufen, nicht der Fertigstellung; daran haengen die
tool_call_id-Zuordnung und der Cache-Praefix.

27 neue Tests, 887 gesamt, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-12 13:34:36 +02:00
co-authored by Claude Opus 5
parent fb185ca84d
commit ea3844006f
11 changed files with 979 additions and 45 deletions
+63 -2
View File
@@ -28,14 +28,45 @@ public sealed class ChatMessage
[JsonIgnore]
public bool CacheBreakpoint { get; set; }
/// <summary>
/// Der Denkverlauf als Klartext, so wie der Anbieter ihn geliefert hat.
/// </summary>
public string? Reasoning { get; set; }
/// <summary>
/// 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.
/// </summary>
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<ToolCall> toolCalls) => new()
/// <summary>
/// Baut die assistant-Nachricht für einen Zug mit Werkzeugaufrufen.
///
/// <paramref name="content"/> 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, <i>dass</i> ein Werkzeug lief, nicht <i>warum</i>.
/// </summary>
public static ChatMessage AssistantWithToolCalls(
List<ToolCall> 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<ChatMessage>
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<ChatMessage>
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();
}
}
@@ -29,4 +29,98 @@ public sealed class ChatRequest
[JsonPropertyName("stream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Stream { get; set; }
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("provider")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ProviderPreferences? Provider { get; set; }
/// <summary>
/// Fordert die Abrechnungsdaten des Gateways an. Ohne das bleibt die
/// Kostenrechnung eine Eigenrechnung aus Preisliste mal Tokenzahl.
/// </summary>
[JsonPropertyName("usage")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public UsageRequest? Usage { get; set; }
/// <summary>
/// Denktiefe. Ohne diese Angabe entsteht bei den meisten Modellen gar kein
/// Reasoning — der Parameter schaltet es überhaupt erst ein.
/// </summary>
[JsonPropertyName("reasoning")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ReasoningRequest? Reasoning { get; set; }
}
/// <summary>
/// Anbietervorgaben für das Gateway. Hier steht bewusst nur das, was diese Anwendung
/// tatsächlich setzt — die Schnittstelle kennt deutlich mehr Felder.
/// </summary>
public sealed class ProviderPreferences
{
/// <summary>
/// Nur an Anbieter weiterreichen, die <b>alle</b> mitgeschickten Parameter
/// beherrschen.
///
/// Das ist die eigentliche Härtung: Ohne sie darf ein Anbieter
/// <c>cache_control</c>, <c>reasoning</c> oder <c>tools</c> ignorieren, und der
/// Lauf liefert eine gewöhnliche Textantwort, wo ein Werkzeugaufruf hingehört
/// hätte. Mit ihr scheitert die Anfrage stattdessen sichtbar.
/// </summary>
[JsonPropertyName("require_parameters")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? RequireParameters { get; set; }
/// <summary>
/// Ob bei Nichtverfügbarkeit auf einen anderen Anbieter ausgewichen werden darf.
/// Null lässt die Voreinstellung des Gateways gelten.
/// </summary>
[JsonPropertyName("allow_fallbacks")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? AllowFallbacks { get; set; }
/// <summary>
/// <c>deny</c> schließt Anbieter aus, die Eingaben zu eigenen Zwecken speichern.
/// </summary>
[JsonPropertyName("data_collection")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DataCollection { get; set; }
}
public sealed class UsageRequest
{
[JsonPropertyName("include")]
public bool Include { get; set; }
}
/// <summary>
/// Denktiefe. <see cref="Effort"/> und <see cref="MaxTokens"/> schließen einander
/// nicht formal aus, werden aber von den Anbietern verschieden ausgelegt — deshalb
/// setzt <see cref="Config.AgentConfig"/> immer nur eines von beiden.
/// </summary>
public sealed class ReasoningRequest
{
/// <summary>max, xhigh, high, medium, low, minimal, none.</summary>
[JsonPropertyName("effort")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Effort { get; set; }
[JsonPropertyName("max_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxTokens { get; set; }
/// <summary>
/// Denken ja, aber nicht mitliefern. Spart Übertragung — kostet aber die
/// Fortsetzbarkeit über Werkzeugaufrufe hinweg, weil dann nichts zurückzugeben ist.
/// </summary>
[JsonPropertyName("exclude")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? Exclude { get; set; }
}
@@ -46,6 +46,14 @@ public sealed class Usage
[JsonPropertyName("prompt_tokens_details")]
public PromptTokensDetails? PromptTokensDetails { get; set; }
/// <summary>
/// Der vom Gateway tatsächlich abgerechnete Betrag. Kommt nur, wenn die Anfrage
/// <c>usage.include</c> gesetzt hat. Null heißt „nicht geliefert" — dann bleibt
/// es bei der Eigenrechnung aus Preisliste mal Tokenzahl.
/// </summary>
[JsonPropertyName("cost")]
public decimal? Cost { get; set; }
/// <summary>
/// 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
@@ -107,8 +107,60 @@ public sealed class AgentConfig
[JsonPropertyName("maxToolResultChars")]
public int MaxToolResultChars { get; set; } = 16_000;
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("maxParallelToolCalls")]
public int MaxParallelToolCalls { get; set; } = 4;
/// <summary>
/// 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 <see cref="LoopGuardConfig.MaxResponseTokens"/>
/// wie gegen das Budget.
/// </summary>
[JsonPropertyName("reasoningEffort")]
public string ReasoningEffort { get; set; } = "off";
/// <summary>
/// Ob das Gateway nur an Anbieter weiterreichen darf, die alle mitgeschickten
/// Parameter beherrschen.
///
/// Vorgabe <c>true</c>. Ohne das darf ein Anbieter <c>cache_control</c>,
/// <c>reasoning</c> oder sogar <c>tools</c> 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.
/// </summary>
[JsonPropertyName("requireProviderParameters")]
public bool RequireProviderParameters { get; set; } = true;
[JsonPropertyName("budget")]
public AgentBudget Budget { get; set; } = new();
/// <summary>
/// Übersetzt <see cref="ReasoningEffort"/> in das Anfragefeld. Null heißt:
/// nichts mitschicken, das Modell denkt wie voreingestellt.
/// </summary>
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() };
}
}
/// <summary>
+206 -40
View File
@@ -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
}
}
/// <summary>
/// Ob das Modell abgebrochen wurde, statt fertig zu werden (#548).
///
/// <para>Der Grund steht in <c>finish_reason</c>. Anbieter schreiben ihn verschieden:
/// <c>length</c> ist die verbreitete Form, <c>max_tokens</c> kommt bei einigen
/// vor. Beides meint dasselbe — die Ausgabegrenze war erreicht, bevor der Gedanke
/// zu Ende war.</para>
///
/// <para>Fehlt das Feld ganz, wird nicht von Abbruch ausgegangen. Ein Anbieter, der
/// nichts meldet, soll keine Warnung auslösen, die es nicht gibt.</para>
/// </summary>
internal static bool IsTruncated(string? finishReason) =>
finishReason is not null &&
(finishReason.Equals("length", StringComparison.OrdinalIgnoreCase) ||
finishReason.Equals("max_tokens", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// 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.
/// </summary>
private static ChatRequest BuildRequest(
AgentConfig agentConfig,
List<ChatMessage> messages,
List<ToolDefinition> 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()
};
/// <summary>
/// Führt die Werkzeugaufrufe eines Zuges aus und liefert die Ergebnisse
/// <b>in der Reihenfolge der Aufrufe</b> 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.
///
/// <para>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.</para>
/// </summary>
private async Task<string[]> ExecuteToolCallsAsync(
IReadOnlyList<ToolCall> toolCalls,
AgentConfig agentConfig,
string instanceId,
IReadOnlyList<IAgentTool> 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<int>();
var serial = new List<int>();
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<string> 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; }
/// <summary>
/// Summe der vom Gateway gemeldeten Beträge (#550). Null heißt: keiner der
/// Schritte hat einen geliefert — dann bleibt es bei der Eigenrechnung.
///
/// <para>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.</para>
/// </summary>
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;
}
}
+21 -1
View File
@@ -21,6 +21,15 @@ public sealed record AgentRunResult(
/// in <see cref="PromptTokens"/> enthalten, kosten aber nur einen Bruchteil.
/// </summary>
public int CachedTokens { get; init; }
/// <summary>
/// Der vom Gateway gemeldete Betrag über alle Schritte (#550), falls geliefert.
///
/// <para>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.</para>
/// </summary>
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.</para>
/// </summary>
ShuttingDown
ShuttingDown,
/// <summary>
/// Das Modell stieß an die Ausgabegrenze, bevor es fertig war (#548).
///
/// <para>Der Lauf ist damit nicht gescheitert — es liegt ein Teilergebnis vor, und
/// das steht in <see cref="AgentRunResult.FinalMessage"/>. Er ist aber auch nicht
/// <see cref="Completed"/>: 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.</para>
/// </summary>
Truncated
}
+15
View File
@@ -8,6 +8,21 @@ public interface IAgentTool
string Description { get; }
JsonElement InputSchema { get; }
/// <summary>
/// Ob dieses Werkzeug gefahrlos gleichzeitig mit anderen laufen darf.
///
/// Die Vorgabe ist <c>false</c> — 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.
/// </summary>
bool IsParallelSafe => false;
Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
@@ -17,6 +17,18 @@ public sealed class WebFetchTool : IAgentTool
Aktionen: fetch, rss
""";
/// <summary>
/// Freigegeben: Dieses Werkzeug liest ausschließlich. Beide Aktionen enden in einem
/// <c>GetAsync</c>; es gibt keinen Schreibzugriff — weder auf Dateien noch auf den
/// StateStore. Drei Abrufe gleichzeitig unterscheiden sich im Ergebnis durch nichts
/// von drei Abrufen nacheinander.
///
/// <para>Zum Vergleich das Nachbarwerkzeug WebMonitor: Es sieht ähnlich aus, merkt
/// sich aber den zuletzt gesehenen Stand über <c>StateStore.SetAsync</c> und bleibt
/// deshalb seriell.</para>
/// </summary>
public bool IsParallelSafe => true;
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",