diff --git a/my-deepagent/CHANGELOG.md b/my-deepagent/CHANGELOG.md index f39927a..988f02e 100644 --- a/my-deepagent/CHANGELOG.md +++ b/my-deepagent/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +### Added +- **v0.3 PR #9 — Workflow 옵션화 + user 디렉터리 wiring**. Workflow engine 은 + 주력이 아니라 "옵션" 으로 격하 (사용자가 명시적 `/workflow ` 호출 시만 + 활성). 대신 사용자가 `/personas/` 와 `/workflows/` 에 + YAML 파일을 떨궈 자신만의 persona·workflow 를 등록할 수 있게 함. + - `user_dirs.py` (신규): + - `user_personas_dir(config)`, `user_workflows_dir(config)` — 경로 헬퍼. + - `ensure_user_dirs_initialized(config)` — `mkdir -p`, idempotent. + - `load_combined_personas(config, seed_dir)` — seed (strict) + user + (best-effort per-file skip on malformed) merge. Dedupe key + `(name, version)`, user-overrides-seed. Broken user YAML 1개 가 REPL + 을 죽이지 못함. + - `load_combined_workflows(config, seed_dir)` — workflow 도 동일. + - `cli/interactive.py`: + - `InteractiveSession(..., workflows=...)` 시그니처 확장 — 세션은 로드된 + workflow 리스트를 기억. + - `_interactive_loop_async` 가 `ensure_user_dirs_initialized` 호출 + + `load_combined_personas` / `load_combined_workflows` 사용. + - 신규 슬래시 4개: + - `/personas` — 모든 로드된 persona 목록 (현재 활성 표시) + - `/workflows` — 모든 로드된 workflow 템플릿 목록 (phase/role 개수, 파일명) + - `/workflow ` — `mydeepagent run` 명령으로 진행하라는 + 안내 (실제 백그라운드 invoke 은 별도 PR — 현재는 안내만 제공) + - `/binding show` — 각 workflow 의 role 별 required_capabilities 표시 + - `tests/integration/test_user_dirs.py` (신규, 10 케이스): + - 부트스트랩 idempotency + - seed-only / seed+user / user-overrides-seed / malformed-user-skip (persona) + - workflow 동일 4종 + - 빈 user 디렉터리 처리 + ### Added - **v0.3 PR #8 — Conversation-centric Web GUI (`/conversation.html`)**. Workflow run 페이지는 archive 로 격하; 사용자가 처음 보는 화면은 chat-style diff --git a/my-deepagent/src/my_deepagent/cli/interactive.py b/my-deepagent/src/my_deepagent/cli/interactive.py index 30e6f23..47ddfc0 100644 --- a/my-deepagent/src/my_deepagent/cli/interactive.py +++ b/my-deepagent/src/my_deepagent/cli/interactive.py @@ -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"(? 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 ") +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 " + "/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 ` 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 — 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 ") + 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: diff --git a/my-deepagent/src/my_deepagent/user_dirs.py b/my-deepagent/src/my_deepagent/user_dirs.py new file mode 100644 index 0000000..5c4e1a5 --- /dev/null +++ b/my-deepagent/src/my_deepagent/user_dirs.py @@ -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 +``/personas/`` and ``/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 diff --git a/my-deepagent/tests/integration/test_user_dirs.py b/my-deepagent/tests/integration/test_user_dirs.py new file mode 100644 index 0000000..188ca31 --- /dev/null +++ b/my-deepagent/tests/integration/test_user_dirs.py @@ -0,0 +1,204 @@ +"""v0.3 PR #9 — User-scope persona/workflow directory tests. + +Covers: +1. `ensure_user_dirs_initialized` creates both directories (idempotent). +2. `load_combined_personas` returns seed + user, deduplicated by (name, version). +3. User entries override seed entries with the same key (last-wins). +4. Malformed user persona files are logged + skipped (don't kill the REPL). +5. `load_combined_workflows` mirrors the persona behaviour for workflow YAMLs. +6. Empty user dirs → seed-only. +""" + +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +from my_deepagent.config import load_config +from my_deepagent.user_dirs import ( + ensure_user_dirs_initialized, + load_combined_personas, + load_combined_workflows, + user_personas_dir, + user_workflows_dir, +) + + +def _write_persona_yaml( + target: Path, + *, + name: str, + version: int = 1, + model: str = "openrouter:deepseek/deepseek-chat", + backend: str = "openrouter", + capabilities: list[str] | None = None, +) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + caps = capabilities or ["code_edit"] + cap_lines = "\n".join(f" - {c}" for c in caps) + target.write_text( + dedent( + f"""\ + name: {name} + version: {version} + backend: {backend} + model: "{model}" + provider_origin: "CN/DeepSeek" + capabilities: + {cap_lines} + max_risk_level: medium + system_prompt: | + Test persona system prompt (must be ≥10 chars). + allowed_tools: + - read_file + - write_file + deepagents_backend: state + """ + ), + encoding="utf-8", + ) + + +def _write_workflow_yaml(target: Path, *, name: str, version: int = 1) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + dedent( + f"""\ + name: {name} + version: {version} + description: "test workflow {name}" + roles: + - id: writer + required_capabilities: [code_edit] + phases: + - key: p1 + title: "first phase" + risk: medium + role: writer + gates: [] + expected_artifact: + path: artifacts/foo.md + schema: text + instructions: "do something useful in this phase" + """ + ), + encoding="utf-8", + ) + + +# --------------------------------------------------------------------------- +# Bootstrap +# --------------------------------------------------------------------------- + + +def test_ensure_user_dirs_creates_both(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + ensure_user_dirs_initialized(cfg) + assert user_personas_dir(cfg).is_dir() + assert user_workflows_dir(cfg).is_dir() + + +def test_ensure_user_dirs_is_idempotent(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + ensure_user_dirs_initialized(cfg) + # Drop a file to make sure repeat doesn't wipe it. + _write_persona_yaml(user_personas_dir(cfg) / "p.yaml", name="custom-test") + ensure_user_dirs_initialized(cfg) + assert (user_personas_dir(cfg) / "p.yaml").is_file() + + +# --------------------------------------------------------------------------- +# load_combined_personas +# --------------------------------------------------------------------------- + + +def test_load_combined_personas_returns_seed_only_when_no_user(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "seed" + _write_persona_yaml(seed / "a.yaml", name="alpha") + _write_persona_yaml(seed / "b.yaml", name="bravo") + personas = load_combined_personas(cfg, seed) + names = sorted(p.name for p in personas) + assert names == ["alpha", "bravo"] + + +def test_load_combined_personas_adds_user(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "seed" + _write_persona_yaml(seed / "a.yaml", name="alpha") + _write_persona_yaml(user_personas_dir(cfg) / "user.yaml", name="my-custom") + personas = load_combined_personas(cfg, seed) + names = sorted(p.name for p in personas) + assert names == ["alpha", "my-custom"] + + +def test_load_combined_personas_user_overrides_seed(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "seed" + _write_persona_yaml(seed / "alpha.yaml", name="alpha", model="seed-model") + _write_persona_yaml(user_personas_dir(cfg) / "alpha.yaml", name="alpha", model="user-model") + personas = load_combined_personas(cfg, seed) + assert len(personas) == 1 + assert personas[0].name == "alpha" + assert personas[0].model == "user-model" # user wins + + +def test_load_combined_personas_skips_malformed_user_file(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "seed" + _write_persona_yaml(seed / "a.yaml", name="alpha") + bad = user_personas_dir(cfg) / "broken.yaml" + bad.parent.mkdir(parents=True, exist_ok=True) + bad.write_text("not: a valid: persona:::", encoding="utf-8") + # Should not raise — broken file is logged + skipped. + personas = load_combined_personas(cfg, seed) + # Seed alpha is still present. + assert any(p.name == "alpha" for p in personas) + + +# --------------------------------------------------------------------------- +# load_combined_workflows +# --------------------------------------------------------------------------- + + +def test_load_combined_workflows_seed_only(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "wf-seed" + _write_workflow_yaml(seed / "a.yaml", name="wfa") + workflows = load_combined_workflows(cfg, seed) + names = sorted(t.name for (_p, t) in workflows) + assert names == ["wfa"] + + +def test_load_combined_workflows_user_overrides_seed(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "wf-seed" + _write_workflow_yaml(seed / "wfa.yaml", name="wfa", version=1) + _write_workflow_yaml(user_workflows_dir(cfg) / "wfa.yaml", name="wfa", version=1) + workflows = load_combined_workflows(cfg, seed) + # Dedupe by (name, version) — only the user version remains. + assert len(workflows) == 1 + path, tpl = workflows[0] + assert tpl.name == "wfa" + assert path.parent == user_workflows_dir(cfg) + + +def test_load_combined_workflows_user_adds_distinct(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "wf-seed" + _write_workflow_yaml(seed / "a.yaml", name="wfa") + _write_workflow_yaml(user_workflows_dir(cfg) / "user.yaml", name="userwf") + workflows = load_combined_workflows(cfg, seed) + names = sorted(t.name for (_p, t) in workflows) + assert names == ["userwf", "wfa"] + + +def test_load_combined_workflows_skips_malformed(tmp_path: Path) -> None: + cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data") + seed = tmp_path / "wf-seed" + _write_workflow_yaml(seed / "a.yaml", name="wfa") + bad = user_workflows_dir(cfg) / "broken.yaml" + bad.parent.mkdir(parents=True, exist_ok=True) + bad.write_text("not: a workflow:::", encoding="utf-8") + workflows = load_combined_workflows(cfg, seed) + assert any(t.name == "wfa" for (_p, t) in workflows)