Agents
I wanted AI agents that aren't just "call an API and hope for the best." Agents in Adama are first-class reactive types — they live inside documents, persist through crashes, participate in the commit/revert cycle, and delta-sync their state to connected clients. They're as durable and consistent as everything else in the system.
An agent definition creates a new type (like record is for data). A session<Agent> instantiates a live agent context with its own conversation history, mutable state, and request queue. The whole thing is reactive; when an agent responds or a tool fires, connected clients see the updates in real time.
Defining an Agent
Use the agent keyword. At minimum, you must specify max_tool_rounds — the compiler rejects agents without it. I made this required because I've seen what happens when you let an LLM call tools in an unbounded loop. Nothing good.
agent Helper {
max_tool_rounds = 5;
instructions = "You are a helpful assistant.";
}
Configuration Options
| Config | Type | Required | Default | Description |
|---|---|---|---|---|
max_tool_rounds |
int | Yes | — | Maximum tool-call loop iterations per ask |
model |
string | No | vendor default | LLM model identifier (e.g. "gpt-4o", "claude-sonnet-4-20250514") |
max_tokens |
int | No | vendor default | Maximum response tokens per LLM call |
temperature |
double | No | vendor default | Sampling temperature |
max_history |
int | No | unlimited | Conversation depth (0 = stateless, N = keep last N exchanges) |
token_budget |
long | No | unlimited | Total token ceiling per session lifetime |
staleness_timeout |
int | No | 60 | Seconds before a stuck processing session is auto-reset |
web_search |
bool | No | false | Enable web search capability (vendor-dependent) |
instructions |
string expr | No | none | Dynamic system prompt, evaluated at ask-time |
Here's the part I find elegant: config values are expressions evaluated at ask-time, so they can reference document state:
agent Tutor {
max_tool_rounds = 3;
model = preferred_model; // reference a document field
temperature = difficulty * 0.1; // computed from state
instructions = "Help the student learn " + subject;
}
The instructions are a live expression. They change as the document changes. The LLM gets different system prompts depending on the current state of the world.
Complete Example
message SearchQuery { string query; }
message SearchResults { string answer; }
message FindingInput { string title; string notes; }
message FindingAck { bool recorded; }
agent Researcher {
max_tool_rounds = 5;
model = "gpt-4o";
max_tokens = 2000;
temperature = 0.3;
max_history = 50;
token_budget = 100000;
instructions = "You are a research assistant specializing in " + specialty;
mutable string specialty = "general";
mutable int papers_reviewed = 0;
@description("Search articles by topic")
tool<SearchQuery, SearchResults> search_articles {
return { answer: "found results for: " + request.query };
}
@description("Record a finding")
mutating tool<FindingInput, FindingAck> record_finding {
papers_reviewed++;
return { recorded: true };
}
}
Sessions
A session<Agent> field instantiates a live agent context. Sessions are reactive — they persist through crashes, participate in the document's commit/revert cycle, and delta-sync their state (history, mutables, processing status) to connected clients.
public session<Researcher> chat;
Sessions can be embedded in records within tables, which means you can create agent instances dynamically at runtime:
record PanelMember {
public session<Specialist> s;
}
table<PanelMember> panel;
channel add_specialist(string spec) {
panel <- { s: { specialty: spec } };
}
Session Methods
| Method | Returns | Description |
|---|---|---|
.ask(who, prompt) |
future<maybe<string>> |
Send a prompt and wait for a response |
.askWithAsset(who, prompt, asset) |
future<maybe<string>> |
Ask with a single asset attachment |
.askWithAssets(who, prompt, assets) |
future<maybe<string>> |
Ask with multiple asset attachments |
.clear() |
void | Clear conversation history |
.size() |
int |
Number of messages in history |
.is_processing() |
bool |
Whether an LLM call is in-flight |
.token_usage() |
long |
Total tokens consumed across all asks |
.tool_round() |
int |
Current tool round number |
Asking an Agent
The .ask() method sends a prompt and returns a future<maybe<string>>. The agent handles the entire LLM conversation loop — including tool calls — autonomously:
message QuestionMsg { string text; }
channel ask_question(QuestionMsg msg) {
let result = chat.ask(@who, msg.text);
// result is future<maybe<string>>
}
Every .ask() requires an explicit who parameter (a principal) for audit and access control. I didn't want agents doing things without knowing who asked.
Multimodal Requests
Attach assets (images, files) for multimodal LLM interactions:
channel ask_with_image(QuestionMsg msg) {
let result = chat.askWithAsset(@who, msg.text, uploaded_image);
}
channel ask_with_files(QuestionMsg msg) {
let result = chat.askWithAssets(@who, msg.text, file_array);
}
What Happens During an Ask
- The user message is appended to session history and sent to the LLM with available tool schemas
- If the LLM responds with tool calls, the runtime dispatches each tool within the document's transaction model
- Tool results are appended to history and another LLM call fires automatically
- The loop continues until the LLM finishes or
max_tool_roundsis reached - The entire conversation state — including processing status and in-flight tool names — streams to connected clients via delta sync
That last point is worth sitting with. The client can show "the agent is calling the search tool right now" in real time because the processing state is reactive.
Tools
Tools are typed operations the LLM can invoke during a conversation. They're defined with input and output message types:
message AddTaskInput { string name; }
message AddTaskOutput { bool success; }
agent TaskHelper {
max_tool_rounds = 3;
@description("Add a new task to the list")
tool<AddTaskInput, AddTaskOutput> add_task {
tasks <- {name: request.name, status: "todo"};
return { success: true };
}
}
The @description annotation provides the tool description sent to the LLM. The input is available via the implicit request binding.
Read-Only vs Mutating Tools
I split tools into two kinds with different write permissions. This is the safety layer that makes agents trustworthy inside a document:
Read-only tools (tool<In, Out>) can read document state and agent mutables but cannot write anything. The compiler enforces this:
@description("Look up a task by name")
tool<LookupInput, LookupOutput> find_task {
// can read `tasks` table, but cannot insert/update/delete
let found = (iterate tasks where name == request.name).first();
return { found: found.has() };
}
Mutating tools (mutating tool<In, Out>) can write agent mutable fields, but still cannot write document state:
@description("Track a topic")
mutating tool<TopicInput, TopicOutput> set_topic {
last_topic = request.topic; // OK: writing agent mutable
search_count++; // OK: writing agent mutable
// tasks <- {...}; // ERROR: cannot write document state
return { ack: true };
}
This is a deliberate constraint. An LLM calling tools shouldn't be able to corrupt your document state. It can update its own scratch space (mutables) and it can read the world, but it can't write to the world. That boundary is compiler-enforced — not a runtime check, not a "please don't" comment.
Tool Visibility
Control which tool messages get synced to connected clients:
@public(default) — tool description and results are visible in client delta sync@private— tool results are hidden from client sync (server-only)
@private
@description("Internal lookup - results hidden from clients")
tool<InternalQuery, InternalResult> internal_lookup {
return { data: "sensitive info" };
}
Mutable State
mutable fields within an agent definition create per-instance persistent reactive state:
agent Tracker {
max_tool_rounds = 3;
mutable string last_topic = "none";
mutable int query_count = 0;
mutable double confidence = 0.0;
mutable bool initialized = false;
mutable long total_chars = 0;
}
Supported types: int, long, double, bool, string.
Properties of mutable state:
- Persistent — committed with the document, restored on crash recovery
- Reactive — changes propagate via delta sync to connected clients
- Scoped — readable from all tools and instructions; writable only from
mutating toolbodies - Atomic — if an ask fails, all mutable changes are discarded
That atomicity guarantee is the same one the rest of the document enjoys. If something goes wrong mid-conversation, the agent's mutable state rolls back cleanly.
Conversation History
Each session maintains an automatic conversation log:
- Messages are appended after each successful exchange
- History is included in LLM requests automatically
max_historybounds the number of stored messages (0 = stateless).clear()resets history for a new conversation- History is reactive and delta-synced to clients
Messages have visibility levels:
- Public — synced to connected clients (user and assistant messages)
- Private — server-only (system prompts, tool call details)
Token Budget Enforcement
When token_budget is set, total token usage is checked before each LLM call. If the budget is exceeded, the ask fails with an error message appended to the session rather than making the LLM call:
agent CostAware {
max_tool_rounds = 5;
token_budget = 50000; // fail after 50k total tokens
// ...
}
I added this because LLM costs can spiral fast, especially with tool loops. Better to fail explicitly than to get a surprise bill.
Crash Recovery
Agent sessions are fully durable:
RxSessionstate (history, mutables, queue) is persisted via the document commit cycle- On document restore, sessions stuck in processing state are detected
staleness_timeout(default 60 seconds) auto-resets stuck sessions- No manual intervention needed — recovery is automatic
Multi-Vendor LLM Support
Agents work with multiple LLM vendors. Configure the aichat service in your space:
service aichat {
class = "aichat";
apikey = @encrypted_key;
vendor = "openai"; // or "anthropic" or "grok"
}
| Vendor | Models | Notes |
|---|---|---|
| OpenAI | GPT-4o family | Standard chat completions API |
| Anthropic | Claude (claude-sonnet-4-20250514) | Content blocks and tool_use format handled transparently |
| xAI | Grok (grok-4-1) | Supports web_search and file uploads |
Tool call formats are normalized to a canonical representation internally. Vendor-specific quirks are handled transparently — your agent code works the same regardless of which vendor you use. I spent a lot of time on this normalization layer so you don't have to think about it.
Safety Properties
I designed agents with multiple safety layers because, frankly, giving an LLM tools inside a stateful system is inherently risky:
max_tool_roundsis required — the compiler rejects agents without it, preventing unbounded LLM loops- Read-only document tools — no tool can mutate document state (compiler enforced)
- Scoped mutables —
mutating toolcan only write agent mutables, not document state - Atomic failure — if an ask fails, all agent mutable changes are discarded
- Cost bounds —
token_budgetprevents runaway token consumption - Principal tracking — every
.ask()requires an explicitwhoparameter - Staleness recovery — stuck sessions are automatically reset after timeout
Dynamic Agent Panels
Combine sessions with tables to create dynamic collections of agents:
agent Specialist {
max_tool_rounds = 5;
instructions = "You are a board-certified " + specialty;
mutable string specialty = "general";
@description("Access patient records")
tool<PatientQuery, PatientData> get_patient {
return { data: patients[request.id].summary };
}
}
record PanelMember {
public session<Specialist> s;
}
table<PanelMember> panel;
channel add_specialist(string spec) {
panel <- { s: { specialty: spec } };
}
channel consult_all(string question) {
(iterate panel).foreach((p) -> {
p.s.ask(@who, question);
});
}
This pattern lets you spin up new agent instances at runtime — each with their own conversation history, mutable state, and configuration — without code changes or redeployment. It's the kind of thing that would be a massive infrastructure project in a traditional system, and here it's just a table with a session field.