Agents & coordination
Multi-agent
Agents run concurrently on real threads, each in its own isolated interpreter. They coordinate through a shared blackboard and signals — not direct calls. (This is the concurrency layer; for a model that picks tools, see Tool calling.)
-- Doc example: the blackboard (share / observe) — thread-safe shared state.
-- spawn / signal / wait_for run real threads (non-deterministic), shown in the prose.
intent: "doc example: blackboard"
share 42 as "demo_key"
observe "demo_key" as demo_v
print("blackboard: demo_key → " + text(demo_v))
test "the blackboard is shared, synchronous state (share publishes, observe reads)"
share 42 as "answer"
observe "answer" as a
assert_eq(a, 42)
share "hi" as "result_1"
observe "result_1" as g
assert_eq(g, "hi")Define & spawn§
Defining an agent registers it; the body runs only when spawned (in a new thread). The parent continues immediately.
agent Researcher
require net("*.wikipedia.org")
let data be fetch("https://en.wikipedia.org/api/...")
share data as "research"
signal "done"
spawn Researcher with query = "AI safety"
Top-level tasks/values are snapshotted (a copy) into the agent. What the agent does not see: tasks defined inside imported modules (only the entry program's top-level bindings travel — re-export what the agent needs as a top-level task), and a task passed as a spawn argument arrives as text (closures do not cross threads: pass data). Each agent is a fresh interpreter with its own capability set — it needs its own require lines, bounded by the host ceiling. A failing agent is contained (state ERROR); synsema run joins agents before exiting and exits non-zero if any ended in ERROR.
Testing agents. synsema test wires the same real swarm as run (engine v0.6.10+): inside a test block spawn starts the agent in its own thread, agents()/agent_stop exist, blackboard and signals work. When the block ends the runner joins that block's agents; an agent that finished in ERROR fails that test (Agent error [<id>]: …) and the next block starts clean. On engines ≤ 0.6.9 test had no swarm (spawn ran the body in-process, blocking).
Blackboard — share / observe§
share value as "key" publishes; observe "key" as var reads. Thread-safe and versioned; the key is an expression ("result_" + text(id)).
Signals — signal / wait_for§
signal "done" -- emit (a consumable queue, not a latch)
signal "done" with data -- emit with a payload
wait_for "done" as result -- blocks until a signal arrives (default 30s), CONSUMES it
wait_for "done" timeout 2 as r -- bound the wait; returns nothing on timeout
Bound wait_for with timeout inside a route handler so a request can't hang. The channel is an expression — use "cancel:" + text(job_id) for a per-job push channel.
Event bus — bus_publish / bus_subscribe (fan-out) — engine v0.6.7+§
Signals are consumed by one receiver. When N parties must see the same event (every SSE/socket client of a live UI), publish on the bus: one per program, seen by the top level, parallel_map workers, cron ticks, spawned agents and every serve handler; in-process, bounded per subscriber, glob topics, no capability.
bus_publish("agent.done", {"id": 7}) -- → subscribers reached
let sub be bus_subscribe("agent.*")
let ev be bus_recv(sub, 30) -- {type: "event", topic, data, timestamp} or nothing
Full semantics, select over sockets + processes + bus, and the SSE pattern: Agentic apps.
Observing and stopping agents — agents() / agent_stop — engine v0.6.7+§
agents() -- [{id, name, state, error, started_at, finished_at}]
agent_stop(id, reason?) -- cooperative cancellation; true if it was alive → state "stopped"
A stopped agent raises cancelled: <reason> before its next statement and wakes from any wait (wait_for, sleep, select…). Works under run and serve, no capability. Under serve, a spawned agent gets the same wiring as a cron tick (state_*, DB, approvals, bus, memory), runs under the host ceiling (serve --sandbox | --cap-set), and is stopped by an ordered shutdown.
Inspecting a run§
synsema conform --swarm program.syn -- JSON dump: blackboard + per-agent states