Watch¶
Primitives for polling an external system and parking between polls. See the watch-and-respond recipe for how they compose, and Context.wait for the underlying timer suspend.
agentflow.prebuilt.watch
¶
Watch an external system without burning turns while nothing changes.
A watch node polls something outside the graph — a pull request, a build, a
deployment, a ticket, anything a command can check — and hands control back to
the graph only when there is activity or a terminal state. Between polls it
parks: the node calls ctx.wait(wake_at), which suspends the run until the
time passes. Under the control plane that means the worker is released and the
run costs nothing while parked (it is re-claimed when wake_at arrives);
inline, :meth:CompiledGraph.run_until_done sleeps until then.
A watcher reports one of three outcomes each poll:
- idle — nothing changed; park and poll again later.
- activity — something happened; return it to the graph to react to.
- terminal — the thing reached a final state; the loop should exit.
Build a watch loop from the primitives: a watch_node that parks on idle and
writes activity/terminal to state, then a conditional edge that routes activity
to a responder (and back to the watch) and terminal to the exit.
g.add_node("wait", watch_node(CommandWatcher("./poll.sh"), poll_interval=60))
g.add_node("respond", respond) # reads state["watch_result"]["payload"]
g.add_conditional_edges("wait", route_watch, {"activity": "respond", "terminal": END})
g.add_edge("respond", "wait")
Honest limits¶
Polling is not push: "no model turns while parked" is not "no latency" — an
event is seen at the next poll, so poll_interval trades freshness for cost.
Wake granularity is also bounded by how often the control-plane workers claim
(their own poll interval). And the cursor a watcher returns is persisted on the
wait checkpoint, so it survives the park, but a watcher must be able to resume
from a cursor rather than assuming it kept in-memory state across polls.
WatchResult
dataclass
¶
One poll's outcome.
outcome is "idle", "activity", or "terminal". payload is
the event data for activity/terminal (what a responder reacts to).
cursor is opaque state handed back to the next poll so the watcher can
resume where it left off; it is persisted across the park.
Source code in agentflow/prebuilt/watch.py
Watcher
¶
Bases: Protocol
Polls an external system. Must not block the loop for long.
poll is called once per visit with the previous poll's cursor (None
the first time) and returns a :class:WatchResult. It should return
promptly — the parking between polls is the loop's job, not the watcher's.
Source code in agentflow/prebuilt/watch.py
CommandWatcher
¶
Watch anything a command can check: run it, parse one JSON object.
The command prints a single JSON object to stdout describing the current
state; this watcher maps it to a :class:WatchResult. The shape mirrors a
minimal, tool-agnostic contract::
{"outcome": "idle" | "activity" | "terminal",
"payload": <any, optional>,
"cursor": <any, optional>}
The previous cursor is passed to the command as a AGENTFLOW_CURSOR env
var (JSON) so a stateless script can resume. A non-zero exit is treated as
an idle poll by default (transient failure); set strict=True to raise.
GitHub, CI, a ticket system — all are a CommandWatcher over your script,
which is why no provider specifics live here.
Source code in agentflow/prebuilt/watch.py
watch_node
¶
watch_node(watcher: Watcher | Callable[[Any], Awaitable[WatchResult]], *, poll_interval: float = 60.0) -> Callable[[Any, Any], Awaitable[dict[str, Any]]]
Build a node that polls watcher and parks on idle.
On an idle poll the node calls ctx.wait(now + poll_interval, cursor) to
suspend the run until the next poll, carrying the watcher's cursor across
the park. On activity or terminal it returns an update writing the result
(and cursor) to state, where :func:route_watch can branch on it.
The watcher may be a :class:Watcher or a bare async (cursor) ->
WatchResult callable. The node reads/writes two channels, watch_result
and watch_cursor; declare them on your state (both last-reduced).
Source code in agentflow/prebuilt/watch.py
route_watch
¶
Route out of a watch node: "activity" or "terminal".
Reads the watch_result channel the node wrote — a dict
{"outcome", "payload"}. Map these keys to your responder and your exit,
e.g. {"activity": "respond", "terminal": END}. A responder reads the
event via state["watch_result"]["payload"].