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