Branching a conversation
In this guide you branch one conversation into two. You build twoways: an agent reads the code once, then the conversation is forked twice, and each fork tries the task a different way in a worktree of its own, both at once, both knowing what the reading found.
Reach for fork when a conversation has got somewhere expensive, such as an hour of reading the code, and you want to try more than one way on from it without paying for the reading again.
Before you start
- A flow of your own running: Your first flow.
- Several turns at once with
asyncio.gather: Many turns at once. - A git repository with a commit, for the worktrees: Worktrees, copies and scratch.
How it works
agent.fork(session) makes a second session that starts out knowing everything the first one knows, and goes its own way from there:
- Everything said after the fork belongs to one branch only. The parent is untouched, and two forks never see each other's turns.
- A fork is a session like any other. It belongs to the same agent, with the same model, permission and hooks. It has its own
usage, which starts at nothing, and is closed the way every session is. - A fork is cut at its own first turn, not at the call to
fork. The call itself costs nothing; the child's first turn is where the history is carried over. - The child's turns say where it works, with
envas every turn does, and that need not be where the parent worked, on the CLIs that can carry a conversation into another directory.
Take turns below and watch who knows what:
- sessionknows 1
session has taken one turn. Fork it, then take turns on either side.
3 a turn taken on this session · 1 a turn carried from the session it was forked from · a dashed fork has not taken its first turn
Example: read once, try two ways
# .hmz/flows/twoways/__init__.py
import asyncio
from hmz.flows import (
Agent,
AgentCollection,
EnvCollection,
FlowContext,
FlowParams,
GitWorktreeEnvMixin,
LocalEnv,
Session,
UnsupportedOperation,
flow,
)
READ = "Read calc.py and check.py, and say in one sentence what they do. Change nothing."
WAYS = ("with a docstring and type hints", "as briefly as you can")
class Agents(AgentCollection):
agent: Agent
class Workspace(LocalEnv, GitWorktreeEnvMixin): ... # ①
class Envs(EnvCollection):
workspace: Workspace
@flow(agents=Agents, envs=Envs, params=FlowParams)
async def twoways(
task: str, *, agents: Agents, envs: Envs, params: FlowParams, ctx: FlowContext
) -> list[str]:
"""Read once, then try the task two ways, each in a worktree of its own."""
agent, workspace = agents["agent"], envs["workspace"]
session = await agent.spawn()
await agent.run(READ, session=session, env=workspace) # ②
async def attempt(way: str) -> str:
tree = await workspace.derive_worktree() # ③
branch: Session = await agent.fork(session) # ④
print(f"{way}: {tree.workdir}")
try:
return await agent.run(f"{task}, {way}.", session=branch, env=tree) # ⑤
except UnsupportedOperation: # ⑥
fresh = await agent.spawn()
return await agent.run(f"{task}, {way}.", session=fresh, env=tree)
return await asyncio.gather(*(attempt(way) for way in WAYS)) # ⑦What each part does
GitWorktreeEnvMixinlets the flow check out worktrees, so the two attempts do not write over each other's files.- The expensive turn, taken once, in the parent session. A fork needs a conversation to carry, so the parent must have taken at least one turn.
derive_worktree()checks out a new worktree at what the workdir has checked out: a clean copy of the committed code for each attempt.fork(session)makes a new session that carries the reading. Nothing is sent yet, and nothing is settled about where it works.- The fork's first turn, given the worktree as its
env, is where the conversation is cut and carried over, into the worktree. From here on the two branches know only their own turns. UnsupportedOperationis what a CLI that cannot make this fork raises, at that first turn. Falling back to a freshspawnkeeps the flow running on any CLI, at the price of the history.gatherruns both attempts at once, and answers with both answers in the order ofWAYS.
Run it
hmz exec -f twoways -a agent=claude/claude-sonnet-5-5:high -p budget.cost=1 \
"Add subtract(a, b) to calc.py"A real run, abridged:
● agent is working
● Read(/home/you/calc/calc.py)
● Read(/home/you/calc/check.py)
● `calc.py` defines an `add(a, b)` function that returns the sum of its arguments, and `check.py` imports it, asserts that `add(2, 3) == 5`, …
✻ input 4 · output 194 · cache_read 27.8k · cache_write 5.5k · $0.02 · claude-sonnet-5-5 · agent
…
✻ Worked for 5s · agent
● agent is working
● agent is working
● Read(/home/you/.hmz/envs/calc-c69b9824bb32/worktrees/head-88d122ae/calc.py)
● Read(/home/you/.hmz/envs/calc-c69b9824bb32/worktrees/head-278c0743/calc.py)
● Edit(/home/you/.hmz/envs/calc-c69b9824bb32/worktrees/head-88d122ae/calc.py)
● Edit(/home/you/.hmz/envs/calc-c69b9824bb32/worktrees/head-278c0743/calc.py)
● I added `subtract(a, b)` to `calc.py`, returning `a - b`. I didn't run anything, so it's untested.
✻ input 6 · output 378 · cache_read 51.4k · cache_write 926 · $0.02 · claude-sonnet-5-5 · agent
with a docstring and type hints: /home/you/.hmz/envs/calc-c69b9824bb32/worktrees/head-278c0743
as briefly as you can: /home/you/.hmz/envs/calc-c69b9824bb32/worktrees/head-88d122ae
…
● I added `subtract(a: float, b: float) -> float` to `calc.py` in the worktree. It has a one-line docstring and returns `a - b`. …
✻ input 6 · output 537 · cache_read 51.5k · cache_write 1.1k · $0.02 · claude-sonnet-5-5 · agent
…- The first turn is the reading, in your own checkout.
- Two
● agent is workinglines together are the two forks' first turns, going at once, each in its own worktree. cache_read 51.4kagainst the reading's27.8kis the carried history: each fork starts from the whole conversation so far, and neither readcheck.pyagain.
Check it worked
Each worktree holds one attempt, and your own checkout holds neither:
git -C ~/.hmz/envs/calc-c69b9824bb32/worktrees/head-278c0743 diff
git -C ~/.hmz/envs/calc-c69b9824bb32/worktrees/head-88d122ae diff
git status --short@@ -1,2 +1,7 @@
def add(a, b):
return a + b
+
+
+def subtract(a: float, b: float) -> float:
+ """Return the difference of a and b (a - b)."""
+ return a - b
@@ -1,2 +1,6 @@
def add(a, b):
return a + b
+
+
+def subtract(a, b):
+ return a - bgit status --short in your checkout shows only ?? .hmz/. Keep the attempt you like, and git worktree remove the other.
A test checks the shape of the branching with a fake agent: who was forked from whom, and what each branch was told:
# tests/test_twoways.py
from hmz.sdk import fakes
READ = "Read calc.py and check.py, and say in one sentence what they do. Change nothing."
async def test_both_ways_carry_on_from_the_reading() -> None:
agent = fakes.FakeAgentDriver()
await fakes.run_fake("twoways", "Add subtract", agents={"agent": agent})
reading, *branches = agent.sessions
assert [branch.forked_from for branch in branches] == [reading, reading] # ①
assert sorted(branch.prompts[-1] for branch in branches) == [
"Add subtract, as briefly as you can.",
"Add subtract, with a docstring and type hints.",
]
assert all(branch.prompts[0] == READ for branch in branches) # ②
assert reading.prompts == [READ] # ③
async def test_a_cli_that_cannot_fork_starts_afresh() -> None:
agent = fakes.FakeAgentDriver(forks=False) # ④
await fakes.run_fake("twoways", "Add subtract", agents={"agent": agent})
assert [session.forked_from for session in agent.sessions] == [None, None, None]
assert [len(session.prompts) for session in agent.sessions] == [1, 1, 1] # ⑤.. [100%]
2 passed in 0.08sforked_fromis the session a fake session was forked from. Both branches come from the reading.- A fork's
promptsstart with its parent's, which is the history it carries. - The parent is untouched: its prompts are the reading alone.
forks=Falsemakes a fake CLI that cannot fork, so a fork's first turn raisesUnsupportedOperation.- The fallback ran: three fresh sessions, each told one thing only.
Which CLIs can fork
fork is on every agent and needs no mixin. A CLI that cannot make the fork you ask for raises UnsupportedOperation, at the fork's first turn, which is where it is asked for: that turn's env is where the fork is to work.
CLI, as -a names it | Forks | Into another directory |
|---|---|---|
claude, codex, kimi | yes | yes |
grok, mimo, omp, opencode, pi, qwen, an ACP CLI | yes | same directory only |
agy, cursor-agent, dsh, mcode | no | no |
No CLI forks onto another machine.
Variations
Fork in the same directory. Where the branches only answer, rather than write, fork them into the parent's own environment, which more CLIs can do:
careful = await agent.fork(session)
quick = await agent.fork(session)
await asyncio.gather(
agent.run("how would you fix it, carefully?", session=careful, env=workspace),
agent.run("how would you fix it, quickly?", session=quick, env=workspace),
)Carry a conversation on in a later run. A fork is cut from a session this run holds. To branch from a point a run reached and then went past -- an experiment that replays several arms from each boundary of an earlier run, say -- write the session into a resumable flow's state at that point. The state keeps the conversation as it stands then, and each run picking this one up reads back a new session carrying it on from there:
@flow(agents=Agents, envs=Envs, params=Params, resumable=True)
async def arms(task, *, agents, envs, params, ctx):
agent = agents["agent"]
if not ctx.resumed:
session = await agent.spawn()
await agent.run(task, session=session, env=envs["workspace"])
ctx.state["boundary"] = session # ①
await agent.run("go on", session=session, env=envs["workspace"])
return
again: Session = ctx.state["boundary"] # ②
await agent.run(params.arm, session=again, env=envs["workspace"])- Kept as it stands now: what the session is told afterwards is not in what was kept.
- A new session of the same role, every time it is read, whose first turn forks the kept conversation. Run
hmz exec --resume -p arm=…once per arm, each a run of its own: an arm that writes nothing to the state leaves the kept session in the run the next arm picks up. To pick a given run up, name its epic:Hmz().run(…, resume=<epic>), or resume epic on/epics.
Only a CLI that forks can be carried on this way, on this machine, and on the harness that kept it; Sessions in state lists what is refused. Where a run keeps no sessions of its own (HUMANIZE_SESSIONS=off, or a CLI that cannot be linked on macOS), a conversation the CLI's home has gone on with past the kept point is refused rather than put back.
Fork or derive? They sound alike and do opposite things:
| Gives you | Carries the history? | |
|---|---|---|
agent.fork(session) | another conversation of the same agent | yes |
agent.derive(permission=…) | the same agent under a narrower grant | no: it holds no conversation |
derive is covered in A flow that calls a flow.
Pitfalls
Take the child's first turn before the parent moves on. A fork is cut at its own first turn. If the parent takes a turn in between, the child's first turn is refused rather than quietly branching from a later point:
pythonchild = await agent.fork(session) await agent.run("carry on here", session=session, env=workspace) # parent moves on await agent.run("and here", session=child, env=workspace)textSessionError: agent: the session it was forked from has taken a turn since; fork it again to branch from where it is nowFork again when you want the newer point. Turns on a child never move its parent, so
twoways, which only ever turns the children, is safe.A session with no turn has nothing to carry. Forking one raises
SessionError: agent: the session to fork has taken no turn to carry on from.spawna fresh one instead.
Next steps
- Many turns at once, for driving the branches together
- Worktrees, copies and scratch, for branching the files rather than the conversation
- Many conversations at once, for reading the branches at the prompt
- Reference ›
Agent.forkandSession