src/TechToolbox.Agent/Agent/AgentOrchestrator.cs

using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Text.Json;
 
namespace TechToolbox.Agent.Agent;
 
public class AgentOrchestrator
{
    private static readonly Dictionary<string, string> ToolAliases = new(StringComparer.OrdinalIgnoreCase)
    {
        ["read_file"] = "READ-FILE",
        ["list_directory"] = "LIST-DIRECTORY",
        ["write_file"] = "WRITE-FILE"
    };
 
    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;
    private readonly string? _tracePath;
    private readonly object _traceLock = new();
 
    public AgentOrchestrator(
        LlmClient llm,
        Dictionary<string, Func<string, Task<string>>> tools,
        Memory.MemoryStore? memory,
        int maxIterations,
        bool autoRetry,
        string? tracePath = null)
    {
        _llm = llm;
        _tools = tools;
        _memory = memory;
        _maxIterations = maxIterations;
        _autoRetry = autoRetry;
        _tracePath = tracePath;
    }
 
    public AgentResult Run(string prompt)
        => RunAsync(prompt).GetAwaiter().GetResult();
 
    public async Task<AgentResult> RunAsync(string prompt)
    {
        Trace($"RunAsync start maxIterations={_maxIterations} autoRetry={_autoRetry} promptLength={prompt?.Length ?? 0}");
        var toolNames = new List<string>();
        var stopwatch = Stopwatch.StartNew();
 
        var firstAttempt = await RunLoopAsync(prompt, _maxIterations, toolNames).ConfigureAwait(false);
        if (!firstAttempt.ReachedIterationLimit)
        {
            Trace($"RunAsync completed first attempt outputLength={firstAttempt.OutputText?.Length ?? 0}");
            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();
            Trace($"RunAsync retry completed reachedLimit={retryAttempt.ReachedIterationLimit}");
 
            var output = retryAttempt.ReachedIterationLimit
                ? $"Iteration limit reached after retry ({retryIterations})."
                : retryAttempt.OutputText;
 
            return FinalizeResult(output, toolNames, stopwatch.ElapsedMilliseconds);
        }
 
        stopwatch.Stop();
        Trace("RunAsync iteration limit reached without retry");
        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++)
        {
            Trace($"Iteration {i + 1}/{iterationLimit} start messages={messages.Count}");
            var combinedPrompt = string.Join("\n\n", messages);
 
            // 1. Ask the model
            Trace($"Iteration {i + 1} LLM request promptLength={combinedPrompt.Length}");
            var llmResponse = await _llm.GenerateAsync(combinedPrompt).ConfigureAwait(false);
            var text = llmResponse.Text.Trim();
            Trace($"Iteration {i + 1} LLM response length={text.Length} preview={Preview(text)}");
 
            if (string.IsNullOrWhiteSpace(text))
            {
                var isLastIteration = i >= (iterationLimit - 1);
                if (isLastIteration)
                {
                    Trace($"Iteration {i + 1} empty LLM response on final iteration");
                    return new RunLoopResult("LLM returned an empty response.", ReachedIterationLimit: false);
                }
 
                Trace($"Iteration {i + 1} empty LLM response; retrying");
                messages.Add("LLM returned an empty response. Return either a valid tool call or a final answer.");
                continue;
            }
 
            // 2. Detect tool call patterns from structured JSON or mixed prose.
            if (TryParseToolCall(text, out var toolName, out var toolArgs))
            {
                Trace($"Iteration {i + 1} parsed tool={toolName} argsLength={toolArgs?.Length ?? 0}");
                if (!_tools.TryGetValue(toolName, out var toolFunc))
                {
                    Trace($"Iteration {i + 1} tool not found: {toolName}");
                    messages.Add($"Tool '{toolName}' not found.");
                    continue;
                }
 
                toolNames.Add(toolName);
 
                // 3. Execute tool
                Trace($"Iteration {i + 1} executing tool {toolName}");
                var toolResult = await toolFunc(toolArgs).ConfigureAwait(false);
                Trace($"Iteration {i + 1} tool {toolName} completed resultLength={toolResult?.Length ?? 0} preview={Preview(toolResult)}");
 
                // 4. Feed result back into the loop
                messages.Add($"ToolResult({toolName}): {toolResult}");
                continue;
            }
 
            // 5. No tool call -> final answer
            Trace($"Iteration {i + 1} final answer detected length={text.Length}");
            return new RunLoopResult(text, ReachedIterationLimit: false);
        }
 
        Trace($"RunLoop reached iteration limit={iterationLimit}");
        return new RunLoopResult("Iteration limit reached.", ReachedIterationLimit: true);
    }
 
    private AgentResult FinalizeResult(string output, List<string> toolNames, long durationMs)
    {
        Trace($"FinalizeResult toolCalls={toolNames.Count} durationMs={durationMs} outputLength={output?.Length ?? 0}");
        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 void Trace(string message)
    {
        if (string.IsNullOrWhiteSpace(_tracePath))
            return;
 
        try
        {
            var line = $"[{DateTime.UtcNow:O}] {message}{Environment.NewLine}";
            lock (_traceLock)
            {
                var dir = Path.GetDirectoryName(_tracePath);
                if (!string.IsNullOrWhiteSpace(dir))
                    Directory.CreateDirectory(dir);
 
                File.AppendAllText(_tracePath, line);
            }
        }
        catch
        {
            // Diagnostic tracing must never break orchestration.
        }
    }
 
    private static string Preview(string? text)
    {
        if (string.IsNullOrWhiteSpace(text))
            return "(empty)";
 
        var normalized = text.Replace("\r", " ").Replace("\n", " ").Trim();
        return normalized.Length <= 120 ? normalized : normalized[..120];
    }
 
    private string BuildGoalPrompt(string prompt)
    {
        var availableTools = string.Join(", ", _tools.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
 
        return
            "You are a local automation agent. " +
            "Work step-by-step. " +
            "You may only call one of the exact tool names listed below. " +
            "If a tool is needed, call it using the format TOOLNAME{json} or TOOLCALL{TOOLNAME} {json}. " +
            "Tool-call JSON must be strict JSON with double-quoted keys and values. " +
            "Do not emit pseudo-tools such as read_file, list_directory, or write_file unless they are explicitly listed in available tools. " +
            "When calling a tool, output only the tool call with no extra commentary. " +
            "Do not hallucinate tool names. " +
            "Stop when the task is complete.\n\n" +
            $"Available tools: {availableTools}\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;
 
        if (TryParseLegacyToolCall(text, out toolName, out jsonArgs))
            return true;
 
        if (TryParseAliasToolCall(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 _)
                    && !TryExtractLooseJsonObject(text, cursor, out parsedArgs, out _))
                {
                    searchStart = nameIdx + candidate.Length;
                    continue;
                }
 
                toolName = candidate;
                jsonArgs = parsedArgs;
                return true;
            }
        }
 
        return false;
    }
 
    private bool TryParseLegacyToolCall(string text, out string toolName, out string jsonArgs)
    {
        toolName = "";
        jsonArgs = "";
 
        if (string.IsNullOrWhiteSpace(text))
            return false;
 
        var marker = "TOOLCALL{";
        var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
        if (markerIndex < 0)
            return false;
 
        var toolStart = markerIndex + marker.Length;
        var toolEnd = text.IndexOf('}', toolStart);
        if (toolEnd <= toolStart)
            return false;
 
        var candidateToolName = text[toolStart..toolEnd].Trim();
        if (!TryResolveToolName(candidateToolName, out var resolvedToolName))
            return false;
 
        var cursor = toolEnd + 1;
        while (cursor < text.Length && char.IsWhiteSpace(text[cursor]))
        {
            cursor++;
        }
 
        if (cursor < text.Length && text[cursor] == '{' && TryExtractJsonObject(text, cursor, out var parsedArgs, out _))
        {
            toolName = resolvedToolName;
            jsonArgs = parsedArgs;
            return true;
        }
 
        toolName = resolvedToolName;
        jsonArgs = "{}";
        return true;
    }
 
    private bool TryParseAliasToolCall(string text, out string toolName, out string jsonArgs)
    {
        toolName = "";
        jsonArgs = "";
 
        if (string.IsNullOrWhiteSpace(text))
            return false;
 
        foreach (var alias in ToolAliases.Keys)
        {
            var searchStart = 0;
            while (searchStart < text.Length)
            {
                var aliasIdx = text.IndexOf(alias, searchStart, StringComparison.OrdinalIgnoreCase);
                if (aliasIdx < 0)
                    break;
 
                if (!IsTokenBoundary(text, aliasIdx - 1))
                {
                    searchStart = aliasIdx + alias.Length;
                    continue;
                }
 
                var cursor = aliasIdx + alias.Length;
                while (cursor < text.Length && char.IsWhiteSpace(text[cursor]))
                {
                    cursor++;
                }
 
                if (cursor >= text.Length || text[cursor] != '{')
                {
                    searchStart = aliasIdx + alias.Length;
                    continue;
                }
 
                if (!TryExtractJsonObject(text, cursor, out var parsedArgs, out _)
                    && !TryExtractLooseJsonObject(text, cursor, out parsedArgs, out _))
                {
                    searchStart = aliasIdx + alias.Length;
                    continue;
                }
 
                if (!TryResolveToolName(alias, out var resolvedToolName))
                {
                    searchStart = aliasIdx + alias.Length;
                    continue;
                }
 
                toolName = resolvedToolName;
                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 (!TryResolveToolName(parsedName, out var resolvedToolName))
                return false;
 
            if (TryGetObjectProperty(root, out var parsedArgs, "args", "arguments", "input"))
            {
                toolName = resolvedToolName;
                jsonArgs = parsedArgs.GetRawText();
                return true;
            }
 
            toolName = resolvedToolName;
            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 TryExtractLooseJsonObject(string text, int startIndex, out string jsonText, out int endIndex)
    {
        jsonText = "";
        endIndex = -1;
 
        if (!TryExtractBalancedObject(text, startIndex, out var candidate, out endIndex))
            return false;
 
        // Convert simple JS-like object syntax (e.g., {path: "x"}) into strict JSON.
        var normalized = Regex.Replace(
            candidate,
            @"(?<=[\{,]\s*)([A-Za-z_][A-Za-z0-9_-]*)\s*:",
            "\"$1\":");
 
        try
        {
            using var _ = JsonDocument.Parse(normalized);
            jsonText = normalized;
            return true;
        }
        catch
        {
            return false;
        }
    }
 
    private static bool TryExtractBalancedObject(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;
 
            jsonText = text[startIndex..(i + 1)];
            endIndex = i;
            return true;
        }
 
        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 != '-';
    }
 
    private bool TryResolveToolName(string candidateName, out string resolvedName)
    {
        resolvedName = "";
        if (string.IsNullOrWhiteSpace(candidateName))
            return false;
 
        if (_tools.ContainsKey(candidateName))
        {
            resolvedName = candidateName;
            return true;
        }
 
        if (ToolAliases.TryGetValue(candidateName, out var mapped) && _tools.ContainsKey(mapped))
        {
            resolvedName = mapped;
            return true;
        }
 
        return false;
    }
}
 
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;
    }
}