Skip to main content
Version: 0.15 (latest)

Todo List

TodoListMiddleware gives the agent a write_todos tool for maintaining a structured checklist during multi-step tasks. The model calls the tool to create and update items; the middleware enforces that the list stays in sync before the run ends.

Use it when agents need to track progress across many steps, or when you want the model to show the user a live breakdown of what it's doing.

Basic setup​

TodoListMiddleware requires an extra_ref callable that returns the live AgentContext.extra dict. The middleware and the tool both read and write through this reference so state survives checkpointing.

from cubeloop import Agent
from cubeloop.middleware import TodoListMiddleware

agent = Agent(
model=provider.model("claude-sonnet-4-6"),
system_prompt="You are a thorough assistant.",
middleware=[
TodoListMiddleware(extra_ref=lambda: agent.session.state_context),
],
)

When the agent is checkpointed, pass the same extra_ref that points to AgentContext.extra so todo state is persisted and restored across sessions:

from cubeloop import Agent
from cubeloop.checkpointer import PostgresCheckpointer
from cubeloop.middleware import TodoListMiddleware

# extra_ref must return the same object as AgentContext.extra.
# The helper below is the standard pattern with a checkpointed agent.
agent = Agent(
model=provider.model("claude-sonnet-4-6"),
checkpointer=PostgresCheckpointer(...),
thread_id="conv_123",
middleware=[
TodoListMiddleware(extra_ref=lambda: agent.session.state_context),
],
)

The public agent.session.state_context is the live checkpoint extra mapping. The lambda is evaluated after construction. On recovery, call await agent.session.load_checkpoint() before executing a new request.

The write_todos tool​

The tool accepts a todos list and optional wait_for_tasks task IDs. Each Todo has:

  • content — a short task description.
  • status — one of "pending", "in_progress", or "completed".

The model replaces the entire list on every call. The middleware validates the payload and rejects calls that would leave the list in an inconsistent state:

  • Empty content strings are rejected.
  • Exactly one item must be "in_progress" unless all items are "completed".
  • Calling write_todos more than once in a single turn is rejected; the list rolls back to its pre-turn state.
  • An empty list is only accepted when all prior items were already completed.

Finalization guard​

When the model delivers a plain-text response (no tool calls) while unfinished items remain in the list, the middleware injects a correction message and loops the model back for one extra turn to update the checklist. After that forced turn the run proceeds normally regardless of what the model does.

This prevents the common pattern where a model completes work but forgets to mark items as done before responding.

Waiting for host-managed background work​

Pass validate_task_wait when your application owns background tasks that can outlive a run. The async callback has this signature:

from cubeloop.agent.types import AgentContext
from cubeloop.middleware import TaskWaitBinding, TaskWaitValidation

async def validate_task_wait(
task_ids: list[str],
ctx: AgentContext,
prior: TaskWaitBinding | None,
) -> TaskWaitValidation:
# Your service checks scope, actor permissions, cancellation, and whether
# the tasks still have an observable result to deliver.
return await task_service.validate_wait(task_ids, ctx, prior)

middleware = TodoListMiddleware(
extra_ref=lambda: agent.session.state_context,
validate_task_wait=validate_task_wait,
)

The host service in this example is application code, not part of CubeLoop. It returns TaskWaitValidation(status="valid", validation={...}) only after checking the tasks. Store only JSON-compatible, non-secret evidence in validation; it is checkpointed and returned in the next prior binding. No validator means a nonempty wait_for_tasks is rejected.

The declaration binds task IDs to the successful Todo update, run, and current input boundary. Before forcing a finalization correction, CubeLoop asks the host again. A valid wait lets the run end naturally without completing unfinished Todos or making another model call. An ordinary Todo update without task IDs clears the declaration. New user or internal input, including a new run's initial message or a HITL answer (including approval, denial, or editing of a tool call), invalidates it. Automatic policy approval is not new human input. A changed or unprovable boundary after compaction also requires a new declaration.

If the user cancels previously validated work, the callback may return status="cancelled" with a nonempty reason, after proving cancellation happened after the original validation. This also allows natural completion, preserving the unfinished list and cancellation reason. It cannot establish a new wait. Invalid tasks, lookup failures, or stale bindings keep the ordinary guard. Malformed Todo updates, explicit stop, HITL, and other middleware decisions are not bypassed.

Use a checkpointer and the live extra mapping for recoverable declarations. Extra is saved after tool turns before another model call, before a HITL suspension is published, and at normal run end. A failed save does not count as a successfully checkpointed wait. CubeLoop does not start, poll, cancel, or deliver background tasks, and does not keep a waiting Session alive. The host remains responsible for their lifecycle and for delivering results in a later input or run. Agents without a Todo list do not need to call write_todos just to end a run.

Stale-todo reminder​

If the model makes several tool calls in a row without touching write_todos, the middleware injects a soft reminder asking it to sync the list. The model is free to ignore the reminder; it is never a hard block.

The threshold is 5 consecutive non-write_todos turns, with a minimum of 5 turns between successive reminders.

Customizing the tool description and system prompt​

Pass tool_description and system_prompt to override the defaults:

TodoListMiddleware(
extra_ref=extra_ref,
tool_description="Maintain a checklist of steps for the current task.",
system_prompt="## Task tracking\nUse write_todos for all multi-step work.",
)

tool_description is the text the model sees in its tool list; system_prompt is appended to the agent's system prompt by the transform_system_prompt hook.

State layout in ctx.extra​

All state is stored under well-known keys in AgentContext.extra:

KeyTypeDescription
todoslist[Todo] | NoneCurrent checklist
todo_guard_retriesdictPer-guard retry counters
todo_guard_blockedTodoGuardBlocked | NoneActive guard escalation payload
todo_guard_suppressedboolGuard suppression flag after a blocked episode
todo_stale_iterationsintTurns since last write_todos call
todo_finalization_correctionbool | NoneWhether a finalization correction was injected this turn
todo_task_waitdict | NoneValidated task IDs, Todo snapshot, run/input boundary, and host evidence
todo_task_wait_outcomedict | NoneLast valid/cancelled finalization decision and reason

These keys are stable across versions. Checkpointers persist them as part of ctx.extra, so a resumed session starts with the same checklist the model left.

When not to use it​

Skip TodoListMiddleware for short or conversational agents — the tool description and system prompt instructions consume tokens on every turn. The tool is also self-governed: the model decides whether and when to call it. For workflows where you need guaranteed structured output at each step, consider explicit tool definitions instead.