Agent API¶
Mesh tool catalogue, model protocol, and the plan → tool → observe loop.
Details: Agent loop.
Agent loop over mesh tools — plan → query_once → observe.
The fabric primitives stay as they are (@channel, @handle, queues).
This package is the glue that turns a channel handler into an agent whose tools
are other services on Zenoh.
Agent
dataclass
¶
One agent in a handoff graph: a model, its tools, a system prompt, and the agents it may transfer to.
name must be usable in a tool name ([A-Za-z0-9_-]); it is sanitized
the same way key expressions are. description is surfaced to a router
model in the transfer_to_<name> tool so it knows when to hand off.
Source code in src/istos/agent/multi.py
AgentEvent
dataclass
¶
One step the loop emits for the caller to forward (e.g. over a channel).
kind is one of:
message— final assistant text for this turntool_call— model asked to run a mesh tool (name,arguments)tool_result— tool returned (content);errorwhen it raisedapproval_request— waiting on a human before a gated tool runs;approval_idis what to decide,contentthe reason if anyapproval_decision— the human answered;errorwhen refusedhandoff— active agent transferred toname(multi-agent loop)done— turn finished (no more steps)
Source code in src/istos/agent/loop.py
ApprovalDenied
¶
ApprovalGate
¶
Pending approvals for one node: durable state plus the waiters.
Built by :meth:Istos.approvals, which also registers the two fabric keys.
Construct one directly only when driving it yourself (a test, or an agent
that is not an Istos app).
State is written through to the app's StoragePlugin, so with Redis or
SQLAlchemy a restart can still list and settle what was outstanding; with the
in-memory default the pending set dies with the process. The waiter itself is
always in-memory — an agent that has crashed is no longer waiting, and its
recovered request is stale by definition.
Source code in src/istos/agent/approval.py
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 | |
decide_key
property
¶
Where this node accepts decisions.
key
property
¶
Where this node lists its pending requests.
decide(request_id, *, approved, by=None, note=None, arguments=None)
async
¶
Settle a request. Returns it, or None when this gate never had it
(so a fan-out decide can ask every node and only the holder acts).
arguments replaces what the model proposed — an approver may correct a
value rather than reject the call outright. First decision wins; a second
one returns the already-settled request untouched.
Source code in src/istos/agent/approval.py
gate(tool, arguments=None, **kwargs)
async
¶
File a request, wait for it, and return the arguments cleared to run.
Raises :class:ApprovalDenied or :class:ApprovalTimeout otherwise, so
a caller that gets a return value can act::
args = await gate.gate("billing-refund", {"order_id": "o1"})
await tool.call(args)
Source code in src/istos/agent/approval.py
load()
async
¶
Recover requests still pending from a previous run. Runs once.
Source code in src/istos/agent/approval.py
pending()
async
¶
Every request still awaiting a decision, oldest first.
Requests past their deadline are settled as expired here rather than
being offered to an operator who can no longer affect the outcome.
Source code in src/istos/agent/approval.py
request(tool, arguments=None, *, prefix=None, reason=None, requester=None, conversation_id=None, timeout_s=-1.0)
async
¶
File a request and return it, without waiting. timeout_s defaults
to the gate's; pass None for no deadline.
Source code in src/istos/agent/approval.py
wait(request_id, *, timeout_s=-1.0)
async
¶
Block until the request is settled.
Returns the approved request (its arguments are what to run — an
approver may have edited them). Raises :class:ApprovalDenied on a no,
:class:ApprovalTimeout when the deadline passes, and
:class:NotFoundError for an unknown id.
Source code in src/istos/agent/approval.py
ApprovalRequest
dataclass
¶
One pending (or settled) request for a human decision.
Source code in src/istos/agent/approval.py
ApprovalTimeout
¶
Bases: IstosError
Nobody decided before the deadline, so the call did not happen.
Source code in src/istos/agent/approval.py
MeshTool
¶
One mesh endpoint the agent may call.
Built from a local @handle via :func:tools_from_handlers, or by hand
when the tool lives on another node (pass app and the remote prefix)::
MeshTool("math/add", app=app, description="Add two integers",
parameters={"type": "object", "properties": {
"a": {"type": "integer"}, "b": {"type": "integer"},
}, "required": ["a", "b"]})
approval=True (or a string reason) makes the loop stop for a human before
the call — see :mod:istos.agent.approval. The tool's owner can declare it
instead, with @handle(approval=True), in which case discovery carries it
here on its own.
Source code in src/istos/agent/tools.py
approval_reason
property
¶
The reason an approver is shown, when the flag carried one.
call(arguments, *, token=None, timeout_s=5.0)
async
¶
Run the tool. Mesh tools go through query_once so authorizers run.
Source code in src/istos/agent/tools.py
openai_schema()
¶
Tool definition in the OpenAI chat-completions shape.
Model
¶
Bases: Protocol
Source code in src/istos/agent/model.py
complete(messages, *, tools=None)
async
¶
Next assistant turn. tools is the OpenAI tools array, or None.
ModelError
¶
ModelReply
dataclass
¶
What the model returned for one completion turn.
model, finish_reason, and usage ({prompt_tokens,
completion_tokens, …}) are optional telemetry an adapter may fill in; the
loop puts them on its completion span. A custom model can leave them unset.
Source code in src/istos/agent/model.py
OpenAIChatModel
¶
Thin /v1/chat/completions client (non-streaming, with tool calls).
Uses aiohttp — already an Istos dependency — so no extra install for the common OpenAI-compatible local servers::
model = OpenAIChatModel(
base_url="http://127.0.0.1:1234/v1",
model="qwen/qwen3.5-9b",
)
One connection pool is shared across :meth:complete calls, so an agent
loop's steps reuse a live connection instead of reconnecting per turn. The
pool opens on first use and closes with :meth:aclose; use the model as an
async context manager when its lifetime is scoped::
async with OpenAIChatModel(base_url=…, model=…) as model:
await run_agent(model, tools, messages)
Long-lived models (built once at import, used by a handler for the process lifetime) can skip the close — the pool goes away with the loop.
Source code in src/istos/agent/model.py
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 | |
aclose()
async
¶
Close the shared connection pool. Safe to call more than once.
Source code in src/istos/agent/model.py
ToolCall
dataclass
¶
build_registry(entry)
¶
Every agent reachable from entry through handoffs, keyed by name.
Source code in src/istos/agent/multi.py
decide_approval(app, request_id, *, approved, by=None, note=None, timeout_s=3.0, **query_kwargs)
async
¶
Settle a request from anywhere on the mesh, without knowing which node holds it.
Asks every .istos/approvals/*/decide key and returns the settled request
from the one node that had it::
await decide_approval(app, req["id"], approved=False, by="amir",
note="wrong order")
Raises :class:~istos.errors.NotFoundError when no node recognised the id —
it was already settled, it expired, or the waiting node is gone.
Source code in src/istos/agent/approval.py
drive_agents(session, entry, *, max_steps=8, max_messages=40, token=None, timeout_s=5.0, send_events=True, approvals=None)
async
¶
Channel helper: reload history, then run :func:run_multi_agent per turn.
The active agent persists across turns within a session, and is restored on
reconnect from persisted handoff frames (send_events=True); otherwise
a resumed session restarts at entry. token forwards to tool calls
under whichever agent is active. See :func:~istos.agent.loop.drive_channel
for the send_events payload shapes and for how approvals behaves.
Source code in src/istos/agent/multi.py
drive_channel(session, model, tools, *, system=None, max_steps=8, max_messages=40, token=None, timeout_s=5.0, send_events=True, approvals=None)
async
¶
Channel helper: reload history, then run :func:run_agent per inbound turn.
By default each :class:AgentEvent is sent as a dict
{"kind", "content", …}. Set send_events=False to send only the final
message content (plain string). max_messages bounds the reused log so
a long-lived session does not grow unboundedly; pass None to disable.
With approvals set, a gated tool sends an approval_request frame
(carrying approval_id) and the turn pauses. The decision comes back over
the fabric, not this socket — decide_approval(app, approval_id, …), or the
HTTP gateway in front of it — so an operator who is not this channel's peer
can answer, and a peer cannot approve merely by holding the socket.
Source code in src/istos/agent/loop.py
history_to_messages(history, *, system=None, include_tools=True)
¶
Map a durable channel log ([{dir, data, ts}, …]) into chat messages.
With include_tools (the default) the tool transcript is reconstructed:
each persisted tool_call frame becomes an assistant tool_calls message
followed by the tool message carrying its result, so a reconnecting agent
sees the same context it had live. A tool_call with no matching
tool_result in the log (a crash mid-tool) is dropped so the sequence stays
valid. Set include_tools=False to rebuild plain text only.
Source code in src/istos/agent/loop.py
list_approvals(app, *, timeout_s=3.0, **query_kwargs)
async
¶
Every pending approval on the fabric, from any node.
Asks .istos/approvals/*: one key per waiting node, so all of them answer::
for req in await list_approvals(app):
print(req["id"], req["tool"], req["arguments"])
Source code in src/istos/agent/approval.py
run_agent(model, tools, messages, *, max_steps=8, max_messages=None, token=None, timeout_s=5.0, approvals=None, conversation_id=None)
async
¶
Run plan → tool → observe until the model returns text or max_steps.
Mutates messages in place so the caller can keep a multi-turn
conversation. Each mesh tool call forwards token on query_once. Pass
max_messages to bound the log before each completion (system prompt kept).
approvals is an :class:~istos.agent.approval.ApprovalGate (usually
app.approvals()). A tool marked approval=True suspends the loop there:
an approval_request event goes out, the gate waits for a human, and the
call runs only if they said yes. A refusal or a timeout becomes a failed
tool_result the model can respond to — the turn continues, the tool does
not run. Passing tools that need approval without a gate is an error, so a
missing gate can never read as a pass.
Source code in src/istos/agent/loop.py
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | |
run_multi_agent(active, messages, *, max_steps=8, max_messages=None, token=None, timeout_s=5.0, approvals=None, conversation_id=None)
async
¶
Run the loop starting at active, switching agents on handoff.
Mutates messages in place (shared history). Emits the same events as
:func:~istos.agent.loop.run_agent plus handoff when the active agent
changes; the last handoff event names the agent that should drive the
next turn. token is forwarded to every tool call, across handoffs.
approvals gates tools marked approval=True on a human, wherever in the
handoff graph they are reached — see :func:~istos.agent.loop.run_agent.
Source code in src/istos/agent/multi.py
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | |
tool_name(prefix)
¶
The tool name for a key expression (or an agent name).
Every character outside [A-Za-z0-9_-] becomes -, so math/add
is math-add. Distinct prefixes can collide (a/b and a.b both give
a-b); pass an explicit name to :class:~istos.agent.MeshTool when
that matters.
Source code in src/istos/discovery/naming.py
tools_from_discovery(app, *, services=None, prefixes=None, approval=None, timeout_s=3.0)
async
¶
Inventory the fabric and build tools for every remote @handle found.
The counterpart to :func:tools_from_handlers: instead of the local
registry, this asks .istos/capabilities/* (via
:meth:Istos.discover_capabilities), so a remote handler's schema and
docstring come from the node that owns it rather than being written out by
hand::
tools = await tools_from_discovery(app, services=["billing", "search"])
async for event in run_agent(model, tools, messages):
...
Pass services to whitelist by service name, prefixes to whitelist by
key expression, approval to gate extra prefixes on a human (endpoints
declaring @handle(approval=…) arrive gated already). Nodes with discovery
disabled (Istos(enable_discovery=False)) do not answer and contribute
nothing.
The catalogue is a snapshot: call again to pick up nodes that joined later. Duplicate prefixes across services collapse to the first one seen — the same key expression is the same endpoint either way.
Source code in src/istos/agent/tools.py
tools_from_handlers(app, *, prefixes=None, approval=None)
¶
Build :class:MeshTool entries from the app's @handle registry.
Plumbing under .istos/ is skipped. Pass prefixes to whitelist
(exact key expressions). Schemas come from the same path MCP uses.
A handler registered with @handle(approval=…) produces a tool that needs
a human; approval=["some/prefix"] marks extra prefixes from the caller's
side, for endpoints that did not declare it themselves.
Source code in src/istos/agent/tools.py
tools_from_manifest(app, manifest, *, prefixes=None, approval=None)
¶
Build :class:MeshTool entries from one capability manifest.
manifest is what :meth:Istos.export_capabilities returns (and what
:meth:Istos.discover_capabilities collects per service). Only handle
entries become tools: a mesh tool is a query_once, so streams, channels,
and pub/sub entries are not callable this way and are skipped.
app is the local node — it issues the query; the handler itself lives
wherever the manifest came from. An entry the owner marked
@handle(approval=…) arrives with its flag intact; approval=[…] adds
prefixes this caller wants gated regardless.
Source code in src/istos/agent/tools.py
user_text(msg)
¶
Pull a user string out of a channel message (str or common dict shapes).