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:

View File

@@ -0,0 +1,149 @@
"""Sub-agent session linkage (v0.3 PR #6) — fork a session into a child.
PR #1 already added `parent_session_id` + `depth` columns to
`InteractiveSessionRow`. This module provides:
- :func:`spawn_subagent_session` — atomically creates a child row inheriting
``project_key`` (so memory is shared) and ``persona_id`` from the parent
unless overridden, sets ``parent_session_id`` + ``depth = parent.depth + 1``,
and rejects when depth would exceed :data:`MAX_SUBAGENT_DEPTH`.
- :func:`list_subagents` — return all direct children of a session for
``/agents`` and the Web GUI's session tree.
The deepagents ``task`` tool is *separate* from this concept — that tool
spawns langchain-internal sub-agents that run inline and return a string.
Those don't get InteractiveSessionRows. Use ``spawn_subagent_session`` when
you want a persisted, addressable session that the user can navigate to via
``mydeepagent --session <id>`` or the Web GUI.
"""
from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy import select
from .errors import MyDeepAgentError
from .persistence.db import Database
from .persistence.models import AgentPersonaRow, InteractiveSessionRow
from .persona import Persona
#: Maximum sub-agent nesting depth. Above this we refuse to spawn — Claude
#: Code's `task` tool limits agent stacks to roughly 3 levels (Main → A → B)
#: to keep budgets and audit trails legible.
MAX_SUBAGENT_DEPTH: int = 3
def _now_iso() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
async def spawn_subagent_session(
db: Database,
*,
parent_session_id: UUID,
persona: Persona,
initial_title: str | None = None,
) -> UUID:
"""Create a child :class:`InteractiveSessionRow` linked to ``parent_session_id``.
The child inherits ``project_key`` from the parent — same memory dir,
same skill dir. ``depth`` is incremented by 1; if that would exceed
:data:`MAX_SUBAGENT_DEPTH` we raise ``MyDeepAgentError(human_required)``
so the caller (REPL slash / API endpoint) can surface a clean message.
The persona may be different from the parent's (callers often want a
specialised role for the child), so ``persona`` is required. We upsert
its ``AgentPersonaRow`` for the FK exactly like
:func:`cli.interactive._load_or_create_session_row` does for root rows.
Returns the new child ``session_id``.
"""
async with db.session() as s:
parent = await s.get(InteractiveSessionRow, str(parent_session_id))
if parent is None:
raise MyDeepAgentError.human_required(
"parent_session_missing",
message=f"cannot spawn sub-agent: parent session {parent_session_id} not found",
recovery_hint="confirm the parent session id; sub-agents require a live parent",
)
if parent.state == "ended":
raise MyDeepAgentError.human_required(
"parent_session_ended",
message=f"cannot spawn sub-agent: parent {parent.id} is ended",
recovery_hint="resume the parent session first or pick a different parent",
)
new_depth = (parent.depth or 0) + 1
if new_depth > MAX_SUBAGENT_DEPTH:
raise MyDeepAgentError.human_required(
"subagent_depth_exceeded",
message=(
f"sub-agent depth limit reached: parent depth={parent.depth}, "
f"max={MAX_SUBAGENT_DEPTH}"
),
recovery_hint=(
f"flatten the agent stack (max {MAX_SUBAGENT_DEPTH} levels) "
"or close intermediate sub-agents first"
),
)
# Upsert AgentPersonaRow for the persona we're spawning with.
ph = persona.compute_hash()
persona_row = (
await s.execute(select(AgentPersonaRow).where(AgentPersonaRow.hash == ph))
).scalar_one_or_none()
if persona_row is None:
persona_row = AgentPersonaRow(
id=str(uuid4()),
name=persona.name,
version=persona.version,
hash=ph,
definition=persona.model_dump(by_alias=True),
created_at=_now_iso(),
)
s.add(persona_row)
await s.flush()
child_id = uuid4()
child = InteractiveSessionRow(
id=str(child_id),
persona_id=persona_row.id,
persona_hash=ph,
started_at=_now_iso(),
last_message_at=None,
state="active",
total_input_tokens=0,
total_output_tokens=0,
model=persona.model,
project_key=parent.project_key, # inherit so memory is shared
title=initial_title,
plan_mode=False,
parent_session_id=parent.id,
depth=new_depth,
)
s.add(child)
await s.commit()
return child_id
async def list_subagents(db: Database, parent_session_id: UUID) -> list[InteractiveSessionRow]:
"""Return all direct children of ``parent_session_id``, oldest first.
Used by the ``/agents`` slash and the Web GUI session tree. Does NOT
recurse — callers that want the full tree must walk it themselves.
"""
async with db.session() as s:
rows: Sequence[InteractiveSessionRow] = (
(
await s.execute(
select(InteractiveSessionRow)
.where(InteractiveSessionRow.parent_session_id == str(parent_session_id))
.order_by(InteractiveSessionRow.started_at)
)
)
.scalars()
.all()
)
return list(rows)