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,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();
}
}