Files
ai-agent/backend/agents/routes.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

161 lines
5.9 KiB
Python

"""Agents catalog routes (/api/agents)."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import accounts as accounts_mod
import agents as agents_mod
import cron as cron_mod
import schemas
from agents.runs import _agent_card, _agent_runs
from core.config import WORKSPACE
from core.http import _r
from core.state import AppState, State
from cron.service import _cron_job_out
from notifications import store as notify_mod
from runs.service import _send_message
router = APIRouter()
@router.get("/api/agents", responses=_r(schemas.AgentsResponse))
def agents_list(deps: State, account: str | None = None):
"""Card metadata + run/cost stats for every agent, last-run first.
The **union** of the agent definitions on disk and the types the transcripts
saw run — same shape as `/api/skills`, but the money here is real spend.
``account`` scopes the runs to one Claude account (unset = the totals).
"""
sel = accounts_mod.selector(account)
runs = [r for r in _agent_runs(deps, ) if sel(r.get("account"))]
usage = agents_mod.rollup(runs)
files = deps.store.all_files()
items = [_agent_card(a, a["name"], usage, files)
for a in agents_mod.catalog()]
known = {a["name"] for a in items}
for name in sorted(usage.keys() - known):
items.append(_agent_card(None, name, usage, files))
items.sort(key=lambda a: (a["lastRun"] or "", a["name"]), reverse=True)
return {
"agents": items,
"totals": {
"agents": len(items),
"used": sum(1 for a in items if a["runs"] > 0),
"runs": sum(a["runs"] for a in items),
"cost": sum(a["cost"] for a in items),
"tokens": sum(a["tokens"] for a in items),
},
}
def _agent_entry(name: str) -> dict | None:
return next((a for a in agents_mod.catalog() if a["name"] == name), None)
def _agent_cron_job(deps: AppState, path: str | None) -> dict | None:
"""The cron job scheduled on this agent's file, if one exists — what turns
the detail page's "Schedule" button into a "next run in 3h" line."""
if not path:
return None
for job in deps.cron_store.list():
if (job.get("promptFile") or "").lstrip("/") == path:
return _cron_job_out(deps, job)
return None
# How many runs the detail page's history list shows. The rollup counts them
# all; only the tail is worth shipping.
AGENT_RUN_LIMIT = 40
@router.get("/api/agents/{name}", responses=_r(schemas.AgentDetail))
def agent_detail(deps: State, name: str, account: str | None = None):
"""One agent: its definition, its stats, its recent runs and its schedule.
404s only when the name is neither on disk nor in any transcript — an agent
that exists but has never run is a perfectly good (empty) page.
"""
entry = _agent_entry(name)
runs = [r for r in _agent_runs(deps, ) if r["agent"] == name]
if entry is None and not runs:
raise HTTPException(404, "agent not found")
sel = accounts_mod.selector(account)
runs = [r for r in runs if sel(r.get("account"))]
usage = agents_mod.rollup(runs)
card = _agent_card(entry, name, usage, deps.store.all_files())
content = ""
if entry:
try:
content = (WORKSPACE / entry["path"]).read_text(
encoding="utf-8", errors="replace")
except OSError:
content = ""
# Schedulable = cron would accept this path as a prompt file. Nested
# definitions (`.claude/agents/hooks/post-task.md`) and project-local ones
# aren't, so the UI can say why instead of offering a button that 400s.
can_schedule = False
if entry:
try:
cron_mod.valid_prompt_file(entry["path"])
can_schedule = True
except ValueError:
can_schedule = False
return {
"agent": card,
"content": content,
"runs": runs[:AGENT_RUN_LIMIT],
"daily": agents_mod.daily(runs),
"cron": _agent_cron_job(deps, entry["path"] if entry else None),
"canSchedule": can_schedule,
}
class AgentRunBody(BaseModel):
# An extra instruction appended under the definition — the "what should it
# do this time?" box on the agent page. Empty ⇒ run the definition as-is,
# exactly like a cron firing does.
prompt: str | None = None
@router.post("/api/agents/{name}/run", responses=_r(schemas.SpawnResult))
def agent_run(deps: State, name: str, body: AgentRunBody):
"""Run an agent now, as a top-level session — the agent page's "Run now".
Same machinery as a cron firing (`_fire_cron_job`): the definition's body is
the prompt and its frontmatter picks the harness/model/effort, so a "Run
now" and a scheduled run are the same run. The session is stamped
`agentRun` in the metadata sidecar, which is both what puts it in this
agent's history and what makes the conversation card say where it came from.
"""
entry = _agent_entry(name)
if entry is None:
raise HTTPException(404, "agent not found")
try:
raw = (WORKSPACE / entry["path"]).read_text(encoding="utf-8")
except OSError:
raise HTTPException(409, f"could not read {entry['path']}")
cfg = cron_mod.run_config(raw)
prompt = cron_mod.strip_frontmatter(raw).strip()
if not prompt:
raise HTTPException(409, f"{entry['path']} has no prompt body")
extra = (body.prompt or "").strip()
if extra:
prompt = f"{prompt}\n\n---\n\n**This run's task:** {extra}"
out = _send_message(deps, prompt, model=cfg["model"], harness=cfg["harness"],
thinking=cfg["thinking"], effort=cfg["effort"],
account=cfg.get("account"))
sid = out["sessionId"]
deps.meta_store.update(sid, {"agentRun": {"name": name,
"at": notify_mod._now_iso()}})
deps.hub.publish({"type": "meta", "id": sid})
return out