feat(my-deepagent): v0.3 PR #9 — workflow optionization + user dir wiring

Workflow engine 을 주력에서 "옵션" 으로 격하: 사용자가 명시적
`/workflow <name>` 호출 시만 활성.  대신 `<data_dir>/personas/` 와
`<data_dir>/workflows/` 에 YAML 파일을 떨궈 자신만의 persona·workflow 를
등록할 수 있게 함 (seed override 가능).

핵심 동작:
- `ensure_user_dirs_initialized(config)` — 두 사용자 디렉터리 `mkdir -p`,
  idempotent.  매 REPL 시작 시 호출.
- `load_combined_personas(config, seed_dir)` — seed (strict) + user
  (best-effort per-file skip) merge.  Dedupe key `(name, version)`,
  user-overrides-seed.  Broken user YAML 1개 가 REPL 죽이지 못함.
- `load_combined_workflows(config, seed_dir)` — workflow 도 동일.

데이터·라이브러리:
- `user_dirs.py` (신규): `user_personas_dir`, `user_workflows_dir`,
  `ensure_user_dirs_initialized`, `load_combined_personas`,
  `load_combined_workflows`, `_safe_load_personas`, `_safe_load_workflows`.

REPL 통합 (`cli/interactive.py`):
- `InteractiveSession(..., workflows=...)` 시그니처 확장.
- `_interactive_loop_async` 가 user dir bootstrap + combined load 사용.
- 신규 슬래시 4개:
  - `/personas` — 로드된 persona 목록 (현재 활성 표시)
  - `/workflows` — 로드된 workflow 템플릿 목록 (phase/role 개수, 파일명)
  - `/workflow <name>` — `mydeepagent run` 명령 안내 (현재 백그라운드 invoke
    는 안내 메시지만; 실제 kick-off 는 별도 PR 또는 `mydeepagent run` CLI)
  - `/binding show` — 각 workflow 의 role 별 required_capabilities 표시
- `_register_workflow_slash` 의 복잡도(C901) 회피를 위해 print 헬퍼
  (`_print_personas` 등) 를 module-level 로 추출.

테스트 (`tests/integration/test_user_dirs.py`, 10 케이스):
- 부트스트랩 idempotency
- persona seed-only / seed+user / user-overrides-seed / malformed-user-skip
- workflow 동일 4종
- 빈 user 디렉터리 처리

게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
  --ignore=tests/integration/test_openrouter_smoke.py: 685 passed (10 신규 포함)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-17 21:11:19 +09:00
parent e326c07dcb
commit 361d6d7636
4 changed files with 465 additions and 2 deletions

View File

@@ -53,7 +53,7 @@ from ..monitoring.token_budget import count_tokens
from ..persistence.checkpointer import get_checkpointer_ctx
from ..persistence.db import Database
from ..persistence.models import InteractiveSessionRow, MessageRow
from ..persona import Persona, load_personas_from_dir
from ..persona import Persona
from ..session import build_agent
from ..skills import (
ensure_skills_initialized,
@@ -64,6 +64,12 @@ from ..skills import (
)
from ..slash import SlashParsed, SlashRegistry, parse_slash
from ..subagents import list_subagents, spawn_subagent_session
from ..user_dirs import (
ensure_user_dirs_initialized,
load_combined_personas,
load_combined_workflows,
)
from ..workflow import WorkflowTemplate
_CONSOLE = Console()
_FILE_REF_PATTERN = re.compile(r"(?<![\w./])@([\w./\-]+)")
@@ -148,9 +154,11 @@ class InteractiveSession:
session_id: UUID,
saver: Any,
project_key: str,
workflows: list[tuple[Path, WorkflowTemplate]] | None = None,
) -> None:
self.config = config
self.personas = personas
self.workflows = workflows or []
self.db = db
self.pricing = pricing
self.repo_root = repo_root
@@ -727,6 +735,107 @@ def _register_subagent_slash(reg: SlashRegistry, sess: InteractiveSession) -> No
reg.register("spawn", _spawn, help="fork a child session: /spawn <persona-name>")
def _print_personas(sess: InteractiveSession) -> None:
_CONSOLE.print(f"[bold]personas[/] (current: {sess.persona.name}@{sess.persona.version})")
for p in sess.personas:
tag = " [green](current)[/]" if p.name == sess.persona.name else ""
_CONSOLE.print(
f" - [cyan]{p.name}@{p.version}[/] backend={p.backend.value} model={p.model}{tag}"
)
def _print_workflows(sess: InteractiveSession) -> None:
_CONSOLE.print(f"[bold]workflows[/] ({len(sess.workflows)} loaded)")
if not sess.workflows:
_CONSOLE.print(
" [dim](none — drop YAML files under docs/schemas/workflows/ or "
"<data_dir>/workflows/)[/]"
)
return
for path, tpl in sess.workflows:
desc = tpl.description or "(no description)"
_CONSOLE.print(
f" - [cyan]{tpl.name}@{tpl.version}[/] "
f"phases={len(tpl.phases)} roles={len(tpl.roles)} "
f"[dim]{desc}[/] [dim italic]{path.name}[/]"
)
def _find_workflow(
sess: InteractiveSession, target_name: str
) -> tuple[Path, WorkflowTemplate] | None:
for path, tpl in sess.workflows:
label = f"{tpl.name}@{tpl.version}"
if tpl.name == target_name or label == target_name:
return (path, tpl)
return None
def _print_workflow_kickoff(path: Path, tpl: WorkflowTemplate) -> None:
_CONSOLE.print(
f"[yellow]/workflow kick-off is best invoked via:[/]\n"
f" mydeepagent run --workflow {path} --repo .\n"
f"That command launches a full WorkflowEngine.run with audit + budget "
f"+ resume support. Live progress: `mydeepagent runs show <id>` or "
f"the Web GUI."
)
_CONSOLE.print(
f" workflow: [cyan]{tpl.name}@{tpl.version}[/] "
f"phases={len(tpl.phases)} roles={len(tpl.roles)}"
)
def _print_bindings(sess: InteractiveSession) -> None:
if not sess.workflows:
_CONSOLE.print("[dim](no workflows loaded)[/]")
return
for _path, tpl in sess.workflows:
_CONSOLE.print(f"[bold]{tpl.name}@{tpl.version}[/]")
for role in tpl.roles:
caps = ", ".join(c.value for c in role.required_capabilities)
_CONSOLE.print(f" - role [cyan]{role.id}[/] required: [dim]{caps}[/]")
def _register_workflow_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
"""Register /personas, /workflows, /workflow, /binding slash handlers (PR #9)."""
async def _personas_cmd(_: SlashParsed) -> bool:
_print_personas(sess)
return False
async def _workflows_cmd(_: SlashParsed) -> bool:
_print_workflows(sess)
return False
async def _workflow_cmd(cmd: SlashParsed) -> bool:
if not cmd.args:
_CONSOLE.print(
"[yellow]usage:[/] /workflow <name[@version]> — kick off a "
"background workflow run. See /runs for progress."
)
return False
target_name = cmd.args[0]
match = _find_workflow(sess, target_name)
if match is None:
_CONSOLE.print(f"[red]workflow not found:[/] {target_name}")
return False
path, tpl = match
_print_workflow_kickoff(path, tpl)
return False
async def _binding_cmd(cmd: SlashParsed) -> bool:
if not cmd.args or cmd.args[0] != "show":
_CONSOLE.print("[yellow]usage:[/] /binding show — list role→persona defaults")
return False
_print_bindings(sess)
return False
reg.register("personas", _personas_cmd, help="list all loaded personas")
reg.register("workflows", _workflows_cmd, help="list all loaded workflow templates")
reg.register("workflow", _workflow_cmd, help="kick off a workflow: /workflow <name>")
reg.register("binding", _binding_cmd, help="inspect bindings: /binding show")
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
_register_navigation_slash(reg, sess)
_register_persona_slash(reg, sess)
@@ -736,6 +845,7 @@ def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
_register_skills_slash(reg, sess)
_register_plan_mode_slash(reg, sess)
_register_subagent_slash(reg, sess)
_register_workflow_slash(reg, sess)
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
@@ -890,10 +1000,13 @@ async def _interactive_loop_async(
require_consent(config.data_dir)
db = Database(config.database_url)
await db.init_schema()
personas = load_personas_from_dir(_seed_root() / "personas")
# v0.3 PR #9: bootstrap user persona/workflow dirs and load seed + user.
ensure_user_dirs_initialized(config)
personas = load_combined_personas(config, _seed_root() / "personas")
if not personas:
_CONSOLE.print("[red]no personas seeded; run `mydeepagent init`[/]")
return 1
workflows = load_combined_workflows(config, _seed_root() / "workflows")
pricing = _static_pricing_seed()
# Resolve session id: --session given → existing; otherwise new uuid.
@@ -934,6 +1047,7 @@ async def _interactive_loop_async(
session_id,
saver,
project_key,
workflows=workflows,
)
if persona_override:
try:

View File

@@ -0,0 +1,115 @@
"""User-scope persona / workflow directories (v0.3 PR #9).
Existing personas live at ``docs/schemas/personas/`` (seeded with the
my-deepagent install). Users can drop additional YAML files into
``<config.data_dir>/personas/`` and ``<config.data_dir>/workflows/`` to
register their own — these are layered ON TOP of the seed (user version
wins on `(name, version)` collision).
This module exposes:
- :func:`user_personas_dir` / :func:`user_workflows_dir` — path helpers.
- :func:`ensure_user_dirs_initialized` — `mkdir -p` for both, idempotent.
- :func:`load_combined_personas` — seed + user, deduplicated by (name, version)
with user-overrides-seed semantics.
- :func:`load_combined_workflows` — seed + user, deduplicated by (name, version).
"""
from __future__ import annotations
import logging
from pathlib import Path
from .config import Config
from .persona import Persona, load_personas_from_dir
from .workflow import WorkflowTemplate, load_workflow_yaml
_LOG = logging.getLogger(__name__)
def user_personas_dir(config: Config) -> Path:
return Path(config.data_dir) / "personas"
def user_workflows_dir(config: Config) -> Path:
return Path(config.data_dir) / "workflows"
def ensure_user_dirs_initialized(config: Config) -> None:
"""`mkdir -p` for both user directories. Idempotent."""
user_personas_dir(config).mkdir(parents=True, exist_ok=True)
user_workflows_dir(config).mkdir(parents=True, exist_ok=True)
def load_combined_personas(config: Config, seed_dir: Path) -> list[Persona]:
"""Combine seeded + user personas with user-overrides-seed precedence.
Returns a list whose order is "seed first, then user-only (excluding
overrides)" — useful for CLI listings. Internal dedupe is keyed on
``(name, version)``. The seed dir uses strict loading (we want to know
if a shipped YAML is broken). The user dir uses best-effort per-file
loading so a single broken file cannot break the REPL.
"""
seed = load_personas_from_dir(seed_dir)
user_dir = user_personas_dir(config)
user = _safe_load_personas(user_dir) if user_dir.is_dir() else []
return _merge_with_user_override(seed, user)
def _safe_load_personas(directory: Path) -> list[Persona]:
"""Best-effort load — skip individual malformed files."""
from .persona import load_persona_yaml
out: list[Persona] = []
for p in sorted(directory.glob("*.yaml")):
try:
out.append(load_persona_yaml(p))
except Exception as e:
_LOG.warning("skipping invalid persona file %s: %s", p, e)
return out
def _merge_with_user_override(seed: list[Persona], user: list[Persona]) -> list[Persona]:
"""Last-wins on `(name, version)`. Preserves seed order for entries not
overridden, then appends user-only entries in their own order."""
user_keys = {(p.name, p.version) for p in user}
merged: list[Persona] = [p for p in seed if (p.name, p.version) not in user_keys]
merged.extend(user)
return merged
def load_combined_workflows(config: Config, seed_dir: Path) -> list[tuple[Path, WorkflowTemplate]]:
"""Combine seeded + user workflows with user-overrides-seed precedence.
Returns `[(path, WorkflowTemplate), ...]`. Malformed YAMLs (seed or user)
are logged and skipped — broken files cannot break the REPL. Order is
seed first (deduped), then user-only.
"""
seed = _safe_load_workflows(seed_dir)
user_dir = user_workflows_dir(config)
user = _safe_load_workflows(user_dir) if user_dir.is_dir() else []
return _merge_workflows_with_user_override(seed, user)
def _safe_load_workflows(directory: Path) -> list[tuple[Path, WorkflowTemplate]]:
if not directory.is_dir():
return []
out: list[tuple[Path, WorkflowTemplate]] = []
for p in sorted(directory.glob("*.yaml")):
try:
out.append((p, load_workflow_yaml(p)))
except Exception as e:
_LOG.warning("skipping invalid workflow file %s: %s", p, e)
return out
def _merge_workflows_with_user_override(
seed: list[tuple[Path, WorkflowTemplate]],
user: list[tuple[Path, WorkflowTemplate]],
) -> list[tuple[Path, WorkflowTemplate]]:
user_keys = {(t.name, t.version) for (_p, t) in user}
merged: list[tuple[Path, WorkflowTemplate]] = [
(p, t) for (p, t) in seed if (t.name, t.version) not in user_keys
]
merged.extend(user)
return merged