# ClawdDotNet – Tool-Entwicklungsanleitung ## Übersicht Tools sind eigenständige Plugins, die Agenten Zugriff auf externe Systeme geben (Datenbanken, Dateisysteme, E-Mail, APIs etc.). Jedes Tool ist ein separates .NET-Projekt, das ausschließlich `ClawdDotNet.Core` referenziert. **Kernregeln:** - Tools sind **niemals voneinander abhängig** (Tool A darf Tool B nicht kennen) - Der Core kompiliert und läuft **ohne jedes Tool** - Jedes Tool ist **pro Agent konfiguriert** (via `AgentConfig.Tools`) - Tools werden zur Laufzeit über die `ToolRegistry` registriert --- ## Projekt-Struktur ``` ClawdDotNet.sln ├── src/ │ ├── ClawdDotNet.Core/ ← Klassenbibliothek (keine Tool-Verweise!) │ ├── ClawdDotNet.Tools.MeinTool/ ← Dein Tool-Plugin │ │ ├── ClawdDotNet.Tools.MeinTool.csproj │ │ └── MeinToolTool.cs │ ├── ClawdDotNet.App/ ← Fachschicht ohne Oberflaeche (verweist auf Core + alle Tools) │ └── ClawdDotNet.Desktop/ ← Oberflaeche in Avalonia (verweist auf App) ``` ### Neues Tool-Projekt anlegen ```xml net10.0 enable enable ClawdDotNet.Tools.MeinTool ``` --- ## IAgentTool implementieren Jedes Tool implementiert das Interface `ClawdDotNet.Core.Tools.IAgentTool`: ```csharp using System.Text.Json; using ClawdDotNet.Core.Tools; namespace ClawdDotNet.Tools.MeinTool; public sealed class MeinToolTool : IAgentTool { // 1. Eindeutiger Name – wird vom LLM in tool_calls verwendet public string Name => "MeinTool"; // 2. Beschreibung für das LLM (geht in den System-Prompt) public string Description => "Beschreibung, was dieses Tool kann..."; // 3. JSON Schema des Input-Objekts (OpenAI Function Calling Format) public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "properties": { "action": { "type": "string", "enum": ["read", "write"], "description": "Die auszuführende Aktion" }, "data": { "type": "string", "description": "Eingabedaten" } }, "required": ["action"] } """).RootElement.Clone(); // 4. Ausführung public async Task ExecuteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { // Action aus dem Input lesen var action = input.GetProperty("action").GetString() ?? throw new ArgumentException("'action' is required"); // Konfiguration aus dem AgentToolContext lesen // NIEMALS hardcodierte Werte, immer aus context.ToolConfig! var meineSetting = context.ToolConfig.TryGetValue("meineSetting", out var val) ? val?.ToString() ?? "" : ""; // Logger verwenden context.Logger.LogInformation("MeinTool executing action: {Action}", action); return action switch { "read" => await HandleReadAsync(input, context, ct), "write" => await HandleWriteAsync(input, context, ct), _ => ToolResult.Fail($"Unknown action: {action}") }; } private async Task HandleReadAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { // Implementierung... await Task.CompletedTask; // Platzhalter return ToolResult.Ok("Ergebnis als JSON oder Text"); } private async Task HandleWriteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { // Implementierung... await Task.CompletedTask; // Platzhalter return ToolResult.Ok("Erfolgreich geschrieben"); } } ``` --- ## Konfiguration pro Agent Die Tool-Konfiguration ist **pro Agent** definiert, nicht global. Das bedeutet: Agent A kann auf Datenbank X zugreifen, Agent B auf Datenbank Y. ### In der Config-Datei (z.B. `config.json`): ```json { "agents": [ { "agentId": "analyst", "tools": { "MeinTool": { "meineSetting": "wert-fuer-agent-analyst", "andereOption": true } } }, { "agentId": "developer", "tools": { "MeinTool": { "meineSetting": "wert-fuer-agent-developer", "andereOption": false } } } ] } ``` ### Konfiguration im Tool auslesen: ```csharp // Im ExecuteAsync: var setting = context.ToolConfig["meineSetting"]?.ToString(); var option = context.ToolConfig.TryGetValue("andereOption", out var v) && v is JsonElement je && je.GetBoolean(); ``` **Wichtig:** `context.ToolConfig` enthält nur die Config für dieses Tool und diesen Agent. Du bekommst nie die Config eines anderen Agents oder eines anderen Tools. ### Rechte und Freigaben (#641) Was ein Agent mit einem Werkzeug ohne Rückfrage darf, steht in **seiner** Werkzeugkonfiguration – neben Verbindung, Tabellen und Verzeichnissen: ```json "Database": { "type": "mysql", "allowedTables": ["messwerte", "protokoll"], "erlaubt": ["select", "insert", "update"], "verboten": ["drop", "truncate"] } ``` - `erlaubt` läuft ohne Rückfrage. Ist die Liste gesetzt, wird **alles andere einzeln zur Freigabe vorgelegt** – nicht verweigert. Braucht der Agent einmal ein `alter`, entscheidet der Mensch über genau dieses eine. - `verboten` wird gar nicht erst vorgelegt. - `"*"` in `erlaubt` heißt: alles, was nicht verboten ist. - Ohne `erlaubt` gilt `IAgentTool.Vorgabe`, sonst die Standardregel (`StagingPolicy`). - Beim Freigeben kann der Mensch „Gleichartiges künftig erlauben“ wählen. Das erteilt eine widerrufbare **Dauerfreigabe** für genau diesen Agenten, dieses Werkzeug und diese Operation. Nach drei gleichartigen Freigaben in 14 Tagen schlägt die Oberfläche das vor; übernehmen muss der Mensch. - **Änderungen an Agenten** (Identität, Soul, neuer Agent) bleiben ausnahmslos einzeln freigabepflichtig. `erlaubt`, `"*"` und Dauerfreigaben heben das nicht auf. Was ein Werkzeug dafür liefert: | Mitglied | Zweck | |---|---| | `StagingAktion(input)` | Der Operationsschlüssel **aus dem Inhalt** des Aufrufs, nicht aus dem frei wählbaren `action`-Argument. Database: das SQL-Schlüsselwort (`select` für alles Lesende, sonst `insert`, `alter` …). | | `Operationen` | Die Operationen für die Rechte-Tabelle der Oberfläche. Vorgabe: die Werte der `action`-Aufzählung im Schema. | | `Vorgabe(operation, config)` | Die Einstufung ohne `erlaubt`. `null` = Standardregel. | | `Preflight(input, context)` | Nur harte Grenzen (fremde Tabelle, unlesbares SQL, Pfad außerhalb des Arbeitsbereichs). **Nicht** über die Operation urteilen – das tut die Freigabestelle. | `context.FreigabeGeprueft` ist `true`, wenn die Freigabestelle den Aufruf zugelassen hat (erlaubt, Dauerfreigabe oder einzeln freigegeben). Ein Werkzeug mit eigener Rechteprüfung lässt sie dann aus – sonst scheitert ein freigegebenes `alter` erst nach der Freigabe am Werkzeug. Ohne Freigabestelle bleibt es `false`, und das Werkzeug prüft selbst. --- ## Tool im Host registrieren Im `Host/Program.cs` (oder einer Startup-Klasse) wird das Tool registriert: ```csharp var registry = new ToolRegistry(); // Jedes Tool einzeln registrieren registry.Register(new MeinToolTool()); registry.Register(new DatabaseTool()); registry.Register(new FileRWTool()); // ... ``` Der `AgentEngine` nutzt dann die `ToolRegistry`, um für jeden Agent nur die erlaubten Tools bereitzustellen. --- ## Geplante Tools (Referenz) ### Database (`ClawdDotNet.Tools.Database`) | Eigenschaft | Wert | |---|---| | Name | `Database` | | NuGet | `MySqlConnector`, `Npgsql`, `Microsoft.Data.SqlClient`, `MongoDB.Driver` (SQLite über `Microsoft.Data.Sqlite` aus Core) | | Aktionen | `query`, `insert`, `upsert`, `describe`, `tables` | **Config-Felder:** - `connectionString` – Datenbankverbindung (pro Agent!) - `type` – `"mysql"`, `"postgres"`, `"mssql"`, `"sqlite"` oder `"mongodb"` **Schemaauskunft (#676):** `describe` nennt alle Spalten einer freigegebenen Tabelle. `tables` nennt die freigegebenen Tabellen und sagt, ob es sie gibt. Scheitert ein Statement an einer unbekannten Spalte oder Tabelle, nennt die Antwort, was vorhanden ist. Das gilt nur für freigegebene Tabellen. Die Abfragen dafür schreibt das Werkzeug selbst, mit dem Tabellennamen als Parameter. `PRAGMA` bleibt im Agenten-SQL verboten. - `allowedTables` – Liste erlaubter Tabellen (Whitelist) **Sicherheit:** - Parameterized Queries gegen SQL-Injection - Nur Tabellen aus `allowedTables` erlaubt - Connection String kommt IMMER aus `context.ToolConfig` --- ### FileRW (`ClawdDotNet.Tools.FileRW`) | Eigenschaft | Wert | |---|---| | Name | `FileRW` | | NuGet | (keine) | | Aktionen | `read`, `read_many`, `write`, `append`, `list`, `delete`, `copy`, `stock_add` | `read_many` liest mehrere Dateien in einem Aufruf – entweder `paths` (Liste) oder `path` (Verzeichnis) mit `pattern`. Gedeckelt auf 50 Dateien und 200.000 Zeichen; was nicht mehr hineinpasst, steht namentlich am Ende der Antwort. Der Grund steht in #663: Ein Lauf, der 24 Dateien einzeln liest, verbraucht 24 Schritte und stirbt an der Schrittgrenze – die soll Endlosschleifen abfangen, nicht ehrliche Arbeit. Schlägt ein Zugriff fehl, weil der Pfad im *anderen* Workspace liegt, sagt die Fehlermeldung das (#662) – aber nur, wenn der Agent den anderen Workspace auch benutzen darf. `list` zeigt die 50 zuletzt geänderten Einträge, neueste zuerst, eine Zeile je Eintrag; `pattern`, `sort` (`modified`/`name`), `order` (`desc`/`asc`) und `limit` (max 500) steuern das. Der Kopf nennt immer die Gesamtzahl – eine gedeckelte Liste, die ihre Deckelung verschweigt, ist die schlimmere Falle. Der Grund steht in #673: Ein Verzeichnis mit 105 Dateien, unsortiert und als eingerücktes JSON ausgegeben, ließ die Betriebskontrolle zwei vorhandene Dateien als fehlend melden. `pattern` ist ein **Dateiname mit Platzhaltern, kein Pfad**. Trennzeichen und `..` werden abgewiesen, weil `Directory.GetFiles` sie auswertet und ein Muster sonst an der Pfadprüfung vorbei aus dem Verzeichnis herausführt (#675). **Config-Felder:** - `rootPath` – Basisverzeichnis (Agent ist darin eingesperrt) - `allowWrite` – `true`/`false` - `allowedExtensions` – z.B. `[".txt", ".json", ".html"]` **Sicherheit (Pflicht):** - Path-Traversal-Check: `Path.GetFullPath(requested).StartsWith(Path.GetFullPath(rootPath))` - Nur erlaubte Dateiendungen - Schreibzugriff nur wenn `allowWrite = true` --- ### Mail (`ClawdDotNet.Tools.Mail`) | Eigenschaft | Wert | |---|---| | Name | `Mail` | | NuGet | `MailKit` | | Aktionen | `send`, `read_inbox`, `read_message`, `mark_read` | **Config-Felder:** - `imapHost`, `imapPort` – IMAP-Server - `smtpHost`, `smtpPort` – SMTP-Server - `username`, `password` – Zugangsdaten - `allowedRecipients` – Whitelist erlaubter Empfänger **Sicherheit:** - Empfänger müssen in `allowedRecipients` stehen --- ## AgentToolContext – Referenz ```csharp public sealed record AgentToolContext( string AgentId, // ID des ausführenden Agents string InstanceId, // ID der laufenden ClawdDotNet-Instanz IReadOnlyDictionary ToolConfig, // Tool-Config für DIESEN Agent ILogger Logger, // Logger (schreibt in Tool_{ToolName}.log) CancellationToken CancellationToken ); ``` --- ## ToolResult – Referenz ```csharp public sealed record ToolResult( bool Success, string Content, // Geht zurück ans LLM string? ErrorMessage = null ); // Hilfsmethoden: ToolResult.Ok("Ergebnis als JSON oder Text"); ToolResult.Fail("Fehlerbeschreibung"); ``` --- ## Background Jobs (optional) Manche Tools müssen regelmäßig im Hintergrund arbeiten, ohne dabei jedes Mal den Agenten (und damit das LLM) aufzuwecken. Beispiel: Ein Telegram-Tool prüft alle 30 Sekunden auf neue Nachrichten, weckt den Agenten aber **nur** wenn tatsächlich eine neue Nachricht vorliegt. Dafür gibt es das optionale Interface `IToolJobProvider`. Ein Tool kann es **zusätzlich** zu `IAgentTool` implementieren — es ist kein Ersatz. ### IToolJobProvider implementieren ```csharp using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; using Microsoft.Extensions.Logging; public sealed class MeinToolTool : IAgentTool, IToolJobProvider { // ... IAgentTool-Implementierung wie oben ... // 1. Verfügbare Job-Typen deklarieren public IReadOnlyList GetJobDefinitions() => [ new("meintool_check", "MeinTool Check", "Prüft regelmäßig auf Änderungen") ]; // 2. Job-Tick ausführen (kein LLM, nur Tool-Code!) public async Task ExecuteJobAsync( string jobTypeId, IReadOnlyDictionary toolConfig, IStateStore stateStore, ILogger logger, CancellationToken ct) { if (jobTypeId != "meintool_check") return ToolJobResult.NoAction($"Unbekannter Job: {jobTypeId}"); // Config auslesen (gleiche Felder wie in ExecuteAsync) var apiKey = toolConfig.TryGetValue("apiKey", out var v) ? v?.ToString() ?? "" : ""; // Zustand über IStateStore persistieren (überlebt Neustarts) var lastOffset = await stateStore.GetAsync("meintool_last_offset") ?? "0"; // Prüfung durchführen... var newItems = await CheckForUpdatesAsync(apiKey, lastOffset, ct); if (newItems.Count == 0) return ToolJobResult.NoAction("Keine neuen Einträge"); // Offset speichern await stateStore.SetAsync("meintool_last_offset", newItems.Last().Id); // Agent aufwecken mit Zusammenfassung return ToolJobResult.Wake( $"{newItems.Count} neue Einträge gefunden:\n" + string.Join("\n", newItems.Select(i => $"- {i.Title}")), logSummary: $"{newItems.Count} neue Einträge" ); } } ``` ### ToolJobResult Das Tool gibt deklarativ zurück, ob der Agent geweckt werden soll: | Methode | Effekt | |---------|--------| | `ToolJobResult.NoAction(logSummary?)` | Nichts tun, optional Log-Eintrag | | `ToolJobResult.Wake(wakeMessage, logSummary?)` | Agent **im bestehenden Chat** aufwecken (Default) | | `ToolJobResult.WakeStateless(wakeMessage, logSummary?)` | Agent in **isolierter Session** aufwecken (kein Chatverlauf) | **`Wake` vs `WakeStateless`:** - `Wake` (Default) nutzt `ChatAsync` — die Nachricht erscheint im Chat-Tab, der Agent behält den gesamten Konversationsverlauf. Ideal für alles was Teil einer laufenden Interaktion ist (Telegram-Nachrichten, fertige Transkripte, E-Mail-Antworten). - `WakeStateless` nutzt `RunAsync` — komplett isolierte Ausführung ohne Kontext. Nur für einmalige, kontextfreie Aufgaben verwenden. **Wichtig:** Das Tool hat keinen direkten Zugriff auf den `AgentEngine`. Der `ToolJobScheduler` wertet das Ergebnis aus und weckt den Agenten bei Bedarf. ### IStateStore für Zustandstracking `IStateStore` ist ein einfacher Key-Value-Store (SQLite-basiert), der zwischen Job-Ticks und Neustarts persistiert: ```csharp // Wert lesen (null wenn nicht vorhanden) string? value = await stateStore.GetAsync("mein_key"); // Wert schreiben await stateStore.SetAsync("mein_key", "neuer_wert"); ``` Verwende aussagekräftige Keys, z.B. `meintool_poll_offset` oder `meintool_last_check`. ### JSON-Konfiguration Tool Jobs werden pro Agent in der `config.json` konfiguriert, parallel zum bestehenden `scheduler`: ```json { "agentId": "support-bot", "tools": { "MeinTool": { "apiKey": "..." } }, "scheduler": [ { "cron": "0 8 * * 1-5", "taskMessage": "Morgenbericht erstellen" } ], "toolJobs": [ { "jobId": "abc12345", "toolName": "MeinTool", "jobTypeId": "meintool_check", "cron": "*/5 * * * *", "enabled": true, "runOnStart": false } ] } ``` | Feld | Beschreibung | |------|-------------| | `jobId` | Eindeutige ID (wird automatisch generiert) | | `toolName` | Name des Tools (muss `IToolJobProvider` implementieren) | | `jobTypeId` | Einer der von `GetJobDefinitions()` deklarierten IDs | | `cron` | Cron-Ausdruck für die Ausführungsintervalle | | `enabled` | Job aktiv/inaktiv | | `runOnStart` | Beim Programmstart sofort einmal ausführen | ### Unterschied: Agent Wakeup vs Tool Job | | Agent Wakeup (`scheduler`) | Tool Job (`toolJobs`) | |---|---|---| | **Ausführung** | Voller LLM-Run | Nur Tool-Code (kein LLM) | | **Kosten** | Token pro Tick | Kostenlos pro Tick | | **Agent-Aufruf** | Immer | Nur bei `ShouldWakeAgent` | | **Anwendungsfall** | Regelmäßige Aufgaben | Polling, Monitoring, Checks | --- ## Checkliste für neue Tools - [ ] Eigenes Projekt `ClawdDotNet.Tools.{Name}` anlegen - [ ] Nur `ClawdDotNet.Core` referenzieren - [ ] `IAgentTool` implementieren - [ ] `InputSchema` als gültiges JSON Schema definieren - [ ] Alle Konfiguration aus `context.ToolConfig` lesen - [ ] Alle `await`-Aufrufe mit `CancellationToken` versehen - [ ] `context.Logger` für Logging verwenden (kein `Console.WriteLine`) - [ ] Sicherheitsprüfungen implementieren (je nach Tool-Typ) - [ ] Schreibende Aktionen: `StagingAktion` aus dem Inhalt bestimmen, `Operationen` prüfen, eigene Rechteprüfung an `context.FreigabeGeprueft` koppeln (siehe „Rechte und Freigaben“) - [ ] Holt das Werkzeug Text von außen herein (Web, Mail, Chat, fremde Server, Datenbanken mit Kundendaten)? Dann `ReadsForeignContent => true` setzen. Daraus folgt die Vertrauensstufe des Agenten und damit sein Berichtsweg (#715, `ReportingLine`). Die Vorgabe `false` fällt nicht auf, wenn sie falsch ist. - [ ] Im Host registrieren: `registry.Register(new MeinToolTool());` - [ ] In der Solution-Datei (.slnx) einbinden - [ ] NuGet-Packages in `NuGet.Config` PackageSourceMapping ergänzen - [ ] *Optional:* `IToolJobProvider` implementieren (wenn Hintergrund-Polling nötig) - [ ] *Optional:* `GetJobDefinitions()` mit eindeutigen `JobTypeId`s definieren - [ ] *Optional:* `ExecuteJobAsync()` implementieren, `IStateStore` für Zustandstracking nutzen --- ## Logging Das Logging-System trennt automatisch nach Modul. Wenn dein Tool den Logger aus dem `AgentToolContext` verwendet, landen die Logs automatisch in: ``` Logs/ ├── 2026-05-12/ │ ├── Core.log ← Engine, Scheduler, Config │ ├── Host.log ← Oberflaeche │ ├── Tool_Database.log ← Database-Tool │ ├── Tool_FileRW.log ← FileRW-Tool │ ├── Tool_MeinTool.log ← Dein Tool! │ └── ... ``` Die Zuordnung geschieht über den Namespace: - `ClawdDotNet.Core.*` → `Core.log` - `ClawdDotNet.Tools.{Name}.*` → `Tool_{Name}.log` - `ClawdDotNet.Host.*` → `Host.log` Log-Level: `Debug`, `Info`, `Warn`, `Error`