Planning and TODOs
Where the code stands#
Axon is a terminal coding assistant built on LangGraph by hand. It is a ReAct loop with a gate in it: chat calls the model, human_approval asks you before each tool call runs, tools runs the approved ones and loops back. State is a messages list and nothing else. The tools can list, read, search, write, and edit files.
What a todo list is for#
Ask Axon for something that takes more than a couple of steps and it starts well, then quietly wanders. It reads a file, gets interested in what it found, and finishes something next to what you asked for. Your request is still in the conversation. It is just far away by then, with thirty tool calls piled on top of it.
The fix is a plan the agent writes for itself and keeps re-posting into the conversation. Not a plan stored somewhere and consulted by code. A plan as text, at the bottom of the thread, where the model looks next. Every time the agent updates it, the whole list lands in context again, and the original goal comes back into view.
That is the entire feature. Two tools and one field on State. The tools do almost nothing, and that is the point.
- todo list
- The agent's working memory: a short plan it writes, re-reads, and rewrites as it works.
- recitation
- Re-posting the plan at the end of the context on every update, which is where the model looks next.
1. One request, start to finish#
This project has no README, so write one.
Press play, or step with the arrows. The graph on top shows where the turn is. Below it, the context window is the message list the model sees, growing one message at a time. human_approval runs in auto mode here, so calls pass straight through.
Nothing has run yet. The thread holds the system prompt and this request is still in the composer. Hit send (or press Enter) to run it.
Send the message to start the run, or click the bar and use ← → to step through.
Two things to take from that run.
The thread is the only thing carrying the plan. Watch where the write_todos output lands: at the bottom, every time. There is no plan variable, no queue, no scheduler. The plan is N messages from the end counter climbs while the agent reads files and snaps back to zero on every rewrite. That is recitation, measured.
Almost none of it was work. Nine model turns, and one of them changed a file. Four rewrote a list that no code reads. It looks wasteful until you ask what the list is actually for.
2. Two tools that do nothing#
A task is two fields:
class Todo(TypedDict):
content: str
status: Literal["pending", "in_progress", "completed"]And here is the whole of write_todos:
@tool
def write_todos(todos: list[Todo]) -> str:
"""Create or replace the task list used to plan multi-step work.
Do NOT use this for single, trivial actions (reading one file, answering a
question). The overhead is not worth it.
Rules:
- Send the FULL list every time; this replaces the previous list.
- Keep exactly one step 'in_progress' at a time.
- Mark a step 'completed' the moment it is done, not in batches.
"""
if not todos:
return "Task list cleared."
return "Task list updated:\n" + render_todos(todos)It formats a string and returns it. It runs nothing, enforces nothing. The tool is a no-op. Two things still happen when the model calls it:
- Decomposition. To call the tool at all, the model has to break the request into steps before starting. That alone improves the result.
- Recitation. The returned string enters the thread as a
ToolMessage, so the full plan reappears at the end of the context, which is the position the model attends to most. The agent is reminding itself out loud.
Since the tool has no mechanical power, all the engineering is in the docstring. "Send the FULL list every time" is why the argument is the whole list, not a patch: rewriting invites the model to reconsider the plan as it learns. "Do NOT use this for single, trivial actions" is the off switch. Without it, the agent writes a three-item plan to answer "what does this function do?".
read_todos is the other half. Twenty tool calls later the plan has scrolled away, so the agent needs a way to pull it back:
@tool
def read_todos(state: Annotated[dict, InjectedState]) -> str:
"""Re-read the current task list to check what is done and what is left.
Call this after finishing a step, before deciding what to do next.
"""
todos = state.get("todos") or []
if not todos:
return "No task list yet. Call write_todos to create one."
return render_todos(todos)Notice the two tools get their data from opposite directions. write_todos is handed its list by the model, which types it out. read_todos needs the list from the agent's state, which the model has never seen and must not be asked to supply. That Annotated[dict, InjectedState] is what makes the split work.
3. InjectedState#
Every tool has two readers. The model reads the JSON schema and fills in the arguments. The runtime calls the Python function. Usually they see the same parameters.
read_todos breaks that. Write def read_todos(state) with no annotation and @tool builds a schema with a state parameter in it. The model sees it and tries to invent the entire agent state as an argument. InjectedState removes the parameter from the schema the model sees while keeping it in the Python signature. Switch the views below and watch the parameter count. Toggle the annotation off and watch state leak into the model's schema.
{"path": "string","offset": "integer (optional)","limit": "integer (optional)"}
def read_file(path: str,offset: int = 1,limit: int = 2000,) -> str:
Zero parameters on the left, one on the right. Something has to fill that gap, and in a prebuilt agent ToolNode does it. We wrote our own tool_node, so the job is one expression: {**tc["args"], "state": state}. The tool gets the whole state by reference, so treat it as read-only. Only the return value goes back to the model, so injecting it costs no tokens.
4. One field on State#
State has been messages and nothing else. Add the field:
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
todos: list[Todo]Skip this line and nothing crashes, which is the problem. A node that returns {"todos": [...]} against a State that does not declare todos gets its update silently dropped. No error, no warning. read_todos answers "No task list yet" forever, and the agent writes the plan again and again. State is a fixed set of declared channels, not a dict.
There is no reducer on todos, unlike messages. A write replaces the list wholesale, which is the same decision as "send the FULL list every time": the model gets to reorganize the plan, not just append to it.
5. Landing it in tool_node#
write_todos returns a string, so someone has to lift the list out of the call and put it in state. Two new lines in the tool loop:
for tc in pending_tool_calls(state):
tool = tools_by_name[tc["name"]]
result = tool.invoke({**tc["args"], "state": working}) # NEW: injection
if tc["name"] == "write_todos": # NEW: the write
update["todos"] = tc["args"]["todos"]
working = {**working, "todos": tc["args"]["todos"]}
messages.append(ToolMessage(content=str(result), tool_call_id=tc["id"], name=tc["name"]))
return {"messages": messages, **update}update carries the new list back to LangGraph at the end. working is the same list, but visible now. The state a node receives is a snapshot, and the update it returns lands only after the node finishes. So if the model calls write_todos and read_todos in the same message, a naive read_todos would answer "No task list yet" even though a write happened two lines earlier. working starts as the snapshot and advances as calls run, so a read later in the turn sees a write from earlier in it.
6. Auto mode#
The approval node asks you about every tool call. With todo tools in the mix that gets absurd: you end up approving a function that does nothing. The run in section 1 would have needed eight extra confirmations.
The tempting fix is a SAFE_TOOLS set that skips approval for read-only tools. Axon did not do that, because it means the author decides what counts as safe on your behalf, in a set literal. Approval stays all or nothing, and you turn it off yourself:
def human_approval_node(state: State, config: RunnableConfig) -> dict:
if config["configurable"].get("auto_approve"): # /auto -> run without asking
return {"messages": []}
...The flag lives in config, not State, because it is a runtime setting and not something the agent knows about itself. Type /auto in the REPL to flip it.
7. Next#
The agent can hold a plan while it works. What it cannot do is keep its own context clean: every file it reads to check one fact stays in the thread for the rest of the session.
Next: subagents. The same loop, started on a fresh message list with its own tools and prompt, doing the reading somewhere else and sending back a report.