An agent is a named capability the router can dispatch a user request to. This is the contract, the shapes available for building one, a minimal example, and the rules an agent must follow to stay consistent with the rest of the harness.
threetoks/agents/base.py:
@dataclass(frozen=True)
class AgentSpec:
name: str
description: str
run: Callable[[str, Services, Policy], dict]name— short, unique, lowercase (casual,web,files). Shown in/agentsand used as the router's fallback key (see below).description— one line, shown to the router model as the menu option text ("{name} — {description}"). Keep it short and disambiguating; the router is a single one-tokenMenuNodedecision, so overlapping descriptions cost accuracy.run(task, services, policy) -> dict— does the work.
def run(task: str, services: Services, policy: Policy) -> dict:
...task: str— the raw user request text.services: Services(threetoks/services.py) — shared capabilities:provider(search),fetch_page(url ->PageText),files_root,max_research_rounds. Take what you need, ignore the rest; do not assume every field is populated for every agent (e.g.filesnever touchesprovider).policy: Policy(threetoks/policy.py) — the only way to consult the model. Callpolicy.decide(episode, node); never call a backend directly.
Required result key:
"answer": str— the final answer text shown to the user.
Optional keys (fill in what applies; the TUI result panel and tests tolerate missing ones):
"notes": str— rendered note text (NoteStore.render()), shown dim under the answer as sources."agent": str— your agent's name; set it inrun()(or via a thin wrapper — seethreetoks/agents/web.py) so the TUI panel labels itself correctly. Not automatic."rounds": int— round count, shown in the stats footer when present.- Anything else your agent wants to report (e.g.
files.pyadds"files_opened") — extra keys are ignored by callers that don't know them.
Everything the model sees is one of the node types in threetoks/nodes.py.
An agent that does a single decision can build these directly; an agent
that runs a multi-step exploration (like files or web) drives them
through a Vertical (see threetoks/engine.py) so the harness's step loop,
budgets, and escape handling are shared instead of reimplemented.
| Node | Model output | Use it for |
|---|---|---|
MenuNode(question, options, escape=True) |
one digit | any single-choice decision: "what next", judge verdicts, routing |
PickManyNode(question, n_items, max_picks=4) |
comma-separated indices | "which numbered sentences/lines answer this" — the note-taking step |
ShortTextNode(question, prefill, max_tokens=24) |
bounded free text | search queries, final free-text answers |
confirm_node(question) |
MenuNode with ["yes", "no"], no escape |
rarely — see the design-rules note on judge phrasing below |
Construct a node, then call policy.decide(episode, node) to get back a
Decision(kind, value, raw_text, valid). Always handle decision.valid == False — treat it the same as an explicit escape, never crash or retry
silently forever.
An agent that answers with the current line count of a fixed string, via one menu decision (yes/no framed semantically, not as raw "yes"/"no" per the design rule below) — enough structure to copy for a real one-shot agent:
"""Example agent: counts words in the task text after one confirmation."""
from threetoks.agents.base import AgentSpec
from threetoks.nodes import MenuNode
from threetoks.render import Episode
AGENT_NAME = "wordcount"
AGENT_DESCRIPTION = "counts words in your message"
PREFIX = """You confirm a simple action.
Example:
ACTIONS:
1 = go ahead and count the words
2 = do not count, just say hello instead
Reply with exactly ONE digit.
ANSWER: 1"""
OPT_COUNT = "go ahead and count the words"
OPT_SKIP = "do not count, just say hello instead"
def run(task: str, services, policy) -> dict:
episode = Episode(PREFIX, task)
episode.open_observation(f"MESSAGE:\n{task}")
node = MenuNode("What should I do?", [OPT_COUNT, OPT_SKIP], escape=False)
decision = policy.decide(episode, node)
if decision.valid and decision.value == OPT_COUNT:
answer = f"{len(task.split())} words"
else:
answer = "hello!"
return {"answer": answer, "agent": AGENT_NAME}
SPEC = AgentSpec(AGENT_NAME, AGENT_DESCRIPTION, run)For anything with more than one decision, model the state machine as a
Vertical (episode, next_node(), apply(node, decision), result())
and drive it with threetoks.engine.run_episode(vertical, policy, max_steps=...) — see threetoks/agents/files.py for the smallest real
example and threetoks/web/vertical.py for the fullest one.
threetoks/agents/__init__.py:
from threetoks.agents import casual, code, files, web
def default_agents(services: Services) -> list[AgentSpec]:
return [casual.SPEC, web.SPEC, files.SPEC, code.SPEC]To add an agent: write the module exposing a module-level SPEC = AgentSpec(...), import it, and add yourmodule.SPEC to the list returned
by default_agents. Order matters only in one respect: the first spec in
the list is the router's fallback when the model's choice is invalid or
unparseable (threetoks/agents/router.py), so keep a cheap, safe default
(casual) first. Names must stay unique — the registry test enforces this.
Agents gated on an optional capability register through
optional_agents(services, model) instead: light appears only when a
GPIO relay is live on services.relay (Raspberry Pi relay extra) and
look only when services.capture_frame is set (camera extra) AND the
model name passes is_vision_model (a name-mark heuristic in
threetoks/backend/base.py — extend _VISION_MODEL_MARKS when new
vision families appear). List any such agent's name in
OPTIONAL_AGENT_NAMES so /model can re-derive the set at runtime.
Import the agent module lazily inside optional_agents — the registry
must import on a bare install.
The light agent is deliberately minimal — it exists as the worked
example for wiring ANY physical tool (fan, servo, sensor, second relay)
into the mesh. The recipe, with threetoks/relay.py +
threetoks/agents/light.py as the reference at every step:
- Hardware module (
threetoks/yourtool.py): one class owning the device. Import the hardware library lazily inside__init__and accept an injectable stand-in for tests (seeRelay(pin, gpio=None)). Action methods return short spoken strings — never booleans dressed as strings. Add a pure availability gate likerelay_available()(platform +find_specchecks, zero import side effects). - Agent module (
threetoks/agents/yourtool.py): a module-levelSPEC = AgentSpec(name, one-line description, run). Insiderun(task, services, policy): a guard clause answers when the device is absent; a word-boundary regex settles clear phrasings with ZERO model calls (status-style questions must never drive the hardware); anything else spends exactly ONE MenuNode of semantically described options, escape last. Answers are one spoken-length sentence — voice mode reads them aloud. - Services field: add
yourtool: object = Nonetothreetoks/services.py; construct it intui.build_statebehind the availability gate, degrading toNonewith a notice on failure (see_make_relay). - Registration: extend
optional_agents()(lazy import) and add the agent's name toOPTIONAL_AGENT_NAMES. - Packaging: a new extra in
pyproject.toml; if the dependency is Pi-only, gate it on architecture the wayrelaydoes ("RPi.GPIO; platform_machine == 'aarch64' or ..."). - Tests + smoke: offline only — a fake device recording calls
(
tests/test_relay.py), agent tests asserting the regex paths never consult the backend (tests/test_light_agent.py), your module listed intests/test_lazy_extras.py, and anif __name__ == "__main__":smoke block that passes with no hardware library installed.
An agent may itself route further: the code agent front-doors four modes
(navigate / edit / compute / author) through threetoks/code/route.py —
deterministic pre-checks first, one one-token menu otherwise (measured in
spike E7; see docs/DESIGN-coding-agent.md §9a). It also reads
services.retriever (the opt-in retrieval-as-repair hook, None unless
[code] retrieval is enabled and a search provider exists) and
services.files_root (the corpus the navigate/edit modes operate on).
These are load-bearing, not stylistic — Phase-0/Phase-2 measurements are the reason for each one:
- Menus have at most 9 numbered lines, including the escape.
MenuNodeenforces this (MENU_MAX_OPTIONS = 9) and raises if you try to exceed it. If a domain has more than 8 real options, page them — don't widen the menu. - The escape is a numbered LAST option, never digit
0. Tiny models essentially never emit an out-of-distribution0(E4); an escape rendered as0is unreachable in practice. UseMenuNode(..., escape=True)(the default) unless the decision genuinely has no "none of these" case (e.g. a judge's good/bad verdict, which usesescape=Falsebecause both options are exhaustive). - Notes are verbatim, never rewritten. Take notes by having the model
PickManyNodeover numbered sentences/lines the harness already rendered, then copy the chosen text into aNoteStorebyte-for-byte. Never ask the model to summarize or restate source content — that reintroduces hallucination risk the numbered-pointer design exists to remove. - Answers are grounded generation over curated notes. When notes
exist, rank them down with a curation
PickManyNode(pick order = ranking), then run ONE shortShortTextNodegeneration with the notes on screen that answers the task in plain words — the web and files verticals share this shape. Never dump picked notes verbatim as the default answer (a live files run answered "what are these files?" with naked code lines), and never generate without the notes visible: ungrounded synthesis garbles facts the notes already hold (eval data). If the generation keeps bleeding menu digits, fall back to quoting the task-closest notes (rank_by_overlap) — the fallback stays aimed at the goal, not at store order. - Budgets and guards are mandatory, not optional. Every multi-step
agent needs: a step budget (
max_stepspassed torun_episode, or an equivalentsteps_leftcounter), a forced-answer trigger a few steps before the budget runs out (seeFORCE_ANSWER_AT_STEPS_LEFTinfiles.py/vertical.py), and a cap on expensive sub-actions (pages opened, files opened, searches run). A model that never terminates a loop is a bug in the vertical, not something to patch with a bigger step budget. - Judge / confirm decisions should be phrased semantically, not as
abstract yes/no.
research.py's judge uses two option texts describing what "good" and "bad" mean for the task, rather than a bareconfirm_node, because the 1.5b's plain yes/no accuracy was close to a coin flip in practice. Prefer descriptive option pairs overconfirm_nodewhenever the model needs to make a judgment call (not a purely mechanical yes/no like "keep going?"). - Deterministic pre-checks before spending a model call. If code alone
can tell an answer is junk (empty, a bare number, no supporting notes),
reject it before asking the model to judge — see
research._obviously_bad. Every model call has a latency and error-rate cost; don't spend one where a regex or a length check will do.