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]
|
||||
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 User(string content) => new() { Role = "user", 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",
|
||||
ToolCalls = toolCalls
|
||||
Content = string.IsNullOrEmpty(content) ? null : content,
|
||||
ToolCalls = toolCalls,
|
||||
Reasoning = reasoning,
|
||||
ReasoningDetails = reasoningDetails
|
||||
};
|
||||
|
||||
public static ChatMessage ToolResponse(string toolCallId, string content) => new()
|
||||
@@ -95,6 +126,24 @@ public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
|
||||
message.ToolCallId = reader.GetString();
|
||||
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:
|
||||
reader.Skip();
|
||||
break;
|
||||
@@ -175,6 +224,18 @@ public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
|
||||
if (value.ToolCallId is not null)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,98 @@ public sealed class ChatRequest
|
||||
[JsonPropertyName("stream")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
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")]
|
||||
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>
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user