232 lines
12 KiB
C#
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,
|
|
};
|
|
}
|