Files
ai-agent/backend/schemas/conversations.py
Gabriel Vidal 68a3206641 Merge branch 'main' into split-big-files
# Conflicts:
#	backend/main.py
#	backend/schemas.py
#	sidecar/sidecar.py
#	sidecar/test_claude_args.py
2026-10-06 23:57:03 +02:00

427 lines
17 KiB
Python

"""Conversations: list cards, the detail page's thread, subagents, memories
and artefacts as embedded in a conversation (``backend/conversations.py``)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from .base import Schema
from .files import Pricing
__all__ = [
"ToolUsage",
"ConvNotification",
"WorktreeInfo",
"ForkedFrom",
"CronRef",
"AgentRunRef",
"ConvMeta",
"ConvSummary",
"Usage",
"SubagentRef",
"SubagentListItem",
"SubagentsResponse",
"RunningAgent",
"ParentConvRef",
"Task",
"ConversationSummary",
"ThreadItem",
"TurnUsage",
"MemorySummary",
"ArtefactItem",
"PlanSummary",
"ConversationDetail",
"ConversationsResponse",
]
class ToolUsage(Schema):
tokens: int
cost: float
output: int
class ConvNotification(Schema):
title: Optional[str] = None
body: Optional[str] = None
type: Optional[str] = None
url: Optional[str] = None
# An image shown in the push (notify-done `--image`): a full URL or a
# `/local/<file>` path served from Home Assistant's www/ dir.
image: Optional[str] = None
at: Optional[str] = None
# The French line the desk phone read aloud (notify-done --spoken), kept so
# the UI can replay the push's sound. Absent on pushes that weren't spoken.
spoken: Optional[str] = None
class WorktreeInfo(Schema):
name: str
dir: Optional[str] = None
createdAt: Optional[str] = None
removedAt: Optional[str] = None
class ForkedFrom(Schema):
"""Where a forked conversation branched off: the source conversation and
the user-message ordinal (``mi``) whose edit created the fork."""
id: str # source conversation id (<slug>/<sid>.jsonl)
sessionId: Optional[str] = None # source session id
mi: int # message ordinal the fork cut before
class CronRef(Schema):
"""The cron job that spawned a conversation (see cron.py) — rendered as a
tag on the card/detail linking back to Settings → Cron."""
id: str
name: Optional[str] = None
class AgentRunRef(Schema):
"""The agent definition a conversation was launched from by the agent
page's "Run now" — the manual sibling of :class:`CronRef`, rendered as a
tag linking back to ``/agents/<name>``."""
name: str
at: Optional[str] = None
class ConvMeta(Schema):
state: str
# Title the session chose for itself (`conv-meta title "…"`); the backend
# already prefers it over the parsed one on every card, so the UI reads
# `title` — this is here for the raw-sidecar endpoint.
title: Optional[str] = None
projects: list[str]
services: list[str]
worktrees: Optional[list[WorktreeInfo]] = None
committed: Optional[str] = None
pushed: Optional[str] = None
merged: Optional[str] = None
deployed: Optional[str] = None
notified: Optional[str] = None
notifications: list[ConvNotification]
archived: bool = False
# Agent harness the session runs on: "claude" | "pi" (None = pre-field).
harness: Optional[str] = None
# The Claude account the session runs on / bills: "personal" | "work"
# (backend/accounts.py). Always resolved server-side — spawn stamp, else
# the transcript's import source, else the cwd rule, else the default.
account: Optional[str] = None
# The paired remote worker (backend/workers.py) the session runs on — the
# one the hub launched it on, else the one its transcript was mirrored
# from. None = the host sidecar / a terminal on the lab.
worker: Optional[str] = None
# Whether the session runs with thinking on. False ⇒ it was spawned (or last
# resumed) with thinking disabled. None = pre-field, i.e. on.
thinking: Optional[bool] = None
# The claude `--effort` level the session was spawned (or last resumed) with:
# "low" | "medium" | "high" | "xhigh" | "max". Claude-only, and None when the
# run never picked one (the CLI's own default) — pi runs never set it.
effort: Optional[str] = None
# Set when this conversation was forked off another one (edit & resubmit).
forkedFrom: Optional[ForkedFrom] = None
# Set when a cron job spawned this conversation.
cron: Optional[CronRef] = None
# Set when the agent page's "Run now" spawned this conversation.
agentRun: Optional[AgentRunRef] = None
# When the `complete` skill's end-of-task pass ran (its reply signs off
# with COMPLETED, so the marker on the transcript's last message is the
# record). Rendered as the COMPLETED seal on the conversation card.
completedAt: Optional[str] = None
# The conversation's published summary, written by the `complete` skill.
summary: Optional["ConvSummary"] = None
class ConvSummary(Schema):
"""A conversation's published summary: the filled-in scaffold markdown (see
scaffold.py) plus when it was published."""
markdown: str
at: Optional[str] = None
class Usage(Schema):
input: int
output: int
cacheRead: int
cacheWriteTokens: int
cacheWriteUnits: int
class SubagentRef(Schema):
id: str
agentType: Optional[str] = None
description: Optional[str] = None
title: Optional[str] = None
model: Optional[str] = None
tokens: Optional[int] = None
cost: Optional[float] = None
messages: Optional[int] = None
# Lifecycle of the subagent itself: ``running`` while the parent's Task call
# is still open (no tool_result yet), else ``finished``.
state: Optional[str] = None
startedAt: Optional[str] = None
endedAt: Optional[str] = None
# How many agents this one spawned in turn (its whole subtree) — they are
# linked from *its* page, not listed here.
subagentCount: Optional[int] = None
class SubagentListItem(Schema):
"""A flat subagent (sidechain) ref with its parent ids — the graph view's
node source (the top-level conversation list hides these). Agents nest:
``parentId`` is the transcript holding the Task card that spawned it (a
sidechain itself for a nested agent), ``rootId`` the top-level
conversation, ``depth`` 1 for a direct child of the root."""
id: str
parentId: str
rootId: Optional[str] = None
depth: Optional[int] = None
agentType: Optional[str] = None
description: Optional[str] = None
title: Optional[str] = None
tokens: Optional[int] = None
cost: Optional[float] = None
state: Optional[str] = None
class SubagentsResponse(Schema):
subagents: list[SubagentListItem]
class RunningAgent(Schema):
"""A Task/Agent call that hasn't returned — i.e. a subagent still running."""
toolUseId: str
agentType: Optional[str] = None
description: Optional[str] = None
startedAt: Optional[str] = None
class ParentConvRef(Schema):
id: str
title: Optional[str] = None
toolUseId: Optional[str] = None
agentType: Optional[str] = None
description: Optional[str] = None
class Task(Schema):
"""One task from the conversation's task list (TaskCreate/TaskUpdate, or the
older TodoWrite). ``status`` is one of pending / in_progress / completed /
cancelled."""
id: str
subject: str
status: str
class ConversationSummary(Schema):
id: str
sessionId: Optional[str] = None
project: Optional[str] = None
cwd: Optional[str] = None
gitBranch: Optional[str] = None
model: str
# Every model that produced a turn, busiest first (a session can switch
# models mid-thread). Rendered as the conversation's model tags.
models: Optional[list[str]] = None
# Every `--effort` level the turns actually ran at, busiest first — the read
# side of the composer's effort select, mined from the transcript. Empty for
# pi runs and for transcripts predating the CLI flag.
efforts: Optional[list[str]] = None
title: str
userTurns: int
assistantTurns: int
messages: int
startedAt: Optional[str] = None
endedAt: Optional[str] = None
tokens: int
cost: float
usage: Optional[Usage] = None
byTool: Optional[dict[str, ToolUsage]] = None
# Second-level breakdown of each activity bucket: bucket -> sub-label ->
# usage. bash by program, read/edit by file extension, msg by prompt/replies,
# skill as "skills read", other tool buckets by tool name. Powers the
# click-to-drill-down on the conversations dashboard chart.
byToolSub: Optional[dict[str, dict[str, ToolUsage]]] = None
subagentCount: Optional[int] = None
subagentTokens: Optional[int] = None
subagentCost: Optional[float] = None
# Subagents still in flight (their Task call never came back). The list card
# shows one "running" chip per entry while the conversation itself is running.
runningAgents: Optional[list[RunningAgent]] = None
# A deploy command this conversation issued hasn't returned yet. The list
# card shows a "Deploying…" chip while the conversation itself is running,
# same gating as `runningAgents`.
deploying: Optional[bool] = None
# Current context size: the last assistant API call's full prompt+reply
# (input + cache read/write + output) — what the next request replays.
# `contextModel` is the model that made that call, so the UI can pick the
# right context-window size (see ModelInfo.contextWindow).
contextTokens: Optional[int] = None
contextModel: Optional[str] = None
# The session's first API call's input side, split in two: the injected
# context (system prompt, CLAUDE.md, memories, other <system-reminder>
# blocks) vs the user's actual first message. The API reports only the
# combined total, so the message share is estimated from its length
# (~4 chars/token) and the remainder is attributed to context.
firstContextTokens: Optional[int] = None
firstMessageTokens: Optional[int] = None
tasks: Optional[list[Task]] = None
# What the conversation *used*, busiest first — the tool-side counterpart of
# `meta.projects`/`meta.services`, and what the tag registry derives its
# `skill:`/`agent:` tags from. Skills are the `Skill(...)` calls mined from
# the transcript (a spawned subagent's calls count as the parent's, since
# the child is never listed on its own); agents are the run list the agents
# catalog counts, so a card can't disagree with the agent page.
skillsUsed: Optional[list[str]] = None
agentsUsed: Optional[list[str]] = None
meta: Optional[ConvMeta] = None
class ThreadItem(Schema):
role: Literal["user", "assistant", "tool"]
# "turn" is a content-less carrier: an API call that rendered nothing (a
# redacted-thinking turn) but was still billed. It exists so the cost chart
# sees every priced turn; the viewer never draws it.
kind: Literal[
"text", "thinking", "tool_use", "tool_result", "interrupted", "paused", "held",
"turn"]
name: Optional[str] = None
input: Optional[Any] = None
text: Optional[str] = None
isError: Optional[bool] = None
result: Optional[str] = None
out: Optional[int] = None
turnTokens: Optional[int] = None
turnCost: Optional[float] = None
# The cached-prompt share of this turn: tokens replayed from the prompt cache
# and what they cost (10% of the input rate). The rest of `turnTokens` /
# `turnCost` is fresh input, cache writes and output.
turnCacheTokens: Optional[int] = None
turnCacheCost: Optional[float] = None
# The activity bucket this turn's cost was charged to in the `byTool`
# breakdown (msg / read / bash / …), so the cost chart colours and totals a
# turn exactly as "cost by activity" does.
turnBucket: Optional[str] = None
# On the first API call only: the share booked to the "context" bucket
# (injected system prompt, CLAUDE.md, memories), split off from this turn's
# own activity just as the breakdown does.
turnContextCost: Optional[float] = None
turnContextTokens: Optional[int] = None
# Only on the user's *first* message: how much of the session's first API
# call was injected context (system prompt, CLAUDE.md, memories, …) vs the
# message's own text (length-estimated split of the API-reported total).
firstContextTokens: Optional[int] = None
firstMessageTokens: Optional[int] = None
mi: Optional[int] = None
ts: Optional[str] = None
# On an assistant text item the CLI wrote for a failed API call
# (``isApiErrorMessage``): why — ``auth`` | ``rate_limit`` |
# ``cli_outdated`` | ``overloaded`` | ``network`` | ``unknown``.
apiError: Optional[str] = None
toolUseId: Optional[str] = None
subagent: Optional[SubagentRef] = None
# On a Task/Agent card: whether the agent it spawned is still running. Set
# even before the subagent's transcript exists, so a just-spawned agent's
# card reads "running" straight away.
agentRunning: Optional[bool] = None
# Wall-clock duration of this step: for a tool call, how long it ran (call →
# its result); for a message/thinking block, the assistant API call's latency
# (previous transcript record → this one).
durationMs: Optional[int] = None
# The model that produced this assistant step.
model: Optional[str] = None
# Size of a tool call's result payload (before clipping).
resultChars: Optional[int] = None
resultLines: Optional[int] = None
# On a `Skill` tool card: the injected SKILL.md body (shown collapsible in the
# card) and a repo-relative path to it (a link into the editor), so a skill
# invocation renders as one self-contained action instead of a huge "You" bubble.
skillBody: Optional[str] = None
skillPath: Optional[str] = None
# On a deploy tool call (npm run deploy / deploy*.sh / zipgo deploy / the
# deploy-html skill): the URL it reported, scraped from the raw — unclipped —
# output. Drives the deploy preview widget and the "open the deploy" gizmo.
deployedUrl: Optional[str] = None
class TurnUsage(Usage):
model: Optional[str] = None
class MemorySummary(Schema):
slug: str
name: str
description: str
type: Optional[str] = None
originSessionId: Optional[str] = None
projects: list[str]
services: list[str]
links: list[str]
words: int
bytes: int
updatedAt: Optional[str] = None
class ArtefactItem(Schema):
"""One generated artefact (screenshot, render, export) found under a
project/service's ``.ai/artefacts/<date>/<sessionId>/`` folder."""
kind: str # "project" | "service"
slug: str
name: str
date: str # the YYYY-MM-DD folder it landed in
path: str # <date>/<sessionId>/<file>, for /api/artefact
url: str # ready-to-use /api/artefact serve URL
bytes: int
mtime: Optional[str] = None
video: Optional[bool] = None
# A plan's card, as embedded in ``ConversationDetail.plans``. It lives here
# rather than in ``plans.py`` because that module already imports ``ConvMeta``
# from this one — keeping the package free of import cycles.
class PlanSummary(Schema):
slug: str
title: str
status: str
created: Optional[str] = None
updatedAt: Optional[str] = None
sessionId: Optional[str] = None
conversationIds: list[str]
projects: list[str]
services: list[str]
estCost: Optional[float] = None
estTimeMinutes: Optional[int] = None
estTokens: Optional[int] = None
files: int
steps: int
questions: int = 0
class ConversationDetail(ConversationSummary):
usage: Usage
thread: list[ThreadItem]
turns: list[TurnUsage]
memoriesRead: Optional[list[MemorySummary]] = None
memoriesCreated: Optional[list[MemorySummary]] = None
# Direct children only — a nested agent is linked from its own parent's page.
subagents: Optional[list[SubagentRef]] = None
# On a subagent page: the direct parent (+ the Task card that spawned it)…
parentConversation: Optional[ParentConvRef] = None
# …and every ancestor, root first, direct parent last (the breadcrumb).
parentChain: Optional[list[ParentConvRef]] = None
spawnDepth: Optional[int] = None
# Plans (data/plans/*.md) whose planning session is this conversation — the
# `plan` skill stamps its sessionId/conversationIds, so we join them back.
plans: Optional[list[PlanSummary]] = None
# Generated artefacts collected from the conversation's projects/services
# (.ai/artefacts/<date>/<sessionId>/), oldest first.
artefacts: Optional[list[ArtefactItem]] = None
class ConversationsResponse(Schema):
conversations: list[ConversationSummary]
# Total conversations matching the filter (pre-pagination).
count: Optional[int] = None
# Cursor for the next page (`?before=`); None when this page ends the list.
nextBefore: Optional[str] = None
pricing: Pricing