Skip to content
Tracon

Write your own agent decorator

IAgentDecorator wraps every agent the catalog resolves, the same mechanism Tracon uses for run recording, telemetry, and the tool-approval gate. Use it for cross-cutting behavior that has to see every agent — an audit trail, a per-tenant rate limiter, a custom authorization check — rather than repeating that logic inside each agent definition.

public sealed class AuditingAgentDecorator(IAuditLog auditLog) : IAgentDecorator
{
public int Order => 5;
public AIAgent Decorate(AIAgent agent, AgentDescriptor descriptor)
=> new AuditingAgent(agent, descriptor.Name, auditLog);
}

AddAgentDecorator has three overloads, the same shape as AddAgentSource and AddRunJudge. The generic one resolves the decorator’s own constructor dependencies through DI:

builder.AddTracon()
.AddAgentDecorator<AuditingAgentDecorator>();

Pass a ready instance, or a factory, when the decorator needs something that does not belong in the container as its own singleton:

builder.AddTracon()
.AddAgentDecorator(sp => new AuditingAgentDecorator(sp.GetRequiredService<IAuditLog>()));

All three register the decorator as a singleton in the same IEnumerable<IAgentDecorator> the catalog resolves; calling the generic overload twice for the same type registers it once.

Order decides the position: the catalog sorts decorators by descending Order and applies them in that sequence, so the highest value runs first and ends up innermost — closest to the agent’s own calls. Tracon’s own decorators use 0 (run recording, outermost), 10 (telemetry), 20 (tool approval), and 30 (structured-response validation, innermost — closest to the model call). Place a decorator that has to observe or veto everything below it at a low value; place one that has to sit close to the model call itself at a high value.

A decorator can also add Microsoft Agent Framework middleware with AIAgentBuilder: run middleware, UseAIContextProviders, or function-invocation middleware that sees each tool call. The agent keeps every Tracon ring, because the decorator wraps the compiled agent.

flowchart LR
    accTitle: Where a decorator wraps
    accDescr: A run passes from the caller through run recording at Order 0, telemetry at 10, tool approval at 20 and structured-response validation at 30, then through a decorator with a higher Order, to the compiled agent. Run recording receives TraconRunOptions and passes a plain AgentRunOptions inward.
    CALLER["Caller<br/>TraconRunOptions"] --> REC["Run recording<br/>Order 0"]
    REC -->|"plain AgentRunOptions"| TEL["Telemetry<br/>Order 10"]
    TEL --> APP["Tool approval<br/>Order 20"]
    APP --> VAL["Structured response<br/>Order 30"]
    VAL --> YOURS["Your decorator<br/>Order 100"]
    YOURS --> AGENT["Compiled agent"]
public sealed class ToolCallLogDecorator(ILogger<ToolCallLogDecorator> logger) : IAgentDecorator
{
public int Order => 100;
public AIAgent Decorate(AIAgent agent, AgentDescriptor descriptor)
=> agent.AsBuilder()
.Use(async (innerAgent, context, next, cancellationToken) =>
{
logger.LogInformation("{Agent} calls {Tool}", descriptor.Name, context.Function.Name);
return await next(context, cancellationToken);
})
.Build();
}

Give such a decorator an Order above 0. Run recording (Order 0) hands the agents inside it a plain AgentRunOptions. A decorator outside it receives TraconRunOptions, and MAF’s function-invocation middleware rejects that type with NotSupportedException.

A decorator must be thread-safe — the catalog can decorate agents concurrently — and must not swallow a genuine OperationCanceledException (one thrown while the caller’s token is cancelled). If Decorate throws anything else, including an OperationCanceledException while the caller has not cancelled, the catalog normalizes it into a TraconAgentSourceException (the same shape a broken IAgentSource produces) rather than letting a raw, unclassified error reach the caller.

A parameterized run compiled outside the catalog (bypassing CompiledAgentCache) still needs the same pipeline; call AgentDecoratorPipeline.Apply(agent, descriptor, decorators) by hand rather than applying decorators one at a time — otherwise the agent runs undecorated, with no run recording, no telemetry, and no tool-approval gate.

ITraconBuilder.Services is the escape hatch for anything Tracon does not model directly. Every Tracon service is registered with TryAdd, so registration order decides who wins — and the answer is different depending on whether the extension point allows one implementation or many.

Seam shape Examples Registered before AddTracon() Registered after AddTracon()
Single-instance ITenantContext, IProviderRetryClassifier, IRunErrorClassifier Your registration wins outright; only one registration remains Also wins for a direct resolve, but Tracon’s own registration is not removed — it stays behind as a second, unused entry
Multi-registration IAgentDecorator, IAgentSource, IRunJudge, IContentGuard, IModelProvider Your registration joins the list alongside the built-in ones Also joins the list — the built-in implementation keeps running too
Keyed IJobHandler Order does not matter; the handler is found by its key, not by position Same — but a plain AddSingleton<IJobHandler, T>() registers nothing the dispatcher can find

The dedicated Add*() methods (AddAgentDecorator, AddAgentSource, AddRunJudge, AddContentGuard, AddModelProvider) exist for the multi-registration seams precisely because getting this wrong there is silent: a decorator you meant to replace the built-in one instead runs alongside it, and both apply.

IJobHandler is keyed, not multi-registration, and its Add*() method is not optional. A job names the handler it needs, so the handler is registered against that name:

builder.Services.AddJobHandler<NightlyReportJobHandler>("contoso.nightly-report");

Writing services.AddSingleton<IJobHandler, NightlyReportJobHandler>() instead compiles and appears to work: nothing is registered under a key, so the dispatcher finds no handler and the first job queued for that name fails. A single-instance seam has no such trap — a consumer’s own services.AddSingleton<IProviderRetryClassifier, T>() simply overrides the default, in either order — which is why those extension points have no dedicated registration method: the plain escape hatch already behaves the way you would expect.

// Multi-registration seam: joins the list, does not replace anything.
builder.AddTracon()
.AddAgentDecorator<AuditingAgentDecorator>();
// Single-instance seam: this line alone replaces the built-in classifier,
// whether it runs before or after AddTracon().
builder.Services.AddSingleton<IProviderRetryClassifier, AcmeRetryClassifier>();