Files
ClawdDotNet/docs/ToolDevelopmentGuide.md
T
RichardandClaude Opus 5 24e457b11f FileRW: Sammel-Lesen (#663) und Hinweis auf den anderen Workspace (#662)
#663: Neue Aktion 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, wird namentlich genannt, damit der
Agent gezielt nachholen kann. Anlass: Der Tagesabschluss im Testbetrieb las 24
Stundendateien einzeln und starb zweimal an der Schrittgrenze - die soll
Endlosschleifen abfangen, nicht ehrliche Arbeit.

#662: Schlaegt read oder list fehl, weil der Pfad im anderen Workspace liegt,
sagt die Fehlermeldung das - aber nur, wenn der Agent diesen Workspace auch
benutzen darf. Guenstige Modelle vergassen workspace "shared" regelmaessig und
verbrauchten mit jedem Rateversuch einen Schritt.

Dateipruefung, Endungen und Workspace-Grenze gelten in read_many unveraendert;
eine verbotene Endung wird uebergangen statt den Lauf zu kippen.

Version 0.1.14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 15:53:08 +02:00

18 KiB
Raw Blame History

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

<!-- ClawdDotNet.Tools.MeinTool.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>ClawdDotNet.Tools.MeinTool</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <!-- NUR Core referenzieren, KEINE anderen Tools -->
    <ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
  </ItemGroup>

  <ItemGroup>
    <!-- Tool-spezifische NuGet-Packages hier -->
  </ItemGroup>
</Project>

IAgentTool implementieren

Jedes Tool implementiert das Interface ClawdDotNet.Core.Tools.IAgentTool:

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<ToolResult> 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<ToolResult> HandleReadAsync(
        JsonElement input, AgentToolContext context, CancellationToken ct)
    {
        // Implementierung...
        await Task.CompletedTask; // Platzhalter
        return ToolResult.Ok("Ergebnis als JSON oder Text");
    }

    private async Task<ToolResult> 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):

{
  "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:

// 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:

"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:

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, MongoDB.Driver
Aktionen query, insert, upsert

Config-Felder:

  • connectionString – Datenbankverbindung (pro Agent!)
  • type – "mysql" oder "mongodb"
  • 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.

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

public sealed record AgentToolContext(
    string AgentId,          // ID des ausführenden Agents
    string InstanceId,       // ID der laufenden ClawdDotNet-Instanz
    IReadOnlyDictionary<string, object?> ToolConfig,  // Tool-Config für DIESEN Agent
    ILogger Logger,          // Logger (schreibt in Tool_{ToolName}.log)
    CancellationToken CancellationToken
);

ToolResult – Referenz

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

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<ToolJobDefinition> 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<ToolJobResult> ExecuteJobAsync(
        string jobTypeId,
        IReadOnlyDictionary<string, object?> 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:

// 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:

{
  "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“)
  • 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 JobTypeIds 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