Initial commit: ClawdDotNet

Import des bestehenden Projektstands in Git.
- .NET 10 WinForms Anwendung (Multi-Agent / Tool-System)
- .gitignore fuer Build-Artefakte, Secrets und Runtime-Daten ergaenzt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-26 18:21:46 +02:00
co-authored by Claude Opus 4.8
commit 2fed388c99
154 changed files with 29736 additions and 0 deletions
@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ChatMessage
{
[JsonPropertyName("role")]
public string Role { get; set; } = "";
[JsonPropertyName("content")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Content { get; set; }
[JsonPropertyName("tool_calls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<ToolCall>? ToolCalls { get; set; }
[JsonPropertyName("tool_call_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ToolCallId { get; set; }
public static ChatMessage System(string content) => new() { Role = "system", Content = content };
public static ChatMessage User(string content) => new() { Role = "user", Content = content };
public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content };
public static ChatMessage AssistantWithToolCalls(List<ToolCall> toolCalls) => new()
{
Role = "assistant",
ToolCalls = toolCalls
};
public static ChatMessage ToolResponse(string toolCallId, string content) => new()
{
Role = "tool",
ToolCallId = toolCallId,
Content = content
};
}
@@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ChatRequest
{
[JsonPropertyName("model")]
public string Model { get; set; } = "";
[JsonPropertyName("messages")]
public List<ChatMessage> Messages { get; set; } = new();
[JsonPropertyName("tools")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<ToolDefinition>? Tools { get; set; }
[JsonPropertyName("tool_choice")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ToolChoice { get; set; }
[JsonPropertyName("temperature")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Temperature { get; set; }
[JsonPropertyName("max_tokens")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? MaxTokens { get; set; }
[JsonPropertyName("stream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Stream { get; set; }
}
@@ -0,0 +1,54 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ChatResponse
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("choices")]
public List<Choice> Choices { get; set; } = new();
[JsonPropertyName("usage")]
public Usage? Usage { get; set; }
[JsonPropertyName("model")]
public string? Model { get; set; }
[JsonPropertyName("error")]
public ApiError? Error { get; set; }
}
public sealed class Choice
{
[JsonPropertyName("index")]
public int Index { get; set; }
[JsonPropertyName("message")]
public ChatMessage Message { get; set; } = new();
[JsonPropertyName("finish_reason")]
public string? FinishReason { get; set; }
}
public sealed class Usage
{
[JsonPropertyName("prompt_tokens")]
public int PromptTokens { get; set; }
[JsonPropertyName("completion_tokens")]
public int CompletionTokens { get; set; }
[JsonPropertyName("total_tokens")]
public int TotalTokens { get; set; }
}
public sealed class ApiError
{
[JsonPropertyName("message")]
public string Message { get; set; } = "";
[JsonPropertyName("code")]
public int? Code { get; set; }
}
@@ -0,0 +1,15 @@
namespace ClawdDotNet.Core.Api.Models;
/// <summary>
/// Repräsentiert ein verfügbares LLM-Modell von OpenRouter.
/// </summary>
public sealed class ModelInfo
{
/// <summary>OpenRouter Modell-ID (z.B. "anthropic/claude-sonnet-4-5")</summary>
public string Id { get; set; } = "";
/// <summary>Anzeigename des Modells</summary>
public string Name { get; set; } = "";
public override string ToString() => Id;
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ToolCall
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "function";
[JsonPropertyName("function")]
public ToolCallFunction Function { get; set; } = new();
}
public sealed class ToolCallFunction
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("arguments")]
public string Arguments { get; set; } = "";
}
@@ -0,0 +1,25 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
public sealed class ToolDefinition
{
[JsonPropertyName("type")]
public string Type { get; set; } = "function";
[JsonPropertyName("function")]
public FunctionDefinition Function { get; set; } = new();
}
public sealed class FunctionDefinition
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("parameters")]
public JsonElement Parameters { get; set; }
}
@@ -0,0 +1,120 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Api.Models;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Api;
public sealed class OpenRouterClient : IDisposable
{
private const string BaseUrl = "https://openrouter.ai/api/v1/";
private readonly HttpClient _http;
private readonly ILogger _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public OpenRouterClient(string apiKey, ILogger logger, HttpClient? httpClient = null)
{
_logger = logger;
_http = httpClient ?? new HttpClient();
_http.BaseAddress = new Uri(BaseUrl);
_http.Timeout = TimeSpan.FromMinutes(5); // LLM-Calls können bei großen Prompts lange dauern
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
_http.DefaultRequestHeaders.Add("X-Title", "ClawdDotNet");
}
public async Task<ChatResponse> CompleteAsync(ChatRequest request, CancellationToken ct)
{
request.Stream = false;
var json = JsonSerializer.Serialize(request, JsonOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
_logger.LogDebug("Sending request to OpenRouter: model={Model}, messages={Count}",
request.Model, request.Messages.Count);
using var response = await _http.PostAsync("chat/completions", content, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("OpenRouter API error {StatusCode}: {Body}",
(int)response.StatusCode, responseBody);
throw new OpenRouterException(
$"API request failed with status {(int)response.StatusCode}",
(int)response.StatusCode,
responseBody);
}
var result = JsonSerializer.Deserialize<ChatResponse>(responseBody, JsonOptions)
?? throw new OpenRouterException("Empty response from OpenRouter", 0, responseBody);
if (result.Error is not null)
{
_logger.LogError("OpenRouter returned error: {Error}", result.Error.Message);
throw new OpenRouterException(result.Error.Message, result.Error.Code ?? 0, responseBody);
}
_logger.LogDebug("OpenRouter response received: tokens={Tokens}",
result.Usage?.TotalTokens ?? 0);
return result;
}
/// <summary>
/// Ruft die verfügbaren Modelle von OpenRouter ab (/models Endpoint).
/// Gibt eine Liste von Modell-IDs zurück, sortiert nach Name.
/// </summary>
public async Task<List<ModelInfo>> GetAvailableModelsAsync(CancellationToken ct = default)
{
_logger.LogDebug("Fetching available models from OpenRouter...");
using var response = await _http.GetAsync("models", ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch models: {StatusCode} {Body}",
(int)response.StatusCode, body);
return [];
}
var doc = JsonDocument.Parse(body);
if (!doc.RootElement.TryGetProperty("data", out var dataArray))
return [];
var models = new List<ModelInfo>();
foreach (var item in dataArray.EnumerateArray())
{
var id = item.GetProperty("id").GetString() ?? "";
var name = item.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? id : id;
models.Add(new ModelInfo { Id = id, Name = name });
}
models.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.OrdinalIgnoreCase));
_logger.LogDebug("Fetched {Count} models from OpenRouter", models.Count);
return models;
}
public void Dispose()
{
_http.Dispose();
}
}
public sealed class OpenRouterException(string message, int statusCode, string responseBody)
: Exception(message)
{
public int StatusCode { get; } = statusCode;
public string ResponseBody { get; } = responseBody;
}
+7
View File
@@ -0,0 +1,7 @@
namespace ClawdDotNet.Core;
public static class BuildInfo
{
public const int Build = 1;
public const string Changes = "ToolJob-System, ChatAsync/RunAsync, ContextCompaction, OnRunCompleted-Event";
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ClawdDotNet.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
</ItemGroup>
</Project>
+124
View File
@@ -0,0 +1,124 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class AgentConfig
{
[JsonPropertyName("agentId")]
public string AgentId { get; set; } = "";
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = "";
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
[JsonPropertyName("systemPrompt")]
public string SystemPrompt { get; set; } = "";
/// <summary>
/// Identität des Agenten (aus Identity.md geladen).
/// Definiert WER der Agent ist: Name, Rolle, Hintergrund.
/// </summary>
[JsonIgnore]
public string Identity { get; set; } = "";
/// <summary>
/// Seele des Agenten (aus Soul.md geladen).
/// Definiert WIE der Agent denkt: Persönlichkeit, Werte, Verhaltensmuster.
/// </summary>
[JsonIgnore]
public string Soul { get; set; } = "";
/// <summary>
/// Lokaler Pfad zum Workspace-Verzeichnis des Agenten.
/// Wird zur Laufzeit vom Host gesetzt.
/// </summary>
/// <summary>
/// Kurzbeschreibung der Rolle/Aufgabe des Agenten.
/// Wird aus AgentList.json geladen und bei list_agents angezeigt.
/// </summary>
[JsonIgnore]
public string Description { get; set; } = "";
/// <summary>
/// Absoluter Pfad zum Agent-Verzeichnis (Agent-XYZ).
/// Wird zur Laufzeit gesetzt und bleibt stabil auch bei DisplayName-Änderungen.
/// </summary>
[JsonIgnore]
public string AgentDir { get; set; } = "";
[JsonIgnore]
public string WorkspacePath { get; set; } = "";
/// <summary>
/// Lokaler Pfad zum geteilten Workspace-Verzeichnis (SharedWorkspace).
/// Wird zur Laufzeit vom Host gesetzt.
/// </summary>
[JsonIgnore]
public string SharedWorkspacePath { get; set; } = "";
/// <summary>
/// Baut den vollständigen System-Prompt aus Identity + Soul + SystemPrompt zusammen.
/// </summary>
[JsonIgnore]
public string FullSystemPrompt
{
get
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(Identity))
parts.Add($"# Identity\n{Identity}");
if (!string.IsNullOrWhiteSpace(Soul))
parts.Add($"# Soul\n{Soul}");
if (!string.IsNullOrWhiteSpace(SystemPrompt))
parts.Add(SystemPrompt);
return string.Join("\n\n", parts);
}
}
[JsonPropertyName("tools")]
public Dictionary<string, Dictionary<string, object?>> Tools { get; set; } = new();
[JsonPropertyName("scheduler")]
public SchedulerConfig? Scheduler { get; set; }
[JsonPropertyName("toolJobs")]
public List<ToolJobConfig> ToolJobs { get; set; } = new();
[JsonPropertyName("loopGuard")]
public LoopGuardConfig LoopGuard { get; set; } = new();
}
public sealed class SchedulerConfig
{
[JsonPropertyName("cron")]
public string Cron { get; set; } = "";
[JsonPropertyName("runOnStart")]
public bool RunOnStart { get; set; }
[JsonPropertyName("taskMessage")]
public string TaskMessage { get; set; } = "Führe deine zugewiesenen Aufgaben aus.";
}
public sealed class LoopGuardConfig
{
[JsonPropertyName("maxSteps")]
public int MaxSteps { get; set; } = 20;
[JsonPropertyName("maxTokens")]
public int MaxTokens { get; set; } = 80_000;
[JsonPropertyName("timeoutSeconds")]
public int TimeoutSeconds { get; set; } = 600;
[JsonPropertyName("maxContextTokens")]
public int MaxContextTokens { get; set; } = 100_000;
[JsonPropertyName("compactionThreshold")]
public double CompactionThreshold { get; set; } = 0.80;
[JsonIgnore]
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
}
@@ -0,0 +1,42 @@
using System.Text.Json;
namespace ClawdDotNet.Core.Config;
public static class ConfigLoader
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true
};
public static async Task<InstanceConfig> LoadAsync(string filePath, CancellationToken ct = default)
{
if (!File.Exists(filePath))
throw new FileNotFoundException($"Config file not found: {filePath}");
await using var stream = File.OpenRead(filePath);
var config = await JsonSerializer.DeserializeAsync<InstanceConfig>(stream, JsonOptions, ct)
?? throw new InvalidOperationException($"Config file is empty or invalid: {filePath}");
Validate(config, filePath);
return config;
}
private static void Validate(InstanceConfig config, string filePath)
{
if (string.IsNullOrWhiteSpace(config.OpenRouterApiKey))
throw new InvalidOperationException($"'openRouterApiKey' is required in {filePath}");
var agentIds = new HashSet<string>();
foreach (var agent in config.Agents)
{
if (string.IsNullOrWhiteSpace(agent.AgentId))
throw new InvalidOperationException($"Every agent must have an 'agentId' in {filePath}");
if (!agentIds.Add(agent.AgentId))
throw new InvalidOperationException($"Duplicate agentId '{agent.AgentId}' in {filePath}");
}
}
}
@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class InstanceConfig
{
[JsonPropertyName("instanceId")]
public string InstanceId { get; set; } = Guid.NewGuid().ToString("N")[..8];
[JsonPropertyName("instanceName")]
public string InstanceName { get; set; } = "Default";
[JsonPropertyName("openRouterApiKey")]
public string OpenRouterApiKey { get; set; } = "";
[JsonPropertyName("workingDirectory")]
public string WorkingDirectory { get; set; } = "./data/";
[JsonPropertyName("logDirectory")]
public string LogDirectory { get; set; } = "./Logs";
[JsonPropertyName("webServerPort")]
public int WebServerPort { get; set; } = 8080;
[JsonPropertyName("telegramClient")]
public TelegramClientConfig? TelegramClient { get; set; }
[JsonPropertyName("agents")]
public List<AgentConfig> Agents { get; set; } = new();
[JsonPropertyName("services")]
public List<ServiceConfig> Services { get; set; } = new();
}
public sealed class TelegramClientConfig
{
[JsonPropertyName("apiId")]
public int ApiId { get; set; }
[JsonPropertyName("apiHash")]
public string ApiHash { get; set; } = "";
[JsonPropertyName("phoneNumber")]
public string PhoneNumber { get; set; } = "";
[JsonPropertyName("password2FA")]
public string? Password2FA { get; set; }
}
@@ -0,0 +1,74 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class ServiceConfig
{
[JsonPropertyName("serviceId")]
public string ServiceId { get; set; } = Guid.NewGuid().ToString("N")[..8];
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("autoStart")]
public bool AutoStart { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("builtIn")]
public bool BuiltIn { get; set; }
}
public static class BuiltInServices
{
public const string AgentChatWebUI = "AgentChatWebUI";
public const string AgentWebsite = "AgentWebsite";
public const string ClawdDotNetApi = "ClawdDotNetApi";
public static List<ServiceConfig> CreateDefaults() =>
[
new()
{
ServiceId = "svc_chat",
Name = "Agent Chat WebUI",
Type = AgentChatWebUI,
Port = 5080,
Enabled = true,
AutoStart = true,
BuiltIn = true,
Description = "WebUI für den Agenten-Chat (WebView2)"
},
new()
{
ServiceId = "svc_web",
Name = "Agent Website",
Type = AgentWebsite,
Port = 5081,
Enabled = false,
AutoStart = false,
BuiltIn = true,
Description = "Von Agenten entwickelte und betreute Website"
},
new()
{
ServiceId = "svc_api",
Name = "ClawdDotNet API",
Type = ClawdDotNetApi,
Port = 5082,
Enabled = true,
AutoStart = true,
BuiltIn = true,
Description = "REST-API für die Kommunikation mit der WebApp"
}
];
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
public sealed class ToolJobConfig
{
[JsonPropertyName("jobId")]
public string JobId { get; set; } = Guid.NewGuid().ToString("N")[..8];
[JsonPropertyName("toolName")]
public string ToolName { get; set; } = "";
[JsonPropertyName("jobTypeId")]
public string JobTypeId { get; set; } = "";
[JsonPropertyName("cron")]
public string Cron { get; set; } = "";
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("runOnStart")]
public bool RunOnStart { get; set; }
}
+700
View File
@@ -0,0 +1,700 @@
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<string, List<ChatEntry>> _chatHistories = new();
private readonly Dictionary<string, List<ChatMessage>> _chatContexts = new();
private readonly Dictionary<string, CancellationTokenSource> _runningChats = new();
private readonly Lock _lock = 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(
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<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 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<AgentRunResult> 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<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 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<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);
}
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<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.");
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);
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<ToolDefinition> BuildToolDefinitions(IReadOnlyList<IAgentTool> tools)
{
return tools.Select(t => new ToolDefinition
{
Function = new FunctionDefinition
{
Name = t.Name,
Description = t.Description,
Parameters = t.InputSchema
}
}).ToList();
}
}
@@ -0,0 +1,19 @@
namespace ClawdDotNet.Core.Engine;
public sealed record AgentRunResult(
string AgentId,
AgentRunStatus Status,
string? FinalMessage,
int StepCount,
int TokensUsed,
TimeSpan Duration,
Exception? Error = null
);
public enum AgentRunStatus
{
Completed,
Failed,
Cancelled,
LoopLimitExceeded
}
+21
View File
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Engine;
public sealed record ChatEntry(
[property: JsonPropertyName("role")] string Role,
[property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("timestamp")] DateTime Timestamp,
[property: JsonPropertyName("source")] string? Source = null
);
/// <summary>
/// Bekannte Quellen für Chat-Nachrichten. Wird verwendet um Routing-Entscheidungen zu treffen.
/// </summary>
public static class ChatSource
{
public const string WebView = "webview";
public const string Telegram = "telegram";
public const string AgentComm = "agentcomm";
public const string Job = "job";
}
@@ -0,0 +1,189 @@
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
using ClawdDotNet.Core.Config;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Engine;
public sealed class ContextCompactor
{
private readonly OpenRouterClient _client;
private readonly ILogger _logger;
private const int ProtectedTailMessages = 6;
private const int MaxToolResultChars = 2000;
private const string TruncatedMarker = "\n\n[... Ergebnis gekürzt ...]";
public ContextCompactor(OpenRouterClient client, ILoggerFactory loggerFactory)
{
_client = client;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.ContextCompactor");
}
public static int EstimateTokens(List<ChatMessage> messages)
{
var totalChars = 0;
foreach (var msg in messages)
{
totalChars += msg.Content?.Length ?? 0;
totalChars += msg.Role.Length + 10;
if (msg.ToolCalls is not null)
{
foreach (var tc in msg.ToolCalls)
totalChars += tc.Function.Name.Length + tc.Function.Arguments.Length + 20;
}
}
return totalChars / 4;
}
public async Task<bool> CompactIfNeededAsync(
List<ChatMessage> messages,
int lastPromptTokens,
LoopGuardConfig guard,
string model,
CancellationToken ct)
{
var contextTokens = lastPromptTokens > 0
? lastPromptTokens
: EstimateTokens(messages);
var threshold = (int)(guard.MaxContextTokens * guard.CompactionThreshold);
if (contextTokens < threshold)
return false;
_logger.LogInformation(
"Context kompaktierung gestartet: {Tokens} Tokens (Schwelle: {Threshold})",
contextTokens, threshold);
// Stufe 1: Tool-Results kürzen
var pruned = PruneToolResults(messages);
if (pruned)
{
var afterPrune = EstimateTokens(messages);
_logger.LogInformation("Stufe 1 (Tool-Pruning): {Before} → {After} geschätzte Tokens",
contextTokens, afterPrune);
if (afterPrune < threshold)
return true;
}
// Stufe 2: Auto-Compaction via LLM
await CompactViaLlmAsync(messages, model, ct);
return true;
}
private bool PruneToolResults(List<ChatMessage> messages)
{
var pruned = false;
var protectedStart = Math.Max(0, messages.Count - ProtectedTailMessages);
for (var i = 0; i < protectedStart; i++)
{
var msg = messages[i];
if (msg.Role != "tool" || msg.Content is null)
continue;
if (msg.Content.Length <= MaxToolResultChars)
continue;
msg.Content = msg.Content[..MaxToolResultChars] + TruncatedMarker;
pruned = true;
}
return pruned;
}
private async Task CompactViaLlmAsync(
List<ChatMessage> messages, string model, CancellationToken ct)
{
var systemMsg = messages.FirstOrDefault(m => m.Role == "system");
var conversationParts = messages
.Where(m => m.Role != "system")
.Select(FormatMessageForSummary);
var conversationText = string.Join("\n", conversationParts);
// Auf max 30k Zeichen begrenzen für den Summarization-Call
if (conversationText.Length > 30_000)
conversationText = conversationText[..30_000] + "\n[... weitere Nachrichten ausgelassen ...]";
var summaryRequest = new ChatRequest
{
Model = model,
Messages =
[
ChatMessage.System(
"Du bist ein Konversations-Zusammenfasser. Erstelle eine präzise Zusammenfassung " +
"der bisherigen Konversation. Behalte alle wichtigen Fakten, Entscheidungen, " +
"Ergebnisse von Tool-Aufrufen und den aktuellen Arbeitsstand bei. " +
"Schreibe in der dritten Person. Format: Strukturierte Stichpunkte."),
ChatMessage.User(
"Fasse die folgende Konversation zusammen. Behalte alle wichtigen Details:\n\n" +
conversationText)
]
};
try
{
var response = await _client.CompleteAsync(summaryRequest, ct);
var summary = response.Choices.FirstOrDefault()?.Message?.Content;
if (string.IsNullOrWhiteSpace(summary))
{
_logger.LogWarning("Compaction: Keine Zusammenfassung erhalten");
return;
}
// Nachrichten ersetzen: System-Prompt + Zusammenfassung + geschützte letzte Nachrichten
var tail = messages
.Skip(Math.Max(0, messages.Count - ProtectedTailMessages))
.ToList();
messages.Clear();
if (systemMsg is not null)
messages.Add(systemMsg);
messages.Add(ChatMessage.User(
"[Zusammenfassung der bisherigen Konversation]\n\n" + summary));
messages.Add(ChatMessage.Assistant(
"Verstanden. Ich habe den Kontext der bisherigen Konversation erfasst und arbeite weiter."));
messages.AddRange(tail);
_logger.LogInformation(
"Stufe 2 (Auto-Compaction): Konversation auf {Count} Nachrichten kompaktiert",
messages.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Compaction fehlgeschlagen");
}
}
private static string FormatMessageForSummary(ChatMessage msg)
{
if (msg.Role == "tool")
{
var preview = msg.Content?.Length > 200
? msg.Content[..200] + "..."
: msg.Content;
return $"[Tool-Result ({msg.ToolCallId})]: {preview}";
}
if (msg.ToolCalls is { Count: > 0 })
{
var calls = string.Join(", ",
msg.ToolCalls.Select(tc => $"{tc.Function.Name}({tc.Function.Arguments[..Math.Min(100, tc.Function.Arguments.Length)]})"));
return $"[Assistant → Tool-Calls]: {calls}";
}
var content = msg.Content?.Length > 500
? msg.Content[..500] + "..."
: msg.Content;
return $"[{msg.Role}]: {content}";
}
}
+29
View File
@@ -0,0 +1,29 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Engine;
public sealed class LoopGuard
{
private readonly LoopGuardConfig _cfg;
private int _steps;
private int _tokens;
public LoopGuard(LoopGuardConfig cfg) => _cfg = cfg;
public int Steps => _steps;
public int Tokens => _tokens;
public void RecordStep()
{
if (Interlocked.Increment(ref _steps) > _cfg.MaxSteps)
throw new LoopLimitExceededException($"Max steps ({_cfg.MaxSteps}) exceeded.");
}
public void RecordTokens(int count)
{
if (Interlocked.Add(ref _tokens, count) > _cfg.MaxTokens)
throw new LoopLimitExceededException($"Max tokens ({_cfg.MaxTokens}) exceeded.");
}
}
public sealed class LoopLimitExceededException(string message) : Exception(message);
@@ -0,0 +1,118 @@
using System.Collections.Concurrent;
using System.Text;
namespace ClawdDotNet.Core.Logging;
/// <summary>
/// Schreibt Log-Einträge asynchron in datei- und datumsgetrennte Logfiles.
/// Struktur: {LogDirectory}/{Datum}/{Modul}.log
/// Thread-safe durch ConcurrentQueue + dediziertem Writer-Task.
/// </summary>
public sealed class FileLogWriter : IAsyncDisposable
{
private readonly FileLoggerOptions _options;
private readonly ConcurrentQueue<LogEntry> _queue = new();
private readonly SemaphoreSlim _signal = new(0);
private readonly CancellationTokenSource _cts = new();
private readonly Task _writerTask;
private readonly ConcurrentDictionary<string, Lock> _fileLocks = new();
public FileLogWriter(FileLoggerOptions options)
{
_options = options;
Directory.CreateDirectory(_options.LogDirectory);
_writerTask = Task.Run(ProcessQueueAsync);
}
public void Enqueue(LogEntry entry)
{
if (entry.Level < _options.MinimumLevel)
return;
_queue.Enqueue(entry);
_signal.Release();
}
private async Task ProcessQueueAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
await _signal.WaitAsync(_cts.Token);
}
catch (OperationCanceledException)
{
break;
}
DrainQueue();
}
DrainQueue();
}
private void DrainQueue()
{
while (_queue.TryDequeue(out var entry))
{
try
{
WriteEntry(entry);
}
catch
{
// Logging darf die Anwendung niemals crashen
}
}
}
private void WriteEntry(LogEntry entry)
{
var dateDir = Path.Combine(
_options.LogDirectory,
entry.Timestamp.ToString(_options.DateFormat));
Directory.CreateDirectory(dateDir);
var safeModule = SanitizeModuleName(entry.Module);
var filePath = Path.Combine(dateDir, $"{safeModule}.log");
var fileLock = _fileLocks.GetOrAdd(filePath, _ => new Lock());
lock (fileLock)
{
var line = entry.Format(_options.TimestampFormat) + Environment.NewLine;
File.AppendAllText(filePath, line, Encoding.UTF8);
}
}
private static string SanitizeModuleName(string module)
{
var sanitized = module
.Replace('.', '_')
.Replace('/', '_')
.Replace('\\', '_');
foreach (var c in Path.GetInvalidFileNameChars())
sanitized = sanitized.Replace(c, '_');
return string.IsNullOrWhiteSpace(sanitized) ? "Unknown" : sanitized;
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try
{
await _writerTask;
}
catch (OperationCanceledException)
{
}
_signal.Dispose();
_cts.Dispose();
}
}
@@ -0,0 +1,18 @@
namespace ClawdDotNet.Core.Logging;
public sealed class FileLoggerOptions
{
public string LogDirectory { get; set; } = "./Logs";
public LogLevel MinimumLevel { get; set; } = LogLevel.Info;
public int MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10 MB
public string DateFormat { get; set; } = "yyyy-MM-dd";
public string TimestampFormat { get; set; } = "HH:mm:ss.fff";
}
public enum LogLevel
{
Debug = 0,
Info = 1,
Warn = 2,
Error = 3
}
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using MEL = Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Logging;
/// <summary>
/// ILoggerProvider-Implementierung für das dateibasierte Logging.
/// Erzeugt pro Kategorie (= Modul) einen eigenen ModuleLogger.
///
/// Kategorien werden auf Modulnamen gemappt:
/// "ClawdDotNet.Core.Engine.AgentEngine" → "Core"
/// "ClawdDotNet.Tools.Database.DatabaseTool" → "Tool_Database"
/// "ClawdDotNet.Tools.FileRW.FileRWTool" → "Tool_FileRW"
/// Alles andere → letzter Namespace-Teil oder "General"
/// </summary>
public sealed class FileLoggerProvider : MEL.ILoggerProvider
{
private readonly FileLogWriter _writer;
private readonly FileLoggerOptions _options;
private readonly ConcurrentDictionary<string, ModuleLogger> _loggers = new();
public FileLoggerProvider(FileLoggerOptions options)
{
_options = options;
_writer = new FileLogWriter(options);
}
public MEL.ILogger CreateLogger(string categoryName)
{
var module = MapCategoryToModule(categoryName);
return _loggers.GetOrAdd(module, m => new ModuleLogger(m, _writer, _options));
}
internal static string MapCategoryToModule(string categoryName)
{
if (string.IsNullOrWhiteSpace(categoryName))
return "General";
// ClawdDotNet.Core.* → "Core"
if (categoryName.StartsWith("ClawdDotNet.Core.", StringComparison.Ordinal))
return "Core";
// ClawdDotNet.Host.* → "Host"
if (categoryName.StartsWith("ClawdDotNet.Host.", StringComparison.Ordinal)
|| categoryName == "ClawdDotNet.Host")
return "Host";
// ClawdDotNet.Tools.{ToolName}.* → "Tool_{ToolName}"
if (categoryName.StartsWith("ClawdDotNet.Tools.", StringComparison.Ordinal))
{
var afterTools = categoryName["ClawdDotNet.Tools.".Length..];
var dotIndex = afterTools.IndexOf('.');
var toolName = dotIndex > 0 ? afterTools[..dotIndex] : afterTools;
return $"Tool_{toolName}";
}
// Fallback: letzter Segment-Teil
var lastDot = categoryName.LastIndexOf('.');
return lastDot >= 0 ? categoryName[(lastDot + 1)..] : categoryName;
}
public void Dispose()
{
_writer.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace ClawdDotNet.Core.Logging;
public sealed record LogEntry(
DateTime Timestamp,
LogLevel Level,
string Module,
string Message,
Exception? Exception = null
)
{
public string Format(string timestampFormat)
{
var levelTag = Level switch
{
LogLevel.Debug => "DBG",
LogLevel.Info => "INF",
LogLevel.Warn => "WRN",
LogLevel.Error => "ERR",
_ => "???"
};
var line = $"[{Timestamp.ToString(timestampFormat)}] [{levelTag}] {Message}";
if (Exception is not null)
line += Environment.NewLine + Exception.ToString();
return line;
}
}
@@ -0,0 +1,42 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Logging;
public static class LoggingExtensions
{
public static ILoggerFactory AddClawdFileLogging(
this ILoggerFactory factory,
FileLoggerOptions? options = null)
{
factory.AddProvider(new FileLoggerProvider(options ?? new FileLoggerOptions()));
return factory;
}
public static ILoggerFactory CreateClawdLoggerFactory(
string logDirectory = "./Logs",
LogLevel minimumLevel = LogLevel.Info)
{
var options = new FileLoggerOptions
{
LogDirectory = logDirectory,
MinimumLevel = minimumLevel
};
var factory = LoggerFactory.Create(builder =>
{
builder.SetMinimumLevel(MapToMelLevel(minimumLevel));
});
factory.AddClawdFileLogging(options);
return factory;
}
private static Microsoft.Extensions.Logging.LogLevel MapToMelLevel(LogLevel level) => level switch
{
LogLevel.Debug => Microsoft.Extensions.Logging.LogLevel.Debug,
LogLevel.Info => Microsoft.Extensions.Logging.LogLevel.Information,
LogLevel.Warn => Microsoft.Extensions.Logging.LogLevel.Warning,
LogLevel.Error => Microsoft.Extensions.Logging.LogLevel.Error,
_ => Microsoft.Extensions.Logging.LogLevel.Information
};
}
@@ -0,0 +1,62 @@
using MEL = Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Logging;
/// <summary>
/// Implementiert Microsoft.Extensions.Logging.ILogger und leitet alle
/// Einträge an den zentralen FileLogWriter weiter.
/// Jede Instanz ist einem Modul zugeordnet (z.B. "Core", "Tool_Database").
/// </summary>
public sealed class ModuleLogger : MEL.ILogger
{
private readonly string _module;
private readonly FileLogWriter _writer;
private readonly FileLoggerOptions _options;
public ModuleLogger(string module, FileLogWriter writer, FileLoggerOptions options)
{
_module = module;
_writer = writer;
_options = options;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(MEL.LogLevel logLevel)
{
var mapped = MapLevel(logLevel);
return mapped >= _options.MinimumLevel;
}
public void Log<TState>(
MEL.LogLevel logLevel,
MEL.EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var message = formatter(state, exception);
var entry = new LogEntry(
DateTime.Now,
MapLevel(logLevel),
_module,
message,
exception);
_writer.Enqueue(entry);
}
private static LogLevel MapLevel(MEL.LogLevel level) => level switch
{
MEL.LogLevel.Trace => LogLevel.Debug,
MEL.LogLevel.Debug => LogLevel.Debug,
MEL.LogLevel.Information => LogLevel.Info,
MEL.LogLevel.Warning => LogLevel.Warn,
MEL.LogLevel.Error => LogLevel.Error,
MEL.LogLevel.Critical => LogLevel.Error,
_ => LogLevel.Info
};
}
@@ -0,0 +1,143 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Scheduling;
public sealed class AgentScheduler : IAsyncDisposable
{
private readonly AgentEngine _engine;
private readonly string _instanceId;
private readonly ILogger _logger;
private readonly CancellationTokenSource _cts = new();
private readonly List<Task> _schedulerTasks = new();
private readonly Dictionary<string, AgentRunResult?> _lastResults = new();
private readonly Lock _resultsLock = new();
public event Action<string, AgentRunResult>? OnRunCompleted;
public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory)
{
_engine = engine;
_instanceId = instanceId;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling");
}
public void RegisterAgent(AgentConfig agentConfig)
{
if (agentConfig.Scheduler is null)
return;
_logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}",
agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart);
var task = RunScheduledAgentAsync(agentConfig, _cts.Token);
_schedulerTasks.Add(task);
}
public void RegisterAll(IEnumerable<AgentConfig> agents)
{
foreach (var agent in agents)
RegisterAgent(agent);
}
public async Task<AgentRunResult> RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct)
{
_logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId);
var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct);
StoreResult(agentConfig.AgentId, result);
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
return result;
}
public AgentRunResult? GetLastResult(string agentId)
{
lock (_resultsLock)
return _lastResults.GetValueOrDefault(agentId);
}
private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct)
{
var scheduler = agentConfig.Scheduler!;
if (scheduler.RunOnStart)
{
await ExecuteScheduledRunAsync(agentConfig, ct);
}
if (string.IsNullOrWhiteSpace(scheduler.Cron))
return;
var cron = CronExpression.Parse(scheduler.Cron);
while (!ct.IsCancellationRequested)
{
var now = DateTime.Now;
var next = cron.GetNextOccurrence(now);
if (next is null)
{
_logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId);
return;
}
var delay = next.Value - now;
_logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value);
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
break;
}
await ExecuteScheduledRunAsync(agentConfig, ct);
}
}
private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct)
{
try
{
var result = await _engine.RunAsync(
agentConfig,
agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.",
_instanceId,
ct);
StoreResult(agentConfig.AgentId, result);
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
_logger.LogInformation(
"Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}",
agentConfig.AgentId, result.Status, result.TokensUsed);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId);
}
}
private void StoreResult(string agentId, AgentRunResult result)
{
lock (_resultsLock)
_lastResults[agentId] = result;
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try
{
await Task.WhenAll(_schedulerTasks);
}
catch (OperationCanceledException)
{
}
_cts.Dispose();
}
}
@@ -0,0 +1,119 @@
namespace ClawdDotNet.Core.Scheduling;
/// <summary>
/// Einfaches Cron-Parsing für 5-Felder-Ausdrücke: Minute Stunde Tag Monat Wochentag
/// Unterstützt: Zahlen, Wildcards (*), Bereiche (1-5), Listen (1,3,5), Schritte (*/5)
/// </summary>
public sealed class CronExpression
{
private readonly HashSet<int> _minutes;
private readonly HashSet<int> _hours;
private readonly HashSet<int> _daysOfMonth;
private readonly HashSet<int> _months;
private readonly HashSet<int> _daysOfWeek;
private CronExpression(
HashSet<int> minutes, HashSet<int> hours,
HashSet<int> daysOfMonth, HashSet<int> months,
HashSet<int> daysOfWeek)
{
_minutes = minutes;
_hours = hours;
_daysOfMonth = daysOfMonth;
_months = months;
_daysOfWeek = daysOfWeek;
}
public static CronExpression Parse(string expression)
{
var parts = expression.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 5)
throw new FormatException($"Cron expression must have 5 fields, got {parts.Length}: '{expression}'");
return new CronExpression(
ParseField(parts[0], 0, 59),
ParseField(parts[1], 0, 23),
ParseField(parts[2], 1, 31),
ParseField(parts[3], 1, 12),
ParseField(parts[4], 0, 6)
);
}
public bool Matches(DateTime dt)
{
return _minutes.Contains(dt.Minute)
&& _hours.Contains(dt.Hour)
&& _daysOfMonth.Contains(dt.Day)
&& _months.Contains(dt.Month)
&& _daysOfWeek.Contains((int)dt.DayOfWeek);
}
public DateTime? GetNextOccurrence(DateTime after)
{
var candidate = new DateTime(after.Year, after.Month, after.Day, after.Hour, after.Minute, 0)
.AddMinutes(1);
// Suche maximal 2 Jahre in die Zukunft
var limit = after.AddYears(2);
while (candidate < limit)
{
if (Matches(candidate))
return candidate;
candidate = candidate.AddMinutes(1);
// Optimierung: überspringe ungültige Stunden/Tage
if (!_months.Contains(candidate.Month))
{
candidate = new DateTime(candidate.Year, candidate.Month, 1).AddMonths(1);
continue;
}
if (!_daysOfMonth.Contains(candidate.Day) || !_daysOfWeek.Contains((int)candidate.DayOfWeek))
{
candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day).AddDays(1);
continue;
}
if (!_hours.Contains(candidate.Hour))
{
candidate = new DateTime(candidate.Year, candidate.Month, candidate.Day, candidate.Hour, 0, 0)
.AddHours(1);
}
}
return null;
}
private static HashSet<int> ParseField(string field, int min, int max)
{
var result = new HashSet<int>();
foreach (var part in field.Split(','))
{
if (part == "*")
{
for (var i = min; i <= max; i++) result.Add(i);
}
else if (part.Contains('/'))
{
var split = part.Split('/');
var start = split[0] == "*" ? min : int.Parse(split[0]);
var step = int.Parse(split[1]);
for (var i = start; i <= max; i += step) result.Add(i);
}
else if (part.Contains('-'))
{
var split = part.Split('-');
var from = int.Parse(split[0]);
var to = int.Parse(split[1]);
for (var i = from; i <= to; i++) result.Add(i);
}
else
{
result.Add(int.Parse(part));
}
}
return result;
}
}
@@ -0,0 +1,213 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Scheduling;
public sealed class ToolJobScheduler : IAsyncDisposable
{
private readonly AgentEngine _engine;
private readonly ToolRegistry _toolRegistry;
private readonly IStateStore _stateStore;
private readonly string _instanceId;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly CancellationTokenSource _cts = new();
private readonly List<Task> _schedulerTasks = new();
private readonly Dictionary<string, ToolJobResult?> _lastResults = new();
private readonly Lock _resultsLock = new();
public event Action<string, string, ToolJobResult>? OnJobTick;
public ToolJobScheduler(
AgentEngine engine,
ToolRegistry toolRegistry,
IStateStore stateStore,
ILoggerFactory loggerFactory,
string instanceId)
{
_engine = engine;
_toolRegistry = toolRegistry;
_stateStore = stateStore;
_instanceId = instanceId;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling.ToolJob");
}
public void RegisterAll(IEnumerable<AgentConfig> agents)
{
foreach (var agent in agents)
{
foreach (var jobConfig in agent.ToolJobs)
{
if (!jobConfig.Enabled)
continue;
var tool = _toolRegistry.Get(jobConfig.ToolName);
if (tool is not IToolJobProvider provider)
{
_logger.LogWarning(
"Tool '{ToolName}' for job '{JobId}' on agent '{AgentId}' is not a IToolJobProvider or not found",
jobConfig.ToolName, jobConfig.JobId, agent.AgentId);
continue;
}
_logger.LogInformation(
"Registering tool job: Agent={AgentId}, Tool={Tool}, JobType={JobType}, Cron={Cron}",
agent.AgentId, jobConfig.ToolName, jobConfig.JobTypeId, jobConfig.Cron);
var task = RunToolJobAsync(agent, jobConfig, provider, _cts.Token);
_schedulerTasks.Add(task);
}
}
}
public ToolJobResult? GetLastResult(string jobId)
{
lock (_resultsLock)
return _lastResults.GetValueOrDefault(jobId);
}
/// <summary>
/// Führt einen Tool-Job sofort manuell aus (außerhalb des Cron-Zeitplans).
/// </summary>
public async Task<ToolJobResult> TriggerJobAsync(AgentConfig agentConfig, ToolJobConfig jobConfig, CancellationToken ct)
{
var tool = _toolRegistry.Get(jobConfig.ToolName);
if (tool is not IToolJobProvider provider)
return ToolJobResult.NoAction($"Tool '{jobConfig.ToolName}' ist kein IToolJobProvider oder nicht registriert.");
_logger.LogInformation(
"Manual trigger: Agent={AgentId}, Job={JobId}, Type={JobType}",
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId);
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
lock (_resultsLock)
return _lastResults.GetValueOrDefault(jobConfig.JobId)
?? ToolJobResult.NoAction("Job wurde ausgeführt, aber kein Ergebnis vorhanden.");
}
private async Task RunToolJobAsync(
AgentConfig agentConfig,
ToolJobConfig jobConfig,
IToolJobProvider provider,
CancellationToken ct)
{
if (jobConfig.RunOnStart)
{
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
}
if (string.IsNullOrWhiteSpace(jobConfig.Cron))
return;
var cron = CronExpression.Parse(jobConfig.Cron);
while (!ct.IsCancellationRequested && jobConfig.Enabled)
{
var now = DateTime.Now;
var next = cron.GetNextOccurrence(now);
if (next is null)
{
_logger.LogWarning("No next occurrence for tool job {JobId}", jobConfig.JobId);
return;
}
var delay = next.Value - now;
_logger.LogInformation("Tool job {JobId} ({JobType}) next tick at {NextRun}",
jobConfig.JobId, jobConfig.JobTypeId, next.Value);
try
{
await Task.Delay(delay, ct);
}
catch (OperationCanceledException)
{
break;
}
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
}
}
private async Task ExecuteTickAsync(
AgentConfig agentConfig,
ToolJobConfig jobConfig,
IToolJobProvider provider,
CancellationToken ct)
{
var jobLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{jobConfig.ToolName}.Job");
if (!agentConfig.Tools.ContainsKey(jobConfig.ToolName))
{
_logger.LogWarning(
"Tool '{ToolName}' is no longer assigned to agent '{AgentId}' — disabling job '{JobId}'",
jobConfig.ToolName, agentConfig.AgentId, jobConfig.JobId);
jobConfig.Enabled = false;
var disabledResult = new ToolJobResult(false, null,
$"Job deaktiviert: Agent '{agentConfig.DisplayName}' hat keinen Zugriff auf Tool '{jobConfig.ToolName}'");
lock (_resultsLock)
_lastResults[jobConfig.JobId] = disabledResult;
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, disabledResult);
return;
}
try
{
var toolConfig = agentConfig.Tools.TryGetValue(jobConfig.ToolName, out var cfg)
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
: new Dictionary<string, object?>().AsReadOnly();
var result = await provider.ExecuteJobAsync(
jobConfig.JobTypeId, toolConfig, _stateStore, jobLogger, ct,
agentConfig.AgentId, agentConfig.WorkspacePath);
lock (_resultsLock)
_lastResults[jobConfig.JobId] = result;
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, result);
_logger.LogInformation(
"Tool job tick: Agent={AgentId}, Job={JobId}, Type={JobType}, Wake={Wake}, Log={Log}",
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId, result.ShouldWakeAgent, result.LogSummary);
if (result.ShouldWakeAgent && !string.IsNullOrWhiteSpace(result.WakeMessage))
{
_logger.LogInformation(
"Tool job waking agent: Agent={AgentId}, Job={JobId}, ChatContext={UseChatContext}",
agentConfig.AgentId, jobConfig.JobId, result.UseChatContext);
if (result.UseChatContext)
await _engine.ChatAsync(agentConfig, result.WakeMessage, _instanceId, ct, source: ChatSource.Job);
else
await _engine.RunAsync(agentConfig, result.WakeMessage, _instanceId, ct);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Tool job tick failed: Agent={AgentId}, Job={JobId}",
agentConfig.AgentId, jobConfig.JobId);
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
try
{
await Task.WhenAll(_schedulerTasks);
}
catch (OperationCanceledException)
{
}
_cts.Dispose();
}
}
@@ -0,0 +1,18 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Security;
public sealed class PermissionGate
{
public bool IsAllowed(string agentId, string toolName, AgentConfig agentConfig)
=> agentConfig.Tools.ContainsKey(toolName);
public void Enforce(string agentId, string toolName, AgentConfig agentConfig)
{
if (!IsAllowed(agentId, toolName, agentConfig))
throw new ToolAccessDeniedException(agentId, toolName);
}
}
public sealed class ToolAccessDeniedException(string agentId, string toolName)
: Exception($"Agent '{agentId}' has no access to tool '{toolName}'.");
@@ -0,0 +1,8 @@
namespace ClawdDotNet.Core.State;
public interface IStateStore
{
Task<string?> GetAsync(string key, CancellationToken ct);
Task SetAsync(string key, string value, CancellationToken ct);
Task DeleteAsync(string key, CancellationToken ct);
}
@@ -0,0 +1,63 @@
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.State;
public sealed class SqliteStateStore : IStateStore
{
private readonly string _connectionString;
public SqliteStateStore(string dbPath)
{
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate
}.ToString();
InitializeDatabase();
}
private void InitializeDatabase()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS ToolState (Key TEXT PRIMARY KEY, Value TEXT)";
cmd.ExecuteNonQuery();
}
public async Task<string?> GetAsync(string key, CancellationToken ct)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = @key";
cmd.Parameters.AddWithValue("@key", key);
var result = await cmd.ExecuteScalarAsync(ct);
return result?.ToString();
}
public async Task SetAsync(string key, string value, CancellationToken ct)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES (@key, @value)";
cmd.Parameters.AddWithValue("@key", key);
cmd.Parameters.AddWithValue("@value", value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task DeleteAsync(string key, CancellationToken ct)
{
using var conn = new SqliteConnection(_connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM ToolState WHERE Key = @key";
cmd.Parameters.AddWithValue("@key", key);
await cmd.ExecuteNonQueryAsync(ct);
}
}
@@ -0,0 +1,16 @@
using ClawdDotNet.Core.State;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tools;
public sealed record AgentToolContext(
string AgentId,
string InstanceId,
IReadOnlyDictionary<string, object?> ToolConfig,
IStateStore StateStore,
ILogger Logger,
CancellationToken CancellationToken,
string? WorkspacePath = null,
string? SharedWorkspacePath = null,
IAgentMessageRouter? MessageRouter = null
);
@@ -0,0 +1,24 @@
namespace ClawdDotNet.Core.Tools;
public sealed record AgentInfo(string AgentId, string DisplayName, string Role, string Model, bool IsRunning);
public sealed record AgentMessageResult(bool Delivered, string? Response, string? Error = null);
public sealed record AgentSpawnResult(bool Started, string? AgentId, string? FinalMessage, string? Error = null);
public interface IAgentMessageRouter
{
IReadOnlyList<AgentInfo> ListAgents(string callerAgentId);
Task<AgentMessageResult> SendMessageAsync(
string fromAgentId,
string toAgentId,
string message,
CancellationToken ct);
Task<AgentSpawnResult> SpawnAgentAsync(
string fromAgentId,
string targetAgentId,
string taskMessage,
CancellationToken ct);
}
+15
View File
@@ -0,0 +1,15 @@
using System.Text.Json;
namespace ClawdDotNet.Core.Tools;
public interface IAgentTool
{
string Name { get; }
string Description { get; }
JsonElement InputSchema { get; }
Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
CancellationToken ct);
}
@@ -0,0 +1,18 @@
using ClawdDotNet.Core.State;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tools;
public interface IToolJobProvider
{
IReadOnlyList<ToolJobDefinition> GetJobDefinitions();
Task<ToolJobResult> ExecuteJobAsync(
string jobTypeId,
IReadOnlyDictionary<string, object?> toolConfig,
IStateStore stateStore,
ILogger logger,
CancellationToken ct,
string? agentId = null,
string? workspacePath = null);
}
@@ -0,0 +1,7 @@
namespace ClawdDotNet.Core.Tools;
public sealed record ToolJobDefinition(
string JobTypeId,
string DisplayName,
string Description
);
@@ -0,0 +1,26 @@
namespace ClawdDotNet.Core.Tools;
public sealed record ToolJobResult(
bool ShouldWakeAgent,
string? WakeMessage,
string? LogSummary,
bool UseChatContext = true
)
{
public static ToolJobResult NoAction(string? logSummary = null)
=> new(false, null, logSummary);
/// <summary>
/// Agent im bestehenden Chat-Kontext aufwecken (Default).
/// Die Nachricht erscheint im Chat-Tab und der Agent behält den Konversationsverlauf.
/// </summary>
public static ToolJobResult Wake(string wakeMessage, string? logSummary = null)
=> new(true, wakeMessage, logSummary, UseChatContext: true);
/// <summary>
/// Agent in einer isolierten, zustandslosen Session aufwecken.
/// Kein Chat-Verlauf, keine Persistenz — für einmalige, kontextfreie Aufgaben.
/// </summary>
public static ToolJobResult WakeStateless(string wakeMessage, string? logSummary = null)
=> new(true, wakeMessage, logSummary, UseChatContext: false);
}
@@ -0,0 +1,44 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Tools;
public sealed class ToolRegistry
{
private readonly Dictionary<string, IAgentTool> _tools = new();
private readonly Lock _lock = new();
public void Register(IAgentTool tool)
{
lock (_lock)
_tools[tool.Name] = tool;
}
public IAgentTool? Get(string name)
{
lock (_lock)
return _tools.GetValueOrDefault(name);
}
public IReadOnlyList<IAgentTool> GetForAgent(AgentConfig agent)
{
lock (_lock)
return _tools.Values
.Where(t => agent.Tools.ContainsKey(t.Name))
.ToList();
}
public IReadOnlyList<IAgentTool> GetAll()
{
lock (_lock)
return _tools.Values.ToList();
}
public IReadOnlyList<(IAgentTool Tool, IToolJobProvider Provider)> GetJobProviders()
{
lock (_lock)
return _tools.Values
.OfType<IToolJobProvider>()
.Select(p => ((IAgentTool)p, p))
.ToList();
}
}
+12
View File
@@ -0,0 +1,12 @@
namespace ClawdDotNet.Core.Tools;
public sealed record ToolResult(
bool Success,
string Content,
string? ErrorMessage = null
)
{
public static ToolResult Ok(string content) => new(true, content);
public static ToolResult Fail(string error) => new(false, "", error);
}