Files
ai-agent/backend/agents/runs.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

144 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}