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:
@@ -2,6 +2,39 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Added
|
||||||
- **v0.3 PR #5 — Plan mode (`/plan` / `/approve` / `/reject`)**. Claude Code의
|
- **v0.3 PR #5 — Plan mode (`/plan` / `/approve` / `/reject`)**. Claude Code의
|
||||||
plan mode 등가. `/plan` 진입 시 `write_file` / `edit_file` / `execute` /
|
plan mode 등가. `/plan` 진입 시 `write_file` / `edit_file` / `execute` /
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ from ..skills import (
|
|||||||
user_skills_dir,
|
user_skills_dir,
|
||||||
)
|
)
|
||||||
from ..slash import SlashParsed, SlashRegistry, parse_slash
|
from ..slash import SlashParsed, SlashRegistry, parse_slash
|
||||||
|
from ..subagents import list_subagents, spawn_subagent_session
|
||||||
|
|
||||||
_CONSOLE = Console()
|
_CONSOLE = Console()
|
||||||
_FILE_REF_PATTERN = re.compile(r"(?<![\w./])@([\w./\-]+)")
|
_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")
|
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:
|
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||||
_register_navigation_slash(reg, sess)
|
_register_navigation_slash(reg, sess)
|
||||||
_register_persona_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_memory_slash(reg, sess)
|
||||||
_register_skills_slash(reg, sess)
|
_register_skills_slash(reg, sess)
|
||||||
_register_plan_mode_slash(reg, sess)
|
_register_plan_mode_slash(reg, sess)
|
||||||
|
_register_subagent_slash(reg, sess)
|
||||||
|
|
||||||
|
|
||||||
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
|
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
|
||||||
|
|||||||
149
my-deepagent/src/my_deepagent/subagents.py
Normal file
149
my-deepagent/src/my_deepagent/subagents.py
Normal 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)
|
||||||
280
my-deepagent/tests/integration/test_subagents.py
Normal file
280
my-deepagent/tests/integration/test_subagents.py
Normal 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
|
||||||
Reference in New Issue
Block a user