Skip to content

Commit b622eef

Browse files
futrimeclaude
andcommitted
feat(agents): a flow that runs an agent under a goal says so, and is refused one without
`pursue` is the backend's own goal feature: the agent decides for itself that the objective has been met, and until it does, a turn that would have ended starts another. Three of the six here have one. A flow built on it is not a flow any agent can drive -- and there was no way to say that, so the official `goal` flow started happily on `pi` and died in the middle of its first turn with `PiSession has no goal feature`, half an hour of a run spent finding out what could have been checked before the first word went anywhere. It is checked now, where every other requirement of a place is. An agent says `pursues` on the class, beside the moments it runs; a flow writes `Annotated[AgentBase, Goal]` where it declares the place; and the runner refuses the pairing before the first turn, saying which agent and which backend. The interface's `/agents` sheet offers only the CLIs that would work, which is what it already did for moments, and says why the list is short. Found by running every flow humanize offers against every CLI installed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b7b303c commit b622eef

12 files changed

Lines changed: 226 additions & 9 deletions

File tree

docs/flows.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,29 @@ class Agents(NamedTuple):
255255
exactly as it did, and what is written beside it is what the place asks of whoever fills it.
256256
Several moments are several arguments.
257257

258-
It is checked before the first turn, for the same reason the count is:
258+
**A goal is asked for the same way.** `agent.pursue(objective)` is the backend's own goal
259+
feature — the agent decides for itself that the objective has been met, and until it does, a
260+
turn that would have ended starts another. Three backends have one (Claude Code, codex, Kimi),
261+
so a flow built on it says so:
262+
263+
```python
264+
from hmz.agents import AgentBase, Goal
265+
266+
267+
class Agents(NamedTuple):
268+
"""The one it drives, which has to have a goal of its own."""
269+
270+
worker: Annotated[AgentBase, Goal]
271+
```
272+
273+
Both are checked before the first turn, for the same reason the count is:
259274

260275
```console
261276
$ hmz exec -f gated -a codex/gpt-5.6-sol:high -a codex/gpt-5.6-sol:high "fix the build"
262277
hmz exec: error: /.../gated.py: builder has to run PermissionRequest, which codex does not
278+
279+
$ hmz exec -f pursuing -a pi/gpt-5.4:high "fix the build"
280+
hmz exec: error: /.../pursuing.py: worker is run under a goal, which pi has no feature for
263281
```
264282

265283
and the interface's `/agents` offers only the CLIs that would work for that place, so it cannot

src/hmz/SPEC.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ class Runner:
135135
asked for MUST be read before the name is resolved to a file, and a name no flow in the file
136136
answers to MUST be reported as a usage error saying which ones it holds -- a file of three
137137
asked for by its own name is a colon away from what was meant.
138+
- A flow that runs one of its agents under the backend's own goal feature MUST say so where it
139+
declares the place, by writing `Goal` beside the type, and an agent whose backend has none
140+
MUST be refused before the first turn -- for the reason a moment it cannot run is: a loop
141+
built on `pursue` finds out in the middle of a turn otherwise, hours in. What each backend
142+
has MUST be said on the agent rather than asked of it, so that whoever is choosing one can
143+
offer only the ones that would work.
138144
- A `NamedTuple` of agents MUST be accepted in its place, and MUST additionally say what the
139145
flow calls each of them. `drives` MUST report those names, so that whatever asks for the
140146
agents asks for them by what they are for rather than by their place in a line; a plain

src/hmz/agents/SPEC.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ with no behaviour on them.
3535
## `config.py`
3636

3737
```python
38+
class Goal: ...
39+
40+
3841
class Remote: ...
3942

4043

src/hmz/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .config import (
1616
PERMISSIONS,
1717
AgentConfig,
18+
Goal,
1819
Isolated,
1920
Remote,
2021
anchored,
@@ -57,6 +58,7 @@
5758
"CodexSession",
5859
"CommandSessionBase",
5960
"Event",
61+
"Goal",
6062
"Hook",
6163
"Hooks",
6264
"HumanAgent",

src/hmz/agents/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,6 +1567,13 @@ class AgentBase(ABC):
15671567
#: more, and a flow that needs one of those says so where it declares the agents it drives.
15681568
moments: ClassVar[frozenset[Moment]] = EVERYWHERE
15691569

1570+
#: Whether this backend has a goal feature of its own -- one where the agent decides for
1571+
#: itself that an objective has been met, and a turn that would have ended starts another
1572+
#: instead, which is what `pursue` reaches for. Three of them have; a flow that runs its
1573+
#: agent under a goal says so where it declares them, and is then refused an agent that
1574+
#: has not rather than raising on the first turn.
1575+
pursues: ClassVar[bool] = False
1576+
15701577
def __init__(self, config: AgentConfig, *, name: str | None = None) -> None:
15711578
"""Initializes an agent that has opened nothing yet.
15721579

src/hmz/agents/claude.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,9 @@ class ClaudeCodeAgent(AgentBase):
518518
#: one backend here where a hook can say no to something and have the agent hear it.
519519
moments: ClassVar[frozenset[Moment]] = EVERYWHERE | {Moment.PERMISSION_REQUEST}
520520

521+
#: Claude keeps itself going toward an objective, which is what `pursue` reaches for.
522+
pursues: ClassVar[bool] = True
523+
521524
def new(self, cwd: str | os.PathLike[str] | None = None) -> ClaudeCodeSession:
522525
"""Opens a new Claude Code session, in the directory it is given or in this one."""
523526
return ClaudeCodeSession(self, cwd)

src/hmz/agents/codex.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,9 @@ class CodexAgent(AgentBase):
900900

901901
moments: ClassVar[frozenset[Moment]] = EVERYWHERE | {Moment.PERMISSION_REQUEST}
902902

903+
#: codex keeps itself going toward an objective, which is what `pursue` reaches for.
904+
pursues: ClassVar[bool] = True
905+
903906
def __init__(self, config: AgentConfig, *, name: str | None = None) -> None:
904907
"""Initializes an agent whose app server is not running yet.
905908

src/hmz/agents/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@
3333
PERMISSIONS = ("read-only", "workspace-write", "auto", "bypass")
3434

3535

36+
class Goal:
37+
"""What a flow writes beside an agent it runs under the backend's own goal feature.
38+
39+
`pursue` is the agent keeping itself going toward an objective it decides for itself is
40+
met, and three backends have it. A flow built on that is not a flow any agent can drive,
41+
so it says which of its agents has to have one, by writing this where it declares them::
42+
43+
class Agents(NamedTuple):
44+
worker: Annotated[AgentBase, Goal]
45+
46+
and an agent whose backend has no goal feature is refused before the first turn rather
47+
than raising in the middle of one, which is where a loop would otherwise find out.
48+
"""
49+
50+
3651
class Remote:
3752
"""What a flow writes beside an agent that may be pointed at another machine.
3853

src/hmz/agents/kimi.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import weakref
2727
from collections import Counter
2828
from dataclasses import dataclass, field
29-
from typing import TYPE_CHECKING, Any, cast
29+
from typing import TYPE_CHECKING, Any, ClassVar, cast
3030

3131
from .base import AgentBase, SessionBase
3232
from .config import AgentConfig
@@ -592,6 +592,9 @@ def _submit(self, prompt: str, *, goal: bool) -> Iterator[Event]:
592592
class KimiCodeCLIAgent(AgentBase):
593593
"""Kimi Code, driven through an app server of its own so a whole session is settable."""
594594

595+
#: Kimi keeps itself going toward an objective, which is what `pursue` reaches for.
596+
pursues: ClassVar[bool] = True
597+
595598
def __init__(self, config: AgentConfig, *, name: str | None = None) -> None:
596599
"""Initializes an agent whose server is not running yet.
597600

src/hmz/runner.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ class Place(NamedTuple):
129129
moments: The moments the agent filling it has to run, which the flow said by writing
130130
`Annotated[AgentBase, Moment.PERMISSION_REQUEST]` where it declared the place. Empty
131131
where it asked for nothing in particular, which is most places.
132+
goal: Whether the flow runs this one under the backend's own goal feature, which it
133+
said by writing `Annotated[AgentBase, Goal]` where it declared the place. Only three
134+
backends have one, so a flow built on it is not a flow any agent can drive.
132135
where: Where the agent filling it may work, which the flow said the same way -- `Remote`
133136
for one that may be pointed at another machine, an `Isolated` for one that works in a
134137
container the flow itself names the image of. None for a place the flow said nothing
@@ -141,6 +144,7 @@ class Place(NamedTuple):
141144
person: bool
142145
moments: frozenset[Moment]
143146
where: type[Remote] | Remote | Isolated | None = None
147+
goal: bool = False
144148

145149

146150
def drives(flow: str | os.PathLike[str]) -> tuple[str, ...]:
@@ -682,9 +686,12 @@ def _place(name: str, kind: object) -> Place:
682686
"""
683687
moments = frozenset(_moments(kind))
684688
where = _where(kind)
689+
goal = _goal(kind)
685690
if get_origin(kind) is Annotated:
686691
kind = get_args(kind)[0]
687-
return Place(name=name, person=_is_person(kind), moments=moments, where=where)
692+
return Place(
693+
name=name, person=_is_person(kind), moments=moments, where=where, goal=goal
694+
)
688695

689696

690697
def _where(kind: object) -> type[Remote] | Remote | Isolated | None:
@@ -707,6 +714,23 @@ def _where(kind: object) -> type[Remote] | Remote | Isolated | None:
707714
return None
708715

709716

717+
def _goal(kind: object) -> bool:
718+
"""Whether a flow said the agent filling a place is run under its backend's goal feature.
719+
720+
Args:
721+
kind: What the flow annotated the place with.
722+
723+
Returns:
724+
True if it wrote `Goal` beside the type, and False for a place annotated with the type
725+
alone -- which is one driven by turns like every other.
726+
"""
727+
from .agents import Goal
728+
729+
if get_origin(kind) is not Annotated:
730+
return False
731+
return any(said is Goal for said in get_args(kind)[1:])
732+
733+
710734
def _moments(kind: object) -> tuple[Moment, ...]:
711735
"""The moments a flow asked the agent filling a place to run.
712736
@@ -831,6 +855,11 @@ def __init__(
831855
f"{flow}: {place.name or 'the agent'} has to run "
832856
f"{', '.join(sorted(short))}, which {agent.backend} does not"
833857
)
858+
if place.goal and not type(agent).pursues:
859+
raise NotAFlow(
860+
f"{flow}: {place.name or 'the agent'} is run under a goal, which "
861+
f"{agent.backend} has no feature for"
862+
)
834863
_lands(flow, agent, place)
835864
# The person at the prompt is made here rather than given: nobody chooses what they
836865
# run, so nothing upstream of this was ever asked about them.

0 commit comments

Comments
 (0)