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,244 @@
|
||||
"""CLI doctor command for environment diagnostics. Implemented in Step 12."""
|
||||
"""mydeepagent doctor — full 8-check environment diagnostic.
|
||||
|
||||
Checks:
|
||||
1. Python 3.12+ <3.14
|
||||
2. uv >= 0.5
|
||||
3. git >= 2.40
|
||||
4. WORKSPACE_ROOT writable
|
||||
5. config + governance consent
|
||||
6. OpenRouter API key reachable
|
||||
7. OpenRouter /models ping + pricing matrix upsert
|
||||
8. Disk free + SQLite integrity_check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
from ..config import Config, load_config
|
||||
from ..errors import MyDeepAgentError
|
||||
from ..governance import has_consent
|
||||
from ..i18n import t
|
||||
from ..monitoring.pricing import (
|
||||
ModelPrice,
|
||||
fetch_openrouter_pricing,
|
||||
)
|
||||
from ..persistence.db import Database
|
||||
from ..persistence.models import ModelPricingRow
|
||||
from ..secrets import resolve_openrouter_api_key
|
||||
|
||||
_CONSOLE = Console()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckResult:
|
||||
name: str
|
||||
status: Literal["ok", "warn", "fail"]
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def _check_python() -> CheckResult:
|
||||
if (3, 12) <= sys.version_info[:2] < (3, 14):
|
||||
return CheckResult("python", "ok", f"v{sys.version.split()[0]}")
|
||||
return CheckResult(
|
||||
"python",
|
||||
"fail",
|
||||
f"need 3.12<=x<3.14, got {sys.version.split()[0]}",
|
||||
)
|
||||
|
||||
|
||||
def _check_uv() -> CheckResult:
|
||||
path = shutil.which("uv")
|
||||
if not path:
|
||||
return CheckResult("uv", "warn", "not on PATH (only needed for dev workflows)")
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603
|
||||
[path, "--version"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return CheckResult("uv", "warn", f"version probe failed: {e}")
|
||||
version = result.stdout.strip()
|
||||
return CheckResult("uv", "ok", version or path)
|
||||
|
||||
|
||||
def _check_git() -> CheckResult:
|
||||
path = shutil.which("git")
|
||||
if not path:
|
||||
return CheckResult("git", "warn", "not on PATH (workflows may use git tools)")
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603
|
||||
[path, "--version"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
return CheckResult("git", "warn", f"version probe failed: {e}")
|
||||
return CheckResult("git", "ok", result.stdout.strip())
|
||||
|
||||
|
||||
def _check_workspace(config: Config) -> CheckResult:
|
||||
root = config.workspace_root
|
||||
if not root.exists():
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
return CheckResult("workspace_root", "fail", f"cannot create: {e}")
|
||||
try:
|
||||
probe = root / ".doctor_probe"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except OSError as e:
|
||||
return CheckResult("workspace_root", "fail", f"not writable: {e}")
|
||||
return CheckResult("workspace_root", "ok", str(root))
|
||||
|
||||
|
||||
def _check_config_and_governance(config: Config) -> CheckResult:
|
||||
if not has_consent(config.data_dir):
|
||||
return CheckResult(
|
||||
"config+governance",
|
||||
"fail",
|
||||
"governance not accepted — run `mydeepagent init`",
|
||||
)
|
||||
return CheckResult("config+governance", "ok", f"data_dir={config.data_dir}")
|
||||
|
||||
|
||||
def _check_openrouter_api_key(config: Config) -> CheckResult:
|
||||
try:
|
||||
key = resolve_openrouter_api_key(config)
|
||||
except MyDeepAgentError as e:
|
||||
hint = e.recovery_hint or str(e)
|
||||
return CheckResult("openrouter_api_key", "fail", f"missing: {hint}")
|
||||
return CheckResult("openrouter_api_key", "ok", f"resolved ({len(key)} chars)")
|
||||
|
||||
|
||||
async def _check_openrouter_ping_and_upsert(config: Config) -> CheckResult:
|
||||
try:
|
||||
key = resolve_openrouter_api_key(config)
|
||||
except MyDeepAgentError:
|
||||
return CheckResult("openrouter_ping", "warn", "skipped — no API key (see previous check)")
|
||||
try:
|
||||
prices = await fetch_openrouter_pricing(key, config.openrouter_base_url)
|
||||
except MyDeepAgentError as e:
|
||||
return CheckResult("openrouter_ping", "warn", f"fetch failed: {e}")
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
return CheckResult("openrouter_ping", "fail", "401 — API key invalid")
|
||||
return CheckResult("openrouter_ping", "warn", f"http {e.response.status_code}")
|
||||
if not prices:
|
||||
return CheckResult("openrouter_ping", "warn", "no models in response payload")
|
||||
await _upsert_pricing(config, prices)
|
||||
return CheckResult("openrouter_ping", "ok", f"{len(prices)} models cached")
|
||||
|
||||
|
||||
async def _upsert_pricing(config: Config, prices: list[ModelPrice]) -> None:
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
now = datetime.now(UTC).isoformat(timespec="seconds")
|
||||
try:
|
||||
async with db.session() as s:
|
||||
for p in prices:
|
||||
stmt = (
|
||||
sqlite_insert(ModelPricingRow)
|
||||
.values(
|
||||
model=p.model,
|
||||
input_per_1k_usd=p.input_per_1k_usd,
|
||||
output_per_1k_usd=p.output_per_1k_usd,
|
||||
context_length=p.context_length,
|
||||
fetched_at=now,
|
||||
raw_payload="",
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
index_elements=["model"],
|
||||
set_={
|
||||
"input_per_1k_usd": p.input_per_1k_usd,
|
||||
"output_per_1k_usd": p.output_per_1k_usd,
|
||||
"context_length": p.context_length,
|
||||
"fetched_at": now,
|
||||
},
|
||||
)
|
||||
)
|
||||
await s.execute(stmt)
|
||||
await s.commit()
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
async def _check_disk_and_db(config: Config) -> CheckResult:
|
||||
usage = shutil.disk_usage(str(config.workspace_root))
|
||||
free_gb = usage.free / (1024**3)
|
||||
if free_gb < 2.0:
|
||||
disk_status: Literal["ok", "warn", "fail"] = "fail"
|
||||
elif free_gb < 10.0:
|
||||
disk_status = "warn"
|
||||
else:
|
||||
disk_status = "ok"
|
||||
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
async with db.session() as s:
|
||||
row = (await s.execute(sa_text("PRAGMA integrity_check"))).scalar_one()
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
db_ok = row == "ok"
|
||||
detail = f"free={free_gb:.1f}GB, sqlite_integrity={'ok' if db_ok else str(row)}"
|
||||
if disk_status == "fail" or not db_ok:
|
||||
final: Literal["ok", "warn", "fail"] = "fail"
|
||||
elif disk_status == "warn":
|
||||
final = "warn"
|
||||
else:
|
||||
final = "ok"
|
||||
return CheckResult("disk+db", final, detail)
|
||||
|
||||
|
||||
def doctor_command() -> None:
|
||||
asyncio.run(_doctor_async())
|
||||
|
||||
|
||||
async def _doctor_async() -> None:
|
||||
try:
|
||||
config = load_config()
|
||||
except MyDeepAgentError as e:
|
||||
_CONSOLE.print(f"[red]config load failed: {e}[/]")
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
checks: list[CheckResult] = []
|
||||
checks.append(_check_python())
|
||||
checks.append(_check_uv())
|
||||
checks.append(_check_git())
|
||||
checks.append(_check_workspace(config))
|
||||
checks.append(_check_config_and_governance(config))
|
||||
checks.append(_check_openrouter_api_key(config))
|
||||
checks.append(await _check_openrouter_ping_and_upsert(config))
|
||||
checks.append(await _check_disk_and_db(config))
|
||||
|
||||
_render(checks)
|
||||
|
||||
has_fail = any(c.status == "fail" for c in checks)
|
||||
if has_fail:
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def _render(checks: list[CheckResult]) -> None:
|
||||
title = t("doctor.header") or "Environment diagnostics:"
|
||||
table = Table(title=title)
|
||||
table.add_column("Check")
|
||||
table.add_column("Status")
|
||||
table.add_column("Detail")
|
||||
color_map: dict[str, str] = {"ok": "green", "warn": "yellow", "fail": "red"}
|
||||
for c in checks:
|
||||
color = color_map[c.status]
|
||||
table.add_row(c.name, f"[{color}]{c.status}[/]", c.detail)
|
||||
_CONSOLE.print(table)
|
||||
|
||||
39
my-deepagent/src/my_deepagent/cli/init.py
Normal file
39
my-deepagent/src/my_deepagent/cli/init.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""mydeepagent init: first-run wizard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from ..config import load_config
|
||||
from ..governance import has_consent, record_consent
|
||||
from ..i18n import t
|
||||
from ..keys import set_api_key
|
||||
from .doctor import doctor_command
|
||||
|
||||
_CONSOLE = Console()
|
||||
|
||||
|
||||
def init_command() -> None:
|
||||
config = load_config()
|
||||
_CONSOLE.print(f"[bold cyan]{t('init.welcome')}[/]")
|
||||
_CONSOLE.print()
|
||||
if not has_consent(config.data_dir):
|
||||
_CONSOLE.print(f"[yellow]{t('init.governance_title')}[/]")
|
||||
_CONSOLE.print(t("init.governance_body"))
|
||||
answer = typer.prompt(t("init.governance_prompt"))
|
||||
if answer.strip().lower() != "yes":
|
||||
_CONSOLE.print(f"[red]{t('init.governance_declined')}[/]")
|
||||
raise typer.Exit(code=1)
|
||||
record_consent(config.data_dir)
|
||||
api_key = typer.prompt(t("init.api_key_prompt"), hide_input=True, default="")
|
||||
if api_key.strip():
|
||||
set_api_key("openrouter", api_key.strip())
|
||||
_CONSOLE.print(f"[green]{t('init.api_key_saved')}[/]")
|
||||
else:
|
||||
_CONSOLE.print(f"[yellow]{t('init.api_key_empty')}[/]")
|
||||
_CONSOLE.print()
|
||||
_CONSOLE.print(t("init.doctor_running"))
|
||||
doctor_command()
|
||||
_CONSOLE.print()
|
||||
_CONSOLE.print(f"[bold green]{t('init.done')}[/]")
|
||||
@@ -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))
|
||||
|
||||
40
my-deepagent/src/my_deepagent/cli/keys_cmd.py
Normal file
40
my-deepagent/src/my_deepagent/cli/keys_cmd.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""login / logout / keys list commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from ..i18n import t
|
||||
from ..keys import delete_api_key, get_api_key, list_providers, mask, set_api_key
|
||||
|
||||
_CONSOLE = Console()
|
||||
|
||||
|
||||
def login_command(provider: str) -> None:
|
||||
value = typer.prompt(t("login.prompt", provider=provider), hide_input=True, default="")
|
||||
if not value.strip():
|
||||
_CONSOLE.print(f"[yellow]{t('login.empty')}[/]")
|
||||
raise typer.Exit(code=1)
|
||||
set_api_key(provider, value.strip())
|
||||
_CONSOLE.print(f"[green]{t('login.saved', provider=provider)}[/]")
|
||||
|
||||
|
||||
def logout_command(provider: str) -> None:
|
||||
removed = delete_api_key(provider)
|
||||
if removed:
|
||||
_CONSOLE.print(f"[green]{t('logout.removed', provider=provider)}[/]")
|
||||
else:
|
||||
_CONSOLE.print(f"[yellow]{t('logout.not_found', provider=provider)}[/]")
|
||||
|
||||
|
||||
def keys_list_command() -> None:
|
||||
_CONSOLE.print(t("keys.header"))
|
||||
found = False
|
||||
for provider in list_providers():
|
||||
value = get_api_key(provider)
|
||||
if value:
|
||||
_CONSOLE.print(t("keys.entry", provider=provider, masked=mask(value)))
|
||||
found = True
|
||||
if not found:
|
||||
_CONSOLE.print(t("keys.none"))
|
||||
@@ -1 +1,150 @@
|
||||
"""Typer CLI entry point. Filled in Step 6."""
|
||||
"""my-deepagent CLI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from .doctor import doctor_command
|
||||
from .init import init_command
|
||||
from .keys_cmd import keys_list_command, login_command, logout_command
|
||||
|
||||
app = typer.Typer(no_args_is_help=False, add_completion=True)
|
||||
|
||||
runs_app = typer.Typer(help="Inspect or resume past runs.")
|
||||
|
||||
|
||||
@runs_app.command("list")
|
||||
def runs_list(
|
||||
limit: int = typer.Option(20, help="Number of runs to show"),
|
||||
state: str | None = typer.Option(None, help="Filter by state"),
|
||||
) -> None:
|
||||
"""List recent runs."""
|
||||
from .runs import runs_list_command
|
||||
|
||||
runs_list_command(limit, state)
|
||||
|
||||
|
||||
@runs_app.command("show")
|
||||
def runs_show(run_id: str = typer.Argument(...)) -> None:
|
||||
"""Show details for a specific run."""
|
||||
from .runs import runs_show_command
|
||||
|
||||
runs_show_command(run_id)
|
||||
|
||||
|
||||
@runs_app.command("resume")
|
||||
def runs_resume(run_id: str = typer.Argument(...)) -> None:
|
||||
"""Resume a paused run (v0.1.0: not implemented — shows status only)."""
|
||||
from .runs import runs_resume_command
|
||||
|
||||
runs_resume_command(run_id)
|
||||
|
||||
|
||||
app.add_typer(runs_app, name="runs")
|
||||
|
||||
|
||||
@app.command()
|
||||
def init() -> None:
|
||||
"""First-run setup: governance consent + API key + doctor."""
|
||||
init_command()
|
||||
|
||||
|
||||
@app.command()
|
||||
def login(provider: str = typer.Argument("openrouter")) -> None:
|
||||
"""Store an API key for the given provider in the OS keyring."""
|
||||
login_command(provider)
|
||||
|
||||
|
||||
@app.command()
|
||||
def logout(provider: str = typer.Argument("openrouter")) -> None:
|
||||
"""Remove a stored API key from the OS keyring."""
|
||||
logout_command(provider)
|
||||
|
||||
|
||||
@app.command(name="keys")
|
||||
def keys_list() -> None:
|
||||
"""List registered providers (masked)."""
|
||||
keys_list_command()
|
||||
|
||||
|
||||
@app.command()
|
||||
def doctor() -> None:
|
||||
"""Run environment diagnostics (Python/uv/disk for v0.1.0; full suite in Step 12)."""
|
||||
doctor_command()
|
||||
|
||||
|
||||
@app.command(name="run")
|
||||
def run(
|
||||
workflow_path: Path = typer.Argument(..., help="Path to the workflow yaml"), # noqa: B008
|
||||
repo: Path = typer.Option(Path.cwd(), help="Repo root"), # noqa: B008
|
||||
base_branch: str = typer.Option("main", help="Base branch"),
|
||||
no_preview: bool = typer.Option(False, "--no-preview", help="Skip cost preview"),
|
||||
) -> None:
|
||||
"""Execute a workflow template end-to-end."""
|
||||
from .run import run_command
|
||||
|
||||
run_command(workflow_path, repo, base_branch, no_preview)
|
||||
|
||||
|
||||
@app.command()
|
||||
def stats(
|
||||
by: str = typer.Option("model", help="model | persona | day"),
|
||||
since_days: int = typer.Option(7, help="Window size in days"),
|
||||
) -> None:
|
||||
"""Aggregate LLM-call stats from the ledger."""
|
||||
from .stats import stats_command
|
||||
|
||||
stats_command(by, since_days)
|
||||
|
||||
|
||||
@app.command()
|
||||
def budget() -> None:
|
||||
"""Show the current budget ledger (per-scope spend / cap)."""
|
||||
from .stats import budget_command
|
||||
|
||||
budget_command()
|
||||
|
||||
|
||||
@app.command(name="costs")
|
||||
def costs() -> None:
|
||||
"""Alias for `stats --by day` over the last 30 days."""
|
||||
from .stats import stats_command
|
||||
|
||||
stats_command(by="day", since_days=30)
|
||||
|
||||
|
||||
@app.command(name="pricing")
|
||||
def pricing() -> None:
|
||||
"""Show cached OpenRouter pricing matrix (populated by `doctor`)."""
|
||||
from .stats import pricing_command
|
||||
|
||||
pricing_command()
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
ctx: typer.Context,
|
||||
agent: str | None = typer.Option(None, "--agent", help="Start with a specific persona"),
|
||||
model: str | None = typer.Option(None, "--model", help="Model override"),
|
||||
) -> None:
|
||||
from ..logging import configure_logging
|
||||
|
||||
try:
|
||||
from ..config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
configure_logging(level=cfg.log_level, json_output=False)
|
||||
except Exception:
|
||||
configure_logging(level="info", json_output=False)
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
from .interactive import interactive_command
|
||||
|
||||
code = interactive_command(agent, model)
|
||||
raise typer.Exit(code=code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
@@ -1 +1,194 @@
|
||||
"""CLI run command implementation. Implemented in Step 6."""
|
||||
"""mydeepagent run <workflow.yaml> — execute a workflow end-to-end."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..artifact_schema import ArtifactSchemaRegistry
|
||||
from ..binding import BackendAvailability, PersonaConsentStore, bind_personas
|
||||
from ..budget import BudgetTracker, make_budget_tracker_from_config
|
||||
from ..config import Config, load_config
|
||||
from ..engine import WorkflowEngine
|
||||
from ..enums import Backend
|
||||
from ..governance import require_consent
|
||||
from ..monitoring.cost_estimator import WorkflowCostEstimate, estimate_workflow
|
||||
from ..monitoring.pricing import ModelPrice, PricingCache
|
||||
from ..persistence.db import Database
|
||||
from ..persistence.models import ModelPricingRow
|
||||
from ..persona import load_personas_from_dir
|
||||
from ..tui.approval import cli_approval_callback
|
||||
from ..workflow import load_workflow_yaml
|
||||
|
||||
_CONSOLE = Console()
|
||||
|
||||
|
||||
def run_command(
|
||||
workflow_path: Path,
|
||||
repo: Path,
|
||||
base_branch: str,
|
||||
no_preview: bool = False,
|
||||
) -> None:
|
||||
"""Synchronous CLI wrapper for the async engine."""
|
||||
asyncio.run(_run_async(workflow_path, repo, base_branch, no_preview))
|
||||
|
||||
|
||||
async def cli_budget_prompt(scope: str, projected: float, cap: float) -> bool:
|
||||
"""Prompt the user to extend the budget cap when it is hit."""
|
||||
_CONSOLE.print()
|
||||
_CONSOLE.print(
|
||||
f"[yellow]Budget cap reached[/]: scope={scope} projected=${projected:.4f} cap=${cap:.4f}"
|
||||
)
|
||||
return typer.confirm("Extend cap and proceed?", default=False)
|
||||
|
||||
|
||||
def _static_pricing_seed_fallback() -> list[ModelPrice]:
|
||||
"""Return seed model prices used when the model_pricing DB table is empty.
|
||||
|
||||
Unit: USD per 1,000 tokens. (OpenRouter publishes per-token; we store per-1K to keep
|
||||
cost arithmetic in a more readable range. ``compute_cost(model, in, out)`` divides
|
||||
by 1000.)
|
||||
"""
|
||||
return [
|
||||
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),
|
||||
]
|
||||
|
||||
|
||||
async def _load_pricing_from_db(config: Config, db: Database) -> PricingCache:
|
||||
"""Load pricing from the persisted model_pricing table.
|
||||
|
||||
Falls back to the static seed when the table is empty (doctor not yet run).
|
||||
"""
|
||||
async with db.session() as s:
|
||||
rows = list((await s.execute(select(ModelPricingRow))).scalars().all())
|
||||
cache = PricingCache()
|
||||
if rows:
|
||||
cache.set(
|
||||
[
|
||||
ModelPrice(
|
||||
model=r.model,
|
||||
input_per_1k_usd=r.input_per_1k_usd,
|
||||
output_per_1k_usd=r.output_per_1k_usd,
|
||||
context_length=r.context_length,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
)
|
||||
return cache
|
||||
cache.set(_static_pricing_seed_fallback())
|
||||
return cache
|
||||
|
||||
|
||||
def _print_preview(estimate: WorkflowCostEstimate, config: object) -> None:
|
||||
cfg: Config = config # type: ignore[assignment]
|
||||
table = Table(title="Cost preview")
|
||||
table.add_column("Phase")
|
||||
table.add_column("Persona")
|
||||
table.add_column("Model")
|
||||
table.add_column("In/Out tokens", justify="right")
|
||||
table.add_column("Est. cost", justify="right")
|
||||
for p in estimate.phases:
|
||||
cost_str = f"${p.estimated_cost_usd:.4f}"
|
||||
table.add_row(
|
||||
p.phase_key,
|
||||
p.persona_name,
|
||||
p.model,
|
||||
f"{p.estimated_input_tokens}/{p.estimated_output_tokens}",
|
||||
cost_str,
|
||||
)
|
||||
_CONSOLE.print(table)
|
||||
_CONSOLE.print(f"Total estimated: [bold]${estimate.total_usd:.4f}[/]")
|
||||
_CONSOLE.print(
|
||||
f"Run cap: [bold]${cfg.budget_run_usd}[/] | Daily cap: [bold]${cfg.budget_daily_usd}[/]"
|
||||
)
|
||||
|
||||
|
||||
async def _run_async(
|
||||
workflow_path: Path,
|
||||
repo: Path,
|
||||
base_branch: str,
|
||||
no_preview: bool,
|
||||
) -> None:
|
||||
config = load_config()
|
||||
require_consent(config.data_dir)
|
||||
|
||||
template = load_workflow_yaml(workflow_path)
|
||||
|
||||
# Locate seed schemas relative to the installed package root
|
||||
seed_root = Path(__file__).resolve().parents[3] / "docs" / "schemas"
|
||||
personas_dir = seed_root / "personas"
|
||||
artifacts_root = seed_root / "artifacts"
|
||||
|
||||
personas = load_personas_from_dir(personas_dir)
|
||||
registry = ArtifactSchemaRegistry(roots=[artifacts_root])
|
||||
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
|
||||
# Crash recovery: mark non-terminal runs from a previous process as failed
|
||||
# so the active-run uniqueness slot is freed before starting new work.
|
||||
from ..recovery import sweep_orphan_runs
|
||||
|
||||
report = await sweep_orphan_runs(db)
|
||||
if report.total:
|
||||
_CONSOLE.print(
|
||||
f"[yellow]recovery: marked {len(report.failed_runs)} orphan run(s) "
|
||||
f"and {len(report.failed_phases)} phase(s) as failed[/]"
|
||||
)
|
||||
|
||||
try:
|
||||
consent_store = PersonaConsentStore(config.data_dir / "persona-consents.json")
|
||||
bindings = bind_personas(
|
||||
template,
|
||||
personas,
|
||||
BackendAvailability(available_backends=frozenset(Backend)),
|
||||
consent_store,
|
||||
)
|
||||
|
||||
# Pricing + cost preview — use DB-cached prices; fall back to static seed
|
||||
pricing = await _load_pricing_from_db(config, db)
|
||||
|
||||
if not no_preview:
|
||||
estimate = estimate_workflow(template, bindings, pricing)
|
||||
_print_preview(estimate, config)
|
||||
if not typer.confirm("Proceed?", default=True):
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
budget: BudgetTracker = make_budget_tracker_from_config(
|
||||
db, config, prompt_callback=cli_budget_prompt
|
||||
)
|
||||
await budget.init()
|
||||
|
||||
engine = WorkflowEngine(
|
||||
db=db,
|
||||
config=config,
|
||||
persona_pool=personas,
|
||||
artifact_registry=registry,
|
||||
consent_store=consent_store,
|
||||
available_backends=BackendAvailability(available_backends=frozenset(Backend)),
|
||||
approval_callback=cli_approval_callback,
|
||||
budget_tracker=budget,
|
||||
pricing=pricing,
|
||||
)
|
||||
engine.install_signal_handlers()
|
||||
result = await engine.run(
|
||||
template,
|
||||
repo_path=repo,
|
||||
base_branch=base_branch,
|
||||
)
|
||||
_CONSOLE.print(f"[bold]{result.state.value}[/] run_id={result.run_id}")
|
||||
if result.final_report_path:
|
||||
_CONSOLE.print(f"report: {result.final_report_path}")
|
||||
if result.error:
|
||||
_CONSOLE.print(f"[red]error[/]: {result.error}")
|
||||
raise typer.Exit(code=1)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
204
my-deepagent/src/my_deepagent/cli/runs.py
Normal file
204
my-deepagent/src/my_deepagent/cli/runs.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""mydeepagent runs list / show / resume — read-only-ish run history queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from sqlalchemy import desc, select
|
||||
|
||||
from ..config import load_config
|
||||
from ..persistence.db import Database
|
||||
from ..persistence.models import (
|
||||
ArtifactRow,
|
||||
RunEventRow,
|
||||
RunPhaseRow,
|
||||
RunRow,
|
||||
)
|
||||
|
||||
_CONSOLE = Console()
|
||||
|
||||
|
||||
def runs_list_command(limit: int = 20, state_filter: str | None = None) -> None:
|
||||
asyncio.run(_runs_list_async(limit, state_filter))
|
||||
|
||||
|
||||
def runs_show_command(run_id: str) -> None:
|
||||
asyncio.run(_runs_show_async(run_id))
|
||||
|
||||
|
||||
def runs_resume_command(run_id: str) -> None:
|
||||
asyncio.run(_runs_resume_async(run_id))
|
||||
|
||||
|
||||
async def _runs_list_async(limit: int, state_filter: str | None) -> None:
|
||||
config = load_config()
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
async with db.session() as s:
|
||||
stmt = select(RunRow).order_by(desc(RunRow.created_at)).limit(limit)
|
||||
if state_filter:
|
||||
stmt = stmt.where(RunRow.state == state_filter)
|
||||
rows = (await s.execute(stmt)).scalars().all()
|
||||
if not rows:
|
||||
_CONSOLE.print("[dim](no runs)[/]")
|
||||
return
|
||||
table = Table(title=f"Recent runs (latest {len(rows)})")
|
||||
table.add_column("Run ID")
|
||||
table.add_column("State")
|
||||
table.add_column("Repo")
|
||||
table.add_column("Branch")
|
||||
table.add_column("Created")
|
||||
table.add_column("Ended")
|
||||
for r in rows:
|
||||
table.add_row(
|
||||
str(r.id)[:8] + "…",
|
||||
r.state,
|
||||
Path(r.repo_path).name,
|
||||
r.base_branch,
|
||||
(r.created_at or "")[:19],
|
||||
(r.ended_at or "—")[:19] if r.ended_at else "—",
|
||||
)
|
||||
_CONSOLE.print(table)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
async def _runs_show_async(run_id: str) -> None:
|
||||
full_id = await _resolve_run_id(run_id)
|
||||
config = load_config()
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
async with db.session() as s:
|
||||
run = await s.get(RunRow, full_id)
|
||||
if run is None:
|
||||
_CONSOLE.print(f"[red]run not found:[/] {run_id}")
|
||||
raise typer.Exit(code=1)
|
||||
phases = (
|
||||
(
|
||||
await s.execute(
|
||||
select(RunPhaseRow)
|
||||
.where(RunPhaseRow.run_id == full_id)
|
||||
.order_by(RunPhaseRow.seq)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
artifacts = (
|
||||
(await s.execute(select(ArtifactRow).where(ArtifactRow.run_id == full_id)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
events = (
|
||||
(
|
||||
await s.execute(
|
||||
select(RunEventRow)
|
||||
.where(RunEventRow.run_id == full_id)
|
||||
.order_by(RunEventRow.seq)
|
||||
.limit(50)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
_CONSOLE.print(f"[bold]Run {run.id}[/]")
|
||||
_CONSOLE.print(f" state: [cyan]{run.state}[/]")
|
||||
_CONSOLE.print(f" repo: {run.repo_path}@{run.base_branch}")
|
||||
_CONSOLE.print(f" worktree: {run.worktree_root}")
|
||||
_CONSOLE.print(f" created: {run.created_at}")
|
||||
_CONSOLE.print(f" ended: {run.ended_at or '—'}")
|
||||
if run.final_report_path:
|
||||
_CONSOLE.print(f" report: {run.final_report_path}")
|
||||
_CONSOLE.print()
|
||||
_CONSOLE.print("[bold]Phases[/]")
|
||||
for ph in phases:
|
||||
_CONSOLE.print(f" - {ph.phase_key:20s} state={ph.state:15s} attempts={ph.attempts}")
|
||||
if artifacts:
|
||||
_CONSOLE.print()
|
||||
_CONSOLE.print("[bold]Artifacts[/]")
|
||||
for a in artifacts:
|
||||
_CONSOLE.print(f" - {a.path} (schema={a.schema_id}, valid={a.valid})")
|
||||
_CONSOLE.print()
|
||||
_CONSOLE.print(f"[bold]Events (last {len(events)})[/]")
|
||||
for ev in events:
|
||||
_CONSOLE.print(f" [{ev.seq:4d}] {ev.ts} {ev.type}")
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
async def _runs_resume_async(run_id: str) -> None:
|
||||
"""v0.1.0: resume is not implemented.
|
||||
|
||||
Surfaces the run state and hints at next steps. Future v0.2 implementation:
|
||||
rehydrate the workflow template by template_hash, replay phase loop from the
|
||||
first non-completed phase using the existing checkpointer.
|
||||
"""
|
||||
full_id = await _resolve_run_id(run_id)
|
||||
config = load_config()
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
async with db.session() as s:
|
||||
run = await s.get(RunRow, full_id)
|
||||
if run is None:
|
||||
_CONSOLE.print(f"[red]run not found:[/] {run_id}")
|
||||
raise typer.Exit(code=1)
|
||||
if run.state in ("completed", "failed", "aborted"):
|
||||
_CONSOLE.print(
|
||||
f"[yellow]Run {run.id} is already terminal ({run.state}). "
|
||||
"Start a fresh run with `mydeepagent run <workflow.yaml>`.[/]"
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
_CONSOLE.print(
|
||||
"[yellow]Resume is not implemented in v0.1.0. The crash-recovery sweep at startup "
|
||||
"marked this run as failed; relaunch the workflow with `mydeepagent run`.[/]"
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
async def _resolve_run_id(prefix_or_full: str) -> str:
|
||||
"""Accept either a full UUID or a 6+ char prefix and return the canonical full id."""
|
||||
try:
|
||||
return str(UUID(prefix_or_full))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if len(prefix_or_full) < 6:
|
||||
_CONSOLE.print(
|
||||
f"[red]ambiguous run id (need full UUID or >=6-char prefix):[/] {prefix_or_full}"
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
|
||||
config = load_config()
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
async with db.session() as s:
|
||||
rows = (
|
||||
(
|
||||
await s.execute(
|
||||
select(RunRow.id).where(RunRow.id.like(f"{prefix_or_full}%")).limit(2)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
_CONSOLE.print(f"[red]no run matches prefix:[/] {prefix_or_full}")
|
||||
raise typer.Exit(code=1)
|
||||
if len(rows) > 1:
|
||||
_CONSOLE.print(f"[red]ambiguous prefix matches >1 run:[/] {prefix_or_full}")
|
||||
raise typer.Exit(code=1)
|
||||
return rows[0]
|
||||
finally:
|
||||
await db.dispose()
|
||||
@@ -1 +1,179 @@
|
||||
"""CLI stats command for usage summary. Implemented in Step 12."""
|
||||
"""mydeepagent stats / costs / budget / pricing — read-only ledger + history queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ..config import load_config
|
||||
from ..persistence.db import Database
|
||||
from ..persistence.models import BudgetLedgerRow, LlmCallRow, ModelPricingRow
|
||||
|
||||
_CONSOLE = Console()
|
||||
|
||||
|
||||
def stats_command(by: str = "model", since_days: int = 7) -> None:
|
||||
"""Synchronous CLI wrapper for the async stats query."""
|
||||
asyncio.run(_stats_async(by, since_days))
|
||||
|
||||
|
||||
async def _stats_async(by: str, since_days: int) -> None:
|
||||
config = load_config()
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
since = (datetime.now(UTC) - timedelta(days=since_days)).isoformat(timespec="seconds")
|
||||
async with db.session() as s:
|
||||
if by == "model":
|
||||
rows: Sequence[Any] = (
|
||||
await s.execute(
|
||||
select(
|
||||
LlmCallRow.model,
|
||||
func.count().label("calls"),
|
||||
func.sum(LlmCallRow.input_tokens).label("input"),
|
||||
func.sum(LlmCallRow.output_tokens).label("output"),
|
||||
func.sum(LlmCallRow.cost_usd_total).label("cost"),
|
||||
)
|
||||
.where(LlmCallRow.ts >= since)
|
||||
.group_by(LlmCallRow.model)
|
||||
)
|
||||
).all()
|
||||
_render_stats_table(
|
||||
"Stats by model",
|
||||
rows,
|
||||
["Model", "Calls", "Input", "Output", "Cost ($)"],
|
||||
)
|
||||
elif by == "persona":
|
||||
rows = (
|
||||
await s.execute(
|
||||
select(
|
||||
LlmCallRow.persona_name,
|
||||
func.count().label("calls"),
|
||||
func.sum(LlmCallRow.cost_usd_total).label("cost"),
|
||||
)
|
||||
.where(LlmCallRow.ts >= since)
|
||||
.group_by(LlmCallRow.persona_name)
|
||||
)
|
||||
).all()
|
||||
_render_stats_table(
|
||||
"Stats by persona",
|
||||
rows,
|
||||
["Persona", "Calls", "Cost ($)"],
|
||||
)
|
||||
elif by == "day":
|
||||
rows = (
|
||||
await s.execute(
|
||||
select(
|
||||
func.substr(LlmCallRow.ts, 1, 10).label("day"),
|
||||
func.count().label("calls"),
|
||||
func.sum(LlmCallRow.cost_usd_total).label("cost"),
|
||||
)
|
||||
.where(LlmCallRow.ts >= since)
|
||||
.group_by("day")
|
||||
)
|
||||
).all()
|
||||
_render_stats_table(
|
||||
"Stats by day",
|
||||
rows,
|
||||
["Day", "Calls", "Cost ($)"],
|
||||
)
|
||||
else:
|
||||
typer.echo(f"unknown --by option: {by!r}", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
def budget_command() -> None:
|
||||
"""Synchronous CLI wrapper for the async budget ledger query."""
|
||||
asyncio.run(_budget_async())
|
||||
|
||||
|
||||
async def _budget_async() -> None:
|
||||
config = load_config()
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
async with db.session() as s:
|
||||
rows = list((await s.execute(select(BudgetLedgerRow))).scalars().all())
|
||||
if not rows:
|
||||
_CONSOLE.print("[dim](no budget activity yet)[/]")
|
||||
return
|
||||
table = Table(title="Budget ledger")
|
||||
table.add_column("Scope")
|
||||
table.add_column("Spent ($)", justify="right")
|
||||
table.add_column("Cap ($)", justify="right")
|
||||
table.add_column("Remaining ($)", justify="right")
|
||||
table.add_column("Last update")
|
||||
for row in rows:
|
||||
remaining = (
|
||||
"" if row.cap_usd is None else f"{max(0.0, row.cap_usd - row.spent_usd):.4f}"
|
||||
)
|
||||
cap = "—" if row.cap_usd is None else f"{row.cap_usd:.4f}"
|
||||
table.add_row(
|
||||
row.scope,
|
||||
f"{row.spent_usd:.4f}",
|
||||
cap,
|
||||
remaining,
|
||||
row.last_updated,
|
||||
)
|
||||
_CONSOLE.print(table)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
def pricing_command() -> None:
|
||||
"""Show cached OpenRouter pricing matrix (populated by `doctor`)."""
|
||||
asyncio.run(_pricing_async())
|
||||
|
||||
|
||||
async def _pricing_async() -> None:
|
||||
config = load_config()
|
||||
db = Database(config.database_url)
|
||||
await db.init_schema()
|
||||
try:
|
||||
async with db.session() as s:
|
||||
rows = list(
|
||||
(await s.execute(select(ModelPricingRow).order_by(ModelPricingRow.model)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
_CONSOLE.print("[dim](no pricing data — run `mydeepagent doctor` to fetch)[/]")
|
||||
return
|
||||
table = Table(title="OpenRouter pricing (per 1K tokens, USD)")
|
||||
table.add_column("Model")
|
||||
table.add_column("Input", justify="right")
|
||||
table.add_column("Output", justify="right")
|
||||
table.add_column("Context", justify="right")
|
||||
table.add_column("Fetched")
|
||||
for r in rows:
|
||||
table.add_row(
|
||||
r.model,
|
||||
f"{r.input_per_1k_usd:.4f}",
|
||||
f"{r.output_per_1k_usd:.4f}",
|
||||
str(r.context_length),
|
||||
(r.fetched_at or "")[:19],
|
||||
)
|
||||
_CONSOLE.print(table)
|
||||
finally:
|
||||
await db.dispose()
|
||||
|
||||
|
||||
def _render_stats_table(title: str, rows: Sequence[Any], headers: list[str]) -> None:
|
||||
if not rows:
|
||||
_CONSOLE.print("[dim](no data for the past period)[/]")
|
||||
return
|
||||
table = Table(title=title)
|
||||
for h in headers:
|
||||
table.add_column(h)
|
||||
for row in rows:
|
||||
table.add_row(*[str(v if v is not None else "") for v in row])
|
||||
_CONSOLE.print(table)
|
||||
|
||||
Reference in New Issue
Block a user