Engineering

Agents that pick up where they left off.

A task can return its result without throwing away its transcript or working state. You can correct it while it runs, answer a question from inside it, or continue the same work later.

Most agent frameworks give you one of two ways to work.

The first is fire-and-forget. You hand the agent a task, it runs, and it comes back with a result or an error. If you want a follow-up, something like "great, now do the same for the March data", you start a new task from scratch. The new run has none of the old one's context, so the credentials it discovered are gone along with everything it learned about the API it spent ten minutes figuring out. It re-derives all of that, slowly, or it gets it subtly wrong.

The second is chat. You keep full shared context, but you're supervising every step. The agent can't go away and work for twenty minutes while you do something else.

Neither behaves like working with a colleague. A colleague can work independently, accept a correction halfway through, and return with a question. When they finish, a follow-up continues the same conversation rather than starting from nothing.

Unify keeps the outer conversation separate from the inner working loops. The worker itself is fairly conventional; most of the additional machinery moves instructions and questions between the two layers.

The shape

The outer loop is the ConversationManager: the thing you actually talk to, whether over chat or on a live call. It doesn't do the work itself. When work needs doing, it calls act(query, persist=...), which spawns a CodeActActor with its own LLM transcript and its own Python sandbox.

Unify's dispatch and steering flow: the user reaches the ConversationManager through mediums and an event broker; the ConversationManager calls act(...) on the CodeActActor, which calls primitives.* on the back office. The steering bus runs the other way: handles propagate back up and streamed responses reach the user.
Dispatch flows down, and the steering bus runs back up through the handles.

Every running action is tracked in in_flight_actions. The ConversationManager receives steering tools named for each action:

  • interject_<name>__<id>, which pushes a correction or follow-up into the running task
  • ask_<name>__<id>, which inspects what the task is doing without disturbing it
  • pause_<name>__<id> and resume_<name>__<id>, to suspend and continue
  • stop_<name>__<id>, to cancel
  • answer_clarification_<name>__<id>__<call>, which appears only while the task is blocked on a question

When you send a correction, the conversation model can address the relevant running task with an ordinary tool call.

Persistent sessions: finishing isn't ending

The simplest of the three is persist=True, where completing the work doesn't end the session.

A normal persist=False action returns its result and is gone. The handle moves to completed_actions, where all you can still do is ask questions about what happened. A persistent action finishes a piece of work, surfaces its response upward, and then blocks, waiting:

# unify/common/_async_tool/loop.py — end of a turn, persist mode
if persist:
    await _outer._notification_q.put(
        {"type": "response", "content": _response_to_surface},
    )
    logger.info("Persist mode: waiting for next interjection...")
    ...
    # Block until an interjection arrives or cancellation is requested
    ...
    continue  # Back to top of loop to process the interjection

return final_content  # persist=False: DONE

The ConversationManager sees the response, marks the action awaiting_input, and keeps it in in_flight_actions, so the task is finished while the session is still alive. The full inner transcript is still in memory, and so is the Python sandbox with whatever state the work built up.

There is no separate "resume session" API. When you say "now do March", the outer model calls interject_<name>__<id>. The same loop wakes with the new instruction appended to its existing transcript. A follow-up and a mid-task correction therefore use the same mechanism.

Starting a new task with a summary is not equivalent. A summary can omit details that did not look important when it was written. It also cannot carry an authenticated client object held in a sandbox variable. A live session preserves both the transcript and that working state.

Two columns comparing the same piece of work run with persist=False and persist=True. Both start with the work running in its own transcript and Python sandbox. With persist=False the result comes back, the handle moves to completed_actions, and the session is gone — transcript dropped, sandbox destroyed — so the follow-up 'now do March' is a second act() call from a blank slate that rediscovers the credentials and re-derives the API. With persist=True the result is surfaced upward and the action sits in awaiting_input, still in in_flight_actions with its transcript in memory and its sandbox alive, so 'now do March' arrives as interject_<name>__<id> and the same loop wakes up with the line appended.
The same follow-up, against a session that ended and one that didn't.

The cost is that keeping sessions alive becomes a real decision. Our system prompt pushes the outer model to default to persist=True whenever a follow-up is plausible, and to close sessions explicitly with stop_* rather than letting persist=False quietly throw away context we turn out to need.

Talking down: interjection

Interjections are how corrections get in. When the outer loop calls handle.interject(message), the message lands on the inner loop's queue and gets appended to its transcript as a user message, rather than as a system message or a synthetic tool result. The inner model sees it the way it sees any instruction, tagged so it knows it arrived mid-task.

It's also immediate. The inner loop runs with interrupt_llm_with_interjections enabled, so it races the in-flight LLM generation against the interjection queue. If your correction arrives while the model is mid-generation, the generation is cancelled and restarted with your message included. You're not waiting for the current step to finish before "no, wrong account" takes effect.

A correction — 'use WORK email' — entering the top of a nested stack and routing down the margin into the ContactManager loop that needs it. The ConversationManager, Actor, TaskScheduler and ContactManager are each marked still running: a live redirect, no restart.
A live redirect reaching the loop that needs it, without a restart.

Talking up: clarification

The inner loop gets the mirror-image channel. An actor started with clarification enabled has request_clarification(question) in its tool surface. Calling it blocks that exact call site. The question travels up through the handle's clarification queue, the ConversationManager wakes and relays it to the user, and an answer_clarification_* tool appears for the pending question. When the answer comes back, it's routed down the same queues and the blocked call returns with the answer as its value.

So "which of these two Alices did you mean?" doesn't kill the task, it just suspends it at precisely the point of ambiguity and then resumes from that point with the answer in hand.

A clarification — 'which Sarah? two matches' — raised by the nested ContactManager loop at the top of the stack and routed past the Actor and ConversationManager to the User at the bottom, with the answer 'the one in Berlin' travelling back up the same chain of handles to the loop that asked.
A question bubbles up from a nested loop, and the answer travels back down the same path to where it was asked.

It nests

A large action may delegate to nested loops. Steering follows the work down, so pausing or correcting the outer action sends the same operation to its children. Each child records the change in its own transcript. A model several layers deep can therefore see that it was paused or redirected instead of encountering an unexplained change in context.

Sequence diagram of nested steering: the user asks to find when Sarah last mentioned Berlin; the ConversationManager calls act(prompt) on the Actor, which opens a nested TranscriptManager handle. The user interjects mid-flight — 'actually include emails too' — and the interjection cascades down both handles before refined results and the final answer flow back up.
A mid-flight interjection cascading down through a nested handle.

What persistence does not survive

Sessions are in-process. The handle and its sandbox live in the runtime's memory, so a persistent session survives across hours of conversation but not across a process restart. Durable state still has to be written somewhere real, and the actor does that explicitly. Python variables persist across execute_code calls only when stateful execution is requested; the default is a clean slate per call, which is usually what you want.

A live session also keeps its sandbox open. A runtime that never stops sessions will accumulate them, so the outer model receives an explicit stop_* tool for closing work it no longer needs.

The lifecycle policy still depends too heavily on the outer model remembering to close sessions. I expect that part to change.

Where to look

All of this is MIT-licensed and in the open at github.com/unifyai/unify. The pieces referenced here:

The architecture doc has the deeper tour: ARCHITECTURE.md.

Read next

The rest of the notes