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