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
20 changes: 15 additions & 5 deletions src/panopticon/container/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<name>.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)}"
)


Expand Down Expand Up @@ -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)}"
)


Expand Down
19 changes: 18 additions & 1 deletion src/panopticon/core/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
25 changes: 21 additions & 4 deletions src/panopticon/taskservice/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
Expand Down
5 changes: 5 additions & 0 deletions src/panopticon/taskservice/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion tests/container/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions tests/core/test_briefing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
22 changes: 22 additions & 0 deletions tests/taskservice/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading