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:
@@ -28,14 +28,45 @@ public sealed class ChatMessage
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool CacheBreakpoint { get; set; }
|
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 System(string content) => new() { Role = "system", Content = content };
|
||||||
public static ChatMessage User(string content) => new() { Role = "user", 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 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",
|
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()
|
public static ChatMessage ToolResponse(string toolCallId, string content) => new()
|
||||||
@@ -95,6 +126,24 @@ public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
|
|||||||
message.ToolCallId = reader.GetString();
|
message.ToolCallId = reader.GetString();
|
||||||
break;
|
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:
|
default:
|
||||||
reader.Skip();
|
reader.Skip();
|
||||||
break;
|
break;
|
||||||
@@ -175,6 +224,18 @@ public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
|
|||||||
if (value.ToolCallId is not null)
|
if (value.ToolCallId is not null)
|
||||||
writer.WriteString("tool_call_id", value.ToolCallId);
|
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();
|
writer.WriteEndObject();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,98 @@ public sealed class ChatRequest
|
|||||||
[JsonPropertyName("stream")]
|
[JsonPropertyName("stream")]
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||||
public bool Stream { get; set; }
|
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")]
|
[JsonPropertyName("prompt_tokens_details")]
|
||||||
public PromptTokensDetails? PromptTokensDetails { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Anteil der Prompt-Tokens, der aus dem Cache gelesen wurde. Nur damit lässt sich
|
/// 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
|
/// belegen, ob das Prompt-Caching tatsächlich greift — ein still wirkungsloser
|
||||||
|
|||||||
@@ -107,8 +107,60 @@ public sealed class AgentConfig
|
|||||||
[JsonPropertyName("maxToolResultChars")]
|
[JsonPropertyName("maxToolResultChars")]
|
||||||
public int MaxToolResultChars { get; set; } = 16_000;
|
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")]
|
[JsonPropertyName("budget")]
|
||||||
public AgentBudget Budget { get; set; } = new();
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
messages.Add(ChatMessage.User(userMessage));
|
messages.Add(ChatMessage.User(userMessage));
|
||||||
|
|
||||||
string? finalMessage = null;
|
string? finalMessage = null;
|
||||||
|
var truncated = false;
|
||||||
var tally = new TokenTally();
|
var tally = new TokenTally();
|
||||||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||||||
|
|
||||||
@@ -216,16 +217,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
if (cachingEnabled)
|
if (cachingEnabled)
|
||||||
PromptCache.ApplyBreakpoints(messages);
|
PromptCache.ApplyBreakpoints(messages);
|
||||||
|
|
||||||
var request = new ChatRequest
|
var request = BuildRequest(agentConfig, messages, toolDefinitions);
|
||||||
{
|
|
||||||
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 estimated = _compactor.EstimateFor(agentConfig.Model, messages);
|
var estimated = _compactor.EstimateFor(agentConfig.Model, messages);
|
||||||
|
|
||||||
@@ -249,15 +241,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
|
|
||||||
if (assistantMessage.ToolCalls is { Count: > 0 })
|
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 toolResults = await ExecuteToolCallsAsync(
|
||||||
{
|
assistantMessage.ToolCalls, agentConfig, instanceId, tools,
|
||||||
var toolResult = await ExecuteToolCallAsync(
|
logger, ct, runId, source, chain);
|
||||||
toolCall, 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,
|
OnStepCompleted?.Invoke(agentConfig.AgentId,
|
||||||
$"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed");
|
$"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed");
|
||||||
@@ -265,18 +264,28 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
finalMessage = assistantMessage.Content ?? "";
|
finalMessage = assistantMessage.Content ?? "";
|
||||||
|
truncated = IsTruncated(choice.FinishReason);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sw.Stop();
|
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(
|
logger.LogInformation(
|
||||||
"Agent run completed: {AgentId}, steps={Steps}, tokens={Tokens} (davon {Cached} aus Cache), duration={Duration}ms",
|
"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);
|
agentConfig.AgentId, loopGuard.Steps, tally.Total, tally.Cached, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
var result = new AgentRunResult(
|
var result = new AgentRunResult(
|
||||||
agentConfig.AgentId,
|
agentConfig.AgentId,
|
||||||
AgentRunStatus.Completed,
|
truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed,
|
||||||
finalMessage,
|
finalMessage,
|
||||||
loopGuard.Steps,
|
loopGuard.Steps,
|
||||||
tally.Total,
|
tally.Total,
|
||||||
@@ -284,7 +293,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
{
|
{
|
||||||
PromptTokens = tally.Prompt,
|
PromptTokens = tally.Prompt,
|
||||||
CompletionTokens = tally.Completion,
|
CompletionTokens = tally.Completion,
|
||||||
CachedTokens = tally.Cached
|
CachedTokens = tally.Cached,
|
||||||
|
ReportedCostUsd = tally.ReportedCost
|
||||||
};
|
};
|
||||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||||
return result;
|
return result;
|
||||||
@@ -453,6 +463,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
|
AddChatEntry(agentConfig.AgentId, "user", userMessage, source);
|
||||||
|
|
||||||
string? finalMessage = null;
|
string? finalMessage = null;
|
||||||
|
var truncated = false;
|
||||||
var tally = new TokenTally();
|
var tally = new TokenTally();
|
||||||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||||||
|
|
||||||
@@ -474,16 +485,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
if (cachingEnabled)
|
if (cachingEnabled)
|
||||||
PromptCache.ApplyBreakpoints(messages);
|
PromptCache.ApplyBreakpoints(messages);
|
||||||
|
|
||||||
var request = new ChatRequest
|
var request = BuildRequest(agentConfig, messages, toolDefinitions);
|
||||||
{
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
// Was die Schätzung für genau diese Anfrage sagt — gleich neben dem, was
|
// 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
|
// 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 })
|
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 toolResults = await ExecuteToolCallsAsync(
|
||||||
{
|
assistantMessage.ToolCalls, agentConfig, instanceId, tools,
|
||||||
var toolResult = await ExecuteToolCallAsync(
|
logger, ct, runId, source, chain);
|
||||||
toolCall, 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,
|
OnStepCompleted?.Invoke(agentConfig.AgentId,
|
||||||
$"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)");
|
$"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)");
|
||||||
@@ -525,6 +533,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
finalMessage = assistantMessage.Content ?? "";
|
finalMessage = assistantMessage.Content ?? "";
|
||||||
|
truncated = IsTruncated(choice.FinishReason);
|
||||||
messages.Add(ChatMessage.Assistant(finalMessage));
|
messages.Add(ChatMessage.Assistant(finalMessage));
|
||||||
AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source);
|
AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source);
|
||||||
break;
|
break;
|
||||||
@@ -532,13 +541,25 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
}
|
}
|
||||||
|
|
||||||
sw.Stop();
|
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(
|
var result = new AgentRunResult(
|
||||||
agentConfig.AgentId, AgentRunStatus.Completed, finalMessage,
|
agentConfig.AgentId,
|
||||||
|
truncated ? AgentRunStatus.Truncated : AgentRunStatus.Completed,
|
||||||
|
finalMessage,
|
||||||
loopGuard.Steps, tally.Total, sw.Elapsed)
|
loopGuard.Steps, tally.Total, sw.Elapsed)
|
||||||
{
|
{
|
||||||
PromptTokens = tally.Prompt,
|
PromptTokens = tally.Prompt,
|
||||||
CompletionTokens = tally.Completion,
|
CompletionTokens = tally.Completion,
|
||||||
CachedTokens = tally.Cached
|
CachedTokens = tally.Cached,
|
||||||
|
ReportedCostUsd = tally.ReportedCost
|
||||||
};
|
};
|
||||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||||
return 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(
|
private async Task<string> ExecuteToolCallAsync(
|
||||||
ToolCall toolCall,
|
ToolCall toolCall,
|
||||||
AgentConfig agentConfig,
|
AgentConfig agentConfig,
|
||||||
@@ -1234,6 +1381,12 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
{
|
{
|
||||||
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
|
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
|
await _usageRepository.RecordAsync(new RunUsage
|
||||||
{
|
{
|
||||||
AgentId = agentConfig.AgentId,
|
AgentId = agentConfig.AgentId,
|
||||||
@@ -1241,8 +1394,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
PromptTokens = result.PromptTokens,
|
PromptTokens = result.PromptTokens,
|
||||||
CompletionTokens = result.CompletionTokens,
|
CompletionTokens = result.CompletionTokens,
|
||||||
CachedTokens = result.CachedTokens,
|
CachedTokens = result.CachedTokens,
|
||||||
CostUsd = estimate?.Usd ?? 0m,
|
CostUsd = reported ?? estimate?.Usd ?? 0m,
|
||||||
CostIsKnown = estimate?.IsKnown ?? false,
|
CostIsKnown = reported is not null || (estimate?.IsKnown ?? false),
|
||||||
Status = result.Status.ToString(),
|
Status = result.Status.ToString(),
|
||||||
StepCount = result.StepCount,
|
StepCount = result.StepCount,
|
||||||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||||||
@@ -1343,6 +1496,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
public int Completion { get; private set; }
|
public int Completion { get; private set; }
|
||||||
public int Cached { 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).
|
// Voll qualifiziert: "Usage" ist auch ein Namespace (ClawdDotNet.Core.Accounting).
|
||||||
public void Add(Api.Models.Usage usage)
|
public void Add(Api.Models.Usage usage)
|
||||||
{
|
{
|
||||||
@@ -1350,6 +1513,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
Prompt += usage.PromptTokens;
|
Prompt += usage.PromptTokens;
|
||||||
Completion += usage.CompletionTokens;
|
Completion += usage.CompletionTokens;
|
||||||
Cached += usage.CachedTokens;
|
Cached += usage.CachedTokens;
|
||||||
|
|
||||||
|
if (usage.Cost is { } cost)
|
||||||
|
ReportedCost = (ReportedCost ?? 0m) + cost;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ public sealed record AgentRunResult(
|
|||||||
/// in <see cref="PromptTokens"/> enthalten, kosten aber nur einen Bruchteil.
|
/// in <see cref="PromptTokens"/> enthalten, kosten aber nur einen Bruchteil.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int CachedTokens { get; init; }
|
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
|
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
|
/// 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>
|
/// dazukämen — die Instanz käme nie zum Ende.</para>
|
||||||
/// </summary>
|
/// </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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,21 @@ public interface IAgentTool
|
|||||||
string Description { get; }
|
string Description { get; }
|
||||||
JsonElement InputSchema { 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(
|
Task<ToolResult> ExecuteAsync(
|
||||||
JsonElement input,
|
JsonElement input,
|
||||||
AgentToolContext context,
|
AgentToolContext context,
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ public sealed class WebFetchTool : IAgentTool
|
|||||||
Aktionen: fetch, rss
|
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("""
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -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;
|
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>
|
/// <summary>Antwort für alle Aufrufe, die über die programmierte Folge hinausgehen.</summary>
|
||||||
public FakeChatClient AlwaysRespondsWithText(string text)
|
public FakeChatClient AlwaysRespondsWithText(string text)
|
||||||
{
|
{
|
||||||
@@ -143,6 +189,11 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
|||||||
MaxTokens = request.MaxTokens,
|
MaxTokens = request.MaxTokens,
|
||||||
ToolChoice = request.ToolChoice,
|
ToolChoice = request.ToolChoice,
|
||||||
Tools = request.Tools?.ToList(),
|
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()
|
Messages = request.Messages.Select(CloneMessage).ToList()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,6 +204,8 @@ internal sealed class FakeChatClient : IChatCompletionClient
|
|||||||
ToolCallId = m.ToolCallId,
|
ToolCallId = m.ToolCallId,
|
||||||
// Muss mitkopiert werden, sonst prüfen Caching-Tests am Wire-Format vorbei.
|
// Muss mitkopiert werden, sonst prüfen Caching-Tests am Wire-Format vorbei.
|
||||||
CacheBreakpoint = m.CacheBreakpoint,
|
CacheBreakpoint = m.CacheBreakpoint,
|
||||||
|
Reasoning = m.Reasoning,
|
||||||
|
ReasoningDetails = m.ReasoningDetails,
|
||||||
ToolCalls = m.ToolCalls?.Select(tc => new ToolCall
|
ToolCalls = m.ToolCalls?.Select(tc => new ToolCall
|
||||||
{
|
{
|
||||||
Id = tc.Id,
|
Id = tc.Id,
|
||||||
|
|||||||
@@ -22,12 +22,47 @@ internal sealed class FakeTool : IAgentTool
|
|||||||
/// <summary>Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id.</summary>
|
/// <summary>Jeder Aufruf wird mitgeschrieben — inklusive der aufrufenden Agent-Id.</summary>
|
||||||
public ConcurrentBag<string> Invocations { get; } = new();
|
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)
|
private FakeTool(string name, Func<JsonElement, AgentToolContext, CancellationToken, Task<ToolResult>> handler)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
_handler = handler;
|
_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")
|
public static FakeTool Returning(string result, string name = "TestTool")
|
||||||
=> new(name, (_, _, _) => Task.FromResult(ToolResult.Ok(result)));
|
=> 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")
|
public static FakeTool Throwing(Exception ex, string name = "TestTool")
|
||||||
=> new(name, (_, _, _) => throw ex);
|
=> 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);
|
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