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:
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)
|
||||
Reference in New Issue
Block a user