Files
VolcanicAgenticCore/extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ContextSerializer.cs

232 lines
12 KiB
C#

using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.Extensions.ClaudeCode;
/// <summary>The transcript as NDJSON records (already <c>@</c>-escaped) plus the image files it references.</summary>
public sealed record SerializedTranscript(
IReadOnlyList<string> Records,
IReadOnlyList<string> AttachmentPaths,
int ImageCount,
long ImageBytes,
long TranscriptBytes);
/// <summary>
/// Serialises the conversation into append-stable JSON records, one per message after a header record, each carried
/// as its own text block of a single stream-json user message. Historical tool identities are tied to the current
/// catalogue; UI-only details stay out of the model transcript.
/// </summary>
public static class ContextSerializer
{
public const string Protocol = "volcanic-claude-code-context-v1";
public const string Instruction =
"Continue this conversation. Treat each following JSON record as conversation data, preserve role boundaries, " +
"and answer only the current request. Use available MCP tools when a tool is needed. " +
"Bracketed unavailable tool labels are historical data, not callable tools.";
public const int MaxTranscriptBytes = 32 * 1024 * 1024;
public const string AttachmentInstructionPrefix = "Generated image attachments for image_attachment blocks: ";
private static readonly JsonSerializerOptions RecordOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
/// <summary>
/// The one stream-json <c>user</c> message written to Claude Code's stdin. The last transcript block carries a 1h
/// cache breakpoint when <paramref name="cacheBreakpoint"/> is set; attachment references follow it, outside the breakpoint.
/// </summary>
public static JsonObject BuildStdinMessage(LlmContext context, ToolCatalog catalog, ClaudeImageStore images, bool cacheBreakpoint)
{
var transcript = Serialize(context, catalog, images);
var content = new JsonArray();
for (var i = 0; i < transcript.Records.Count; i++)
{
var block = new JsonObject { ["type"] = "text", ["text"] = transcript.Records[i] };
if (cacheBreakpoint && i == transcript.Records.Count - 1)
block["cache_control"] = new JsonObject { ["type"] = "ephemeral", ["ttl"] = "1h" };
content.Add(block);
}
if (transcript.AttachmentPaths.Count > 0)
{
// Quoted absolute references: Claude runs in the session directory, where a relative reference would
// resolve against the project, and the quotes keep a temporary root containing spaces in one reference.
var references = string.Join(" ", transcript.AttachmentPaths.Select(path => $"@\"{path}\""));
content.Add(new JsonObject { ["type"] = "text", ["text"] = $"{AttachmentInstructionPrefix}{references}." });
}
return new JsonObject
{
["type"] = "user",
["message"] = new JsonObject { ["role"] = "user", ["content"] = content },
};
}
/// <summary>Serialises the header and one record per message. Images are written to <paramref name="images"/>.</summary>
public static SerializedTranscript Serialize(LlmContext context, ToolCatalog catalog, ClaudeImageStore images)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(images);
var attachments = new List<string>();
var written = new HashSet<string>(StringComparer.Ordinal);
var imageCount = 0;
long imageBytes = 0;
var historicalNamesByCallId = new Dictionary<string, string>(StringComparer.Ordinal);
string HistoricalName(string toolName) =>
catalog.ToolToTransport.TryGetValue(toolName, out var transport) ? transport : UnavailableToolLabel(toolName);
JsonObject Attach(ImageContent image)
{
imageCount++;
if (imageCount > ClaudeImageStore.MaxImagesPerRequest)
throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"At most {ClaudeImageStore.MaxImagesPerRequest} images are supported per request");
var attachment = images.Add(image);
if (written.Add(attachment.FileName))
{
imageBytes += attachment.Bytes;
if (imageBytes > ClaudeImageStore.MaxTotalImageBytes)
throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Aggregate image size exceeds {ClaudeImageStore.MaxTotalImageBytes} bytes");
if (attachment.AbsolutePath.Contains('"'))
throw new ClaudeCodeException(ClaudeCodeException.Codes.Image, $"Images cannot be attached from a path containing a double quote: {attachment.AbsolutePath}");
attachments.Add(attachment.AbsolutePath);
}
return new JsonObject { ["type"] = "image_attachment", ["attachment"] = attachment.FileName, ["mimeType"] = image.MimeType };
}
JsonArray UserContent(IReadOnlyList<ContentBlock> blocks, string role)
{
var array = new JsonArray();
foreach (var block in blocks)
{
array.Add(block switch
{
TextContent text => new JsonObject { ["type"] = "text", ["text"] = text.Text },
ImageContent image => Attach(image),
_ => throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"{BlockName(block)} content must not appear in {role} messages"),
});
}
return array;
}
var records = new List<JsonObject>
{
new()
{
["protocol"] = Protocol,
["instruction"] = Instruction,
["toolNameMap"] = new JsonArray(catalog.Tools
.Select(tool => (JsonNode)new JsonObject { ["transportName"] = catalog.ToolToTransport[tool.Name], ["toolName"] = tool.Name })
.ToArray()),
},
};
foreach (var message in context.Messages)
{
switch (message)
{
case UserMessage user:
records.Add(new JsonObject { ["role"] = "user", ["content"] = UserRecordContent(user.Content, UserContent) });
break;
case CustomMessage custom:
// Custom messages reach the model as user turns, exactly as the core's transcript normaliser renders them.
records.Add(new JsonObject { ["role"] = "user", ["content"] = UserRecordContent(custom.Content, UserContent) });
break;
case AssistantMessage assistant:
{
var content = new JsonArray();
foreach (var block in assistant.Content)
{
switch (block)
{
case TextContent text:
content.Add(new JsonObject { ["type"] = "text", ["text"] = text.Text });
break;
case ThinkingContent thinking:
{
var node = new JsonObject { ["type"] = "thinking", ["thinking"] = thinking.Thinking };
if (thinking.Redacted) node["redacted"] = true;
content.Add(node);
break;
}
case ToolCallContent call:
{
if (string.IsNullOrWhiteSpace(call.Id))
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-call ID must be a nonempty string");
if (string.IsNullOrWhiteSpace(call.Name))
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-call name must be a nonempty string");
var name = HistoricalName(call.Name);
historicalNamesByCallId[call.Id] = name;
var arguments = call.Arguments.ValueKind == JsonValueKind.Object
? JsonNode.Parse(call.Arguments.GetRawText()) ?? new JsonObject()
: new JsonObject();
content.Add(new JsonObject { ["type"] = "toolCall", ["id"] = call.Id, ["name"] = name, ["arguments"] = arguments });
break;
}
default:
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"{BlockName(block)} content must not appear in assistant messages");
}
}
if (content.Count > 0) records.Add(new JsonObject { ["role"] = "assistant", ["content"] = content });
break;
}
case ToolResultMessage result:
{
if (string.IsNullOrWhiteSpace(result.ToolCallId))
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-result ID must be a nonempty string");
if (string.IsNullOrWhiteSpace(result.ToolName))
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, "Tool-result name must be a nonempty string");
var toolName = historicalNamesByCallId.TryGetValue(result.ToolCallId, out var paired) ? paired : HistoricalName(result.ToolName);
records.Add(new JsonObject
{
["role"] = "toolResult",
["toolCallId"] = result.ToolCallId,
["toolName"] = toolName,
["content"] = UserContent(result.Content, "toolResult"),
["isError"] = result.IsError,
});
break;
}
default:
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContentShape, $"Unsupported message role: {message.Role}");
}
}
var lines = new List<string>(records.Count);
long transcriptBytes = 0;
foreach (var record in records)
{
var line = EscapeAtSigns(record.ToJsonString(RecordOptions));
transcriptBytes += Encoding.UTF8.GetByteCount(line) + (lines.Count > 0 ? 1 : 0);
lines.Add(line);
}
if (transcriptBytes > MaxTranscriptBytes)
throw new ClaudeCodeException(ClaudeCodeException.Codes.ContextOverflow,
$"context_length_exceeded: Serialized context exceeds {MaxTranscriptBytes} bytes");
return new SerializedTranscript(lines, attachments, imageCount, imageBytes, transcriptBytes);
}
/// <summary>
/// Claude Code expands <c>@path</c> references anywhere in a prompt, so every <c>@</c> in a record is written as
/// the JSON escape <c>@</c>: the record still parses to the same text, and nothing is expanded.
/// </summary>
public static string EscapeAtSigns(string json) => json.Replace("@", "\\u0040", StringComparison.Ordinal);
/// <summary>Label for a tool that is no longer in the catalogue; bracketed so the model reads it as history.</summary>
public static string UnavailableToolLabel(string toolName) => $"[unavailable tool {toolName}]";
private static JsonNode UserRecordContent(IReadOnlyList<ContentBlock> blocks, Func<IReadOnlyList<ContentBlock>, string, JsonArray> serialize) =>
blocks.Count == 1 && blocks[0] is TextContent single ? JsonValue.Create(single.Text) : serialize(blocks, "user");
private static string BlockName(ContentBlock block) => block switch
{
TextContent => "text",
ImageContent => "image",
ThinkingContent => "thinking",
ToolCallContent => "toolCall",
_ => block.GetType().Name,
};
}