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:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ChatMessage>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ClawdDotNet.Core.Tools.ToolResult> 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);
|
||||
}
|
||||
}
|
||||
@@ -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