mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-25 12:55:53 -04:00
8ee2d6affe
Continues the dcode package restructure by giving the Textual execution adapter a canonical home under `deepagents_code.tui`. Shared session stats formatting now lives outside the TUI layer so headless and client paths do not need to import Textual-facing code. ## Changes - Moves `TextualUIAdapter` and `execute_task_textual` under the TUI package and updates app, integration, benchmark, and unit-test imports to use the new canonical path. - Keeps `client/non_interactive` and shared command logic independent of `deepagents_code.tui` by moving `print_usage_table` alongside `SessionStats` and `format_token_count`. - Relocates adapter-specific tests under the matching `tests/unit_tests/tui` layout and moves shared session-stat coverage to the existing `_session_stats` test module. - Refreshes threat-model and inline references that described the old flat adapter location.
236 lines
7.6 KiB
Python
236 lines
7.6 KiB
Python
"""Lightweight session statistics, token formatting, and usage-table rendering.
|
|
|
|
Holds `SessionStats`/`ModelStats`, the `format_token_count` formatter, and
|
|
`print_usage_table` (which imports `rich.table` lazily). The module is
|
|
intentionally kept free of heavy top-level dependencies (no pydantic, no
|
|
config, no widget imports) so that `app.py` can import `SessionStats` and
|
|
`format_token_count` at module level without pulling in the full
|
|
`textual_adapter` dependency tree.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Literal
|
|
|
|
from deepagents_code.formatting import format_duration
|
|
|
|
if TYPE_CHECKING:
|
|
from rich.console import Console
|
|
|
|
SpinnerStatus = (
|
|
Literal[
|
|
"Thinking",
|
|
"Offloading",
|
|
"Loading thread",
|
|
"Drafting acceptance criteria",
|
|
]
|
|
| None
|
|
)
|
|
"""Valid spinner display states, or `None` to hide."""
|
|
|
|
|
|
@dataclass
|
|
class ModelStats:
|
|
"""Token stats for a single model within a session."""
|
|
|
|
request_count: int = 0
|
|
"""Number of LLM API requests made to this model."""
|
|
|
|
input_tokens: int = 0
|
|
"""Cumulative input tokens sent to this model."""
|
|
|
|
output_tokens: int = 0
|
|
"""Cumulative output tokens received from this model."""
|
|
|
|
provider: str = ""
|
|
"""Provider that served this model (e.g. `openai`), or `""` when unknown."""
|
|
|
|
model_name: str = ""
|
|
"""Model name displayed in usage output."""
|
|
|
|
|
|
ModelStatsKey = tuple[str, str]
|
|
"""Per-model dict key: the `(provider, model_name)` pair.
|
|
|
|
Pairing the provider with the model name keeps the same model served by
|
|
different providers (e.g. `gpt-5.5` via `openai` vs `azure`) in separate rows
|
|
instead of collapsing them. The key is always built from the same values stored
|
|
on the corresponding `ModelStats`, so key and fields never diverge.
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class SessionStats:
|
|
"""Stats accumulated over a single agent turn (or full session)."""
|
|
|
|
request_count: int = 0
|
|
"""Total LLM API requests made.
|
|
|
|
Each chunk with `usage_metadata` counts as one completed request.
|
|
"""
|
|
|
|
input_tokens: int = 0
|
|
"""Cumulative input tokens across all LLM requests."""
|
|
|
|
output_tokens: int = 0
|
|
"""Cumulative output tokens across all LLM requests."""
|
|
|
|
wall_time_seconds: float = 0.0
|
|
"""Wall-clock duration from stream start to end."""
|
|
|
|
per_model: dict[ModelStatsKey, ModelStats] = field(default_factory=dict)
|
|
"""Per-model breakdown keyed by `(provider, model_name)`.
|
|
|
|
Populated only when `record_request` receives a non-empty `model_name`. Empty
|
|
dict means no named-model requests were recorded; `print_usage_table` omits
|
|
the model table in that case and shows only the wall-time line (if applicable).
|
|
"""
|
|
|
|
def record_request(
|
|
self,
|
|
model_name: str,
|
|
input_toks: int,
|
|
output_toks: int,
|
|
provider: str = "",
|
|
) -> None:
|
|
"""Accumulate token counts for one completed LLM request.
|
|
|
|
Updates both the session totals and the per-model breakdown.
|
|
|
|
Args:
|
|
model_name: The model that served this request. Combined with
|
|
`provider` to form the per-model key. Pass an empty string to
|
|
skip the per-model breakdown for this request.
|
|
input_toks: Input tokens for this request.
|
|
output_toks: Output tokens for this request.
|
|
provider: Provider that served the model (e.g. `openai`). Combined
|
|
with `model_name` to form the per-model key, so the same model
|
|
served by different providers is tracked separately.
|
|
"""
|
|
self.request_count += 1
|
|
self.input_tokens += input_toks
|
|
self.output_tokens += output_toks
|
|
if model_name:
|
|
key = (provider, model_name)
|
|
entry = self.per_model.setdefault(
|
|
key,
|
|
ModelStats(provider=provider, model_name=model_name),
|
|
)
|
|
entry.request_count += 1
|
|
entry.input_tokens += input_toks
|
|
entry.output_tokens += output_toks
|
|
|
|
def merge(self, other: SessionStats) -> None:
|
|
"""Merge another `SessionStats` into this one (mutates *self*).
|
|
|
|
Used to accumulate per-turn stats into a session-level total.
|
|
|
|
Args:
|
|
other: The stats to fold in.
|
|
"""
|
|
self.request_count += other.request_count
|
|
self.input_tokens += other.input_tokens
|
|
self.output_tokens += other.output_tokens
|
|
self.wall_time_seconds += other.wall_time_seconds
|
|
for key, ms in other.per_model.items():
|
|
entry = self.per_model.setdefault(
|
|
key,
|
|
ModelStats(provider=ms.provider, model_name=ms.model_name),
|
|
)
|
|
entry.request_count += ms.request_count
|
|
entry.input_tokens += ms.input_tokens
|
|
entry.output_tokens += ms.output_tokens
|
|
|
|
|
|
def format_token_count(count: int) -> str:
|
|
"""Format a token count into a human-readable short string.
|
|
|
|
Args:
|
|
count: Number of tokens.
|
|
|
|
Returns:
|
|
Formatted string like `'12.5K'`, `'1.2M'`, or `'500'`.
|
|
"""
|
|
if count >= 1_000_000: # noqa: PLR2004
|
|
return f"{count / 1_000_000:.1f}M"
|
|
if count >= 1000: # noqa: PLR2004
|
|
return f"{count / 1000:.1f}K"
|
|
return str(count)
|
|
|
|
|
|
def print_usage_table(
|
|
stats: SessionStats,
|
|
wall_time: float,
|
|
console: Console,
|
|
) -> None:
|
|
"""Print a model-usage stats table to a Rich console.
|
|
|
|
Each row shows the serving provider alongside the model name. When the
|
|
session spans multiple models each gets its own row with a totals row
|
|
appended; single-model sessions show one row.
|
|
|
|
Args:
|
|
stats: Cumulative session stats.
|
|
wall_time: Total wall-clock time in seconds.
|
|
console: Rich console for output.
|
|
"""
|
|
from rich.table import Table
|
|
|
|
has_time = wall_time >= 0.1 # noqa: PLR2004
|
|
if not (stats.request_count or stats.input_tokens or has_time):
|
|
return
|
|
|
|
if stats.per_model:
|
|
multi_model = len(stats.per_model) > 1
|
|
|
|
table = Table(
|
|
show_header=True,
|
|
header_style="bold",
|
|
box=None,
|
|
padding=(0, 2, 0, 0),
|
|
show_edge=False,
|
|
)
|
|
table.add_column("Provider", style="dim")
|
|
table.add_column("Model", style="dim")
|
|
table.add_column("Reqs", justify="right", style="dim")
|
|
table.add_column("InputTok", justify="right", style="dim")
|
|
table.add_column("OutputTok", justify="right", style="dim")
|
|
|
|
if multi_model:
|
|
for ms in stats.per_model.values():
|
|
table.add_row(
|
|
ms.provider,
|
|
ms.model_name,
|
|
str(ms.request_count),
|
|
format_token_count(ms.input_tokens),
|
|
format_token_count(ms.output_tokens),
|
|
)
|
|
table.add_row(
|
|
"",
|
|
"Total",
|
|
str(stats.request_count),
|
|
format_token_count(stats.input_tokens),
|
|
format_token_count(stats.output_tokens),
|
|
)
|
|
else:
|
|
ms = next(iter(stats.per_model.values()))
|
|
table.add_row(
|
|
ms.provider,
|
|
ms.model_name,
|
|
str(stats.request_count),
|
|
format_token_count(stats.input_tokens),
|
|
format_token_count(stats.output_tokens),
|
|
)
|
|
|
|
console.print()
|
|
console.print("[bold]Usage Stats[/bold]")
|
|
console.print(table)
|
|
if has_time:
|
|
console.print()
|
|
console.print(
|
|
f"Agent active {format_duration(wall_time)}",
|
|
style="dim",
|
|
highlight=False,
|
|
)
|