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>
144 lines
6.1 KiB
Python
144 lines
6.1 KiB
Python
"""The agents' measured run list (subagent + session runs) and the agent card."""
|
||
|
||
import pathlib
|
||
|
||
import accounts as accounts_mod
|
||
import agents as agents_mod
|
||
from conversations.cards import _account_of
|
||
from conversations.tree import (
|
||
_conv_id,
|
||
_is_sidechain_path,
|
||
_root_path_of,
|
||
_subagent_meta_cached,
|
||
)
|
||
from core.memo import _Memo
|
||
from core.state import AppState
|
||
|
||
# ── agents catalog ──────────────────────────────────────────────────────────
|
||
# `agents.py` owns the disk scan and the arithmetic; assembling the run list
|
||
# needs the summary store, the metadata sidecar and the cron store, so it lives
|
||
# here — the same split the skills catalog uses. Sits after the cron section
|
||
# because a scheduled agent's runs come out of `cron_store`.
|
||
|
||
|
||
def _build_agent_runs(deps: AppState) -> list[dict]:
|
||
"""Every recorded run of an agent, newest first, in one flat shape.
|
||
|
||
Two very different things are being unified here (see `agents.py`): a
|
||
**subagent** run, which is a sidechain transcript whose `agent-*.meta.json`
|
||
names the agent type, and a **session** run, which is an ordinary top-level
|
||
conversation that happened to be spawned *from* an agent file by cron or by
|
||
the agent page's "Run now". Both carry a real cost, because both are whole
|
||
transcripts — that's the whole point of measuring agents rather than skills.
|
||
|
||
A subagent run's `conversationId` is its **root** conversation (the card
|
||
that lists the agents used, and the unit "used in N conversations" counts —
|
||
a nested agent's Task card lives in another sidechain, but it still ran in
|
||
that conversation); `subConversationId` opens the child transcript itself.
|
||
For a session run the two are the same conversation, so only the former is
|
||
set.
|
||
"""
|
||
by_file = {a["path"]: a["name"] for a in agents_mod.catalog()}
|
||
runs: list[dict] = []
|
||
sessions: dict[str, tuple[str, dict]] = {}
|
||
|
||
for path, s in deps.store.all_summaries():
|
||
if _is_sidechain_path(path):
|
||
name = _subagent_meta_cached(pathlib.Path(path)).get("agentType")
|
||
if not name:
|
||
continue # a Task call with no resolved type (pre-meta.json)
|
||
parent = _root_path_of(path)
|
||
runs.append({
|
||
"agent": name,
|
||
"origin": "task",
|
||
"account": _account_of(deps, s),
|
||
"at": s.get("endedAt") or s.get("startedAt"),
|
||
"cost": s.get("cost") or 0.0,
|
||
"tokens": s.get("tokens") or 0,
|
||
"model": s.get("model"),
|
||
"title": s.get("title"),
|
||
"conversationId": _conv_id(parent) if parent else None,
|
||
"subConversationId": _conv_id(path),
|
||
})
|
||
elif s.get("sessionId"):
|
||
sessions[s["sessionId"]] = (path, s)
|
||
|
||
def session_run(name: str, origin: str, sid: str, at: str | None) -> dict:
|
||
"""A top-level run, filled in from its transcript when one has synced —
|
||
a just-spawned session has none yet, so it shows up on the recorded
|
||
timestamp with zero spend rather than not at all."""
|
||
path, s = sessions.get(sid, (None, {}))
|
||
return {
|
||
"agent": name,
|
||
"origin": origin,
|
||
"account": accounts_mod.resolve(s or None, deps.meta_store.peek(sid)),
|
||
"at": s.get("endedAt") or s.get("startedAt") or at,
|
||
"cost": s.get("cost") or 0.0,
|
||
"tokens": s.get("tokens") or 0,
|
||
"model": s.get("model"),
|
||
"title": s.get("title"),
|
||
"conversationId": _conv_id(path) if path else None,
|
||
"subConversationId": None,
|
||
}
|
||
|
||
for job in deps.cron_store.list():
|
||
name = by_file.get((job.get("promptFile") or "").lstrip("/"))
|
||
if not name:
|
||
continue # a job whose prompt file isn't an agent definition
|
||
for at, sid in (job.get("history") or {}).items():
|
||
if sid:
|
||
runs.append(session_run(name, "cron", sid, at))
|
||
|
||
for sid, m in deps.meta_store.items():
|
||
ar = m.get("agentRun") or {}
|
||
if ar.get("name"):
|
||
runs.append(session_run(ar["name"], "manual", sid, ar.get("at")))
|
||
|
||
runs.sort(key=lambda r: r["at"] or "", reverse=True)
|
||
return runs
|
||
|
||
|
||
_agent_runs_memo = _Memo(_build_agent_runs)
|
||
|
||
|
||
def _agent_runs(deps: AppState) -> list[dict]:
|
||
"""Memoized `_build_agent_runs` — a full pass walks every summary, so the
|
||
list/detail pages (which the SSE bus refetches on every transcript write)
|
||
share one build per store version."""
|
||
return _agent_runs_memo.get(
|
||
(deps.store.summaries_version, deps.meta_store.version), deps)
|
||
|
||
|
||
def _agent_card(entry: dict | None, name: str, usage: dict, files: dict) -> dict:
|
||
"""One agent card: its disk record (or a built-in stub) + its run stats.
|
||
|
||
A built-in type (`Explore`, `general-purpose`, `Plan`, …) has no file in
|
||
this repo but has still earned its runs, so — exactly like a built-in skill
|
||
— it appears with `sourceKind: "builtin"` and no editor link.
|
||
"""
|
||
base = entry or {
|
||
"name": name, "title": name, "description": "",
|
||
"path": None, "dir": None,
|
||
"source": "builtin", "sourceKind": "builtin",
|
||
"tools": [], "bytes": 0, "updatedAt": None,
|
||
"harness": None, "model": None, "effort": None, "thinking": None,
|
||
}
|
||
u = usage.get(name) or {}
|
||
row = files.get(base.get("path") or "")
|
||
return {
|
||
**base,
|
||
"runs": u.get("runs", 0),
|
||
"taskRuns": u.get("taskRuns", 0),
|
||
"sessionRuns": u.get("sessionRuns", 0),
|
||
"lastRun": u.get("lastRun"),
|
||
"conversations": u.get("conversations", 0),
|
||
# Real spend: an agent run is a whole conversation, so unlike a skill's
|
||
# `estSpend` this is measured, not context-size × calls.
|
||
"cost": u.get("cost", 0.0),
|
||
"tokens": u.get("tokens", 0),
|
||
"avgCost": u.get("avgCost", 0.0),
|
||
# What the definition itself costs to load into a session.
|
||
"contextTokens": row["tokens"] if row else None,
|
||
"contextCost": row["cost"] if row else None,
|
||
}
|