Skip to content

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

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 env as 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:

Simulation: no CLI is run, the rules are the ones fork keeps
  1. 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 ​

python
# .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 ​

  1. GitWorktreeEnvMixin lets the flow check out worktrees, so the two attempts do not write over each other's files.
  2. 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.
  3. derive_worktree() checks out a new worktree at what the workdir has checked out: a clean copy of the committed code for each attempt.
  4. fork(session) makes a new session that carries the reading. Nothing is sent yet, and nothing is settled about where it works.
  5. 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.
  6. UnsupportedOperation is what a CLI that cannot make this fork raises, at that first turn. Falling back to a fresh spawn keeps the flow running on any CLI, at the price of the history.
  7. gather runs both attempts at once, and answers with both answers in the order of WAYS.

Run it ​

sh
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:

text
● 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 working lines together are the two forks' first turns, going at once, each in its own worktree.
  • cache_read 51.4k against the reading's 27.8k is the carried history: each fork starts from the whole conversation so far, and neither read check.py again.

Check it worked ​

Each worktree holds one attempt, and your own checkout holds neither:

sh
git -C ~/.hmz/envs/calc-c69b9824bb32/worktrees/head-278c0743 diff
git -C ~/.hmz/envs/calc-c69b9824bb32/worktrees/head-88d122ae diff
git status --short
diff
@@ -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 - b

git 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:

python
# 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]  # ⑤
text
..                                                                       [100%]
2 passed in 0.08s
  1. forked_from is the session a fake session was forked from. Both branches come from the reading.
  2. A fork's prompts start with its parent's, which is the history it carries.
  3. The parent is untouched: its prompts are the reading alone.
  4. forks=False makes a fake CLI that cannot fork, so a fork's first turn raises UnsupportedOperation.
  5. 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 itForksInto another directory
claude, codex, kimiyesyes
grok, mimo, omp, opencode, pi, qwen, an ACP CLIyessame directory only
agy, cursor-agent, dsh, mcodenono

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:

python
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:

python
@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"])
  1. Kept as it stands now: what the session is told afterwards is not in what was kept.
  2. 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 youCarries the history?
agent.fork(session)another conversation of the same agentyes
agent.derive(permission=…)the same agent under a narrower grantno: 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:

    python
    child = 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)  
    text
    SessionError: agent: the session it was forked from has taken a turn since; fork it again to branch from where it is now

    Fork 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. spawn a fresh one instead.

Next steps ​

Released under the Apache-2.0 licence.