FoehnAIBuilder uses a plugin-based architecture where every tool implements a standard .NET class that conforms to the ITool C# interface. As part of the startup process, the application scans the plugin directory, loads all the available assemblies, and includes all that implement this contract.
namespace FoehnAIBuilder.Abstractions;
public interface ITool
{
string Name { get; }
string Description { get; }
string Command { get; }
ToolRiskLevel RiskLevel { get; }
Task<ToolExecutionResult> ExecuteAsync(string argumentsJson, CancellationToken cancellationToken = default);
}
Tools also have a risk level (considering increasing the number of options and training an ML.NET model to detect potentially malicious arguments), so the host application can apply safety controls such as requiring user confirmation before operations that may have significant side effects.
public enum ToolRiskLevel
{
Undefined = 0,
ReadOnly,
Write,
Destructive,
}
FoehnAIBuilder enforces a maximum tool iteration count. This prevents runaway execution loops, stops the context growing to the point where it impacts on the LLM’s performance (The dumb zone), and “burning” lots of Tokens
"Mistral": {
"BaseUrl": "https://api.mistral.ai",
"APIKey": "This is not the APIKey you are looking for",
"DefaultModel": "devstral-latest",
"TimeoutSeconds": 120,
"MaxRetries": 3,
"EnableStreaming": false
},
"FoehnAIBuilder": {
"SystemMessageFile": "foehn.md",
"PluginsPath": ".plugins",
"WorkingDirectory": "",
"MaxToolIterations": 30
},
}
Each tool exposes metadata that allows the LLM to understand how to invoke it. This includes a unique function name, a human-readable description, and a JSON Schema describing the parameters the tool expects. I’m considering implementing the parameters for a plug-in using Data Transfer Objects (DTO) rather than the current approach using strings.
public string Name => "scan";
public string Description =>
"Recursively lists files and directories under a given path, or the current working " +
"folder if no path is supplied. Use this first to discover what exists before reading, " +
"writing, deleting, or executing anything.";
public string Command => """
{
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory to scan. Defaults to the application's current working folder if omitted." },
"pattern": { "type": "string", "description": "Search pattern, e.g. '*.cs'. Defaults to '*' (all files)." },
"recursive": { "type": "boolean", "description": "Whether to recurse into subdirectories. Defaults to true." }
},
"required": []
}
The plugins have code to detect an LLM directory escape with a path in a parameter like “directory to scan”. When the LLM chooses to invoke a tool, FoehnAIBuilder calls the tool’s ExecuteAsync method and passes the arguments as a JSON document that conforms to the schema exposed by the tool.
FoehnAIBuilder processes the request and returns a ToolExecutionResult, which provides a standardised way for both the application and the LLM to understand the outcome. The result contains a boolean success indicator and a descriptive message that may include returned data, status information, or error details.
public sealed class ToolExecutionResult
{
public required bool Success { get; init; }
public required string Result { get; init; }
public static ToolExecutionResult Ok(string result) => new() { Success = true, Result = result };
public static ToolExecutionResult Fail(string result) => new() { Success = false, Result = result };
}
ToolExecutionResult approach follows the result pattern rather than an exception-driven programming model. Every tool invocation returns a result containing both a success indicator and a human-readable message describing the outcome. This provides a consistent contract between the tool, the host, and the language model. This allows the LLM to reason about both successful operations and expected failure conditions such as validation errors, missing resources, or access restrictions.
The plug-in implementations handle and translate all anticipated error conditions into a ToolExecutionResult.Fail response rather than allowing exceptions to propagate to the FoehnAIBuilder host (this would be bad). Returning structured failure information enables the language model to understand what went wrong and potentially adjust its behaviour and retry with different inputs.
try
{
var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
...
return Task.FromResult(ToolExecutionResult.Ok(sb.ToString()));
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
_logger.LogError(ex, "Error scanning {Path}", path);
return Task.FromResult(ToolExecutionResult.Fail($"Error scanning \"{path}\": {ex.Message}"));
}
Exceptions are reserved for genuinely unexpected conditions such as programming errors, infrastructure failures, or unrecoverable runtime errors. As a general rule, no exception in a tool plug-in should be returned to FoehnAIBuilder for business logic or user-correctable error, these should always be represented as a failed ToolExecutionResult containing a clear and actionable explanation of the problem.
The next couple of posts will explore progressively more capable (read dangerous) operations. First, file and directory tools, where path traversal, deletion, and privilege boundaries (file and directory permissions) introduce real risk. Then plug-ins for application execution and the additional safeguards required when an LLM can launch processes. After having confirm a lots of application executions requests, I have added a specialised plug-in wrapper for the dotnet command‑line tool that can be operated with lower risk.



























