Skip to main content

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
In these cases, “cancel and restart” loses all accumulated context and work.

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:
handle = await actor.act("Research flights to Tokyo and draft an itinerary")

# Twenty seconds later, while it's still working:
await handle.interject("Also check train options from Tokyo to Osaka")

# Ask about progress without disrupting the work:
status_handle = await handle.ask("What have you found so far?")

# Pause for something urgent:
await handle.pause()
# ... deal with the urgent thing ...
await handle.resume()

# Or stop entirely:
await handle.stop("No longer needed")

The protocol

Every handle implements SteerableToolHandle — the abstract protocol defined in unity/common/async_tool_loop.py:
MethodPurpose
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.
This is the minimum universal contract. Derived handles can extend it — for example, 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 calls primitives.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.
ConversationManager (SteerableToolHandle)
    └── Actor.act (SteerableToolHandle)
        ├── ContactManager.ask (SteerableToolHandle)
        ├── KnowledgeManager.update (SteerableToolHandle)
        └── TaskScheduler.execute (SteerableToolHandle)
Steering propagates correctly through the full depth:
  • 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
Each level makes its own decisions about how to handle steering events. The ConversationManager might translate a user’s casual “also check hotels” into a structured interjection on the Actor’s handle. The Actor might route that to the specific sub-operation it’s relevant to.

How it works under the hood

Async tool loops

Each handle is backed by an asyncio.Task running an LLM tool loop (start_async_tool_loop in unity/common/async_tool_loop.py). The loop:
  1. Builds a prompt with the current context (including any interjections received since the last turn)
  2. Calls the LLM
  3. Executes the tool calls from the response
  4. Repeats until the LLM produces a final response (no more tool calls)
Interjections are injected between turns via an async queue. Pause/resume uses 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 (for ask 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 via contextvars. 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)
The lineage label looks like: 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 uses forward_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’s interrupt() 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

FileWhat’s there
unity/common/async_tool_loop.pySteerableToolHandle protocol and AsyncToolLoopHandle implementation
unity/common/_async_tool/loop.pyThe async tool loop engine — turn execution, interjection handling, compression
unity/common/_async_tool/messages.pyforward_handle_call, helper tool recognition, context role rewriting
unity/common/_async_tool/loop_config.pyLineage tracking via contextvars
unity/common/_async_tool/multi_handle.pyMultiHandleCoordinator for concurrent requests in one loop