Skip to content
Closed
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
26 changes: 24 additions & 2 deletions docs/guide/goals.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,28 @@ The agent keeps going until it decides the objective has been met, and `pursue`
final turn. This is the backend's own goal feature, the one its `/goal` command reaches, not a
prompt that asks for one. The backend starts the extra turns itself.

## Give a goal more context

Keep source material separate when it is larger than the completion condition, or simply is
not part of what decides that the work is done:

```python
agent.pursue(
"answer the question, return only the requested JSON, then mark the goal complete",
context=f"""Keep this complete question in context for the goal that follows.
Do not answer it yet.

{question}
""",
)
```

`context` is an ordinary turn in the same conversation immediately before the native goal
starts. Its answer is not returned; the goal's last answer still is. This lets a backend keep
the complete task in conversation while receiving only the short completion condition through
its goal interface. Because this first turn is ordinary, tell the agent what to retain and what
to defer if work must not start until the goal is active.

## What `pursue` answers with

A goal takes as many turns of the model as it needs. `pursue` follows the goal across all of
Expand All @@ -30,8 +52,8 @@ while True:
agent.pursue(objective, suppress=True)
```

The awaited twin is `agent.apursue(objective)`. A session has both: `session.pursue(...)` and
`await session.apursue(...)`.
The awaited twin is `agent.apursue(objective, context=...)`. A session has both:
`session.pursue(...)` and `await session.apursue(...)`.

## Which backends have one

Expand Down
13 changes: 9 additions & 4 deletions docs/reference/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,11 @@ backend starts them itself; `pursue` follows the goal across all of them and ans
last. A session that has gone quiet is a goal that has stopped only once the goal itself says
so.

Pass `context="..."` to send an ordinary turn in the same conversation immediately before
the goal starts. Its answer is discarded, but the goal can use what that turn put in context;
only `objective` is sent through the backend's native goal interface. This is useful when a
large task and its short completion condition should stay separate.

A flow that loops over `pursue` is running the objective again, rather than nudging an agent
that stopped early.

Expand Down Expand Up @@ -1196,11 +1201,11 @@ class AgentBase:

# `cwd` is the directory the session it opens works in, or None for the flow's own.
def __call__(prompt: str, *, suppress: bool = False, schema: type[T] = …, cwd: Where = None) -> str | T | None
def pursue(objective: str, *, suppress: bool = False, cwd: Where = None) -> str
def pursue(objective: str, *, suppress: bool = False, context: str | None = None, cwd: Where = None) -> str
def new(cwd: Where = None) -> SessionBase

async def aturn(prompt: str, *, suppress: bool = False, schema: type[T] = …, cwd: Where = None) -> str | T | None
async def apursue(objective: str, *, suppress: bool = False, cwd: Where = None) -> str
async def apursue(objective: str, *, suppress: bool = False, context: str | None = None, cwd: Where = None) -> str

def batch_new(count: int, cwd: Where = None) -> list[SessionBase]
def batch(prompts, *, suppress: bool = False, schema: type[T] = …, at_once: int = 0, cwd: Where = None) -> list[...]
Expand All @@ -1225,10 +1230,10 @@ class SessionBase:

def __call__(prompt: str, *, suppress: bool = False, schema: type[T] = …) -> str | T | None
def stream(prompt: str, *, schema: type[BaseModel] | None = None) -> Iterator[Event]
def pursue(objective: str, *, suppress: bool = False) -> str
def pursue(objective: str, *, suppress: bool = False, context: str | None = None) -> str

async def aturn(prompt: str, *, suppress: bool = False, schema: type[T] = …) -> str | T | None
async def apursue(objective: str, *, suppress: bool = False) -> str
async def apursue(objective: str, *, suppress: bool = False, context: str | None = None) -> str

def interject(text: str) -> None
def close() -> None
Expand Down
40 changes: 35 additions & 5 deletions src/hmz/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,18 +691,27 @@ async def aturn[T: BaseModel](
f"{self._agent.id}-turn",
)

async def apursue(self, objective: str, *, suppress: bool = False) -> str:
async def apursue(
self,
objective: str,
*,
suppress: bool = False,
context: str | None = None,
) -> str:
"""The same goal as :meth:`pursue`, awaited: `await session.apursue(objective)`.

Args:
objective: What the agent is to have achieved before it stops.
suppress: Whether a goal that fails answers with nothing, as for :meth:`pursue`.
context: An ordinary turn to add to this conversation before the goal starts, as
for :meth:`pursue`.

Returns:
What :meth:`pursue` would have answered with.
"""
return await _awaited(
lambda: self.pursue(objective, suppress=suppress), f"{self._agent.id}-goal"
lambda: self.pursue(objective, suppress=suppress, context=context),
f"{self._agent.id}-goal",
)

def stream(
Expand Down Expand Up @@ -1359,7 +1368,13 @@ def _adopt(self, session_id: str) -> None:
# backend logs it under this id and never says whose it was.
self._agent.cycle.opened(self._agent, session_id)

def pursue(self, objective: str, *, suppress: bool = False) -> str:
def pursue(
self,
objective: str,
*,
suppress: bool = False,
context: str | None = None,
) -> str:
"""Runs the session under a goal, which the agent then keeps itself going toward.

This is the backend's own goal feature rather than a prompt that asks for one: the
Expand All @@ -1371,6 +1386,9 @@ def pursue(self, objective: str, *, suppress: bool = False) -> str:
objective: What the agent is to have achieved before it stops.
suppress: Whether a goal that fails answers with nothing instead of raising, as
for :meth:`__call__`.
context: An ordinary turn to add to this conversation before the goal starts, or
None. Its answer is not returned. Use it for task material that the goal needs
in context but that is not itself the completion condition.

Returns:
The agent's response once it stops, stripped, or "" for a goal that failed while
Expand All @@ -1385,7 +1403,11 @@ def pursue(self, objective: str, *, suppress: bool = False) -> str:
"""
if not self._agent.goals_enabled:
raise RuntimeError(f"{self._agent.id}: goals are disabled")
if context is not None and not self._agent.pursues:
raise NotImplementedError(f"{type(self).__name__} has no goal feature")
try:
if context is not None:
self(context)
return self._pursue(objective)
except Unrecoverable:
raise # not covered by `suppress`, for the reason it is not in a turn
Expand Down Expand Up @@ -2905,6 +2927,7 @@ def pursue(
objective: str,
*,
suppress: bool = False,
context: str | None = None,
cwd: str | os.PathLike[str] | None = None,
) -> str:
"""Runs a goal in a session of its own, and keeps nothing.
Expand All @@ -2913,12 +2936,14 @@ def pursue(
objective: What the agent is to have achieved before it stops.
suppress: Whether a goal that fails answers with nothing, as for
:meth:`SessionBase.pursue`.
context: An ordinary turn to add to the goal's conversation first, as for
:meth:`SessionBase.pursue`.
cwd: Where it works, as for :meth:`__call__`.

Returns:
What the agent answered once it stopped, stripped.
"""
return self._opens_at(cwd).pursue(objective, suppress=suppress)
return self._opens_at(cwd).pursue(objective, suppress=suppress, context=context)

@overload
async def aturn(
Expand Down Expand Up @@ -2973,19 +2998,24 @@ async def apursue(
objective: str,
*,
suppress: bool = False,
context: str | None = None,
cwd: str | os.PathLike[str] | None = None,
) -> str:
"""The same goal as :meth:`pursue`, awaited: `await agent.apursue(objective)`.

Args:
objective: What the agent is to have achieved before it stops.
suppress: Whether a goal that fails answers with nothing, as for :meth:`pursue`.
context: An ordinary turn to add to the goal's conversation first, as for
:meth:`pursue`.
cwd: Where it works, as for :meth:`__call__`.

Returns:
What :meth:`pursue` would have answered with.
"""
return await self._opens_at(cwd).apursue(objective, suppress=suppress)
return await self._opens_at(cwd).apursue(
objective, suppress=suppress, context=context
)

def batch_new(
self, count: int, cwd: str | os.PathLike[str] | None = None
Expand Down
18 changes: 16 additions & 2 deletions src/hmz/flows/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,23 @@ async def aturn[T: BaseModel](
"""The same turn, awaited: `await session.aturn(prompt)`."""
...

def pursue(self, objective: str, *, suppress: bool = False) -> str:
def pursue(
self,
objective: str,
*,
suppress: bool = False,
context: str | None = None,
) -> str:
"""Runs the backend's own goal feature in this conversation, until it stops."""
...

async def apursue(self, objective: str, *, suppress: bool = False) -> str:
async def apursue(
self,
objective: str,
*,
suppress: bool = False,
context: str | None = None,
) -> str:
"""The same goal, awaited."""
...

Expand Down Expand Up @@ -325,6 +337,7 @@ def pursue(
objective: str,
*,
suppress: bool = False,
context: str | None = None,
cwd: str | os.PathLike[str] | None = None,
) -> str:
"""Runs a goal in a session of its own, and keeps nothing."""
Expand All @@ -335,6 +348,7 @@ async def apursue(
objective: str,
*,
suppress: bool = False,
context: str | None = None,
cwd: str | os.PathLike[str] | None = None,
) -> str:
"""The same goal, awaited."""
Expand Down
20 changes: 19 additions & 1 deletion tests/agents/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,22 @@ def test_claude_pursues_through_its_own_goal_command(clis: _FakeCLIs) -> None:
]


def test_claude_can_receive_large_goal_context_before_its_short_objective(
clis: _FakeCLIs,
) -> None:
"""Task material is a remembered turn, not part of Claude's `/goal` command."""
session = ClaudeCodeAgent(
ClaudeCodeAgentConfig(model="claude-opus-4-8", effort="high")
).new()
context = "complete task material " * 250
session.pursue("return the final answer", context=context)

assert [call.stdin for call in clis.calls() if call.stdin] == [
context,
"/goal return the final answer",
]


def test_disabled_goals_never_reach_claude(clis: _FakeCLIs, tmp_path: Path) -> None:
"""A goals-off ordinary turn cannot leave HMZ through continuation tools."""
agent = ClaudeCodeAgent(
Expand Down Expand Up @@ -499,8 +515,10 @@ def test_an_anchored_agent_hands_its_whole_turn_to_the_anchor(clis: _FakeCLIs) -


def test_a_backend_without_a_goal_feature_says_so() -> None:
agent = _EchoAgent(CONFIG)
with pytest.raises(NotImplementedError):
_EchoAgent(CONFIG).new().pursue("the suite passes")
agent.new().pursue("the suite passes", context="must not be sent")
assert agent.opened == []


#: A `claude` that answers with the error it is: `subtype` still reads "success", so the
Expand Down
33 changes: 33 additions & 0 deletions tests/agents/test_appservers.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ def send(message):
send({"method": "thread/status/changed", "params": {"status": {"type": "idle"}}})
if call["method"] == "turn/start":
send({"method": "turn/started", "params": {"turnId": "turn_fake"}})
if call["params"]["input"][0]["text"] == "the complete task material":
send({"method": "item/completed",
"params": {"item": {"type": "agentMessage", "text": " ready "}}})
send({"method": "turn/completed", "params": {}})
send({"method": "thread/status/changed",
"params": {"status": {"type": "idle"}}})
continue
if call["params"]["input"][0]["text"] == "recovering":
send({"method": "error", "params": {"error": {
"message": "Reconnecting... 1/5",
Expand Down Expand Up @@ -576,6 +583,32 @@ def test_codex_pursues_by_setting_a_goal_on_the_thread(codex: _FakeServer) -> No
assert agent.opened == ["thread_fake"]


def test_codex_keeps_goal_context_on_the_thread(codex: _FakeServer) -> None:
"""The context lands first; only the completion condition becomes Codex's goal."""
session = CodexAgent(CodexAgentConfig(model="gpt-5-codex", effort="high")).new()

assert (
session.pursue("return the final answer", context="the complete task material")
== "answered"
)

called = codex.calls()
starts = [call["params"] for call in called if call.get("method") == "turn/start"]
assert [start["input"] for start in starts] == [
[{"type": "text", "text": "the complete task material"}],
[{"type": "text", "text": "return the final answer"}],
]
goals = [
call["params"] for call in called if call.get("method") == "thread/goal/set"
]
assert goals == [
{"threadId": "thread_fake", "objective": "return the final answer"}
]
methods = [call.get("method") for call in called]
assert methods.count("thread/start") == 1
assert methods.count("thread/resume") == 1


def test_codex_gives_up_on_a_goal_that_has_gone_quiet(
codex: _FakeServer, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down
23 changes: 20 additions & 3 deletions tests/agents/test_batches.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,15 +402,32 @@ def _pursue(self, objective: str) -> str:
return f"pursued: {objective}"

class _GoalAgent(_InProcessAgent):
pursues: ClassVar[bool] = True

def new(self, cwd: str | os.PathLike[str] | None = None) -> _Goal:
return _Goal(self, cwd)

agent = _GoalAgent()
context: list[str] = []
agent = _GoalAgent(doing=context.append)

assert await agent.apursue("get it done") == "pursued: get it done"
assert await agent.new().apursue("and again") == "pursued: and again"
assert (
await agent.apursue("get it done", context="the complete task")
== "pursued: get it done"
)
assert (
await agent.new().apursue("and again", context="more task material")
== "pursued: and again"
)
assert context == ["the complete task", "more task material"]
assert agent.goals == ["get it done", "and again"]

failing = _GoalAgent(doing=_explodes("bad context"))
assert (
await failing.apursue("must not start", context="bad context", suppress=True)
== ""
)
assert failing.goals == []


async def test_awaiting_a_turn_hands_the_loop_back_while_the_turn_takes() -> None:
"""A turn is minutes of waiting on a process, and a flow's loop is not to wait inside it."""
Expand Down