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
@@ -76,6 +76,52 @@ internal sealed class FakeChatClient : IChatCompletionClient
return this;
}
/// <summary>
/// Antwortet mit einem an der Ausgabegrenze abgeschnittenen Text (#548).
/// </summary>
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;
}
/// <summary>
/// Antwortet mit Werkzeugaufrufen UND Begleittext bzw. Denkverlauf — der Fall, den
/// #547 und #549 betreffen.
/// </summary>
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;
}
/// <summary>Antwort für alle Aufrufe, die über die programmierte Folge hinausgehen.</summary>
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,