Forge
csharpdeeb25a2
1#nullable enable
2
3using System.Text;
4using System.Text.Json;
5using CascadeIDE.Models;
6using CascadeIDE.Services;
7
8namespace CascadeIDE.Features.Agent.Harness;
9
10/// <summary>L0 hot + session checkpoint (ADR 0166 P0.1–P0.2 interim product hooks).</summary>
11public 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
336public 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
343public 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
View only · write via MCP/CIDE