feat(my-deepagent): v0.3 PR #5 — plan mode (/plan, /approve, /reject)
Claude Code의 plan mode 등가. `/plan` 진입 시 write_file / edit_file /
execute / bash / task (sub-agent) 도구가 차단되고 read_file / glob / grep /
ls / write_todos 만 허용.
핵심 동작:
- `PlanModeMiddleware(is_active: Callable[[], bool])` 가 `awrap_tool_call` /
`wrap_tool_call` 에서 활성 + 차단 도구면 synthetic
`ToolMessage(status="error")` 반환. raise 하지 않음 — LLM 이 차단 메시지를
보고 다른 도구로 전환하거나 plan 다듬기로 자동 복귀.
- `is_active` 는 closure 라서 슬래시 토글 후 agent 재빌드 불필요.
- `InteractiveSessionRow.plan_mode` 영속 + resume 시 복원.
데이터·라이브러리:
- `middleware/plan_mode.py` (신규):
- `BLOCKED_TOOLS_IN_PLAN_MODE = write_file / edit_file / bash / execute /
run_command / shell / task`.
- `PlanModeMiddleware` async + sync 양쪽 구현.
REPL 통합 (`cli/interactive.py`):
- `InteractiveSession._plan_mode: bool` + `set_plan_mode(enabled)` async →
flag 토글 + `thread_suffix` bump + row 영속.
- resume path 에서 `sess._plan_mode = row.plan_mode` 로 복원.
- `_register_plan_mode_slash`: `/plan`, `/approve`, `/reject` 등록.
- `/reject` 는 thread 까지 리셋해 plan thread 폐기.
테스트 (`tests/integration/test_plan_mode.py`, 9 케이스):
- inactive 시 모든 도구 패스스루
- active 시 write_file / execute / task 차단 (status=error,
tool_call_id 유지, 메시지에 도구명 + "Plan-mode" 포함)
- active 시 read_file / glob / grep / ls / write_todos 허용
- closure 토글로 동작 변경 (rebuild 없이)
- 동기 wrap_tool_call 도 동일 동작
- BLOCKED_TOOLS_IN_PLAN_MODE 상수 sanity
게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
--ignore=tests/integration/test_openrouter_smoke.py: 657 passed (9 신규 포함)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ from ..memory import (
|
||||
)
|
||||
from ..middleware.audit import AuditToolMiddleware
|
||||
from ..middleware.cost import CostMiddleware
|
||||
from ..middleware.plan_mode import PlanModeMiddleware
|
||||
from ..monitoring.pricing import ModelPrice, PricingCache
|
||||
from ..monitoring.token_budget import count_tokens
|
||||
from ..persistence.checkpointer import get_checkpointer_ctx
|
||||
@@ -169,6 +170,9 @@ class InteractiveSession:
|
||||
# users drop `<name>/SKILL.md` directories under here to register skills.
|
||||
self.skills_dir: Path = user_skills_dir(config)
|
||||
ensure_skills_initialized(self.skills_dir)
|
||||
# v0.3 PR #5: plan-mode flag. PlanModeMiddleware reads this via closure
|
||||
# every tool call — no agent rebuild needed when toggling on/off.
|
||||
self._plan_mode: bool = False
|
||||
|
||||
@property
|
||||
def thread_id(self) -> str:
|
||||
@@ -216,6 +220,28 @@ class InteractiveSession:
|
||||
self._agent = None
|
||||
self._thread_suffix += 1
|
||||
|
||||
@property
|
||||
def plan_mode(self) -> bool:
|
||||
"""Whether plan mode is currently active for this session."""
|
||||
return self._plan_mode
|
||||
|
||||
async def set_plan_mode(self, enabled: bool) -> None:
|
||||
"""Toggle plan mode + persist to the session row.
|
||||
|
||||
PlanModeMiddleware re-reads via closure each tool call → no agent
|
||||
rebuild required. We DO bump the thread suffix on each toggle so the
|
||||
model doesn't carry over "I was about to write a file" state into the
|
||||
new mode. Persists `plan_mode` on the InteractiveSessionRow so resumes
|
||||
re-establish the mode.
|
||||
"""
|
||||
self._plan_mode = enabled
|
||||
self._thread_suffix += 1
|
||||
async with self.db.session() as s:
|
||||
row = await s.get(InteractiveSessionRow, str(self.session_id))
|
||||
if row is not None:
|
||||
row.plan_mode = enabled
|
||||
await s.commit()
|
||||
|
||||
def build_agent_if_needed(self) -> Any:
|
||||
if self._agent is not None:
|
||||
return self._agent
|
||||
@@ -231,6 +257,9 @@ class InteractiveSession:
|
||||
interactive_session_id=self.session_id,
|
||||
file_recorder=make_audit_recorder(self.config.state_dir),
|
||||
)
|
||||
# v0.3 PR #5: plan-mode middleware reads `self._plan_mode` via closure
|
||||
# every tool call → toggling /plan vs /approve doesn't require rebuild.
|
||||
plan_mw = PlanModeMiddleware(is_active=lambda: self._plan_mode)
|
||||
# Re-glob memory paths every time the agent is rebuilt — `/remember` and
|
||||
# `/forget` call `clear_agent_cache()` so this picks up new/removed files.
|
||||
memory_paths = list_memory_paths(self.memory_dir)
|
||||
@@ -239,7 +268,7 @@ class InteractiveSession:
|
||||
self._persona,
|
||||
self.config,
|
||||
root_dir=self.repo_root,
|
||||
middleware=[cost_mw, audit_mw],
|
||||
middleware=[plan_mw, cost_mw, audit_mw],
|
||||
model_override=self._model_override,
|
||||
checkpointer=self.saver,
|
||||
memory_paths_override=memory_paths,
|
||||
@@ -596,6 +625,43 @@ def _register_skills_slash(reg: SlashRegistry, sess: InteractiveSession) -> None
|
||||
reg.register("skill", _skill, help="show a skill's body: /skill <name>")
|
||||
|
||||
|
||||
def _register_plan_mode_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
"""Register /plan, /approve, /reject slash handlers (v0.3 PR #5)."""
|
||||
|
||||
async def _plan(_: SlashParsed) -> bool:
|
||||
if sess.plan_mode:
|
||||
_CONSOLE.print("[yellow]plan-mode is already active.[/]")
|
||||
return False
|
||||
await sess.set_plan_mode(True)
|
||||
_CONSOLE.print(
|
||||
"[bold yellow]plan-mode ON[/] — write_file / edit_file / "
|
||||
"execute / task tools are blocked. Use /approve to leave, "
|
||||
"or /reject to discard the plan."
|
||||
)
|
||||
return False
|
||||
|
||||
async def _approve(_: SlashParsed) -> bool:
|
||||
if not sess.plan_mode:
|
||||
_CONSOLE.print("[yellow]plan-mode is not active.[/]")
|
||||
return False
|
||||
await sess.set_plan_mode(False)
|
||||
_CONSOLE.print("[green]plan approved → leaving plan-mode (writes re-enabled).[/]")
|
||||
return False
|
||||
|
||||
async def _reject(_: SlashParsed) -> bool:
|
||||
if not sess.plan_mode:
|
||||
_CONSOLE.print("[yellow]plan-mode is not active.[/]")
|
||||
return False
|
||||
await sess.set_plan_mode(False)
|
||||
sess.clear_agent_cache() # drop the plan thread entirely
|
||||
_CONSOLE.print("[red]plan rejected → fresh thread, writes re-enabled.[/]")
|
||||
return False
|
||||
|
||||
reg.register("plan", _plan, help="enter plan-mode (block writes until /approve)")
|
||||
reg.register("approve", _approve, help="leave plan-mode, allow writes")
|
||||
reg.register("reject", _reject, help="leave plan-mode, discard plan thread")
|
||||
|
||||
|
||||
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
_register_navigation_slash(reg, sess)
|
||||
_register_persona_slash(reg, sess)
|
||||
@@ -603,6 +669,7 @@ def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
_register_compaction_slash(reg, sess)
|
||||
_register_memory_slash(reg, sess)
|
||||
_register_skills_slash(reg, sess)
|
||||
_register_plan_mode_slash(reg, sess)
|
||||
|
||||
|
||||
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
|
||||
@@ -818,9 +885,12 @@ async def _interactive_loop_async(
|
||||
sess._thread_suffix = 0
|
||||
|
||||
# Now persist the session row (or load existing).
|
||||
await _load_or_create_session_row(
|
||||
row = await _load_or_create_session_row(
|
||||
db, session_id, sess.persona, Path.cwd(), create=creating
|
||||
)
|
||||
# v0.3 PR #5: restore plan_mode flag from row on resume so the
|
||||
# session remembers it across REPL restarts.
|
||||
sess._plan_mode = bool(row.plan_mode)
|
||||
|
||||
reg = SlashRegistry()
|
||||
_register_slash(reg, sess)
|
||||
|
||||
Reference in New Issue
Block a user