Agents, think() and hordes
LLM calls inside a state machine: think(), tools, budgets, [task] handlers and hordes. Part of the guide to the Soma programming language.
cell agent, think(), tools and capabilities, [task] handlers, hordes, budgets, mocks, record / replay. The language itself: https://soma-lang.dev/llms.txt (soma docs agent).
Agents (LLMs inside the cage)
cell agent Researcher {
face {
signal research(topic: String) -> Map
tool search(q: String) -> String [capability: "https://api.x.com/*"] "Search"
}
cost { tokens: 6000 } // PROVEN by soma check: 2000 × 3 rounds
state job { initial: idle idle -> working working -> done * -> failed except [done] }
on search(q: String) { return to_json(http_get("https://api.x.com/search?q={q}")) }
on research(topic: String) {
set_budget(8000) // token cap: a prompt that alone overruns what is left raises kind budget before it is sent; otherwise the provider round that crosses it completes and the NEXT round (a later tool round of the same think() too) raises kind budget
transition(topic, "working") // one machine instance per topic
let r = try { think("Research {topic}", map("max_tokens", 2000, "max_rounds", 3)) }
if r.error != () {
transition(topic, "failed")
return map("error", r.kind)
}
transition(topic, "done")
return map("facts", r.value, "spent", tokens_used())
}
}
// `on ask(q: String) [task] { … think(…) … }` runs as STEPS: each think() ends
// the current step (its writes commit) and waits for the model OUTSIDE the
// handler lock, so concurrent requests overlap their model calls (200 × 2 s
// waits in ~9 s instead of 400 s); a failure rolls back the current step only
// (writes before the last think() stay — a `try` cannot undo them either);
// the prover does not carry a read or `require` across a think (another
// request may write meanwhile): read, require and write AFTER the last think.
// Only the ENTRY POINT decides: a [task] handler called from a plain handler
// (`on request` routing to it, a listener, a tick) runs inside the caller's
// atomic unit and holds the lock — mark the caller [task] too:
// `on request(method: String, path: String, body: String) [task]`,
// `every 1min [task] { … }`, `after 5s [task] { … }`. check warns on each
// of these. SOMA_LLM_MOCK_LATENCY_MS gives a mock a latency to test it. A
// plain handler stays one atomic unit. Unknown handler annotations
// ([tsak]) are errors; known: [native], [task], [deterministic], [record].
// HORDES — one [task] handler over many inputs with a bounded pool:
// let h = horde(Reviewer.review, docs, map("concurrency", 200,
// "on_result", "_store", "on_done", "_summarize", "on_error", "_failed",
// "max_attempts", 2)) // returns "Audit:h1" at once
// on _store(v: Map) { verdicts.set(v.id, v) } // or _store(input, result)
// horde_status(h) → {state, total, queued, running, done, failed, cancelled, tokens}
// horde_results(h) → results in input order (() if not done); horde_cancel(h)
// The target takes one parameter; mark it [task] (check warns when it
// thinks: each task would hold the lock). Write the target and the options
// AT THE CALL (a literal map with literal handler names — check refuses a
// computed target or options: they decide what runs and what it costs);
// name callbacks `_…` (a public one is an HTTP endpoint). Only the owner
// cell (and test rules) can read or cancel a horde. The queue is stored in
// soma.db under serve / run, even without a [persistent] slot: a restart
// resumes it; with several servers on one .soma_data each horde runs in one
// of them (a lease, taken over ~6 s after its server stops). A task's
// result is recorded — and on_result called — in the same atomic unit as
// its LAST step, so each result is written once even after kill -9; steps
// before its last think() may run again. on_error(input, error: Map
// {error, kind, detail}) after max_attempts (default 1); on_done(id) once,
// when nothing is left (cancel included). concurrency default 8, max 1000.
// Without workers (`soma test`) the horde runs right after the calling
// handler commits — its next statements run first, as under serve — one
// task per unit. A restart does not resume a horde whose target or
// callbacks the new code renamed (it says so; the tasks wait).
// BUDGET: map("budget_tokens", 2000000) is a hard ceiling for the whole horde — every think() of its
// tasks and callbacks first RESERVES an upper bound (request bytes +
// max_tokens), waits outside the lock for calls in flight to settle if it
// does not fit yet, and is refused (kind budget) when it never will; the
// horde then stops (state "exhausted"; tasks it stopped count as
// cancelled, no on_error). A horde started inside a task or callback of a
// budgeted horde runs under that budget too (nested hordes cannot spend
// past it). on_done's think() also counts: keep headroom for it. A
// provider that ignores max_tokens raises kind llm, charged what it
// reported. The ceiling counts SETTLED calls: calls in flight
// at a kill -9 were sent, then are sent again after the restart. soma verify prints each horde's bound:
// the literal budget_tokens, else inputs × per-task × max_attempts for a
// literal list/range (reply tokens), else unbounded — a horde in a cell
// with `cost { tokens: N }` needs a literal budget_tokens to prove it.
// ROUNDS (simulations): map("snapshot", world, "apply", "_apply", "seed", 7,
// "instance", "id") — the target takes (input, snapshot): every agent of
// the round sees the same world; `apply(result)` / `apply(input, result)`
// runs at the END, in input order, once per task (not as replies arrive),
// then on_done — which may start the next round (a horde() in on_done).
// seed: random() in task i draws the same numbers on every run. instance:
// the input field naming an agent; its remember()/recall() keys and its
// conversation are its own and persist across rounds. 10 000 agents × 20
// rounds: 46 s, identical on every run. After a crash, steps before a
// task's last think() may run again — write memory in the last step.
// vote(Cell.handler, input, k) → {winner, count, k, unanimous, errors,
// votes}: k agents on one input (in a [task] step under serve/run they run
// at once, outside the lock); the most common answer wins. Provider limits
// for every think() of
// the process: soma.toml [agent] rpm = 500, tpm = 200000 (or SOMA_LLM_RPM /
// SOMA_LLM_TPM): at most rpm requests / tpm tokens in ANY 60 s window; a
// 429 pauses every caller. Measured: 10 000 tasks with a
// 2 s mocked model, concurrency 500, in 41 s.
// soma verify PROVES the lifecycle; `cost { tokens: N }` (also latency:,
// usd:) is PROVEN when every think() has a literal max_tokens and runs a
// known number of times — with tools, × the rounds (`max_rounds`, else 10).
// `latency` counts WAITING: each think()'s `timeout` (a total, retries
// included — the wait for a rate limit and a mock's latency too) × its
// rounds, http timeouts, literal sleep()s, vote() × k; approve(), file/stdin
// reads and computed sleeps make it advisory; CPU time is not in it. `usd`
// is the REPLY tokens × the price the table knows for the cell's model
// (prompts are not counted; a model the table does not know makes it
// advisory) — an estimate of the provider's bill, not the bill.
// TOOLS FROM OUTSIDE: a tool may run on a server instead of in a handler
// (it then has NO `on` handler; your code and the model call it the same way):
// tool search(query: String, limit: Int) -> String [mcp: "docs", capability: "https://docs.example/*"] "Search the docs"
// tool get_pet(petId: Int) -> Map [openapi: "pets/getPetById"] "Fetch a pet"
// # soma.toml
// [tools.docs] mcp = "https://docs.example/mcp" token_env = "DOCS_TOKEN" # MCP over Streamable HTTP
// [tools.pets] openapi = "petstore.json" base_url = "https://…" # OpenAPI 3, JSON
// `[mcp: "docs"]` is the server's tool of the same name, `[mcp: "docs/other"]`
// another name; for OpenAPI the name is the operationId. `soma tools sync`
// asks each server for the contract and pins it in soma.lock (commit it);
// `soma check` then works offline: a tool not pinned, an argument the server
// does not take, a wrong type or a required argument left out is an error
// (kind tool_binding). Parameter NAMES are the server's (path, query, header
// and JSON-body fields for OpenAPI; a non-object body is one argument `body`).
// At run time a server whose contract no longer matches the pin is refused
// before anything is sent (kind tool_schema: sync again, read the CHANGED
// lines, check). Its capabilities apply to every call, yours too; redirects
// are not followed; a server-side failure is kind `tool`. A test never reaches
// the server: `mock search "…"` scripts it. `soma tools list` shows the pins.
// Not read: MCP over stdio, YAML OpenAPI documents, MCP resources / prompts.
// The model may call only the face's `tool`s (never a private or other
// handler), with typed arguments, within their capabilities (URL patterns with
// `*`: "https://api.x.com/*"; a tool with no capability is unrestricted; a
// tool that calls think() makes a `cost` bound unprovable, a tool doing I/O a
// `latency` bound; I/O in a loop or lambda leaves a `tokens` bound provable) — the scope binds
// MODEL calls only (your own code calling the tool handler is not
// restricted), and a tool is not an HTTP endpoint; `mock think` scripts text
// replies only (to test tool calls offline, point [agent] url at a fake
// OpenAI-chat-compatible server: tools go out as `tools`, results come back
// as `role: "tool"` messages, think_json sets response_format json_object,
// usage.total_tokens feeds tokens_used(), usage.completion_tokens is held to
// max_tokens); a tool call that raises is rolled
// back. `map("max_rounds", N)` caps the tool-call rounds of one think();
// `map("tools_allowed", ["lookup"])` offers only those tools to that call
// (classify with no refund tool in reach); a `*` in a capability stays in
// its part of the URL (host or path), `..` segments and userinfo are
// refused, and a tool's scope holds for the tools of any agent it calls;
// a scoped tool reads/writes no file (read_file, load, include, …) and opens
// no link/socket. An unknown option, or a tools_allowed name that is not a
// tool, is an error. A reply longer than its max_tokens (a provider ignoring
// the cap) raises kind `llm`; a tool that calls back its think() handler is a
// termination ⚠ (the model drives that recursion). Each agent cell keeps its OWN multi-turn
// context (clear_context() resets it); every/after ticks use the same
// [agent] config as requests. `soma test` mocks think() only when no key is
// configured — set SOMA_LLM_MOCK=echo for offline tests with a soma.toml key;
// `mock h [[1, 2]]` scripts ONE List answer ([1, 2] is a queue of two).
// approve(msg) is a gate a PERSON answers: tests script it (mock approve),
// serve raises approval_required unless SOMA_APPROVE says otherwise (and
// the handler rolls back). For a web flow use a DURABLE WAIT in a [task]
// handler:
// on refund(id: String, amount: Int) [task] {
// transition(id, "pending")
// let ok = await approve("refund {amount} for {id}") // 202 {waiting: "w_…"}
// if !ok { transition(id, "refused") return response(403, map("refused", id)) }
// transition(id, "paid")
// }
// Unanswered, the call returns 202 and the step commits; the handler's
// position and locals are stored with the data and the rest runs when the
// answer arrives, after a restart too: POST /_approvals/<id> {"approved":
// true|false} (GET /_approvals lists them), or resume(id, answer) from a
// route of your own (your auth). The /_approvals and /_signals routes answer
// only a server bound to loopback, or `Authorization: Bearer $SOMA_WAIT_TOKEN`.
// resume() / cancel_wait() are refused inside a tool: the model does not
// answer its own request. `await signal("picked_up", id)` waits for
// deliver("picked_up", id, value) or POST /_signals/picked_up/<id> (the JSON
// body is the value); `await sleep(2h)` for the clock (serve's timer resumes
// it; `soma run` sleeps between two steps; a test does not wait). Each resumed
// part is a step: if it fails it rolls back and the wait stays, to be answered
// again. Give a resting state another way out (an `every` sweep, a cancel
// handler): `soma verify` warns when only the answer leaves it. Scripted
// answers still win: `mock approve true` makes `await approve` go straight on.
// think(prompt, map(..)) or think(prompt, system, map(..)): options go LAST.
// Offline: `mock think …` in tests, SOMA_LLM_MOCK=echo | fixed:TEXT |
// rules:mocks.json — a list of {"match": "substring of the prompt",
// "cell": "AgentCell", "reply": "text" or a JSON value}: the first rule whose
// match / cell fit answers, none: an echo — or
// [agent] mock = "echo" in soma.toml. Record/replay: `soma run --record` (not serve) logs each top-level
// call; `soma replay` re-runs them from EMPTY storage with the soma.toml
// [agent], calls think() again (replies are not recorded) and compares the
// RESULTS — a state difference shows only in a later result.Untrusted values, replay, evals, telemetry (3.0)
-
UNTRUSTED VALUES. What think() / think_json() / http_*() / read_stdin() return, and what is computed from it, is untrusted. Reaching the target of transition(), the URL of an http_* call, a file path or set_budget without a test is a check warning (edition 2027: error kind untrusted_value). A value is cleared when the handler tests it — it appears in a require, an if / while condition or a match subject — or passes it to another handler. match d { "approved" -> transition(id, "approved") _ -> fail("bad_state", d) } is the form; what an arm BINDS out of an untrusted subject (AutoPay { amount }) is still untrusted.
-
REPLAY. soma run --record writes the model's answers beside the log (app.somalog.replies); soma replay hands them back in order, so the replay reproduces the run without asking the model (--live asks again). A think() that called tools is given its final answer only: the tools do not run again. A mocked reply counts ~4 characters per token, so a handler that returns tokens_used() replays with a different number.
-
EVALS. soma eval app.cell --handler classify --dataset cases.jsonl [--grade exact|contains|handler:Cell.judge] [--threshold 0.9]: one JSON object per line, {"input": …, "expected": …, "name"?: …} (input a list: the arguments). The run is stored in .soma_eval/ and the next run names the cases that passed and now fail. judge(input, answer, expected) answers a Bool. think() is the configured model when a key is set, the mock otherwise — as under soma test.
-
TELEMETRY. SOMA_OTEL_FILE=spans.jsonl: one OTLP/JSON line per top-level handler invocation — a span (Cell.handler, error kind as status) with one event per model call (gen_ai.chat, gen_ai.usage.total_tokens), tool call, approval and transition. Prompts, tool arguments and results are exported as their sizes unless SOMA_OTEL_CONTENT=1.