From 8a0ffbecf3a27ea1ba3d9930e5918618ce5ae087 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:58:58 +0000 Subject: [PATCH] feat(agents): let goals start with context --- docs/guide/goals.md | 26 +++++++++++++++++++-- docs/reference/agents.md | 13 +++++++---- src/hmz/agents/base.py | 40 ++++++++++++++++++++++++++++----- src/hmz/flows/agent.py | 18 +++++++++++++-- tests/agents/test_agents.py | 20 ++++++++++++++++- tests/agents/test_appservers.py | 33 +++++++++++++++++++++++++++ tests/agents/test_batches.py | 23 ++++++++++++++++--- 7 files changed, 156 insertions(+), 17 deletions(-) diff --git a/docs/guide/goals.md b/docs/guide/goals.md index 51cf9eb..3014d20 100644 --- a/docs/guide/goals.md +++ b/docs/guide/goals.md @@ -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 @@ -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 diff --git a/docs/reference/agents.md b/docs/reference/agents.md index 99845b2..4e72e6c 100644 --- a/docs/reference/agents.md +++ b/docs/reference/agents.md @@ -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. @@ -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[...] @@ -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 diff --git a/src/hmz/agents/base.py b/src/hmz/agents/base.py index ef095b9..1c6102b 100644 --- a/src/hmz/agents/base.py +++ b/src/hmz/agents/base.py @@ -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( @@ -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 @@ -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 @@ -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 @@ -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. @@ -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( @@ -2973,6 +2998,7 @@ 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)`. @@ -2980,12 +3006,16 @@ async def apursue( 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 diff --git a/src/hmz/flows/agent.py b/src/hmz/flows/agent.py index e924629..49346dc 100644 --- a/src/hmz/flows/agent.py +++ b/src/hmz/flows/agent.py @@ -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.""" ... @@ -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.""" @@ -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.""" diff --git a/tests/agents/test_agents.py b/tests/agents/test_agents.py index ae5cece..308c635 100644 --- a/tests/agents/test_agents.py +++ b/tests/agents/test_agents.py @@ -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( @@ -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 diff --git a/tests/agents/test_appservers.py b/tests/agents/test_appservers.py index 9c8a1a2..ab3d620 100644 --- a/tests/agents/test_appservers.py +++ b/tests/agents/test_appservers.py @@ -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", @@ -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: diff --git a/tests/agents/test_batches.py b/tests/agents/test_batches.py index 8baaada..8effd80 100644 --- a/tests/agents/test_batches.py +++ b/tests/agents/test_batches.py @@ -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."""