Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 91 additions & 5 deletions src/hmz/coganchor/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,20 @@ class SessionBase(ABC):
#: cannot otherwise tell that turn from one that has wedged.
narrates: ClassVar[bool] = False

#: Whether a fork of this backend's conversation may be opened in another directory than
#: the one the conversation is in. Only where the CLI is told where the child works as it
#: cuts it -- a thread forked with a `cwd` of its own, a `fork --cwd` -- or where a store
#: kept per directory can be carried across first, which :meth:`_carry` is for. False for
#: every backend that has not been checked to: a fork its CLI looks for in the wrong
#: directory is a first turn that fails saying the conversation does not exist.
forks_elsewhere: ClassVar[bool] = False

#: Whether :meth:`cut` puts down what carries a turn, for a backend an interrupt does not
#: reach while a turn is under way: one whose turns an app server or a daemon holds for
#: every session of the agent, and one whose runtime holds them where nothing reaches in.
#: A goal on those is no turn at all, and is reached the same way.
cuts_transport: ClassVar[bool] = False

def __init__(
self, agent: AgentBase, cwd: str | os.PathLike[str] | None = None
) -> None:
Expand Down Expand Up @@ -2344,7 +2358,12 @@ def forks(self) -> bool:
profile = named(self._agent.backend)
return profile is not None and profile.forks

def fork(self) -> SessionBase:
def fork(
self,
*,
into: AgentBase | None = None,
cwd: str | os.PathLike[str] | None = None,
) -> SessionBase:
"""A second conversation carrying this one's history, and its own from here on.

Which is what a conversation that has got somewhere is worth: a flow that has spent
Expand Down Expand Up @@ -2372,15 +2391,30 @@ def fork(self) -> SessionBase:
after another turn has been sent here would branch from somewhere else, which is not
what was asked for, and is refused rather than done quietly.

The child may belong to another agent of the same backend and account -- one set up
differently, at another rung or carrying other skills -- and may work in another
directory, where :attr:`forks_elsewhere` says the backend can be told so. Both are
what a caller holding an agent per conversation needs to branch one: the history is
the account's, so any agent signed in as it can carry it on.

Args:
into: The agent the child is a conversation of, or None for this one's. It must be
of this backend, on the account this conversation is kept under, and working on
the same machine.
cwd: Where the child works, or None for where this conversation does.

Returns:
The new session, unopened with the backend: it is the fork, and the fork happens
where its first turn does. Which is what makes two forks of one conversation cost
nothing until they are used.

Raises:
NotImplementedError: If this backend has no fork to reach for. A flow handed back a
second handle on the one conversation would be two loops writing into one, so it
is refused where it is asked -- and :attr:`forks` is how a flow asks first.
NotImplementedError: If this backend has no fork to reach for, or none that can be
opened in another directory. A flow handed back a second handle on the one
conversation would be two loops writing into one, so it is refused where it is
asked -- and :attr:`forks` is how a flow asks first.
ValueError: If `into` is of another backend, on another account or on another
machine, none of which can read this conversation where it is kept.
RuntimeError: If no turn has landed here yet, so there is no conversation to carry:
a session that has got nowhere is one to open rather than one to fork.
"""
Expand All @@ -2389,8 +2423,28 @@ def fork(self) -> SessionBase:
f"{self._agent.backend} has no way of carrying a conversation "
"into a second one"
)
agent = self._agent if into is None else into
if agent is not self._agent and (
type(agent) is not type(self._agent)
or agent.backend != self._agent.backend
or agent.config.provider != self._agent.config.provider
or agent.config.machine != self._agent.config.machine
):
raise ValueError(
f"{self._agent.backend}: a conversation is carried on only by an agent of "
"the same backend, signed in as the same account, on the same machine"
)
where = self._cwd
elsewhere = cwd is not None and os.path.abspath(cwd) != self.cwd # noqa: PTH100
if elsewhere and not type(self).forks_elsewhere:
raise NotImplementedError(
f"{self._agent.backend} cannot carry a conversation into another directory"
)
seed = self.id # raises while nothing has landed, which is nothing to carry
made = self._agent._opens_at(self._cwd)
if elsewhere and cwd is not None:
where = os.fspath(cwd)
self._carry(where)
made = agent._opens_at(where)
made._forked_from = seed
# Where this conversation had got to when the fork was asked for, and a weak hold on
# the conversation itself: the child checks both as it opens, so that a fork taken
Expand All @@ -2409,6 +2463,38 @@ def fork(self) -> SessionBase:
made.offers(self._tools)
return made

def _carry(self, cwd: str) -> None:
"""Puts this conversation where a fork of it opened in another directory will look.

Nothing by default: a backend whose CLI is told where the child works as it cuts it
finds the conversation wherever it is kept. One that keeps its conversations per
directory says so here, by copying this one across before the child's first turn.

Args:
cwd: The directory the child is to work in.
"""
del cwd

def cut(self, *, why: str) -> None:
"""Cuts the turn under way off, whatever is holding it, for a caller holding it alone.

:meth:`interrupt`, and then -- on a backend whose turns are held somewhere every
session of the agent shares, an app server or a daemon, where an interrupt is heard
only at the next answer -- that transport put down, which is what ends the turn now.
It also reaches a goal, which such a backend runs outside a turn of its own. The
conversation is not ended by it: the next turn starts the transport again and resumes
this conversation by its id, as it does after a watchdog.

For a caller that holds the agent for this one conversation. On one holding several,
putting the transport down ends the turns of all of them.

Args:
why: What to say it was cut off for.
"""
self.interrupt(why=why)
if type(self).cuts_transport:
self._lets_go()

def _at_the_boundary(self) -> None:
"""Refuses a fork whose conversation has moved on since it was asked for.

Expand Down
103 changes: 102 additions & 1 deletion src/hmz/coganchor/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import os
import uuid
from collections import Counter
from dataclasses import dataclass
Expand All @@ -14,7 +15,6 @@
from .hooks import EVERYWHERE, SUBAGENTS, WAITING, Moment, about, arriving

if TYPE_CHECKING:
import os
from collections.abc import Iterator

#: The tool Claude reaches for when it wants a person rather than a file. Its input is a list
Expand Down Expand Up @@ -57,6 +57,55 @@

_ALLOWED_TOOLS_MAX = 32

#: How long a directory spelled as a name may be before Claude cuts it short and tells it
#: apart from others cut the same way by a hash of the whole.
_PROJECT_MAX = 200


def _project(where: str) -> str:
"""A directory as Claude names the folder its conversations there are kept in.

Every character that is not an ASCII letter or digit is a dash -- counted as Claude counts
them, in UTF-16 code units, so a character outside the basic plane is two -- and a name
longer than :data:`_PROJECT_MAX` is cut there and given the base-36 of a 32-bit string
hash of the whole path. Read off Claude Code 2.1.282, where it is the one function that
names a project directory.

Args:
where: The directory, absolute.

Returns:
The folder's name.
"""
units = where.encode("utf-16-le")
codes = [
int.from_bytes(units[at : at + 2], "little") for at in range(0, len(units), 2)
]
said = "".join(
chr(code) if chr(code).isascii() and chr(code).isalnum() else "-"
for code in codes
)
if len(said) <= _PROJECT_MAX:
return said
hashed = 0
for code in codes:
hashed = (hashed * 31 + code) & 0xFFFFFFFF
if hashed >= 1 << 31:
hashed -= 1 << 32
return f"{said[:_PROJECT_MAX]}-{_base36(abs(hashed))}"


def _base36(number: int) -> str:
"""A non-negative number written in base 36, as JavaScript's `toString(36)` writes it."""
digits = "0123456789abcdefghijklmnopqrstuvwxyz"
said = ""
while True:
number, digit = divmod(number, 36)
said = digits[digit] + said
if not number:
return said


_ALLOWED_TOOL_RULE_MAX_CHARS = 4096

#: Reasons that leave an answer unfinished even when a broken intermediary labels the result
Expand Down Expand Up @@ -245,6 +294,11 @@ class ClaudeCodeSession(StreamSessionBase):
#: reads them -- which is why it is declared here rather than in `hmz.coganchor.backends`.
narrates: ClassVar[bool] = True

#: Claude keeps a conversation under the directory it was held in, and `--resume` looks
#: for it under the directory it is run in. A fork opened elsewhere is carried there first,
#: by :meth:`_carry`.
forks_elsewhere: ClassVar[bool] = True

def __init__(
self, agent: AgentBase, cwd: str | os.PathLike[str] | None = None
) -> None:
Expand Down Expand Up @@ -1019,6 +1073,53 @@ def _reply(self, said: dict[str, Any], answer: dict[str, Any]) -> None:
+ "\n"
)

def _carry(self, cwd: str) -> None:
"""Copies this conversation to where a Claude run in another directory looks for it.

Claude keeps a conversation as `projects/<the directory, spelled as a name>/<id>.jsonl`
under its home, and `--resume <id> --fork-session` reads it from under the directory
it is run in. So a fork opened elsewhere is given a copy there to be cut from; the
copy is this conversation as it stands, which is what the fork carries on from.

Args:
cwd: The directory the fork is to work in.

Raises:
NotImplementedError: For an agent whose turns land on another machine, whose Claude
keeps its conversations there rather than here.
RuntimeError: If this conversation cannot be found where Claude keeps it.
"""
import shutil

from hmz.coganchor.backends import named

if self._agent.config.machine is not None:
raise NotImplementedError(
"claude cannot carry a conversation into another directory on another machine"
)
profile = named("claude")
assert profile is not None # noqa: S101 -- the backend this driver is for
projects = profile.directory(self._environ()) / "projects"
# Where this conversation is held first: an earlier fork carried elsewhere left a
# copy of it there, as it stood then, which is not where it stands now.
held = os.path.abspath(self.cwd) # noqa: PTH100
found = next(
(
kept
for spelled in dict.fromkeys((held, os.path.realpath(held)))
if (kept := projects / _project(spelled) / f"{self.id}.jsonl").is_file()
),
None,
) or next(projects.glob(f"*/{self.id}.jsonl"), None)
if found is None:
raise RuntimeError(f"claude: no conversation {self.id} under {projects}")
where = os.path.abspath(cwd) # noqa: PTH100
for spelled in dict.fromkeys((where, os.path.realpath(where))):
there = projects / _project(spelled)
there.mkdir(parents=True, exist_ok=True)
if (there / found.name) != found:
shutil.copyfile(found, there / found.name)

def _pursue(self, objective: str) -> str:
"""Runs the turn as Claude Code's own ``/goal``, which print mode expands like any other.

Expand Down
Loading
Loading