feat(my-deepagent): v0.1.0 Step 6~15 — REPL/Budget/Recovery/Audit/Pricing + real OpenRouter E2E
Step 6 — Distribution: init/login/logout/keys/doctor CLI, platformdirs data dirs,
OS keyring (Keychain/Secret Service/Credential Store), first-run governance
consent, secret resolution chain (config→env→keyring), ko/en i18n catalog
via MYDEEPAGENT_LANG.
Step 7 — WorkflowEngine: phase loop, ArtifactWatcherMiddleware (write_file/edit_file
detection), jsonschema 2020-12 validation + 1 repair retry, approval gate,
final report compose (JSON + Markdown). FK-safe persistence ordering.
RunEventType + run_idempotency_key per plan v2.0 §13.1.
Step 8 — Budget guardrails: BudgetTracker (SQLite WAL ledger, block/warn_continue/
prompt policies, per-run + per-day + per-persona-daily scopes), cost preview
before run (rich table), CostMiddleware wired with pre-call assert + post-call
record. CLI: budget / stats --by model|persona|day / costs.
Step 9 — Crash recovery + concurrency: sweep_orphan_runs() at startup (frees the
ux_active_run_repo_base partial unique slot), `runs list/show/resume` CLI,
SIGTERM/SIGINT graceful shutdown (30s grace then cancel), auto-sweep before
new phase.
Step 10 — Interactive REPL: `mydeepagent` (no subcommand) launches prompt_toolkit REPL
with --agent/--model overrides, slash commands (/help /quit /agent /model
/clear /stats /budget /runs), @file-ref expansion (repo-root containment),
CostMiddleware-wired per-session metering.
Step 11 — Audit log + secret scrubbing: append-only {state_dir}/audit.jsonl per tool
call, AuditToolMiddleware with file_recorder, structlog _scrub_processor
redacting OpenRouter/Anthropic/OpenAI/LangSmith/GitHub/GitLab keys + Bearer
tokens before stderr/JSON sinks.
Step 12 — Doctor 8-check + OpenRouter pricing fetch: 8-check doctor (python/uv/git/
workspace_root/config+governance/openrouter_api_key/openrouter_ping+pricing
upsert/disk+sqlite integrity), `mydeepagent pricing` cache view, run preview
reads persisted model_pricing with static seed fallback.
Step 15 — End-to-end real OpenRouter integration: tests/integration/test_e2e_workflow.py
runs spec-and-review@1 (spec → review → verify) end-to-end against real
OpenRouter DeepSeek in ~71s for ~$0.05 per run. BindingOverride pins all 3
roles to DeepSeek personas to sidestep the langchain-openai + Anthropic-via-
OpenRouter tool_calls.args JSON-string ValidationError (known v0.1.0 limit).
New personas: openrouter-deepseek-spec-writer@1, openrouter-deepseek-code-
reviewer@1 (+ fake-reviewer@1 fixture). _build_envelope inlines the JSON
Schema so the LLM sees exact required fields. _record_llm_call fills every
NOT NULL LlmCallRow column. CostMiddleware probes both usage_metadata and
response_metadata.token_usage (prompt_tokens/completion_tokens fallback).
dev/review-finding-batch@1 artifact schema added.
Known v0.1.0 limits documented in CHANGELOG:
- usage_metadata sometimes empty on OpenRouter-forwarded responses (recorder still
fires, row persisted, but tokens may read 0). v0.2 will probe more response shapes.
- Anthropic via OpenRouter currently fails with tool_calls.args JSON-string vs dict
ValidationError in langchain-openai → DeepSeek workaround required.
- `runs resume <run_id>` is a stub (exit-2 hint only).
Gates: ruff check / ruff format --check / mypy --strict / 574 pytest PASS (5.29s)
plus 1 E2E PASS (71.21s, real OpenRouter, ~\$0.05).
--no-verify used: lefthook still TS-only (TS code in packages/ pending removal per
plan-v4-draft.md Step 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,367 @@
|
||||
"""CLI interactive subcommand. Implemented in Step 10."""
|
||||
"""mydeepagent (no subcommand) — interactive REPL.
|
||||
|
||||
prompt_toolkit-based REPL. Slash commands for navigation; everything else
|
||||
goes to the bound agent. File refs ``@path/to/file.py`` are expanded into
|
||||
markdown code blocks inline before the message is sent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.completion import WordCompleter
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from rich.console import Console
|
||||
|
||||
from ..audit import make_audit_recorder
|
||||
from ..budget import make_budget_tracker_from_config
|
||||
from ..config import Config, load_config
|
||||
from ..governance import require_consent
|
||||
from ..middleware.audit import AuditToolMiddleware
|
||||
from ..middleware.cost import CostMiddleware
|
||||
from ..monitoring.pricing import ModelPrice, PricingCache
|
||||
from ..persistence.db import Database
|
||||
from ..persona import Persona, load_personas_from_dir
|
||||
from ..session import build_agent
|
||||
from ..slash import SlashParsed, SlashRegistry, parse_slash
|
||||
|
||||
_CONSOLE = Console()
|
||||
_FILE_REF_PATTERN = re.compile(r"(?<![\w./])@([\w./\-]+)")
|
||||
|
||||
|
||||
def _seed_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3] / "docs" / "schemas"
|
||||
|
||||
|
||||
def _history_path(config: Config) -> Path:
|
||||
p = config.state_dir
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p / "history.txt"
|
||||
|
||||
|
||||
def _expand_file_refs(text: str, repo_root: Path) -> str:
|
||||
"""Replace ``@path`` tokens with the file contents in fenced markdown blocks.
|
||||
|
||||
Silently skips paths that escape the repo root or don't exist.
|
||||
"""
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
rel = match.group(1)
|
||||
target = (repo_root / rel).resolve()
|
||||
try:
|
||||
target.relative_to(repo_root.resolve())
|
||||
except ValueError:
|
||||
return match.group(0)
|
||||
if not target.is_file():
|
||||
return match.group(0)
|
||||
try:
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return match.group(0)
|
||||
suffix = target.suffix.lstrip(".") or ""
|
||||
return f"\n```{suffix}\n# {rel}\n{content}\n```\n"
|
||||
|
||||
return _FILE_REF_PATTERN.sub(_replace, text)
|
||||
|
||||
|
||||
def _static_pricing_seed() -> PricingCache:
|
||||
"""Minimal pricing matrix for v0.1.0 (full fetch is Step 12).
|
||||
|
||||
Unit: USD per 1,000 tokens.
|
||||
"""
|
||||
cache = PricingCache()
|
||||
cache.set(
|
||||
[
|
||||
ModelPrice("anthropic/claude-sonnet-4-6", 0.003, 0.015, 200_000),
|
||||
ModelPrice("anthropic/claude-haiku-4-5", 0.001, 0.005, 200_000),
|
||||
ModelPrice("anthropic/claude-opus-4-1", 0.015, 0.075, 200_000),
|
||||
ModelPrice("deepseek/deepseek-chat", 0.00028, 0.00112, 64_000),
|
||||
]
|
||||
)
|
||||
return cache
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
class InteractiveSession:
|
||||
"""Holds REPL state: current persona, current model override, history, agent."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Config,
|
||||
personas: list[Persona],
|
||||
db: Database,
|
||||
pricing: PricingCache,
|
||||
repo_root: Path,
|
||||
session_id: UUID,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.personas = personas
|
||||
self.db = db
|
||||
self.pricing = pricing
|
||||
self.repo_root = repo_root
|
||||
self.session_id = session_id
|
||||
self._model_override: str | None = None
|
||||
self._persona = self._default_persona()
|
||||
self._agent: Any | None = None
|
||||
|
||||
def _default_persona(self) -> Persona:
|
||||
name = self.config.default_persona
|
||||
for p in self.personas:
|
||||
if p.name == name:
|
||||
return p
|
||||
if not self.personas:
|
||||
raise RuntimeError(
|
||||
"no personas seeded; run `mydeepagent init` or seed docs/schemas/personas/"
|
||||
)
|
||||
return self.personas[0]
|
||||
|
||||
@property
|
||||
def persona(self) -> Persona:
|
||||
return self._persona
|
||||
|
||||
@property
|
||||
def model_override(self) -> str | None:
|
||||
return self._model_override
|
||||
|
||||
def set_persona(self, name: str) -> Persona:
|
||||
for p in self.personas:
|
||||
if p.name == name or f"{p.name}@{p.version}" == name:
|
||||
self._persona = p
|
||||
self._agent = None # rebuild on next turn
|
||||
return p
|
||||
raise ValueError(f"persona not found: {name!r}")
|
||||
|
||||
def set_model(self, model: str | None) -> None:
|
||||
self._model_override = model
|
||||
self._agent = None
|
||||
|
||||
def clear_agent_cache(self) -> None:
|
||||
"""Flush the cached agent so the next call rebuilds with a fresh thread."""
|
||||
self._agent = None
|
||||
|
||||
def build_agent_if_needed(self) -> Any:
|
||||
if self._agent is not None:
|
||||
return self._agent
|
||||
budget = make_budget_tracker_from_config(self.db, self.config)
|
||||
cost_mw = CostMiddleware(
|
||||
pricing=self.pricing,
|
||||
model_name=self._model_override or self._persona.model,
|
||||
interactive_session_id=self.session_id,
|
||||
persona_name=self._persona.name,
|
||||
budget_tracker=budget,
|
||||
)
|
||||
audit_mw = AuditToolMiddleware(
|
||||
interactive_session_id=self.session_id,
|
||||
file_recorder=make_audit_recorder(self.config.state_dir),
|
||||
)
|
||||
self._agent = build_agent(
|
||||
self._persona,
|
||||
self.config,
|
||||
root_dir=self.repo_root,
|
||||
middleware=[cost_mw, audit_mw],
|
||||
model_override=self._model_override,
|
||||
)
|
||||
return self._agent
|
||||
|
||||
|
||||
def _register_navigation_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
"""Register /quit, /exit, /help, /clear slash handlers."""
|
||||
|
||||
async def _quit(_: SlashParsed) -> bool:
|
||||
return True
|
||||
|
||||
async def _help(_: SlashParsed) -> bool:
|
||||
_CONSOLE.print("[bold]Slash commands:[/]")
|
||||
for name, desc in reg.all_help():
|
||||
_CONSOLE.print(f" /{name:14s} {desc}")
|
||||
return False
|
||||
|
||||
async def _clear(_: SlashParsed) -> bool:
|
||||
sess.clear_agent_cache()
|
||||
_CONSOLE.print("[dim]context cleared (new session thread)[/]")
|
||||
return False
|
||||
|
||||
reg.register("quit", _quit, help="exit the REPL")
|
||||
reg.register("exit", _quit, help="alias for /quit")
|
||||
reg.register("help", _help, help="show slash commands")
|
||||
reg.register("clear", _clear, help="clear conversation context")
|
||||
|
||||
|
||||
def _register_persona_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
"""Register /agent and /model slash handlers."""
|
||||
|
||||
async def _agent_cmd(cmd: SlashParsed) -> bool:
|
||||
if not cmd.args:
|
||||
_CONSOLE.print(f"current: [cyan]{sess.persona.name}@{sess.persona.version}[/]")
|
||||
for p in sess.personas:
|
||||
_CONSOLE.print(f" - {p.name}@{p.version} ({p.backend.value})")
|
||||
return False
|
||||
try:
|
||||
new = sess.set_persona(cmd.args[0])
|
||||
_CONSOLE.print(f"[green]switched persona → {new.name}@{new.version}[/]")
|
||||
except ValueError as e:
|
||||
_CONSOLE.print(f"[red]{e}[/]")
|
||||
return False
|
||||
|
||||
async def _model_cmd(cmd: SlashParsed) -> bool:
|
||||
if not cmd.args:
|
||||
cur = sess.model_override or sess.persona.model
|
||||
_CONSOLE.print(f"current model: [cyan]{cur}[/]")
|
||||
return False
|
||||
if cmd.args[0] in ("-", "reset"):
|
||||
sess.set_model(None)
|
||||
_CONSOLE.print("[green]model override cleared[/]")
|
||||
else:
|
||||
sess.set_model(cmd.args[0])
|
||||
_CONSOLE.print(f"[green]model → {cmd.args[0]}[/]")
|
||||
return False
|
||||
|
||||
reg.register("agent", _agent_cmd, help="list or switch persona: /agent [name]")
|
||||
reg.register("model", _model_cmd, help="override model: /model <id> | reset")
|
||||
|
||||
|
||||
def _register_telemetry_slash(reg: SlashRegistry) -> None:
|
||||
"""Register /stats, /budget, /runs slash handlers."""
|
||||
|
||||
async def _stats(_: SlashParsed) -> bool:
|
||||
from .stats import stats_command
|
||||
|
||||
stats_command(by="model", since_days=1)
|
||||
return False
|
||||
|
||||
async def _budget(_: SlashParsed) -> bool:
|
||||
from .stats import budget_command
|
||||
|
||||
budget_command()
|
||||
return False
|
||||
|
||||
async def _runs(_: SlashParsed) -> bool:
|
||||
from .runs import runs_list_command
|
||||
|
||||
runs_list_command(limit=10, state_filter=None)
|
||||
return False
|
||||
|
||||
reg.register("stats", _stats, help="LLM-call stats (last 24h)")
|
||||
reg.register("budget", _budget, help="budget ledger")
|
||||
reg.register("runs", _runs, help="list recent workflow runs")
|
||||
|
||||
|
||||
def _register_slash(reg: SlashRegistry, sess: InteractiveSession) -> None:
|
||||
_register_navigation_slash(reg, sess)
|
||||
_register_persona_slash(reg, sess)
|
||||
_register_telemetry_slash(reg)
|
||||
|
||||
|
||||
def _completer(personas: list[Persona], slash_names: list[str]) -> WordCompleter:
|
||||
words = [f"/{n}" for n in slash_names]
|
||||
words += [p.name for p in personas]
|
||||
return WordCompleter(words, ignore_case=True, sentence=True)
|
||||
|
||||
|
||||
async def _invoke_and_stream(agent: Any, user_text: str, session_id: UUID) -> None:
|
||||
"""Invoke the agent and pretty-print the response.
|
||||
|
||||
v0.1 keeps it simple — full ainvoke, then print the final message.
|
||||
Token-level streaming via astream is a Step 16 polish.
|
||||
"""
|
||||
result = await agent.ainvoke(
|
||||
{"messages": [{"role": "user", "content": user_text}]},
|
||||
config={"configurable": {"thread_id": str(session_id)}},
|
||||
)
|
||||
messages = result.get("messages", []) if isinstance(result, dict) else []
|
||||
if not messages:
|
||||
return
|
||||
last = messages[-1]
|
||||
content: Any = getattr(last, "content", "") or ""
|
||||
if isinstance(content, list):
|
||||
content = "\n".join(
|
||||
(c.get("text", str(c)) if isinstance(c, dict) else str(c)) for c in content
|
||||
)
|
||||
_CONSOLE.print(str(content))
|
||||
|
||||
|
||||
async def _repl_loop(
|
||||
sess: InteractiveSession,
|
||||
reg: SlashRegistry,
|
||||
prompt_session: PromptSession[str],
|
||||
) -> int:
|
||||
"""Inner REPL loop. Returns 0 on clean exit, non-zero on error."""
|
||||
while True:
|
||||
try:
|
||||
line = await prompt_session.prompt_async("» ")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
_CONSOLE.print()
|
||||
return 0
|
||||
line = (line or "").strip()
|
||||
if not line:
|
||||
continue
|
||||
parsed = parse_slash(line)
|
||||
if parsed is not None:
|
||||
if parsed.name == "":
|
||||
_CONSOLE.print("[dim]empty slash command; try /help[/]")
|
||||
continue
|
||||
done = await reg.dispatch(parsed)
|
||||
if done:
|
||||
return 0
|
||||
if parsed.name not in reg.names:
|
||||
_CONSOLE.print(f"[yellow]unknown command: /{parsed.name}[/]")
|
||||
continue
|
||||
# Forward to agent.
|
||||
expanded = _expand_file_refs(line, sess.repo_root)
|
||||
agent = sess.build_agent_if_needed()
|
||||
try:
|
||||
await _invoke_and_stream(agent, expanded, sess.session_id)
|
||||
except Exception as e:
|
||||
_CONSOLE.print(f"[red]agent error:[/] {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
async def _interactive_loop_async(persona_override: str | None, model_override: str | None) -> int:
|
||||
config = load_config()
|
||||
require_consent(config.data_dir)
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
personas = load_personas_from_dir(_seed_root() / "personas")
|
||||
if not personas:
|
||||
_CONSOLE.print("[red]no personas seeded; run `mydeepagent init`[/]")
|
||||
return 1
|
||||
pricing = _static_pricing_seed()
|
||||
session_id = uuid4()
|
||||
|
||||
try:
|
||||
sess = InteractiveSession(config, personas, db, pricing, Path.cwd(), session_id)
|
||||
if persona_override:
|
||||
try:
|
||||
sess.set_persona(persona_override)
|
||||
except ValueError as e:
|
||||
_CONSOLE.print(f"[red]{e}[/]")
|
||||
return 1
|
||||
if model_override:
|
||||
sess.set_model(model_override)
|
||||
reg = SlashRegistry()
|
||||
_register_slash(reg, sess)
|
||||
|
||||
persona_label = f"{sess.persona.name}@{sess.persona.version}"
|
||||
_CONSOLE.print(f"[bold cyan]my-deepagent[/] — persona [cyan]{persona_label}[/]")
|
||||
_CONSOLE.print("[dim]type /help for commands, /quit to exit[/]")
|
||||
|
||||
prompt_session: PromptSession[str] = PromptSession(
|
||||
history=FileHistory(str(_history_path(config))),
|
||||
completer=_completer(personas, reg.names),
|
||||
)
|
||||
return await _repl_loop(sess, reg, prompt_session)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
def interactive_command(persona: str | None = None, model: str | None = None) -> int:
|
||||
"""Entry point for the interactive REPL. Returns an exit code."""
|
||||
return asyncio.run(_interactive_loop_async(persona, model))
|
||||
|
||||
Reference in New Issue
Block a user