Forge
csharpdeeb25a2
1using System.Text.Json;
2using CascadeIDE.Features.Agent.Environment;
3using CascadeIDE.ViewModels;
4
5namespace CascadeIDE.Features.AutonomousAgent;
6
7/// <summary>
8/// Простейший автономный раннер: модель возвращает JSON-решение (tool_call или final),
9/// раннер исполняет IDE/внешние MCP тул-вызовы и повторяет цикл.
10///
11/// В этом шаге:
12/// - IDE инструменты исполняются через <see cref="IIdeMcpActions.ExecuteCommandAsync"/>
13/// - внешние MCP инструменты вызываются через <see cref="McpClientService.CallToolAsync"/>
14///
15/// Важное замечание: в репозитории пока нет полноценного function-calling bridge для провайдеров,
16/// поэтому используется “модель -> JSON -> парсинг”.
17/// </summary>
18public sealed class AutonomousAgentService
19{
20 private static readonly string[] ToolKindsForTrace = ["PLAN", "ACTION", "OBSERVATION", "NEXT"];
21
22 private readonly AiProviderManager _aiProviderManager;
23 private readonly IIdeMcpActions _ideActions;
24 private readonly McpClientService _mcpClientService;
25
26 private readonly Func<string> _getActiveAiProvider;
27 private readonly Func<string?> _getSelectedOllamaModel;
28 private readonly Func<bool> _getUseMinimizedContext;
29 private readonly Func<string?> _getCurrentFilePath;
30 private readonly Func<string> _getEditorText;
31
32 private readonly Action<string, string, string, DateTimeOffset?> _appendTraceStep;
33 private readonly Action<string> _appendEvent;
34 private readonly IAgentOrchestrator? _orchestrator;
35
36 public AutonomousAgentService(
37 AiProviderManager aiProviderManager,
38 IIdeMcpActions ideActions,
39 McpClientService mcpClientService,
40 Func<string> getActiveAiProvider,
41 Func<string?> getSelectedOllamaModel,
42 Func<bool> getUseMinimizedContext,
43 Func<string?> getCurrentFilePath,
44 Func<string> getEditorText,
45 Action<string, string, string, DateTimeOffset?> appendTraceStep,
46 Action<string> appendEvent,
47 IAgentOrchestrator? orchestrator = null)
48 {
49 _aiProviderManager = aiProviderManager;
50 _ideActions = ideActions;
51 _mcpClientService = mcpClientService;
52 _getActiveAiProvider = getActiveAiProvider;
53 _getSelectedOllamaModel = getSelectedOllamaModel;
54 _getUseMinimizedContext = getUseMinimizedContext;
55 _getCurrentFilePath = getCurrentFilePath;
56 _getEditorText = getEditorText;
57 _appendTraceStep = appendTraceStep;
58 _appendEvent = appendEvent;
59 _orchestrator = orchestrator;
60 }
61
62 public async Task<string> RunAutonomousAsync(string objective, string safetyLevel, int maxSteps, CancellationToken cancellationToken)
63 {
64 return await RunAutonomousAsync(objective, safetyLevel, maxSteps, state: null, cancellationToken).ConfigureAwait(false);
65 }
66
67 public async Task<string> RunAutonomousAsync(
68 string objective,
69 string safetyLevel,
70 int maxSteps,
71 AutonomousRunState? state,
72 CancellationToken cancellationToken)
73 {
74 objective = objective?.Trim() ?? "";
75 if (string.IsNullOrWhiteSpace(objective))
76 return "Error: empty objective.";
77
78 maxSteps = Math.Clamp(maxSteps, 1, 40);
79
80 state ??= new AutonomousRunState
81 {
82 Objective = objective,
83 SafetyLevel = safetyLevel,
84 MaxSteps = maxSteps,
85 NextStep = 0
86 };
87
88 // Ensure state fields are consistent with latest call.
89 state.Objective = objective;
90 state.SafetyLevel = safetyLevel;
91 state.MaxSteps = maxSteps;
92
93 var isResume = state.NextStep > 0;
94
95 if (!isResume)
96 {
97 state.History.Clear();
98 _appendEvent($"{DateTime.Now:HH:mm:ss} — Autonomous started");
99 _appendTraceStep("PLAN", objective, "PENDING", null);
100 }
101
102 var toolKeys = await SafeListExternalToolsAsync(cancellationToken).ConfigureAwait(false);
103
104 // Initialize history header once.
105 if (state.History.Count == 0)
106 {
107 state.History.Add($"Objective: {objective}");
108 state.History.Add($"Safety: {safetyLevel}");
109 state.History.Add(
110 toolKeys.Count > 0
111 ? $"External MCP tools: {string.Join(", ", toolKeys.Take(40))}"
112 : "External MCP tools: none");
113 }
114
115 var startStep = Math.Clamp(state.NextStep, 0, maxSteps);
116
117 for (var step = startStep; step < maxSteps; step++)
118 {
119 cancellationToken.ThrowIfCancellationRequested();
120 state.NextStep = step; // if cancellation happens mid-step, resume repeats this step
121
122 var progress = (int)((step * 100.0) / maxSteps);
123 _appendEvent($"{DateTime.Now:HH:mm:ss} — Step {step + 1}/{maxSteps} (progress {progress}%)");
124
125 var prompt = BuildPrompt(objective, safetyLevel, step + 1, maxSteps, state.History, toolKeys);
126 _appendTraceStep("ACTION", $"Request tool decision (step {step + 1}).", "PENDING", null);
127
128 var assistantRaw = await GetAssistantRawAsync(prompt, cancellationToken).ConfigureAwait(false);
129 var decision = TryParseDecision(assistantRaw);
130
131 if (decision.Type == "final")
132 {
133 _appendTraceStep("OBSERVATION", decision.FinalAnswer ?? assistantRaw, "SUCCESS", null);
134 _appendEvent($"{DateTime.Now:HH:mm:ss} — Autonomous finished");
135 state.NextStep = maxSteps;
136 return decision.FinalAnswer ?? assistantRaw;
137 }
138
139 if (decision.Type != "tool_call")
140 {
141 _appendTraceStep("OBSERVATION", "Model returned invalid decision; stopping.", "WARNING", null);
142 state.NextStep = step; // keep this step for retry if user resumes
143 return "Autonomous stopped: invalid decision output from model.";
144 }
145
146 var observation = await ExecuteToolCallAsync(decision, safetyLevel, cancellationToken).ConfigureAwait(false);
147 observation = AppendVerifyAfterWrite(decision, observation, maxSteps);
148 state.History.Add($"[{step + 1}] {observation}");
149 state.NextStep = step + 1;
150
151 _appendTraceStep("NEXT", "Continue.", "PENDING", null);
152 }
153
154 state.NextStep = maxSteps;
155 return $"Max steps reached ({maxSteps}).";
156 }
157
158 private async Task<IReadOnlyList<string>> SafeListExternalToolsAsync(CancellationToken cancellationToken)
159 {
160 try
161 {
162 var tools = await _mcpClientService.ListToolsAsync(cancellationToken).ConfigureAwait(false);
163 return tools.Select(t => t.ToolKey).ToList();
164 }
165 catch (Exception ex)
166 {
167 _appendEvent($"{DateTime.Now:HH:mm:ss} — MCP tool list error: {ex.Message}");
168 return [];
169 }
170 }
171
172 private static string BuildPrompt(
173 string objective,
174 string safetyLevel,
175 int stepNumber,
176 int maxSteps,
177 IReadOnlyList<string> history,
178 IReadOnlyList<string> externalToolKeys)
179 {
180 var ideCommands = new[]
181 {
182 "get_editor_state",
183 "get_ide_state",
184 "get_current_file_diagnostics",
185 "build_structured",
186 "run_tests",
187 "run_affected_tests",
188 "run_code_cleanup",
189 "get_solution_files",
190 "open_file",
191 "select",
192 "go_to_position",
193 "set_breakpoint",
194 "remove_breakpoint",
195 // destructive actions (будут gating по safety)
196 "apply_edit",
197 "git_status",
198 "git_diff",
199 "git_commit",
200 "git_push",
201 "write_agent_notes",
202 "read_agent_notes"
203 };
204
205 var externalToolsLine = externalToolKeys.Count == 0
206 ? "External tools: none."
207 : "External tools: " + string.Join(", ", externalToolKeys.Take(80));
208
209 // Schema is intentionally small to reduce model drift.
210 return
211$@"You are an autonomous agent inside CascadeIDE.
212Your job: {objective}
213
214Safety level: {safetyLevel} ({AgentSafetyLevel.Observe} read-only, {AgentSafetyLevel.Confirm} confirm high-risk, {AgentSafetyLevel.Autonomous} full autonomy).
215Rules:
216- Return ONLY valid JSON (no markdown).
217- Decide ONE action per step: either a tool_call or final.
218
219IDE tools (call via scope=""ide""): allowed command_id values:
220{string.Join(", ", ideCommands)}
221
222{externalToolsLine}
223
224History (latest last):
225{string.Join("\n", history.TakeLast(12))}
226
227Now step {stepNumber}/{maxSteps}.
228
229Return JSON in this exact shape:
2301) Tool call:
231{{
232 ""type"": ""tool_call"",
233 ""scope"": ""ide""|""external"",
234 ""ide_command_id"": ""<one of ide command ids>"" (only when scope=""ide""),
235 ""external_tool_key"": ""<toolKey from external tools>"" (only when scope=""external""),
236 ""args"": {{ /* arbitrary JSON object */ }}
237}}
2382) Final:
239{{
240 ""type"": ""final"",
241 ""final_answer"": ""<what to do next / summary>""
242}}";
243 }
244
245 private async Task<string> GetAssistantRawAsync(string prompt, CancellationToken cancellationToken)
246 {
247 var providerKey = _getActiveAiProvider();
248 var modelSentinel = MainWindowViewModel.InstallNewSentinel;
249 if (providerKey == "Ollama" && string.IsNullOrWhiteSpace(_getSelectedOllamaModel()) || _getSelectedOllamaModel() == modelSentinel)
250 return "Error: Ollama model not configured.";
251
252 var messages = new List<ChatMessage>
253 {
254 new("user", prompt)
255 };
256
257 var sb = new System.Text.StringBuilder(4096);
258 await foreach (var token in _aiProviderManager.StreamChatAsync(
259 providerKey,
260 messages,
261 _getCurrentFilePath(),
262 _getEditorText(),
263 _getUseMinimizedContext(),
264 cancellationToken).ConfigureAwait(false))
265 {
266 sb.Append(token);
267 }
268 return sb.ToString().Trim();
269 }
270
271 private static Decision TryParseDecision(string assistantRaw)
272 {
273 var json = ExtractFirstJsonObject(assistantRaw);
274 if (string.IsNullOrWhiteSpace(json))
275 return new Decision(Type: "final", FinalAnswer: assistantRaw);
276
277 try
278 {
279 using var doc = JsonDocument.Parse(json);
280 var root = doc.RootElement;
281 var type = root.TryGetProperty("type", out var te) ? te.GetString() : null;
282 if (!string.Equals(type, "tool_call", StringComparison.OrdinalIgnoreCase)
283 && !string.Equals(type, "final", StringComparison.OrdinalIgnoreCase))
284 return new Decision(Type: "final", FinalAnswer: assistantRaw);
285
286 if (string.Equals(type, "final", StringComparison.OrdinalIgnoreCase))
287 {
288 var finalAnswer = root.TryGetProperty("final_answer", out var fe) ? fe.GetString() : null;
289 return new Decision(Type: "final", FinalAnswer: finalAnswer);
290 }
291
292 var scope = root.TryGetProperty("scope", out var se) ? se.GetString() : null;
293 var ideCommandId = root.TryGetProperty("ide_command_id", out var ie) ? ie.GetString() : null;
294 var externalToolKey = root.TryGetProperty("external_tool_key", out var ee) ? ee.GetString() : null;
295 var argsRaw = root.TryGetProperty("args", out var ae) ? ae.GetRawText() : null;
296
297 if (string.IsNullOrWhiteSpace(scope))
298 return new Decision(Type: "final", FinalAnswer: assistantRaw);
299
300 if (string.Equals(scope, "ide", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(ideCommandId))
301 return new Decision(Type: "final", FinalAnswer: assistantRaw);
302
303 if (string.Equals(scope, "external", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(externalToolKey))
304 return new Decision(Type: "final", FinalAnswer: assistantRaw);
305
306 return new Decision(
307 Type: "tool_call",
308 Scope: scope,
309 IdeCommandId: ideCommandId,
310 ExternalToolKey: externalToolKey,
311 ArgsRawJson: argsRaw);
312 }
313 catch
314 {
315 return new Decision(Type: "final", FinalAnswer: assistantRaw);
316 }
317 }
318
319 private async Task<string> ExecuteToolCallAsync(Decision decision, string safetyLevel, CancellationToken cancellationToken)
320 {
321 var scope = decision.Scope ?? "";
322 if (string.Equals(scope, "ide", StringComparison.OrdinalIgnoreCase))
323 {
324 var cmd = decision.IdeCommandId ?? "";
325 if (string.IsNullOrWhiteSpace(cmd))
326 return "Blocked: missing ide_command_id.";
327
328 if (!IsIdeCommandAllowed(cmd, safetyLevel, out var riskReason))
329 return $"Blocked by safety ({safetyLevel}): {riskReason}";
330
331 _appendTraceStep("ACTION", $"ide {cmd}", "PENDING", null);
332
333 // Args are optional; pass only if it is an object.
334 IReadOnlyDictionary<string, JsonElement>? argsDict = null;
335 if (!string.IsNullOrWhiteSpace(decision.ArgsRawJson))
336 {
337 using var argsDoc = JsonDocument.Parse(decision.ArgsRawJson);
338 var argsEl = argsDoc.RootElement;
339 if (argsEl.ValueKind == JsonValueKind.Object)
340 argsDict = argsEl.EnumerateObject().ToDictionary(p => p.Name, p => p.Value);
341 }
342
343 if (IsHighRiskIdeCommand(cmd) && !AgentSafetyLevel.IsAutonomous(safetyLevel))
344 {
345 var ok = await _ideActions.RequestConfirmationAsync(
346 $"Autonomous action: {cmd}\nSafety={safetyLevel}\nExecute tool call?",
347 cancellationToken).ConfigureAwait(false);
348 if (!string.Equals(ok, ConfirmationResponses.Ok, StringComparison.OrdinalIgnoreCase))
349 return "Cancelled by user confirmation.";
350 }
351
352 var text = await _ideActions.ExecuteCommandAsync(cmd, argsDict, cancellationToken).ConfigureAwait(false);
353 _appendTraceStep("OBSERVATION", $"{cmd} => {TrimForTrace(text)}", "SUCCESS", null);
354 _appendEvent($"{DateTime.Now:HH:mm:ss} — ide call: {cmd}");
355 return $"{cmd}: {text}";
356 }
357
358 if (string.Equals(scope, "external", StringComparison.OrdinalIgnoreCase))
359 {
360 var key = decision.ExternalToolKey ?? "";
361 if (string.IsNullOrWhiteSpace(key))
362 return "Blocked: missing external_tool_key.";
363
364 if (!AgentSafetyLevel.IsAutonomous(safetyLevel))
365 {
366 _appendTraceStep("OBSERVATION", $"External tool calls require {AgentSafetyLevel.Autonomous}. Blocked: {key}", "WARNING", null);
367 return $"Blocked by safety ({safetyLevel}): external tool calls require {AgentSafetyLevel.Autonomous}.";
368 }
369
370 _appendTraceStep("ACTION", $"external {key}", "PENDING", null);
371
372 IReadOnlyDictionary<string, object?>? argsObj = null;
373 if (!string.IsNullOrWhiteSpace(decision.ArgsRawJson))
374 {
375 using var argsDoc = JsonDocument.Parse(decision.ArgsRawJson);
376 var argsEl = argsDoc.RootElement;
377 if (argsEl.ValueKind == JsonValueKind.Object)
378 argsObj = argsEl.EnumerateObject().ToDictionary(p => p.Name, p => JsonElementToObject(p.Value));
379 }
380
381 var text = await _mcpClientService.CallToolAsync(key, argsObj, cancellationToken).ConfigureAwait(false);
382 _appendTraceStep("OBSERVATION", $"{key} => {TrimForTrace(text)}", "SUCCESS", null);
383 _appendEvent($"{DateTime.Now:HH:mm:ss} — external call: {key}");
384 return $"{key}: {text}";
385 }
386
387 return $"Blocked: unknown scope '{scope}'.";
388 }
389
390 private static bool IsIdeCommandAllowed(string cmd, string safetyLevel, out string reason)
391 {
392 reason = "";
393 if (AgentSafetyLevel.IsAutonomous(safetyLevel))
394 return true;
395
396 if (AgentSafetyLevel.IsObserve(safetyLevel))
397 {
398 if (IsHighRiskIdeCommand(cmd))
399 {
400 reason = $"high-risk (edit/git) blocked in {AgentSafetyLevel.Observe}";
401 return false;
402 }
403 }
404
405 // safety.confirm allows tool calls, but high-risk ones still need confirmation.
406 return true;
407 }
408
409 private static bool IsHighRiskIdeCommand(string cmd)
410 {
411 return cmd is "apply_edit"
412 or "git_commit"
413 or "git_push"
414 or "write_agent_notes";
415 }
416
417 private static string TrimForTrace(string s, int maxLen = 500)
418 {
419 if (string.IsNullOrWhiteSpace(s))
420 return "";
421 if (s.Length <= maxLen)
422 return s;
423 return s[..maxLen] + "…";
424 }
425
426 private static object? JsonElementToObject(JsonElement el)
427 {
428 return el.ValueKind switch
429 {
430 JsonValueKind.String => el.GetString(),
431 JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.TryGetDouble(out var d) ? d : el.GetDecimal(),
432 JsonValueKind.True => true,
433 JsonValueKind.False => false,
434 JsonValueKind.Null => null,
435 JsonValueKind.Array => el.EnumerateArray().Select(JsonElementToObject).ToList(),
436 JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
437 _ => el.GetRawText()
438 };
439 }
440
441 private string AppendVerifyAfterWrite(Decision decision, string observation, int maxSteps)
442 {
443 if (_orchestrator is null || !ToolWritesWorkspace(decision))
444 return observation;
445
446 var verify = _orchestrator.TryVerifyAfterStep(writesOccurred: true, longRun: maxSteps > 10);
447 if (!verify.Accepted)
448 return observation + $"\n[AEE] verify skipped: {verify.Error ?? "not started"}";
449
450 return observation + $"\n[AEE] verify queued ({verify.RunId?[..8]}…).";
451 }
452
453 private static bool ToolWritesWorkspace(Decision decision)
454 {
455 if (!string.Equals(decision.Scope, "ide", StringComparison.OrdinalIgnoreCase))
456 return false;
457
458 return decision.IdeCommandId is "apply_edit" or "git_commit" or "write_agent_notes";
459 }
460
461 private static string? ExtractFirstJsonObject(string text)
462 {
463 if (string.IsNullOrWhiteSpace(text))
464 return null;
465
466 var start = text.IndexOf('{');
467 if (start < 0)
468 return null;
469
470 var end = text.LastIndexOf('}');
471 if (end <= start)
472 return null;
473
474 return text.Substring(start, end - start + 1);
475 }
476
477 private sealed record Decision(
478 string Type,
479 string? Scope = null,
480 string? IdeCommandId = null,
481 string? ExternalToolKey = null,
482 string? ArgsRawJson = null,
483 string? FinalAnswer = null);
484}
485
486
View only · write via MCP/CIDE