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

  1. The user message is appended to session history and sent to the LLM with available tool schemas
  2. If the LLM responds with tool calls, the runtime dispatches each tool within the document's transaction model
  3. Tool results are appended to history and another LLM call fires automatically
  4. The loop continues until the LLM finishes or max_tool_rounds is reached
  5. 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 tool bodies
  • 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_history bounds 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:

  • RxSession state (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:

  1. max_tool_rounds is required — the compiler rejects agents without it, preventing unbounded LLM loops
  2. Read-only document tools — no tool can mutate document state (compiler enforced)
  3. Scoped mutablesmutating tool can only write agent mutables, not document state
  4. Atomic failure — if an ask fails, all agent mutable changes are discarded
  5. Cost boundstoken_budget prevents runaway token consumption
  6. Principal tracking — every .ask() requires an explicit who parameter
  7. 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.

Previous Testing
Next Debug