Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

NAT vs Rig: one architecture, two agent runtimes

From cognokratos/simple-agent-template · docs/NAT-VS-RIG.md · pinned revision aa808c10d80e

This template has two implementations of the same agent service:

BranchAgent runtimeRole
CanonicalmainNVIDIA NeMo Agent Toolkit (NAT) + NeMo Guardrails, Pythonthe recommended production reference
Alternativerust-agentRig 0.44 + explicit policy code, Rusta comparative learning implementation

The Rig implementation exists for one reason: to implement the same production-agent architecture with a thinner framework, so that what an agent runtime actually does — the loop, tool dispatch, policy, streaming, MCP, suspension for a human — is visible as ordinary code instead of toolkit configuration. It is not a replacement for NAT, and this page does not pick a winner. The question it answers is:

What changes when the same architecture is implemented using a high-level agent toolkit versus a thinner Rust framework?

What is held constant

same architecture        browser → assistant-ui → gateway → AGENT → MCP → PostgreSQL
same trust model         the model proposes; deterministic software decides
same application         support-ticket triage, same seed data
same MCP boundary        the same Rust MCP server, tools and approval verifier
same gateway             the same OIDC, sessions, CSRF and identity headers
same contract            AGENT-SERVICE-CONTRACT.md — routes, SSE, errors, provenance
same prompts             byte-identical system and guardrail prompts (equal prompt_sha256)
same evaluation          the same datasets, scorers and gates

different agent runtime  NAT + Python + NeMo Guardrails  vs  Rig + Rust + explicit policy

Everything outside agent/ is identical on the two branches, apart from the build files that run the agent (Compose service, Makefile targets, CI job) and the checks that inspect agent source. The documentation is identical too: it lives on main and describes both.

How to read the comparison

Each concern below names the NAT realisation, the Rig realisation, and how the two relate:

RelationMeaning
equivalentthe same property, enforced to the same extent
stricterRig enforces more than NAT, or fails closed where NAT does not
narrowerRig covers less than NAT
differenta different mechanism with a different trade-off; neither dominates

Source links point at each branch explicitly: NAT files exist only on main, Rust files only on rust-agent.

Concern by concern

The agent loop

NATNAT's ReAct graph on LangGraph, wrapped by register.py to stream native tool-calling answers; bounded by a recursion limit derived from max_tool_calls
RigRig's AgentRunner over a serialisable AgentRun state machine, built per request in builder.rs and driven in execution.rs; bounded by a model-call budget and a deterministic tool-call budget
Relationequivalent loop; stricter bound (exactly max_tool_calls tool calls)
TakeawayBoth frameworks own the loop. Rig exposes it as a small state machine you can read; NAT hides it behind a configurable graph. The loop is never where authority is decided.

Model client

NATLangChain ChatOpenAI through a NAT provider that omits empty optional parameters (llm_config.py)
RigRig's OpenAI-compatible Chat Completions client (model.rs); an empty reasoning_effort is None in typed config
Relationequivalent
Takeaway"Empty means absent" needed a custom provider in NAT and is one Option in Rust: configuration semantics are part of the integration surface.

MCP and tool discovery

NATNAT's mcp_client function group: streamable HTTP, include: allow-list, description overrides, reconnect
Rigrmcp 2.2 (the server's SDK line) through rig-rmcp's McpTool (client.rs); allow-list and overrides from config; tool schemas outside an enforceable subset, or declaring identity/approval fields, stop startup
Relationequivalent boundary; stricter discovery
TakeawayMCP stays the capability boundary in both. Neither agent touches the database.

Tool dispatch and schema validation

NATArguments parsed into pydantic models derived from the MCP schema; the tools that exist are whatever NAT was configured with
RigEvery model-proposed call passes Rig's AgentHook::on_dispatch (hooks.rs), which asks one pure function, ToolPolicy::decide: closed registry, JSON object only, no unknown fields, no coercion ("5" is not an integer), mutations only into the approval gate; the executors re-check the same policy
Relationstricter
Takeaway"The model proposes; deterministic software authorises" is a single, testable function on Rig, and is distributed across configuration and framework parsing on NAT. In both, the MCP server re-validates and stays authoritative.

Middleware and hooks

NATNAT middleware (text_guardrails), a custom FastAPI worker, a substituted execution store, telemetry processors
RigRig AgentHook for tool policy; everything else is the service's own Axum middleware and plain functions
Relationdifferent
TakeawayNAT's extension points let you change behaviour without owning the loop; Rig's let you own the loop without patching anything. On main several modules exist to work around specific toolkit behaviour (see EXTENDING.md); rust-agent has none.

Trusted identity

NATService key, then exactly one x-authenticated-user-id, enforced by ASGI middleware in fastapi_worker.py because NAT's own refusal does not reach the client on the workflow routes
RigThe same two checks in api/auth.rs; the result is a TrustedCaller with no public constructor
Relationequivalent; the type makes "the model cannot forge identity" a compile-time property of the agent code
TakeawayIdentity comes from the gateway, never from conversation text, in both.

Streaming

NATNAT generator through the guardrail middleware; SSE from NAT's front end
RigRig stream → output guard → bounded channel → Axum SSE body; dropping the body aborts the run
Relationequivalent wire format (contract)
TakeawayWhat is streamed is only what the output policy released, in both.

Input guardrails

NATNeMo self check input + deterministic deny patterns and anchored allow templates (text_guardrails.py)
RigThe same patterns, templates, precedence and prompt as pure functions (guardrails/input.rs); one classifier call parsed exactly as NeMo 0.21's is_content_safe
Relationequivalent; Rig fails closed (refuses) when the classifier is unreachable
TakeawayThe only probabilistic part is the classifier's reply; how it is read and combined is deterministic on both.

Output guardrails and PII

NATNeMo regex rail blocks credentials on a rolling window of chunks; Presidio masks PII, which forces the whole answer to be buffered first
RigThe same secret patterns in a bounded streaming window (320 characters held back, 512 of look-behind); deterministic PII recognisers (regex + Luhn, IBAN mod-97, Base58Check, Bech32) masked inside the same window (output.rs, pii.rs)
Relationsecrets equivalent; PII narrower; latency different
TakeawayThis is the clearest trade-off in the comparison, and it is not parity. Presidio combines patterns, context and a spaCy NER model; the Rust recognisers are stricter where an entity has a checksum and miss more where it does not (international phone layouts, anything found only through context). What the narrowing buys is a model-free output path that keeps streaming.

Tool results shown to people

NATTool steps reach the UI's tool card and the trace raw
RigThe display copy is redacted (secrets) and masked (PII); the model still reasons over the raw result
Relationstricter
TakeawayAn output rail that covers only assistant text leaves the tool card as a side channel.

Execution state

NATImplicit in NAT's runner
RigAn explicit enum with legal transitions (state.rs): Received → ScreeningInput → Running ⇄ AwaitingApproval → Completed / Failed / Cancelled, ScreeningInput → Blocked
Relationdifferent
TakeawayMaking invalid states unrepresentable — a blocked request cannot await approval, a finished run cannot resume — is cheap in Rust and worth copying into any runtime's design, even when the framework hides its own state.

Human-in-the-loop

NATNAT's interaction layer pauses the workflow coroutine; interaction_guard.py adds owner and offered-choice checks; approval.py prompts and mints
Rigapproval/gate.rs suspends the run on a PendingTicket; approval/pending.rs checks the answer and only then builds a VerifiedDecision (private constructor); approval/token.rs mints the same token
Relationequivalent security properties; stricter lifecycle (prompts expire; partial enablement refuses to start; the model never sees actor_id)
TakeawaySame token, same MCP verifier, same single-use transaction. The difference is where the checks live: patched into a toolkit's store, or built into the type the run waits on.

Persistence of a suspended run

NATIn memory: a paused coroutine holding the client's stream
RigIn memory: an awaiting task holding the client's stream; Rig's AgentRun is serialisable, which is the piece a durable design would build on (not implemented)
Relationequivalent (both lose pending approvals on restart)
TakeawayDurable suspension needs more than a serialisable run: a reconnectable stream and a pending-record store. Part II of the curriculum covers it.

Tracing

NATNAT's exporter and NeMo Guardrails' OTel spans, joined into one trace by pre-seeding NAT's root span through private NAT attributes
Rigtracing + tracing-opentelemetry; Rig adopts the request's root span, so its chat and execute_tool spans nest under it; the export filter is fixed in code because Rig logs whole provider requests at TRACE
Relationequivalent goal (one trace per request); different mechanism
TakeawayOne tracing model in one process removes the hardest problem NAT's observability package solves.

Evaluation

BothThe same harness, datasets, scorers and gates; the run is tagged provenance.agent.runtime (nat or rig-rust)
Relationequivalent
TakeawayThe suites are the fair comparison. See EVALUATION.md.

Deployment and runtime size

NATPython slim image with NAT, NeMo Guardrails, Presidio and a spaCy model: 2.01 GB (measured from main at 8ed2ceb)
RigDistroless image with one binary and its config: 71 MB; no shell, so in-container checks use the binary's probe subcommand
Relationdifferent
TakeawayMost of NAT's size is the capability it brings (NER, guardrail flows, integrations). Size alone is not the lesson.

Framework-provided behaviour vs explicit code

BehaviourNAT gives it to youRig leaves it to you
Agent loop, tool calling, streamingyesRig provides it too
Guardrail flows, model-based checks, NER maskingyes (NeMo Guardrails, Presidio)written by hand
Interaction/pause protocolyes (with gaps closed by main's guard)written by hand
Tool policy as one decision pointno — spread over configuration and parsingwritten by hand, and therefore explicit
Execution statehiddenwritten as an enum
Profiling, evaluation and plugin ecosystemyesno

Deliberate behavioural differences

Every difference between the two agents, in one list. None is claimed to be equivalent.

AreaNAT (main)Rig (rust-agent)Relation
PII detectionPresidio, confidence floor 0.4regex + checksumsnarrower
When masked output is releasedwhole answer bufferedprogressively; last 320 characters held backdifferent
Tool results in UI and tracerawredacted for displaystricter
Approval result shown to the modelincludes actor_idactor_id removedstricter
Tool-call budgetvia recursion limitexactly max_tool_callsstricter
Tool argument coercionpydantic coercesrefusedstricter
Tool schemastrusted as publishedrefused at startup if unenforceable or identity-bearingstricter
Pending approvalwaits until disconnectexpires (HITL_INTERACTION_TIMEOUT_SECONDS)stricter
Interactions without an ownerallowed unless strictnone existstricter
Unrecognised security booleandefault kept, warningrefuses to startstricter
Partially enabled approvalssome combinations start read-onlyrefuses to startstricter
Classifier unavailablerequest errorsrefused with a decision eventstricter
Model content on spansNAT records LLM I/ORig content capture off by defaultstricter
Route surfaceNAT's full route setcontract routes onlystricter
Models verifiedOllama: qwen3:8b, qwen3.5:9b (CONFIGURATION.md)Ollama: qwen3:8bnarrower

What was measured

Single runs on a local stack (qwen3:8b on Ollama, both models the same) — observations, not benchmarks:

Rig (rust-agent)
"Show me the open support tickets"search_tickets{status: open}, 15.6 s through the UI
"Which ticket should we handle first, and why?"one search_tickets call, TKT-1002 chosen, 3.2 s
"Summarize ticket TKT-1003 and its history"get_ticket{TKT-1003}, grounded, 8.4 s
Input-rail classifier call0.2–2.3 s
make eval-allall four suites at their gates (1.0)
Agent image71 MB (NAT: 2.01 GB)

CONFIGURATION.md records that qwen3:8b struggles with the prioritisation prompt on NAT; on Rig it answered from search_tickets alone in the runs observed. One run is not evidence that the runtime caused it: the model and prompt are the same, but the two clients build different request bodies (tool-schema rendering, message structure).

Where each is the better tool

  • NAT when you want breadth now: managed guardrail flows with model-based checks, Presidio's NER, a plugin ecosystem, profiling and evaluation integrations, and a Python team. You pay in dependency weight and in behaviour that lives in framework internals — main needs compatibility shims and private-attribute access to get one trace.
  • Rig when you want the loop as a library inside code you own: a small dependency surface, compile-time-checked state, a single binary, and every security decision as ordinary code. You pay by writing what NAT gives you, and by tracking a younger framework whose API moved substantially between minor versions.

Either way, the boundary that matters most does not move: the MCP server, not the agent, decides what may change in the database.

Further reading