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>
281 lines
9.3 KiB
Python
281 lines
9.3 KiB
Python
"""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
|