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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user