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

@@ -2,6 +2,39 @@
## [Unreleased]
### Added
- **v0.3 PR #6 — Sub-agent session linkage (`/agents` / `/spawn <persona>`)**.
Claude Code의 sub-agent (task tool) 와 별개로, my-deepagent 만의 **persisted**
session forking. 부모 session 의 thread 컨텍스트에 langchain-internal 로
spawn 되는 deepagents `task` 도구와 달리, 여기서 만든 child 는 자체
`InteractiveSessionRow` 를 가지고 `mydeepagent --session <id>` 로 별도
resume / Web GUI 트리 탐색이 가능. 부모의 `project_key` 를 그대로 상속해
memory · skills 디렉터리 공유. depth limit = `MAX_SUBAGENT_DEPTH = 3`.
- `subagents.py` (신규):
- `spawn_subagent_session(db, parent_session_id, persona, initial_title)`
트랜잭션 단일 단위:
(1) 부모 존재·`state == "active"` 확인
(2) `depth = parent.depth + 1`, 초과 시 `MyDeepAgentError(human_required,
"subagent_depth_exceeded")`
(3) `AgentPersonaRow` upsert (`compute_hash` 같으면 재사용)
(4) 부모의 `project_key` 그대로 상속 + `parent_session_id`, `depth` 세팅
→ 새 `child_id` 반환.
- `list_subagents(db, parent_session_id)` — 직접 자식만 (`started_at` 순)
반환. grandchild 는 포함 안 함 (caller 가 트리 순회).
- `cli/interactive.py`:
- `_register_subagent_slash`: `/agents` (직접 자식 목록), `/spawn <persona>`
(자식 생성 + resume 안내 메시지) 등록.
- `tests/integration/test_subagents.py` (신규, 8 케이스):
- Happy path: 자식 row 생성 + `parent_session_id`/`depth=1`/`project_key`
상속 검증
- 같은 부모에 자식 2개 → 둘 다 depth=1
- Depth chain spawn 3 회 → 4번째에서 거부 (`subagent_depth_exceeded`)
- 존재 안 하는 부모 → `parent_session_missing`
- 부모 state="ended" → `parent_session_ended`
- `list_subagents`: direct only, no grandchild
- 빈 부모 → 빈 리스트
- 같은 persona hash → 동일 `persona_id` 재사용
### Added
- **v0.3 PR #5 — Plan mode (`/plan` / `/approve` / `/reject`)**. Claude Code의
plan mode 등가. `/plan` 진입 시 `write_file` / `edit_file` / `execute` /

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)

View File

@@ -0,0 +1,280 @@
"""v0.3 PR #6 — Sub-agent session linkage tests.
Covers:
1. `spawn_subagent_session` creates a child row with correct parent_session_id,
depth = parent.depth + 1, inherited project_key.
2. Depth limit `MAX_SUBAGENT_DEPTH` rejects further spawns.
3. Spawn against ended/missing parent raises human_required errors.
4. `list_subagents` returns direct children in start-order, excludes grandchildren.
5. Persona upsert behaves correctly — same persona hash → same persona_id.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from pathlib import Path
import pytest
from my_deepagent.config import load_config
from my_deepagent.errors import MyDeepAgentError
from my_deepagent.persistence.db import Database
from my_deepagent.persistence.models import (
AgentPersonaRow,
InteractiveSessionRow,
)
from my_deepagent.persona import Persona
from my_deepagent.subagents import (
MAX_SUBAGENT_DEPTH,
list_subagents,
spawn_subagent_session,
)
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
def _make_persona(name: str = "spec-writer") -> Persona:
return Persona(
name=name,
version=1,
backend="openrouter",
model="openrouter:deepseek/deepseek-chat",
provider_origin="CN/DeepSeek",
capabilities=("spec_write",),
max_risk_level="medium",
system_prompt="System prompt — at least ten chars",
deepagents_backend="state",
)
@pytest.fixture
async def db_with_root(tmp_path: Path) -> AsyncIterator[tuple[Database, str]]:
"""Database + one root InteractiveSessionRow with depth=0 + project_key='proj1234abcdef00'."""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'subagent.sqlite3'}"
db = Database(db_url)
await db.init_schema()
persona_id = str(uuid.uuid4())
root_id = str(uuid.uuid4())
async with db.session() as s:
s.add(
AgentPersonaRow(
id=persona_id,
name="default-interactive",
version=1,
hash="parent-hash",
definition={"name": "default-interactive", "version": 1},
created_at=_now(),
)
)
s.add(
InteractiveSessionRow(
id=root_id,
persona_id=persona_id,
persona_hash="parent-hash",
started_at=_now(),
last_message_at=_now(),
state="active",
total_input_tokens=0,
total_output_tokens=0,
model="openrouter:deepseek/deepseek-chat",
project_key="proj1234abcdef00",
title="root",
plan_mode=False,
parent_session_id=None,
depth=0,
)
)
await s.commit()
try:
yield (db, root_id)
finally:
await db.dispose()
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_creates_child_with_inherited_project_key(
db_with_root: tuple[Database, str],
) -> None:
db, root_id = db_with_root
persona = _make_persona()
child_id = await spawn_subagent_session(
db,
parent_session_id=uuid.UUID(root_id),
persona=persona,
initial_title="planner-1",
)
async with db.session() as s:
child = await s.get(InteractiveSessionRow, str(child_id))
assert child is not None
assert child.parent_session_id == root_id
assert child.depth == 1
assert child.project_key == "proj1234abcdef00" # inherited
assert child.title == "planner-1"
assert child.state == "active"
assert child.plan_mode is False
assert child.persona_hash == persona.compute_hash()
@pytest.mark.asyncio
async def test_spawn_two_children_depth_one_each(
db_with_root: tuple[Database, str],
) -> None:
db, root_id = db_with_root
persona = _make_persona()
child_a = await spawn_subagent_session(
db, parent_session_id=uuid.UUID(root_id), persona=persona
)
child_b = await spawn_subagent_session(
db, parent_session_id=uuid.UUID(root_id), persona=persona
)
async with db.session() as s:
a = await s.get(InteractiveSessionRow, str(child_a))
b = await s.get(InteractiveSessionRow, str(child_b))
assert a is not None and b is not None
assert a.depth == b.depth == 1
assert a.parent_session_id == b.parent_session_id == root_id
# ---------------------------------------------------------------------------
# Depth limit
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_rejects_beyond_max_depth(db_with_root: tuple[Database, str]) -> None:
db, root_id = db_with_root
persona = _make_persona()
current = uuid.UUID(root_id)
# Chain spawns down to MAX_SUBAGENT_DEPTH (root depth=0; spawn produces 1, 2, 3).
for expected_depth in range(1, MAX_SUBAGENT_DEPTH + 1):
new_child = await spawn_subagent_session(db, parent_session_id=current, persona=persona)
async with db.session() as s:
row = await s.get(InteractiveSessionRow, str(new_child))
assert row is not None
assert row.depth == expected_depth
current = new_child
# Now `current` has depth=MAX_SUBAGENT_DEPTH (3) → spawn must reject.
with pytest.raises(MyDeepAgentError) as exc_info:
await spawn_subagent_session(db, parent_session_id=current, persona=persona)
assert exc_info.value.code == "subagent_depth_exceeded"
# ---------------------------------------------------------------------------
# Invalid parent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_missing_parent_raises(db_with_root: tuple[Database, str]) -> None:
db, _root_id = db_with_root
persona = _make_persona()
bogus = uuid.uuid4()
with pytest.raises(MyDeepAgentError) as exc_info:
await spawn_subagent_session(db, parent_session_id=bogus, persona=persona)
assert exc_info.value.code == "parent_session_missing"
@pytest.mark.asyncio
async def test_spawn_ended_parent_raises(db_with_root: tuple[Database, str]) -> None:
db, root_id = db_with_root
async with db.session() as s:
row = await s.get(InteractiveSessionRow, root_id)
assert row is not None
row.state = "ended"
await s.commit()
persona = _make_persona()
with pytest.raises(MyDeepAgentError) as exc_info:
await spawn_subagent_session(db, parent_session_id=uuid.UUID(root_id), persona=persona)
assert exc_info.value.code == "parent_session_ended"
# ---------------------------------------------------------------------------
# list_subagents
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_subagents_returns_direct_children_only(
db_with_root: tuple[Database, str],
) -> None:
db, root_id = db_with_root
persona = _make_persona()
# root → child_a → grandchild
child_a = await spawn_subagent_session(
db, parent_session_id=uuid.UUID(root_id), persona=persona
)
child_b = await spawn_subagent_session(
db, parent_session_id=uuid.UUID(root_id), persona=persona
)
grandchild = await spawn_subagent_session(db, parent_session_id=child_a, persona=persona)
direct = await list_subagents(db, uuid.UUID(root_id))
ids = [r.id for r in direct]
assert str(child_a) in ids
assert str(child_b) in ids
assert str(grandchild) not in ids # depth-2 not in direct children
assert len(direct) == 2
@pytest.mark.asyncio
async def test_list_subagents_no_children_returns_empty(
db_with_root: tuple[Database, str],
) -> None:
db, root_id = db_with_root
direct = await list_subagents(db, uuid.UUID(root_id))
assert direct == []
# ---------------------------------------------------------------------------
# Persona upsert
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_spawn_reuses_persona_row_for_same_hash(
db_with_root: tuple[Database, str],
) -> None:
db, root_id = db_with_root
persona = _make_persona("shared-persona")
child_a = await spawn_subagent_session(
db, parent_session_id=uuid.UUID(root_id), persona=persona
)
child_b = await spawn_subagent_session(
db, parent_session_id=uuid.UUID(root_id), persona=persona
)
async with db.session() as s:
a = await s.get(InteractiveSessionRow, str(child_a))
b = await s.get(InteractiveSessionRow, str(child_b))
assert a is not None and b is not None
assert a.persona_id == b.persona_id
assert a.persona_hash == b.persona_hash
# No duplicate AgentPersonaRow.
async with db.session() as s:
cfg = load_config(workspace_root=Path.cwd(), data_dir=Path.cwd() / "data") # noqa: F841
from sqlalchemy import select
rows = (
(
await s.execute(
select(AgentPersonaRow).where(AgentPersonaRow.hash == persona.compute_hash())
)
)
.scalars()
.all()
)
assert len(rows) == 1