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:
@@ -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,
|
||||
|
||||
@@ -22,12 +22,47 @@ internal sealed class FakeTool : IAgentTool
|
||||
/// <summary>Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id.</summary>
|
||||
public ConcurrentBag<string> Invocations { get; } = new();
|
||||
|
||||
/// <summary>Ob das Werkzeug sich für gleichzeitige Ausführung freigibt (#551).</summary>
|
||||
public bool IsParallelSafe { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int MaxConcurrent => _maxConcurrent;
|
||||
|
||||
private int _current;
|
||||
private int _maxConcurrent;
|
||||
|
||||
private FakeTool(string name, Func<JsonElement, AgentToolContext, CancellationToken, Task<ToolResult>> handler)
|
||||
{
|
||||
Name = name;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein langsames Werkzeug, das sich für Nebenläufigkeit freigibt und mitzählt,
|
||||
/// wie viele Aufrufe sich überlappt haben.
|
||||
/// </summary>
|
||||
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 };
|
||||
|
||||
/// <summary>Dasselbe, aber ohne Freigabe — muss seriell bleiben.</summary>
|
||||
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<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
public async Task<ToolResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user