Skip to content

Repository files navigation

Trellis

Typed agents on a structured graph, for .NET.

Trellis lets you build AI agents and multi-step agent workflows in idiomatic C#. Define a record, and your agent returns it — strongly typed, validated, deserialized. Compose agents and logic into a graph of nodes with conditional routing, stream every step as it executes, and checkpoint progress so workflows survive crashes and resume where they left off.

No new abstraction layer to learn: Trellis sits directly on Microsoft.Extensions.AI, so it works with any provider that ships an IChatClient — OpenAI, Anthropic, Azure OpenAI, Ollama, and more.

⚠️ Early release (v0.2). The API is small on purpose and will evolve.

Features

  • 🎯 Strongly-typed agent outputs — ask for an Agent<FlightResult> and get a FlightResult back, not a string to parse. Structured JSON output and deserialization are handled for you.
  • 🩹 Self-healing structured outputs — when the model's JSON fails to parse or fails validation, the errors are fed back to the model as a correction and it retries (bounded) before a typed OutputValidationException surfaces. Add semantic rules via IOutputValidator<TResult>; a DataAnnotations validator is included.
  • 📶 Streaming agent runsRunStreamingAsync yields token-by-token updates and, once the stream ends, hands you the same assembled, deserialized, validated Result the buffered call would have produced.
  • 🔧 Tools are plain C# methods — register any delegate as a tool; tool calls are executed automatically in a loop until the model produces its final answer.
  • 🔌 MCP servers as toolsTrellis.Mcp connects agents to Model Context Protocol servers over stdio or HTTP, aggregating several servers with collision-free naming, an allow-list, and failure isolation so one dead server degrades the agent instead of breaking it.
  • [Tool] source generation — mark methods with [Tool] and a Roslyn source generator emits CreateTools() at compile time. No assembly scanning, no reflection-based discovery.
  • 💉 Dependency-injected agentsAgent<TDeps, TResult> builds its tool set per run from a typed dependencies object, so tools can use your services (database, current user, HTTP clients) with full compile-time checking.
  • 🤝 Agents as graph nodesAddAgentNode(...) drops any agent into a workflow: build the prompt from state, fold the typed result back in.
  • 🔀 Parallel fan-out/fan-inAddParallelNode(...) runs branches concurrently against the same state and merges the results.
  • Human-in-the-loop interrupts — pause a run in front of any node (InterruptBefore), let a human review or edit the state, then resume from the same thread id.
  • 🗄️ Durable SQLite checkpointingTrellis.Checkpointing.Sqlite persists progress to a database file; workflows survive crashes and process restarts.
  • 🚦 Model failover & prioritizationModelRouter spreads requests across multiple deployments by priority. A model that hits a rate limit or runs out of quota is tripped with an exponential cooldown and skipped entirely until it recovers — later requests go straight to the fallback with zero added latency.
  • 🧭 Capability-aware routing — endpoints declare what they support (tools, vision, JSON output, context window); a request needing tools never gets sent to a model that can't call them.
  • 🩺 Typed failure handling — status-code-based classification with Retry-After support; context-window and content-policy failures fail over without penalizing a healthy endpoint. Every policy is an interface you can swap.
  • 🌐 Fleet-shared circuit state — cooldown state flows through the Trellis.State abstraction (ISharedStateStore): in-memory by default, Redis via Trellis.State.Redis, or any IDistributedCache provider (SQL Server, Cosmos, Garnet) through the built-in bridge. One instance tripping a dead deployment protects the whole fleet.
  • ⚖️ Latency & cost-aware selection — order same-priority deployments by observed latency (EMA) or price per token instead of plain round-robin.
  • 🧵 Portable conversationsConversation keeps the canonical history client-side. Endpoints with server-side context (e.g. OpenAI's Responses API) transparently receive only the new messages plus their conversation id; failing over to a stateless provider replays the full history, so mid-conversation failover loses nothing.
  • 🗂️ Multi-instance conversationsIConversationStore persists live conversations (hot messages, rolling summary, epoch) so consecutive turns can land on different instances, with optimistic concurrency that refuses to silently clobber another instance's turn.
  • 🔥❄️ Hot / cold context — long conversations stay bounded: recent turns stay hot (verbatim in the prompt); older turns go cold — folded into a rolling summary by a cheap model and archived verbatim to any store. Context windows never overflow, token costs stay flat, nothing is lost.
  • 🕸️ Graph workflow engine — model multi-step processes as a state machine: nodes transform your state object, fixed or conditional edges decide what runs next, and the graph shape is validated at compile time.
  • 📡 Streaming execution — observe every step live via IAsyncEnumerable: node started, node completed, graph completed — perfect for progress UIs and logging.
  • 💾 Checkpointing & resume — pluggable ICheckpointer<TState> records progress after every node; rerun with the same ThreadId and the workflow picks up exactly where it stopped.
  • 🔁 Per-node retry & fallback — give any node an INodeRetryPolicy (capped exponential backoff with jitter by default) and a fallback that turns a dead dependency into a degraded state instead of a dead workflow.
  • 🌀 Loop protection — a MaxSteps guard stops runaway cycles before they burn tokens.
  • 🔌 Provider-agnostic — anything with an IChatClient works; swap OpenAI for Ollama with one line.
  • 🗣️ Multi-turn conversations — pass full message histories, with system instructions prepended automatically.
  • 📊 OpenTelemetry + cost accounting — agent runs and graph nodes emit spans and metrics through plain ActivitySource/Meter (no SDK dependency), following the GenAI semantic conventions. Plug in a price list and every run reports what it cost.
  • 🧪 Testable by design — the whole test suite runs against a fake IChatClient; no API keys, no network.
  • 🧩 Zero-AI graph coreTrellis.Graph has no AI dependency at all; use it to orchestrate any workflow, agentic or not.

Typed agents in 5 lines

using Microsoft.Extensions.AI;
using Trellis;

record FlightResult(string Destination, decimal Price);

IChatClient client = /* any Microsoft.Extensions.AI provider */;

var agent = new Agent<FlightResult>(client, instructions: "You book flights.");
FlightResult flight = (await agent.RunAsync("book me a flight to Pune")).Output;

Tools are plain C# methods:

var agent = new Agent<FlightResult>(client,
    instructions: "You book flights.",
    tools: [AIFunctionFactory.Create(
        (string city) => $"Cheapest flight to {city}: $129.50",
        name: "search_flights")]);

Or let the source generator discover them — mark methods with [Tool] on a partial class and a CreateTools() method is generated at compile time:

public partial class FlightTools
{
    [Tool(Description = "Searches for the cheapest flight to a city")]
    public string SearchFlights(string city) => /* ... */;   // exposed as "search_flights"
}

var agent = new Agent<FlightResult>(client, tools: new FlightTools().CreateTools());

Need your services inside tools? Agent<TDeps, TResult> builds the tool set per run from a typed dependencies object:

var agent = new Agent<OrderServices, OrderSummary>(client,
    tools: deps => [AIFunctionFactory.Create(
        () => deps.Db.GetOrders(deps.CurrentUserId), name: "list_orders")]);

OrderSummary summary = (await agent.RunAsync(services, "summarize my orders")).Output;

Streaming agent runs

Stream tokens to a UI without giving up the typed result — enumerate for deltas, then read Result:

AgentStream<FlightResult> run = agent.RunStreamingAsync("book me a flight to Pune");

await foreach (string delta in run.TextDeltasAsync())
    Console.Write(delta);                 // token-by-token, as the model produces it

FlightResult flight = run.Result.Output;  // assembled, deserialized, validated

Conversations stream too, and stay canonical: agent.RunStreamingAsync(conversation, "...") appends the user turn when enumeration starts and folds the assembled reply in when it finishes, so a stream you never enumerate — or abandon halfway — never leaves a half-turn in the history.

⚠️ Streaming does not self-heal. Validation can only run once the last token has arrived, and tokens already handed to the caller can't be retracted — so a rejected output throws OutputValidationException at the end of enumeration rather than silently streaming a second, contradictory answer. Use the buffered RunAsync when self-healing matters more than first-token latency. (One more caveat: streaming sets the JSON-schema response format directly, so a TResult whose schema root isn't an object — a bare int or array — is rejected by providers that require an object root. Wrap primitives in a record.)

Self-healing structured outputs

Models sometimes return JSON that doesn't parse, or values that parse but are wrong. Instead of throwing on the first bad response, a typed agent feeds the exact error back to the model — alongside its own failed attempt — and lets it correct itself:

using System.ComponentModel.DataAnnotations;

record Booking(
    [property: Required] string Destination,
    [property: Range(0, 10_000)] decimal Price);

var agent = new Agent<Booking>(client,
    instructions: "You book flights.",
    outputValidator: new DataAnnotationsOutputValidator<Booking>(),  // or any IOutputValidator<Booking>
    outputRetry: new OutputRetryOptions { MaxRetries = 2 });         // the defaults, shown explicitly

Booking booking = (await agent.RunAsync("book me a flight to Pune")).Output;

How it behaves:

  • On by default for typed outputs: deserialization failures self-heal with up to 2 correction retries even if you configure nothing. MaxRetries = 0 restores fail-fast.
  • Semantic validation goes through IOutputValidator<TResult> — return errors phrased for the model ("Price must be positive") and they become the correction prompt. DataAnnotationsOutputValidator<TResult> covers the declarative cases.
  • Budget exhausted → typed failure: an OutputValidationException carrying every attempt's raw text and errors, not a bare JsonException from deep inside serialization.
  • Cost is visible and bounded: each retry re-pays roughly the full request, and result.Attempts reports what a run actually used.
  • Conversations stay clean: retry traffic lives only inside the run — a Conversation absorbs the user turn and the final accepted answer, never the failed attempts or corrections.
  • The correction message's role and wording are configurable (FeedbackRole, FeedbackFormatter), and validators can be async (call a DB, an eval model, ...).

MCP servers as tools

Trellis.Mcp connects an agent to Model Context Protocol servers — GitHub, filesystems, databases, your own — and hands their tools to the model:

using Trellis.Mcp;

await using var github = McpServerToolSource.Stdio("github", "npx", ["-y", "@modelcontextprotocol/server-github"]);
await using var docs   = McpServerToolSource.Http("docs", new Uri("https://internal/mcp"));

var toolset = new McpToolset([github, docs], new McpToolsetOptions
{
    AllowedTools = ["create_issue", "search_docs"],   // allow-list anything you don't control
});

var agent = new Agent(client, instructions: "Use tools.", tools: await toolset.GetToolsAsync());

MCP tools already arrive as AIFunctions, so no adapter is needed to reach an agent. What Trellis adds is what a multi-server deployment actually needs:

  • Collision-free naming — tools are prefixed with their server (github_create_issue), which also tells the model which system a tool belongs to. Choose McpToolNaming.Preserve to keep the server's names, and a cross-server duplicate then fails fast instead of one server silently shadowing another.
  • Failure isolation — a server that's down is skipped (with a callback so a degraded agent is loud, not silent) rather than taking the whole agent with it. Set OnServerUnavailable = Throw for servers the agent is useless without.
  • An allow-list — a server can add tools at any time, and whatever it advertises becomes callable by your model. ⚠️ Treat third-party servers as untrusted: their tool descriptions enter your prompt and their tools run for real.
  • Concurrent, deterministic loading — servers are queried in parallel so a slow one doesn't serialize the rest, but the tool list is assembled in registration order. Listings are cached (5 min by default) so agent runs don't pay a round trip each time.

Trellis's own logic sits behind IMcpToolSource and is unit-tested without a server; protocol conformance is the official MCP SDK's job. The adapter itself is validated against the real reference server (@modelcontextprotocol/server-everything) in McpIntegrationTests, which no-op when Node isn't installed.

Graph workflows

Nodes transform a state object; edges decide what runs next; every step can be checkpointed.

using Trellis.Graph;

record ResearchState(string Question, string? Draft, int Revisions);

var graph = new StateGraph<ResearchState>()
    .AddNode("draft",  async s => s with { Draft = await WriteDraftAsync(s.Question) })
    .AddNode("review", async s => s with { Draft = await ReviewAsync(s.Draft!), Revisions = s.Revisions + 1 })
    .AddEdge("draft", "review")
    .AddConditionalEdge("review", s => s.Revisions < 2 ? "review" : StateGraph.End)
    .SetEntryPoint("draft")
    .Compile(new InMemoryCheckpointer<ResearchState>());

// Run to completion...
var result = await graph.RunAsync(new ResearchState("What is a trellis?", null, 0));

// ...or stream every step:
await foreach (var evt in graph.StreamAsync(new ResearchState("...", null, 0)))
    Console.WriteLine($"{evt.Type}: {evt.Node}");

Reuse a ThreadId in GraphRunOptions and a crashed or interrupted workflow resumes from its latest checkpoint instead of starting over.

Agents drop straight into a graph, branches can fan out in parallel, and runs can pause for a human:

var graph = new StateGraph<ResearchState>()
    .AddAgentNode("draft", draftAgent,
        prompt: s => s.Question,
        apply: (s, output) => s with { Draft = output })
    .AddParallelNode("factcheck",
        branches: [CheckSourcesAsync, CheckToneAsync],
        merge: (input, results) => Merge(input, results))
    .AddEdge("draft", "factcheck")
    .AddEdge("factcheck", "publish")
    .AddNode("publish", PublishAsync)
    .SetEntryPoint("draft")
    .Compile(SqliteCheckpointer<ResearchState>.FromFile("workflows.db"));

// Pause before publishing so a human can approve or edit the draft:
var options = new GraphRunOptions { ThreadId = "wf-1", InterruptBefore = ["publish"] };
var paused = await graph.RunAsync(initialState, options);          // Status: Interrupted

await graph.UpdateStateAsync("wf-1", s => s with { Draft = editedDraft });
var done = await graph.RunAsync(initialState, options);            // resumes → Completed

Per-node retry & fallback

Nodes call flaky things. Give one a resilience policy and a transient failure stops being a dead workflow:

.AddNode("enrich", EnrichFromApiAsync, new NodeResilience<OrderState>
{
    Retry = new ExponentialBackoffRetryPolicy(maxAttempts: 4, baseDelay: TimeSpan.FromMilliseconds(200),
                                              shouldRetry: e => e is not ArgumentException),
    Fallback = (state, error, ct) => Task.FromResult(state with { Enrichment = null, Degraded = true }),
})

Retries are off by default and opt-in per node — a node is arbitrary code, and re-running one re-runs its side effects. Enable them on nodes you've made idempotent; the XML docs say so at the point of use.

  • Backoff doubles per attempt, clamps at maxDelay, and carries jitter so a fleet doesn't retry a shared dependency in lockstep.
  • shouldRetry filters out errors that will never succeed (bad input, auth), so you don't wait 4 times for a certain failure.
  • Retries don't consume MaxSteps (they're re-executions of one step) and don't write checkpoints — only the successful attempt does, so a resumed run never replays a failed one.
  • Cancellation is never retried; it means the caller gave up, not that the node failed.
  • When the fallback itself fails you get a GraphExecutionException holding both errors in an AggregateException — the fallback's error alone would hide why it ran.
  • StreamAsync emits NodeRetrying (with attempt number and error) and NodeFallbackApplied, so retries are visible to progress UIs and logs rather than hidden latency.

Model failover & prioritization

Running multiple deployments (Azure + OpenAI + a local Ollama, or the same model in several regions)? ModelRouter is an IChatClient, so it drops underneath any agent or graph unchanged — and it solves the "one model ran out of tokens" problem without adding latency to every request:

using Trellis.Routing;

IChatClient router = new ModelRouter(
[
    new ModelEndpoint("azure-eastus", azureClient,  priority: 0),  // preferred
    new ModelEndpoint("azure-west",   azureClient2, priority: 0),  // same tier → round-robin
    new ModelEndpoint("openai",       openaiClient, priority: 1),  // fallback
    new ModelEndpoint("ollama-local", ollamaClient, priority: 2),  // last resort
],
new ModelRouterOptions
{
    BaseCooldown = TimeSpan.FromSeconds(30),   // doubles per consecutive failure, capped at MaxCooldown
    OnEndpointTripped = (e, ex, until) => logger.LogWarning("{Name} tripped until {Until}", e.Name, until),
    OnEndpointRecovered = e => logger.LogInformation("{Name} recovered", e.Name),
});

var agent = new Agent<FlightResult>(router, instructions: "You book flights.");

How it behaves when a deployment hits a 429 / quota exhaustion / outage:

  1. The failing request pays the failover cost once and is answered by the next tier.
  2. The failed endpoint is tripped — subsequent requests skip it entirely (no probe, no wait, no friction) while its cooldown runs.
  3. Repeated failures double the cooldown (30s → 60s → 120s → ... up to MaxCooldown).
  4. When the cooldown expires, the next request quietly retries it; on success it's restored to full priority automatically.

If everything is cooling down, the router either degrades gracefully to the soonest-recovering endpoint (default) or fails fast, per AllTrippedBehavior. Streaming fails over too, up until the first token arrives.

Typed failure handling

The router is built from four pluggable strategies (each an interface with a sensible default) so every policy decision is yours to override without touching routing code:

Extension point Decides Default
IFailureClassifier What went wrong — typed FailureKind from status codes and messages, plus the provider's Retry-After when available DefaultFailureClassifier
IFailurePolicy What to do about it — propagate, fail over + trip, or fail over only DefaultFailurePolicy
IEndpointHealthStore Where cooldown state livesSharedStateEndpointHealthStore adapts any ISharedStateStore backend so your whole fleet shares one view of which deployments are down InMemoryEndpointHealthStore
IEndpointSelectionStrategy How a priority tier is ordered — round-robin, lowest observed latency, or lowest cost RoundRobinSelectionStrategy

The failure policy distinguishes provider problems from request problems, LiteLLM-style:

  • Rate limit / quota / timeout / 5xx → fail over and trip the endpoint (it's unhealthy).
  • Context-window overflow / content-policy rejection → fail over without tripping — the model is healthy, this request just doesn't fit it; a bigger-window or more permissive deployment gets it instead, and the endpoint stays in rotation for the next request.
  • Unknown errors → propagate immediately; they'd fail on every model anyway.

When a provider says how long to back off (Retry-After), that exact duration is used instead of the exponential cooldown. Latency is tracked per endpoint (EMA), so LowestLatencySelectionStrategy routes to whatever is actually fastest right now, and LowestCostSelectionStrategy uses ModelEndpoint.CostPerMillionTokens.

Sharing circuit state across your fleet

Cross-instance state goes through one narrow abstraction — ISharedStateStore in Trellis.State (get / set-with-TTL / remove) — with providers, not couplings:

// Redis (Trellis.State.Redis):
var options = new ModelRouterOptions
{
    HealthStore = new SharedStateEndpointHealthStore(
        new RedisSharedStateStore(connectionMultiplexer)),
};

// ...or any existing IDistributedCache provider (SQL Server, Cosmos, Garnet):
HealthStore = new SharedStateEndpointHealthStore(
    new DistributedCacheSharedStateStore(distributedCache)),

With a shared backend, one instance hitting a dead deployment trips it for every instance, and recovery propagates fleet-wide the same way. The router never knows which backend is in play — it only sees IEndpointHealthStore.

Heterogeneous providers: capabilities

Deployments rarely support the same things. Declare per-endpoint capabilities and the router only considers endpoints that can actually serve the request — a tool-calling request skips the model without function calling, an oversized prompt skips the small context window, instead of failing there first:

new ModelEndpoint("gpt-4o", openaiClient, priority: 0),                       // default: tools + vision + JSON
new ModelEndpoint("small-local", ollamaClient, priority: 2, new ModelCapabilities
{
    Features = ModelFeatures.JsonResponseFormat,                              // no tools, no vision
    MaxInputTokens = 8_000,
}),

If no registered endpoint supports what a request needs, you get a NoCompatibleModelException up front — not a provider error after a doomed round-trip.

Heterogeneous providers: conversation context

Some providers manage conversation state server-side (OpenAI's Responses API); most are stateless. Trellis resolves the mismatch with one rule: the client-side Conversation is always the source of truth, and provider-side state is only an optimization.

var agent = new Agent(router, instructions: "You are a travel assistant.");
var conversation = new Conversation();

await agent.RunAsync(conversation, "Find me flights to Pune");
await agent.RunAsync(conversation, "What about hotels near the airport?");  // full context, any provider

Mark an endpoint with ModelFeatures.ServerConversationState and the router exploits it automatically: follow-up turns send only the unsynced messages plus the provider's own conversation id. The moment a request routes anywhere else — failover, load balancing, recovery — that endpoint receives the complete canonical history instead. Mid-conversation failover between providers is seamless, and your conversation is never trapped inside one vendor's API.

Conversations across instances

A Conversation is a live in-memory object. Behind a load balancer, turn 2 may land on a different pod than turn 1 — so give it a store:

var store = new SharedStateConversationStore(
    new RedisSharedStateStore(mux),
    timeToLive: TimeSpan.FromHours(12),   // abandoned sessions expire instead of accumulating
    requireAtomicStore: true);            // refuse a backend that can't compare-and-swap

Conversation conversation = await store.LoadAsync(sessionId) ?? new Conversation(sessionId);
await agent.RunAsync(conversation, prompt);
await store.SaveAsync(conversation);

Saves carry a version. If another instance advanced the conversation since this copy was loaded, SaveAsync throws ConversationConcurrencyException instead of overwriting that turn — reload and reapply, and if it happens often, add session affinity rather than retrying harder. On Redis the check is a real compare-and-swap (a Lua script, so the read and write can't interleave); on the IDistributedCache bridge, which has no CAS, the version check still catches the common case but a narrow race remains — requireAtomicStore: true rejects such a backend up front rather than letting you inherit silent last-write-wins.

This complements the cold archive: IConversationArchive keeps compacted history, IConversationStore keeps the working conversation.

Hot & cold context: conversations that never overflow

A long-running conversation can't send its whole history forever. Give the agent a ConversationCompactor and the history is tiered automatically:

  • Hot — the most recent turns, kept verbatim and sent every request.
  • Cold — once the hot history exceeds its budget, the oldest turns are (1) folded into a rolling summary by an IConversationSummarizer (point it at a small, cheap model) and (2) archived verbatim through an IConversationArchive, so the full transcript remains retrievable for display, audit, or search.
var compactor = new ConversationCompactor(
    summarizer: new ChatClientConversationSummarizer(cheapClient),   // e.g. a local or mini model
    archive: new SharedStateConversationArchive(new RedisSharedStateStore(mux)),
    options: new CompactionOptions { MaxHotMessages = 40, KeepRecentMessages = 12 });

var agent = new Agent(router, instructions: "You are a travel assistant.", compactor: compactor);

// Turn 500 costs the same as turn 5: summary + hot tail, never the whole transcript.
await agent.RunAsync(conversation, "so which hotel did we settle on again?");

Budgets come in two flavours, and whichever trips first wins:

new CompactionOptions
{
    MaxHotMessages = 40, KeepRecentMessages = 12,   // message count: predictable, free
    MaxHotTokens = 8_000, KeepRecentTokens = 2_500, // tokens: what windows and bills are denominated in
    TokenCounter = new HeuristicTokenCounter(),     // or wrap a real tokenizer via ITokenCounter
}

The token trigger prefers the provider's own reported input tokens for the previous turn over any estimate — exact, free, and it accounts for your instructions, the rolling summary, and images. Whatever that reported total exceeds what the counter can attribute to the hot messages is treated as fixed overhead and charged against the retained tail's allowance, so the budget still bites when history isn't what's blowing it. (If that overhead alone exceeds the budget, every turn compacts to the newest message and stays there — the budget is unreachable, and only raising it or shortening your instructions fixes it.) The rolling summary is itself capped, so it can't quietly re-inflate every prompt over weeks.

What the model sees each turn: your instructions → "Summary of the earlier conversation: ..." → the hot tail. Each compaction bumps the conversation's ContextEpoch, which changes its routing id — so a conversation-aware router discards provider-side deltas and replays the compacted history in full (a server-side delta against the pre-compaction transcript would be wrong). The archive reuses ISharedStateStore, so cold context can live in memory, Redis, or any IDistributedCache backend.

Observability & cost

Trellis instruments the layer that provider-level tracing can't see: a whole agent run (self-healing retries included) and graph orchestration (one span per node execution). It uses only System.Diagnostics primitives, so there's no OpenTelemetry SDK dependency — subscribe by name:

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource(AgentTelemetry.ActivitySourceName)   // "Trellis.Agent"
                       .AddSource(GraphTelemetry.ActivitySourceName))  // "Trellis.Graph"
    .WithMetrics(m => m.AddMeter(AgentTelemetry.MeterName)
                       .AddMeter(GraphTelemetry.MeterName));

It deliberately does not instrument the chat call itself — Microsoft.Extensions.AI's UseOpenTelemetry() already does, and duplicating it would double-count tokens. Compose both for the full picture.

Signal What it tells you
invoke_agent span One agent run: result type, model, token usage, trellis.agent.attempts (>1 means self-healing kicked in), error status
graph.run / graph.node {name} spans Orchestration shape; a retried node produces one span per attempt, so retries are visible instead of just looking slow
trellis.agent.output.rejections How often the model's output fails validation — the metric that tells you a prompt or schema needs work
trellis.graph.node.retries / .fallbacks Which nodes are unstable, and which are running degraded
gen_ai.client.token.usage, trellis.agent.cost Tokens by type, and spend when a cost model is configured

Cost accounting is opt-in, because token prices change and vary per contract — Trellis ships no price list it would be wrong about:

AgentTelemetry.CostModel = new StaticTokenCostModel(new Dictionary<string, ModelPrice>
{
    ["gpt-4o"] = new(InputPerMillion: 2.50m, OutputPerMillion: 10.00m),
});

An unknown model prices as null, never 0 — a dashboard can then distinguish "not priced" from "free". With nothing listening, instrumentation costs a null check per run.

Packages

Package What's in it
Trellis Typed agents (Agent<TResult>, Agent<TDeps, TResult>), the [Tool] source generator, and agent-as-node graph helpers
Trellis.Graph Graph runtime: StateGraph<TState>, parallel branches, streaming, interrupts, checkpointing (no AI dependency — usable for any workflow)
Trellis.Checkpointing.Sqlite Durable SQLite-backed checkpointer for graph workflows
Trellis.Routing ModelRouter: priority-based, capability-aware failover across model deployments with circuit-breaker cooldowns, automatic recovery, and portable conversation state
Trellis.State Cross-instance shared state: ISharedStateStore with in-memory and IDistributedCache providers
Trellis.State.Redis Redis provider for Trellis.State (StackExchange.Redis)
Trellis.Mcp MCP client support: connect agents to Model Context Protocol servers (stdio/HTTP) with multi-server aggregation, allow-listing, and failure isolation

Building

dotnet test

Requires the .NET 10 SDK. No API keys needed for the test suite — agent tests run against a fake IChatClient.

Validation & the abstraction contract

Trellis is an abstraction layer over IChatClient. Its tests target the contract, never a vendor: provider wire-format correctness belongs to the adapter (OpenAI SDK, OllamaSharp, ...), and Trellis stays deliberately uncoupled from live vendor APIs.

  • Contract behavior validated against a real model (local Ollama): plain agent runs, typed structured outputs, self-healing validation retries, token-by-token streaming (text and typed), automatic tool invocation, multi-turn conversations. These integration tests live in OllamaIntegrationTests and run whenever a local Ollama is reachable; they no-op otherwise (e.g. CI).
  • 📜 ServerConversationState is an opt-in contract: marking an endpoint with it asserts its IChatClient follows the documented ConversationId semantics (see the flag's XML docs). Trellis's sync logic is verified against that contract; conformance of a given adapter is the adapter's responsibility.
  • ⚠️ Multi-instance notes: router health state, conversation archives, and the conversation store are fleet-safe with an atomic backend (Redis); the IDistributedCache bridge emulates atomic ops (single-writer only). Conversations now persist and rehydrate through IConversationStore with optimistic concurrency; the graph run-guard remains per-process, so route a given thread id to one instance.

Roadmap

  • Source-generated tool discovery ([Tool] attribute → CreateTools() at compile time)
  • Agent<TDeps, TResult> — compile-time-checked dependency injection into tools
  • Agent-as-node helpers bridging Trellis and Trellis.Graph
  • Parallel fan-out/fan-in branches
  • Trellis.Checkpointing.Sqlite durable checkpointer
  • Human-in-the-loop interrupts
  • NuGet release pipeline (packages publish on version tags)
  • Model failover & prioritization (Trellis.Routing)
  • Capability-aware routing (tools / vision / JSON / context window)
  • Portable conversation state across stateful and stateless providers
  • Error-type-specific failover (context window / content policy vs rate limit)
  • Status-code failure classification + Retry-After honoring
  • Pluggable shared circuit-breaker state (IEndpointHealthStore)
  • Latency (EMA) and cost-based tier selection strategies
  • Trellis.State shared-state abstraction with in-memory, IDistributedCache, and Redis providers
  • Hot/cold conversation context (rolling summary + verbatim archive)
  • Self-healing structured outputs (validation-retry with error feedback)
  • Token-budget-based compaction thresholds (ITokenCounter + provider-reported usage)
  • Streaming agent responses (token-by-token)
  • OpenTelemetry instrumentation for agents and graph runs (+ cost accounting)
  • Retry/fallback policies per node (INodeRetryPolicy + NodeResilience<TState>)
  • IConversationStore — multi-instance hot conversation state with optimistic concurrency
  • MCP (Model Context Protocol) client support (Trellis.Mcp)
  • Eval harness for agent outputs
  • Durable execution semantics (idempotency keys, deterministic replay)
  • Retrieval over the cold conversation archive
  • Postgres checkpointer

License

MIT

About

Typed agents on a structured graph, for .NET — strongly-typed AI agent outputs, graph workflows, streaming, and checkpointing on Microsoft.Extensions.AI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages