Files
ClawdDotNet/src/ClawdDotNet.Core/Api/Models/ChatMessage.cs
T
RichardandClaude Opus 5 ea3844006f 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>
2026-09-12 13:34:36 +02:00

242 lines
8.7 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
[JsonConverter(typeof(ChatMessageConverter))]
public sealed class ChatMessage
{
public string Role { get; set; } = "";
public string? Content { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
public string? ToolCallId { get; set; }
/// <summary>
/// Setzt einen Prompt-Caching-Breakpoint auf diese Nachricht.
///
/// Alles, was im Prompt VOR dem Breakpoint steht (Tool-Definitionen, System-Prompt,
/// vorherige Nachrichten), wird beim nächsten Aufruf aus dem Cache gelesen und
/// kostet nur einen Bruchteil. Der Inhalt wird dann als Block-Array statt als
/// einfacher String serialisiert.
///
/// Wichtig: Der Prompt-Abschnitt vor dem Breakpoint muss zwischen zwei Aufrufen
/// zeichengenau identisch sein, sonst greift der Cache nicht.
/// </summary>
[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 };
/// <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",
Content = string.IsNullOrEmpty(content) ? null : content,
ToolCalls = toolCalls,
Reasoning = reasoning,
ReasoningDetails = reasoningDetails
};
public static ChatMessage ToolResponse(string toolCallId, string content) => new()
{
Role = "tool",
ToolCallId = toolCallId,
Content = content
};
}
/// <summary>
/// Serialisiert <see cref="ChatMessage"/>. Der Inhalt geht normalerweise als einfacher
/// String raus; ist ein <see cref="ChatMessage.CacheBreakpoint"/> gesetzt, stattdessen
/// als Block-Array mit cache_control — das Format, das Anbieter für Prompt-Caching
/// erwarten.
///
/// Beim Lesen werden beide Formate akzeptiert: Antworten liefern den Inhalt als String,
/// gespeicherte Kontexte können ihn als Array enthalten.
/// </summary>
public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
{
public override ChatMessage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("ChatMessage: Objekt erwartet.");
var message = new ChatMessage();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return message;
if (reader.TokenType != JsonTokenType.PropertyName)
continue;
var propertyName = reader.GetString();
reader.Read();
switch (propertyName)
{
case "role":
message.Role = reader.GetString() ?? "";
break;
case "content":
message.Content = ReadContent(ref reader);
break;
case "tool_calls":
message.ToolCalls = reader.TokenType == JsonTokenType.Null
? null
: JsonSerializer.Deserialize<List<ToolCall>>(ref reader, options);
break;
case "tool_call_id":
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;
}
}
throw new JsonException("ChatMessage: unerwartetes Ende.");
}
/// <summary>Nimmt den Inhalt als String oder als Block-Array entgegen.</summary>
private static string? ReadContent(ref Utf8JsonReader reader)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
return reader.GetString();
if (reader.TokenType != JsonTokenType.StartArray)
{
reader.Skip();
return null;
}
// Block-Array: die text-Anteile zusammenführen.
var parts = new List<string>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
reader.Skip();
continue;
}
using var block = JsonDocument.ParseValue(ref reader);
if (block.RootElement.TryGetProperty("text", out var text) &&
text.GetString() is { } value)
{
parts.Add(value);
}
}
return parts.Count > 0 ? string.Join("", parts) : null;
}
public override void Write(Utf8JsonWriter writer, ChatMessage value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("role", value.Role);
if (value.Content is not null)
{
if (value.CacheBreakpoint)
{
// [{ "type": "text", "text": "…", "cache_control": { "type": "ephemeral" } }]
writer.WriteStartArray("content");
writer.WriteStartObject();
writer.WriteString("type", "text");
writer.WriteString("text", value.Content);
writer.WriteStartObject("cache_control");
writer.WriteString("type", "ephemeral");
writer.WriteEndObject();
writer.WriteEndObject();
writer.WriteEndArray();
}
else
{
writer.WriteString("content", value.Content);
}
}
if (value.ToolCalls is { Count: > 0 })
{
writer.WritePropertyName("tool_calls");
JsonSerializer.Serialize(writer, value.ToolCalls, options);
}
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();
}
}