The problem with flat agent loops
Most agent frameworks work like this: one LLM, one loop, one tool call at a time. The model picks a tool, calls it, reads the result, picks the next. If you want to interrupt — redirect the task, provide new information, pause for something urgent — you cancel and start over. This is fine for short, predictable tasks. It breaks down when:- The agent is mid-way through a 30-step research task and you realize you forgot to mention a constraint
- You need the agent to pause its current work, handle something urgent, then resume where it left off
- You want to ask “how’s it going?” without disrupting the work in progress
- Multiple operations need to run concurrently with independent steering
Steerable handles
Every significant operation in Unity — whether it’s searching contacts, updating knowledge, or executing a multi-step task — runs inside its own async LLM tool loop and returns a steerable handle. The handle is a live reference to the running operation with a uniform control surface:The protocol
Every handle implementsSteerableToolHandle — the abstract protocol defined in unity/common/async_tool_loop.py:
| Method | Purpose |
|---|---|
ask(question) | Query progress or results without modifying the task. Returns a new handle for the answer. |
interject(message) | Inject new context, corrections, or requirements into the running task. |
pause() | Pause the task. In-flight operations finish, but no new actions start. |
resume() | Resume a paused task. Work that completed during the pause is processed first. |
stop(reason) | Cancel the task and all its sub-operations. |
done() | Check whether the task has completed. |
result() | Await the final output. |
ActiveTask.stop adds a cancel parameter, and ConversationManagerHandle.interject adds pinned for priority messages.
Nested composition
The key insight is that handles compose through arbitrary depth. When the Actor callsprimitives.contacts.ask(...), the ContactManager starts its own tool loop and returns its own handle — nested inside the Actor’s handle, which is nested inside the ConversationManager’s handle.
- User interjects on the ConversationManager → the CM decides whether to forward to the Actor → the Actor decides whether to forward to the active manager
- User pauses the Actor → all active manager operations continue to their current step, then wait
- User asks the Actor → spawns an inspection loop that can query any active sub-handle
How it works under the hood
Async tool loops
Each handle is backed by anasyncio.Task running an LLM tool loop (start_async_tool_loop in unity/common/async_tool_loop.py). The loop:
- Builds a prompt with the current context (including any interjections received since the last turn)
- Calls the LLM
- Executes the tool calls from the response
- Repeats until the LLM produces a final response (no more tool calls)
asyncio.Event flags. Stop triggers graceful cancellation of the underlying task.
Context separation
Nested loops use role rewriting to prevent confusion. When an outer loop’s context is injected into an inner loop (forask or interject), the messages are tagged with distinct roles (outer_user, outer_assistant) so the inner LLM can distinguish between its own conversation and the parent’s context.
Lineage tracking
Each loop gets a position in a lineage hierarchy tracked viacontextvars. This is used for:
- Structured logging (every log line shows its position in the nesting tree)
- Event attribution (the EventBus tags events with their loop lineage)
- Debugging (you can trace exactly which operation at which depth produced a given action)
ConversationManager->Actor(a3f2)->ContactManager(7b1c).
Forward dispatching
When a parent loop needs to forward a steering call to a child handle whose concrete type it doesn’t know, it usesforward_handle_call. This introspects the target method’s actual signature, filters out kwargs the target doesn’t accept, and applies positional fallbacks — no hand-written try/except TypeError cascades needed.
Why this matters
vs. OpenClaw
OpenClaw’s steering is Gateway-mediated: you can inject a message into the current run’s queue or abort the run. There’s no nested composition — the Gateway manages one run at a time per session, and subagents are separate sessions, not nested steerable loops.vs. HermesAgent
HermesAgent’sinterrupt() is essentially a kill signal with optional message, propagated to child agents. There’s no pause/resume, no ask for status, no mid-task interjection that modifies the operation in progress.
What Unity enables
The steerable handle pattern means the assistant can hold a real-time voice conversation while executing background tasks, accept corrections mid-flight without losing progress, and manage multiple concurrent operations that the user can independently query, redirect, or cancel. The user doesn’t wait in silence — they interact with a working system.Where to start reading
| File | What’s there |
|---|---|
unity/common/async_tool_loop.py | SteerableToolHandle protocol and AsyncToolLoopHandle implementation |
unity/common/_async_tool/loop.py | The async tool loop engine — turn execution, interjection handling, compression |
unity/common/_async_tool/messages.py | forward_handle_call, helper tool recognition, context role rewriting |
unity/common/_async_tool/loop_config.py | Lineage tracking via contextvars |
unity/common/_async_tool/multi_handle.py | MultiHandleCoordinator for concurrent requests in one loop |
