Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions DESIGN_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1348,7 +1348,7 @@ Every API call is tracked (illustrative schema):
}
```

> **Implementation note:** `CostRecord` stores `input_tokens` and `output_tokens`; `total_tokens` is not stored on `CostRecord` — it is a `@computed_field` property on `TokenUsage` (the model embedded in `CompletionResponse`). Spending summary models (`AgentSpending`, `DepartmentSpending`, `PeriodSpending`) each independently define `total_cost_usd`, `total_input_tokens`, `total_output_tokens`, and `record_count` fields. Extracting a shared `_SpendingTotals` base is a planned convention (see §15.5).
> **Implementation note:** `CostRecord` stores `input_tokens` and `output_tokens`; `total_tokens` is not stored on `CostRecord` — it is a `@computed_field` property on `TokenUsage` (the model embedded in `CompletionResponse`). `_SpendingTotals` base class provides shared `total_cost_usd`, `total_input_tokens`, `total_output_tokens`, and `record_count` fields. `AgentSpending`, `DepartmentSpending`, and `PeriodSpending` extend it with their dimension-specific fields.

### 10.3 CFO Agent Responsibilities

Expand Down Expand Up @@ -2178,7 +2178,7 @@ ai-company/
│ │ ├── config.py # Budget configuration models
│ │ ├── cost_record.py # CostRecord model (frozen)
│ │ ├── tracker.py # CostTracker service (records + queries)
│ │ ├── spending_summary.py # AgentSpending, DepartmentSpending, PeriodSpending
│ │ ├── spending_summary.py # _SpendingTotals base + spending summary models
│ │ ├── hierarchy.py # BudgetHierarchy, BudgetConfig
│ │ ├── enums.py # Budget-related enums
│ │ ├── limits.py # Budget enforcement (M5)
Expand Down Expand Up @@ -2243,7 +2243,7 @@ These conventions were established during the M0–M2+ review cycle. **Adopted**
| **Config vs runtime split** | Adopted (M3) | Frozen models for config/identity; `model_copy(update=...)` for runtime state transitions | `TaskExecution` and `AgentContext` (in `engine/`) are frozen Pydantic models that use `model_copy(update=...)` for copy-on-write state transitions without re-running validators (per Pydantic `model_copy` semantics). Config layer (`AgentIdentity`, `Task`) remains unchanged. |
| **Derived fields** | Adopted | `@computed_field` instead of stored + validated | Eliminates redundant storage and impossible-to-fail validators. `TokenUsage.total_tokens` migrated from stored `Field` + `@model_validator` to `@computed_field` property. |
| **String validation** | Adopted | `NotBlankStr` type from `core.types` for all identifiers | Eliminates per-model `@model_validator` boilerplate for whitespace checks. All identifier/name fields use `NotBlankStr`; optional identifiers use `NotBlankStr \| None`; tuple fields use `tuple[NotBlankStr, ...]` for per-element validation. |
| **Shared field groups** | Planned | Extract common field sets into base models (e.g. `_SpendingTotals`) | Prevents field duplication across spending summary models. Not yet implemented — each model independently defines fields. |
| **Shared field groups** | Adopted (M2.5) | Extracted common field sets into base models (e.g. `_SpendingTotals`) | Prevents field duplication across spending summary models. `_SpendingTotals` provides shared aggregation fields; `AgentSpending`, `DepartmentSpending`, `PeriodSpending` extend it. |
| **Event constants** | Adopted (per-domain) | Per-domain submodules under `events/` package (e.g. `events.provider`, `events.budget`). Import directly: `from ai_company.observability.events.<domain> import CONSTANT` | Split by domain for discoverability, co-location with domain logic, and reduced merge conflicts as constants grow. `__init__.py` serves as package marker with usage documentation; no re-exports. |
| **Parallel tool execution** | Adopted (M2.5) | `asyncio.TaskGroup` in `ToolInvoker.invoke_all` with optional `max_concurrency` semaphore | Structured concurrency with proper cancellation semantics. Fatal errors collected via guarded wrapper and re-raised after all tasks complete. |
| **Tool sandboxing** | Planned (M3) | Layered `SandboxBackend` protocol: `SubprocessSandbox` for low-risk tools (file, git), `DockerSandbox` for high-risk tools (code_runner, terminal, web, database). `K8sSandbox` planned for future container deployments. | Risk-proportionate isolation. Docker optional — only needed for code execution and network-sensitive tools. Pluggable protocol enables seamless migration to K8s per-agent pods in Phase 3-4. See §11.1.2. |
Expand Down
88 changes: 24 additions & 64 deletions src/ai_company/budget/spending_summary.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Spending summary models for aggregated cost reporting.

Provides the aggregation data structures consumed by the CFO agent
(DESIGN_SPEC Section 10.3) for cost reporting and budget monitoring.
Provides the aggregation data structures used by
:class:`~ai_company.budget.tracker.CostTracker` for cost reporting and
designed for consumption by the CFO agent (DESIGN_SPEC Section 10.3).
Views of :class:`~ai_company.budget.cost_record.CostRecord` data are
aggregated by agent, department, and time period.
"""
Expand All @@ -16,26 +17,25 @@
from ai_company.core.types import NotBlankStr # noqa: TC001


class PeriodSpending(BaseModel):
"""Spending aggregation for a specific time period.
class _SpendingTotals(BaseModel):
"""Shared aggregation fields for spending summary models.

Not intended for direct instantiation — subclass with a
dimension-specific identifier (agent, department, or period).

Attributes:
start: Period start (inclusive).
end: Period end (exclusive).
total_cost_usd: Total cost for the period.
total_cost_usd: Total cost for the aggregation group.
total_input_tokens: Total input tokens consumed.
total_output_tokens: Total output tokens consumed.
record_count: Number of cost records aggregated.
"""
Comment on lines +20 to 31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring correctly states that _SpendingTotals is not intended for direct instantiation. To enforce this and prevent potential misuse, you can add a check to prevent it from being instantiated directly. This improves the robustness of the design.

Here's how you could do it using model_post_init (you'll also need to from typing import Any):

    def model_post_init(self, __context: Any) -> None:
        """Prevent direct instantiation of this base class."""
        if type(self) is _SpendingTotals:
            raise TypeError(
                "_SpendingTotals is a base class and cannot be instantiated directly. "
                "Subclass it and add a dimension-specific identifier."
            )


model_config = ConfigDict(frozen=True)

start: datetime = Field(description="Period start (inclusive)")
end: datetime = Field(description="Period end (exclusive)")
total_cost_usd: float = Field(
default=0.0,
ge=0.0,
description="Total cost for the period",
description="Total cost for the aggregation group",
)
Comment on lines 35 to 39
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

total_cost_usd Field description has been changed from the per-dimension wording (period/agent/department) to the generic "aggregation group" wording. This alters generated JSON schema / API docs and contradicts the PR claim of "no behavioral changes" if schema/metadata is considered part of the public surface. Either restore the previous per-model descriptions (e.g., by overriding field metadata in subclasses) or update the PR summary/changelog to explicitly call out the schema/doc metadata change.

Copilot uses AI. Check for mistakes.
total_input_tokens: int = Field(
default=0,
Expand All @@ -53,6 +53,18 @@ class PeriodSpending(BaseModel):
description="Number of cost records aggregated",
)
Comment on lines 20 to 54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_SpendingTotals is directly instantiable

The docstring states "Not intended for direct instantiation", but because all four fields carry defaults (0.0 / 0), the class can be silently instantiated without error:

t = _SpendingTotals()          # works — produces a dimensionless totals object
t = _SpendingTotals(total_cost_usd=9.99)  # also works

The underscore prefix communicates the intent by convention, but nothing prevents accidental use. Consider enforcing the constraint by raising in __init_subclass__ or, more idiomatically with Pydantic v2, by overriding model_post_init:

def model_post_init(self, __context: object) -> None:
    if type(self) is _SpendingTotals:
        raise TypeError(
            "_SpendingTotals is not intended for direct instantiation; "
            "use AgentSpending, DepartmentSpending, or PeriodSpending instead."
        )

This turns a documentation-only contract into a runtime-enforced one, which is particularly valuable given the class lives in a public package with three concrete subclasses already defined.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/budget/spending_summary.py
Line: 20-54

Comment:
**`_SpendingTotals` is directly instantiable**

The docstring states *"Not intended for direct instantiation"*, but because all four fields carry defaults (`0.0` / `0`), the class can be silently instantiated without error:

```python
t = _SpendingTotals()          # works — produces a dimensionless totals object
t = _SpendingTotals(total_cost_usd=9.99)  # also works
```

The underscore prefix communicates the intent by convention, but nothing prevents accidental use. Consider enforcing the constraint by raising in `__init_subclass__` or, more idiomatically with Pydantic v2, by overriding `model_post_init`:

```python
def model_post_init(self, __context: object) -> None:
    if type(self) is _SpendingTotals:
        raise TypeError(
            "_SpendingTotals is not intended for direct instantiation; "
            "use AgentSpending, DepartmentSpending, or PeriodSpending instead."
        )
```

This turns a documentation-only contract into a runtime-enforced one, which is particularly valuable given the class lives in a public package with three concrete subclasses already defined.

How can I resolve this? If you propose a fix, please make it concise.



class PeriodSpending(_SpendingTotals):
"""Spending aggregation for a specific time period.

Attributes:
start: Period start (inclusive).
end: Period end (exclusive).
"""

start: datetime = Field(description="Period start (inclusive)")
end: datetime = Field(description="Period end (exclusive)")

@model_validator(mode="after")
def _validate_period_ordering(self) -> Self:
"""Ensure start is strictly before end."""
Expand All @@ -65,78 +77,26 @@ def _validate_period_ordering(self) -> Self:
return self


class AgentSpending(BaseModel):
class AgentSpending(_SpendingTotals):
"""Spending aggregation for a single agent.

Attributes:
agent_id: Agent identifier.
total_cost_usd: Total cost for this agent.
total_input_tokens: Total input tokens consumed.
total_output_tokens: Total output tokens consumed.
record_count: Number of cost records.
"""

model_config = ConfigDict(frozen=True)

agent_id: NotBlankStr = Field(description="Agent identifier")
total_cost_usd: float = Field(
default=0.0,
ge=0.0,
description="Total cost for this agent",
)
total_input_tokens: int = Field(
default=0,
ge=0,
description="Total input tokens consumed",
)
total_output_tokens: int = Field(
default=0,
ge=0,
description="Total output tokens consumed",
)
record_count: int = Field(
default=0,
ge=0,
description="Number of cost records",
)


class DepartmentSpending(BaseModel):
class DepartmentSpending(_SpendingTotals):
"""Spending aggregation for a department.

Attributes:
department_name: Department name.
total_cost_usd: Total cost for this department.
total_input_tokens: Total input tokens consumed.
total_output_tokens: Total output tokens consumed.
record_count: Number of cost records.
"""

model_config = ConfigDict(frozen=True)

department_name: NotBlankStr = Field(
description="Department name",
)
Comment on lines 57 to 99
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON serialization field order has changed

The refactor silently changes the field ordering in model_dump() / model_dump_json() output for all three subclasses. Pydantic v2 emits fields in definition order — parent fields first, then subclass fields — so the identifier fields (agent_id, department_name, start/end) are now serialized last instead of first.

Before (e.g. AgentSpending):

{"agent_id": "alice", "total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0}

After:

{"total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0, "agent_id": "alice"}

Semantically this is harmless for roundtrip deserialization (Pydantic and most JSON consumers are order-agnostic), but the PR description claims "no behavioral changes." The change is real, and it could surface in:

  • snapshot / golden-file tests that do string-level JSON comparisons
  • downstream consumers that parse the JSON positionally (e.g. some CSV-style streaming parsers)
  • OpenAPI schema generation, where field order affects documentation readability

If preserving the original order matters, one lightweight fix is to redeclare the inherited dimension-specific field at the top of each subclass (with no body change) — Pydantic respects the MRO order and moves a redeclared field to where it's first seen in the subclass. Otherwise, consider adding an explicit note to the PR description and/or DESIGN_SPEC acknowledging the order change.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/budget/spending_summary.py
Line: 56-98

Comment:
**JSON serialization field order has changed**

The refactor silently changes the field ordering in `model_dump()` / `model_dump_json()` output for all three subclasses. Pydantic v2 emits fields in definition order — parent fields first, then subclass fields — so the identifier fields (`agent_id`, `department_name`, `start`/`end`) are now serialized *last* instead of first.

Before (e.g. `AgentSpending`):
```json
{"agent_id": "alice", "total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0}
```

After:
```json
{"total_cost_usd": 10.0, "total_input_tokens": 0, "total_output_tokens": 0, "record_count": 0, "agent_id": "alice"}
```

Semantically this is harmless for roundtrip deserialization (Pydantic and most JSON consumers are order-agnostic), but the PR description claims "no behavioral changes." The change is real, and it could surface in:
- snapshot / golden-file tests that do string-level JSON comparisons
- downstream consumers that parse the JSON positionally (e.g. some CSV-style streaming parsers)
- OpenAPI schema generation, where field order affects documentation readability

If preserving the original order matters, one lightweight fix is to redeclare the inherited dimension-specific field at the top of each subclass (with no body change) — Pydantic respects the MRO order and moves a redeclared field to where it's first seen in the subclass. Otherwise, consider adding an explicit note to the PR description and/or DESIGN_SPEC acknowledging the order change.

How can I resolve this? If you propose a fix, please make it concise.

total_cost_usd: float = Field(
default=0.0,
ge=0.0,
description="Total cost for this department",
)
total_input_tokens: int = Field(
default=0,
ge=0,
description="Total input tokens consumed",
)
total_output_tokens: int = Field(
default=0,
ge=0,
description="Total output tokens consumed",
)
record_count: int = Field(
default=0,
ge=0,
description="Number of cost records",
)


class SpendingSummary(BaseModel):
Expand Down