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:
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **v0.3 PR #9 — Workflow 옵션화 + user 디렉터리 wiring**. Workflow engine 은
|
||||||
|
주력이 아니라 "옵션" 으로 격하 (사용자가 명시적 `/workflow <name>` 호출 시만
|
||||||
|
활성). 대신 사용자가 `<data_dir>/personas/` 와 `<data_dir>/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 <name[@version]>` — `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
|
### Added
|
||||||
- **v0.3 PR #8 — Conversation-centric Web GUI (`/conversation.html`)**.
|
- **v0.3 PR #8 — Conversation-centric Web GUI (`/conversation.html`)**.
|
||||||
Workflow run 페이지는 archive 로 격하; 사용자가 처음 보는 화면은 chat-style
|
Workflow run 페이지는 archive 로 격하; 사용자가 처음 보는 화면은 chat-style
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ from ..monitoring.token_budget import count_tokens
|
|||||||
from ..persistence.checkpointer import get_checkpointer_ctx
|
from ..persistence.checkpointer import get_checkpointer_ctx
|
||||||
from ..persistence.db import Database
|
from ..persistence.db import Database
|
||||||
from ..persistence.models import InteractiveSessionRow, MessageRow
|
from ..persistence.models import InteractiveSessionRow, MessageRow
|
||||||
from ..persona import Persona, load_personas_from_dir
|
from ..persona import Persona
|
||||||
from ..session import build_agent
|
from ..session import build_agent
|
||||||
from ..skills import (
|
from ..skills import (
|
||||||
ensure_skills_initialized,
|
ensure_skills_initialized,
|
||||||
@@ -64,6 +64,12 @@ from ..skills import (
|
|||||||
)
|
)
|
||||||
from ..slash import SlashParsed, SlashRegistry, parse_slash
|
from ..slash import SlashParsed, SlashRegistry, parse_slash
|
||||||
from ..subagents import list_subagents, spawn_subagent_session
|
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()
|
_CONSOLE = Console()
|
||||||
_FILE_REF_PATTERN = re.compile(r"(?<![\w./])@([\w./\-]+)")
|
_FILE_REF_PATTERN = re.compile(r"(?<![\w./])@([\w./\-]+)")
|
||||||
@@ -148,9 +154,11 @@ class InteractiveSession:
|
|||||||
session_id: UUID,
|
session_id: UUID,
|
||||||
saver: Any,
|
saver: Any,
|
||||||
project_key: str,
|
project_key: str,
|
||||||
|
workflows: list[tuple[Path, WorkflowTemplate]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.personas = personas
|
self.personas = personas
|
||||||
|
self.workflows = workflows or []
|
||||||
self.db = db
|
self.db = db
|
||||||
self.pricing = pricing
|
self.pricing = pricing
|
||||||
self.repo_root = repo_root
|
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>")
|
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:
|
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||||
_register_navigation_slash(reg, sess)
|
_register_navigation_slash(reg, sess)
|
||||||
_register_persona_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_skills_slash(reg, sess)
|
||||||
_register_plan_mode_slash(reg, sess)
|
_register_plan_mode_slash(reg, sess)
|
||||||
_register_subagent_slash(reg, sess)
|
_register_subagent_slash(reg, sess)
|
||||||
|
_register_workflow_slash(reg, sess)
|
||||||
|
|
||||||
|
|
||||||
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
|
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
|
||||||
@@ -890,10 +1000,13 @@ async def _interactive_loop_async(
|
|||||||
require_consent(config.data_dir)
|
require_consent(config.data_dir)
|
||||||
db = Database(config.database_url)
|
db = Database(config.database_url)
|
||||||
await db.init_schema()
|
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:
|
if not personas:
|
||||||
_CONSOLE.print("[red]no personas seeded; run `mydeepagent init`[/]")
|
_CONSOLE.print("[red]no personas seeded; run `mydeepagent init`[/]")
|
||||||
return 1
|
return 1
|
||||||
|
workflows = load_combined_workflows(config, _seed_root() / "workflows")
|
||||||
pricing = _static_pricing_seed()
|
pricing = _static_pricing_seed()
|
||||||
|
|
||||||
# Resolve session id: --session given → existing; otherwise new uuid.
|
# Resolve session id: --session given → existing; otherwise new uuid.
|
||||||
@@ -934,6 +1047,7 @@ async def _interactive_loop_async(
|
|||||||
session_id,
|
session_id,
|
||||||
saver,
|
saver,
|
||||||
project_key,
|
project_key,
|
||||||
|
workflows=workflows,
|
||||||
)
|
)
|
||||||
if persona_override:
|
if persona_override:
|
||||||
try:
|
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
|
||||||
204
my-deepagent/tests/integration/test_user_dirs.py
Normal file
204
my-deepagent/tests/integration/test_user_dirs.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user