Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ src/ai_company/
api/ # FastAPI REST + WebSocket routes
budget/ # Per-agent cost tracking and spending controls
cli/ # Typer CLI commands
communication/ # Message bus, dispatcher, messenger, channels, delegation, loop prevention
communication/ # Message bus, dispatcher, messenger, channels, delegation, loop prevention, meeting protocol
config/ # YAML company config loading and validation
core/ # Shared domain models and base classes
engine/ # Agent orchestration, execution loops, parallel execution, task lifecycle, recovery, and shutdown
Expand Down Expand Up @@ -82,7 +82,7 @@ src/ai_company/
- **Every module** with business logic MUST have: `from ai_company.observability import get_logger` then `logger = get_logger(__name__)`
- **Never** use `import logging` / `logging.getLogger()` / `print()` in application code
- **Variable name**: always `logger` (not `_logger`, not `log`)
- **Event names**: always use constants from the domain-specific module under `ai_company.observability.events` (e.g. `PROVIDER_CALL_START` from `events.provider`, `BUDGET_RECORD_ADDED` from `events.budget`). Import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`
- **Event names**: always use constants from the domain-specific module under `ai_company.observability.events` (e.g. `PROVIDER_CALL_START` from `events.provider`, `BUDGET_RECORD_ADDED` from `events.budget`, `MEETING_STARTED` from `events.meeting`). Import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`
- **Structured kwargs**: always `logger.info(EVENT, key=value)` — never `logger.info("msg %s", val)`
- **All error paths** must log at WARNING or ERROR with context before raising
- **All state transitions** must log at INFO
Expand Down
20 changes: 17 additions & 3 deletions DESIGN_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ When a loop is detected, the framework:
3. Escalates to the sender's manager (or human if at top of hierarchy)
4. Logs the loop for analytics and process improvement

> **Current state (M4 in-progress):** The communication foundation is implemented: `MessageBus` protocol with `InMemoryMessageBus` backend (asyncio queues, pull-model `receive()`), `MessageDispatcher` for concurrent handler routing via `asyncio.TaskGroup`, `AgentMessenger` per-agent facade (auto-fills sender/timestamp/ID, deterministic direct-channel naming `@{sorted_a}:{sorted_b}`), and `DeliveryEnvelope` for delivery tracking. Loop prevention (§5.5) is implemented: `DelegationGuard` orchestrates five mechanisms (ancestry, depth, dedup, rate limit, circuit breaker) with `LoopPreventionConfig`. Hierarchical delegation is implemented via `DelegationService` with `HierarchyResolver` and `AuthorityValidator`. Task model extended with `parent_task_id` and `delegation_chain` fields. Conflict resolution (§5.6) and meeting protocol (§5.7) are planned for later M4 work.
> **Current state (M4 in-progress):** The communication foundation is implemented: `MessageBus` protocol with `InMemoryMessageBus` backend (asyncio queues, pull-model `receive()`), `MessageDispatcher` for concurrent handler routing via `asyncio.TaskGroup`, `AgentMessenger` per-agent facade (auto-fills sender/timestamp/ID, deterministic direct-channel naming `@{sorted_a}:{sorted_b}`), and `DeliveryEnvelope` for delivery tracking. Loop prevention (§5.5) is implemented: `DelegationGuard` orchestrates five mechanisms (ancestry, depth, dedup, rate limit, circuit breaker) with `LoopPreventionConfig`. Hierarchical delegation is implemented via `DelegationService` with `HierarchyResolver` and `AuthorityValidator`. Task model extended with `parent_task_id` and `delegation_chain` fields. Meeting protocol (§5.7) is implemented with all 3 protocols (round-robin, position papers, structured phases) via `MeetingOrchestrator` in `communication/meeting/`. Conflict resolution (§5.6) is planned for later M4 work.

### 5.6 Conflict Resolution Protocol

Expand Down Expand Up @@ -650,7 +650,7 @@ conflict_resolution:

Meetings (§5.1 Pattern 3) follow configurable protocols that determine how agents interact during structured multi-agent conversations. Different meeting types naturally suit different protocols. All protocols implement a `MeetingProtocol` protocol, making the system extensible — new protocols can be registered and selected per meeting type. Cost bounds are enforced by `duration_tokens` in meeting config (§5.4).

> **MVP: Not in M3.** Meetings are an M4 feature. Round-Robin (Protocol 1) is the initial default.
> **Current state (M4 complete):** All 3 meeting protocols are implemented in `communication/meeting/`: `RoundRobinProtocol`, `PositionPapersProtocol`, and `StructuredPhasesProtocol`. The `MeetingOrchestrator` runs meetings end-to-end with token budget enforcement via `TokenTracker`. All protocols implement the `MeetingProtocol` protocol interface.

#### Protocol 1: Round-Robin Transcript

Expand Down Expand Up @@ -694,10 +694,10 @@ Meeting split into phases with targeted participation:

```yaml
meeting_protocol: "structured_phases"
auto_create_tasks: true # action items become tasks (top-level, applies to any protocol)
structured_phases:
skip_discussion_if_no_conflicts: true
max_discussion_tokens: 1000
auto_create_tasks: true # action items become tasks
```

- Cost-efficient — parallel input, discussion only when needed
Expand Down Expand Up @@ -2415,6 +2415,19 @@ ai-company/
│ │ │ ├── models.py # GuardCheckOutcome
│ │ │ └── rate_limit.py # DelegationRateLimiter (per-pair)
│ │ ├── message.py # Message model
│ │ ├── meeting/ # Meeting protocol subsystem
│ │ │ ├── __init__.py # Package exports
│ │ │ ├── _prompts.py # LLM prompt templates for meeting phases
│ │ │ ├── _token_tracker.py # TokenTracker for duration_tokens enforcement
│ │ │ ├── config.py # MeetingProtocolConfig, protocol-specific config models
│ │ │ ├── enums.py # MeetingProtocolType, MeetingPhase enums
│ │ │ ├── errors.py # Meeting error hierarchy
│ │ │ ├── models.py # MeetingRecord, AgendaItem, ActionItem, etc.
│ │ │ ├── orchestrator.py # MeetingOrchestrator (runs meetings end-to-end)
│ │ │ ├── position_papers.py # PositionPapersProtocol implementation
│ │ │ ├── protocol.py # MeetingProtocol protocol interface
│ │ │ ├── round_robin.py # RoundRobinProtocol implementation
│ │ │ └── structured_phases.py # StructuredPhasesProtocol implementation
│ │ ├── messenger.py # AgentMessenger per-agent facade
│ │ └── subscription.py # Subscription + DeliveryEnvelope models
│ ├── memory/ # Agent memory system (M5, stubs only)
Expand All @@ -2438,6 +2451,7 @@ ai-company/
│ │ │ ├── correlation.py # CORRELATION_* constants
│ │ │ ├── execution.py # EXECUTION_* constants
│ │ │ ├── git.py # GIT_* constants
│ │ │ ├── meeting.py # MEETING_* constants
│ │ │ ├── parallel.py # PARALLEL_* constants
│ │ │ ├── personality.py # PERSONALITY_* constants
│ │ │ ├── prompt.py # PROMPT_* constants
Expand Down
58 changes: 58 additions & 0 deletions src/ai_company/communication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,45 @@
check_ancestry,
check_delegation_depth,
)
from ai_company.communication.meeting import (
ActionItem,
AgentCaller,
AgentResponse,
ConflictDetector,
KeywordConflictDetector,
MeetingAgenda,
MeetingAgendaItem,
MeetingAgentError,
MeetingBudgetExhaustedError,
MeetingContribution,
MeetingError,
MeetingMinutes,
MeetingOrchestrator,
MeetingParticipantError,
MeetingPhase,
MeetingProtocol,
MeetingProtocolConfig,
MeetingProtocolNotFoundError,
MeetingProtocolType,
MeetingRecord,
MeetingStatus,
PositionPapersConfig,
PositionPapersProtocol,
RoundRobinConfig,
RoundRobinProtocol,
StructuredPhasesConfig,
StructuredPhasesProtocol,
TaskCreator,
)
from ai_company.communication.message import Attachment, Message, MessageMetadata
from ai_company.communication.messenger import AgentMessenger
from ai_company.communication.subscription import DeliveryEnvelope, Subscription

__all__ = [
"ActionItem",
"AgentCaller",
"AgentMessenger",
"AgentResponse",
"Attachment",
"AttachmentType",
"AuthorityCheckResult",
Expand All @@ -84,6 +117,7 @@
"CommunicationConfig",
"CommunicationError",
"CommunicationPattern",
"ConflictDetector",
"DelegationAncestryError",
"DelegationAuthorityError",
"DelegationCircuitBreaker",
Expand All @@ -108,7 +142,24 @@
"HierarchyResolutionError",
"HierarchyResolver",
"InMemoryMessageBus",
"KeywordConflictDetector",
"LoopPreventionConfig",
"MeetingAgenda",
"MeetingAgendaItem",
"MeetingAgentError",
"MeetingBudgetExhaustedError",
"MeetingContribution",
"MeetingError",
"MeetingMinutes",
"MeetingOrchestrator",
"MeetingParticipantError",
"MeetingPhase",
"MeetingProtocol",
"MeetingProtocolConfig",
"MeetingProtocolNotFoundError",
"MeetingProtocolType",
"MeetingRecord",
"MeetingStatus",
"MeetingTypeConfig",
"MeetingsConfig",
"Message",
Expand All @@ -125,8 +176,15 @@
"MessageRetentionConfig",
"MessageType",
"NotSubscribedError",
"PositionPapersConfig",
"PositionPapersProtocol",
"RateLimitConfig",
"RoundRobinConfig",
"RoundRobinProtocol",
"StructuredPhasesConfig",
"StructuredPhasesProtocol",
"Subscription",
"TaskCreator",
"check_ancestry",
"check_delegation_depth",
]
5 changes: 5 additions & 0 deletions src/ai_company/communication/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CommunicationPattern,
MessageBusBackend,
)
from ai_company.communication.meeting.config import MeetingProtocolConfig
from ai_company.core.types import (
NotBlankStr,
validate_unique_strings,
Expand Down Expand Up @@ -109,6 +110,10 @@ class MeetingTypeConfig(BaseModel):
gt=0,
description="Token budget for the meeting",
)
protocol_config: MeetingProtocolConfig = Field(
default_factory=MeetingProtocolConfig,
description="Meeting protocol configuration",
)
Comment on lines +116 to +119

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.

⚠️ Potential issue | 🟡 Minor

Update MeetingTypeConfig docs for the new public field.

protocol_config is now part of the public model, but the MeetingTypeConfig docstring still stops at duration_tokens. Please document the new attribute there so generated docs and IDE help stay accurate. As per coding guidelines, "All public classes and functions must have Google-style docstrings; ruff D rules enforce this".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/communication/config.py` around lines 113 - 116, Update the
Google-style docstring for the public class MeetingTypeConfig to include the new
public attribute `protocol_config` (type MeetingProtocolConfig) and a short
description such as "Meeting protocol configuration" so generated docs and IDE
hints reflect the new field; ensure the docstring follows the existing
Google-style sections (Args/Attributes) and mentions `duration_tokens` and the
new `protocol_config` entry consistently with other attributes.


@model_validator(mode="after")
def _validate_frequency_or_trigger(self) -> Self:
Expand Down
1 change: 1 addition & 0 deletions src/ai_company/communication/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class MessageType(StrEnum):
DELEGATION = "delegation"
STATUS_REPORT = "status_report"
ESCALATION = "escalation"
MEETING_CONTRIBUTION = "meeting_contribution"


class MessagePriority(StrEnum):
Expand Down
83 changes: 83 additions & 0 deletions src/ai_company/communication/meeting/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Meeting protocol subsystem (DESIGN_SPEC Section 5.7).

Provides pluggable meeting protocol strategies for structured
multi-agent conversations:

- **Round-Robin**: Sequential turns with full transcript context.
- **Position Papers**: Parallel independent papers, then synthesis.
- **Structured Phases**: Phased agenda with conditional discussion.
"""

from ai_company.communication.meeting.config import (
MeetingProtocolConfig,
PositionPapersConfig,
RoundRobinConfig,
StructuredPhasesConfig,
)
from ai_company.communication.meeting.enums import (
MeetingPhase,
MeetingProtocolType,
MeetingStatus,
)
from ai_company.communication.meeting.errors import (
MeetingAgentError,
MeetingBudgetExhaustedError,
MeetingError,
MeetingParticipantError,
MeetingProtocolNotFoundError,
)
from ai_company.communication.meeting.models import (
ActionItem,
AgentResponse,
MeetingAgenda,
MeetingAgendaItem,
MeetingContribution,
MeetingMinutes,
MeetingRecord,
)
from ai_company.communication.meeting.orchestrator import MeetingOrchestrator
from ai_company.communication.meeting.position_papers import (
PositionPapersProtocol,
)
from ai_company.communication.meeting.protocol import (
AgentCaller,
ConflictDetector,
MeetingProtocol,
TaskCreator,
)
from ai_company.communication.meeting.round_robin import RoundRobinProtocol
from ai_company.communication.meeting.structured_phases import (
KeywordConflictDetector,
StructuredPhasesProtocol,
)

__all__ = [
"ActionItem",
"AgentCaller",
"AgentResponse",
"ConflictDetector",
"KeywordConflictDetector",
"MeetingAgenda",
"MeetingAgendaItem",
"MeetingAgentError",
"MeetingBudgetExhaustedError",
"MeetingContribution",
"MeetingError",
"MeetingMinutes",
"MeetingOrchestrator",
"MeetingParticipantError",
"MeetingPhase",
"MeetingProtocol",
"MeetingProtocolConfig",
"MeetingProtocolNotFoundError",
"MeetingProtocolType",
"MeetingRecord",
"MeetingStatus",
"PositionPapersConfig",
"PositionPapersProtocol",
"RoundRobinConfig",
"RoundRobinProtocol",
"StructuredPhasesConfig",
"StructuredPhasesProtocol",
"TaskCreator",
]
28 changes: 28 additions & 0 deletions src/ai_company/communication/meeting/_prompts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Shared prompt builders for meeting protocol implementations."""

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from ai_company.communication.meeting.models import MeetingAgenda


def build_agenda_prompt(agenda: MeetingAgenda) -> str:
Comment on lines +3 to +9

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Python 3.14 with PEP 649 lazy annotations, what happens when a function annotation references a type imported only inside if TYPE_CHECKING:andtyping.get_type_hints() evaluates that annotation?

💡 Result:

With PEP 649 lazy annotations (Python 3.14+), the annotation expression isn’t evaluated at function definition time, so referencing a name that’s only imported under if TYPE_CHECKING: is “harmless” until you actually introspect/evaluate the annotations. However, typing.get_type_hints() does try to resolve/evaluate annotations, and if a referenced forward name can’t be resolved (because the TYPE_CHECKING import never ran at runtime), it raises an exception such as NameError at the moment get_type_hints() evaluates that annotation. [1]

This matches the general PEP 649/749 model: evaluation is deferred, but when the deferred evaluation runs, missing names can still produce runtime errors like NameError. [2]

Implication: if you want get_type_hints() to succeed, you must ensure the referenced type is available in the evaluation namespace (e.g., do a real runtime import, or pass an appropriate globalns/localns), or use annotationlib.get_annotations(..., format=Format.FORWARDREF or Format.STRING) when you need to inspect annotations that may contain undefined symbols without failing. [1]

Sources:
[1] Python 3.14 typing docs for get_type_hints() / TYPE_CHECKING behavior and the NameError warning for unresolvable forward references (https://docs.python.org/bn-in/3.14/library/typing.html)
[2] PEP 749 (implementation notes/examples showing deferred evaluation can still raise NameError when evaluated) (https://peps.python.org/pep-0749/)


🏁 Script executed:

# Check if there's a circular import between models.py and _prompts.py
cd src/ai_company/communication/meeting

echo "=== Checking models.py imports ==="
head -20 models.py | grep -E "^(import|from)" || echo "No imports in first 20 lines"

echo -e "\n=== Checking _prompts.py imports ==="
head -20 _prompts.py | grep -E "^(import|from)" || echo "No imports in first 20 lines"

echo -e "\n=== Checking if models.py imports from _prompts.py ==="
grep -n "_prompts" models.py || echo "No imports from _prompts in models.py"

Repository: Aureliolo/ai-company

Length of output: 477


🏁 Script executed:

# Read full _prompts.py to see the complete function implementation
cat -n src/ai_company/communication/meeting/_prompts.py

Repository: Aureliolo/ai-company

Length of output: 1097


🏁 Script executed:

# Check MeetingAgenda and MeetingAgendaItem definitions in models.py
cat -n src/ai_company/communication/meeting/models.py | head -100

Repository: Aureliolo/ai-company

Length of output: 3382


🏁 Script executed:

# Find usages of build_agenda_prompt
rg "build_agenda_prompt" --type py -B 2 -A 2

Repository: Aureliolo/ai-company

Length of output: 3462


🏁 Script executed:

# Check for test files
find src/ai_company/communication/meeting -name "*test*" -o -name "test_*"

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Check if there's documentation about what presenter_id should do
grep -rn "presenter_id" src/ai_company/communication/meeting/ --type py

Repository: Aureliolo/ai-company

Length of output: 181


🏁 Script executed:

# Search for presenter_id usage in meeting module
rg "presenter_id" src/ai_company/communication/meeting/

Repository: Aureliolo/ai-company

Length of output: 266


🏁 Script executed:

# Check if there are any test files for the prompts
find . -path "*/test*" -name "*prompt*" -o -path "*/test*" -name "*meeting*" | grep -E "(test|spec)" | head -20

Repository: Aureliolo/ai-company

Length of output: 239


🏁 Script executed:

# Check the docstring and usage context more broadly
rg "presenter" src/ai_company/communication/meeting/ -B 2 -A 2

Repository: Aureliolo/ai-company

Length of output: 888


🏁 Script executed:

# Check unit tests for meeting module to see what's expected
ls -la tests/unit/communication/meeting/

Repository: Aureliolo/ai-company

Length of output: 918


🏁 Script executed:

# Look for tests specifically for _prompts.py
cat tests/unit/communication/meeting/*prompt* 2>/dev/null || echo "No prompt tests found, checking all tests"
ls tests/unit/communication/meeting/

Repository: Aureliolo/ai-company

Length of output: 296


🏁 Script executed:

# Check integration test for meeting to see how presenter_id is used
cat tests/integration/communication/test_meeting_integration.py | head -100

Repository: Aureliolo/ai-company

Length of output: 2998


Use a real import for MeetingAgenda here.

build_agenda_prompt() exposes MeetingAgenda in a public annotation, but the name only exists under TYPE_CHECKING. With Python 3.14 lazy annotation evaluation, runtime consumers such as typing.get_type_hints() will raise NameError when trying to resolve the annotation at runtime, since the TYPE_CHECKING block never executes. No circular dependency exists between these modules, so a real import is safe.

Safer import pattern
-from typing import TYPE_CHECKING
-
-if TYPE_CHECKING:
-    from ai_company.communication.meeting.models import MeetingAgenda
+from ai_company.communication.meeting.models import MeetingAgenda
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ai_company.communication.meeting.models import MeetingAgenda
def build_agenda_prompt(agenda: MeetingAgenda) -> str:
from ai_company.communication.meeting.models import MeetingAgenda
def build_agenda_prompt(agenda: MeetingAgenda) -> str:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/communication/meeting/_prompts.py` around lines 3 - 9, The
public annotation for MeetingAgenda in build_agenda_prompt is only available
under TYPE_CHECKING which causes runtime resolution errors; replace the
TYPE_CHECKING-only import with a real import of MeetingAgenda from
ai_company.communication.meeting.models at module level (remove the conditional
block or add a normal import alongside it) so that build_agenda_prompt can be
resolved at runtime by typing.get_type_hints() and other consumers.

"""Build the initial agenda prompt text.

Args:
agenda: The meeting agenda to format.

Returns:
Formatted agenda text for use in agent prompts.
"""
parts = [f"Meeting: {agenda.title}"]
if agenda.context:
parts.append(f"Context: {agenda.context}")
if agenda.items:
parts.append("Agenda items:")
for i, item in enumerate(agenda.items, 1):
entry = f" {i}. {item.title}"
if item.description:
entry += f" — {item.description}"
parts.append(entry)
Comment on lines +23 to +27

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.

⚠️ Potential issue | 🟠 Major

Don't drop presenter_id from the shared agenda prompt.

MeetingAgendaItem includes an optional presenter assignment in src/ai_company/communication/meeting/models.py, but this formatter strips it. Any protocol that uses this helper loses agenda ownership and can prompt the wrong meeting flow for presenter-specific items.

📝 Suggested formatter fix
         for i, item in enumerate(agenda.items, 1):
             entry = f"  {i}. {item.title}"
+            if item.presenter_id:
+                entry += f" (presenter: {item.presenter_id})"
             if item.description:
                 entry += f" — {item.description}"
             parts.append(entry)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/communication/meeting/_prompts.py` around lines 23 - 27, The
agenda item formatter in _prompts.py currently drops
MeetingAgendaItem.presenter_id when building entries from agenda.items; update
the loop that builds entry (for i, item in enumerate(agenda.items, 1)) to
include the optional presenter assignment when present (e.g., append a presenter
indicator using item.presenter_id or resolved presenter name) so presenter
ownership is preserved in the shared agenda prompt and any downstream protocol
that consumes MeetingAgendaItem can route presenter-specific flows correctly.

return "\n".join(parts)
68 changes: 68 additions & 0 deletions src/ai_company/communication/meeting/_token_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Shared token budget tracker for meeting protocol implementations.

Concurrency note: ``TokenTracker`` is safe for use within a single
``asyncio`` event loop (cooperative multitasking). ``record()`` runs
to completion without suspension, so concurrent coroutines sharing a
tracker will not interleave reads and writes. However, intermediate
values of ``remaining`` during a parallel ``TaskGroup`` phase reflect
only the tasks that have completed so far — callers should pre-divide
budgets before launching parallel work rather than checking
``remaining`` inside concurrent tasks.
"""

import dataclasses


@dataclasses.dataclass
class TokenTracker:
"""Mutable token budget tracker scoped to a single meeting execution.

Attributes:
budget: Total token budget for the meeting.
input_tokens: Total prompt tokens consumed so far.
output_tokens: Total response tokens generated so far.
"""

budget: int
input_tokens: int = 0
output_tokens: int = 0

def __post_init__(self) -> None:
"""Validate budget is positive."""
if self.budget <= 0:
msg = f"budget must be positive, got {self.budget}"
raise ValueError(msg)

@property
def used(self) -> int:
"""Total tokens consumed so far."""
return self.input_tokens + self.output_tokens

@property
def remaining(self) -> int:
"""Tokens remaining in the budget."""
return max(0, self.budget - self.used)

@property
def is_exhausted(self) -> bool:
"""Whether the budget is fully consumed."""
return self.remaining == 0

def record(self, input_tokens: int, output_tokens: int) -> None:
"""Record token usage from an agent call.

Args:
input_tokens: Prompt tokens consumed (must be >= 0).
output_tokens: Response tokens generated (must be >= 0).

Raises:
ValueError: If either token count is negative.
"""
if input_tokens < 0 or output_tokens < 0:
msg = (
f"Token counts must be non-negative, got "
f"input_tokens={input_tokens}, output_tokens={output_tokens}"
)
raise ValueError(msg)
self.input_tokens += input_tokens
self.output_tokens += output_tokens
Comment on lines +16 to +68

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.

🛠️ Refactor suggestion | 🟠 Major

Prefer copy-on-write state over mutating TokenTracker in place.

This helper introduces shared mutable state via a dataclass and record(), which goes against the repo’s runtime-state convention and makes retries or parallel branches harder to reason about. Returning an updated tracker, or switching to a frozen Pydantic model plus model_copy(update=...), would keep budget accounting consistent with the rest of the codebase.

As per coding guidelines, "Create new objects instead of mutating existing ones" and "Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (model_copy(update=...)) for runtime state that evolves; never mix static config fields with mutable runtime fields".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/communication/meeting/_token_tracker.py` around lines 6 - 38,
TokenTracker currently mutates shared state via record(), violating the repo
convention of copy-on-write runtime state; replace in-place mutation by making
TokenTracker immutable and having record return a new instance with updated
input_tokens/output_tokens (or convert to a frozen Pydantic model and use
model_copy(update=...)). Update the TokenTracker dataclass to be frozen (or use
Pydantic BaseModel frozen) and change the record method signature to return
TokenTracker with the new totals; keep the used, remaining, and is_exhausted
properties as-is but computed from the new instance to preserve behavior. Ensure
all call sites that previously relied on in-place updates now reassign the
returned TokenTracker from record(...) to propagate state.

Comment on lines +41 to +68

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.

⚠️ Potential issue | 🟠 Major

Fail fast when a token record would go negative or exceed the budget.

record() currently accepts any integers and remaining just clamps at zero, so a bad agent result can silently undercount, overcount, or overspend the meeting budget. This helper is the shared enforcement point, so it should reject negative deltas and raise before mutating when the projected total exceeds budget.

🛡️ Proposed fix
+from ai_company.communication.meeting.errors import MeetingBudgetExhaustedError
+
@@
     def record(self, input_tokens: int, output_tokens: int) -> None:
         """Record token usage from an agent call."""
+        if input_tokens < 0 or output_tokens < 0:
+            msg = "token counts must be non-negative"
+            raise ValueError(msg)
+        projected_total = self.used + input_tokens + output_tokens
+        if projected_total > self.budget:
+            msg = (
+                "meeting token budget exhausted: "
+                f"projected_total={projected_total}, budget={self.budget}"
+            )
+            raise MeetingBudgetExhaustedError(msg)
         self.input_tokens += input_tokens
         self.output_tokens += output_tokens
As per coding guidelines, "Validate at system boundaries (user input, external APIs, config files)" and "Handle errors explicitly; never silently swallow errors".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/communication/meeting/_token_tracker.py` around lines 25 - 38,
The record method currently accepts any integers and can silently
under/over-count tokens; update record(input_tokens, output_tokens) to validate
both deltas are non-negative ints, compute projected_used = self.used +
input_tokens + output_tokens and raise a ValueError (or custom exception) before
mutating if projected_used > self.budget or if either delta < 0; keep remaining
and is_exhausted as-is but rely on this check to prevent negative/overspend
states and perform the validation atomically so
self.input_tokens/self.output_tokens/self.used are only updated after the checks
pass.

Loading