using System.Diagnostics; using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Engine; public sealed class AgentEngine : IAgentMessageRouter { private readonly OpenRouterClient _client; private readonly ToolRegistry _toolRegistry; private readonly PermissionGate _permissionGate; private readonly IStateStore _stateStore; private readonly ILoggerFactory _loggerFactory; private readonly ContextCompactor _compactor; private readonly Dictionary> _chatHistories = new(); private readonly Dictionary> _chatContexts = new(); private readonly Dictionary _runningChats = new(); private readonly Lock _lock = new(); private Func>? _agentConfigProvider; private Func? _agentDirResolver; private string _instanceId = ""; private static readonly JsonSerializerOptions _jsonOpts = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public AgentEngine( OpenRouterClient client, ToolRegistry toolRegistry, PermissionGate permissionGate, IStateStore stateStore, ILoggerFactory loggerFactory) { _client = client; _toolRegistry = toolRegistry; _permissionGate = permissionGate; _stateStore = stateStore; _loggerFactory = loggerFactory; _compactor = new ContextCompactor(client, loggerFactory); } public event Action? OnStepCompleted; /// /// Wird ausgelöst wenn ein neuer ChatEntry hinzugefügt wird (agentId, role, content, source). /// Erlaubt der UI, Nachrichten aus Hintergrund-Runs (ToolJobs, AgentComm) live anzuzeigen. /// Source gibt an, woher die Nachricht kam (webview, telegram, agentcomm, job, null). /// public event Action? OnChatEntryAdded; /// /// Wird nach jedem abgeschlossenen Run/Chat ausgelöst (model, result). /// Erlaubt der UI, Token-Verbrauch und Kosten für ALLE Runs zu tracken. /// public event Action? OnRunCompleted; public async Task RunAsync( AgentConfig agentConfig, string userMessage, string instanceId, CancellationToken externalCt) { var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}"); var loopGuard = new LoopGuard(agentConfig.LoopGuard); var sw = Stopwatch.StartNew(); using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token); var ct = linkedCts.Token; logger.LogInformation("Agent run started: {AgentId}, model={Model}", agentConfig.AgentId, agentConfig.Model); try { var tools = _toolRegistry.GetForAgent(agentConfig); var toolDefinitions = BuildToolDefinitions(tools); var messages = new List(); var systemPrompt = agentConfig.FullSystemPrompt; if (!string.IsNullOrWhiteSpace(systemPrompt)) messages.Add(ChatMessage.System(systemPrompt)); messages.Add(ChatMessage.User(userMessage)); string? finalMessage = null; var totalTokens = 0; while (true) { ct.ThrowIfCancellationRequested(); loopGuard.RecordStep(); var request = new ChatRequest { Model = agentConfig.Model, Messages = messages, Tools = toolDefinitions.Count > 0 ? toolDefinitions : null }; var response = await _client.CompleteAsync(request, ct); var promptTokens = 0; if (response.Usage is not null) { totalTokens += response.Usage.TotalTokens; promptTokens = response.Usage.PromptTokens; loopGuard.RecordTokens(response.Usage.TotalTokens); } // Context-Compaction auch in RunAsync – verhindert Token-Explosion bei komplexen Analysen var compacted = await _compactor.CompactIfNeededAsync( messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct); if (compacted) { OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert (RunAsync)"); } var choice = response.Choices.FirstOrDefault(); if (choice is null) { finalMessage = "[No response from model]"; break; } var assistantMessage = choice.Message; if (assistantMessage.ToolCalls is { Count: > 0 }) { messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls)); foreach (var toolCall in assistantMessage.ToolCalls) { var toolResult = await ExecuteToolCallAsync( toolCall, agentConfig, instanceId, tools, logger, ct); messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult)); } OnStepCompleted?.Invoke(agentConfig.AgentId, $"Step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s) executed"); } else { finalMessage = assistantMessage.Content ?? ""; break; } } sw.Stop(); logger.LogInformation( "Agent run completed: {AgentId}, steps={Steps}, tokens={Tokens}, duration={Duration}ms", agentConfig.AgentId, loopGuard.Steps, totalTokens, sw.ElapsedMilliseconds); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.Completed, finalMessage, loopGuard.Steps, totalTokens, sw.Elapsed); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { sw.Stop(); logger.LogWarning("Agent run timed out: {AgentId} after {Duration}ms", agentConfig.AgentId, sw.ElapsedMilliseconds); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.Cancelled, null, loopGuard.Steps, loopGuard.Tokens, sw.Elapsed); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } catch (OperationCanceledException) { sw.Stop(); logger.LogInformation("Agent run cancelled: {AgentId}", agentConfig.AgentId); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.Cancelled, null, loopGuard.Steps, loopGuard.Tokens, sw.Elapsed); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } catch (LoopLimitExceededException ex) { sw.Stop(); logger.LogWarning("Agent run loop limit exceeded: {AgentId}: {Message}", agentConfig.AgentId, ex.Message); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, null, loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } catch (Exception ex) { sw.Stop(); logger.LogError(ex, "Agent run failed: {AgentId}", agentConfig.AgentId); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.Failed, null, loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } } public async Task ChatAsync( AgentConfig agentConfig, string userMessage, string instanceId, CancellationToken externalCt, string? source = null) { var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}"); var loopGuard = new LoopGuard(agentConfig.LoopGuard); var sw = Stopwatch.StartNew(); using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt, timeoutCts.Token); var ct = linkedCts.Token; lock (_lock) _runningChats[agentConfig.AgentId] = linkedCts; try { var tools = _toolRegistry.GetForAgent(agentConfig); var toolDefinitions = BuildToolDefinitions(tools); List messages; lock (_lock) { if (!_chatContexts.TryGetValue(agentConfig.AgentId, out messages!)) { messages = new List(); var systemPrompt = agentConfig.FullSystemPrompt; if (!string.IsNullOrWhiteSpace(systemPrompt)) messages.Add(ChatMessage.System(systemPrompt)); _chatContexts[agentConfig.AgentId] = messages; } } // Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt var routedMessage = source switch { ChatSource.WebView => $"[WebView Chat – antworte als normaler Text, NICHT über Telegram senden]\n{userMessage}", ChatSource.Telegram => userMessage, // Telegram-Nachrichten kommen bereits mit [Telegram] Prefix vom Job _ => userMessage }; messages.Add(ChatMessage.User(routedMessage)); AddChatEntry(agentConfig.AgentId, "user", userMessage, source); string? finalMessage = null; var totalTokens = 0; while (true) { ct.ThrowIfCancellationRequested(); loopGuard.RecordStep(); var request = new ChatRequest { Model = agentConfig.Model, Messages = messages, Tools = toolDefinitions.Count > 0 ? toolDefinitions : null }; var response = await _client.CompleteAsync(request, ct); var promptTokens = 0; if (response.Usage is not null) { totalTokens += response.Usage.TotalTokens; promptTokens = response.Usage.PromptTokens; loopGuard.RecordTokens(response.Usage.TotalTokens); } // Context-Compaction nach API-Response prüfen var compacted = await _compactor.CompactIfNeededAsync( messages, promptTokens, agentConfig.LoopGuard, agentConfig.Model, ct); if (compacted) { PersistChatState(agentConfig.AgentId); OnStepCompleted?.Invoke(agentConfig.AgentId, "Context kompaktiert"); } var choice = response.Choices.FirstOrDefault(); if (choice is null) { finalMessage = "[No response from model]"; break; } var assistantMessage = choice.Message; if (assistantMessage.ToolCalls is { Count: > 0 }) { messages.Add(ChatMessage.AssistantWithToolCalls(assistantMessage.ToolCalls)); foreach (var toolCall in assistantMessage.ToolCalls) { var toolResult = await ExecuteToolCallAsync( toolCall, agentConfig, instanceId, tools, logger, ct); messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult)); } OnStepCompleted?.Invoke(agentConfig.AgentId, $"Chat step {loopGuard.Steps}: {assistantMessage.ToolCalls.Count} tool call(s)"); } else { finalMessage = assistantMessage.Content ?? ""; messages.Add(ChatMessage.Assistant(finalMessage)); AddChatEntry(agentConfig.AgentId, "assistant", finalMessage, source); break; } } sw.Stop(); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.Completed, finalMessage, loopGuard.Steps, totalTokens, sw.Elapsed); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } catch (OperationCanceledException) { sw.Stop(); var cancelMsg = "[Chat abgebrochen]"; AddChatEntry(agentConfig.AgentId, "assistant", cancelMsg, source); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.Cancelled, cancelMsg, loopGuard.Steps, loopGuard.Tokens, sw.Elapsed); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } catch (LoopLimitExceededException ex) { sw.Stop(); var limitMsg = $"[Loop-Limit erreicht: {ex.Message}]"; logger.LogWarning("Chat loop limit: {AgentId}: {Message}", agentConfig.AgentId, ex.Message); AddChatEntry(agentConfig.AgentId, "assistant", limitMsg, source); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, limitMsg, loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } catch (Exception ex) { sw.Stop(); var errorMsg = $"[Fehler: {ex.Message}]"; logger.LogError(ex, "Chat failed: {AgentId}", agentConfig.AgentId); AddChatEntry(agentConfig.AgentId, "assistant", errorMsg, source); var result = new AgentRunResult( agentConfig.AgentId, AgentRunStatus.Failed, errorMsg, loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex); OnRunCompleted?.Invoke(agentConfig.Model, result); return result; } finally { lock (_lock) _runningChats.Remove(agentConfig.AgentId); } } public IReadOnlyList GetChatHistory(string agentId) { lock (_lock) return _chatHistories.TryGetValue(agentId, out var history) ? history.ToList().AsReadOnly() : []; } public bool IsRunning(string agentId) { lock (_lock) return _runningChats.ContainsKey(agentId); } public void AbortChat(string agentId) { lock (_lock) { if (_runningChats.TryGetValue(agentId, out var cts)) cts.Cancel(); } } public void ClearChatHistory(string agentId) { lock (_lock) { _chatHistories.Remove(agentId); _chatContexts.Remove(agentId); } var dir = _agentDirResolver?.Invoke(agentId); if (dir is null) return; var historyPath = Path.Combine(dir, "ChatHistory.json"); var contextPath = Path.Combine(dir, "ChatContext.json"); if (File.Exists(historyPath)) File.Delete(historyPath); if (File.Exists(contextPath)) File.Delete(contextPath); } public void SetAgentConfigProvider( Func> provider, string instanceId, Func? agentDirResolver = null) { _agentConfigProvider = provider; _instanceId = instanceId; _agentDirResolver = agentDirResolver; } public void LoadPersistedChats() { var configs = _agentConfigProvider?.Invoke() ?? []; foreach (var agent in configs) { var dir = _agentDirResolver?.Invoke(agent.AgentId); if (dir is null || !Directory.Exists(dir)) continue; try { var historyPath = Path.Combine(dir, "ChatHistory.json"); if (File.Exists(historyPath)) { var history = JsonSerializer.Deserialize>( File.ReadAllText(historyPath), _jsonOpts); if (history is { Count: > 0 }) { lock (_lock) _chatHistories[agent.AgentId] = history; } } var contextPath = Path.Combine(dir, "ChatContext.json"); if (File.Exists(contextPath)) { var raw = File.ReadAllText(contextPath); List? context = null; // Versuche zuerst als Array (direktes List) // Dann als Wrapper-Objekt {"messages":[...]} try { context = JsonSerializer.Deserialize>(raw, _jsonOpts); } catch (JsonException) { try { using var doc = JsonDocument.Parse(raw); if (doc.RootElement.TryGetProperty("messages", out var msgs)) { context = JsonSerializer.Deserialize>( msgs.GetRawText(), _jsonOpts); } } catch { /* Beide Formate fehlgeschlagen — ignorieren */ } } if (context is { Count: > 0 }) { lock (_lock) _chatContexts[agent.AgentId] = context; } } } catch (Exception ex) { _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Persistence") .LogWarning(ex, "Failed to load chat state for agent {AgentId}", agent.AgentId); } } } // ─── IAgentMessageRouter ─── public IReadOnlyList ListAgents(string callerAgentId) { var configs = _agentConfigProvider?.Invoke() ?? []; return configs .Where(a => a.AgentId != callerAgentId) .Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId))) .ToList(); } public async Task SendMessageAsync( string fromAgentId, string toAgentId, string message, CancellationToken ct) { var configs = _agentConfigProvider?.Invoke(); if (configs is null) return new AgentMessageResult(false, null, "Agent config provider not set."); var targetConfig = configs.FirstOrDefault(a => a.AgentId == toAgentId); if (targetConfig is null) return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found."); var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId); var fromName = fromConfig?.DisplayName ?? fromAgentId; var wrappedMessage = $"[Nachricht von Agent \"{fromName}\" ({fromAgentId})]\n\n{message}"; try { var result = await ChatAsync(targetConfig, wrappedMessage, _instanceId, ct, source: ChatSource.AgentComm); return result.Status switch { AgentRunStatus.Completed => new AgentMessageResult(true, result.FinalMessage), AgentRunStatus.LoopLimitExceeded => new AgentMessageResult(true, result.FinalMessage, "Agent hat das Step-Limit erreicht, die Nachricht wurde aber zugestellt."), _ => new AgentMessageResult(false, null, $"Agent run status: {result.Status}") }; } catch (Exception ex) { return new AgentMessageResult(false, null, $"Failed to reach agent: {ex.Message}"); } } public async Task SpawnAgentAsync( string fromAgentId, string targetAgentId, string taskMessage, CancellationToken ct) { var configs = _agentConfigProvider?.Invoke(); if (configs is null) return new AgentSpawnResult(false, targetAgentId, null, "Agent config provider not set."); var targetConfig = configs.FirstOrDefault(a => a.AgentId == targetAgentId); if (targetConfig is null) return new AgentSpawnResult(false, targetAgentId, null, $"Agent '{targetAgentId}' not found."); if (IsRunning(targetAgentId)) return new AgentSpawnResult(false, targetAgentId, null, $"Agent '{targetConfig.DisplayName}' läuft bereits. Verwende send_message statt spawn."); var fromConfig = configs.FirstOrDefault(a => a.AgentId == fromAgentId); var fromName = fromConfig?.DisplayName ?? fromAgentId; var wrappedMessage = $"[Spawn-Auftrag von Agent \"{fromName}\" ({fromAgentId})]\n\n{taskMessage}"; try { var result = await RunAsync(targetConfig, wrappedMessage, _instanceId, ct); return result.Status == AgentRunStatus.Completed ? new AgentSpawnResult(true, targetAgentId, result.FinalMessage) : new AgentSpawnResult(false, targetAgentId, null, $"Agent run status: {result.Status}"); } catch (Exception ex) { return new AgentSpawnResult(false, targetAgentId, null, $"Spawn failed: {ex.Message}"); } } private void AddChatEntry(string agentId, string role, string content, string? source = null) { lock (_lock) { if (!_chatHistories.TryGetValue(agentId, out var history)) { history = new List(); _chatHistories[agentId] = history; } history.Add(new ChatEntry(role, content, DateTime.Now, source)); } PersistChatState(agentId); OnChatEntryAdded?.Invoke(agentId, role, content, source); } private void PersistChatState(string agentId) { var dir = _agentDirResolver?.Invoke(agentId); if (dir is null) return; try { Directory.CreateDirectory(dir); List? history; List? context; lock (_lock) { _chatHistories.TryGetValue(agentId, out history); _chatContexts.TryGetValue(agentId, out context); } if (history is not null) File.WriteAllText( Path.Combine(dir, "ChatHistory.json"), JsonSerializer.Serialize(history, _jsonOpts)); if (context is not null) File.WriteAllText( Path.Combine(dir, "ChatContext.json"), JsonSerializer.Serialize(context, _jsonOpts)); } catch (Exception ex) { _loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Persistence") .LogWarning(ex, "Failed to persist chat state for agent {AgentId}", agentId); } } private async Task ExecuteToolCallAsync( ToolCall toolCall, AgentConfig agentConfig, string instanceId, IReadOnlyList availableTools, ILogger logger, CancellationToken ct) { var toolName = toolCall.Function.Name; try { _permissionGate.Enforce(agentConfig.AgentId, toolName, agentConfig); var tool = availableTools.FirstOrDefault(t => t.Name == toolName); if (tool is null) return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found.")); var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments) ? default : JsonDocument.Parse(toolCall.Function.Arguments).RootElement; var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg) ? cfg.AsReadOnly() : new Dictionary().AsReadOnly(); var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution"); var context = new AgentToolContext( agentConfig.AgentId, instanceId, toolConfig, _stateStore, toolLogger, ct, agentConfig.WorkspacePath, agentConfig.SharedWorkspacePath, this); logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId); var result = await tool.ExecuteAsync(input, context, ct); logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success); return result.Success ? result.Content : JsonSerializer.Serialize(new { error = result.ErrorMessage }); } catch (ToolAccessDeniedException ex) { logger.LogWarning("Tool access denied: {Message}", ex.Message); return JsonSerializer.Serialize(new { error = ex.Message }); } catch (Exception ex) { logger.LogError(ex, "Tool {Tool} threw an exception", toolName); return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" }); } } private static List BuildToolDefinitions(IReadOnlyList tools) { return tools.Select(t => new ToolDefinition { Function = new FunctionDefinition { Name = t.Name, Description = t.Description, Parameters = t.InputSchema } }).ToList(); } }