diff --git a/docs/plans.md b/docs/plans.md index 1590314a..7e488093 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -24,11 +24,12 @@ User reviews (via /plans UI or chat tools) | Tool | Description | |------|-------------| | `plan_propose` | Propose an implementation plan for a task. Stored for async human review. | +| `plan_update` | Revise a pending plan in place — supersedes the old version and creates v+1 linked via `parent_plan_id`. Preferred over decline+propose for self-refinement. | | `plan_list` | List existing plans. Used to check which tasks already have pending plans. | | `plan_read` | Read full plan content. Used to review details before approving/declining. | | `plan_approve` | Approve a pending plan and spawn an implementation session. | -| `plan_decline` | Decline a pending plan with optional feedback. | -| `plan_revise` | Request revision of a pending plan — sends feedback to the planner session. | +| `plan_decline` | Decline a pending plan with optional feedback. Moves the related task to `done` (declining is treated as abandoning the effort, not pausing it). | +| `plan_revise` | Request revision of a pending plan — sends feedback to the planner session, which calls `plan_update` to produce v+1. | ### `plan_propose(task_id, content, plan_type?)` @@ -61,12 +62,26 @@ User reviews (via /plans UI or chat tools) - Spawns `engine.run()` in background (unchanged — the agent decides how to implement) - Returns `{ plan_id, impl_session_id }` +### `plan_update(plan_id, content, feedback?)` + +- Guards: only pending plans can be updated +- Marks the old plan as `superseded` (and stores optional `feedback` on it explaining the change) +- Creates a new plan record: `version = old.version + 1`, `parent_plan_id = old.id`, same `task_id`/`plan_type`, fresh `session_id` from the updating agent +- Writes a "Plan updated: …" note to task history +- Task status is untouched — the plan is just being refined +- Returns `{ new_plan_id, version }` + +**When to use which:** +- `plan_update` — you (or the planner) want to refine your own pending plan. The task stays open, history is linked. **Default choice for revisions.** +- `plan_decline` — the user truly rejects the plan and abandons the effort. The task moves to `done`. +- `plan_revise` — the user wants the original planner agent to rethink the plan. Sends feedback to the planner session, which then calls `plan_update`. + ### `plan_decline(plan_id, feedback?)` - Guards: only pending plans can be declined - Sets status to `declined` with timestamp - Stores optional feedback on the plan -- Writes decline note to task history +- Moves the related task to `done` with a note explaining the closure (uses the feedback as the reason, or a generic "closed without a specified reason" if none was given) - Returns confirmation ### `plan_revise(plan_id, feedback)` @@ -75,8 +90,8 @@ User reviews (via /plans UI or chat tools) - Stores feedback on the plan record - Writes revision note to task history - Sends feedback as a message to the persistent `cron:task-planner` session -- Planner agent sees prior context + feedback, proposes revised plan via `plan_propose` -- Previous pending plan is automatically superseded when new version is proposed +- Planner agent sees prior context + feedback, calls `plan_update` to produce v+1 linked to the existing plan +- The current pending plan is automatically superseded by the update ## Plan Statuses @@ -106,10 +121,10 @@ When the user requests a revision: 1. User writes feedback in the plan detail page 2. API sends feedback as a new message to the persistent `cron:task-planner` session 3. The agent sees its prior planning context + the feedback -4. Agent calls `plan_propose` with the revised plan -5. Previous plan is automatically superseded +4. Agent calls `plan_update(plan_id, content, feedback)` — old version becomes `superseded`, new version (v+1) is pending review +5. The two plans stay linked via `parent_plan_id` so reviewers can see what changed -This works because the planner uses a **persistent session** — the agent retains conversation history across triggers and revision requests. +This works because the planner uses a **persistent session** — the agent retains conversation history across triggers and revision requests. The same flow works for any agent refining its own plan in chat — call `plan_update` directly instead of `plan_decline + plan_propose`. ## Approval → Auto-Implementation diff --git a/nerve/agent/tools.py b/nerve/agent/tools.py index a24224cf..db9962ab 100644 --- a/nerve/agent/tools.py +++ b/nerve/agent/tools.py @@ -1171,6 +1171,83 @@ async def plan_propose(args: dict) -> dict: return await _plan_propose_impl(args) +async def _plan_update_impl(args: dict, session_id: str | None = None) -> dict: + """Update a pending plan by superseding it with a new version. + + Creates a new plan record (v+1) linked to the old one via parent_plan_id, + marks the old plan as 'superseded', and optionally stores feedback on the + old plan explaining why it was replaced. Use this instead of the + decline→propose dance when refining your own plan based on feedback. + """ + import uuid + + plan_id = args["plan_id"] + content = args["content"] + feedback = (args.get("feedback", "") or "").strip() + + if not _db: + return {"content": [{"type": "text", "text": "Database not available."}]} + + old_plan = await _db.get_plan(plan_id) + if not old_plan: + return {"content": [{"type": "text", "text": f"Plan not found: {plan_id}"}]} + + if old_plan["status"] != "pending": + return {"content": [{"type": "text", "text": f"Plan is '{old_plan['status']}' — only pending plans can be updated."}]} + + # Supersede the old plan, optionally recording why it was replaced. + update_fields: dict = {"status": "superseded"} + if feedback: + update_fields["feedback"] = feedback + await _db.update_plan(plan_id, **update_fields) + + # Create the new version, linked to the old one. + new_plan_id = f"plan-{str(uuid.uuid4())[:8]}" + new_version = int(old_plan.get("version", 1)) + 1 + await _db.create_plan( + plan_id=new_plan_id, + task_id=old_plan["task_id"], + content=content, + session_id=session_id, + model="", + version=new_version, + parent_plan_id=plan_id, + plan_type=old_plan.get("plan_type", "generic"), + ) + + # Write a task note documenting the version bump. + note = f"Plan updated: {plan_id} → {new_plan_id} (v{new_version})" + if feedback: + note += f" — {feedback}" + await task_update.handler({ + "task_id": old_plan["task_id"], + "note": note, + }) + + return { + "content": [{ + "type": "text", + "text": f"Plan {plan_id} superseded by {new_plan_id} (v{new_version}). The new version is pending review.", + }] + } + + +_PLAN_UPDATE_SCHEMA = { + "plan_id": {"type": "string", "description": "The pending plan ID to update"}, + "content": {"type": "string", "description": "The full revised plan content in markdown"}, + "feedback": {"type": "string", "description": "Optional reason for the revision — stored on the superseded plan", "default": ""}, +} + + +@tool( + "plan_update", + "Update a pending plan with revised content. Creates a new version (v+1), marks the old version as superseded, and links them via parent_plan_id. Prefer this over plan_decline + plan_propose when you're refining your own plan based on feedback — the task stays open and the version history is preserved.", + _PLAN_UPDATE_SCHEMA, +) +async def plan_update(args: dict) -> dict: + return await _plan_update_impl(args) + + @tool( "plan_list", "List existing plans. Use this to check which tasks already have pending plans before proposing new ones.", @@ -1435,12 +1512,16 @@ async def plan_revise(args: dict) -> dict: "note": f"Revision requested for {plan_id}: {feedback_summary}", }) - # Send revision request to persistent planner session + # Send revision request to persistent planner session. + # Tell the planner to update the existing plan in-place via plan_update + # so the version history stays linked instead of producing an orphan via + # plan_propose. feedback_prompt = ( f'Revise plan {plan_id} for task "{task["title"]}" based on this feedback:\n\n' f"{feedback}\n\n" f"Explore the codebase again if needed, then call " - f'plan_propose(task_id="{plan["task_id"]}", content="...") with the revised plan.' + f'plan_update(plan_id="{plan_id}", content="...", feedback="") ' + f"with the revised plan." ) session_id = plan.get("session_id") or "cron:task-planner" @@ -2195,6 +2276,17 @@ async def session_plan_propose(args: dict) -> dict: # session_id captured from enclosing scope — tracks which agent proposed the plan return await _plan_propose_impl(args, session_id=session_id) + # --- plan_update session-scoped (session_id attributes the revision) --- + + @tool( + "plan_update", + "Update a pending plan with revised content. Creates a new version (v+1), marks the old version as superseded, and links them via parent_plan_id. Prefer this over plan_decline + plan_propose when you're refining your own plan based on feedback — the task stays open and the version history is preserved.", + _PLAN_UPDATE_SCHEMA, + ) + async def session_plan_update(args: dict) -> dict: + # session_id captured from enclosing scope — attributes the revision to the updating agent + return await _plan_update_impl(args, session_id=session_id) + # --- houseofagents session-scoped tool (needs session_id for streaming) --- _HOA_EXECUTE_SCHEMA = { @@ -2282,8 +2374,8 @@ async def session_send_file(args: dict) -> dict: return await _send_file_impl(args, session_id) # Shared tools (don't need session context) + session-scoped tools - shared_tools = [t for t in ALL_TOOLS if t.name not in ("notify", "ask_user", "react", "send_sticker", "plan_propose")] - session_tools: list[SdkMcpTool] = [session_notify, session_ask_user, session_react, session_send_sticker, session_plan_propose, session_send_file] + shared_tools = [t for t in ALL_TOOLS if t.name not in ("notify", "ask_user", "react", "send_sticker", "plan_propose", "plan_update")] + session_tools: list[SdkMcpTool] = [session_notify, session_ask_user, session_react, session_send_sticker, session_plan_propose, session_plan_update, session_send_file] # Only include houseofagents tools when enabled — saves context tokens otherwise hoa_enabled = _config and _config.houseofagents.enabled diff --git a/nerve/templates/personal/AGENTS.md b/nerve/templates/personal/AGENTS.md index 93ded4ca..8ce803d0 100644 --- a/nerve/templates/personal/AGENTS.md +++ b/nerve/templates/personal/AGENTS.md @@ -28,8 +28,9 @@ These tools are always available via MCP: **Plans** — Async planning for autonomous work (cron jobs, background tasks). - `plan_propose` — Submit an implementation plan for async approval +- `plan_update` — Revise a pending plan in place (creates v+1, supersedes old) - `plan_list` / `plan_read` — Browse and inspect pending plans -- `plan_approve` / `plan_decline` / `plan_revise` — Manage plan lifecycle +- `plan_approve` / `plan_decline` / `plan_revise` — Manage plan lifecycle (decline moves the task to done; use `plan_update` for revisions) **Notifications** — Async communication with your human. - `notify` — Fire-and-forget status update diff --git a/nerve/templates/worker/AGENTS.md b/nerve/templates/worker/AGENTS.md index 715c4e15..4ea06697 100644 --- a/nerve/templates/worker/AGENTS.md +++ b/nerve/templates/worker/AGENTS.md @@ -26,8 +26,9 @@ These tools are always available via MCP: **Plans** — Async planning for autonomous work (cron jobs, background tasks). - `plan_propose` — Submit an implementation plan for async approval +- `plan_update` — Revise a pending plan in place (creates v+1, supersedes old). Prefer this over decline+propose when refining your own plan. - `plan_list` / `plan_read` — Browse and inspect pending plans -- `plan_approve` / `plan_decline` / `plan_revise` — Manage plan lifecycle +- `plan_approve` / `plan_decline` / `plan_revise` — Manage plan lifecycle (decline moves the task to done; use `plan_update` for revisions) **Notifications** — Async communication with your reviewer. - `notify` — Fire-and-forget status update diff --git a/tests/test_db.py b/tests/test_db.py index de3dd0d2..8b422f7e 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -643,6 +643,110 @@ def test_roundtrip(self): assert tags_to_string(parse_tags_string(original)) == original +# --- Plan lifecycle --- + +@pytest.mark.asyncio +class TestPlanUpdate: + """Verify plan_update supersedes the old plan and creates a linked v+1.""" + + async def _setup_pending_plan(self, db: Database, tmp_path): + """Create a task on disk + in DB, plus a pending plan, and wire up tools.""" + from nerve.agent import tools as tools_mod + + task_id = "t-update-flow" + file_path = "test-task.md" + task_md = tmp_path / file_path + task_md.write_text("# Test task\n\nBody.\n", encoding="utf-8") + + await db.upsert_task( + task_id=task_id, file_path=file_path, title="Test task", + status="pending", content=task_md.read_text(), + ) + await db.create_plan( + plan_id="plan-v1", task_id=task_id, content="v1 content", + session_id="sess-orig", version=1, plan_type="generic", + ) + # Plans default to status=pending via column DEFAULT — confirm. + plan = await db.get_plan("plan-v1") + assert plan["status"] == "pending" + + # Wire tools to this DB + workspace + tools_mod.init_tools(workspace=tmp_path, db=db) + return task_id, task_md + + async def test_update_supersedes_and_bumps_version(self, db: Database, tmp_path): + from nerve.agent.tools import plan_update + + task_id, task_md = await self._setup_pending_plan(db, tmp_path) + + result = await plan_update.handler({ + "plan_id": "plan-v1", + "content": "v2 content with refinements", + "feedback": "too vague on edge cases", + }) + + # Tool result mentions the new plan + text = result["content"][0]["text"] + assert "superseded" in text + + # Old plan: superseded, feedback recorded + old = await db.get_plan("plan-v1") + assert old["status"] == "superseded" + assert old["feedback"] == "too vague on edge cases" + + # New plan: linked, v=2, fresh content, pending + new_plans = [p for p in await db.get_plans_for_task(task_id) if p["id"] != "plan-v1"] + assert len(new_plans) == 1 + new = new_plans[0] + assert new["version"] == 2 + assert new["parent_plan_id"] == "plan-v1" + assert new["content"] == "v2 content with refinements" + assert new["status"] == "pending" + assert new["plan_type"] == "generic" + + # Task note was appended to the markdown file + body = task_md.read_text(encoding="utf-8") + assert "Plan updated: plan-v1" in body + assert "v2" in body + assert "too vague on edge cases" in body + + async def test_update_refuses_non_pending_plan(self, db: Database, tmp_path): + from nerve.agent.tools import plan_update + + _, _ = await self._setup_pending_plan(db, tmp_path) + await db.update_plan("plan-v1", status="declined") + + result = await plan_update.handler({ + "plan_id": "plan-v1", + "content": "should not apply", + }) + text = result["content"][0]["text"] + assert "declined" in text + assert "only pending" in text + + # No new plan was created + plans = await db.get_plans_for_task("t-update-flow") + assert len(plans) == 1 + + async def test_update_without_feedback_leaves_old_feedback_alone(self, db: Database, tmp_path): + """If no feedback is supplied, the old plan's feedback field is untouched.""" + from nerve.agent.tools import plan_update + + await self._setup_pending_plan(db, tmp_path) + # Pre-existing feedback on plan-v1 + await db.update_plan("plan-v1", feedback="prior feedback") + + await plan_update.handler({ + "plan_id": "plan-v1", + "content": "v2 content", + }) + + old = await db.get_plan("plan-v1") + assert old["status"] == "superseded" + # The original feedback survives because we only write feedback when supplied + assert old["feedback"] == "prior feedback" + + # --- Diagnostics helpers --- @pytest.mark.asyncio diff --git a/web/src/components/Chat/tools/PlanToolBlock.tsx b/web/src/components/Chat/tools/PlanToolBlock.tsx index df697a0e..303d001b 100644 --- a/web/src/components/Chat/tools/PlanToolBlock.tsx +++ b/web/src/components/Chat/tools/PlanToolBlock.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ChevronRight, ChevronDown, Lightbulb, ListTodo, FileText, Check, X, MessageSquare, Loader2, ExternalLink } from 'lucide-react'; +import { ChevronRight, ChevronDown, Lightbulb, ListTodo, FileText, Check, X, MessageSquare, Loader2, ExternalLink, RefreshCw } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { MarkdownContent } from '../MarkdownContent'; import type { ToolCallBlockData } from '../../../types/chat'; @@ -52,10 +52,11 @@ const STATUS_COLORS: Record = { superseded: 'bg-border-subtle text-text-muted', }; -type PlanTool = 'plan_propose' | 'plan_list' | 'plan_read' | 'plan_approve' | 'plan_decline' | 'plan_revise'; +type PlanTool = 'plan_propose' | 'plan_update' | 'plan_list' | 'plan_read' | 'plan_approve' | 'plan_decline' | 'plan_revise'; const TOOL_CONFIG: Record = { plan_propose: { label: 'Propose Plan', icon: Lightbulb, runningLabel: 'Proposing...' }, + plan_update: { label: 'Update Plan', icon: RefreshCw, runningLabel: 'Updating...' }, plan_list: { label: 'List Plans', icon: ListTodo, runningLabel: 'Loading...' }, plan_read: { label: 'Read Plan', icon: FileText, runningLabel: 'Reading...' }, plan_approve: { label: 'Approve Plan', icon: Check, runningLabel: 'Approving...' }, @@ -84,6 +85,13 @@ export function PlanToolBlock({ block }: { block: ToolCallBlockData }) { ? resultText.match(/Plan proposed:\s*(plan-\S+)/)?.[1] : null; + // plan_update: extract the new (replacement) plan ID and version + const updatedPlan = toolName === 'plan_update' + ? resultText.match(/superseded by\s+(plan-\S+)\s*\(v(\d+)\)/) + : null; + const updatedNewPlanId = updatedPlan?.[1]; + const updatedNewVersion = updatedPlan?.[2]; + // plan_approve: extract impl session ID const implSessionId = toolName === 'plan_approve' ? resultText.match(/impl[_ ]session[_ ](?:id)?:?\s*(\S+)/i)?.[1] @@ -99,6 +107,7 @@ export function PlanToolBlock({ block }: { block: ToolCallBlockData }) { // Collapsed summary text let summary = ''; if (toolName === 'plan_propose') summary = String(block.input.task_id || ''); + else if (toolName === 'plan_update' && updatedNewVersion) summary = `${planId} → v${updatedNewVersion}`; else if (toolName === 'plan_list' && planList.length > 0) summary = `${planList.length} plans`; else if (planId) summary = planId; @@ -242,6 +251,37 @@ export function PlanToolBlock({ block }: { block: ToolCallBlockData }) { )} + {/* ── plan_update ── */} + {toolName === 'plan_update' && !block.isError && ( +
+ {block.input.content ? ( +
+ {String(block.input.content).slice(0, 500)} + {String(block.input.content).length > 500 ? '...' : null} +
+ ) : null} + {feedback && ( +
+
+

{feedback}

+
+ )} + {updatedNewPlanId && ( + + )} + {updatedNewPlanId && ( +
+ Plan revised — awaiting review +
+ )} +
+ )} + {/* ── plan_revise ── */} {toolName === 'plan_revise' && resultText && !block.isError && (