Initial commit: ClawdDotNet

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

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