feat(my-deepagent): v0.3 PR #6 — sub-agent session linkage (/agents, /spawn)

deepagents 의 langchain-internal `task` tool 과 별개로, my-deepagent 만의
**persisted** session forking 구현.  Child 는 자체 `InteractiveSessionRow` 를
가져 `mydeepagent --session <id>` 로 독립 resume / Web GUI 트리 탐색 가능.
부모의 `project_key` 그대로 상속해 memory · skills 디렉터리 공유.
Depth limit = MAX_SUBAGENT_DEPTH = 3.

핵심 동작:
- `spawn_subagent_session(db, parent_session_id, persona, initial_title)` —
  단일 트랜잭션 단위로:
  (1) 부모 존재·`state == "active"` 확인
  (2) `depth = parent.depth + 1`, 초과 시 `MyDeepAgentError(human_required)`
  (3) `AgentPersonaRow` upsert (compute_hash 같으면 재사용)
  (4) 부모의 `project_key` 상속 + `parent_session_id`, `depth` 세팅
  → 새 `child_id` 반환.
- `list_subagents(db, parent_session_id)` — 직접 자식만 (`started_at` 순),
  grandchild 는 caller 가 트리 순회.

데이터·라이브러리:
- `subagents.py` (신규): 위 두 함수 + `MAX_SUBAGENT_DEPTH = 3`.

REPL 통합 (`cli/interactive.py`):
- `_register_subagent_slash`: `/agents` (직접 자식 목록), `/spawn <persona>`
  (자식 생성 + resume 안내).

테스트 (`tests/integration/test_subagents.py`, 8 케이스):
- Happy path (project_key 상속, depth=1)
- 같은 부모에 자식 2개 → 둘 다 depth=1
- Depth chain spawn 3 회 후 4번째 거부 (`subagent_depth_exceeded`)
- 존재 안 하는 부모 → `parent_session_missing`
- 부모 state="ended" → `parent_session_ended`
- `list_subagents` direct only (grandchild 제외)
- 자식 없으면 빈 리스트
- 같은 persona hash → 동일 persona_id 재사용

게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
  --ignore=tests/integration/test_openrouter_smoke.py: 665 passed (8 신규 포함)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-17 20:52:00 +09:00
parent fb7e67fd20
commit 5e9656e8a3
4 changed files with 520 additions and 0 deletions

View File

@@ -62,6 +62,7 @@ from ..skills import (
user_skills_dir,
)
from ..slash import SlashParsed, SlashRegistry, parse_slash
from ..subagents import list_subagents, spawn_subagent_session
_CONSOLE = Console()
_FILE_REF_PATTERN = re.compile(r"(?<![\w./])@([\w./\-]+)")
@@ -662,6 +663,62 @@ def _register_plan_mode_slash(reg: SlashRegistry, sess: InteractiveSession) -> N
reg.register("reject", _reject, help="leave plan-mode, discard plan thread")
def _register_subagent_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
"""Register /agents (list children) and /spawn <persona> slash handlers (PR #6)."""
async def _agents(_: SlashParsed) -> bool:
children = await list_subagents(sess.db, sess.session_id)
_CONSOLE.print(f"[bold]sub-agents of {str(sess.session_id)[:8]}…[/]")
if not children:
_CONSOLE.print(" [dim](none — use /spawn <persona> to create one)[/]")
return False
for c in children:
label = c.title or "(no title)"
_CONSOLE.print(
f" - [cyan]{c.id[:8]}…[/] depth={c.depth} state={c.state} [dim]{label}[/]"
)
return False
async def _spawn(cmd: SlashParsed) -> bool:
if not cmd.args:
_CONSOLE.print(
"[yellow]usage:[/] /spawn <persona-name> — fork a child session "
"with the named persona (inherits project memory + skills)"
)
return False
target_name = cmd.args[0]
target = None
for p in sess.personas:
if p.name == target_name or f"{p.name}@{p.version}" == target_name:
target = p
break
if target is None:
_CONSOLE.print(f"[red]persona not found:[/] {target_name}")
return False
try:
child_id = await spawn_subagent_session(
sess.db,
parent_session_id=sess.session_id,
persona=target,
initial_title=f"child of {str(sess.session_id)[:8]}",
)
except Exception as e:
_CONSOLE.print(f"[red]spawn failed:[/] {type(e).__name__}: {e}")
return False
async with sess.db.session() as s:
child = await s.get(InteractiveSessionRow, str(child_id))
depth = child.depth if child is not None else "?"
_CONSOLE.print(
f"[green]spawned[/] {str(child_id)[:8]}"
f"depth={depth} "
f"resume with: `mydeepagent --session {str(child_id)[:8]}`"
)
return False
reg.register("agents", _agents, help="list direct sub-agents of this session")
reg.register("spawn", _spawn, help="fork a child session: /spawn <persona-name>")
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
_register_navigation_slash(reg, sess)
_register_persona_slash(reg, sess)
@@ -670,6 +727,7 @@ def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
_register_memory_slash(reg, sess)
_register_skills_slash(reg, sess)
_register_plan_mode_slash(reg, sess)
_register_subagent_slash(reg, sess)
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter: