diff --git a/src/panopticon/container/skills.py b/src/panopticon/container/skills.py index bd28b035..aacd9fa3 100644 --- a/src/panopticon/container/skills.py +++ b/src/panopticon/container/skills.py @@ -41,19 +41,30 @@ def render_command(skill: Skill, task_id: str) -> str: return f"---\ndescription: {skill.description}\n---\n{skill.instructions}\n{_task_id_note(task_id)}" +def _operation_tail(target_state: str) -> str: + """The shared close of an operation's procedure: the gate, then *keep going*. The agent must not + read the move as the end of its turn — it once did ("starts a new turn"), announcing the new + phase and stopping until prompted. The tool result carries the new phase's briefing to act on.""" + return ( + f"don't edit the state directly. It's gated on the current state's responsibilities. Once it " + f"succeeds the task is in **{target_state}**: follow the briefing in the tool's result and " + f"start that phase's work right away, in this same turn — don't end your turn just to " + f"announce the new state.\n" + ) + + def render_operation(name: str, target_state: str, task_id: str) -> str: """The rendered ``.md`` body for a core operation (advance/drop/…). CLI-agnostic — both adapters write this same text; only the destination dir differs. Operations are the workflow's **declared, gated** moves; the agent applies one by name via the - `apply_operation` tool (not by editing state directly), which starts a new agentic turn. + `apply_operation` tool (not by editing state directly), then carries on with the new phase. """ return ( f"---\ndescription: Apply the workflow's '{name}' operation.\n---\n" f"Apply this workflow's `{name}` operation — it moves the task to **{target_state}**. " f'Invoke it with the `apply_operation` tool (`operation="{name}"`, `task_id="{task_id}"`); ' - f"don't edit the state directly. It's gated on the current state's responsibilities and " - f"starts a new turn.\n" + f"{_operation_tail(target_state)}" ) @@ -115,8 +126,7 @@ def render_agent_operation(name: str, target_state: str, task_id: str) -> str: f"---\nname: {name}\ndescription: Apply the workflow's '{name}' operation.\n---\n" f"Apply this workflow's `{name}` operation — it moves the task to **{target_state}**. " f'Invoke it with the `apply_operation` tool (`operation="{name}"`, `task_id="{task_id}"`); ' - f"don't edit the state directly. It's gated on the current state's responsibilities and " - f"starts a new turn.\n" + f"{_operation_tail(target_state)}" ) diff --git a/src/panopticon/core/workflow.py b/src/panopticon/core/workflow.py index fb0a8912..c2afa921 100644 --- a/src/panopticon/core/workflow.py +++ b/src/panopticon/core/workflow.py @@ -440,7 +440,9 @@ async def briefing(self, task: Task, *, artifacts: ArtifactStore) -> str: if self.advanced_by(label) is Actor.USER: lines.append( f"When these are met, **stop and hand back to the user** — they review and decide " - f"when to advance (→ {target}). Don't advance on your own." + f"when to advance (→ {target}). Don't advance on your own — but when the user " + f"asks you to, apply `advance` and carry straight on with the next phase's work " + f"in the same turn." ) else: lines.append( @@ -452,6 +454,21 @@ async def briefing(self, task: Task, *, artifacts: ArtifactStore) -> str: lines += ["", *extras] return "\n".join(lines) + async def entry_briefing(self, task: Task, *, artifacts: ArtifactStore) -> str: + """The briefing for a phase the task has **just entered** — what a transition tool returns. + + The per-turn :meth:`briefing` arrives with the user's prompt, so on a mid-turn move (the + user says "advance") the agent's latest guidance is still the *old* phase's — for a + user-advanced phase, "stop and hand back". Agents obeyed it: they announced the new phase + and stopped. This pins the new phase and tells the agent to keep going in the same turn.""" + text = await self.briefing(task, artifacts=artifacts) + if self.is_terminal(task.state): + return text + return ( + f"The task just moved to **{task.state}**. Begin this phase's work now, in this same " + f"turn — don't end your turn just to report the transition.\n\n{text}" + ) + def _overview_extras(self) -> Sequence[str]: """Extra lines a workflow appends to the static :meth:`overview` map. Default none; a subclass overrides to inject its own — each returned string is one line/block, joined on diff --git a/src/panopticon/taskservice/mcp.py b/src/panopticon/taskservice/mcp.py index 32f20b4c..2e7e7a3c 100644 --- a/src/panopticon/taskservice/mcp.py +++ b/src/panopticon/taskservice/mcp.py @@ -78,15 +78,32 @@ async def request_push(task_id: str, branch: str) -> dict[str, Any]: _log.debug("mcp request_push task=%s branch=%s", task_id, branch) return _task(await service.request_push(task_id, branch=branch)) - @mcp.tool(description="Apply a named core operation (e.g. 'advance', 'drop').") + # The transition tools also return the **new** phase's briefing: the per-turn one arrived with + # the user's prompt, so after a mid-turn move it still describes the old phase ("stop and hand + # back"), and agents would announce the new state and stop. This one says to keep going. + async def _transitioned(task: object) -> dict[str, Any]: + out = _task(task) + return {**out, "briefing": await service.entry_briefing(out["id"])} + + @mcp.tool( + description=( + "Apply a named core operation (e.g. 'advance', 'drop'). Returns the task plus a " + "`briefing` for the phase it entered — follow it and keep working in the same turn." + ) + ) async def apply_operation(task_id: str, operation: str) -> dict[str, Any]: _log.debug("mcp apply_operation task=%s operation=%s", task_id, operation) - return _task(await service.apply_operation(task_id, operation)) + return await _transitioned(await service.apply_operation(task_id, operation)) - @mcp.tool(description="Move the task to any state directly (free move; bypasses the gate).") + @mcp.tool( + description=( + "Move the task to any state directly (free move; bypasses the gate). Returns the task " + "plus a `briefing` for the phase it entered — follow it and keep working in the same turn." + ) + ) async def set_state(task_id: str, state: str) -> dict[str, Any]: _log.debug("mcp set_state task=%s state=%s", task_id, state) - return _task(await service.set_state(task_id, state)) + return await _transitioned(await service.set_state(task_id, state)) @mcp.tool( description="Resolve one promised responsibility ('met', or 'failed' with a comment)." diff --git a/src/panopticon/taskservice/service.py b/src/panopticon/taskservice/service.py index a8caa7b9..b3a0ef96 100644 --- a/src/panopticon/taskservice/service.py +++ b/src/panopticon/taskservice/service.py @@ -502,6 +502,11 @@ async def briefing(self, task_id: str) -> str: task = await self.get_task(task_id) return await self._workflow(task.workflow).briefing(task, artifacts=self._artifacts) + async def entry_briefing(self, task_id: str) -> str: + """The briefing for the phase a task just entered (what the MCP transition tools return).""" + task = await self.get_task(task_id) + return await self._workflow(task.workflow).entry_briefing(task, artifacts=self._artifacts) + async def workflow_overview(self, task_id: str) -> str: """A one-time map of the task's whole workflow (the agent gets this in its system prompt).""" task = await self.get_task(task_id) diff --git a/tests/container/test_skills.py b/tests/container/test_skills.py index f642cd81..40cfb7c5 100644 --- a/tests/container/test_skills.py +++ b/tests/container/test_skills.py @@ -4,7 +4,12 @@ from pathlib import Path -from panopticon.container.skills import render_command, render_operation, write_commands +from panopticon.container.skills import ( + render_agent_operation, + render_command, + render_operation, + write_commands, +) from panopticon.core.models import Skill @@ -23,6 +28,14 @@ def test_render_operation_injects_the_task_id() -> None: assert 'operation="advance"' in body and 'task_id="t-9"' in body +def test_render_operation_says_to_keep_working_after_the_move() -> None: + # "starts a new turn" read as "your turn is over": agents announced the new phase and stopped. + for render in (render_operation, render_agent_operation): + body = render("advance", "ITERATING", "t-9") + assert "new turn" not in body + assert "in this same turn" in body and "briefing in the tool's result" in body + + def test_write_commands_writes_one_file_per_skill(tmp_path: Path) -> None: skills = [ Skill("babysit-ci", "Watch CI.", "watch loop"), diff --git a/tests/core/test_briefing.py b/tests/core/test_briefing.py index 508abaad..114c6941 100644 --- a/tests/core/test_briefing.py +++ b/tests/core/test_briefing.py @@ -76,6 +76,8 @@ def test_briefing_names_the_phase_responsibilities_and_user_advance(tmp_path: Pa assert "ITERATING" in text # the advance target # PLANNING is user-advanced, so the agent should hand back, not advance itself assert "hand back to the user" in text and "Don't advance on your own" in text + # ... unless the user asks — then it advances and keeps going rather than stopping to report + assert "carry straight on" in text and "same turn" in text def test_briefing_for_an_agent_advanced_phase(tmp_path: Path) -> None: diff --git a/tests/fixtures/briefing/github_peer_reviewed_state_ITERATING.md b/tests/fixtures/briefing/github_peer_reviewed_state_ITERATING.md index b13ade4a..01bf52f0 100644 --- a/tests/fixtures/briefing/github_peer_reviewed_state_ITERATING.md +++ b/tests/fixtures/briefing/github_peer_reviewed_state_ITERATING.md @@ -9,4 +9,4 @@ This phase's responsibilities (resolve each one as you complete it — don't wai - [pending] pr-updated: The PR title and description reflect the final change, with no Test Plan / Verification section. - [pending] url-recorded: The PR URL is recorded on the task with the `set_url` MCP tool. -When these are met, **stop and hand back to the user** — they review and decide when to advance (→ REVIEW). Don't advance on your own. \ No newline at end of file +When these are met, **stop and hand back to the user** — they review and decide when to advance (→ REVIEW). Don't advance on your own — but when the user asks you to, apply `advance` and carry straight on with the next phase's work in the same turn. \ No newline at end of file diff --git a/tests/fixtures/briefing/github_peer_reviewed_state_PLANNING.md b/tests/fixtures/briefing/github_peer_reviewed_state_PLANNING.md index ef568e6e..fd6b175b 100644 --- a/tests/fixtures/briefing/github_peer_reviewed_state_PLANNING.md +++ b/tests/fixtures/briefing/github_peer_reviewed_state_PLANNING.md @@ -3,4 +3,4 @@ You are in the **PLANNING** phase of the `github-peer-reviewed` workflow. Collec This phase's responsibilities (resolve each one as you complete it — don't wait until the end of your turn to mark them all): - [pending] plan-written: The plan is uploaded to the plan artifact `plan.md` (a markdown file) with the `put_artifact` tool — not just written to the working tree. When you end your planning turn, also post a concise summary of the plan in chat so the user can review it without opening the artifact. -When these are met, **stop and hand back to the user** — they review and decide when to advance (→ ITERATING). Don't advance on your own. \ No newline at end of file +When these are met, **stop and hand back to the user** — they review and decide when to advance (→ ITERATING). Don't advance on your own — but when the user asks you to, apply `advance` and carry straight on with the next phase's work in the same turn. \ No newline at end of file diff --git a/tests/fixtures/briefing/github_peer_reviewed_state_REVIEW.md b/tests/fixtures/briefing/github_peer_reviewed_state_REVIEW.md index f9ab55df..418752c3 100644 --- a/tests/fixtures/briefing/github_peer_reviewed_state_REVIEW.md +++ b/tests/fixtures/briefing/github_peer_reviewed_state_REVIEW.md @@ -3,4 +3,4 @@ You are in the **REVIEW** phase of the `github-peer-reviewed` workflow. Wait for This phase's responsibilities (resolve each one as you complete it — don't wait until the end of your turn to mark them all): - [pending] pr-reviewed: The PR has been reviewed. -When these are met, **stop and hand back to the user** — they review and decide when to advance (→ MERGING). Don't advance on your own. \ No newline at end of file +When these are met, **stop and hand back to the user** — they review and decide when to advance (→ MERGING). Don't advance on your own — but when the user asks you to, apply `advance` and carry straight on with the next phase's work in the same turn. \ No newline at end of file diff --git a/tests/taskservice/test_mcp.py b/tests/taskservice/test_mcp.py index be1bc330..3d48631d 100644 --- a/tests/taskservice/test_mcp.py +++ b/tests/taskservice/test_mcp.py @@ -74,6 +74,28 @@ async def test_tools_are_exposed_and_drive_the_task(tmp_path: Path) -> None: assert (await svc.get_task(task.id)).state == "COMPLETE" # the tool actually mutated the task +async def test_transition_tools_return_the_entered_phases_briefing(tmp_path: Path) -> None: + # The per-turn briefing still describes the *old* phase after a mid-turn move, so the + # transition tools hand back the new one — with an explicit "keep working" lead. + svc = await _service(tmp_path) + task = await svc.create_task("r2", "github-self-reviewed") + await svc.set_slug(task.id, "s") + async with connect(build_mcp_server(svc)) as s: + await s.initialize() + result = await s.call_tool("set_state", {"task_id": task.id, "state": "ITERATING"}) + assert result.isError is False and result.structuredContent is not None + out = result.structuredContent + assert out["state"] == "ITERATING" and out["id"] == task.id # still the task + assert out["briefing"].startswith("The task just moved to **ITERATING**") + assert "in this same turn" in out["briefing"] + assert "You are in the **ITERATING** phase" in out["briefing"] + + result = await s.call_tool("apply_operation", {"task_id": task.id, "operation": "drop"}) + assert result.structuredContent is not None + briefing = result.structuredContent["briefing"] + assert "terminal state **DROPPED**" in briefing and "just moved" not in briefing + + async def test_artifacts_round_trip_via_tool_and_resource(tmp_path: Path) -> None: svc = await _service(tmp_path) task = await svc.create_task("r1", "spike")