Voir aussi : Inventaire des outils · Retour à l'index
Pattern — Implémenter un nouvel outil
Ce guide détaille le processus complet de création d'un outil Orkeon, basé sur les classes et interfaces existantes dans le code source.
Étape 1 — Choisir la classe de base
Orkeon fournit trois classes de base dans Orkeon.Tools.Abstractions.Base :
| Classe de base | Usage | Protection intégrée |
|---|---|---|
ToolBase<TRequest, TResponse> |
Outil générique | Aucune spécialisation |
FileToolBase<TRequest, TResponse> |
Opérations sur fichiers | IFileSystemService (le VFS — premier argument de ctor obligatoire) + IPathValidator (protection path traversal) |
HttpToolBase<TRequest, TResponse> |
Opérations HTTP/API | IUrlValidator (protection SSRF), HttpHeaderSanitizer |
La classe de base ToolBase<TRequest, TResponse> hérite de ToolBase (non-generic) qui implémente IBaseTool et ITool (Orkeon.Domain.Tools).
Étape 2 — Définir les types Request et Response
Chaque outil typé nécessite un record TRequest (paramètres d'entrée) et un record TResponse (résultat). Les attributs [FieldSchema] et [ReturnSchema] (Orkeon.Domain.Attributes) servent à générer automatiquement le schéma JSON exposé au LLM.
Attribut [FieldSchema] (sur TRequest)
Propriétés disponibles :
[FieldSchema(
Description = "...", // Description lisible pour le LLM
Type = "string", // Type JSON Schema (inféré si omis)
Format = "uri", // Format OpenAPI (inféré si omis)
IsRequired = true, // Required (inféré de la nullabilité C# si omis)
Default = "value", // Valeur par défaut YAML
Enum = new[] { "a", "b" }, // Valeurs autorisées
Example = "example", // Exemple pour la documentation
ItemsType = "string", // Type des éléments si array
ItemsFormat = "...", // Format des éléments si array
TypeDefinitionRef = "..." // Référence à un type défini
)]
Attribut [ReturnSchema] (sur TResponse)
[ReturnSchema(
Description = "...", // Description du champ retourné
Type = "boolean", // Type JSON Schema (inféré si omis)
Format = "...", // Indice de format JSON Schema
ItemsType = "...", // Type des éléments si array
ItemsFormat = "...", // Format des éléments si array
TypeDefinitionRef = "...", // Référence vers un type défini
Example = true // Exemple
)]
Exemple concret — Request et Response
using Orkeon.Domain.Attributes;
public sealed record TranslateRequest
{
[FieldSchema(Description = "Text to translate", IsRequired = true,
Example = "Hello, world!")]
public string Text { get; init; } = "";
[FieldSchema(Description = "Target language ISO code", IsRequired = true,
Example = "fr", Enum = new[] { "fr", "de", "es", "it", "pt", "ja", "zh" })]
public string TargetLanguage { get; init; } = "";
[FieldSchema(Description = "Source language (auto-detected if omitted)",
IsRequired = false, Default = "auto")]
public string SourceLanguage { get; init; } = "auto";
}
public sealed record TranslateResponse
{
[ReturnSchema(Description = "Whether the translation succeeded")]
public bool Success { get; init; }
[ReturnSchema(Description = "Translated text")]
public string TranslatedText { get; init; } = "";
[ReturnSchema(Description = "Detected source language")]
public string DetectedLanguage { get; init; } = "";
[ReturnSchema(Description = "Error message if translation failed")]
public string? Error { get; init; }
}
Étape 3 — Implémenter la classe outil
Hériter de la classe de base choisie, poser un attribut [ToolContract] sur la
classe et implémenter ExecuteTypedAsync. Le premier argument positionnel du contrat
est le nom visible par les agents (UniqueName — la chaîne exacte que les listes
YAML tools: utilisent, celle que la porte CI des doc-claims vérifie contre
docs/tools/inventory.md) ; Name, Description et Category voyagent sur le même
attribut (ToolBase les lit : Name => contract?.UniqueName ?? contract?.Name ?? GetType().Name). Un outil sans contrat se rabat sur une surcharge des propriétés
Name/Description — chaque outil livré utilise l'attribut.
[ToolContract("weather_lookup",
Description = "Météo courante d'une ville via l'API du fournisseur.")]
public class WeatherTool : HttpToolBase<WeatherRequest, WeatherResponse> { … }
Pipeline automatique
Le pipeline de ToolBase<TRequest, TResponse> est scellé (sealed override ExecuteCoreAsync) et effectue automatiquement :
- Injection des défauts YAML : les paramètres optionnels absents sont complétés par leurs valeurs
Default - Désérialisation :
Dictionary<string, object?>→TRequestviaComponentBase<TRequest, TResponse> - Validation : appel optionnel de
ValidateTypedRequest(TRequest)— retournernullsi valide, un message d'erreur sinon - Exécution : appel de
ExecuteTypedAsync(TRequest, CancellationToken)— votre logique métier - Sérialisation :
TResponse→Dictionary<string, object?> - Filtrage : seuls les champs déclarés dans
[ReturnSchema]sont inclus dans la réponse
Exemple complet — Outil simple (sans dépendance externe)
using Microsoft.Extensions.Logging;
using Orkeon.Tools.Abstractions.Base;
namespace Orkeon.Tools.Data;
public sealed class TranslateTool : ToolBase<TranslateRequest, TranslateResponse>
{
public TranslateTool(ILogger<TranslateTool>? logger = null) : base(logger) { }
public override string Name => "translate";
public override string Description =>
"Translate text from one language to another. " +
"Source language is auto-detected if not specified.";
protected override string? ValidateTypedRequest(TranslateRequest request)
{
if (string.IsNullOrWhiteSpace(request.Text))
return "Text to translate cannot be empty.";
if (string.IsNullOrWhiteSpace(request.TargetLanguage))
return "Target language must be specified.";
return null; // Valide
}
protected override async Task<TranslateResponse> ExecuteTypedAsync(
TranslateRequest request,
CancellationToken cancellationToken)
{
try
{
// Logique métier — ici une traduction simplifiée
var translated = $"[{request.TargetLanguage}] {request.Text}";
return new TranslateResponse
{
Success = true,
TranslatedText = translated,
DetectedLanguage = request.SourceLanguage == "auto" ? "en" : request.SourceLanguage
};
}
catch (Exception ex)
{
return new TranslateResponse
{
Success = false,
Error = ex.Message
};
}
}
}
Étape 4 — Exemple complet — Outil avec dépendance externe (HTTP)
Pour un outil appelant une API externe, utiliser HttpToolBase<TRequest, TResponse> qui fournit un HttpClient partagé et la validation d'URL.
using Microsoft.Extensions.Logging;
using Orkeon.Tools.Abstractions.Base;
using Orkeon.Domain.Attributes;
using System.Net.Http.Json;
using System.Text.Json;
namespace Orkeon.Tools.Web;
// --- Request ---
public sealed record WeatherRequest
{
[FieldSchema(Description = "City name to get weather for",
IsRequired = true, Example = "Paris")]
public string City { get; init; } = "";
[FieldSchema(Description = "Temperature unit",
IsRequired = false, Default = "celsius",
Enum = new[] { "celsius", "fahrenheit" })]
public string Unit { get; init; } = "celsius";
}
// --- Response ---
public sealed record WeatherResponse
{
[ReturnSchema(Description = "Whether the request succeeded")]
public bool Success { get; init; }
[ReturnSchema(Description = "Current temperature")]
public double Temperature { get; init; }
[ReturnSchema(Description = "Weather description")]
public string Description { get; init; } = "";
[ReturnSchema(Description = "Error message if request failed")]
public string? Error { get; init; }
}
// --- Tool ---
public sealed class WeatherTool : HttpToolBase<WeatherRequest, WeatherResponse>
{
private const string BaseUrl = "https://api.weatherapi.com/v1";
private readonly string _apiKey;
// Ctor simple (HttpClient statique partagé). Pour la protection SSRF, utiliser le ctor
// base(IUrlValidator, HttpHeaderSanitizer, HttpClient?, ILogger?).
public WeatherTool(
string apiKey,
HttpClient? httpClient = null,
ILogger<WeatherTool>? logger = null)
: base(httpClient, logger)
{
_apiKey = apiKey;
}
public override string Name => "weather";
public override string Description =>
"Get current weather for a city. Returns temperature and conditions.";
protected override async Task<WeatherResponse> ExecuteTypedAsync(
WeatherRequest request,
CancellationToken cancellationToken)
{
try
{
var url = $"{BaseUrl}/current.json?key={_apiKey}&q={Uri.EscapeDataString(request.City)}";
// HttpClient est disponible via le champ protégé _httpClient (hérité de HttpToolBase)
var response = await _httpClient.GetAsync(url, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>(
cancellationToken: cancellationToken).ConfigureAwait(false);
var tempC = json.GetProperty("current").GetProperty("temp_c").GetDouble();
var condition = json.GetProperty("current")
.GetProperty("condition")
.GetProperty("text")
.GetString() ?? "Unknown";
var temp = request.Unit == "fahrenheit" ? tempC * 9.0 / 5.0 + 32 : tempC;
return new WeatherResponse
{
Success = true,
Temperature = Math.Round(temp, 1),
Description = condition
};
}
catch (HttpRequestException ex)
{
return new WeatherResponse
{
Success = false,
Error = $"HTTP error: {ex.Message}"
};
}
}
}
Étape 5 — Enregistrement et utilisation
Option A — Injection directe via le builder
L'approche la plus simple : instancier l'outil et le passer au builder d'agent.
var weatherTool = new WeatherTool(apiKey: "my-api-key");
var agent = new AgentBuilder()
.Role("Weather Reporter")
.Goal("Provide accurate weather forecasts")
.WithTool(weatherTool)
.Build();
Option B — Enregistrement DI via IServiceCollection
Pour une intégration complète dans le pipeline d'injection de dépendances :
// Dans une extension method ou Startup
services.AddSingleton<IBaseTool>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var logger = sp.GetService<ILogger<WeatherTool>>();
return new WeatherTool(
apiKey: config["Weather:ApiKey"]!,
logger: logger);
});
L'outil est alors disponible via IEnumerable<IBaseTool>. La résolution par nom
depuis le YAML exige un registre adossé à la DI : le AddOrkeonInfrastructure()
par défaut enregistre le stub vide InMemoryToolRegistry, qui ne voit jamais vos
enregistrements IBaseTool — le runner host substitue ServiceProviderToolRegistry
(Orkeon.Hosting), et un hôte qui embarque doit faire de même :
services.AddSingleton<IToolRegistry, ServiceProviderToolRegistry>();
Option C — Via IToolRegistry
IToolRegistry (Orkeon.Domain.Tools, implémentation InMemoryToolRegistry) permet l'enregistrement et la résolution dynamique d'outils par nom :
var toolRegistry = serviceProvider.GetRequiredService<IToolRegistry>();
var tool = await toolRegistry.GetToolByNameAsync("weather");
Étape 6 — Pattern de composition interne (FileToolBase / HttpToolBase)
Les classes FileToolBase<TRequest, TResponse> et HttpToolBase<TRequest, TResponse> utilisent un pattern de composition via une classe interne privée ComponentPipeline qui hérite de ComponentBase<TRequest, TResponse> (couche Domain).
Ce pattern permet à la classe outil d'accéder aux méthodes de sérialisation/désérialisation du pipeline typé sans hériter directement de ComponentBase (qui est dans la couche Domain et ne connaît pas les concepts d'outils).
Architecture du pipeline
ToolBase<TRequest, TResponse> (ou FileToolBase<> / HttpToolBase<>)
│
├── Contient : private ComponentPipeline _pipeline
│ └── hérite de ComponentBase<TRequest, TResponse>
│ └── délègue à IComponentSerializer (statique)
│ └── JsonComponentSerializer (singleton)
│
└── sealed override ExecuteCoreAsync()
1. MergeWithYamlDefaults(parameters) → Dict enrichi
2. _pipeline.Deserialize(merged) → TRequest
3. ValidateTypedRequest(typedRequest) → null | erreur
4. ExecuteTypedAsync(request, ct) → TResponse ← VOTRE CODE
5. _pipeline.Serialize(typedResponse) → Dict
6. FilterOutput(resultDict) → Dict filtré
7. return ToolCallResponse(success, result, error)
Extrait du code source (FileToolBaseGeneric.cs)
public abstract partial class FileToolBase<TRequest, TResponse> : FileToolBase
where TRequest : class, new()
where TResponse : class
{
private readonly ComponentPipeline _pipeline = new();
// Le pipeline scellé empêche les sous-classes de court-circuiter
// la sérialisation ou la validation
protected sealed override async Task<ProtocolToolCallResponse> ExecuteCoreAsync(
ProtocolToolCallRequest request, CancellationToken cancellationToken)
{
var mergedParams = MergeWithYamlDefaults(request.Parameters);
var typedRequest = _pipeline.Deserialize(mergedParams);
var validationError = ValidateTypedRequest(typedRequest);
if (validationError is not null)
return new ProtocolToolCallResponse(Success: false, Result: null, Error: validationError);
var typedResponse = await ExecuteTypedAsync(typedRequest, cancellationToken);
var resultDict = _pipeline.Serialize(typedResponse);
var filteredResult = FilterOutput(resultDict);
// ... propagation success/error ...
return new ProtocolToolCallResponse(Success: success, Result: filteredResult, Error: error);
}
// Votre point d'extension — logique métier pure
protected abstract Task<TResponse> ExecuteTypedAsync(
TRequest request, CancellationToken cancellationToken);
// Classe interne de composition
private sealed class ComponentPipeline : ComponentBase<TRequest, TResponse>
{
public TRequest Deserialize(Dictionary<string, object> parameters)
=> DeserializeRequest(parameters);
public Dictionary<string, object> Serialize(TResponse response)
=> SerializeResponse(response);
protected override Task<TResponse> ExecuteTypedAsync(
TRequest request, CancellationToken ct)
=> throw new NotSupportedException("Pipeline helper does not execute.");
}
}
Sérialisation : snake_case et coercition de types
Le JsonComponentSerializer (Orkeon.Infrastructure.Serialization) utilise JsonNamingPolicy.SnakeCaseLower et inclut 10 convertisseurs tolérants pour gérer les valeurs YAML qui arrivent sous forme de strings — les six ci-dessous plus TolerantEnumConverterFactory, UriTolerantConverter, ImmutableArrayEnumTolerantConverter<EdgeKind> et ImmutableArrayStringTolerantConverter :
BoolTolerantConverter:"true","1","yes"→trueIntTolerantConverter:"42"→42LongTolerantConverter:"123456789"→123456789LDecimalInvariantConverter:"3.14"→3.14m(culture-invariant)DoubleInvariantConverter:"3.14"→3.14dRawObjectConverter: unwrapJsonElementvers types .NET natifs
Cela garantit que les paramètres YAML (qui sont tous des strings à la désérialisation) sont correctement convertis vers les types C# attendus par TRequest.
Étape 7 — Enregistrer un outil dans une suite (package NuGet)
Si l'outil est destiné à être distribué dans un package, créer une extension DI dans le même projet :
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Orkeon.Tools.MyPackage;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOrkeonMyPackageTools(
this IServiceCollection services)
{
// Un enregistrement IBaseTool par outil. PAS TryAddSingleton<IBaseTool, …>
// deux fois : TryAdd est indexé sur le type de SERVICE, le second appel
// serait un no-op silencieux. Les outils à arguments de ctor hors-DI
// (l'apiKey de WeatherTool) exigent la forme factory.
services.AddSingleton<IBaseTool>(sp => new WeatherTool(
apiKey: sp.GetRequiredService<IConfiguration>()["Weather:ApiKey"]!,
logger: sp.GetService<ILogger<WeatherTool>>()));
services.TryAddEnumerable(ServiceDescriptor.Singleton<IBaseTool, TranslateTool>());
// Utilisables par nom en YAML dès qu'un IToolRegistry adossé à la DI est
// enregistré (ServiceProviderToolRegistry — voir l'Option A plus haut).
return services;
}
}
Usage dans le Program.cs :
services.AddOrkeonApplication();
services.AddOrkeonInfrastructure();
services.AddOrkeonMyPackageTools(); // Vos outils custom
Récapitulatif du pattern
1. Choisir la base class → ToolBase<> / FileToolBase<> / HttpToolBase<>
2. Définir TRequest → Record avec [FieldSchema] sur chaque propriété
3. Définir TResponse → Record avec [ReturnSchema] sur chaque propriété
4. Implémenter la classe → override Name, Description, ExecuteTypedAsync
5. (Optionnel) Validation → override ValidateTypedRequest
6. Enregistrer → Builder / DI / ToolFactory
Le schéma JSON est auto-généré par ToolSchemaGenerator à partir des attributs — aucune maintenance manuelle requise.