diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index 76fd5ca..33c7d7c 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -2,6 +2,39 @@ ## [Unreleased] +### Added +- **v0.3 PR #6 — Sub-agent session linkage (`/agents` / `/spawn `)**. + Claude Code의 sub-agent (task tool) 와 별개로, my-deepagent 만의 **persisted** + session forking. 부모 session 의 thread 컨텍스트에 langchain-internal 로 + spawn 되는 deepagents `task` 도구와 달리, 여기서 만든 child 는 자체 + `InteractiveSessionRow` 를 가지고 `mydeepagent --session ` 로 별도 + 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 ` + (자식 생성 + 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` / diff --git a/my-deepagent/src/my_deepagent/cli/interactive.py b/my-deepagent/src/my_deepagent/cli/interactive.py index 8959324..bd19143 100644 --- a/my-deepagent/src/my_deepagent/cli/interactive.py +++ b/my-deepagent/src/my_deepagent/cli/interactive.py @@ -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"(? 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 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 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 — 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 ") + + 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: diff --git a/my-deepagent/src/my_deepagent/subagents.py b/my-deepagent/src/my_deepagent/subagents.py new file mode 100644 index 0000000..ec01c8f --- /dev/null +++ b/my-deepagent/src/my_deepagent/subagents.py @@ -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 `` 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) diff --git a/my-deepagent/tests/integration/test_subagents.py b/my-deepagent/tests/integration/test_subagents.py new file mode 100644 index 0000000..fd9ebab --- /dev/null +++ b/my-deepagent/tests/integration/test_subagents.py @@ -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