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

Rust learning extension: the same agent, without the toolkit

From cognokratos/simple-agent-template · docs/RUST-LEARNING-PATH.md · pinned revision aa808c10d80e

An optional extension to the Production Agent Engineering learning path. Take it after the core path: it assumes you already know the architecture — gateway, agent, MCP capability boundary, guardrails, evaluation, observability, human approval — from the concept pages and labs, which teach it on the canonical NAT implementation.

This extension follows the same architecture through the rust-agent branch, where only the agent runtime is replaced: Rig + Rust + explicit policy code instead of NeMo Agent Toolkit + NeMo Guardrails. The point is not Rust. It is to see the machinery a high-level toolkit hides — the loop, tool dispatch, policy, streaming, suspension, resumption — as code you can read, change and test. Every lesson ends on the invariant both implementations must satisfy.

The model proposes actions. Deterministic software decides what authority is actually exercised.

It is not a Rust course. Rust concepts appear only where they carry an agent-engineering idea: enums as state machines, ownership across await, Arc for shared request context, async streams, traits, typed errors, serialisation, and visibility as a security property.

Setup

git clone https://github.com/cognokratos/simple-agent-template
cd simple-agent-template
git checkout rust-agent
cd agent && cargo test          # Rust ≥ 1.95; no model, cluster or network needed

The integration tests run the real agent service against a fake OpenAI-compatible model and an in-process MCP server (tests/support/mod.rs), so every exercise until lesson 13 runs offline. Keep NAT-VS-RIG.md open alongside; each lesson has a row there.

The lessons at a glance

#LessonRig sourceNAT counterpart
01Call an LLM from Rustagent/model.rsLangChain client via llm_config.py
02Build an agent using Rigagent/builder.rsNAT workflow config in agent/config.yml
03Understand the agent loopagent/execution.rsNAT ReAct graph, register.py
04Discover tools through MCPmcp/client.rsNAT mcp_client function group
05Validate model-proposed tool argumentsmcp/schema.rspydantic models from MCP schemas
06Separate model proposals from software authorityagent/hooks.rs, guardrails/tools.rsNAT tool configuration
07Stream output safelyguardrails/output.rs, api/workflow.rsNAT streaming + NeMo regex rail
08Implement deterministic guardrailsguardrails/input.rs, guardrails/pii.rstext_guardrails.py, Presidio
09Propagate trusted identity outside the promptidentity.rs, api/auth.rsfastapi_worker.py
10Model execution as an explicit state machineagent/state.rsimplicit in NAT's runner
11Suspend for human approvalapproval/gate.rs, agent/scope.rsNAT interaction layer, approval.py
12Resume and execute safelyapproval/pending.rs, approval/token.rsinteraction_guard.py
13Trace and evaluate the whole lifecycletelemetry/mod.rs, tests/NAT observability/, the shared evaluator

01 — Call an LLM from Rust

Rig exposes. An OpenAI-compatible endpoint is a base URL, a key and a model name: OpenAIConfig::with_base_url(..).client().chat(model). classify in model.rs is the simplest possible model call — one request, one reply — and it is the input rail's classifier. Note what it does not do: parse. The reply is text; deciding what it means is classifier_says_safe, a pure function.

NAT gives us. A configured provider (llms.primary in NAT's config) that NAT injects into its workflow; main adds a provider so that an empty reasoning_effort is omitted rather than sent.

Rust that matters. Result at an I/O boundary: classify returns Result<String, GuardModelError>, and the caller decides that a failure means refuse (fail closed).

Invariant. A model reply is data to be interpreted by deterministic code, never a decision in itself.

Exercise. In tests/agent_loop.rs, an_unreadable_verdict_fails_closed sets the classifier's reply to "<think>hmm". Change it to "Not safe", predict the outcome from classifier_says_safe's documentation, and run cargo test --test agent_loop unreadable. Explain why that known parser hazard is unreachable in practice — the same three reasons apply on NAT (GUARDRAILS.md).

02 — Build an agent using Rig

Rig exposes. A Rig Agent is a model, a preamble, sampling settings, a turn budget and a set of tools. The Rig agent is built once per request (builder.rs) because building one spawns nothing, and because each tool can then close over that request's trusted context (lesson 09).

NAT gives us. One workflow assembled at startup from config.yml; the request's context reaches functions through NAT's Context.

Rust that matters. A typestate builder (AgentBuilder changes type when tools are added) and Arc<RequestScope> shared among tool closures.

Invariant. What a tool can reach is fixed before the model produces a single token.

Exercise. Add .temperature(0.7) in build_agent, run cargo test --test agent_loop a_valid_call, and inspect what the fake model received (agent.llm.agent_requests()[0]["temperature"]). Revert.

03 — Understand the agent loop

Rig exposes. Call the model; if it asked for tools, run them and call again; stop at an answer or the budget. Rig implements this as a serialisable, sans-I/O state machine (AgentRun: CallModel, CallTools, Done) driven by a runner. execution.rs walks one request through it, step by step, labelling each decision probabilistic or deterministic. Read AgentRun::next_step (cargo doc -p rig-agent --open) to see exactly what the runner does between your hooks.

NAT gives us. The same loop as a LangGraph ReAct graph, configured by max_tool_calls and friends (concept 1).

Rust that matters. Enums as protocol state (AgentRunStep).

Invariant. The loop is bounded by configuration, not by the model deciding to stop.

Exercise. the_loop_is_bounded_by_configuration scripts a model that never stops calling tools. Set max_tool_calls: 3 in agent/config.yml, run it, and explain the two limits involved: Rig's model-call budget and the hook's tool-call budget. Why was the second one needed? Revert.

04 — Discover tools through MCP

Rig exposes. At startup the agent asks the MCP server for its tools, keeps the allow-listed ones, overrides descriptions from config, and refuses to start if a schema uses anything it cannot enforce or declares an argument such as user_id (client.rs, select_tools). Invocation goes through rig-rmcp's McpTool over rmcp.

NAT gives us. The mcp_client function group: discovery, include:, tool_overrides, reconnect — configuration rather than code.

Rust that matters. A pure function (select_tools) separated from the I/O that feeds it, so the policy is unit-testable without a server.

Invariant. MCP is the capability boundary; the agent has no other route to the data.

Exercise. In the fake MCP server (tests/support/mod.rs), add a requester_email: Option<String> field to GetTicketArgs. Run any integration test and read the startup error. What would have happened downstream had it been accepted?

05 — Validate model-proposed tool arguments

Rig exposes. schema.rs turns each published JSON Schema into a closed, typed representation and checks every proposed argument object against it: unknown fields refused even without additionalProperties: false, nothing coerced ({"limit": 5.0} is not an integer), unsupported keywords refused at discovery.

NAT gives us. Arguments parsed into pydantic models, which coerce where pydantic coerces.

Rust that matters. serde_json::Value at the untrusted edge, typed structures inside.

Invariant. The model gains no authority by producing convincing JSON; the MCP server still re-validates and stays authoritative.

Exercise. Run cargo test --test agent_loop malformed_and_mistyped and add a case for {"ticket_id": "TKT-1001", "ticket_id ": "x"} (a key with a trailing space). Predict the decision first.

06 — Separate model proposals from software authority

Rig exposes. Rig calls AgentHook::on_dispatch before every tool runs, with the name and raw argument text the model produced. hooks.rs asks one pure function, ToolPolicy::decide: Allow → proceed, RequireApproval → proceed only into the approval gate, Deny → skip with a reason the model sees. The executors check again.

NAT gives us. The set of callable tools is NAT configuration; there is no single decision point between "the model asked" and "the tool ran".

Rust that matters. An exhaustive match on a decision enum: add a variant and the compiler lists every place that must handle it.

Invariant. The decision whether a proposed call runs is deterministic, and deciding whether a mutation is permitted stays with the MCP server.

Exercise. Add a ToolEffect::Forbidden variant that decide always refuses, and follow the compiler errors to completion. Then delete it.

07 — Stream output safely

Rig exposes. Rig yields stream items; only text fragments are candidates for the client. The run executes in its own task and writes typed wire events into a bounded channel; the HTTP body is a stream over the receiver (workflow.rs). Dropping the body aborts the run. Before release, each fragment passes the output guard (output.rs), which holds back the last 320 characters and scans them with 512 of look-behind, so a secret split across fragments is seen whole before any of it leaves.

NAT gives us. NAT's stream through the guardrail middleware; NeMo's regex rail evaluates a rolling window of chunks. PII masking forces main to buffer the whole answer (GUARDRAILS.md).

Rust that matters. Async streams, bounded channels for back-pressure, RAII cancellation (Drop).

Invariant. Nothing reaches the client that the output policy has not released — across chunk boundaries.

Exercise. Run long_answers_stream_progressively and a_secret_split_across_chunks_is_blocked_before_any_part_is_released. Set HOLDBACK_CHARS to 4 and run them again. What does the hold-back cost, and what does it buy?

08 — Implement deterministic guardrails

Rig exposes. The input policy as pure functions (input.rs) — the same deny patterns, anchored allow templates and asymmetric precedence as main — plus one classifier call. The output PII recognisers (pii.rs) are regex tightened with checksums.

NAT gives us. NeMo Guardrails flows and Presidio's NER. Presidio finds entities the Rust recognisers miss; the Rust recognisers need no model and fit the streaming window. That is a narrower guarantee, not parity (NAT-VS-RIG.md).

Rust that matters. LazyLock<Regex> compiled once; table-driven tests.

Invariant. Every guardrail decision records which layer made it, and only the classifier's reply is probabilistic.

Exercise. Add a test splitting a key across three chunks after a long preamble, and one interleaving a PII entity with a secret. Then compare with lab 07 on NAT.

09 — Propagate trusted identity outside the prompt

Rig exposes. TrustedCaller has no public constructor: the only code that builds one reads gateway headers after the service key is verified (auth.rs). It is never rendered into the prompt, no tool schema has a field for it, and the approval token takes its actor_id from it.

NAT gives us. The same two checks as ASGI middleware in fastapi_worker.py, and identity read from NAT's request metadata.

Rust that matters. Visibility (pub(crate)) as a security property; a redacting Debug so a stray {:?} cannot log the subject.

Invariant. The model cannot choose, see or forge the identity that authorisation and audit use.

Exercise. Try to construct a TrustedCaller in an integration test and read the compiler error. Then explain what identity_never_enters_the_model_context_or_a_tool_call checks that the type system alone does not.

10 — Model execution as an explicit state machine

Rig exposes. state.rs:

Received → ScreeningInput → Running ⇄ AwaitingApproval → Completed / Failed / Cancelled
                         ↘ Blocked

Every legal move is listed in one match; anything else is refused. A blocked request cannot await approval, a finished run cannot resume, input screening cannot be skipped.

NAT gives us. The same lifecycle, implicit in NAT's runner and its execution store.

Rust that matters. Enums with data (AwaitingApproval { interaction_id }) make invalid states hard to represent; transitions return Result.

Invariant. How a run ended is a fact recorded by code, not inferred from logs.

Exercise. Run cargo test --lib agent::state. Add a transition Completed → Running and write the test that should reject it.

11 — Suspend for human approval

Rig exposes. The model proposes ticket_priority_change. gate.rs opens a pending interaction, sends interaction_required, and awaits a PendingTicket (scope.rs, ask). The whole run is suspended inside that .await, holding the client's stream — the same shape as NAT's paused coroutine. Nothing is persisted; see LIMITATIONS.md.

NAT gives us. prompt_user_input in NAT's interaction layer, used by approval.py.

Rust that matters. tokio::sync::oneshot as a single-use rendezvous; Drop to remove an abandoned prompt.

Invariant. A suspended run can be resumed only through the authenticated interaction route.

Exercise. Run cargo test --test approvals a_disconnect_while_waiting and an_unanswered_prompt_expires, and draw the state transitions for each.

12 — Resume and execute safely

Rig exposes. InteractionRegistry::respond checks that the interaction exists and is pending, the responder owns it, the response kind matches the prompt, and the (id, value) pair was offered — and only then builds a VerifiedDecision, a type nothing else can construct. The token (token.rs) is minted from that decision plus the trusted caller, and the MCP server verifies it independently.

NAT gives us. NAT resolves an interaction on two UUIDs alone; main's interaction_guard.py substitutes NAT's execution store to add the same checks.

Rust that matters. "Parse, don't validate": an unforgeable type as proof that a check happened.

Invariant. The model cannot manufacture approval; the MCP server is the final authority on the mutation.

Exercise. Run cargo test --test approvals and read only_the_owner_can_answer_and_only_with_what_was_offered. Then run cargo test --lib against_the_mcp_verifier, which compiles the MCP server's own verifier source into the agent's tests. Swap the key order in canonical_json and watch which tests fail.

13 — Trace and evaluate the whole lifecycle

Rig exposes. One root span per request; everything else — the rails, Rig's own chat and execute_tool spans, tool.policy, the MCP call, the approval wait — is its child because it is created while the root is current (telemetry/mod.rs). The export filter is fixed in code, so raising RUST_LOG cannot export Rig's raw request log. The test suites (tests/) play the model as an adversary: unknown tools, invented user_ids, forged approval fields, split secrets, a loop that never ends.

NAT gives us. NAT's spans and NeMo's spans joined into one trace by main's observability package; the same evaluation harness.

Rust that matters. tracing::Instrument to carry a span across .await and tokio::spawn.

Invariant. One trace per request, without credentials or raw identity; the boundary is tested, not the model.

Exercise. Run cargo test --test observability. Then bring up the stack on rust-agent (make dev), run make eval-all, and do the same on main. Compare the two runs in MLflow by their provenance.agent.runtime tag (EVALUATION.md), and the two traces of the same prompt (request walkthrough).