| 1 | #nullable enable |
| 2 | |
| 3 | using System.Text; |
| 4 | using System.Text.Json; |
| 5 | |
| 6 | namespace CascadeIDE.Services; |
| 7 | |
| 8 | /// <summary> |
| 9 | /// Превращает ответ MCP/IDE тула в читаемый текст для модели и для панели чата. |
| 10 | /// Снижает «подражание JSON» у локальных LLM и выглядит ближе к подписанным результатам тула в хостах вроде Cursor. |
| 11 | /// </summary> |
| 12 | internal static class IdeMcpToolResultPlainFormatter |
| 13 | { |
| 14 | internal const int DefaultMaxCharsForModel = 14_000; |
| 15 | internal const int DefaultMaxCharsForUiTrace = 2_800; |
| 16 | internal const int DefaultMaxCharsForSalvagePayload = CascadeIdeMafIdeAgentChat.SalvageOutcomeMaxCharsForSummary; |
| 17 | |
| 18 | private const int MaxStringLeafChars = 600; |
| 19 | private const int MaxObjectProperties = 80; |
| 20 | private const int MaxArrayItems = 24; |
| 21 | private const int MaxDepth = 14; |
| 22 | |
| 23 | /// <summary>Тело для возврата в цикл Agent Framework → модель (не сырой JSON при возможности).</summary> |
| 24 | internal static string ForModel(string toolDisplayName, string outcome, int maxChars = DefaultMaxCharsForModel) |
| 25 | => FormatCore(toolDisplayName, outcome, maxChars, forModel: true); |
| 26 | |
| 27 | /// <summary>Краткий текст трассы в истории чата («Инструмент»).</summary> |
| 28 | internal static string ForUiTrace(string toolDisplayName, string outcome, int maxChars = DefaultMaxCharsForUiTrace) |
| 29 | => FormatCore(toolDisplayName, outcome, maxChars, forModel: false); |
| 30 | |
| 31 | /// <summary>Текст в salvage user message — тоже не сырой JSON, чтобы второй проход модели не копировал скобки.</summary> |
| 32 | internal static string ForSalvagePayload(string outcome, int maxChars = DefaultMaxCharsForSalvagePayload) |
| 33 | => FormatCore("результат_инструмента", outcome, maxChars, forModel: true); |
| 34 | |
| 35 | private static string FormatCore(string toolDisplayName, string outcome, int maxChars, bool forModel) |
| 36 | { |
| 37 | outcome = outcome?.Trim() ?? ""; |
| 38 | if (outcome.Length == 0) |
| 39 | return ClampLine($"Инструмент «{toolDisplayName}»: (пустой ответ)", maxChars); |
| 40 | |
| 41 | if (!LooksLikeStructuredJson(outcome)) |
| 42 | { |
| 43 | var plain = $"Вызов: {toolDisplayName}\n{outcome}"; |
| 44 | if (forModel) |
| 45 | plain = $"Инструмент «{toolDisplayName}» завершился.\n\n{outcome}"; |
| 46 | return ClampLine(plain, maxChars); |
| 47 | } |
| 48 | |
| 49 | try |
| 50 | { |
| 51 | using var doc = JsonDocument.Parse(outcome); |
| 52 | var sb = new StringBuilder(); |
| 53 | if (forModel) |
| 54 | { |
| 55 | sb.AppendLine($"Инструмент «{toolDisplayName}» завершился."); |
| 56 | sb.AppendLine(); |
| 57 | } |
| 58 | else |
| 59 | { |
| 60 | sb.AppendLine($"Вызов: {toolDisplayName}"); |
| 61 | sb.AppendLine(new string('─', Math.Min(32, maxChars > 40 ? 32 : 8))); |
| 62 | } |
| 63 | |
| 64 | AppendElement(sb, doc.RootElement, depth: 0, linesBudget: forModel ? 220 : 80); |
| 65 | return ClampLine(sb.ToString().TrimEnd(), maxChars); |
| 66 | } |
| 67 | catch (JsonException) |
| 68 | { |
| 69 | var plain = forModel |
| 70 | ? $"Инструмент «{toolDisplayName}» завершился.\n\n{outcome}" |
| 71 | : $"Вызов: {toolDisplayName}\n{outcome}"; |
| 72 | return ClampLine(plain, maxChars); |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | private static bool LooksLikeStructuredJson(string s) |
| 77 | { |
| 78 | if (s.Length < 2) |
| 79 | return false; |
| 80 | var c = s[0]; |
| 81 | return c is '{' or '['; |
| 82 | } |
| 83 | |
| 84 | private static void AppendElement(StringBuilder sb, JsonElement el, int depth, int linesBudget) |
| 85 | { |
| 86 | var ind = IndentString(depth); |
| 87 | if (depth > MaxDepth || linesBudget <= 0) |
| 88 | { |
| 89 | sb.Append(ind).AppendLine("…"); |
| 90 | return; |
| 91 | } |
| 92 | |
| 93 | switch (el.ValueKind) |
| 94 | { |
| 95 | case JsonValueKind.Object: |
| 96 | { |
| 97 | var n = 0; |
| 98 | foreach (var p in el.EnumerateObject()) |
| 99 | { |
| 100 | if (n >= MaxObjectProperties || linesBudget <= 0) |
| 101 | { |
| 102 | sb.Append(ind).AppendLine("… (ещё поля опущены)"); |
| 103 | return; |
| 104 | } |
| 105 | |
| 106 | sb.Append(ind).Append(p.Name).Append(": "); |
| 107 | if (p.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) |
| 108 | { |
| 109 | sb.AppendLine(); |
| 110 | AppendElement(sb, p.Value, depth + 1, linesBudget - 1); |
| 111 | } |
| 112 | else |
| 113 | { |
| 114 | AppendInlinePrimitive(sb, p.Value); |
| 115 | sb.AppendLine(); |
| 116 | } |
| 117 | |
| 118 | n++; |
| 119 | linesBudget--; |
| 120 | } |
| 121 | |
| 122 | break; |
| 123 | } |
| 124 | case JsonValueKind.Array: |
| 125 | { |
| 126 | var len = el.GetArrayLength(); |
| 127 | sb.Append(ind).Append("[массив, элементов: ").Append(len).AppendLine("]"); |
| 128 | var i = 0; |
| 129 | foreach (var item in el.EnumerateArray()) |
| 130 | { |
| 131 | var indInner = IndentString(depth + 1); |
| 132 | if (i >= MaxArrayItems || linesBudget <= 0) |
| 133 | { |
| 134 | sb.Append(indInner).AppendLine("…"); |
| 135 | return; |
| 136 | } |
| 137 | |
| 138 | sb.Append(indInner).Append('[').Append(i).Append(']').Append(' '); |
| 139 | if (item.ValueKind is JsonValueKind.Object or JsonValueKind.Array) |
| 140 | { |
| 141 | sb.AppendLine(); |
| 142 | AppendElement(sb, item, depth + 2, linesBudget - 2); |
| 143 | } |
| 144 | else |
| 145 | { |
| 146 | AppendInlinePrimitive(sb, item); |
| 147 | sb.AppendLine(); |
| 148 | } |
| 149 | |
| 150 | i++; |
| 151 | linesBudget--; |
| 152 | } |
| 153 | |
| 154 | break; |
| 155 | } |
| 156 | default: |
| 157 | AppendInlinePrimitive(sb, el); |
| 158 | sb.AppendLine(); |
| 159 | break; |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | private static void AppendInlinePrimitive(StringBuilder sb, JsonElement el) |
| 164 | { |
| 165 | switch (el.ValueKind) |
| 166 | { |
| 167 | case JsonValueKind.String: |
| 168 | { |
| 169 | var t = el.GetString() ?? ""; |
| 170 | if (t.Length > MaxStringLeafChars) |
| 171 | sb.Append(t.AsSpan(0, MaxStringLeafChars)).Append("… (").Append(t.Length).Append(" симв.)"); |
| 172 | else |
| 173 | sb.Append(t); |
| 174 | break; |
| 175 | } |
| 176 | case JsonValueKind.Number: |
| 177 | sb.Append(el.GetRawText()); |
| 178 | break; |
| 179 | case JsonValueKind.True: |
| 180 | sb.Append("да"); |
| 181 | break; |
| 182 | case JsonValueKind.False: |
| 183 | sb.Append("нет"); |
| 184 | break; |
| 185 | case JsonValueKind.Null: |
| 186 | case JsonValueKind.Undefined: |
| 187 | sb.Append("(нет)"); |
| 188 | break; |
| 189 | default: |
| 190 | sb.Append(el.GetRawText()); |
| 191 | break; |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | private static string IndentString(int depth) |
| 196 | { |
| 197 | var n = Math.Clamp(depth * 2, 0, 64); |
| 198 | return n == 0 ? "" : new string(' ', n); |
| 199 | } |
| 200 | |
| 201 | private static string ClampLine(string s, int maxChars) |
| 202 | { |
| 203 | if (s.Length <= maxChars) |
| 204 | return s; |
| 205 | return s.AsSpan(0, Math.Max(0, maxChars - 80)).ToString() |
| 206 | + $"\n… (всего {s.Length} симв.; показано ~{maxChars - 80}.)"; |
| 207 | } |
| 208 | } |
| 209 | |