| 1 | #nullable enable |
| 2 | |
| 3 | using System.Text; |
| 4 | using System.Text.Json; |
| 5 | using CascadeIDE.Models; |
| 6 | using CascadeIDE.Services; |
| 7 | |
| 8 | namespace CascadeIDE.Features.Agent.Harness; |
| 9 | |
| 10 | /// <summary>L0 hot + session checkpoint (ADR 0166 P0.1–P0.2 interim product hooks).</summary> |
| 11 | public sealed class ChatHarnessCoordinator |
| 12 | { |
| 13 | private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); |
| 14 | |
| 15 | private readonly Func<CascadeIdeSettings> _getSettings; |
| 16 | private readonly Func<string, IReadOnlyDictionary<string, JsonElement>?, CancellationToken, Task<string>>? _executeIdeCommand; |
| 17 | private readonly object _gate = new(); |
| 18 | |
| 19 | private Guid _sessionId = Guid.Empty; |
| 20 | private int _userTurnCount; |
| 21 | private int _lastCheckpointTurn; |
| 22 | private int _lastContextPressureAtCount; |
| 23 | private int _lastUsagePressureAtPct; |
| 24 | private string? _hotContextBlock; |
| 25 | private bool _hotContextLoaded; |
| 26 | private string? _hotContextScope; |
| 27 | private string? _pendingAgentContext; |
| 28 | |
| 29 | public ChatHarnessCoordinator( |
| 30 | Func<CascadeIdeSettings> getSettings, |
| 31 | Func<string, IReadOnlyDictionary<string, JsonElement>?, CancellationToken, Task<string>>? executeIdeCommand) |
| 32 | { |
| 33 | _getSettings = getSettings; |
| 34 | _executeIdeCommand = executeIdeCommand; |
| 35 | } |
| 36 | |
| 37 | public string? HotContextBlock |
| 38 | { |
| 39 | get |
| 40 | { |
| 41 | lock (_gate) |
| 42 | return _hotContextBlock; |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | public void BindSession(Guid sessionId) |
| 47 | { |
| 48 | lock (_gate) |
| 49 | { |
| 50 | if (_sessionId == sessionId) |
| 51 | return; |
| 52 | |
| 53 | _sessionId = sessionId; |
| 54 | _userTurnCount = 0; |
| 55 | _lastCheckpointTurn = 0; |
| 56 | _lastContextPressureAtCount = 0; |
| 57 | _lastUsagePressureAtPct = 0; |
| 58 | _hotContextBlock = null; |
| 59 | _hotContextLoaded = false; |
| 60 | _hotContextScope = null; |
| 61 | _pendingAgentContext = null; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | public async Task OnSessionInitializedAsync(CancellationToken cancellationToken = default) |
| 66 | { |
| 67 | var h = _getSettings().Agent.Harness; |
| 68 | if (!h.LoadHotContextOnSessionStart) |
| 69 | return; |
| 70 | |
| 71 | await RefreshHotContextAsync(h.HotContextActiveScope, cancellationToken).ConfigureAwait(false); |
| 72 | } |
| 73 | |
| 74 | public async Task OnTopicForkedAsync(CancellationToken cancellationToken = default) |
| 75 | { |
| 76 | var h = _getSettings().Agent.Harness; |
| 77 | if (!h.LoadHotContextOnTopicFork) |
| 78 | return; |
| 79 | |
| 80 | await RefreshHotContextAsync(h.HotContextActiveScope, cancellationToken).ConfigureAwait(false); |
| 81 | } |
| 82 | |
| 83 | public HarnessUserTurnResult OnUserMessageCommitted() |
| 84 | { |
| 85 | var h = _getSettings().Agent.Harness; |
| 86 | lock (_gate) |
| 87 | _userTurnCount++; |
| 88 | |
| 89 | if (!h.CheckpointEnabled) |
| 90 | return HarnessUserTurnResult.None; |
| 91 | |
| 92 | var turn = _userTurnCount; |
| 93 | var threshold = Math.Max(1, h.CheckpointThresholdUserTurns); |
| 94 | var repeat = Math.Max(1, h.CheckpointRepeatEveryUserTurns); |
| 95 | |
| 96 | lock (_gate) |
| 97 | { |
| 98 | if (turn < threshold) |
| 99 | return HarnessUserTurnResult.None; |
| 100 | |
| 101 | if (turn == threshold || (turn > threshold && (turn - _lastCheckpointTurn) >= repeat)) |
| 102 | { |
| 103 | _lastCheckpointTurn = turn; |
| 104 | QueueAgentContextReminder(turn, "user_turn_threshold"); |
| 105 | return HarnessUserTurnResult.CheckpointPrompt(BuildCheckpointUserMessage(turn)); |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | return HarnessUserTurnResult.None; |
| 110 | } |
| 111 | |
| 112 | public HarnessContextPressureResult OnThreadMessageCommitted(int threadMessageCount) |
| 113 | { |
| 114 | var h = _getSettings().Agent.Harness; |
| 115 | if (!h.CheckpointOnContextPressure) |
| 116 | return HarnessContextPressureResult.None; |
| 117 | |
| 118 | var threshold = Math.Max(1, h.ContextPressureThreadMessageThreshold); |
| 119 | var repeat = Math.Max(1, h.ContextPressureRepeatEveryMessages); |
| 120 | var count = Math.Max(0, threadMessageCount); |
| 121 | |
| 122 | lock (_gate) |
| 123 | { |
| 124 | if (count < threshold) |
| 125 | return HarnessContextPressureResult.None; |
| 126 | |
| 127 | if (count == threshold || (count > threshold && (count - _lastContextPressureAtCount) >= repeat)) |
| 128 | { |
| 129 | _lastContextPressureAtCount = count; |
| 130 | QueueAgentContextReminder(count, "context_pressure"); |
| 131 | return HarnessContextPressureResult.PreCompactPrompt(BuildPreCompactUserMessage(count)); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | return HarnessContextPressureResult.None; |
| 136 | } |
| 137 | |
| 138 | /// <summary> |
| 139 | /// FM prompt tokens vs model max — usage-based pressure (Cursor Context Usage parity). |
| 140 | /// Fires at <see cref="AgentHarnessSettings.ContextWarnPct"/> and every +10pp thereafter. |
| 141 | /// </summary> |
| 142 | public HarnessContextPressureResult OnContextUsagePct(int promptTokens, int maxModelLen) |
| 143 | { |
| 144 | var h = _getSettings().Agent.Harness; |
| 145 | if (!h.CheckpointOnContextPressure || maxModelLen <= 0 || promptTokens <= 0) |
| 146 | return HarnessContextPressureResult.None; |
| 147 | |
| 148 | var pct = (int)Math.Round(100.0 * promptTokens / maxModelLen); |
| 149 | var warnPct = Math.Clamp(h.ContextWarnPct, 1, 100); |
| 150 | if (pct < warnPct) |
| 151 | return HarnessContextPressureResult.None; |
| 152 | |
| 153 | lock (_gate) |
| 154 | { |
| 155 | // First fire at warnPct; then every +10 percentage points of context fill. |
| 156 | if (_lastUsagePressureAtPct == 0) |
| 157 | { |
| 158 | if (pct < warnPct) |
| 159 | return HarnessContextPressureResult.None; |
| 160 | _lastUsagePressureAtPct = warnPct; |
| 161 | } |
| 162 | else if (pct < _lastUsagePressureAtPct + 10) |
| 163 | { |
| 164 | return HarnessContextPressureResult.None; |
| 165 | } |
| 166 | else |
| 167 | { |
| 168 | _lastUsagePressureAtPct = pct; |
| 169 | } |
| 170 | |
| 171 | QueueAgentContextReminder(pct, "context_usage_pct"); |
| 172 | return HarnessContextPressureResult.PreCompactPrompt(BuildUsagePressureUserMessage(pct, promptTokens, maxModelLen)); |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | public string? TryConsumePendingAgentContext() |
| 177 | { |
| 178 | lock (_gate) |
| 179 | { |
| 180 | var pending = _pendingAgentContext; |
| 181 | _pendingAgentContext = null; |
| 182 | return pending; |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | public string? BuildTelemetryContextBlock(AgentHarnessTelemetry telemetry, bool verifyEpochUiStale) |
| 187 | { |
| 188 | if (!_getSettings().Agent.Harness.InjectHarnessTelemetryInContext) |
| 189 | return null; |
| 190 | |
| 191 | var sb = new StringBuilder(); |
| 192 | sb.AppendLine("<!-- harness telemetry (ide_agent_status parity) -->"); |
| 193 | sb.AppendLine($"session_user_turns: {telemetry.SessionUserTurnCount}"); |
| 194 | sb.AppendLine($"checkpoint_due: {telemetry.CheckpointDue.ToString().ToLowerInvariant()}"); |
| 195 | if (telemetry.NextCheckpointAtTurn is { } next) |
| 196 | sb.AppendLine($"next_checkpoint_at_turn: {next}"); |
| 197 | sb.AppendLine($"hot_context_loaded: {telemetry.HotContextLoaded.ToString().ToLowerInvariant()}"); |
| 198 | if (!string.IsNullOrWhiteSpace(telemetry.HotContextScope)) |
| 199 | sb.AppendLine($"hot_context_scope: {telemetry.HotContextScope.Trim()}"); |
| 200 | sb.AppendLine($"verify_epoch_ui_stale: {verifyEpochUiStale.ToString().ToLowerInvariant()}"); |
| 201 | sb.AppendLine("verify_habit: green diagnostics = current verify epoch; call ide_agent_status after edits."); |
| 202 | return sb.ToString().Trim(); |
| 203 | } |
| 204 | |
| 205 | public AgentHarnessTelemetry GetTelemetry() |
| 206 | { |
| 207 | var h = _getSettings().Agent.Harness; |
| 208 | lock (_gate) |
| 209 | { |
| 210 | int? next = null; |
| 211 | if (h.CheckpointEnabled) |
| 212 | { |
| 213 | var threshold = Math.Max(1, h.CheckpointThresholdUserTurns); |
| 214 | var repeat = Math.Max(1, h.CheckpointRepeatEveryUserTurns); |
| 215 | if (_userTurnCount < threshold) |
| 216 | next = threshold; |
| 217 | else |
| 218 | next = _lastCheckpointTurn + repeat; |
| 219 | } |
| 220 | |
| 221 | var due = h.CheckpointEnabled |
| 222 | && _userTurnCount >= Math.Max(1, h.CheckpointThresholdUserTurns) |
| 223 | && (_userTurnCount == _lastCheckpointTurn); |
| 224 | |
| 225 | return new AgentHarnessTelemetry( |
| 226 | _userTurnCount, |
| 227 | due, |
| 228 | next, |
| 229 | _hotContextLoaded, |
| 230 | _hotContextScope); |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | private async Task RefreshHotContextAsync(string? activeScope, CancellationToken cancellationToken) |
| 235 | { |
| 236 | if (_executeIdeCommand is null) |
| 237 | return; |
| 238 | |
| 239 | IReadOnlyDictionary<string, JsonElement>? args = null; |
| 240 | if (!string.IsNullOrWhiteSpace(activeScope)) |
| 241 | { |
| 242 | args = new Dictionary<string, JsonElement> |
| 243 | { |
| 244 | ["active_scope"] = JsonSerializer.SerializeToElement(activeScope.Trim(), Json), |
| 245 | }; |
| 246 | } |
| 247 | |
| 248 | try |
| 249 | { |
| 250 | var raw = await _executeIdeCommand( |
| 251 | IdeCommands.ReadHotContext, |
| 252 | args, |
| 253 | cancellationToken).ConfigureAwait(false); |
| 254 | |
| 255 | var block = FormatHotContextBlock(raw, activeScope); |
| 256 | lock (_gate) |
| 257 | { |
| 258 | _hotContextBlock = block; |
| 259 | _hotContextLoaded = !string.IsNullOrWhiteSpace(block); |
| 260 | _hotContextScope = activeScope?.Trim(); |
| 261 | } |
| 262 | } |
| 263 | catch |
| 264 | { |
| 265 | // best-effort; agent may still call read_hot_context manually |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | private static string? FormatHotContextBlock(string raw, string? scope) |
| 270 | { |
| 271 | if (string.IsNullOrWhiteSpace(raw)) |
| 272 | return null; |
| 273 | |
| 274 | var trimmed = raw.Trim(); |
| 275 | if (trimmed.StartsWith('{')) |
| 276 | { |
| 277 | try |
| 278 | { |
| 279 | using var doc = JsonDocument.Parse(trimmed); |
| 280 | if (doc.RootElement.TryGetProperty("error", out var err)) |
| 281 | return null; |
| 282 | if (doc.RootElement.TryGetProperty("content", out var content)) |
| 283 | { |
| 284 | var text = content.GetString(); |
| 285 | if (!string.IsNullOrWhiteSpace(text)) |
| 286 | return WrapHotBlock(text, scope); |
| 287 | } |
| 288 | } |
| 289 | catch (JsonException) |
| 290 | { |
| 291 | // fall through — treat as plain text |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | return WrapHotBlock(trimmed, scope); |
| 296 | } |
| 297 | |
| 298 | private static string WrapHotBlock(string body, string? scope) |
| 299 | { |
| 300 | var sb = new StringBuilder(); |
| 301 | sb.AppendLine("<!-- harness L0 hot (read_hot_context in-proc) -->"); |
| 302 | if (!string.IsNullOrWhiteSpace(scope)) |
| 303 | sb.AppendLine($"active_scope: {scope.Trim()}"); |
| 304 | sb.AppendLine(body.Trim()); |
| 305 | return sb.ToString().Trim(); |
| 306 | } |
| 307 | |
| 308 | private void QueueAgentContextReminder(int count, string reason) |
| 309 | { |
| 310 | _pendingAgentContext = |
| 311 | "Session checkpoint (L1 harness · ADCM · agent-memory §9): длинная сессия или context pressure. " + |
| 312 | "Предложи явно: (1) chat_export_readable, (2) краткое резюме решений/open items, " + |
| 313 | "(3) согласование с пользователем. Не silent summary. Тактики: Prevent/Partition/Persist/Prune — " + |
| 314 | $"playbook-agent-driven-context-management-v1.md " + |
| 315 | $"(harness: {reason}, approx={count})"; |
| 316 | } |
| 317 | |
| 318 | private static string BuildPreCompactUserMessage(int threadMessages) => |
| 319 | $"[harness ADCM · context pressure · ~{threadMessages} messages in topic] " + |
| 320 | "Длинная ветка — бюджет контекста. " + |
| 321 | "Выбери тактику ADCM: Prevent/Partition/Persist/Prune (не silent rewrite чата). " + |
| 322 | "По канону часто: export → резюме → согласование. Скажи «подведи итоги с экспортом», если важно сохранить нить."; |
| 323 | |
| 324 | private static string BuildUsagePressureUserMessage(int pct, int promptTokens, int maxModelLen) => |
| 325 | $"[harness ADCM · context usage · ~{pct}% prompt {promptTokens}/{maxModelLen}] " + |
| 326 | "Контекст по токенам заполнен сильно. " + |
| 327 | "Выбери тактику ADCM (часто Persist/export или Partition/fork). " + |
| 328 | "Скажи «подведи итоги с экспортом», если важно сохранить нить."; |
| 329 | |
| 330 | private static string BuildCheckpointUserMessage(int turn) => |
| 331 | $"[harness checkpoint · ~{turn} user turns] Длинная сессия — прошу подвести итоги: " + |
| 332 | "chat_export_readable → краткое резюме решений/open items → согласование. " + |
| 333 | "Ответь «пропустить», если не сейчас."; |
| 334 | } |
| 335 | |
| 336 | public readonly record struct HarnessContextPressureResult(bool InjectPreCompact, string? PreCompactUserMessage) |
| 337 | { |
| 338 | public static HarnessContextPressureResult None => new(false, null); |
| 339 | |
| 340 | public static HarnessContextPressureResult PreCompactPrompt(string message) => new(true, message); |
| 341 | } |
| 342 | |
| 343 | public readonly record struct HarnessUserTurnResult(bool InjectCheckpoint, string? CheckpointUserMessage) |
| 344 | { |
| 345 | public static HarnessUserTurnResult None => new(false, null); |
| 346 | |
| 347 | public static HarnessUserTurnResult CheckpointPrompt(string message) => new(true, message); |
| 348 | } |
| 349 | |