navree
Documentation

Agents and tools

Agents that call your services through typed #[tool] methods, under the signed-in user's permissions, with conversation memory, approvals, streaming, queued runs and AiFake tests.

On this page

Introduction

Featureai An agent is a model that can act. You give it instructions and a set of tools, which are ordinary async methods on one of your services, and it decides which to call, reads their results and keeps going until it can answer. "Which of my deals in negotiation haven't heard from us in two weeks? Log a task to call each of them" becomes a search, a few activity lookups, three new tasks and a short reply.

An agent in Navree is a provider that implements the Agent trait, with its tools marked #[tool]:

Rust

Three things make agents safe to put in front of users:

  • Tools run as the user. Every run acts as one user, with that user's roles, permissions and token restrictions. A tool reads and writes through your services with the same access checks as your API, so the model can do nothing the user couldn't do with a few clicks.
  • Tools are typed. Arguments are a Rust type with a JSON Schema and validation rules; the model gets a field-level error when it sends something invalid, and your code never sees it.
  • Writes are units of work. Each tool call runs in its own transaction, audited with the user as the actor, and rolled back when the tool fails.

The examples build the CRM's deals assistant, which answers questions about the pipeline, logs activities and moves deals, from the user's chat panel.

Defining an agent

An agent is an #[injectable] struct, so its tools reach your services through dependency injection. The CRM's assistant uses the deal and activity services the API already uses:

crates/crm-deals/src/assistant.rs
ItemRequiredWhat it is
const NAMEyesA stable name: it labels logs and metrics and is stored with each conversation.
fn instructions(&self, run)yesThe system prompt. It gets the run, so it can name the user and the date.
fn options(&self)noThe model, step limit and memory.
#[tool] methodsnoWhat the agent can do.

#[agent] goes on the impl Agent for ... block. It takes the #[tool] methods out of the trait impl into an inherent impl, generates their schemas and the dispatch code, and leaves the rest as a normal trait implementation. It comes from navree-macros and is re-exported as navree::ai::agent. Misuse is a compile error that points at the method: a tool without a doc comment, one that isn't async, one with more than one arguments value, an unknown #[tool(..)] option, or an access attribute on a method that isn't a tool.

Register the agent in its module with m.agent::<A>() (from ModuleBuilderAiExt, which the prelude imports), which registers it as a provider:

crates/crm-deals/src/lib.rs

navree make:agent deals assistant generates an agent with one read-only tool in the deals module's agents/assistant.rs and registers it at the module's // navree:wire marker. The agent's NAME is deals-assistant.

Tools

A tool is an async method marked #[tool]. It takes &self, the run, and one arguments value (or none), and returns a Result of anything that serializes, with an error that converts into AppError:

crates/crm-deals/src/assistant.rs
  • The doc comment is the tool's description. The model chooses tools by it, so say what the tool does, what it returns and its limits. A tool without one doesn't compile.
  • The method name is the tool's name (search_deals). Rename a tool and old conversations that mention it still load; the model sees the new name.
  • #[tool(read_only)] marks a tool that changes nothing. Read-only tools may run in parallel, and only they are offered when you start a run with .read_only(). #[tool(read_only, needs_approval)] combines both flags.
  • The result is serialized to JSON for the model. Return your API resources (DealResource, ActivityResource): they are already shaped for reading. A result above 16 KB reaches the model as {"truncated": true, "note": "...", "partial": "<the first 16 KB>"}, the note telling it to narrow its request.

Arguments

Arguments are one struct per tool. It derives Deserialize, ToSchema and garde::Validate, the same derives as a validated request body:

crates/crm-deals/src/assistant.rs

The JSON Schema the model sees comes from ToSchema, with the field doc comments as descriptions. Before the method runs, the arguments are deserialized and validated with the same messages as a validated request. When they fail, the method doesn't run, and the model gets the 400 your API would send, such as {"error": {"statusCode": 400, ..., "errors": {"subject": ["subject must be longer than or equal to 1 characters"]}}}, and usually corrects itself on the next step.

The run

AgentRun is the tool's view of who is asking and where it is:

MethodReturns
run.user()The signed-in User.
run.user_id()Their id.
run.access()Their PrincipalAccess: roles, permissions and compiled abilities, with any token restrictions applied.
run.ability()&Arc<Ability>, for checks in services.
run.accessible::<E, A>()An Accessible<E, A> list filter for the user.
run.authorize::<E, A>(id)Loads record id of E in the tool call's unit of work and checks A on it: an Authorized<E, A>, or a 404 or 403.
run.uow()The tool call's unit of work.
run.now()The time from the app's dyn Clock.
run.conversation_id()The conversation, when the run has memory.
run.ctx()The AppContext, to resolve anything else.

Guarding tools

The model is not a security boundary. A user can ask it to do anything, and text it reads (an email body, a note) can tell it to do something else. So Navree enforces access in code, at three levels.

Which tools the model sees. The access attributes of routes work on tools: #[can(Action, Subject)] (the user may do the action on some records of the subject), #[permissions("...")] (every permission, without conditions) and #[roles("...")] (any of the roles; a super admin passes). Several attributes must all pass. A tool the user fails is left out of the tool list sent to the model, so the model can't call it or mention it, and a call to it anyway is answered as an unknown tool (a 404). Tools without an attribute are available to every user the agent runs for. #[can] on a tool takes no from = "...": load the record with run.authorize instead.

Rust

In the CRM only managers and admins can manage deals, so a rep's assistant is never offered reassign_deal.

Which records a tool touches. Lists go through run.accessible::<E, A>(), as search_deals does. A tool that acts on one record loads it with run.authorize, the tool's equivalent of #[can(Move, Deal, from = "id")] on a route:

Rust

What a refusal looks like. A tool error goes back to the model in Navree's error shape, and the run continues, so the model can explain or try another way:

The tool result the model reads

Server errors (5xx) reach the model as "The tool failed", never with their details, and are logged at error with the agent, the tool and the run's request id. A tool that runs past tool_timeout_secs is a 504 the model reads as "The tool did not finish in time".

Writes happen in the tool call's unit of work. It commits when the tool returns Ok and rolls back when it returns Err, so a failed tool leaves nothing half done. Rows written through Repo are audited with the user as the actor, and jobs and events dispatched by the tool are queued only when it commits, as in a request.

Running an agent

ai.agent::<A>() starts a run. Say who it acts for, optionally which conversation it continues, then send a message:

crates/crm-deals/src/assistant_controller.rs
HTTP
JSON

The run builder:

MethodWhat it does
.acting_as(access)Runs as the user of an Arc<PrincipalAccess>, from the CurrentAccess extractor. Token restrictions carry over.
.acting_as_user(id)Runs as user id with their full access, loaded when the run starts (needs AccessModule). For jobs and commands.
.conversation(id)Continues conversation id (a Uuid or an Option<Uuid>; None starts a new one). Without this call the run stores nothing and conversation_id is null.
.approve(call_id), .reject(call_id, reason)Answers a pending approval.
.read_only()Offers only #[tool(read_only)] tools.
.model(name), .max_steps(n)Override the agent's options for this run.
.prompt(message)Runs and returns an AgentReply.
.structured::<T>(message)Runs, then returns the final answer as a T, like ai.structured.
.stream(message)Runs and returns an AgentStream. See streaming a run.

A run needs a user: there is no anonymous agent, and a run without one fails with AiError::Unauthenticated (a 401). A job that works for no one in particular acts as a dedicated service account with the roles it needs. Agents need DatabaseModule (each tool call's unit of work) and AuthModule (the user); an agent that isn't registered with m.agent::<A>() fails with AiError::Config.

How a run works

  1. The model gets the instructions, the conversation so far, the new message and the tools the user may use. The system prompt is built once per run, so every step of a run sends the same one.
  2. When it answers with tool calls, Navree validates the arguments, checks access and calls the methods, read-only ones in parallel, each in its own unit of work. The results go back to the model in one message, in the order of the calls.
  3. Steps repeat until the model answers without a tool call, a tool needs approval, or the run reaches max_steps. On the last step the model still sees the tools but may not call them, so the user gets a reply.

Every model call goes through the failover chain of the agent's model and counts against the user's budget.

Agent options

fn options(&self) -> AgentOptions sets the agent's defaults:

FieldDefaultMeaning
modelNone (ai.default)The model name from ai.models.
max_steps10Model calls per run, tool rounds included.
max_tokensthe model'sThe output limit of each model call.
temperaturethe model's
memoryMemory::ConversationMemory::None for agents that answer one message at a time.
parallel_toolstrueRun a step's read-only tool calls at the same time.
tool_timeout_secs30The time limit of one tool call; a timeout is a tool error the model sees.

Memory

With Memory::Conversation, the default, a run stores its messages and tool calls in your database, and a later run with the same conversation_id continues where it stopped. The ai migration set creates two tables, ai_conversations and ai_messages; it runs with your other migrations.

  • A conversation belongs to the user who started it and to its agent. Continuing someone else's conversation, or another agent's, is a 404.
  • The model sees the last ai.memory.max_messages messages (default 40, at least 2), starting at a user message. Older ones are folded into a running summary written by ai.memory.summary_model (default ai.default) and added to the instructions, so long conversations keep their thread without growing the prompt. A failed summary is logged and the run goes on.
  • Provider reasoning blocks are not stored: a later run starts without them.
  • Conversations untouched for ai.memory.retention_days (default 90) are deleted by ai:prune. Schedule it like audit:prune:
Rust

The Conversations service backs a chat panel's history:

Rust

Every method takes the user's id and fails with a 404 (AiError::NotFound) for conversations that aren't theirs. list_for returns the most recently changed first; each ConversationSummary has id, agent, title, created_at and updated_at, and each StoredMessage has id, role, text and parts (tool calls and results included) and created_at. A conversation's title is the opening words of its first message, at most 60 characters, until rename changes it.

Tools that need approval

#[tool(needs_approval)] stops the run before the tool executes and hands the decision to the user. The reply carries the pending call:

JSON

The front end shows the call, and the user's answer continues the run. The CRM has one route for each answer:

crates/crm-deals/src/assistant_controller.rs

An unknown or already decided call_id is a 404. Approvals need memory: the pending call is stored with the conversation, so the run that stopped must have had a .conversation(..).

The tool's access checks run when it executes, after the approval, against the access of the run that approves it. A new message instead of an answer rejects the pending call. The other calls of the step run at once; when the model asks for two calls that need approval in one step, the second is refused with a 409 telling the model to ask again after the user decides.

Streaming a run

.stream(message) returns an AgentStream, which is a response, like the AI SDK's text streams. The chat panel shows text as it arrives and the tools as they run:

Rust
Response body (text/event-stream)

Tool results stay on the server: the browser learns that a tool ran and whether it succeeded, not the data it returned. A pending approval arrives as event: approval with the same fields as pending_approval. Inside Rust, an AgentStream is a Stream of StreamEvent, with the ToolCall, ToolResult and Approval variants besides Text and Done. Errors setting up the run (an unknown conversation, no user) fail .stream(..).await; later ones arrive as event: error. The run goes on in its own task and stops when the client disconnects.

Running agents in queued jobs

Agents work outside requests too. The CRM sends each rep a Monday review of their pipeline, written by the assistant with the rep's own access:

crates/crm-deals/src/jobs/weekly_review.rs

.read_only() keeps an unattended run from changing anything. A run in a job has no request, so it stores no conversation unless you pass one, and its tool calls each open their own unit of work as usual. A failed model call fails the job, which the queue retries with backoff.

Testing agents

Script the model's side with AiFake and let your tools run for real, against the test database and with real access checks. The fake's replies are what the model "says"; tool calls among them execute your methods:

apps/crm-api/tests/assistant.rs
  • FakeReply::tool_call(name, args) makes the "model" call a tool, with the call ids call_1, call_2, ... Arguments go through deserialization, validation and access checks like a real call, so a test can also send invalid arguments and assert on the error the model would get.
  • ai.tool_results(name) returns what each call of the tool returned to the model, as JSON. assert_tool_called(name) and assert_tool_not_called(name) check that a call happened.
  • ai.requests() shows what the model was offered: requests()[0].tools() lists the tool names, which is how you test that #[permissions(..)] hides a tool from a user.
  • To test an agent without HTTP, run it directly: crm.get::<Ai>().await.agent::<DealsAssistant>().acting_as_user(crm.user_id(Role::Rep1)).prompt("...").await.

Test what your code decides (access, validation, which tool writes what) with the fake. Test prompt quality, whether the model picks the right tool for a phrasing, with a small evaluation suite against the real model, run on demand rather than on every commit.

Reference

Name
AgentThe trait: const NAME, instructions, options.
#[agent]On impl Agent for X: collects the #[tool] methods.
#[tool], #[tool(read_only)], #[tool(needs_approval)]A tool method. Takes &self, &AgentRun and one arguments value.
#[can(..)], #[permissions(..)], #[roles(..)]On a tool: who may use it.
AgentRunThe run inside a tool: user, access, unit of work, clock.
AgentRunBuilderWhat ai.agent::<A>() returns.
AgentOptions, MemoryModel, steps, memory.
AgentReply, AgentStep, PendingApprovalA finished run: its text, its tool calls and a call waiting for the user.
AgentStreamA streamed run, and a server-sent events response.
Conversations, ConversationSummary, StoredMessageStored conversations, per user; prune(days, now) is what ai:prune runs.
ToolSpec, ToolAccessA tool as #[agent] describes it, and its access rules.
m.agent::<A>()Registers an agent in a module.
navree make:agent <module> <name>Generates an agent.
ai:prune [--days N]Deletes conversations untouched for N days (default ai.memory.retention_days).
Config keyDefault
ai.memory.max_messages40Messages the model sees before older ones are summarized.
ai.memory.summary_modelai.defaultThe model that writes summaries.
ai.memory.retention_days90Age at which ai:prune deletes a conversation.