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:
@@ -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:
|
||||
|
||||
115
my-deepagent/src/my_deepagent/user_dirs.py
Normal file
115
my-deepagent/src/my_deepagent/user_dirs.py
Normal 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
|
||||
Reference in New Issue
Block a user