| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using Microsoft.Extensions.AI; |
| | | 5 | | |
| | | 6 | | namespace NexusLabs.Needlr.AgentFramework.Diagnostics; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Extensions for rendering an <see cref="IAgentRunDiagnostics"/> as a human-readable |
| | | 10 | | /// markdown transcript. |
| | | 11 | | /// </summary> |
| | | 12 | | public static class AgentRunDiagnosticsTranscriptExtensions |
| | | 13 | | { |
| | 1 | 14 | | private static readonly JsonSerializerOptions _jsonOptions = new() |
| | 1 | 15 | | { |
| | 1 | 16 | | WriteIndented = true, |
| | 1 | 17 | | }; |
| | | 18 | | |
| | | 19 | | /// <summary> |
| | | 20 | | /// Renders a deterministic markdown transcript of the agent run, including the |
| | | 21 | | /// input messages, ordered timeline of chat completions and tool calls, and the |
| | | 22 | | /// final output response. All numeric formatting uses <see cref="CultureInfo.InvariantCulture"/>. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <param name="diagnostics">The agent run diagnostics to render.</param> |
| | | 25 | | /// <returns>A markdown string. Never <see langword="null"/>.</returns> |
| | | 26 | | public static string ToTranscriptMarkdown( |
| | | 27 | | this IAgentRunDiagnostics diagnostics) |
| | | 28 | | { |
| | 8 | 29 | | ArgumentNullException.ThrowIfNull(diagnostics); |
| | | 30 | | |
| | 7 | 31 | | var sb = new StringBuilder(); |
| | 7 | 32 | | var inv = CultureInfo.InvariantCulture; |
| | | 33 | | |
| | 7 | 34 | | sb.Append("# Agent run: ").AppendLine(diagnostics.AgentName); |
| | 7 | 35 | | sb.Append("- Execution mode: ").AppendLine(diagnostics.ExecutionMode ?? "(unspecified)"); |
| | 7 | 36 | | sb.Append("- Succeeded: ").AppendLine(diagnostics.Succeeded ? "true" : "false"); |
| | 7 | 37 | | sb.Append("- Total duration: ") |
| | 7 | 38 | | .Append(((long)diagnostics.TotalDuration.TotalMilliseconds).ToString(inv)) |
| | 7 | 39 | | .AppendLine(" ms"); |
| | 7 | 40 | | var tokens = diagnostics.AggregateTokenUsage; |
| | 7 | 41 | | sb.Append("- Aggregate tokens: input=") |
| | 7 | 42 | | .Append(tokens.InputTokens.ToString(inv)) |
| | 7 | 43 | | .Append(", output=") |
| | 7 | 44 | | .Append(tokens.OutputTokens.ToString(inv)) |
| | 7 | 45 | | .Append(", total=") |
| | 7 | 46 | | .AppendLine(tokens.TotalTokens.ToString(inv)); |
| | 7 | 47 | | sb.AppendLine(); |
| | | 48 | | |
| | 7 | 49 | | if (diagnostics.InputMessages.Count > 0) |
| | | 50 | | { |
| | 1 | 51 | | sb.AppendLine("## Input messages"); |
| | 1 | 52 | | sb.AppendLine(); |
| | 6 | 53 | | foreach (var message in diagnostics.InputMessages) |
| | | 54 | | { |
| | 2 | 55 | | AppendChatMessage(sb, message); |
| | | 56 | | } |
| | | 57 | | } |
| | | 58 | | |
| | 7 | 59 | | sb.AppendLine("## Timeline"); |
| | 7 | 60 | | sb.AppendLine(); |
| | 7 | 61 | | var timeline = diagnostics.GetOrderedTimeline(); |
| | 7 | 62 | | if (timeline.Count == 0) |
| | | 63 | | { |
| | 3 | 64 | | sb.AppendLine("_No diagnostics captured._"); |
| | 3 | 65 | | sb.AppendLine(); |
| | | 66 | | } |
| | | 67 | | else |
| | | 68 | | { |
| | 20 | 69 | | foreach (var entry in timeline) |
| | | 70 | | { |
| | 6 | 71 | | var offsetMs = (long)(entry.StartedAt - diagnostics.StartedAt).TotalMilliseconds; |
| | 6 | 72 | | if (entry.Kind == DiagnosticsTimelineEntryKind.ChatCompletion |
| | 6 | 73 | | && entry.ChatCompletion is { } chat) |
| | | 74 | | { |
| | 4 | 75 | | AppendChat(sb, chat, offsetMs, inv); |
| | | 76 | | } |
| | 2 | 77 | | else if (entry.Kind == DiagnosticsTimelineEntryKind.ToolCall |
| | 2 | 78 | | && entry.ToolCall is { } tool) |
| | | 79 | | { |
| | 2 | 80 | | AppendTool(sb, tool, offsetMs, inv); |
| | | 81 | | } |
| | | 82 | | } |
| | | 83 | | } |
| | | 84 | | |
| | 7 | 85 | | if (diagnostics.OutputResponse is { } output && output.Messages.Count > 0) |
| | | 86 | | { |
| | 1 | 87 | | sb.AppendLine("## Output response"); |
| | 1 | 88 | | sb.AppendLine(); |
| | 4 | 89 | | foreach (var message in output.Messages) |
| | | 90 | | { |
| | 1 | 91 | | AppendChatMessage(sb, message); |
| | | 92 | | } |
| | | 93 | | } |
| | | 94 | | |
| | 7 | 95 | | if (!diagnostics.Succeeded) |
| | | 96 | | { |
| | 1 | 97 | | sb.AppendLine("## Error"); |
| | 1 | 98 | | sb.AppendLine(); |
| | 1 | 99 | | sb.AppendLine(diagnostics.ErrorMessage ?? "(no error message)"); |
| | 1 | 100 | | sb.AppendLine(); |
| | | 101 | | } |
| | | 102 | | |
| | 7 | 103 | | return sb.ToString(); |
| | | 104 | | } |
| | | 105 | | |
| | | 106 | | private static void AppendChat( |
| | | 107 | | StringBuilder sb, |
| | | 108 | | ChatCompletionDiagnostics chat, |
| | | 109 | | long offsetMs, |
| | | 110 | | CultureInfo inv) |
| | | 111 | | { |
| | 4 | 112 | | sb.Append("### [+").Append(offsetMs.ToString(inv)) |
| | 4 | 113 | | .Append(" ms] Chat completion #").Append(chat.Sequence.ToString(inv)).AppendLine(); |
| | 4 | 114 | | sb.Append("- Model: ").AppendLine(chat.Model); |
| | 4 | 115 | | sb.Append("- Duration: ") |
| | 4 | 116 | | .Append(((long)chat.Duration.TotalMilliseconds).ToString(inv)) |
| | 4 | 117 | | .AppendLine(" ms"); |
| | 4 | 118 | | sb.Append("- Succeeded: ").AppendLine(chat.Succeeded ? "true" : "false"); |
| | 4 | 119 | | sb.Append("- Tokens: input=") |
| | 4 | 120 | | .Append(chat.Tokens.InputTokens.ToString(inv)) |
| | 4 | 121 | | .Append(", output=") |
| | 4 | 122 | | .Append(chat.Tokens.OutputTokens.ToString(inv)) |
| | 4 | 123 | | .Append(", total=") |
| | 4 | 124 | | .AppendLine(chat.Tokens.TotalTokens.ToString(inv)); |
| | 4 | 125 | | sb.Append("- Request chars: ").AppendLine(chat.RequestCharCount.ToString(inv)); |
| | 4 | 126 | | sb.Append("- Response chars: ").AppendLine(chat.ResponseCharCount.ToString(inv)); |
| | 4 | 127 | | if (!chat.Succeeded && !string.IsNullOrEmpty(chat.ErrorMessage)) |
| | | 128 | | { |
| | 0 | 129 | | sb.Append("- Error: ").AppendLine(chat.ErrorMessage); |
| | | 130 | | } |
| | 4 | 131 | | sb.AppendLine(); |
| | 4 | 132 | | } |
| | | 133 | | |
| | | 134 | | private static void AppendTool( |
| | | 135 | | StringBuilder sb, |
| | | 136 | | ToolCallDiagnostics tool, |
| | | 137 | | long offsetMs, |
| | | 138 | | CultureInfo inv) |
| | | 139 | | { |
| | 2 | 140 | | sb.Append("### [+").Append(offsetMs.ToString(inv)) |
| | 2 | 141 | | .Append(" ms] Tool call #").Append(tool.Sequence.ToString(inv)) |
| | 2 | 142 | | .Append(": ").AppendLine(tool.ToolName); |
| | 2 | 143 | | sb.Append("- Duration: ") |
| | 2 | 144 | | .Append(((long)tool.Duration.TotalMilliseconds).ToString(inv)) |
| | 2 | 145 | | .AppendLine(" ms"); |
| | 2 | 146 | | sb.Append("- Succeeded: ").AppendLine(tool.Succeeded ? "true" : "false"); |
| | 2 | 147 | | sb.Append("- Arguments chars: ").AppendLine(tool.ArgumentsCharCount.ToString(inv)); |
| | 2 | 148 | | sb.Append("- Result chars: ").AppendLine(tool.ResultCharCount.ToString(inv)); |
| | 2 | 149 | | if (tool.Arguments is not null) |
| | | 150 | | { |
| | 1 | 151 | | sb.AppendLine("- Arguments:"); |
| | 1 | 152 | | sb.AppendLine("```json"); |
| | 1 | 153 | | sb.AppendLine(SafeSerialize(tool.Arguments)); |
| | 1 | 154 | | sb.AppendLine("```"); |
| | | 155 | | } |
| | 2 | 156 | | if (tool.Result is not null) |
| | | 157 | | { |
| | 1 | 158 | | sb.AppendLine("- Result:"); |
| | 1 | 159 | | sb.AppendLine("```json"); |
| | 1 | 160 | | sb.AppendLine(SafeSerialize(tool.Result)); |
| | 1 | 161 | | sb.AppendLine("```"); |
| | | 162 | | } |
| | 2 | 163 | | if (!tool.Succeeded && !string.IsNullOrEmpty(tool.ErrorMessage)) |
| | | 164 | | { |
| | 0 | 165 | | sb.Append("- Error: ").AppendLine(tool.ErrorMessage); |
| | | 166 | | } |
| | 2 | 167 | | sb.AppendLine(); |
| | 2 | 168 | | } |
| | | 169 | | |
| | | 170 | | private static void AppendChatMessage(StringBuilder sb, ChatMessage message) |
| | | 171 | | { |
| | 3 | 172 | | sb.Append("### ").AppendLine(message.Role.Value); |
| | 3 | 173 | | var text = message.Text; |
| | 3 | 174 | | if (!string.IsNullOrEmpty(text)) |
| | | 175 | | { |
| | 3 | 176 | | sb.AppendLine(); |
| | 3 | 177 | | sb.AppendLine(text); |
| | | 178 | | } |
| | 3 | 179 | | sb.AppendLine(); |
| | 3 | 180 | | } |
| | | 181 | | |
| | | 182 | | private static string SafeSerialize(object value) |
| | | 183 | | { |
| | | 184 | | try |
| | | 185 | | { |
| | 2 | 186 | | return JsonSerializer.Serialize(value, _jsonOptions); |
| | | 187 | | } |
| | 0 | 188 | | catch (Exception ex) |
| | | 189 | | { |
| | 0 | 190 | | return $"(serialization failed: {ex.GetType().Name}: {ex.Message})"; |
| | | 191 | | } |
| | 2 | 192 | | } |
| | | 193 | | } |