Files
ClawdDotNet/src/ClawdDotNet.Core/Engine/AgentEngine.cs
T
RichardandClaude Opus 4.8 4747835fa1 K1: Langzeitgedaechtnis fuer Agenten
Geplante Agenten begannen bei jedem Cron-Lauf bei null. Ein Agent, der alle 30
Minuten lief, wusste nichts von seinem letzten Durchgang — er rief dieselben
Quellen ab, zog dieselben Schluesse und konnte keine Entwicklung ueber Zeit
verfolgen. Das war zugleich die groesste Faehigkeitsluecke und eine dauerhafte
Token-Verschwendung.

Speicher-Fundament

SqliteStorage buendelt den Zugang zur Instanz-Datenbank und aktiviert WAL,
busy_timeout und Connection-Pooling. Vorher oeffnete jeder Aufruf eine Verbindung
ohne diese Einstellungen; bei mehreren gleichzeitig schreibenden Agenten gab das
"database is locked". Das sah nach einer Grenze von SQLite aus, war aber nur
fehlende Konfiguration. Zwei Tests decken das gezielt ab.

Gedaechtnis

Typisierte Tabelle statt JSON in einer Wert-Spalte — nur so laesst sich filtern,
sortieren und spaeter auswerten. Das Schema ist schlicht gehalten, damit eine
MySQL-Variante spaeter dieselbe Struktur mit wenigen Dialektunterschieden
bekommen kann.

Der wichtigste Teil ist der optionale Schluessel: Erneutes Merken darunter
aktualisiert den Eintrag, statt einen zweiten anzulegen. Ohne das wuechse das
Gedaechtnis eines halbstuendlich laufenden Agenten um 48 Eintraege pro Tag zur
selben Sache. Beobachtungen ohne Schluessel sammeln sich weiterhin an, wenn ein
Verlauf entstehen soll.

Der Abruf sortiert nach Wichtigkeit, dann Aktualitaet — wesentlich, weil das
Ergebnis begrenzt wird und bei einer Kappung das Wichtigste ueberleben muss.
Zusaetzlich greift eine Zeichenobergrenze, damit ein Abruf den Kontext nicht
sprengt.

Die Trennung privat/geteilt ist absichtlich dieselbe wie beim FileRW-Tool, damit
das Konzept fuer Agenten wiedererkennbar bleibt.

Beim Testen fiel auf, dass das Maskieren der LIKE-Platzhalter falsch war: Die
Zeichen wurden entfernt statt maskiert, wodurch eine Suche nach einem
Prozentzeichen zu einem leeren Muster und damit zu einem Treffer auf alles wurde.
Jetzt mit ESCAPE-Klausel.

338 Tests gruen (190 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:44:39 +02:00

892 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Memory;
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 IChatCompletionClient _client;
private readonly ToolRegistry _toolRegistry;
private readonly PermissionGate _permissionGate;
private readonly IStateStore _stateStore;
private readonly IMemoryRepository? _memoryRepository;
private readonly ILoggerFactory _loggerFactory;
private readonly ContextCompactor _compactor;
private readonly Dictionary<string, List<ChatEntry>> _chatHistories = new();
private readonly Dictionary<string, List<ChatMessage>> _chatContexts = new();
private readonly Lock _lock = new();
/// <summary>
/// Alle aktiven Chat-Läufe je Agent — laufende wie wartende. Mehrere Quellen können
/// denselben Agenten gleichzeitig ansprechen (WebView, ToolJob, AgentComm), deshalb
/// eine Liste: AbortChat muss jeden davon erreichen.
/// </summary>
private readonly Dictionary<string, List<CancellationTokenSource>> _runningChats = new();
/// <summary>
/// Serialisiert ChatAsync pro Agent. Der Konversationskontext ist eine geteilte
/// Liste — liefen zwei Chats desselben Agenten gleichzeitig, verschränkten sich ihre
/// Nachrichten zu einer ungültigen Tool-Sequenz, die die API mit HTTP 400 ablehnt.
/// Verschiedene Agenten bleiben unabhängig voneinander.
/// </summary>
private readonly Dictionary<string, SemaphoreSlim> _agentGates = new();
private Func<IReadOnlyList<AgentConfig>>? _agentConfigProvider;
private Func<string, string?>? _agentDirResolver;
private string _instanceId = "";
private static readonly JsonSerializerOptions _jsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public AgentEngine(
IChatCompletionClient client,
ToolRegistry toolRegistry,
PermissionGate permissionGate,
IStateStore stateStore,
ILoggerFactory loggerFactory,
IMemoryRepository? memoryRepository = null)
{
_client = client;
_toolRegistry = toolRegistry;
_permissionGate = permissionGate;
_stateStore = stateStore;
_loggerFactory = loggerFactory;
_memoryRepository = memoryRepository;
_compactor = new ContextCompactor(client, loggerFactory);
}
public event Action<string, string>? OnStepCompleted;
/// <summary>
/// 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).
/// </summary>
public event Action<string, string, string, string?>? OnChatEntryAdded;
/// <summary>
/// Wird nach jedem abgeschlossenen Run/Chat ausgelöst (model, result).
/// Erlaubt der UI, Token-Verbrauch und Kosten für ALLE Runs zu tracken.
/// </summary>
public event Action<string, AgentRunResult>? OnRunCompleted;
public async Task<AgentRunResult> 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<ChatMessage>();
var systemPrompt = agentConfig.FullSystemPrompt;
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
messages.Add(ChatMessage.User(userMessage));
string? finalMessage = null;
var tally = new TokenTally();
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
while (true)
{
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
if (cachingEnabled)
PromptCache.ApplyBreakpoints(messages);
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)
{
tally.Add(response.Usage);
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} (davon {Cached} aus Cache), duration={Duration}ms",
agentConfig.AgentId, loopGuard.Steps, tally.Total, tally.Cached, sw.ElapsedMilliseconds);
var result = new AgentRunResult(
agentConfig.AgentId,
AgentRunStatus.Completed,
finalMessage,
loopGuard.Steps,
tally.Total,
sw.Elapsed)
{
PromptTokens = tally.Prompt,
CompletionTokens = tally.Completion,
CachedTokens = tally.Cached
};
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<AgentRunResult> ChatAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken externalCt,
string? source = null)
{
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
RegisterRun(agentConfig.AgentId, runCts);
var gate = GetAgentGate(agentConfig.AgentId);
try
{
await gate.WaitAsync(runCts.Token);
}
catch (OperationCanceledException)
{
UnregisterRun(agentConfig.AgentId, runCts);
return new AgentRunResult(
agentConfig.AgentId, AgentRunStatus.Cancelled, "[Chat abgebrochen]",
0, 0, TimeSpan.Zero);
}
try
{
return await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
}
finally
{
UnregisterRun(agentConfig.AgentId, runCts);
gate.Release();
}
}
private async Task<AgentRunResult> ChatCoreAsync(
AgentConfig agentConfig,
string userMessage,
string instanceId,
CancellationToken runCt,
string? source)
{
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
var sw = Stopwatch.StartNew();
// Die Timeout-Uhr läuft erst ab hier — Wartezeit in der Warteschlange
// darf den Lauf nicht aufzehren.
using var timeoutCts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token);
var ct = linkedCts.Token;
try
{
var tools = _toolRegistry.GetForAgent(agentConfig);
var toolDefinitions = BuildToolDefinitions(tools);
List<ChatMessage> messages;
lock (_lock)
{
if (!_chatContexts.TryGetValue(agentConfig.AgentId, out messages!))
{
messages = new List<ChatMessage>();
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 tally = new TokenTally();
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
while (true)
{
ct.ThrowIfCancellationRequested();
loopGuard.RecordStep();
if (cachingEnabled)
PromptCache.ApplyBreakpoints(messages);
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)
{
tally.Add(response.Usage);
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, tally.Total, sw.Elapsed)
{
PromptTokens = tally.Prompt,
CompletionTokens = tally.Completion,
CachedTokens = tally.Cached
};
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;
}
}
// ─── Nebenläufigkeits-Helfer ───
private SemaphoreSlim GetAgentGate(string agentId)
{
lock (_lock)
{
if (!_agentGates.TryGetValue(agentId, out var gate))
{
gate = new SemaphoreSlim(1, 1);
_agentGates[agentId] = gate;
}
return gate;
}
}
private void RegisterRun(string agentId, CancellationTokenSource cts)
{
lock (_lock)
{
if (!_runningChats.TryGetValue(agentId, out var list))
{
list = new List<CancellationTokenSource>();
_runningChats[agentId] = list;
}
list.Add(cts);
}
}
private void UnregisterRun(string agentId, CancellationTokenSource cts)
{
lock (_lock)
{
if (!_runningChats.TryGetValue(agentId, out var list))
return;
list.Remove(cts);
if (list.Count == 0)
_runningChats.Remove(agentId);
}
}
public IReadOnlyList<ChatEntry> 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);
}
/// <summary>
/// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
/// die beim nächsten Schritt tatsächlich an das Modell gehen.
/// Nützlich für Diagnose und Kontextgrößen-Anzeige.
/// </summary>
public IReadOnlyList<ChatMessage> GetChatContext(string agentId)
{
lock (_lock)
return _chatContexts.TryGetValue(agentId, out var ctx)
? ctx.ToList()
: [];
}
/// <summary>
/// Bricht ALLE Chat-Läufe des Agenten ab — laufende wie wartende.
/// </summary>
public void AbortChat(string agentId)
{
List<CancellationTokenSource> toCancel;
lock (_lock)
{
if (!_runningChats.TryGetValue(agentId, out var list))
return;
toCancel = list.ToList();
}
// Außerhalb des Locks abbrechen: Cancel führt Continuations aus, die
// ihrerseits wieder auf _lock zugreifen können.
foreach (var cts in toCancel)
{
try { cts.Cancel(); }
catch (ObjectDisposedException) { /* Lauf war bereits fertig */ }
}
}
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<IReadOnlyList<AgentConfig>> provider,
string instanceId,
Func<string, string?>? 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<List<ChatEntry>>(
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<ChatMessage>? context = null;
// Versuche zuerst als Array (direktes List<ChatMessage>)
// Dann als Wrapper-Objekt {"messages":[...]}
try
{
context = JsonSerializer.Deserialize<List<ChatMessage>>(raw, _jsonOpts);
}
catch (JsonException)
{
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.TryGetProperty("messages", out var msgs))
{
context = JsonSerializer.Deserialize<List<ChatMessage>>(
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<AgentInfo> 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<AgentMessageResult> 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.");
// Selbstadressierung würde am Agent-Gate hängen bleiben: Der laufende Chat
// hält es bereits und würde auf sich selbst warten.
if (fromAgentId == toAgentId)
return new AgentMessageResult(false, null,
"Ein Agent kann sich keine Nachricht an sich selbst schicken.");
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<AgentSpawnResult> 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<ChatEntry>();
_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<ChatEntry>? history;
List<ChatMessage>? 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<string> ExecuteToolCallAsync(
ToolCall toolCall,
AgentConfig agentConfig,
string instanceId,
IReadOnlyList<IAgentTool> 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<string, object?>().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,
_memoryRepository);
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);
if (!result.Success)
return JsonSerializer.Serialize(new { error = result.ErrorMessage });
var content = TruncateToolResult(result.Content, agentConfig.MaxToolResultChars);
if (content.Length != result.Content.Length)
{
logger.LogInformation(
"Tool-Ergebnis von {Tool} gekürzt: {Original} → {Limit} Zeichen",
toolName, result.Content.Length, agentConfig.MaxToolResultChars);
}
return content;
}
catch (ToolAccessDeniedException ex)
{
logger.LogWarning("Tool access denied: {Message}", ex.Message);
return JsonSerializer.Serialize(new { error = ex.Message });
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
// Schritt weiter und der Abbruch greift erst verzögert.
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
}
}
/// <summary>Sammelt die Token-Zahlen über alle Schritte eines Runs.</summary>
private sealed class TokenTally
{
public int Total { get; private set; }
public int Prompt { get; private set; }
public int Completion { get; private set; }
public int Cached { get; private set; }
public void Add(Usage usage)
{
Total += usage.TotalTokens;
Prompt += usage.PromptTokens;
Completion += usage.CompletionTokens;
Cached += usage.CachedTokens;
}
}
/// <summary>
/// Kürzt ein Tool-Ergebnis, bevor es in den Kontext wandert.
///
/// Ohne diese Grenze kann ein einzelner Aufruf den Kontext sprengen — ein WebFetch
/// mit dem Standardlimit von 512 KB entspricht rund 130.000 Tokens in EINER
/// Tool-Antwort. Die Compaction greift erst danach, der teure Request ist zu dem
/// Zeitpunkt längst bezahlt.
/// </summary>
internal static string TruncateToolResult(string result, int maxChars)
{
if (maxChars <= 0 || result.Length <= maxChars)
return result;
var omitted = result.Length - maxChars;
return result[..maxChars] +
$"\n\n[… {omitted:N0} Zeichen gekürzt. Das Ergebnis war zu groß für den Kontext. " +
"Grenze die Abfrage ein, wenn du den Rest brauchst.]";
}
private static List<ToolDefinition> BuildToolDefinitions(IReadOnlyList<IAgentTool> tools)
{
return tools.Select(t => new ToolDefinition
{
Function = new FunctionDefinition
{
Name = t.Name,
Description = t.Description,
Parameters = t.InputSchema
}
}).ToList();
}
}