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);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.AgentComm;
|
||||
|
||||
public sealed class AgentCommTool : IAgentTool
|
||||
{
|
||||
public string Name => "AgentComm";
|
||||
|
||||
public string Description =>
|
||||
"Ermöglicht die Kommunikation mit anderen Agenten in der gleichen Instanz. " +
|
||||
"Du kannst Agenten auflisten und ihnen Nachrichten senden.";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_agents", "send_message"],
|
||||
"description": "Die auszuführende Aktion: 'list_agents' listet alle verfügbaren Agenten auf, 'send_message' sendet eine Nachricht an einen anderen Agenten und wartet auf dessen Antwort."
|
||||
},
|
||||
"targetAgentId": {
|
||||
"type": "string",
|
||||
"description": "Die ID des Ziel-Agenten (nur für send_message erforderlich)"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Die Nachricht an den Ziel-Agenten (nur für send_message erforderlich)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var action = input.TryGetProperty("action", out var actionEl)
|
||||
? actionEl.GetString() : null;
|
||||
|
||||
if (context.MessageRouter is null)
|
||||
return ToolResult.Fail("AgentComm ist nicht verfügbar: Kein MessageRouter konfiguriert.");
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "list_agents":
|
||||
return ListAgents(context);
|
||||
|
||||
case "send_message":
|
||||
return await SendMessage(input, context, ct);
|
||||
|
||||
default:
|
||||
return ToolResult.Fail($"Unbekannte Aktion: '{action}'. Verwende 'list_agents' oder 'send_message'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ListAgents(AgentToolContext context)
|
||||
{
|
||||
var agents = context.MessageRouter!.ListAgents(context.AgentId);
|
||||
|
||||
if (agents.Count == 0)
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
info = "Keine anderen Agenten in dieser Instanz gefunden.",
|
||||
agents = Array.Empty<object>()
|
||||
}));
|
||||
|
||||
var result = agents.Select(a => new
|
||||
{
|
||||
agentId = a.AgentId,
|
||||
displayName = a.DisplayName,
|
||||
role = a.Role,
|
||||
model = a.Model,
|
||||
status = a.IsRunning ? "running" : "idle"
|
||||
});
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new { agents = result }));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> SendMessage(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var targetId = input.TryGetProperty("targetAgentId", out var tid) ? tid.GetString() : null;
|
||||
var message = input.TryGetProperty("message", out var msg) ? msg.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetId))
|
||||
return ToolResult.Fail("'targetAgentId' ist erforderlich für send_message.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return ToolResult.Fail("'message' ist erforderlich für send_message.");
|
||||
|
||||
if (targetId == context.AgentId)
|
||||
return ToolResult.Fail("Du kannst dir nicht selbst eine Nachricht senden.");
|
||||
|
||||
context.Logger.LogInformation(
|
||||
"Agent {From} sendet Nachricht an {To}: {Msg}",
|
||||
context.AgentId, targetId, message.Length > 100 ? message[..100] + "..." : message);
|
||||
|
||||
var result = await context.MessageRouter!.SendMessageAsync(
|
||||
context.AgentId, targetId, message, ct);
|
||||
|
||||
if (result.Delivered)
|
||||
{
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
delivered = true,
|
||||
targetAgentId = targetId,
|
||||
response = result.Response
|
||||
}));
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"Nachricht konnte nicht zugestellt werden: {result.Error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.AgentComm;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "Inter-Agent-Kommunikation, Broadcast";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.AgentComm</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,463 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.AgentEditor;
|
||||
|
||||
/// <summary>
|
||||
/// Ermöglicht einem Agent, die Identity- und Soul-Dateien anderer Agenten in der Instanz
|
||||
/// zu lesen, zu bearbeiten und neue Agenten anzulegen.
|
||||
///
|
||||
/// Sicherheitskonzept:
|
||||
/// - Der Agent kann nur Soul.md und Identity.md lesen/schreiben (keine AgentSettings, keine Chat-Daten)
|
||||
/// - Neue Agenten bekommen nur die Grundstruktur — Tool-Zuweisung etc. bleibt dem Benutzer vorbehalten
|
||||
/// - Alle Änderungen werden mit Backup gesichert (.bak-Datei)
|
||||
/// </summary>
|
||||
public sealed class AgentEditorTool : IAgentTool
|
||||
{
|
||||
public string Name => "AgentEditor";
|
||||
|
||||
public string Description => """
|
||||
Verwaltet die Agenten-Persönlichkeiten in der Instanz. Ermöglicht das Lesen und Bearbeiten
|
||||
von Identity (Rolle, Expertise) und Soul (Persönlichkeit, Arbeitsweise, Werte) aller Agenten.
|
||||
|
||||
Aktionen:
|
||||
- list_agents: Übersicht aller Agenten mit Namen, Beschreibung und verfügbaren Dateien
|
||||
- read_identity: Identity.md eines Agenten lesen (WER ist der Agent)
|
||||
- read_soul: Soul.md eines Agenten lesen (WIE denkt/arbeitet der Agent)
|
||||
- update_identity: Identity.md eines Agenten aktualisieren (erstellt Backup)
|
||||
- update_soul: Soul.md eines Agenten aktualisieren (erstellt Backup)
|
||||
- create_agent: Einen neuen Agenten in der Instanz erstellen (Grundstruktur mit Identity und Soul)
|
||||
|
||||
Wichtig: Verschaffe dir IMMER zuerst mit list_agents eine Übersicht, bevor du Änderungen vornimmst.
|
||||
Lies auch die bestehende Identity/Soul bevor du sie überschreibst, um nichts Wichtiges zu verlieren.
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_agents", "read_identity", "read_soul", "update_identity", "update_soul", "create_agent"],
|
||||
"description": "Die auszuführende Aktion"
|
||||
},
|
||||
"agentId": {
|
||||
"type": "string",
|
||||
"description": "Die ID des Ziel-Agenten (z.B. 'senior_developer'). Nicht erforderlich für list_agents."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Der neue Inhalt für Identity oder Soul (nur für update_identity/update_soul)"
|
||||
},
|
||||
"agentName": {
|
||||
"type": "string",
|
||||
"description": "Der Anzeigename für einen neuen Agenten (nur für create_agent, z.B. 'News Scanner')"
|
||||
},
|
||||
"agentDescription": {
|
||||
"type": "string",
|
||||
"description": "Kurzbeschreibung der Rolle des neuen Agenten (nur für create_agent)"
|
||||
},
|
||||
"identity": {
|
||||
"type": "string",
|
||||
"description": "Der Identity.md-Inhalt für einen neuen Agenten (nur für create_agent)"
|
||||
},
|
||||
"soul": {
|
||||
"type": "string",
|
||||
"description": "Der Soul.md-Inhalt für einen neuen Agenten (nur für create_agent)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
try
|
||||
{
|
||||
var agentsDir = ResolveAgentsDirectory(context);
|
||||
if (agentsDir is null)
|
||||
return ToolResult.Fail("Konnte das Agents-Verzeichnis nicht ermitteln. SharedWorkspacePath ist nicht konfiguriert.");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"list_agents" => HandleListAgents(agentsDir, context),
|
||||
"read_identity" => await HandleReadFileAsync(input, agentsDir, "Identity.md", context, ct),
|
||||
"read_soul" => await HandleReadFileAsync(input, agentsDir, "Soul.md", context, ct),
|
||||
"update_identity" => await HandleUpdateFileAsync(input, agentsDir, "Identity.md", context, ct),
|
||||
"update_soul" => await HandleUpdateFileAsync(input, agentsDir, "Soul.md", context, ct),
|
||||
"create_agent" => await HandleCreateAgentAsync(input, agentsDir, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler bei AgentEditor Aktion {Action}", action);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ermittelt das Agents-Verzeichnis aus dem SharedWorkspacePath.
|
||||
/// SharedWorkspace liegt unter .../Agents/SharedWorkspace → Parent = Agents-Verzeichnis.
|
||||
/// </summary>
|
||||
private static string? ResolveAgentsDirectory(AgentToolContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.SharedWorkspacePath))
|
||||
return null;
|
||||
|
||||
// SharedWorkspace = .../Agents/SharedWorkspace → Parent = .../Agents/
|
||||
var agentsDir = Path.GetDirectoryName(context.SharedWorkspacePath);
|
||||
return agentsDir != null && Directory.Exists(agentsDir) ? agentsDir : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Findet das Agent-Verzeichnis anhand der agentId.
|
||||
/// Sucht nach Agent-* Ordnern und gleicht mit der AgentSettings.json ab.
|
||||
/// </summary>
|
||||
private static string? FindAgentDirectory(string agentsDir, string agentId)
|
||||
{
|
||||
foreach (var dir in Directory.GetDirectories(agentsDir, "Agent-*"))
|
||||
{
|
||||
var settingsPath = Path.Combine(dir, "AgentSettings.json");
|
||||
if (!File.Exists(settingsPath)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(settingsPath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var id = doc.RootElement.TryGetProperty("agentId", out var idElem)
|
||||
? idElem.GetString() : null;
|
||||
|
||||
if (string.Equals(id, agentId, StringComparison.OrdinalIgnoreCase))
|
||||
return dir;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fehlerhafte JSON-Datei ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Ordnername-basiert (Agent-Senior_Developer → senior_developer)
|
||||
foreach (var dir in Directory.GetDirectories(agentsDir, "Agent-*"))
|
||||
{
|
||||
var folderName = Path.GetFileName(dir).Replace("Agent-", "");
|
||||
if (string.Equals(folderName, agentId, StringComparison.OrdinalIgnoreCase))
|
||||
return dir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// AKTIONEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private ToolResult HandleListAgents(string agentsDir, AgentToolContext context)
|
||||
{
|
||||
var agents = new List<object>();
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
|
||||
{
|
||||
var folderName = Path.GetFileName(dir);
|
||||
var settingsPath = Path.Combine(dir, "AgentSettings.json");
|
||||
|
||||
string agentId = folderName.Replace("Agent-", "").ToLowerInvariant();
|
||||
string displayName = folderName.Replace("Agent-", "").Replace("_", " ");
|
||||
string description = "";
|
||||
var toolNames = new List<string>();
|
||||
|
||||
if (File.Exists(settingsPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(settingsPath));
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("agentId", out var idEl))
|
||||
agentId = idEl.GetString() ?? agentId;
|
||||
if (root.TryGetProperty("displayName", out var nameEl))
|
||||
displayName = nameEl.GetString() ?? displayName;
|
||||
if (root.TryGetProperty("tools", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Object)
|
||||
toolNames = toolsEl.EnumerateObject().Select(p => p.Name).ToList();
|
||||
}
|
||||
catch { /* fehlerhafte JSON ignorieren */ }
|
||||
}
|
||||
|
||||
// Description aus AgentList.json holen
|
||||
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
|
||||
if (File.Exists(agentListPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var listDoc = JsonDocument.Parse(File.ReadAllText(agentListPath));
|
||||
if (listDoc.RootElement.TryGetProperty("agents", out var agentsArr))
|
||||
{
|
||||
foreach (var entry in agentsArr.EnumerateArray())
|
||||
{
|
||||
if (entry.TryGetProperty("folderName", out var fn) &&
|
||||
fn.GetString() == folderName &&
|
||||
entry.TryGetProperty("description", out var desc))
|
||||
{
|
||||
description = desc.GetString() ?? "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
var hasIdentity = File.Exists(Path.Combine(dir, "Identity.md"));
|
||||
var hasSoul = File.Exists(Path.Combine(dir, "Soul.md"));
|
||||
var isSelf = string.Equals(agentId, context.AgentId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
agents.Add(new
|
||||
{
|
||||
AgentId = agentId,
|
||||
DisplayName = displayName,
|
||||
Description = description,
|
||||
FolderName = folderName,
|
||||
HasIdentity = hasIdentity,
|
||||
HasSoul = hasSoul,
|
||||
Tools = toolNames,
|
||||
IsSelf = isSelf
|
||||
});
|
||||
}
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(agents, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleReadFileAsync(
|
||||
JsonElement input, string agentsDir, string fileName, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var agentId = input.TryGetProperty("agentId", out var ai) ? ai.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
return ToolResult.Fail("'agentId' ist erforderlich. Nutze zuerst list_agents für eine Übersicht.");
|
||||
|
||||
var agentDir = FindAgentDirectory(agentsDir, agentId);
|
||||
if (agentDir is null)
|
||||
return ToolResult.Fail($"Agent '{agentId}' nicht gefunden. Nutze list_agents für eine Übersicht der verfügbaren Agenten.");
|
||||
|
||||
var filePath = Path.Combine(agentDir, fileName);
|
||||
if (!File.Exists(filePath))
|
||||
return ToolResult.Fail($"{fileName} existiert nicht für Agent '{agentId}'.");
|
||||
|
||||
var content = await File.ReadAllTextAsync(filePath, Encoding.UTF8, ct);
|
||||
var label = fileName.Replace(".md", "");
|
||||
return ToolResult.Ok($"[{label} von '{agentId}']\n\n{content}");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleUpdateFileAsync(
|
||||
JsonElement input, string agentsDir, string fileName, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var agentId = input.TryGetProperty("agentId", out var ai) ? ai.GetString() : null;
|
||||
var content = input.TryGetProperty("content", out var c) ? c.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
return ToolResult.Fail("'agentId' ist erforderlich.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("'content' ist erforderlich. Lies zuerst die bestehende Datei mit read_identity/read_soul.");
|
||||
|
||||
var agentDir = FindAgentDirectory(agentsDir, agentId);
|
||||
if (agentDir is null)
|
||||
return ToolResult.Fail($"Agent '{agentId}' nicht gefunden.");
|
||||
|
||||
var filePath = Path.Combine(agentDir, fileName);
|
||||
|
||||
// Backup erstellen falls Datei existiert
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var backupPath = filePath + $".bak_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
File.Copy(filePath, backupPath, overwrite: true);
|
||||
context.Logger.LogInformation(
|
||||
"Backup erstellt: {BackupPath}", Path.GetFileName(backupPath));
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(filePath, content, new UTF8Encoding(false), ct);
|
||||
|
||||
var label = fileName.Replace(".md", "");
|
||||
return ToolResult.Ok(
|
||||
$"{label} von Agent '{agentId}' erfolgreich aktualisiert.\n" +
|
||||
$"Hinweis: Die Änderungen werden beim nächsten Neustart des Agenten oder der Instanz wirksam.");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleCreateAgentAsync(
|
||||
JsonElement input, string agentsDir, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var agentName = input.TryGetProperty("agentName", out var an) ? an.GetString() : null;
|
||||
var description = input.TryGetProperty("agentDescription", out var ad) ? ad.GetString() ?? "" : "";
|
||||
var identity = input.TryGetProperty("identity", out var id) ? id.GetString() : null;
|
||||
var soul = input.TryGetProperty("soul", out var so) ? so.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentName))
|
||||
return ToolResult.Fail("'agentName' ist erforderlich (z.B. 'News Scanner').");
|
||||
|
||||
// Ordnername sanitieren
|
||||
var sanitized = SanitizeName(agentName);
|
||||
var folderName = $"Agent-{sanitized}";
|
||||
var agentDir = Path.Combine(agentsDir, folderName);
|
||||
|
||||
if (Directory.Exists(agentDir))
|
||||
return ToolResult.Fail($"Agent '{agentName}' existiert bereits (Ordner: {folderName}).");
|
||||
|
||||
// Verzeichnisse anlegen
|
||||
Directory.CreateDirectory(agentDir);
|
||||
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
|
||||
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
|
||||
|
||||
var agentId = sanitized.ToLowerInvariant();
|
||||
|
||||
// AgentSettings.json — minimale Grundkonfiguration
|
||||
var settings = new
|
||||
{
|
||||
agentId,
|
||||
displayName = agentName,
|
||||
model = "anthropic/claude-sonnet-4-5",
|
||||
systemPrompt = "",
|
||||
tools = new Dictionary<string, object>(),
|
||||
scheduler = (object?)null,
|
||||
toolJobs = Array.Empty<object>(),
|
||||
loopGuard = new
|
||||
{
|
||||
maxSteps = 20,
|
||||
maxTokens = 80000,
|
||||
timeoutSeconds = 600,
|
||||
maxContextTokens = 100000,
|
||||
compactionThreshold = 0.8
|
||||
}
|
||||
};
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(agentDir, "AgentSettings.json"),
|
||||
JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }),
|
||||
new UTF8Encoding(false), ct);
|
||||
|
||||
// Identity.md
|
||||
var identityContent = identity ?? $"""
|
||||
# Identity: {agentName}
|
||||
|
||||
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
|
||||
|
||||
## Rolle
|
||||
{(string.IsNullOrWhiteSpace(description) ? "[Beschreibe hier die Rolle und Verantwortlichkeiten]" : description)}
|
||||
|
||||
## Expertise
|
||||
[Beschreibe hier die Fachgebiete und Fähigkeiten]
|
||||
|
||||
## Kontext
|
||||
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(agentDir, "Identity.md"), identityContent, new UTF8Encoding(false), ct);
|
||||
|
||||
// Soul.md
|
||||
var soulContent = soul ?? $"""
|
||||
# Soul: {agentName}
|
||||
|
||||
## Persönlichkeit
|
||||
- Gründlich und zuverlässig
|
||||
- Klar und präzise in der Kommunikation
|
||||
- Proaktiv bei der Problemerkennung
|
||||
|
||||
## Arbeitsweise
|
||||
- Analysiere Aufgaben sorgfältig bevor du handelst
|
||||
- Dokumentiere deine Entscheidungen und Ergebnisse
|
||||
- Nutze die dir zugewiesenen Tools effizient
|
||||
|
||||
## Werte
|
||||
- Genauigkeit vor Geschwindigkeit
|
||||
- Transparenz in der Entscheidungsfindung
|
||||
- Sicherheit und Datenschutz haben Priorität
|
||||
""";
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(agentDir, "Soul.md"), soulContent, new UTF8Encoding(false), ct);
|
||||
|
||||
// Leere Chat-Dateien
|
||||
await File.WriteAllTextAsync(Path.Combine(agentDir, "ChatHistory.json"), "[]", ct);
|
||||
await File.WriteAllTextAsync(Path.Combine(agentDir, "ChatContext.json"), "[]", ct);
|
||||
|
||||
// AgentList.json aktualisieren
|
||||
await UpdateAgentListAsync(agentsDir, agentName, description, folderName, ct);
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Agent '{agentName}' erfolgreich erstellt!\n\n" +
|
||||
$" Ordner: {folderName}\n" +
|
||||
$" AgentId: {agentId}\n" +
|
||||
$" Identity: {(identity != null ? "Benutzerdefiniert" : "Standard-Template")}\n" +
|
||||
$" Soul: {(soul != null ? "Benutzerdefiniert" : "Standard-Template")}\n\n" +
|
||||
$"Hinweis: Dem neuen Agenten wurden noch KEINE Tools zugewiesen. " +
|
||||
$"Dies muss über die AgentSettings.json oder die UI erfolgen. " +
|
||||
$"Die Instanz muss neu gestartet werden, damit der Agent verfügbar wird.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// HILFSMETHODEN
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static string SanitizeName(string name)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in name)
|
||||
{
|
||||
if (char.IsLetterOrDigit(c))
|
||||
sb.Append(c);
|
||||
else if (c is ' ' or '-' or '_')
|
||||
sb.Append('_');
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static async Task UpdateAgentListAsync(
|
||||
string agentsDir, string agentName, string description, string folderName, CancellationToken ct)
|
||||
{
|
||||
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
|
||||
|
||||
List<Dictionary<string, string>> agents = new();
|
||||
|
||||
if (File.Exists(agentListPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(agentListPath, ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (doc.RootElement.TryGetProperty("agents", out var agentsArr))
|
||||
{
|
||||
foreach (var entry in agentsArr.EnumerateArray())
|
||||
{
|
||||
var item = new Dictionary<string, string>();
|
||||
foreach (var prop in entry.EnumerateObject())
|
||||
item[prop.Name] = prop.Value.GetString() ?? "";
|
||||
agents.Add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* fehlerhafte JSON → neu aufbauen */ }
|
||||
}
|
||||
|
||||
// Duplikat-Check
|
||||
if (agents.All(a => !a.TryGetValue("folderName", out var fn) || fn != folderName))
|
||||
{
|
||||
agents.Add(new Dictionary<string, string>
|
||||
{
|
||||
["name"] = agentName,
|
||||
["description"] = description,
|
||||
["folderName"] = folderName
|
||||
});
|
||||
}
|
||||
|
||||
var result = new { agents };
|
||||
await File.WriteAllTextAsync(
|
||||
agentListPath,
|
||||
JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }),
|
||||
new UTF8Encoding(false), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.AgentEditor;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "Initiale Version: list_agents, read_identity, read_soul, update_identity, update_soul, create_agent";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.AgentEditor</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.AgentSpawn;
|
||||
|
||||
public sealed class AgentSpawnTool : IAgentTool
|
||||
{
|
||||
public string Name => "AgentSpawn";
|
||||
|
||||
public string Description =>
|
||||
"Ermöglicht es, andere Agenten zu starten und ihnen eine Aufgabe zuzuweisen. " +
|
||||
"Der Zielagent wird als eigenständiger Run gestartet und führt die Aufgabe unabhängig aus. " +
|
||||
"Verwende dies, um Aufgaben an spezialisierte Agenten zu delegieren.";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_agents", "spawn_agent"],
|
||||
"description": "Die auszuführende Aktion: 'list_agents' listet alle verfügbaren Agenten auf, 'spawn_agent' startet einen Agenten mit einer Aufgabe."
|
||||
},
|
||||
"targetAgentId": {
|
||||
"type": "string",
|
||||
"description": "Die ID des Ziel-Agenten (nur für spawn_agent erforderlich)"
|
||||
},
|
||||
"taskMessage": {
|
||||
"type": "string",
|
||||
"description": "Die Aufgabe/Anweisung für den Ziel-Agenten (nur für spawn_agent erforderlich)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var action = input.TryGetProperty("action", out var actionEl)
|
||||
? actionEl.GetString() : null;
|
||||
|
||||
if (context.MessageRouter is null)
|
||||
return ToolResult.Fail("AgentSpawn ist nicht verfügbar: Kein MessageRouter konfiguriert.");
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case "list_agents":
|
||||
return ListAgents(context);
|
||||
|
||||
case "spawn_agent":
|
||||
return await SpawnAgent(input, context, ct);
|
||||
|
||||
default:
|
||||
return ToolResult.Fail($"Unbekannte Aktion: '{action}'. Verwende 'list_agents' oder 'spawn_agent'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static ToolResult ListAgents(AgentToolContext context)
|
||||
{
|
||||
var agents = context.MessageRouter!.ListAgents(context.AgentId);
|
||||
|
||||
if (agents.Count == 0)
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
info = "Keine anderen Agenten in dieser Instanz gefunden.",
|
||||
agents = Array.Empty<object>()
|
||||
}));
|
||||
|
||||
var result = agents.Select(a => new
|
||||
{
|
||||
agentId = a.AgentId,
|
||||
displayName = a.DisplayName,
|
||||
role = a.Role,
|
||||
model = a.Model,
|
||||
status = a.IsRunning ? "running" : "idle"
|
||||
});
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new { agents = result }));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> SpawnAgent(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var targetId = input.TryGetProperty("targetAgentId", out var tid) ? tid.GetString() : null;
|
||||
var taskMessage = input.TryGetProperty("taskMessage", out var msg) ? msg.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(targetId))
|
||||
return ToolResult.Fail("'targetAgentId' ist erforderlich für spawn_agent.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(taskMessage))
|
||||
return ToolResult.Fail("'taskMessage' ist erforderlich für spawn_agent.");
|
||||
|
||||
if (targetId == context.AgentId)
|
||||
return ToolResult.Fail("Du kannst dich nicht selbst spawnen.");
|
||||
|
||||
context.Logger.LogInformation(
|
||||
"Agent {From} spawnt Agent {To} mit Aufgabe: {Task}",
|
||||
context.AgentId, targetId,
|
||||
taskMessage.Length > 100 ? taskMessage[..100] + "..." : taskMessage);
|
||||
|
||||
var result = await context.MessageRouter!.SpawnAgentAsync(
|
||||
context.AgentId, targetId, taskMessage, ct);
|
||||
|
||||
if (result.Started)
|
||||
{
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
spawned = true,
|
||||
targetAgentId = result.AgentId,
|
||||
result = result.FinalMessage
|
||||
}));
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"Agent konnte nicht gestartet werden: {result.Error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.AgentSpawn;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "Dynamischer Agent-Spawn";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.AgentSpawn</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.Database;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "SQL-Abfragen, Schema-Exploration";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.Database</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MySqlConnector" Version="2.5.0" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.1" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="3.8.1" />
|
||||
<PackageReference Include="SharpCompress" Version="0.48.0" />
|
||||
<PackageReference Include="Snappier" Version="1.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\ClawdDotNet.Core\\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using MySqlConnector;
|
||||
using Npgsql;
|
||||
|
||||
namespace ClawdDotNet.Tools.Database;
|
||||
|
||||
public enum DatabaseAccessLevel
|
||||
{
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
Admin
|
||||
}
|
||||
|
||||
public sealed class DatabaseTool : IAgentTool
|
||||
{
|
||||
public string Name => "Database";
|
||||
|
||||
public string Description => "Erlaubt den Zugriff auf SQL (MySQL, Postgres, MSSQL) und NoSQL (MongoDB) Datenbanken mit verschiedenen Zugriffsebenen.";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["query", "insert", "upsert"],
|
||||
"description": "Die auszuführende Aktion"
|
||||
},
|
||||
"sql": {
|
||||
"type": "string",
|
||||
"description": "Das auszuführende SQL Statement (nur für SQL-Typen)"
|
||||
},
|
||||
"collection": {
|
||||
"type": "string",
|
||||
"description": "Der Name der Collection (nur für MongoDB)"
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "JSON-Filter (nur für MongoDB)"
|
||||
},
|
||||
"document": {
|
||||
"type": "string",
|
||||
"description": "JSON-Dokument zum Einfügen/Update (nur für MongoDB)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
var type = context.ToolConfig.TryGetValue("type", out var t) ? t?.ToString()?.ToLowerInvariant() : null;
|
||||
var connectionString = context.ToolConfig.TryGetValue("connectionString", out var cs) ? cs?.ToString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
return ToolResult.Fail("Konfigurationsfehler: 'type' und 'connectionString' sind erforderlich.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (type == "mongodb")
|
||||
{
|
||||
return await HandleMongoAsync(action, input, connectionString, context, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return await HandleSqlAsync(type, action, input, connectionString, context, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler bei Database Aktion {Action} ({Type})", action, type);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleSqlAsync(string type, string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var sql = input.TryGetProperty("sql", out var s) ? s.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(sql)) return ToolResult.Fail("'sql' ist erforderlich.");
|
||||
|
||||
var accessLevel = GetAccessLevel(context);
|
||||
|
||||
// Sicherheitsprüfungen
|
||||
if (IsAdminAttempt(sql))
|
||||
{
|
||||
if (accessLevel != DatabaseAccessLevel.Admin)
|
||||
return ToolResult.Fail("Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.");
|
||||
}
|
||||
else if (IsWriteAttempt(sql))
|
||||
{
|
||||
if (accessLevel == DatabaseAccessLevel.ReadOnly)
|
||||
return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).");
|
||||
}
|
||||
|
||||
// Tabellen-Whitelist-Prüfung
|
||||
if (!IsTableAllowed(sql, context))
|
||||
{
|
||||
return ToolResult.Fail("Sicherheitsfehler: Zugriff auf eine oder mehrere Tabellen im Statement ist nicht erlaubt.");
|
||||
}
|
||||
|
||||
using DbConnection conn = type switch
|
||||
{
|
||||
"mysql" => new MySqlConnection(connectionString),
|
||||
"postgres" => new NpgsqlConnection(connectionString),
|
||||
"mssql" => new SqlConnection(connectionString),
|
||||
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
|
||||
};
|
||||
|
||||
await conn.OpenAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
if (action == "query")
|
||||
{
|
||||
using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var results = new List<Dictionary<string, object>>();
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
var row = new Dictionary<string, object>();
|
||||
for (int i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
row[reader.GetName(i)] = reader.GetValue(i);
|
||||
}
|
||||
results.Add(row);
|
||||
}
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
else
|
||||
{
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct);
|
||||
return ToolResult.Ok($"{affected} Zeilen betroffen.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleMongoAsync(string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(collectionName)) return ToolResult.Fail("'collection' ist erforderlich.");
|
||||
|
||||
if (!IsTableAllowed(collectionName, context))
|
||||
{
|
||||
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Collection '{collectionName}' ist nicht erlaubt.");
|
||||
}
|
||||
|
||||
var accessLevel = GetAccessLevel(context);
|
||||
var client = new MongoClient(connectionString);
|
||||
var dbName = new MongoUrl(connectionString).DatabaseName;
|
||||
var db = client.GetDatabase(dbName);
|
||||
var collection = db.GetCollection<BsonDocument>(collectionName);
|
||||
|
||||
if (action == "query")
|
||||
{
|
||||
var filterJson = input.TryGetProperty("filter", out var f) ? f.GetString() : "{}";
|
||||
var filter = BsonDocument.Parse(filterJson);
|
||||
var docs = await collection.Find(filter).Limit(100).ToListAsync(ct);
|
||||
var results = docs.Select(d => d.ToJson()).ToList();
|
||||
return ToolResult.Ok("[" + string.Join(",", results) + "]");
|
||||
}
|
||||
else if (action == "insert")
|
||||
{
|
||||
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
|
||||
var docJson = input.TryGetProperty("document", out var d) ? d.GetString() : throw new ArgumentException("'document' erforderlich.");
|
||||
var doc = BsonDocument.Parse(docJson);
|
||||
await collection.InsertOneAsync(doc, cancellationToken: ct);
|
||||
return ToolResult.Ok("Dokument erfolgreich eingefügt.");
|
||||
}
|
||||
else // upsert
|
||||
{
|
||||
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
|
||||
return ToolResult.Fail("Upsert für MongoDB noch nicht voll implementiert.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsWriteAttempt(string sql)
|
||||
{
|
||||
var lower = sql.ToLowerInvariant();
|
||||
return lower.Contains("insert") || lower.Contains("update") || lower.Contains("delete");
|
||||
}
|
||||
|
||||
private bool IsAdminAttempt(string sql)
|
||||
{
|
||||
var lower = sql.ToLowerInvariant();
|
||||
return lower.Contains("drop") || lower.Contains("alter") || lower.Contains("create") || lower.Contains("truncate");
|
||||
}
|
||||
|
||||
private DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
|
||||
{
|
||||
if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null)
|
||||
{
|
||||
if (Enum.TryParse<DatabaseAccessLevel>(val.ToString(), true, out var level))
|
||||
return level;
|
||||
}
|
||||
|
||||
// Rückfall auf altes 'allowWrite' für Abwärtskompatibilität
|
||||
if (context.ToolConfig.TryGetValue("allowWrite", out var aw) && aw is JsonElement je && je.GetBoolean())
|
||||
{
|
||||
return DatabaseAccessLevel.ReadWrite;
|
||||
}
|
||||
|
||||
return DatabaseAccessLevel.ReadOnly;
|
||||
}
|
||||
|
||||
private bool IsTableAllowed(string input, AgentToolContext context)
|
||||
{
|
||||
if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var allowed = je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList();
|
||||
var inputLower = input.ToLowerInvariant();
|
||||
|
||||
return allowed.Any(t => t != null && inputLower.Contains(t));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.DirectAPI;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 2;
|
||||
public const string Changes = "Alpha Vantage Provider (quote+history), Yahoo History, Multi-Provider Support";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClawdDotNet.Tools.DirectAPI;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.DirectAPI;
|
||||
|
||||
public sealed class DirectApiTool : IAgentTool
|
||||
{
|
||||
public string Name => "DirectAPI";
|
||||
|
||||
public string Description => """
|
||||
Ruft Echtzeit-Finanzdaten von verifizierten APIs ab.
|
||||
Alle Antworten enthalten fetchedAt und dataAsOf Timestamps.
|
||||
Aktionen: quote, history, crypto, forex, search
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["action", "symbol"],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["quote", "history", "crypto", "forex", "search"],
|
||||
"description": "quote=aktueller Kurs, history=Kursverlauf, crypto=Krypto, forex=Wechselkurs, search=Symbol suchen"
|
||||
},
|
||||
"symbol": { "type": "string", "description": "z.B. NVDA, BTC, EUR/USD" },
|
||||
"provider": { "type": "string", "description": "optional: twelvedata|alphavantage|massive|coingecko|yahoo" },
|
||||
"interval": { "type": "string", "description": "für history: 1min|5min|1h|1day" },
|
||||
"outputsize":{ "type": "integer","description": "für history: Anzahl Datenpunkte, max 500" }
|
||||
}
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()!;
|
||||
var symbol = input.GetProperty("symbol").GetString()!;
|
||||
|
||||
// Config laden
|
||||
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("DirectAPI", out var c) && c is JsonElement je
|
||||
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
|
||||
: context.ToolConfig;
|
||||
|
||||
if (config == null) return ToolResult.Fail("DirectAPI Konfiguration fehlt.");
|
||||
|
||||
var providers = config.GetValueOrDefault("providers") as JsonElement?;
|
||||
var defaultProvider = config.GetValueOrDefault("defaultProvider")?.ToString() ?? "twelvedata";
|
||||
var provider = input.TryGetProperty("provider", out var p) ? p.GetString() : defaultProvider;
|
||||
|
||||
return action switch
|
||||
{
|
||||
"quote" => await FetchQuoteAsync(symbol, provider!, providers, context, ct),
|
||||
"history" => await FetchHistoryAsync(symbol, input, provider!, providers, context, ct),
|
||||
"crypto" => await FetchCryptoAsync(symbol, providers, context, ct),
|
||||
"forex" => await FetchForexAsync(symbol, provider!, providers, context, ct),
|
||||
"search" => await SearchSymbolAsync(symbol, provider!, providers, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler in DirectAPI");
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> FetchQuoteAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
http.DefaultRequestHeaders.Add("Cache-Control", "no-cache");
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
|
||||
if (provider == "twelvedata")
|
||||
{
|
||||
var apiKey = GetApiKey(providers, "twelvedata");
|
||||
var url = $"https://api.twelvedata.com/quote?symbol={symbol}&apikey={apiKey}";
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
if (json.Value.TryGetProperty("code", out var code) && code.GetInt32() != 200)
|
||||
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
|
||||
|
||||
DateTime? dataAsOf = null;
|
||||
if (json.Value.TryGetProperty("timestamp", out var ts))
|
||||
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
||||
|
||||
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
|
||||
}
|
||||
else if (provider == "alphavantage")
|
||||
{
|
||||
var apiKey = GetApiKey(providers, "alphavantage");
|
||||
var url = $"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol={symbol}&apikey={apiKey}";
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
if (json.Value.TryGetProperty("Note", out var note))
|
||||
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
|
||||
if (json.Value.TryGetProperty("Error Message", out var err))
|
||||
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
|
||||
|
||||
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
|
||||
}
|
||||
else if (provider == "massive")
|
||||
{
|
||||
var apiKey = GetApiKey(providers, "massive");
|
||||
var url = $"https://api.massive.com/v3/snapshot?ticker={symbol}";
|
||||
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
return CreateSuccessResult(fetchedAt, fetchedAt, url, json.Value);
|
||||
}
|
||||
else if (provider == "yahoo")
|
||||
{
|
||||
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval=1m&range=1d";
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
|
||||
!chartProp.TryGetProperty("result", out var resultArr) ||
|
||||
resultArr.GetArrayLength() == 0)
|
||||
return ToolResult.Fail($"Yahoo Finance: Keine Daten für '{symbol}' erhalten.");
|
||||
|
||||
var chart = resultArr[0];
|
||||
var meta = chart.GetProperty("meta");
|
||||
|
||||
DateTime? dataAsOf = null;
|
||||
if (meta.TryGetProperty("regularMarketTime", out var ts))
|
||||
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
||||
|
||||
return CreateSuccessResult(fetchedAt, dataAsOf, url, chart);
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"Provider '{provider}' wird für 'quote' noch nicht unterstützt.");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> FetchHistoryAsync(string symbol, JsonElement input, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var interval = input.TryGetProperty("interval", out var i) ? i.GetString() : "1day";
|
||||
var outputSize = input.TryGetProperty("outputsize", out var o) ? o.GetInt32() : 30;
|
||||
using var http = new HttpClient();
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
|
||||
if (provider == "twelvedata")
|
||||
{
|
||||
var apiKey = GetApiKey(providers, "twelvedata");
|
||||
var url = $"https://api.twelvedata.com/time_series?symbol={symbol}&interval={interval}&outputsize={outputSize}&apikey={apiKey}";
|
||||
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
if (json.Value.TryGetProperty("status", out var status) && status.GetString() != "ok")
|
||||
return ToolResult.Fail(json.Value.GetProperty("message").GetString() ?? "API Fehler");
|
||||
|
||||
return CreateSuccessResult(fetchedAt, null, url, json.Value);
|
||||
}
|
||||
else if (provider == "alphavantage")
|
||||
{
|
||||
var apiKey = GetApiKey(providers, "alphavantage");
|
||||
var function_ = interval switch
|
||||
{
|
||||
"1min" or "5min" or "15min" or "30min" or "1h" => "TIME_SERIES_INTRADAY",
|
||||
_ => "TIME_SERIES_DAILY"
|
||||
};
|
||||
var avInterval = interval switch
|
||||
{
|
||||
"1min" => "1min", "5min" => "5min", "15min" => "15min", "30min" => "30min", "1h" => "60min",
|
||||
_ => ""
|
||||
};
|
||||
var urlBase = $"https://www.alphavantage.co/query?function={function_}&symbol={symbol}&apikey={apiKey}&outputsize=compact";
|
||||
if (!string.IsNullOrEmpty(avInterval)) urlBase += $"&interval={avInterval}";
|
||||
|
||||
var (json, error) = await SafeGetJsonAsync(http, urlBase, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
if (json.Value.TryGetProperty("Note", out var note))
|
||||
return ToolResult.Fail($"Alpha Vantage Rate Limit: {note.GetString()}");
|
||||
if (json.Value.TryGetProperty("Error Message", out var err))
|
||||
return ToolResult.Fail($"Alpha Vantage Fehler: {err.GetString()}");
|
||||
|
||||
return CreateSuccessResult(fetchedAt, null, urlBase, json.Value);
|
||||
}
|
||||
else if (provider == "massive")
|
||||
{
|
||||
var apiKey = GetApiKey(providers, "massive");
|
||||
var timespan = interval switch
|
||||
{
|
||||
"1min" => "minute", "5min" => "minute", "15min" => "minute", "1h" => "hour", "1day" => "day",
|
||||
_ => "day"
|
||||
};
|
||||
var multiplier = interval switch
|
||||
{
|
||||
"5min" => 5, "15min" => 15, _ => 1
|
||||
};
|
||||
var from = DateTime.UtcNow.AddDays(-outputSize).ToString("yyyy-MM-dd");
|
||||
var to = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
var url = $"https://api.massive.com/v2/aggs/ticker/{symbol}/range/{multiplier}/{timespan}/{from}/{to}?limit={outputSize}";
|
||||
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
|
||||
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
return CreateSuccessResult(fetchedAt, null, url, json.Value);
|
||||
}
|
||||
else if (provider == "yahoo")
|
||||
{
|
||||
var yahooInterval = interval switch
|
||||
{
|
||||
"1min" => "1m", "5min" => "5m", "15min" => "15m", "1h" => "1h", "1day" => "1d",
|
||||
_ => "1d"
|
||||
};
|
||||
var range = interval is "1min" or "5min" or "15min" ? "1d" : $"{outputSize}d";
|
||||
var url = $"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?interval={yahooInterval}&range={range}";
|
||||
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
if (!json.Value.TryGetProperty("chart", out var chartProp) ||
|
||||
!chartProp.TryGetProperty("result", out var resultArr) ||
|
||||
resultArr.GetArrayLength() == 0)
|
||||
return ToolResult.Fail($"Yahoo Finance: Keine Historien-Daten für '{symbol}' erhalten.");
|
||||
|
||||
var chart = resultArr[0];
|
||||
return CreateSuccessResult(fetchedAt, null, url, chart);
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"Provider '{provider}' wird für 'history' noch nicht unterstützt.");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> FetchCryptoAsync(string symbol, JsonElement? providers, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
var url = $"https://api.coingecko.com/api/v3/simple/price?ids={symbol.ToLowerInvariant()}&vs_currencies=usd&include_last_updated_at=true";
|
||||
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
if (!json.Value.TryGetProperty(symbol.ToLowerInvariant(), out var data))
|
||||
return ToolResult.Fail($"Symbol '{symbol}' nicht gefunden.");
|
||||
|
||||
DateTime? dataAsOf = null;
|
||||
if (data.TryGetProperty("last_updated_at", out var ts))
|
||||
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
||||
|
||||
return CreateSuccessResult(fetchedAt, dataAsOf, url, data);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> FetchForexAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (provider != "twelvedata") return ToolResult.Fail("Forex wird aktuell nur über twelvedata unterstützt.");
|
||||
|
||||
var apiKey = GetApiKey(providers, "twelvedata");
|
||||
using var http = new HttpClient();
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
var url = $"https://api.twelvedata.com/exchange_rate?symbol={symbol}&apikey={apiKey}";
|
||||
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
|
||||
DateTime? dataAsOf = null;
|
||||
if (json.Value.TryGetProperty("timestamp", out var ts))
|
||||
dataAsOf = DateTimeOffset.FromUnixTimeSeconds(ts.GetInt64()).UtcDateTime;
|
||||
|
||||
return CreateSuccessResult(fetchedAt, dataAsOf, url, json.Value);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> SearchSymbolAsync(string symbol, string provider, JsonElement? providers, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (provider != "twelvedata") return ToolResult.Fail("Search wird aktuell nur über twelvedata unterstützt.");
|
||||
|
||||
var apiKey = GetApiKey(providers, "twelvedata");
|
||||
using var http = new HttpClient();
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
var url = $"https://api.twelvedata.com/symbol_search?symbol={symbol}"; // search often doesn't need key or uses same
|
||||
|
||||
var (json, error) = await SafeGetJsonAsync(http, url, ct);
|
||||
if (error != null) return error;
|
||||
return CreateSuccessResult(fetchedAt, null, url, json.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sichere JSON-Antwort von einer API abrufen.
|
||||
/// Prüft HTTP-Status und ob die Antwort tatsächlich JSON ist, bevor geparst wird.
|
||||
/// </summary>
|
||||
private static async Task<(JsonElement? json, ToolResult? error)> SafeGetJsonAsync(
|
||||
HttpClient http, string url, CancellationToken ct)
|
||||
{
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await http.GetAsync(url, ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return (null, ToolResult.Fail($"API nicht erreichbar: {url} → {ex.Message}"));
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
||||
return (null, ToolResult.Fail(
|
||||
$"API Fehler: HTTP {(int)response.StatusCode} {response.ReasonPhrase} von {url}\nAntwort: {preview}"));
|
||||
}
|
||||
|
||||
// Prüfen ob die Antwort überhaupt JSON ist
|
||||
var trimmed = body.TrimStart();
|
||||
if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
|
||||
{
|
||||
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
||||
return (null, ToolResult.Fail(
|
||||
$"API hat kein JSON zurückgegeben ({url}). Antwort: {preview}"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonDocument.Parse(body).RootElement.Clone();
|
||||
return (json, null);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var preview = body.Length > 300 ? body[..300] + "…" : body;
|
||||
return (null, ToolResult.Fail(
|
||||
$"Ungültiges JSON von {url}: {ex.Message}\nAntwort: {preview}"));
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetApiKey(JsonElement? providers, string name)
|
||||
{
|
||||
if (providers == null) return null;
|
||||
if (providers.Value.TryGetProperty(name, out var p) && p.TryGetProperty("apiKey", out var k))
|
||||
return k.GetString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private ToolResult CreateSuccessResult(DateTime fetchedAt, DateTime? dataAsOf, string source, object data)
|
||||
{
|
||||
var result = new
|
||||
{
|
||||
fetchedAt = fetchedAt,
|
||||
dataAsOf = dataAsOf,
|
||||
source = source,
|
||||
data = data
|
||||
};
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.FTP;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "FTP/SFTP Upload/Download";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.FTP</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentFTP" Version="54.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\ClawdDotNet.Core\\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,346 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using FluentFTP;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.FTP;
|
||||
|
||||
public sealed class FTPTool : IAgentTool
|
||||
{
|
||||
public string Name => "FTP";
|
||||
|
||||
public string Description => """
|
||||
Erlaubt den Dateitransfer zwischen dem lokalen Workspace und einem Remote-FTP/FTPS-Server.
|
||||
|
||||
Für localPath werden die gleichen Workspace-Prefixe wie beim FileRW-Tool unterstützt:
|
||||
- "personal:website/index.html" → Datei im persönlichen Agent-Workspace
|
||||
- "shared:website/index.html" → Datei im geteilten SharedWorkspace
|
||||
- Ohne Prefix wird der persönliche Workspace verwendet.
|
||||
|
||||
Beispiel Upload: { "action": "upload", "localPath": "shared:website/index.html", "remotePath": "/index.html" }
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["upload", "download", "list", "delete"],
|
||||
"description": "Die auszuführende Aktion"
|
||||
},
|
||||
"localPath": {
|
||||
"type": "string",
|
||||
"description": "Pfad zur lokalen Datei mit optionalem Workspace-Prefix: 'personal:datei.txt' (Standard) oder 'shared:datei.txt' für den SharedWorkspace. Ohne Prefix wird der persönliche Workspace verwendet."
|
||||
},
|
||||
"remotePath": {
|
||||
"type": "string",
|
||||
"description": "Pfad auf dem FTP-Server"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
var config = context.ToolConfig;
|
||||
var host = config.TryGetValue("host", out var h) ? h?.ToString() : null;
|
||||
var username = config.TryGetValue("username", out var u) ? u?.ToString() : null;
|
||||
var password = config.TryGetValue("password", out var p) ? p?.ToString() : null;
|
||||
var port = 21;
|
||||
if (config.TryGetValue("port", out var po) && po is JsonElement portElement)
|
||||
port = portElement.TryGetInt32(out var p32) ? p32 : 21;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
return ToolResult.Fail("Konfigurationsfehler: 'host' ist erforderlich.");
|
||||
}
|
||||
|
||||
// SSL/TLS-Modus aus Config lesen (Standard: true)
|
||||
var useSsl = true;
|
||||
if (config.TryGetValue("useSsl", out var sslVal))
|
||||
{
|
||||
if (sslVal is JsonElement sslElem) useSsl = sslElem.GetBoolean();
|
||||
else if (sslVal is bool sslBool) useSsl = sslBool;
|
||||
}
|
||||
|
||||
using var ftpClient = new AsyncFtpClient(host, username, password, port);
|
||||
|
||||
if (useSsl)
|
||||
{
|
||||
ftpClient.Config.EncryptionMode = FtpEncryptionMode.Explicit;
|
||||
ftpClient.Config.ValidateAnyCertificate = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ftpClient.Connect(ct);
|
||||
|
||||
return action switch
|
||||
{
|
||||
"upload" => await HandleUploadAsync(ftpClient, input, context, ct),
|
||||
"download" => await HandleDownloadAsync(ftpClient, input, context, ct),
|
||||
"list" => await HandleListAsync(ftpClient, input, context, ct),
|
||||
"delete" => await HandleDeleteAsync(ftpClient, input, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler bei FTP Aktion {Action} auf {Host}", action, host);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ftpClient.IsConnected) await ftpClient.Disconnect(ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst einen localPath mit optionalem Workspace-Prefix auf.
|
||||
/// "shared:website/index.html" → SharedWorkspace + "website/index.html"
|
||||
/// "personal:datei.txt" → persönlicher Workspace + "datei.txt"
|
||||
/// "datei.txt" → persönlicher Workspace + "datei.txt" (Standard)
|
||||
/// </summary>
|
||||
private (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context)
|
||||
{
|
||||
string basePath;
|
||||
string relativePath;
|
||||
string label;
|
||||
|
||||
if (localPath.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relativePath = localPath[7..]; // nach "shared:"
|
||||
basePath = context.SharedWorkspacePath
|
||||
?? throw new ArgumentException("Kein SharedWorkspace konfiguriert.");
|
||||
label = "shared";
|
||||
}
|
||||
else if (localPath.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relativePath = localPath[9..]; // nach "personal:"
|
||||
basePath = context.WorkspacePath
|
||||
?? throw new ArgumentException("Kein persönlicher Workspace konfiguriert.");
|
||||
label = "personal";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Kein Prefix → persönlicher Workspace als Standard
|
||||
relativePath = localPath;
|
||||
basePath = context.WorkspacePath
|
||||
?? throw new ArgumentException("Kein persönlicher Workspace konfiguriert.");
|
||||
label = "personal";
|
||||
}
|
||||
|
||||
// Config-rootPath als Override (nur wenn kein Prefix verwendet)
|
||||
if (label == "personal" && !localPath.Contains(':'))
|
||||
{
|
||||
if (context.ToolConfig.TryGetValue("rootPath", out var rp) && !string.IsNullOrWhiteSpace(rp?.ToString()))
|
||||
{
|
||||
basePath = rp.ToString()!;
|
||||
}
|
||||
}
|
||||
|
||||
basePath = Path.GetFullPath(basePath);
|
||||
var fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
|
||||
|
||||
// Sicherheitsprüfung: Kein Ausbruch aus dem Workspace
|
||||
if (!fullPath.StartsWith(basePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Zugriff außerhalb des {label} Workspace-Verzeichnisses verweigert.");
|
||||
}
|
||||
|
||||
return (fullPath, label);
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleUploadAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var localRel = input.TryGetProperty("localPath", out var lp) ? lp.GetString() : null;
|
||||
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(localRel) || string.IsNullOrWhiteSpace(remote))
|
||||
{
|
||||
return ToolResult.Fail("'localPath' und 'remotePath' sind für upload erforderlich.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (localFull, wsLabel) = ResolveLocalPath(localRel, context);
|
||||
|
||||
if (!File.Exists(localFull))
|
||||
return ToolResult.Fail($"Lokale Datei nicht gefunden: {localRel} (aufgelöst: {localFull})");
|
||||
|
||||
var status = await client.UploadFile(localFull, remote, FtpRemoteExists.Overwrite, true, FtpVerify.None, null, ct);
|
||||
if (status != FtpStatus.Success)
|
||||
return ToolResult.Fail($"Upload fehlgeschlagen: {status}");
|
||||
|
||||
// Bei HTML/CSS/JS-Uploads: .htaccess für UTF-8 sicherstellen
|
||||
var extra = "";
|
||||
if (IsWebFile(remote))
|
||||
{
|
||||
var htaccessDeployed = await EnsureHtaccessAsync(client, remote, context.Logger, ct);
|
||||
if (htaccessDeployed)
|
||||
extra = " (.htaccess für UTF-8-Encoding wurde automatisch erstellt)";
|
||||
}
|
||||
|
||||
return ToolResult.Ok($"[{wsLabel} Workspace] Datei erfolgreich hochgeladen: {localRel} → {remote}{extra}");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ToolResult.Fail(ex.Message);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return ToolResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWebFile(string remotePath)
|
||||
{
|
||||
var ext = Path.GetExtension(remotePath).ToLowerInvariant();
|
||||
return ext is ".html" or ".htm" or ".css" or ".js" or ".json" or ".xml" or ".svg";
|
||||
}
|
||||
|
||||
private const string HtaccessCharsetMarker = "# ClawdDotNet-UTF8-Charset";
|
||||
private const string HtaccessCharsetBlock = $"""
|
||||
|
||||
{HtaccessCharsetMarker}
|
||||
AddDefaultCharset UTF-8
|
||||
<IfModule mod_mime.c>
|
||||
AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt
|
||||
</IfModule>
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Stellt sicher, dass im Zielverzeichnis eine .htaccess mit UTF-8 Charset existiert.
|
||||
/// - Existiert keine .htaccess: Neue mit nur den Charset-Regeln erstellen.
|
||||
/// - Existiert eine .htaccess: Charset-Block anhängen, falls der Marker noch nicht drin ist.
|
||||
/// Der bestehende Inhalt (z.B. Zugriffsschutz, Rewrite-Regeln) bleibt IMMER unangetastet.
|
||||
/// </summary>
|
||||
private async Task<bool> EnsureHtaccessAsync(IAsyncFtpClient client, string remotePath, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var remoteDir = Path.GetDirectoryName(remotePath)?.Replace('\\', '/') ?? "/";
|
||||
if (string.IsNullOrWhiteSpace(remoteDir)) remoteDir = "/";
|
||||
var htaccessPath = remoteDir.TrimEnd('/') + "/.htaccess";
|
||||
|
||||
if (await client.FileExists(htaccessPath, ct))
|
||||
{
|
||||
// .htaccess existiert — lesen und prüfen ob UTF-8-Block schon drin ist
|
||||
using var downloadStream = new MemoryStream();
|
||||
if (!await client.DownloadStream(downloadStream, htaccessPath, token: ct))
|
||||
return false; // Download fehlgeschlagen → nichts tun
|
||||
|
||||
downloadStream.Position = 0;
|
||||
var existingContent = Encoding.UTF8.GetString(downloadStream.ToArray());
|
||||
|
||||
// Wenn unser Marker bereits enthalten ist → nichts tun
|
||||
if (existingContent.Contains(HtaccessCharsetMarker, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
// Wenn bereits AddDefaultCharset gesetzt ist → nicht einmischen
|
||||
if (existingContent.Contains("AddDefaultCharset", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
// Charset-Block an bestehenden Inhalt anhängen
|
||||
var newContent = existingContent.TrimEnd() + "\n" + HtaccessCharsetBlock;
|
||||
var bytes = Encoding.UTF8.GetBytes(newContent);
|
||||
using var uploadStream = new MemoryStream(bytes);
|
||||
var status = await client.UploadStream(uploadStream, htaccessPath, FtpRemoteExists.Overwrite, true, null, ct);
|
||||
|
||||
if (status == FtpStatus.Success)
|
||||
{
|
||||
logger.LogInformation("UTF-8 Charset-Block an bestehende .htaccess angehängt: {Path}", htaccessPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keine .htaccess vorhanden → neue erstellen (nur Charset-Regeln)
|
||||
var content = HtaccessCharsetMarker + "\nAddDefaultCharset UTF-8\n\n<IfModule mod_mime.c>\n AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt\n</IfModule>\n";
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
using var stream = new MemoryStream(bytes);
|
||||
var status = await client.UploadStream(stream, htaccessPath, FtpRemoteExists.Skip, true, null, ct);
|
||||
|
||||
if (status == FtpStatus.Success)
|
||||
{
|
||||
logger.LogInformation("Neue UTF-8 .htaccess erstellt: {Path}", htaccessPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Konnte .htaccess nicht automatisch anpassen (nicht kritisch)");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleDownloadAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var localRel = input.TryGetProperty("localPath", out var lp) ? lp.GetString() : null;
|
||||
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(localRel) || string.IsNullOrWhiteSpace(remote))
|
||||
{
|
||||
return ToolResult.Fail("'localPath' und 'remotePath' sind für download erforderlich.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (localFull, wsLabel) = ResolveLocalPath(localRel, context);
|
||||
|
||||
var localDir = Path.GetDirectoryName(localFull);
|
||||
if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir);
|
||||
|
||||
var status = await client.DownloadFile(localFull, remote, FtpLocalExists.Overwrite, FtpVerify.None, null, ct);
|
||||
return status == FtpStatus.Success
|
||||
? ToolResult.Ok($"[{wsLabel} Workspace] Datei erfolgreich heruntergeladen: {remote} → {localRel}")
|
||||
: ToolResult.Fail($"Download fehlgeschlagen: {status}");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ToolResult.Fail(ex.Message);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return ToolResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleListAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : "/";
|
||||
var items = await client.GetListing(remote, FtpListOption.Recursive, ct);
|
||||
|
||||
var result = items.Select(i => new
|
||||
{
|
||||
i.Name,
|
||||
i.FullName,
|
||||
Type = i.Type.ToString(),
|
||||
Size = i.Size,
|
||||
Modified = i.Modified
|
||||
}).ToList();
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleDeleteAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(remote)) return ToolResult.Fail("'remotePath' ist für delete erforderlich.");
|
||||
|
||||
await client.DeleteFile(remote, ct);
|
||||
return ToolResult.Ok($"Datei/Verzeichnis gelöscht: {remote}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.FileRW;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 3;
|
||||
public const string Changes = "Workspace-Labels in allen Responses, verbesserte Description, Copy-Aktion";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.FileRW</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,601 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.FileRW;
|
||||
|
||||
public sealed class FileRWTool : IAgentTool
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten
|
||||
/// gleichzeitig auf dieselbe Datei zugreifen (read/write/append/delete/copy).
|
||||
/// Key = normalisierter absoluter Pfad (lowercase), Value = SemaphoreSlim(1,1).
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new();
|
||||
|
||||
private static SemaphoreSlim GetFileLock(string fullPath)
|
||||
=> FileLocks.GetOrAdd(NormalizePath(fullPath), _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
=> Path.GetFullPath(path).ToLowerInvariant();
|
||||
|
||||
public string Name => "FileRW";
|
||||
|
||||
public string Description => "Dateiverwaltung in zwei getrennten Workspace-Verzeichnissen:\n" +
|
||||
"- workspace='personal': Dein PRIVATER Ordner — nur du hast Zugriff.\n" +
|
||||
"- workspace='shared': GETEILTER Ordner — alle Agenten im Team können darauf zugreifen.\n" +
|
||||
"Aktionen: read, write, append, list, delete, copy, stock_add.\n" +
|
||||
"Bei copy kann zwischen Workspaces kopiert werden.\n" +
|
||||
"'stock_add' fügt einen neuen Datenpunkt zur Aktien-Wissensdatenbank hinzu (shared:stocks/{ticker}/). " +
|
||||
"Geschützte Pfade (protectedPaths) erlauben nur das Erstellen neuer Dateien — kein Überschreiben oder Löschen.";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["read", "write", "append", "list", "delete", "copy", "stock_add"],
|
||||
"description": "Die auszuführende Aktion. 'stock_add' fügt einen strukturierten Datenpunkt zur Aktien-Wissensdatenbank hinzu."
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"enum": ["personal", "shared"],
|
||||
"description": "In welchem Workspace die Aktion ausgeführt werden soll (Quell-Workspace bei copy). Standard ist 'personal'."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Relativer Pfad zur Datei oder zum Verzeichnis"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Inhalt für write oder append Aktionen"
|
||||
},
|
||||
"destinationWorkspace": {
|
||||
"type": "string",
|
||||
"enum": ["personal", "shared"],
|
||||
"description": "Ziel-Workspace für copy. Standard ist gleicher Workspace wie 'workspace'."
|
||||
},
|
||||
"destinationPath": {
|
||||
"type": "string",
|
||||
"description": "Relativer Zielpfad für copy. Pflichtfeld bei copy."
|
||||
},
|
||||
"ticker": {
|
||||
"type": "string",
|
||||
"description": "Aktien-Ticker für stock_add (z.B. 'NVDA', 'TSLA'). Wird automatisch zu Großbuchstaben."
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["news", "social", "analysis", "capitol", "price", "earnings", "filing", "sentiment", "other"],
|
||||
"description": "Kategorie des Datenpunkts für stock_add."
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Quelle des Datenpunkts für stock_add (z.B. 'reuters', 'x_unusual_whales', 'youtube', 'reddit')."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Kurze Überschrift/Zusammenfassung des Datenpunkts für stock_add."
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Strukturierte Daten des Datenpunkts für stock_add. Kann beliebige Felder enthalten."
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
var workspace = (input.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
|
||||
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
var wsName = workspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace";
|
||||
return ToolResult.Fail($"Konfigurationsfehler: '{wsName}' ist nicht definiert.");
|
||||
}
|
||||
|
||||
// Sicherstellen, dass rootPath absolut ist
|
||||
rootPath = Path.GetFullPath(rootPath);
|
||||
|
||||
// Zugriffsprüfung
|
||||
if (!IsActionAllowed(action, workspace, context))
|
||||
{
|
||||
return ToolResult.Fail($"Zugriff verweigert: Die Aktion '{action}' ist im Workspace '{workspace}' für diesen Agenten nicht erlaubt.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"read" => await HandleReadAsync(input, rootPath, workspace, context, ct),
|
||||
"write" => await HandleWriteAsync(input, rootPath, workspace, context, ct),
|
||||
"append" => await HandleAppendAsync(input, rootPath, workspace, context, ct),
|
||||
"list" => await HandleListAsync(input, rootPath, workspace, context, ct),
|
||||
"delete" => await HandleDeleteAsync(input, rootPath, workspace, context, ct),
|
||||
"copy" => await HandleCopyAsync(input, rootPath, workspace, context, ct),
|
||||
"stock_add" => await HandleStockAddAsync(input, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler bei FileRW Aktion {Action}", action);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsActionAllowed(string action, string workspace, AgentToolContext context)
|
||||
{
|
||||
if (workspace == "personal") return true; // Alles erlaubt
|
||||
|
||||
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
|
||||
|
||||
if (level == "Denied") return false;
|
||||
|
||||
return action switch
|
||||
{
|
||||
"read" or "list" or "copy" => true,
|
||||
"write" or "append" or "stock_add" => level is "ReadWrite" or "Admin",
|
||||
"delete" => level is "Admin",
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ob ein Pfad innerhalb eines geschützten Bereichs liegt.
|
||||
/// Geschützte Pfade erlauben nur: read, list, append, write (nur neue Dateien), stock_add.
|
||||
/// Kein Überschreiben existierender Dateien, kein Löschen.
|
||||
/// Admin-Level umgeht den Schutz.
|
||||
/// </summary>
|
||||
private bool IsPathProtected(string fullPath, string rootPath, AgentToolContext context)
|
||||
{
|
||||
var protectedPaths = GetProtectedPaths(context);
|
||||
if (protectedPaths.Count == 0) return false;
|
||||
|
||||
var relativePath = Path.GetRelativePath(rootPath, fullPath)
|
||||
.Replace('\\', '/').TrimStart('/');
|
||||
|
||||
return protectedPaths.Any(pp =>
|
||||
relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool IsAdminLevel(AgentToolContext context)
|
||||
{
|
||||
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
|
||||
return level == "Admin";
|
||||
}
|
||||
|
||||
private static List<string> GetProtectedPaths(AgentToolContext context)
|
||||
{
|
||||
if (!context.ToolConfig.TryGetValue("protectedPaths", out var pp) || pp is null)
|
||||
return [];
|
||||
|
||||
if (pp is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
return je.EnumerateArray()
|
||||
.Select(e => e.GetString()?.Replace('\\', '/').Trim().TrimEnd('/') + "/")
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.ToList()!;
|
||||
|
||||
if (pp is string s && !string.IsNullOrWhiteSpace(s))
|
||||
return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(p => p.Replace('\\', '/').TrimEnd('/') + "/")
|
||||
.ToList();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
|
||||
{
|
||||
var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : "";
|
||||
if (string.IsNullOrWhiteSpace(relativePath))
|
||||
{
|
||||
relativePath = ".";
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(rootPath, relativePath));
|
||||
|
||||
// Path Traversal Check
|
||||
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"Zugriff verweigert: Der Pfad liegt außerhalb des {workspace} Workspaces.");
|
||||
}
|
||||
|
||||
// Extension Check
|
||||
if (checkExtension && !Directory.Exists(fullPath))
|
||||
{
|
||||
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
|
||||
|
||||
var configKey = workspace == "shared" ? "sharedAllowedExtensions" : "personalAllowedExtensions";
|
||||
|
||||
var allowedExtensions = context.ToolConfig.TryGetValue(configKey, out var ae) && ae is JsonElement je
|
||||
? je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList()
|
||||
: new List<string?> { ".txt", ".json", ".md", ".html", ".js", ".css" }; // Default
|
||||
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"Dateiendung '{extension}' ist im Workspace '{workspace}' nicht erlaubt.");
|
||||
}
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
if (!File.Exists(path)) return ToolResult.Fail($"Datei nicht gefunden im {workspace} Workspace: {Path.GetRelativePath(rootPath, path)}");
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
await fileLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, ct);
|
||||
var relativePath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Ok($"[{workspace}:/{relativePath}]\n{content}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
await fileLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// Schutz: In geschützten Bereichen darf nur geschrieben werden wenn die Datei NICHT existiert
|
||||
if (workspace == "shared" && !IsAdminLevel(context) && File.Exists(path) && IsPathProtected(path, rootPath, context))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Fail(
|
||||
$"🛡️ Geschützter Bereich: Die Datei '{relPath}' existiert bereits und darf nicht überschrieben werden. " +
|
||||
$"Verwende 'append' um Daten hinzuzufügen, oder erstelle eine neue Datei mit anderem Namen. " +
|
||||
$"Tipp: Nutze 'stock_add' für strukturierte Datenpunkte.");
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Datei erfolgreich geschrieben: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
|
||||
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
await fileLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await File.AppendAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
if (!Directory.Exists(path)) return ToolResult.Fail($"Verzeichnis nicht gefunden im {workspace} Workspace.");
|
||||
|
||||
var relativeDirPath = Path.GetRelativePath(rootPath, path);
|
||||
var entries = Directory.GetFileSystemEntries(path)
|
||||
.Select(e => new
|
||||
{
|
||||
Name = Path.GetFileName(e),
|
||||
Type = Directory.Exists(e) ? "directory" : "file",
|
||||
Size = Directory.Exists(e) ? 0 : new FileInfo(e).Length,
|
||||
LastModified = File.GetLastWriteTime(e)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var header = $"[{workspace} Workspace] Verzeichnis: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}\n";
|
||||
return ToolResult.Ok(header + JsonSerializer.Serialize(entries, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
|
||||
|
||||
// Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin)
|
||||
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(rootPath, path);
|
||||
return ToolResult.Fail(
|
||||
$"🛡️ Geschützter Bereich: '{relPath}' liegt in einem geschützten Verzeichnis und darf nicht gelöscht werden. " +
|
||||
$"Daten in geschützten Bereichen sind append-only — sie können nur ergänzt, nicht entfernt werden.");
|
||||
}
|
||||
|
||||
var fileLock = GetFileLock(path);
|
||||
await fileLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Datei gelöscht: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
else if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
return ToolResult.Ok($"[{workspace} Workspace] Verzeichnis gelöscht: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
|
||||
}
|
||||
|
||||
return ToolResult.Fail($"Datei oder Verzeichnis nicht gefunden im {workspace} Workspace.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, string sourceWorkspace, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceWorkspace, false, context);
|
||||
|
||||
var destWorkspace = (input.TryGetProperty("destinationWorkspace", out var dw) ? dw.GetString() : null) ?? sourceWorkspace;
|
||||
var destRelPath = input.TryGetProperty("destinationPath", out var dp) ? dp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(destRelPath))
|
||||
return ToolResult.Fail("'destinationPath' ist für die copy-Aktion erforderlich.");
|
||||
|
||||
var destRootPath = destWorkspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
if (string.IsNullOrWhiteSpace(destRootPath))
|
||||
return ToolResult.Fail($"Konfigurationsfehler: '{(destWorkspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace")}' ist nicht definiert.");
|
||||
|
||||
destRootPath = Path.GetFullPath(destRootPath);
|
||||
|
||||
if (!IsActionAllowed("write", destWorkspace, context))
|
||||
return ToolResult.Fail($"Zugriff verweigert: Schreiben im Workspace '{destWorkspace}' ist für diesen Agenten nicht erlaubt.");
|
||||
|
||||
var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement;
|
||||
var destPath = GetAndValidatePath(destInput, destRootPath, destWorkspace, false, context);
|
||||
|
||||
if (File.Exists(sourcePath))
|
||||
{
|
||||
// Schutz: Kein Überschreiben in geschützten Bereichen per Copy
|
||||
if (destWorkspace == "shared" && !IsAdminLevel(context) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, context))
|
||||
{
|
||||
var relPath = Path.GetRelativePath(destRootPath, destPath);
|
||||
return ToolResult.Fail(
|
||||
$"🛡️ Geschützter Bereich: Die Zieldatei '{relPath}' existiert bereits und darf nicht überschrieben werden.");
|
||||
}
|
||||
|
||||
// Deadlock-sicheres Locking: Immer in alphabetischer Reihenfolge locken
|
||||
var srcNorm = NormalizePath(sourcePath);
|
||||
var dstNorm = NormalizePath(destPath);
|
||||
var first = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0
|
||||
? GetFileLock(sourcePath) : GetFileLock(destPath);
|
||||
var second = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0
|
||||
? GetFileLock(destPath) : GetFileLock(sourcePath);
|
||||
|
||||
await first.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await second.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var destDir = Path.GetDirectoryName(destPath);
|
||||
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
|
||||
|
||||
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, context)), ct);
|
||||
|
||||
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
return ToolResult.Ok($"Datei kopiert: {srcLabel} → {dstLabel}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
second.Release();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
first.Release();
|
||||
}
|
||||
}
|
||||
else if (Directory.Exists(sourcePath))
|
||||
{
|
||||
await Task.Run(() => CopyDirectory(sourcePath, destPath), ct);
|
||||
|
||||
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||
return ToolResult.Ok($"Verzeichnis kopiert: {srcLabel} → {dstLabel}");
|
||||
}
|
||||
|
||||
return ToolResult.Fail("Quelldatei oder -verzeichnis nicht gefunden.");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// STOCK DATABASE (Append-Only Wissensdatenbank)
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
private static readonly JsonSerializerOptions StockJsonOpts = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private async Task<ToolResult> HandleStockAddAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
// Pflichtfelder
|
||||
var ticker = (input.TryGetProperty("ticker", out var t) ? t.GetString() : null)?.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(ticker))
|
||||
return ToolResult.Fail("'ticker' ist ein Pflichtfeld für stock_add (z.B. 'NVDA', 'TSLA').");
|
||||
|
||||
var category = (input.TryGetProperty("category", out var cat) ? cat.GetString() : null) ?? "other";
|
||||
var source = (input.TryGetProperty("source", out var src) ? src.GetString() : null) ?? "unknown";
|
||||
var title = (input.TryGetProperty("title", out var ttl) ? ttl.GetString() : null) ?? "";
|
||||
var content = input.TryGetProperty("content", out var cnt) ? cnt.GetString() : null;
|
||||
var data = input.TryGetProperty("data", out var d) ? d : (JsonElement?)null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(content) && data is null)
|
||||
return ToolResult.Fail("Mindestens 'title', 'content' oder 'data' muss angegeben werden.");
|
||||
|
||||
// SharedWorkspace ist Pflicht für stocks
|
||||
var sharedRoot = context.SharedWorkspacePath;
|
||||
if (string.IsNullOrWhiteSpace(sharedRoot))
|
||||
return ToolResult.Fail("SharedWorkspace ist nicht konfiguriert.");
|
||||
|
||||
sharedRoot = Path.GetFullPath(sharedRoot);
|
||||
|
||||
// Zugriffsprüfung
|
||||
if (!IsActionAllowed("stock_add", "shared", context))
|
||||
return ToolResult.Fail("Zugriff verweigert: Schreiben im SharedWorkspace nicht erlaubt.");
|
||||
|
||||
// Sanitize Ticker für Verzeichnisname
|
||||
var safeTicker = SanitizeFileName(ticker);
|
||||
var stockDir = Path.Combine(sharedRoot, "stocks", safeTicker);
|
||||
Directory.CreateDirectory(stockDir);
|
||||
|
||||
// Timestamp-basierten Dateinamen generieren
|
||||
var now = DateTime.UtcNow;
|
||||
var timestamp = now.ToString("yyyyMMdd_HHmmss");
|
||||
var safeSource = SanitizeFileName(source);
|
||||
var fileName = $"{timestamp}_{category}_{safeSource}.json";
|
||||
var filePath = Path.Combine(stockDir, fileName);
|
||||
|
||||
// Kollisionsvermeidung
|
||||
var counter = 1;
|
||||
while (File.Exists(filePath))
|
||||
{
|
||||
fileName = $"{timestamp}_{category}_{safeSource}_{counter}.json";
|
||||
filePath = Path.Combine(stockDir, fileName);
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Datenpunkt erstellen
|
||||
var entry = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = $"{safeTicker}_{timestamp}_{category}_{safeSource}",
|
||||
["ticker"] = ticker,
|
||||
["category"] = category,
|
||||
["source"] = source,
|
||||
["title"] = title,
|
||||
["timestamp"] = now.ToString("o"),
|
||||
["agent"] = context.AgentId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
entry["content"] = content;
|
||||
|
||||
if (data.HasValue)
|
||||
entry["data"] = data.Value;
|
||||
|
||||
// Datenpunkt speichern
|
||||
var json = JsonSerializer.Serialize(entry, StockJsonOpts);
|
||||
await File.WriteAllTextAsync(filePath, json, new UTF8Encoding(false), ct);
|
||||
|
||||
// _index.json aktualisieren (thread-safe via per-file SemaphoreSlim, append-only)
|
||||
var indexPath = Path.Combine(stockDir, "_index.json");
|
||||
var indexEntry = new Dictionary<string, object?>
|
||||
{
|
||||
["file"] = fileName,
|
||||
["category"] = category,
|
||||
["source"] = source,
|
||||
["title"] = title,
|
||||
["timestamp"] = now.ToString("o"),
|
||||
["agent"] = context.AgentId
|
||||
};
|
||||
|
||||
var indexLock = GetFileLock(indexPath);
|
||||
await indexLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
List<Dictionary<string, object?>> index;
|
||||
if (File.Exists(indexPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await File.ReadAllTextAsync(indexPath, ct);
|
||||
index = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(existing, StockJsonOpts)
|
||||
?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Korrupter Index: Backup + Neustart
|
||||
var backup = indexPath + $".bak_{now:yyyyMMdd_HHmmss}";
|
||||
try { File.Copy(indexPath, backup, overwrite: true); } catch { /* best effort */ }
|
||||
index = [];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
index = [];
|
||||
}
|
||||
|
||||
index.Add(indexEntry);
|
||||
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(index, StockJsonOpts), new UTF8Encoding(false), ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
indexLock.Release();
|
||||
}
|
||||
|
||||
var relPath = $"shared:/stocks/{safeTicker}/{fileName}";
|
||||
return ToolResult.Ok(
|
||||
$"✅ Datenpunkt hinzugefügt: {relPath}\n" +
|
||||
$"Ticker: {ticker} | Kategorie: {category} | Quelle: {source}\n" +
|
||||
$"Titel: {title}\n" +
|
||||
$"Index: {index_Count(indexPath)} Einträge für {ticker}");
|
||||
}
|
||||
|
||||
private static int index_Count(string indexPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(indexPath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.GetArrayLength();
|
||||
}
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var sanitized = name.Trim();
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
sanitized = sanitized.Replace(c, '_');
|
||||
return sanitized.Replace(' ', '_');
|
||||
}
|
||||
|
||||
private static void CopyDirectory(string sourceDir, string destinationDir)
|
||||
{
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
foreach (var file in Directory.GetFiles(sourceDir))
|
||||
File.Copy(file, Path.Combine(destinationDir, Path.GetFileName(file)), overwrite: true);
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(sourceDir))
|
||||
CopyDirectory(dir, Path.Combine(destinationDir, Path.GetFileName(dir)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.Mail;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "IMAP/SMTP E-Mail senden und empfangen";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.Mail</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\ClawdDotNet.Core\\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,278 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Search;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
|
||||
using ClawdDotNet.Core.State;
|
||||
|
||||
namespace ClawdDotNet.Tools.Mail;
|
||||
|
||||
public sealed class MailTool : IAgentTool, IToolJobProvider
|
||||
{
|
||||
public string Name => "Mail";
|
||||
|
||||
public string Description => "Erlaubt das Senden und Empfangen von E-Mails über SMTP und IMAP.";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["send", "read_inbox", "read_message", "mark_read"],
|
||||
"description": "Die auszuführende Aktion"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Empfänger-E-Mail (nur für send)"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Betreff (nur für send)"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Inhalt der E-Mail (nur für send)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximale Anzahl an E-Mails zum Abrufen (nur für read_inbox)"
|
||||
},
|
||||
"messageId": {
|
||||
"type": "string",
|
||||
"description": "Die ID der Nachricht (für read_message oder mark_read)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"send" => await HandleSendAsync(input, context, ct),
|
||||
"read_inbox" => await HandleReadInboxAsync(input, context, ct),
|
||||
"read_message" => await HandleReadMessageAsync(input, context, ct),
|
||||
"mark_read" => await HandleMarkReadAsync(input, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler bei Mail Aktion {Action}", action);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleSendAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var to = input.TryGetProperty("to", out var t) ? t.GetString() : null;
|
||||
var subject = input.TryGetProperty("subject", out var s) ? s.GetString() : "(Kein Betreff)";
|
||||
var body = input.TryGetProperty("body", out var b) ? b.GetString() : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(to)) return ToolResult.Fail("'to' ist erforderlich.");
|
||||
|
||||
// Sicherheitsprüfung: Erlaubte Empfänger
|
||||
if (!IsRecipientAllowed(to, context))
|
||||
{
|
||||
return ToolResult.Fail($"Sicherheitsfehler: Senden an '{to}' ist nicht erlaubt.");
|
||||
}
|
||||
|
||||
var config = context.ToolConfig;
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(new MailboxAddress("ClawdDotNet", config["username"]?.ToString()));
|
||||
message.To.Add(MailboxAddress.Parse(to));
|
||||
message.Subject = subject;
|
||||
message.Body = new TextPart("plain") { Text = body };
|
||||
|
||||
using var client = new SmtpClient();
|
||||
await client.ConnectAsync(config["smtpHost"]?.ToString(), (config["smtpPort"] is JsonElement sp ? sp.GetInt32() : 587), true, ct);
|
||||
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
|
||||
await client.SendAsync(message, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
|
||||
return ToolResult.Ok($"E-Mail erfolgreich an {to} gesendet.");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleReadInboxAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var limit = input.TryGetProperty("limit", out var l) ? l.GetInt32() : 10;
|
||||
var config = context.ToolConfig;
|
||||
|
||||
using var client = new ImapClient();
|
||||
await client.ConnectAsync(config["imapHost"]?.ToString(), (config["imapPort"] is JsonElement ip ? ip.GetInt32() : 993), true, ct);
|
||||
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
|
||||
|
||||
await client.Inbox!.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||
var messages = new List<object>();
|
||||
|
||||
for (int i = client.Inbox!.Count - 1; i >= Math.Max(0, client.Inbox!.Count - limit); i--)
|
||||
{
|
||||
var msg = await client.Inbox!.GetMessageAsync(i, ct);
|
||||
messages.Add(new
|
||||
{
|
||||
Id = i.ToString(),
|
||||
Date = msg.Date,
|
||||
From = msg.From.ToString(),
|
||||
Subject = msg.Subject
|
||||
});
|
||||
}
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(messages, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleReadMessageAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (!input.TryGetProperty("messageId", out var mi) || !int.TryParse(mi.GetString(), out var index))
|
||||
{
|
||||
return ToolResult.Fail("'messageId' (Index) ist erforderlich.");
|
||||
}
|
||||
|
||||
var config = context.ToolConfig;
|
||||
using var client = new ImapClient();
|
||||
await client.ConnectAsync(config["imapHost"]?.ToString(), (config["imapPort"] is JsonElement ip ? ip.GetInt32() : 993), true, ct);
|
||||
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
|
||||
|
||||
await client.Inbox!.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||
var msg = await client.Inbox!.GetMessageAsync(index, ct);
|
||||
|
||||
var result = new
|
||||
{
|
||||
Id = index.ToString(),
|
||||
Date = msg.Date,
|
||||
From = msg.From.ToString(),
|
||||
Subject = msg.Subject,
|
||||
Body = msg.TextBody
|
||||
};
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleMarkReadAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (!input.TryGetProperty("messageId", out var mi) || !int.TryParse(mi.GetString(), out var index))
|
||||
{
|
||||
return ToolResult.Fail("'messageId' (Index) ist erforderlich.");
|
||||
}
|
||||
|
||||
var config = context.ToolConfig;
|
||||
using var client = new ImapClient();
|
||||
await client.ConnectAsync(config["imapHost"]?.ToString(), (config["imapPort"] is JsonElement ip ? ip.GetInt32() : 993), true, ct);
|
||||
await client.AuthenticateAsync(config["username"]?.ToString(), config["password"]?.ToString(), ct);
|
||||
|
||||
await client.Inbox!.OpenAsync(FolderAccess.ReadWrite, ct);
|
||||
await client.Inbox!.AddFlagsAsync(index, MessageFlags.Seen, true, ct);
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
return ToolResult.Ok($"Nachricht {index} als gelesen markiert.");
|
||||
}
|
||||
|
||||
private bool IsRecipientAllowed(string email, AgentToolContext context)
|
||||
{
|
||||
if (!context.ToolConfig.TryGetValue("allowedRecipients", out var val) || val is not JsonElement je)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var allowed = je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList();
|
||||
return allowed.Contains(email.ToLowerInvariant());
|
||||
}
|
||||
|
||||
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
|
||||
[
|
||||
new("mail_check_unread", "Mail Check", "Prüft regelmäßig auf neue ungelesene E-Mails")
|
||||
];
|
||||
|
||||
public async Task<ToolJobResult> ExecuteJobAsync(
|
||||
string jobTypeId,
|
||||
IReadOnlyDictionary<string, object?> toolConfig,
|
||||
IStateStore stateStore,
|
||||
ILogger logger,
|
||||
CancellationToken ct,
|
||||
string? agentId = null,
|
||||
string? workspacePath = null)
|
||||
{
|
||||
if (jobTypeId != "mail_check_unread")
|
||||
return ToolJobResult.NoAction($"Unbekannter Job: {jobTypeId}");
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new ImapClient();
|
||||
var imapHost = toolConfig.TryGetValue("imapHost", out var h) ? h?.ToString() : null;
|
||||
var imapPort = toolConfig.TryGetValue("imapPort", out var p) ? (p is long l ? (int)l : (p is int i ? i : 993)) : 993;
|
||||
var username = toolConfig.TryGetValue("username", out var u) ? u?.ToString() : null;
|
||||
var password = toolConfig.TryGetValue("password", out var pw) ? pw?.ToString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(imapHost) || string.IsNullOrWhiteSpace(username))
|
||||
return ToolJobResult.NoAction("Mail-Konfiguration unvollständig");
|
||||
|
||||
await client.ConnectAsync(imapHost, imapPort, true, ct);
|
||||
await client.AuthenticateAsync(username ?? "", password ?? "", ct);
|
||||
|
||||
await client.Inbox!.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||
|
||||
// Wir suchen nach ungelesenen Nachrichten
|
||||
var uids = await client.Inbox!.SearchAsync(SearchQuery.NotSeen, ct);
|
||||
|
||||
if (uids.Count == 0)
|
||||
{
|
||||
await client.DisconnectAsync(true, ct);
|
||||
return ToolJobResult.NoAction("Keine neuen ungelesenen Mails");
|
||||
}
|
||||
|
||||
// Um zu vermeiden, dass wir bei jedem Tick für die gleichen Mails wecken,
|
||||
// speichern wir die höchste UID, die wir bereits gemeldet haben.
|
||||
var lastUidStr = await stateStore.GetAsync("mail_last_notified_uid", ct);
|
||||
uint lastUid = uint.TryParse(lastUidStr, out var lu) ? lu : 0;
|
||||
|
||||
var newUids = uids.Where(uid => uid.Id > lastUid).OrderBy(uid => uid.Id).ToList();
|
||||
|
||||
if (newUids.Count == 0)
|
||||
{
|
||||
await client.DisconnectAsync(true, ct);
|
||||
return ToolJobResult.NoAction("Keine neuen Mails seit der letzten Prüfung");
|
||||
}
|
||||
|
||||
// Die neuesten Nachrichten abrufen für die Zusammenfassung
|
||||
var summary = new List<string>();
|
||||
foreach (var uid in newUids.Take(5))
|
||||
{
|
||||
var msg = await client.Inbox!.GetMessageAsync(uid, ct);
|
||||
summary.Add($"- Von: {msg.From}, Betreff: {msg.Subject}");
|
||||
}
|
||||
|
||||
if (newUids.Count > 5) summary.Add($"- ... und {newUids.Count - 5} weitere");
|
||||
|
||||
// Höchste UID speichern
|
||||
var maxUid = newUids.Max(u => u.Id);
|
||||
await stateStore.SetAsync("mail_last_notified_uid", maxUid.ToString(), ct);
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
|
||||
return ToolJobResult.Wake(
|
||||
$"Du hast {newUids.Count} neue ungelesene E-Mails erhalten:\n" + string.Join("\n", summary),
|
||||
logSummary: $"{newUids.Count} neue Mails gefunden"
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Fehler im Mail Background Job");
|
||||
return ToolJobResult.NoAction($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.SocialMediaManager;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 2;
|
||||
public const string Changes = "JSON/base64 STT-API, WakeStateless für Transcript Notifier, explizite Wake-Instruktionen";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.SocialMediaManager</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.Telegram;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "Polling via IToolJobProvider, Markdown-Support";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.Telegram</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telegram.Bot" Version="22.10.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\ClawdDotNet.Core\\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace ClawdDotNet.Tools.Telegram;
|
||||
|
||||
public sealed class TelegramTool : IAgentTool, IToolJobProvider
|
||||
{
|
||||
public string Name => "Telegram";
|
||||
|
||||
public string Description => "Erlaubt das Senden und Empfangen von Nachrichten über Telegram.";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["send_message", "get_updates"],
|
||||
"description": "Die auszuführende Aktion"
|
||||
},
|
||||
"chatId": {
|
||||
"type": "string",
|
||||
"description": "Die ID des Chats (optional, falls Standard-ID konfiguriert)"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Der zu sendende Text (nur für send_message)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximale Anzahl an Nachrichten zum Abrufen (nur für get_updates)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
// ─── IAgentTool ───
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
var botToken = context.ToolConfig.TryGetValue("botToken", out var bt) ? bt?.ToString() : null;
|
||||
if (string.IsNullOrWhiteSpace(botToken))
|
||||
{
|
||||
return ToolResult.Fail("Konfigurationsfehler: 'botToken' ist nicht definiert.");
|
||||
}
|
||||
|
||||
var botClient = new TelegramBotClient(botToken);
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"send_message" => await HandleSendMessageAsync(botClient, input, context, ct),
|
||||
"get_updates" => await HandleGetUpdatesAsync(botClient, input, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler bei Telegram Aktion {Action}", action);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IToolJobProvider ───
|
||||
|
||||
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
|
||||
[
|
||||
new("telegram_poll", "Telegram Polling",
|
||||
"Prüft regelmäßig auf neue Telegram-Nachrichten und weckt den Agenten bei neuen Nachrichten.")
|
||||
];
|
||||
|
||||
public async Task<ToolJobResult> ExecuteJobAsync(
|
||||
string jobTypeId,
|
||||
IReadOnlyDictionary<string, object?> toolConfig,
|
||||
IStateStore stateStore,
|
||||
ILogger logger,
|
||||
CancellationToken ct,
|
||||
string? agentId = null,
|
||||
string? workspacePath = null)
|
||||
{
|
||||
if (jobTypeId != "telegram_poll")
|
||||
return ToolJobResult.NoAction($"Unbekannter Job-Typ: {jobTypeId}");
|
||||
|
||||
var botToken = toolConfig.TryGetValue("botToken", out var bt) ? bt?.ToString() : null;
|
||||
if (string.IsNullOrWhiteSpace(botToken))
|
||||
return ToolJobResult.NoAction("Kein botToken konfiguriert");
|
||||
|
||||
var client = new TelegramBotClient(botToken);
|
||||
|
||||
var offsetKey = "telegram_poll_offset";
|
||||
var lastOffsetStr = await stateStore.GetAsync(offsetKey, ct);
|
||||
var lastOffset = int.TryParse(lastOffsetStr, out var o) ? o : 0;
|
||||
|
||||
try
|
||||
{
|
||||
var updates = await client.GetUpdates(
|
||||
offset: lastOffset, limit: 100, cancellationToken: ct);
|
||||
|
||||
var messages = updates
|
||||
.Where(u => u.Message is not null)
|
||||
.Where(u => IsChatIdAllowed(u.Message!.Chat.Id.ToString(), toolConfig))
|
||||
.ToList();
|
||||
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
if (updates.Length > 0)
|
||||
{
|
||||
var newOffset = updates.Max(u => u.Id) + 1;
|
||||
await stateStore.SetAsync(offsetKey, newOffset.ToString(), ct);
|
||||
}
|
||||
return ToolJobResult.NoAction("Polled: 0 neue Nachrichten");
|
||||
}
|
||||
|
||||
var newOffsetFinal = updates.Max(u => u.Id) + 1;
|
||||
await stateStore.SetAsync(offsetKey, newOffsetFinal.ToString(), ct);
|
||||
|
||||
var summary = string.Join("\n", messages.Select(u =>
|
||||
{
|
||||
var from = u.Message!.From?.Username ?? u.Message.From?.FirstName ?? "Unbekannt";
|
||||
var text = u.Message.Text ?? "(kein Text)";
|
||||
return $"- {from}: {text}";
|
||||
}));
|
||||
|
||||
var wakeMessage = $"[Telegram] {messages.Count} neue Nachricht(en) empfangen:\n\n{summary}";
|
||||
|
||||
logger.LogInformation("Telegram Poll: {Count} neue Nachrichten", messages.Count);
|
||||
|
||||
return ToolJobResult.Wake(wakeMessage, $"Polled: {messages.Count} neue Nachrichten");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Telegram Polling fehlgeschlagen");
|
||||
return ToolJobResult.NoAction($"Polling-Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private Helpers ───
|
||||
|
||||
private async Task<ToolResult> HandleSendMessageAsync(
|
||||
ITelegramBotClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var chatIdStr = input.TryGetProperty("chatId", out var ci) ? ci.GetString() : null;
|
||||
var text = input.TryGetProperty("text", out var t) ? t.GetString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chatIdStr))
|
||||
{
|
||||
chatIdStr = context.ToolConfig.TryGetValue("defaultChatId", out var dci) ? dci?.ToString() : null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(chatIdStr) || string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return ToolResult.Fail("'chatId' (oder Standard-ID in Konfig) und 'text' sind für send_message erforderlich.");
|
||||
}
|
||||
|
||||
if (!IsChatIdAllowed(chatIdStr, context.ToolConfig))
|
||||
{
|
||||
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf ChatId '{chatIdStr}' ist nicht erlaubt.");
|
||||
}
|
||||
|
||||
await client.SendMessage(new ChatId(chatIdStr), text, cancellationToken: ct);
|
||||
return ToolResult.Ok($"Nachricht erfolgreich an {chatIdStr} gesendet.");
|
||||
}
|
||||
|
||||
private async Task<ToolResult> HandleGetUpdatesAsync(
|
||||
ITelegramBotClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var limit = input.TryGetProperty("limit", out var l) ? l.GetInt32() : 10;
|
||||
|
||||
var updates = await client.GetUpdates(limit: limit, cancellationToken: ct);
|
||||
|
||||
var allowedUpdates = updates
|
||||
.Where(u => u.Message != null && IsChatIdAllowed(u.Message.Chat.Id.ToString(), context.ToolConfig))
|
||||
.Select(u => new
|
||||
{
|
||||
u.Id,
|
||||
Message = new
|
||||
{
|
||||
u.Message!.Id,
|
||||
ChatId = u.Message.Chat.Id,
|
||||
From = u.Message.From?.Username ?? u.Message.From?.FirstName,
|
||||
u.Message.Text,
|
||||
u.Message.Date
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(allowedUpdates, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static bool IsChatIdAllowed(string chatId, IReadOnlyDictionary<string, object?> toolConfig)
|
||||
{
|
||||
if (toolConfig.TryGetValue("defaultChatId", out var dci) && dci?.ToString() == chatId)
|
||||
return true;
|
||||
|
||||
if (!toolConfig.TryGetValue("allowedChatIds", out var val) || val is not JsonElement je)
|
||||
return false;
|
||||
|
||||
var allowed = je.EnumerateArray().Select(x => x.GetString()).ToList();
|
||||
return allowed.Contains(chatId);
|
||||
}
|
||||
|
||||
private bool IsChatIdAllowed(string chatId, AgentToolContext context)
|
||||
=> IsChatIdAllowed(chatId, context.ToolConfig);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.TelegramClient</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="WTelegramClient" Version="4.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,146 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TL;
|
||||
using WTelegram;
|
||||
|
||||
namespace ClawdDotNet.Tools.TelegramClient;
|
||||
|
||||
public sealed class TelegramClientManager : IAsyncDisposable
|
||||
{
|
||||
private Client? _client;
|
||||
private User? _self;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private readonly int _apiId;
|
||||
private readonly string _apiHash;
|
||||
private readonly string _phoneNumber;
|
||||
private readonly string _sessionPath;
|
||||
private readonly string? _2faPassword;
|
||||
|
||||
private readonly Dictionary<long, User> _users = new();
|
||||
private readonly Dictionary<long, ChatBase> _chats = new();
|
||||
|
||||
public event Func<string, Task<string>>? OnLoginCodeRequired;
|
||||
public event Func<Task<string>>? On2FAPasswordRequired;
|
||||
|
||||
public bool IsConnected => _client?.User != null;
|
||||
public User? Self => _self;
|
||||
|
||||
public TelegramClientManager(
|
||||
InstanceConfig config,
|
||||
string instancePath,
|
||||
ILogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var tgConfig = config.TelegramClient
|
||||
?? throw new InvalidOperationException("TelegramClient config missing in InstanceConfig");
|
||||
|
||||
_apiId = tgConfig.ApiId;
|
||||
_apiHash = tgConfig.ApiHash;
|
||||
_phoneNumber = tgConfig.PhoneNumber;
|
||||
_sessionPath = Path.Combine(instancePath, "sessions", $"telegram_{config.InstanceId}.session");
|
||||
_2faPassword = tgConfig.Password2FA;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
var sessionDir = Path.GetDirectoryName(_sessionPath)!;
|
||||
Directory.CreateDirectory(sessionDir);
|
||||
|
||||
_client = new Client(ConfigCallback);
|
||||
|
||||
Helpers.Log = (lvl, msg) =>
|
||||
_logger.Log((LogLevel)lvl, "WTelegram: {Message}", msg);
|
||||
|
||||
_self = await _client.LoginUserIfNeeded();
|
||||
_logger.LogInformation(
|
||||
"Telegram: logged in as {Name} (id {Id})",
|
||||
_self.first_name, _self.id);
|
||||
}
|
||||
|
||||
private string? ConfigCallback(string what) => what switch
|
||||
{
|
||||
"api_id" => _apiId.ToString(),
|
||||
"api_hash" => _apiHash,
|
||||
"phone_number" => _phoneNumber,
|
||||
"session_pathname" => _sessionPath,
|
||||
|
||||
"verification_code" => OnLoginCodeRequired != null
|
||||
? OnLoginCodeRequired("Bitte Telegram-Verifizierungscode eingeben:").Result
|
||||
: throw new InvalidOperationException(
|
||||
"Verification code required but no UI handler registered. " +
|
||||
"Connect OnLoginCodeRequired to prompt the user."),
|
||||
|
||||
"password" => _2faPassword
|
||||
?? (On2FAPasswordRequired != null
|
||||
? On2FAPasswordRequired().Result
|
||||
: throw new InvalidOperationException(
|
||||
"2FA password required but not configured.")),
|
||||
|
||||
_ => null
|
||||
};
|
||||
|
||||
public async Task<Messages_Dialogs> GetAllDialogsAsync(CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var dialogs = await _client!.Messages_GetAllDialogs();
|
||||
dialogs.CollectUsersChats(_users, _chats);
|
||||
return dialogs;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<Messages_Chats> GetAllChatsAsync(CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try { return await _client!.Messages_GetAllChats(); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<Messages_MessagesBase> GetMessagesAsync(
|
||||
InputPeer peer, int minId = 0, int limit = 50, CancellationToken ct = default)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
return await _client!.Messages_GetHistory(
|
||||
peer, offset_id: 0, offset_date: default,
|
||||
add_offset: 0, limit: limit, max_id: 0, min_id: minId, hash: 0);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<Contacts_ResolvedPeer> ResolveUsernameAsync(string username, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var resolved = await _client!.Contacts_ResolveUsername(username.TrimStart('@'));
|
||||
foreach (var u in resolved.users.Values)
|
||||
_users[u.id] = u;
|
||||
foreach (var c in resolved.chats.Values)
|
||||
_chats[c.ID] = c;
|
||||
return resolved;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public InputPeer? GetInputPeerFromCache(long chatId)
|
||||
{
|
||||
if (_users.TryGetValue(chatId, out var user))
|
||||
return user;
|
||||
if (_chats.TryGetValue(chatId, out var chat))
|
||||
return chat;
|
||||
return null;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_client?.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TL;
|
||||
|
||||
namespace ClawdDotNet.Tools.TelegramClient;
|
||||
|
||||
public sealed class TelegramClientTool : IAgentTool
|
||||
{
|
||||
private readonly TelegramClientManager _tg;
|
||||
|
||||
public TelegramClientTool(TelegramClientManager tg) => _tg = tg;
|
||||
|
||||
public string Name => "TelegramClient";
|
||||
|
||||
public string Description => """
|
||||
Liest Nachrichten aus dem persönlichen Telegram-Account des Nutzers.
|
||||
Zugriff auf alle Chats, Gruppen und Kanäle in denen der Nutzer Mitglied ist.
|
||||
NUR LESEN — kein Senden, kein Löschen, kein Bearbeiten.
|
||||
Aktionen: list_chats, read_messages, read_new
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["list_chats", "read_messages", "read_new"],
|
||||
"description": "list_chats: alle Chats/Gruppen/Kanäle auflisten. read_messages: letzte N Nachrichten aus einem Chat lesen. read_new: nur neue Nachrichten seit letztem Abruf."
|
||||
},
|
||||
"chatId": {
|
||||
"type": "integer",
|
||||
"description": "Chat-ID aus list_chats Ergebnis. Erforderlich für read_messages und read_new."
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"description": "Alternativ zu chatId: @username einer Gruppe/Person auflösen."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max. Anzahl Nachrichten (default: 30, max: 100)"
|
||||
}
|
||||
}
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var allowedChats = ParseLongList(context.ToolConfig, "allowedChatIds");
|
||||
var allowedUsernames = ParseStringList(context.ToolConfig, "allowedUsernames");
|
||||
|
||||
if (!_tg.IsConnected)
|
||||
return ToolResult.Fail(
|
||||
"Telegram-Client ist nicht verbunden. Bitte zuerst authentifizieren.");
|
||||
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
context.Logger.LogInformation("TelegramClient executing action: {Action}", action);
|
||||
|
||||
return action switch
|
||||
{
|
||||
"list_chats" => await ListChatsAsync(allowedChats, ct),
|
||||
"read_messages" => await ReadMessagesAsync(input, allowedChats, context, ct),
|
||||
"read_new" => await ReadNewAsync(input, allowedChats, context, ct),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ListChatsAsync(
|
||||
List<long>? allowedChats, CancellationToken ct)
|
||||
{
|
||||
var dialogs = await _tg.GetAllDialogsAsync(ct);
|
||||
|
||||
var chatList = new List<object>();
|
||||
foreach (var dialog in dialogs.dialogs.OfType<Dialog>())
|
||||
{
|
||||
var peer = dialogs.UserOrChat(dialog);
|
||||
if (peer == null) continue;
|
||||
|
||||
var chatId = dialog.Peer.ID;
|
||||
|
||||
if (allowedChats != null && !allowedChats.Contains(chatId))
|
||||
continue;
|
||||
|
||||
var info = peer switch
|
||||
{
|
||||
User user when user.IsActive => new
|
||||
{
|
||||
chatId,
|
||||
type = "user",
|
||||
name = $"{user.first_name} {user.last_name}".Trim(),
|
||||
username = user.MainUsername,
|
||||
unread = dialog.unread_count,
|
||||
lastMsgId = dialog.TopMessage
|
||||
} as object,
|
||||
|
||||
ChatBase chat when chat.IsActive => new
|
||||
{
|
||||
chatId,
|
||||
type = chat is Channel ch
|
||||
? (ch.IsGroup ? "supergroup" : "channel")
|
||||
: "group",
|
||||
name = chat.Title,
|
||||
username = (chat as Channel)?.MainUsername,
|
||||
unread = dialog.unread_count,
|
||||
lastMsgId = dialog.TopMessage
|
||||
} as object,
|
||||
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (info != null) chatList.Add(info);
|
||||
}
|
||||
|
||||
var result = new
|
||||
{
|
||||
fetchedAt = DateTime.UtcNow,
|
||||
dataAsOf = DateTime.UtcNow,
|
||||
source = "telegram_client_api",
|
||||
data = new
|
||||
{
|
||||
totalChats = chatList.Count,
|
||||
chats = chatList
|
||||
}
|
||||
};
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ReadMessagesAsync(
|
||||
JsonElement input, List<long>? allowedChats,
|
||||
AgentToolContext ctx, CancellationToken ct)
|
||||
{
|
||||
var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct);
|
||||
if (error != null) return ToolResult.Fail(error);
|
||||
|
||||
var limit = input.TryGetProperty("limit", out var l)
|
||||
? Math.Clamp(l.GetInt32(), 1, 100)
|
||||
: 30;
|
||||
|
||||
var messages = await _tg.GetMessagesAsync(peer!, minId: 0, limit: limit, ct: ct);
|
||||
var msgList = FormatMessages(messages);
|
||||
|
||||
var result = new
|
||||
{
|
||||
fetchedAt = DateTime.UtcNow,
|
||||
dataAsOf = DateTime.UtcNow,
|
||||
source = $"telegram_chat_{chatId}",
|
||||
data = new
|
||||
{
|
||||
chatId,
|
||||
count = msgList.Count,
|
||||
messages = msgList
|
||||
}
|
||||
};
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> ReadNewAsync(
|
||||
JsonElement input, List<long>? allowedChats,
|
||||
AgentToolContext ctx, CancellationToken ct)
|
||||
{
|
||||
var (peer, chatId, error) = await ResolvePeerAsync(input, allowedChats, ct);
|
||||
if (error != null) return ToolResult.Fail(error);
|
||||
|
||||
var stateKey = $"tgclient:{ctx.AgentId}:chat_{chatId}:lastMsgId";
|
||||
var lastIdStr = await ctx.StateStore.GetAsync(stateKey, ct);
|
||||
var lastId = int.TryParse(lastIdStr, out var id) ? id : 0;
|
||||
|
||||
var limit = input.TryGetProperty("limit", out var l)
|
||||
? Math.Clamp(l.GetInt32(), 1, 100)
|
||||
: 50;
|
||||
|
||||
var messages = await _tg.GetMessagesAsync(peer!, minId: lastId, limit: limit, ct: ct);
|
||||
var msgList = FormatMessages(messages);
|
||||
|
||||
if (msgList.Count > 0)
|
||||
{
|
||||
var newMaxId = msgList.Max(m => m.MessageId);
|
||||
await ctx.StateStore.SetAsync(stateKey, newMaxId.ToString(), ct);
|
||||
}
|
||||
|
||||
var result = new
|
||||
{
|
||||
fetchedAt = DateTime.UtcNow,
|
||||
dataAsOf = DateTime.UtcNow,
|
||||
source = $"telegram_chat_{chatId}",
|
||||
data = new
|
||||
{
|
||||
chatId,
|
||||
sinceId = lastId,
|
||||
newCount = msgList.Count,
|
||||
messages = msgList
|
||||
}
|
||||
};
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result));
|
||||
}
|
||||
|
||||
private async Task<(InputPeer? Peer, long ChatId, string? Error)> ResolvePeerAsync(
|
||||
JsonElement input, List<long>? allowedChats, CancellationToken ct)
|
||||
{
|
||||
long chatId = 0;
|
||||
InputPeer? peer = null;
|
||||
|
||||
if (input.TryGetProperty("chatId", out var cid))
|
||||
{
|
||||
chatId = cid.GetInt64();
|
||||
if (allowedChats != null && !allowedChats.Contains(chatId))
|
||||
return (null, chatId, $"Agent hat keinen Zugriff auf Chat {chatId}.");
|
||||
|
||||
peer = _tg.GetInputPeerFromCache(chatId);
|
||||
if (peer == null)
|
||||
{
|
||||
await _tg.GetAllDialogsAsync(ct);
|
||||
peer = _tg.GetInputPeerFromCache(chatId);
|
||||
}
|
||||
}
|
||||
else if (input.TryGetProperty("username", out var uname))
|
||||
{
|
||||
var resolved = await _tg.ResolveUsernameAsync(uname.GetString()!, ct);
|
||||
peer = resolved.UserOrChat switch
|
||||
{
|
||||
User u => (InputPeer)u,
|
||||
ChatBase c => (InputPeer)c,
|
||||
_ => null
|
||||
};
|
||||
chatId = resolved.peer.ID;
|
||||
|
||||
if (allowedChats != null && !allowedChats.Contains(chatId))
|
||||
return (null, chatId, $"Agent hat keinen Zugriff auf Chat @{uname.GetString()}.");
|
||||
}
|
||||
|
||||
if (peer == null)
|
||||
return (null, 0, "chatId oder username muss angegeben werden.");
|
||||
|
||||
return (peer, chatId, null);
|
||||
}
|
||||
|
||||
private static List<FormattedMessage> FormatMessages(Messages_MessagesBase messages)
|
||||
{
|
||||
var result = new List<FormattedMessage>();
|
||||
|
||||
foreach (var msgBase in messages.Messages)
|
||||
{
|
||||
var from = messages.UserOrChat(msgBase.From ?? msgBase.Peer);
|
||||
var fromName = from switch
|
||||
{
|
||||
User u => $"{u.first_name} {u.last_name}".Trim(),
|
||||
ChatBase c => c.Title,
|
||||
_ => "Unknown"
|
||||
};
|
||||
|
||||
if (msgBase is Message msg)
|
||||
{
|
||||
result.Add(new FormattedMessage(
|
||||
msg.ID,
|
||||
msg.Date,
|
||||
fromName,
|
||||
msgBase.From?.ID ?? 0,
|
||||
msg.message,
|
||||
msg.media != null,
|
||||
msg.media?.GetType().Name,
|
||||
(msg.reply_to as MessageReplyHeader)?.reply_to_msg_id,
|
||||
msg.fwd_from != null
|
||||
? msg.fwd_from.from_name ?? "forwarded"
|
||||
: null,
|
||||
msg.views));
|
||||
}
|
||||
}
|
||||
|
||||
return result.OrderBy(m => m.MessageId).ToList();
|
||||
}
|
||||
|
||||
private static List<long>? ParseLongList(
|
||||
IReadOnlyDictionary<string, object?> config, string key)
|
||||
{
|
||||
if (!config.TryGetValue(key, out var val) || val == null)
|
||||
return null;
|
||||
|
||||
if (val is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
return je.EnumerateArray().Select(e => e.GetInt64()).ToList();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string>? ParseStringList(
|
||||
IReadOnlyDictionary<string, object?> config, string key)
|
||||
{
|
||||
if (!config.TryGetValue(key, out var val) || val == null)
|
||||
return null;
|
||||
|
||||
if (val is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
return je.EnumerateArray().Select(e => e.GetString()!).ToList();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record FormattedMessage(
|
||||
int MessageId,
|
||||
DateTime Date,
|
||||
string From,
|
||||
long FromId,
|
||||
string? Text,
|
||||
bool HasMedia,
|
||||
string? MediaType,
|
||||
int? ReplyToId,
|
||||
string? ForwardFrom,
|
||||
int? Views);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.WebFetch;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "HTTP GET/POST, HTML-zu-Text";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClawdDotNet.Tools.WebFetch;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.WebFetch;
|
||||
|
||||
public sealed class WebFetchTool : IAgentTool
|
||||
{
|
||||
public string Name => "WebFetch";
|
||||
|
||||
public string Description => """
|
||||
Ruft statische Webseiten oder RSS-Feeds ab und extrahiert Text + Timestamps.
|
||||
Nur Domains aus der Whitelist erlaubt. Kein JavaScript-Rendering.
|
||||
Aktionen: fetch, rss
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["action", "url"],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["fetch", "rss"],
|
||||
"description": "fetch=HTML-Seite abrufen und zu Text konvertieren, rss=RSS/Atom-Feed parsen"
|
||||
},
|
||||
"url": { "type": "string" },
|
||||
"selector":{ "type": "string",
|
||||
"description": "optional: CSS-ähnlicher Hint welcher Teil relevant ist, z.B. 'table', 'article'" }
|
||||
}
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = input.GetProperty("url").GetString()!;
|
||||
var action = input.GetProperty("action").GetString()!;
|
||||
|
||||
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("WebFetch", out var c) && c is JsonElement je
|
||||
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
|
||||
: context.ToolConfig;
|
||||
|
||||
if (config == null) return ToolResult.Fail("WebFetch Konfiguration fehlt.");
|
||||
|
||||
var allowedDomains = config.GetValueOrDefault("allowedDomains") is JsonElement ad
|
||||
? ad.EnumerateArray().Select(x => x.GetString()!).ToList()
|
||||
: (config.GetValueOrDefault("allowedDomains") as IEnumerable<string>)?.ToList() ?? new List<string>();
|
||||
|
||||
// Domain-Whitelist prüfen
|
||||
var uri = new Uri(url);
|
||||
var host = uri.Host.Replace("www.", "").ToLowerInvariant();
|
||||
if (!allowedDomains.Any(d => host == d.ToLowerInvariant() || host.EndsWith("." + d.ToLowerInvariant())))
|
||||
return ToolResult.Fail($"Domain '{host}' nicht in der Whitelist dieses Agenten.");
|
||||
|
||||
return action switch
|
||||
{
|
||||
"fetch" => await FetchPageAsync(url, config, ct),
|
||||
"rss" => await FetchRssAsync(url, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler in WebFetch");
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> FetchPageAsync(string url, IReadOnlyDictionary<string, object?> config, CancellationToken ct)
|
||||
{
|
||||
using var http = CreateHttpClient(config);
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
|
||||
using var response = await http.GetAsync(url, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
|
||||
DateTime? dataAsOf = response.Content.Headers.LastModified?.UtcDateTime
|
||||
?? response.Headers.Date?.UtcDateTime;
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync(ct);
|
||||
var maxKb = GetConfigInt(config, "maxResponseKb", 512);
|
||||
if (html.Length > maxKb * 1024) html = html[..(maxKb * 1024)];
|
||||
|
||||
var text = StripHtml(html);
|
||||
if (dataAsOf == null) dataAsOf = ExtractDateFromHtml(html);
|
||||
|
||||
var result = new
|
||||
{
|
||||
fetchedAt = fetchedAt,
|
||||
dataAsOf = dataAsOf,
|
||||
source = url,
|
||||
data = new { text }
|
||||
};
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> FetchRssAsync(string url, CancellationToken ct)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("ClawdDotNet-Agent/1.0");
|
||||
http.Timeout = TimeSpan.FromSeconds(30);
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
|
||||
using var response = await http.GetAsync(url, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return ToolResult.Fail($"RSS-Feed nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||
|
||||
var xml = await response.Content.ReadAsStringAsync(ct);
|
||||
var doc = XDocument.Parse(xml);
|
||||
|
||||
var items = new List<object>();
|
||||
// Support RSS and Atom
|
||||
var ns = doc.Root?.Name.Namespace;
|
||||
|
||||
if (doc.Root?.Name.LocalName == "rss")
|
||||
{
|
||||
foreach (var item in doc.Descendants("item").Take(20))
|
||||
{
|
||||
items.Add(new
|
||||
{
|
||||
title = item.Element("title")?.Value,
|
||||
link = item.Element("link")?.Value,
|
||||
pubDate = item.Element("pubDate")?.Value,
|
||||
description = StripHtml(item.Element("description")?.Value ?? "")
|
||||
});
|
||||
}
|
||||
}
|
||||
else // Atom
|
||||
{
|
||||
foreach (var entry in doc.Descendants((ns ?? XNamespace.None) + "entry").Take(20))
|
||||
{
|
||||
items.Add(new
|
||||
{
|
||||
title = entry.Element(ns + "title")?.Value,
|
||||
link = entry.Element(ns + "link")?.Attribute("href")?.Value,
|
||||
pubDate = entry.Element(ns + "updated")?.Value ?? entry.Element(ns + "published")?.Value,
|
||||
description = StripHtml(entry.Element(ns + "summary")?.Value ?? entry.Element(ns + "content")?.Value ?? "")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var result = new
|
||||
{
|
||||
fetchedAt = fetchedAt,
|
||||
dataAsOf = fetchedAt, // RSS current state
|
||||
source = url,
|
||||
data = new { items }
|
||||
};
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static string StripHtml(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html)) return "";
|
||||
|
||||
// Script und Style entfernen
|
||||
html = Regex.Replace(html, "<script.*?>.*?</script>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
html = Regex.Replace(html, "<style.*?>.*?</style>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
|
||||
// Alle anderen Tags entfernen
|
||||
html = Regex.Replace(html, "<.*?>", " ", RegexOptions.Singleline);
|
||||
|
||||
// Entities dekodieren
|
||||
html = WebUtility.HtmlDecode(html);
|
||||
|
||||
// Whitespace säubern
|
||||
html = Regex.Replace(html, @"\s+", " ");
|
||||
html = html.Replace(" \n ", "\n").Replace(" \r ", "\r").Trim();
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private static DateTime? ExtractDateFromHtml(string html)
|
||||
{
|
||||
// Suche nach meta-Tags
|
||||
var patterns = new[]
|
||||
{
|
||||
"<meta.*?property=\"article:published_time\".*?content=\"(.*?)\"",
|
||||
"<meta.*?name=\"date\".*?content=\"(.*?)\"",
|
||||
"<time.*?datetime=\"(.*?)\""
|
||||
};
|
||||
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
|
||||
if (match.Success && DateTime.TryParse(match.Groups[1].Value, out var dt))
|
||||
return dt.ToUniversalTime();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient(IReadOnlyDictionary<string, object?> config)
|
||||
{
|
||||
var client = new HttpClient();
|
||||
var timeout = GetConfigInt(config, "timeoutSeconds", 15);
|
||||
client.Timeout = TimeSpan.FromSeconds(timeout);
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
config.GetValueOrDefault("userAgent")?.ToString() ?? "ClawdDotNet-Agent/1.0");
|
||||
client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true, NoStore = true };
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sicheres Lesen eines int-Werts aus der Config (funktioniert mit JsonElement und primitiven Typen).
|
||||
/// </summary>
|
||||
private static int GetConfigInt(IReadOnlyDictionary<string, object?> config, string key, int defaultValue)
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
null => defaultValue,
|
||||
int i => i,
|
||||
long l => (int)l,
|
||||
System.Text.Json.JsonElement je when je.ValueKind == System.Text.Json.JsonValueKind.Number => je.GetInt32(),
|
||||
_ => int.TryParse(val.ToString(), out var parsed) ? parsed : defaultValue
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClawdDotNet.Tools.WebMonitor;
|
||||
|
||||
public static class BuildInfo
|
||||
{
|
||||
public const int Build = 1;
|
||||
public const string Changes = "Website-Änderungsüberwachung";
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ClawdDotNet.Tools.WebMonitor;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.WebMonitor;
|
||||
|
||||
public sealed class WebMonitorTool : IAgentTool
|
||||
{
|
||||
public string Name => "WebMonitor";
|
||||
|
||||
public string Description => """
|
||||
Überwacht Webseiten auf neue Einträge und liefert nur die Deltas seit dem letzten Check.
|
||||
Speichert den Stand in der Datenbank. Ideal für Capitol Trades, SEC-Filings, etc.
|
||||
Aktionen: check, history, status
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["action", "monitorId"],
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["check", "history", "status"],
|
||||
"description": "check=jetzt prüfen und Deltas liefern, history=bisherige Einträge, status=letzter Check-Zeitpunkt"
|
||||
},
|
||||
"monitorId": {
|
||||
"type": "string",
|
||||
"description": "z.B. capitol_trades, sec_filings — muss in Config definiert sein"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "max. Anzahl Einträge für history, default 50"
|
||||
}
|
||||
}
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = input.GetProperty("action").GetString()!;
|
||||
var monitorId = input.GetProperty("monitorId").GetString()!;
|
||||
|
||||
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("WebMonitor", out var c) && c is JsonElement je
|
||||
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
|
||||
: context.ToolConfig;
|
||||
|
||||
if (config == null || !config.TryGetValue("monitors", out var m) || m is not JsonElement monitorsJe)
|
||||
return ToolResult.Fail("WebMonitor Konfiguration oder Monitore fehlen.");
|
||||
|
||||
var monitors = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(monitorsJe.GetRawText());
|
||||
if (monitors == null || !monitors.TryGetValue(monitorId, out var monitorConfigJe))
|
||||
return ToolResult.Fail($"Monitor '{monitorId}' nicht in der Config gefunden.");
|
||||
|
||||
var monitorConfig = JsonSerializer.Deserialize<Dictionary<string, object?>>(monitorConfigJe.GetRawText())!;
|
||||
|
||||
return action switch
|
||||
{
|
||||
"check" => await CheckForNewEntriesAsync(monitorId, monitorConfig, context, ct),
|
||||
"history" => await GetHistoryAsync(monitorId, input, context, ct),
|
||||
"status" => await GetStatusAsync(monitorId, context, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler in WebMonitor");
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ToolResult> CheckForNewEntriesAsync(string monitorId, Dictionary<string, object?> monitorConfig, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var url = monitorConfig["url"]?.ToString()!;
|
||||
var parser = monitorConfig["parser"]?.ToString() ?? "generic";
|
||||
var idPattern = monitorConfig["idPattern"]?.ToString();
|
||||
|
||||
using var http = new HttpClient();
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("ClawdDotNet-Agent/1.0");
|
||||
http.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true };
|
||||
|
||||
var html = await http.GetStringAsync(url, ct);
|
||||
var fetchedAt = DateTime.UtcNow;
|
||||
|
||||
var entries = parser switch
|
||||
{
|
||||
"capitol_trades" => ParseCapitolTrades(html),
|
||||
_ => ParseGeneric(html, idPattern)
|
||||
};
|
||||
|
||||
var stateKey = $"webmonitor:{context.AgentId}:{monitorId}:maxId";
|
||||
var lastMaxIdStr = await context.StateStore.GetAsync(stateKey, ct);
|
||||
var lastKnown = long.TryParse(lastMaxIdStr, out var l) ? l : 0L;
|
||||
|
||||
var newEntries = entries
|
||||
.Where(e => e.NumericId > lastKnown)
|
||||
.OrderBy(e => e.NumericId)
|
||||
.ToList();
|
||||
|
||||
if (newEntries.Count > 0)
|
||||
{
|
||||
var newMax = newEntries.Max(e => e.NumericId).ToString();
|
||||
await context.StateStore.SetAsync(stateKey, newMax, ct);
|
||||
}
|
||||
|
||||
await context.StateStore.SetAsync($"webmonitor:{context.AgentId}:{monitorId}:lastCheck", fetchedAt.ToString("O"), ct);
|
||||
|
||||
var result = new
|
||||
{
|
||||
fetchedAt = fetchedAt,
|
||||
dataAsOf = fetchedAt,
|
||||
source = url,
|
||||
monitorId = monitorId,
|
||||
newCount = newEntries.Count,
|
||||
data = new
|
||||
{
|
||||
newEntries = newEntries,
|
||||
message = newEntries.Count == 0 ? "Keine neuen Einträge." : $"{newEntries.Count} neue Einträge gefunden."
|
||||
}
|
||||
};
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
private static List<MonitorEntry> ParseCapitolTrades(string html)
|
||||
{
|
||||
var entries = new List<MonitorEntry>();
|
||||
|
||||
// Suche nach Trade-IDs und URLs
|
||||
// Format: <a href="/trades/20003797558">
|
||||
var idMatches = Regex.Matches(html, "/trades/(\\d+)");
|
||||
|
||||
foreach (Match match in idMatches)
|
||||
{
|
||||
if (long.TryParse(match.Groups[1].Value, out var id))
|
||||
{
|
||||
if (entries.Any(e => e.NumericId == id)) continue;
|
||||
|
||||
entries.Add(new MonitorEntry(
|
||||
id,
|
||||
id.ToString(),
|
||||
$"https://www.capitoltrades.com/trades/{id}",
|
||||
null,
|
||||
null,
|
||||
new Dictionary<string, string> { { "type", "CapitolTrades" } }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static List<MonitorEntry> ParseGeneric(string html, string? idPattern)
|
||||
{
|
||||
if (string.IsNullOrEmpty(idPattern)) return new List<MonitorEntry>();
|
||||
|
||||
var entries = new List<MonitorEntry>();
|
||||
var matches = Regex.Matches(html, idPattern);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var idStr = match.Groups.Count > 1 ? match.Groups[1].Value : match.Value;
|
||||
if (long.TryParse(Regex.Replace(idStr, "[^0-9]", ""), out var id))
|
||||
{
|
||||
entries.Add(new MonitorEntry(id, idStr, "", null, null, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async Task<ToolResult> GetHistoryAsync(string monitorId, JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
// Simple history from StateStore or just message for now as we don't have a separate DB table for this yet
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new { message = "History-Feature ist über den StateStore aktuell auf die letzte ID begrenzt." }));
|
||||
}
|
||||
|
||||
private async Task<ToolResult> GetStatusAsync(string monitorId, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var lastCheck = await context.StateStore.GetAsync($"webmonitor:{context.AgentId}:{monitorId}:lastCheck", ct);
|
||||
var maxId = await context.StateStore.GetAsync($"webmonitor:{context.AgentId}:{monitorId}:maxId", ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
monitorId = monitorId,
|
||||
lastCheck = lastCheck,
|
||||
maxId = maxId
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MonitorEntry(
|
||||
long NumericId,
|
||||
string RawId,
|
||||
string DetailUrl,
|
||||
DateTime? PublishedAt,
|
||||
DateTime? TradedAt,
|
||||
Dictionary<string, string> Fields
|
||||
);
|
||||
Reference in New Issue
Block a user