src/TechToolbox.Agent/Agent/AgentOrchestrator.cs
|
using System.Diagnostics;
using System.Text.Json; namespace TechToolbox.Agent.Agent; public class AgentOrchestrator { private readonly LlmClient _llm; private readonly Dictionary<string, Func<string, Task<string>>> _tools; private readonly Memory.MemoryStore? _memory; private readonly int _maxIterations; private readonly bool _autoRetry; public AgentOrchestrator( LlmClient llm, Dictionary<string, Func<string, Task<string>>> tools, Memory.MemoryStore? memory, int maxIterations, bool autoRetry) { _llm = llm; _tools = tools; _memory = memory; _maxIterations = maxIterations; _autoRetry = autoRetry; } public AgentResult Run(string prompt) => RunAsync(prompt).GetAwaiter().GetResult(); public async Task<AgentResult> RunAsync(string prompt) { var toolNames = new List<string>(); var stopwatch = Stopwatch.StartNew(); var firstAttempt = await RunLoopAsync(prompt, _maxIterations, toolNames).ConfigureAwait(false); if (!firstAttempt.ReachedIterationLimit) { stopwatch.Stop(); return FinalizeResult(firstAttempt.OutputText, toolNames, stopwatch.ElapsedMilliseconds); } if (_autoRetry) { var retryIterations = Math.Max(_maxIterations + 5, (int)Math.Ceiling(_maxIterations * 1.5)); var retryPrompt = $"{prompt}\n\nPrevious attempt reached the iteration limit ({_maxIterations}). " + "Continue from where you left off and provide a final answer when complete."; var retryAttempt = await RunLoopAsync(retryPrompt, retryIterations, toolNames).ConfigureAwait(false); stopwatch.Stop(); var output = retryAttempt.ReachedIterationLimit ? $"Iteration limit reached after retry ({retryIterations})." : retryAttempt.OutputText; return FinalizeResult(output, toolNames, stopwatch.ElapsedMilliseconds); } stopwatch.Stop(); return FinalizeResult("Iteration limit reached.", toolNames, stopwatch.ElapsedMilliseconds); } private async Task<RunLoopResult> RunLoopAsync(string prompt, int iterationLimit, List<string> toolNames) { var messages = new List<string>(); messages.Add(BuildGoalPrompt(prompt)); for (int i = 0; i < iterationLimit; i++) { var combinedPrompt = string.Join("\n\n", messages); // 1. Ask the model var llmResponse = await _llm.GenerateAsync(combinedPrompt).ConfigureAwait(false); var text = llmResponse.Text.Trim(); // 2. Detect tool call patterns from structured JSON or mixed prose. if (TryParseToolCall(text, out var toolName, out var toolArgs)) { if (!_tools.TryGetValue(toolName, out var toolFunc)) { messages.Add($"Tool '{toolName}' not found."); continue; } toolNames.Add(toolName); // 3. Execute tool var toolResult = await toolFunc(toolArgs).ConfigureAwait(false); // 4. Feed result back into the loop messages.Add($"ToolResult({toolName}): {toolResult}"); continue; } // 5. No tool call -> final answer return new RunLoopResult(text, ReachedIterationLimit: false); } return new RunLoopResult("Iteration limit reached.", ReachedIterationLimit: true); } private AgentResult FinalizeResult(string output, List<string> toolNames, long durationMs) { var result = new AgentResult(output) { ToolNames = toolNames, ToolCallCount = toolNames.Count, DurationMs = (int)durationMs }; // Optional memory integration _memory?.AddHistory(new Memory.RunHistory { Timestamp = DateTimeOffset.UtcNow, Intent = output[..Math.Min(output.Length, 200)], Status = "success", Outcome = "completed", ToolCalls = result.ToolCallCount, ToolNames = toolNames, DurationMs = result.DurationMs }); return result; } private string BuildGoalPrompt(string prompt) { return "You are a local automation agent. " + "Work step-by-step. If a tool is needed, call it using the format TOOLNAME{json}. " + "Do not hallucinate tool names. " + "Stop when the task is complete.\n\n" + $"Goal: {prompt}"; } private bool TryParseToolCall(string text, out string toolName, out string jsonArgs) { toolName = ""; jsonArgs = ""; if (string.IsNullOrWhiteSpace(text)) return false; if (TryParseStructuredToolCall(text, out toolName, out jsonArgs)) return true; var orderedToolNames = _tools.Keys .OrderByDescending(name => name.Length) .ToList(); foreach (var candidate in orderedToolNames) { var searchStart = 0; while (searchStart < text.Length) { var nameIdx = text.IndexOf(candidate, searchStart, StringComparison.OrdinalIgnoreCase); if (nameIdx < 0) break; if (!IsTokenBoundary(text, nameIdx - 1)) { searchStart = nameIdx + candidate.Length; continue; } var cursor = nameIdx + candidate.Length; while (cursor < text.Length && char.IsWhiteSpace(text[cursor])) { cursor++; } if (cursor >= text.Length || text[cursor] != '{') { searchStart = nameIdx + candidate.Length; continue; } if (!TryExtractJsonObject(text, cursor, out var parsedArgs, out _)) { searchStart = nameIdx + candidate.Length; continue; } toolName = candidate; jsonArgs = parsedArgs; return true; } } return false; } private bool TryParseStructuredToolCall(string text, out string toolName, out string jsonArgs) { toolName = ""; jsonArgs = ""; var trimmed = text.Trim(); if (!trimmed.StartsWith('{') || !TryExtractJsonObject(trimmed, 0, out var structuredJson, out _)) return false; try { using var doc = JsonDocument.Parse(structuredJson); var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) return false; if (!TryGetStringProperty(root, out var parsedName, "tool", "toolName", "name")) return false; if (!_tools.ContainsKey(parsedName)) return false; if (TryGetObjectProperty(root, out var parsedArgs, "args", "arguments", "input")) { toolName = parsedName; jsonArgs = parsedArgs.GetRawText(); return true; } toolName = parsedName; jsonArgs = "{}"; return true; } catch { return false; } } private static bool TryGetStringProperty(JsonElement source, out string value, params string[] names) { value = ""; foreach (var property in source.EnumerateObject()) { if (!names.Any(n => string.Equals(n, property.Name, StringComparison.OrdinalIgnoreCase))) continue; if (property.Value.ValueKind != JsonValueKind.String) continue; value = property.Value.GetString() ?? ""; return !string.IsNullOrWhiteSpace(value); } return false; } private static bool TryGetObjectProperty(JsonElement source, out JsonElement value, params string[] names) { foreach (var property in source.EnumerateObject()) { if (!names.Any(n => string.Equals(n, property.Name, StringComparison.OrdinalIgnoreCase))) continue; if (property.Value.ValueKind != JsonValueKind.Object) continue; value = property.Value; return true; } value = default; return false; } private static bool TryExtractJsonObject(string text, int startIndex, out string jsonText, out int endIndex) { jsonText = ""; endIndex = -1; if (startIndex < 0 || startIndex >= text.Length || text[startIndex] != '{') return false; var depth = 0; var inString = false; var escaped = false; for (var i = startIndex; i < text.Length; i++) { var ch = text[i]; if (inString) { if (escaped) { escaped = false; continue; } if (ch == '\\') { escaped = true; continue; } if (ch == '"') { inString = false; } continue; } if (ch == '"') { inString = true; continue; } if (ch == '{') { depth++; continue; } if (ch != '}') continue; depth--; if (depth != 0) continue; var candidate = text[startIndex..(i + 1)]; try { using var _ = JsonDocument.Parse(candidate); jsonText = candidate; endIndex = i; return true; } catch { return false; } } return false; } private static bool IsTokenBoundary(string text, int index) { if (index < 0 || index >= text.Length) return true; var ch = text[index]; return !char.IsLetterOrDigit(ch) && ch != '_' && ch != '-'; } } public record RunLoopResult(string OutputText, bool ReachedIterationLimit); public class AgentResult { public string OutputText { get; } public List<string> ToolNames { get; set; } = new(); public int ToolCallCount { get; set; } public int DurationMs { get; set; } public AgentResult(string output) { OutputText = output; } } |