Vitruvian 0.2.4
dotnet tool install --global Vitruvian --version 0.2.4
dotnet new tool-manifest
dotnet tool install --local Vitruvian --version 0.2.4
#tool dotnet:?package=Vitruvian&version=0.2.4
nuke :add-package Vitruvian --version 0.2.4
<div align="center"> <img src="docs/logo.svg" alt="Vitruvian Logo" width="200"/> </div>
Vitruvian — .NET GOAP Agent Runtime
.NET GOAP agent runtime that decomposes user requests into dependency-aware execution plans, runs independent steps in parallel, and enforces human-in-the-loop approval, caching, and memory — all before any side-effecting action fires.
Vitruvian Agent Runtime — modular, GOAP-driven AI agent orchestration for .NET.
Third-party modules plug in via a single interface (IVitruvianModule). The host handles planning, routing, governance, and security so module authors focus only on capability logic.
Quick Start
Prerequisites
- .NET 8 SDK (8.0.100 or later)
- Git
- An API key for OpenAI, Anthropic, or Google Gemini
Clone, build, and test
git clone https://github.com/mrrasmussendk/Vitruvian.git
cd Vitruvian
dotnet build Vitruvian.sln
dotnet test Vitruvian.sln
Configure a model provider
Create a .env.Vitruvian file in the project root (loaded automatically at startup):
VITRUVIAN_MODEL_PROVIDER=OpenAI
VITRUVIAN_OPENAI_API_KEY=sk-...
Or use the guided installer — see docs/INSTALL.md for all options.
Run the CLI
dotnet run --project src/Vitruvian.Cli
Vitruvian CLI started. Type a request (or 'quit' to exit):
>
Try some requests:
> What is the weather tomorrow?
> Create a file called notes.txt with content "Hello World"
> Read notes.txt then summarize it
Features
| Feature | Description |
|---|---|
| GOAP Planning | Decomposes requests into dependency-aware plans before execution. Multi-step tasks are broken into steps that run in parallel when independent. Each step gets an optional complexity hint for model routing. |
| Conditions & Fallbacks | Steps can declare preconditions (dependency-success gate) and postconditions (keyword match on output). A FallbackStepId names an alternative step that runs only when the primary fails. |
| Replanning | When a plan fails and a model client is available, the executor asks the planner to produce a revised plan (ReplanAsync) and retries automatically (configurable via MaxReplans). |
| Multithreaded Execution | Independent plan steps execute concurrently via Task.WhenAll. Dependent steps wait for their prerequisites. |
| Human-in-the-Loop (HITL) | Write, delete, and execute operations are gated through IApprovalGate. Default-deny on timeout. Full audit trail. |
| Result Caching | Identical (module, input) pairs return cached output, avoiding redundant LLM calls or side effects. |
| Compound Requests | Multi-intent messages are automatically split and each sub-task runs through the full pipeline independently. |
| Conversation History | In-memory conversation history (last 10 turns) provides context-aware routing and execution across turns. |
| Module Extensibility | Implement IVitruvianModule, register via DI or drop a DLL into plugins/ — the GOAP planner discovers it automatically. |
| Security | Linux-style permissions, HITL approval, sandboxed execution with resource limits, and signed-plugin enforcement. |
Architecture
Every user request passes through three phases:
User Request
│
▼
┌──────────┐
│ PLAN │ GoapPlanner builds an ExecutionPlan
└────┬─────┘ (PlanSteps + dependency edges + conditions)
│
▼
┌──────────┐ PlanExecutor runs steps in waves:
│ EXECUTE │ • Precondition gate → Cache check → HITL gate
│ │ • Context injection → Module.ExecuteAsync
│ │ • Postcondition gate → Cache store
│ │ • On failure → run fallback step (if defined)
└────┬─────┘ Independent steps run in parallel
│
▼
┌──────────┐ If plan failed and ReplanCallback is set:
│ REPLAN? │ GoapPlanner.ReplanAsync produces a revised plan
└────┬─────┘ (up to MaxReplans attempts, default 1)
│
▼
┌──────────┐
│ MEMORY │ Store plan result + conversation turn
└────┬─────┘
│
▼
Response
The two core abstractions:
IVitruvianModule— every capability (built-in or third-party) implements this single interface.GoapPlanner— takes a request and the list of registered modules, produces anExecutionPlanwithPlanStepnodes,DependsOnedges, optional conditions, and fallback references. Also providesReplanAsyncto produce a revised plan after a failure.
For a deeper dive see docs/ARCHITECTURE.md.
Complexity-Based Model Routing
Each PlanStep carries an optional Complexity hint (Low, Medium, High) that the GOAP planner assigns automatically when building multi-step plans. The hint flows through to ModelRequest.Complexity, allowing IModelClient implementations to route requests to different underlying models based on task difficulty.
| Complexity | Intended Use | Example Model |
|---|---|---|
Low |
Simple lookups, formatting, trivial transforms | gpt-4o-mini |
Medium |
Moderate reasoning, composition, multi-fact answers | Default model |
High |
Deep reasoning, multi-step logic, creative tasks | gpt-4o, claude-sonnet-4 |
Routing in IModelClient
public Task<ModelResponse> GenerateAsync(ModelRequest request, CancellationToken ct)
{
var model = request.Complexity switch
{
Complexity.Low => "gpt-4o-mini",
Complexity.High => "gpt-4o",
_ => _defaultModel
};
// route to selected model...
}
The complexity hint is entirely optional — when null, the default model is used. Single-step and fallback plans leave it unset. No existing code is affected.
Conditions, Fallbacks & Replanning
The GOAP planner can attach optional preconditions, postconditions, and fallback steps to any plan step. These let the executor validate step feasibility, detect soft failures, and recover — all without leaving the current plan.
Preconditions
A Precondition is a natural-language description of what must hold before a step runs. When set, the executor checks that every step listed in DependsOn succeeded. If any dependency failed, the step is skipped and marked as failed.
Postconditions
A Postcondition is a keyword or phrase that must appear (case-insensitive) in the step output for the result to be considered successful. This catches situations where a module returns a response but the content indicates failure (e.g. "not found").
Fallback Steps
A FallbackStepId names an alternative step in the same plan. Fallback steps are excluded from normal wave execution and only triggered when the primary step fails (precondition, postcondition, module error, or HITL denial). The fallback result is recorded under the original step ID so downstream dependencies continue to resolve.
Replanning
When the overall plan fails and a model client is available, the PlanExecutor invokes GoapPlanner.ReplanAsync to produce a revised plan based on a summary of what failed. The executor then runs the new plan. The number of replan attempts is controlled by MaxReplans (default: 1 when wired through RequestProcessor).
Example Plan
[
{"step_id":"s1","module":"web-search","description":"Search web","input":"find info",
"depends_on":[],"postcondition":"found","fallback_step_id":"s1-fb"},
{"step_id":"s1-fb","module":"conversation","description":"Answer from knowledge",
"input":"answer from what you know","depends_on":[]},
{"step_id":"s2","module":"conversation","description":"Summarize","input":"summarize",
"depends_on":["s1"],"precondition":"search must succeed"}
]
In this plan:
- s1 searches the web. If the output doesn't contain "found", the postcondition fails.
- s1-fb runs only if s1 fails, answering from built-in knowledge instead.
- s2 depends on s1. Its precondition ensures it is skipped if the search (or its fallback) didn't succeed.
Building a Module
Create a net8.0 class library, reference Vitruvian.Abstractions, and implement IVitruvianModule:
using VitruvianAbstractions.Interfaces;
public sealed class TranslationModule : IVitruvianModule
{
private readonly IModelClient? _modelClient;
public string Domain => "translation";
public string Description => "Translate text between languages using AI";
public TranslationModule(IModelClient? modelClient = null)
=> _modelClient = modelClient;
public async Task<string> ExecuteAsync(string request, string? userId, CancellationToken ct)
{
if (_modelClient is null) return "No model configured for translation.";
return await _modelClient.GenerateAsync(
$"Translate the following as requested: {request}", ct);
}
}
Register via DI or drop the compiled DLL into plugins/. See docs/EXTENDING.md for the complete guide including SDK attributes, permissions, and API key declarations.
Using Tools with IModelClient
Modules interact with AI models through the IModelClient interface. When a module needs the model to call specific tools (functions), Vitruvian provides a fluent API for defining tools and managing the tool execution loop.
Defining Tools with ModelToolBuilder
Instead of manually constructing parameter dictionaries, use ModelToolBuilder:
using VitruvianAbstractions.Interfaces;
// Define a function tool with typed parameters
var searchTool = new ModelToolBuilder("search_web", "Search the web for information")
.AddParameter("query", "The search query text")
.AddParameter("maxResults", "Maximum number of results to return")
.Build();
var calculatorTool = new ModelToolBuilder("calculate", "Evaluate a math expression")
.AddParameter("expression", "The mathematical expression to evaluate")
.Build();
Automatic Tool Execution Loop
Use ExecuteWithToolsAsync to let the model call tools and receive results automatically. The loop continues until the model produces a final text response:
// Single callback handler
var result = await modelClient.ExecuteWithToolsAsync(
systemMessage: "You are a helpful assistant with access to search and calculation tools.",
userMessage: "What is the population of Denmark divided by 3?",
tools: [searchTool, calculatorTool],
toolHandler: async (toolName, toolArgs, ct) =>
{
return toolName switch
{
"search_web" => await SearchWeb(toolArgs),
"calculate" => Evaluate(toolArgs),
_ => $"Unknown tool: {toolName}"
};
});
// Or use named handler routing
var handlers = new Dictionary<string, Func<string?, CancellationToken, Task<string>>>
{
["search_web"] = async (args, ct) => await SearchWeb(args),
["calculate"] = (args, _) => Task.FromResult(Evaluate(args))
};
var result = await modelClient.ExecuteWithToolsAsync(
"You are a helpful assistant.", userMessage,
[searchTool, calculatorTool], handlers);
Getting Full Tool Call Information
When you need to inspect which tool the model called (and its arguments), use CompleteWithToolInfoAsync:
var response = await modelClient.CompleteWithToolInfoAsync(
systemMessage: "You are a helpful assistant.",
userMessage: "Search for the latest .NET release",
tools: [searchTool]);
if (response.ToolCall is not null)
{
Console.WriteLine($"Model wants to call: {response.ToolCall}");
Console.WriteLine($"With arguments: {response.ToolArguments}");
}
else
{
Console.WriteLine($"Final answer: {response.Text}");
}
MCP (Model Context Protocol) Tools
Vitruvian supports MCP tools for connecting to external tool servers. MCP tools are detected automatically when tool parameters contain server_url or connector_id, and are forwarded as native MCP tools to providers that support them (OpenAI, Anthropic).
Defining MCP Tools
Use ModelToolBuilder with MCP-specific parameters:
// Remote MCP server (e.g., a filesystem tool server)
var fileServer = new ModelToolBuilder("filesystem", "Access project files")
.AddParameter("server_url", "https://mcp.example.com/filesystem")
.AddParameter("server_label", "project-files")
.AddParameter("require_approval", "always")
.Build();
// OpenAI connector-based MCP tool
var slackConnector = new ModelToolBuilder("slack", "Send messages to Slack")
.AddParameter("connector_id", "conn_abc123")
.AddParameter("server_label", "team-slack")
.AddParameter("server_description", "Post messages and read channels from team Slack")
.Build();
// MCP tool with bearer auth and filtered tool list
var githubServer = new ModelToolBuilder("github", "GitHub repository operations")
.AddParameter("server_url", "https://mcp.example.com/github")
.AddParameter("server_label", "github-repos")
.AddParameter("authorization", "Bearer ghp_xxxx")
.AddParameter("require_approval", "never")
.AddParameter("allowed_tools", "list_repos,get_file,search_code")
.Build();
MCP Tool Parameters Reference
| Parameter | Required | Description |
|---|---|---|
server_url |
One of server_url or connector_id |
URL of the remote MCP server |
connector_id |
One of server_url or connector_id |
OpenAI connector ID |
server_label |
No | Display label (defaults to tool name) |
server_description |
No | Description (defaults to tool description) |
authorization |
No | Auth header value (e.g., Bearer token) |
require_approval |
No | "always", "never", or JSON approval config |
allowed_tools |
No | Comma-separated or JSON array of allowed tool names |
Using MCP Tools in a Module
public sealed class CodeAssistantModule : IVitruvianModule
{
private readonly IModelClient? _modelClient;
// MCP tool connecting to a GitHub MCP server
private static readonly ModelTool GitHubMcpTool = new ModelToolBuilder("github", "GitHub operations")
.AddParameter("server_url", "https://mcp.example.com/github")
.AddParameter("server_label", "github")
.AddParameter("require_approval", "always")
.Build();
// Standard function tool
private static readonly ModelTool FormatCodeTool = new ModelToolBuilder("format_code", "Format source code")
.AddParameter("code", "The source code to format")
.AddParameter("language", "Programming language (e.g. csharp, python)")
.Build();
public string Domain => "code-assistant";
public string Description => "Assist with code tasks using GitHub and formatting tools";
public CodeAssistantModule(IModelClient? modelClient = null) => _modelClient = modelClient;
public async Task<string> ExecuteAsync(string request, string? userId, CancellationToken ct)
{
if (_modelClient is null) return "No model configured.";
// MCP tools are sent to the provider natively; function tools use JSON Schema.
// The model decides which tools to invoke based on the user request.
return await _modelClient.ExecuteWithToolsAsync(
systemMessage: "You are a code assistant. Use GitHub to search repos and format code when asked.",
userMessage: request,
tools: [GitHubMcpTool, FormatCodeTool],
toolHandler: async (toolName, args, innerCt) =>
{
// MCP tools are handled by the provider — only function tools reach here
if (toolName == "format_code") return FormatCode(args);
return $"Unknown tool: {toolName}";
},
cancellationToken: ct);
}
private static string FormatCode(string? args) => $"Formatted: {args}";
}
MCP Approvals with HITL
When a provider (OpenAI or Anthropic) returns an mcp_approval_request, Vitruvian routes the approval through the configured HITL approval gate (IApprovalGate). The user is prompted to approve or deny the tool call, and Vitruvian sends the mcp_approval_response back to the provider automatically.
If no approval gate is configured, Vitruvian throws a clear error indicating that MCP approval requires HITL configuration.
Module Configuration
--configure-modules flag
Interactively enable or disable modules at startup:
vitruvian --configure-modules
=== Module Configuration ===
1. [✓] conversation — Answer general questions (core)
2. [✓] file-operations — Read, write, and list files
3. [✓] gmail — Read and compose Gmail messages
4. [ ] shell-command — Execute shell commands
...
Toggle (number), 'all', 'none', or 'done': 4
shell-command: enabled
Also available at runtime via the /configure-modules command.
Module preferences are persisted to vitruvian-modules.json and loaded automatically on subsequent runs. Core modules (e.g. conversation) cannot be disabled.
--model shortcut
Quickly switch model providers without editing env files:
vitruvian --model openai # use OpenAI with default model
vitruvian --model anthropic:claude-3-5-sonnet-latest # use Anthropic with specific model
vitruvian --model gemini:gemini-2.0-flash # use Gemini with specific model
The provider and model are persisted to .env.Vitruvian for subsequent runs.
Documentation
All detailed documentation lives in the docs/ folder:
| Document | Audience | Description |
|---|---|---|
| Installation | Everyone | Prerequisites, build, guided & manual setup, plugin installation |
| Using Vitruvian | Users | Running the CLI, runtime behaviour, compound requests |
| Architecture | Developers | GOAP pipeline, key components, execution flow |
| Extending | Plugin authors | Writing modules, SDK attributes, permissions, API keys |
| Governance | Operators / Developers | Scoring model, hysteresis, explainability |
| Security | Operators / Plugin authors | Permissions, HITL, sandboxing, installation controls |
| Policy | Operators | Policy validation and default behaviour |
| Operations | Operators | Audit, replay, and doctor commands |
| Compound Requests | Developers | Multi-intent detection, decomposition, execution |
| Contributing | Contributors | Development setup, project areas, testing |
Repository Layout
Vitruvian.sln
├── src/
│ ├── Vitruvian.Abstractions/ ← Core interfaces, enums, facts, planning types
│ ├── Vitruvian.Runtime/ ← GoapPlanner, PlanExecutor, ModuleRouter,
│ │ PermissionChecker, CompoundRequestOrchestrator
│ ├── Vitruvian.PluginSdk/ ← SDK attributes for module metadata
│ ├── Vitruvian.PluginHost/ ← Plugin loader (AssemblyLoadContext), sandboxing
│ ├── Vitruvian.Hitl/ ← ConsoleApprovalGate, HITL facts
│ ├── Vitruvian.StandardModules/ ← Built-in modules (File, Conversation, Web, …)
│ ├── Vitruvian.WeatherModule/ ← Example standalone module
│ └── Vitruvian.Cli/ ← CLI entry point, RequestProcessor, ModelClientFactory
├── tests/
│ └── Vitruvian.Tests/ ← xUnit tests
├── docs/ ← Detailed documentation (see table above)
└── scripts/ ← Guided setup scripts (install.sh / install.ps1)
Configuration
Create a .env.Vitruvian file in the project root, or export environment variables before running:
| Variable | Description | Default |
|---|---|---|
VITRUVIAN_MODEL_PROVIDER |
AI provider: OpenAI, Anthropic, or Gemini |
— |
VITRUVIAN_OPENAI_API_KEY |
OpenAI API key | — |
VITRUVIAN_ANTHROPIC_API_KEY |
Anthropic API key | — |
VITRUVIAN_GEMINI_API_KEY |
Google Gemini API key | — |
VITRUVIAN_MODEL_NAME |
Specific model to use | Provider default |
VITRUVIAN_WORKING_DIRECTORY |
File operations directory | ~/Vitruvian-workspace |
VITRUVIAN_MEMORY_CONNECTION_STRING |
SQLite connection string for durable memory | In-memory |
See docs/INSTALL.md for the full variable reference and profile-based setup.
OpenAI Responses Tools and MCP
When using VITRUVIAN_MODEL_PROVIDER=OpenAI, ModelRequest.Tools are forwarded to the OpenAI /v1/responses payload:
- Non-MCP tools are sent as OpenAI
functiontools with JSON Schema generated fromModelTool.Parameters. - MCP tools (detected via
server_url,connector_id, ortype=mcpparameter) are sent as native OpenAImcptools.
For tool creation examples using ModelToolBuilder, the execution loop via ExecuteWithToolsAsync, and MCP configuration, see Using Tools with IModelClient and MCP Tools above.
Contributing
Contributions welcome! See docs/CONTRIBUTING.md for details.
- Fork the repository
- Create a feature branch
- Make your changes following the existing patterns
- Write or update tests (target: all tests green)
- Submit a pull request
License
See LICENSE file for details.
Support
- Issues: GitHub Issues
- Discussions: GitHub Discussions
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.4 | 85 | 3/13/2026 |
| 0.2.3 | 85 | 3/11/2026 |
| 0.2.2 | 82 | 3/6/2026 |
| 0.2.1 | 75 | 3/6/2026 |
| 0.2.0 | 85 | 3/5/2026 |
| 0.1.9 | 80 | 3/5/2026 |
| 0.1.8 | 74 | 3/5/2026 |
| 0.1.5 | 81 | 3/5/2026 |
| 0.1.4 | 78 | 3/5/2026 |
| 0.1.3 | 83 | 3/5/2026 |
| 0.1.2 | 91 | 3/5/2026 |
| 0.1.1 | 76 | 3/5/2026 |
| 0.1.0 | 82 | 3/4/2026 |
| 0.0.9 | 81 | 3/4/2026 |
| 0.0.8 | 75 | 3/4/2026 |
| 0.0.7 | 76 | 3/4/2026 |
| 0.0.6 | 80 | 3/4/2026 |
| 0.0.5 | 76 | 3/4/2026 |
| 0.0.4 | 75 | 3/4/2026 |
| 0.0.3 | 84 | 3/3/2026 |