Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ src/ai_company/
communication/ # Message bus, dispatcher, messenger, channels, delegation, loop prevention, conflict resolution, meeting protocol
config/ # YAML company config loading and validation
core/ # Shared domain models and base classes
engine/ # Agent orchestration, execution loops, parallel execution, task decomposition, routing, task assignment, task lifecycle, recovery, shutdown, workspace isolation, and coordination error classification
engine/ # Agent orchestration, execution loops, parallel execution, task decomposition, routing, task assignment, task lifecycle, recovery, shutdown, workspace isolation, coordination error classification, and prompt policy validation
hr/ # HR engine: hiring, firing, onboarding, offboarding, agent registry, performance tracking (task metrics, collaboration scoring, trend detection)
memory/ # Persistent agent memory (Mem0 initial, custom stack future — ADR-001), retrieval pipeline (ranking, injection, context formatting), shared org memory (org/), consolidation/archival (consolidation/)
memory/ # Persistent agent memory (Mem0 initial, custom stack future — ADR-001), retrieval pipeline (ranking, injection, context formatting, non-inferable filtering), shared org memory (org/), consolidation/archival (consolidation/)
persistence/ # Operational data persistence — pluggable PersistenceBackend protocol, SQLite initial (§7.6)
observability/ # Structured logging, correlation tracking, log sinks
providers/ # LLM provider abstraction (LiteLLM adapter)
Expand Down Expand Up @@ -84,7 +84,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`, `CFO_ANOMALY_DETECTED` from `events.cfo`, `CONFLICT_DETECTED` from `events.conflict`, `MEETING_STARTED` from `events.meeting`, `CLASSIFICATION_START` from `events.classification`, `CONSOLIDATION_START` from `events.consolidation`, `ORG_MEMORY_QUERY_START` from `events.org_memory`, `API_REQUEST_STARTED` from `events.api`, `CODE_RUNNER_EXECUTE_START` from `events.code_runner`, `DOCKER_EXECUTE_START` from `events.docker`, `MCP_INVOKE_START` from `events.mcp`, `SECURITY_EVALUATE_START` from `events.security`, `HR_HIRING_REQUEST_CREATED` from `events.hr`, `PERF_METRIC_RECORDED` from `events.performance`). 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`, `CFO_ANOMALY_DETECTED` from `events.cfo`, `CONFLICT_DETECTED` from `events.conflict`, `MEETING_STARTED` from `events.meeting`, `CLASSIFICATION_START` from `events.classification`, `CONSOLIDATION_START` from `events.consolidation`, `ORG_MEMORY_QUERY_START` from `events.org_memory`, `API_REQUEST_STARTED` from `events.api`, `CODE_RUNNER_EXECUTE_START` from `events.code_runner`, `DOCKER_EXECUTE_START` from `events.docker`, `MCP_INVOKE_START` from `events.mcp`, `SECURITY_EVALUATE_START` from `events.security`, `HR_HIRING_REQUEST_CREATED` from `events.hr`, `PERF_METRIC_RECORDED` from `events.performance`, `PROMPT_BUILD_START` from `events.prompt`, `MEMORY_RETRIEVAL_START` from `events.memory`). 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
11 changes: 9 additions & 2 deletions DESIGN_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1608,8 +1608,9 @@ receives memories.
> **Decision ([ADR-002](docs/decisions/ADR-002-design-decisions-batch-1.md) D23):** Pluggable `MemoryFilterStrategy` protocol. Initial: tag-based at write time. Define `non-inferable` tag convention enforced at `MemoryBackend.store()` boundary. System prompt instructs agents what qualifies: design rationale, team decisions, "why not X", cross-repo knowledge = non-inferable; code structure, API signatures, file contents = inferable. Uses existing `MemoryMetadata.tags` and `MemoryQuery.tags` — zero new models needed. Future strategies: LLM classification at retrieval, keyword/pattern heuristic.

Pipeline: `MemoryBackend.retrieve()` -> rank by relevance+recency ->
filter by min_relevance -> greedy token-budget packing -> format as
ChatMessage (configured role: SYSTEM or USER) with delimiters.
filter by min_relevance -> apply `MemoryFilterStrategy` (D23, optional) ->
greedy token-budget packing -> format as ChatMessage (configured role:
SYSTEM or USER) with delimiters.

Ranking algorithm:
1. `relevance = entry.relevance_score ?? config.default_relevance`
Expand Down Expand Up @@ -1961,6 +1962,8 @@ Every completion call produces a `CompletionResponse` with `TokenUsage` (token c
- `tokens_per_task` — total tokens consumed (from `AgentContext.accumulated_cost.total_tokens`)
- `cost_per_task` — total USD cost (from `AgentContext.accumulated_cost.cost_usd` via `AgentRunResult.total_cost_usd`)
- `duration_seconds` — wall-clock execution time in seconds (from `AgentRunResult.duration_seconds`)
- `prompt_tokens` — estimated system prompt tokens (from `SystemPrompt.estimated_tokens`)
- `prompt_token_ratio` — ratio of prompt tokens to total tokens (overhead indicator, `@computed_field`; warns when >0.3)

These are natural overhead indicators — a task consuming 15 turns and 50k tokens for a one-line fix signals a problem.

Expand Down Expand Up @@ -2779,6 +2782,7 @@ ai-company/
│ │ ├── context.py # AgentContext + AgentContextSnapshot
│ │ ├── loop_protocol.py # ExecutionLoop protocol + result models
│ │ ├── metrics.py # TaskCompletionMetrics proxy overhead model
│ │ ├── policy_validation.py # Org policy quality heuristics (non-inferable principle)
│ │ ├── react_loop.py # ReAct loop implementation
│ │ ├── plan_models.py # Plan step, plan, and plan-execute config models
│ │ ├── plan_execute_loop.py # Plan-and-Execute loop implementation
Expand Down Expand Up @@ -2922,7 +2926,9 @@ ai-company/
│ │ ├── protocol.py # MemoryBackend protocol
│ │ ├── ranking.py # ScoredMemory model, rank_memories(), scoring functions
│ │ ├── retrieval_config.py # MemoryRetrievalConfig (weights, thresholds, strategy selection)
│ │ ├── filter.py # MemoryFilterStrategy protocol, TagBasedMemoryFilter, PassthroughMemoryFilter
│ │ ├── retriever.py # ContextInjectionStrategy (full retrieval → rank → format pipeline)
│ │ ├── store_guard.py # Advisory non-inferable tag enforcement at store boundary
│ │ ├── shared.py # SharedKnowledgeStore protocol
│ │ ├── consolidation/ # Memory consolidation — strategies, retention, archival
│ │ │ ├── __init__.py
Expand Down Expand Up @@ -2992,6 +2998,7 @@ ai-company/
│ │ │ ├── role.py # ROLE_* constants
│ │ │ ├── routing.py # ROUTING_* constants
│ │ │ ├── sandbox.py # SANDBOX_* constants
│ │ │ ├── security.py # SECURITY_* constants
│ │ │ ├── task.py # TASK_* constants
│ │ │ ├── task_assignment.py # TASK_ASSIGNMENT_* constants
│ │ │ ├── task_routing.py # TASK_ROUTING_* constants
Expand Down
16 changes: 16 additions & 0 deletions src/ai_company/engine/agent_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
EXECUTION_ENGINE_TIMEOUT,
EXECUTION_RECOVERY_FAILED,
)
from ai_company.observability.events.prompt import PROMPT_TOKEN_RATIO_HIGH
from ai_company.observability.events.security import SECURITY_DISABLED
from ai_company.providers.enums import MessageRole
from ai_company.providers.models import ChatMessage
Expand Down Expand Up @@ -91,6 +92,9 @@

logger = get_logger(__name__)

_PROMPT_TOKEN_RATIO_THRESHOLD: float = 0.3
"""Prompt-to-total token ratio above which a warning is emitted."""

_DEFAULT_RECOVERY_STRATEGY = FailAndReassignStrategy()
"""Module-level default instance for the recovery strategy."""

Expand Down Expand Up @@ -760,8 +764,20 @@ def _log_completion(
tokens_per_task=metrics.tokens_per_task,
cost_per_task=metrics.cost_per_task,
duration_seconds=metrics.duration_seconds,
prompt_tokens=metrics.prompt_tokens,
prompt_token_ratio=metrics.prompt_token_ratio,
)

if metrics.prompt_token_ratio > _PROMPT_TOKEN_RATIO_THRESHOLD:
logger.warning(
PROMPT_TOKEN_RATIO_HIGH,
agent_id=agent_id,
task_id=task_id,
prompt_token_ratio=metrics.prompt_token_ratio,
prompt_tokens=metrics.prompt_tokens,
total_tokens=metrics.tokens_per_task,
)

def _handle_budget_error( # noqa: PLR0913
self,
*,
Expand Down
19 changes: 18 additions & 1 deletion src/ai_company/engine/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from typing import TYPE_CHECKING

from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, computed_field

from ai_company.core.types import NotBlankStr # noqa: TC001

Expand All @@ -27,6 +27,9 @@ class TaskCompletionMetrics(BaseModel):
tokens_per_task: Total tokens consumed (input + output).
cost_per_task: Total USD cost for the task.
duration_seconds: Wall-clock execution time in seconds.
prompt_tokens: Estimated system prompt tokens.
prompt_token_ratio: Ratio of prompt tokens to total tokens
(overhead indicator, derived via ``@computed_field``).
"""

model_config = ConfigDict(frozen=True)
Expand All @@ -52,6 +55,19 @@ class TaskCompletionMetrics(BaseModel):
ge=0.0,
description="Wall-clock execution time in seconds",
)
prompt_tokens: int = Field(
default=0,
ge=0,
description="Estimated system prompt tokens",
)

@computed_field # type: ignore[prop-decorator]
@property
def prompt_token_ratio(self) -> float:
"""Ratio of prompt tokens to total tokens (overhead indicator)."""
if self.tokens_per_task > 0:
return self.prompt_tokens / self.tokens_per_task
return 0.0

@classmethod
def from_run_result(cls, result: AgentRunResult) -> TaskCompletionMetrics:
Expand All @@ -72,4 +88,5 @@ def from_run_result(cls, result: AgentRunResult) -> TaskCompletionMetrics:
tokens_per_task=accumulated.total_tokens,
cost_per_task=result.total_cost_usd,
duration_seconds=result.duration_seconds,
prompt_tokens=result.system_prompt.estimated_tokens,
)
Comment on lines 102 to 111

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

prompt_tokens is populated from result.system_prompt.estimated_tokens, but the system prompt message is included in ctx.conversation and is resent on every provider call. Since tokens_per_task aggregates tokens across all turns, prompt_token_ratio will be underestimated for multi-turn runs. Consider either (a) making prompt_tokens represent total prompt tokens across the run (e.g., estimate × result.total_turns), or (b) renaming the field to clarify it's per-call and adjusting the ratio/warning accordingly.

Copilot uses AI. Check for mistakes.
185 changes: 185 additions & 0 deletions src/ai_company/engine/policy_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Org policy quality validation heuristics.

Applies lightweight checks to detect policies that likely violate the
non-inferable principle — e.g. policies that describe codebase structure
(inferable by reading the repo) rather than actionable constraints.

Examples of **good** policies (non-inferable, actionable):

- ``"All API responses must include a correlation_id header"``
- ``"Never store PII in memory without encryption"``
- ``"Escalate budget overruns above $5 to the CFO"``

Examples of **bad** policies (inferable or non-actionable):

- ``"The project uses Python 3.14"`` — discoverable from pyproject.toml
- ``"src/api/ contains REST controllers"`` — discoverable by reading code
- ``"x"`` — too short to be actionable
"""

import re
from typing import Final, Literal

from pydantic import BaseModel, ConfigDict, Field

from ai_company.observability import get_logger
from ai_company.observability.events.prompt import PROMPT_POLICY_QUALITY_ISSUE

logger = get_logger(__name__)

_MIN_POLICY_LENGTH: Final[int] = 10
_MAX_POLICY_LENGTH: Final[int] = 500

# Patterns that suggest inferable codebase context rather than a policy.
_CODE_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
re.compile(r"(?:src|tests|lib|app)/[\w/]+\.py"), # file paths
re.compile(r"\bfrom\s+\w+\s+import\b"), # Python imports
re.compile(r"\bimport\s+\w+"), # bare imports
re.compile(r"\bdef\s+\w+\s*\("), # function definitions
re.compile(r"\bclass\s+\w+[\s:(]"), # class definitions
)
Comment on lines +38 to +44

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The _CODE_PATTERNS regexes are case-sensitive (e.g. \bimport\s+\w+), so policies containing capitalized forms like Import json / From x import y won't be detected. Consider compiling these patterns with re.IGNORECASE (or running them against policy.lower()) to make the heuristic robust to capitalization.

Copilot uses AI. Check for mistakes.

# Action verbs that signal an actionable constraint.
_ACTION_VERBS: Final[frozenset[str]] = frozenset(
{
"must",
"should",
"always",
"never",
"require",
"ensure",
"prohibit",
"enforce",
"restrict",
"mandate",
"avoid",
"prefer",
"escalate",
"approve",
"deny",
"reject",
"validate",
"verify",
}
)


class PolicyQualityIssue(BaseModel):
"""A quality issue found in an org policy.

Attributes:
policy: The policy text that triggered the issue.
issue: Human-readable description of the problem.
severity: ``"warning"`` for advisory, ``"error"`` for likely invalid.
"""

model_config = ConfigDict(frozen=True)

policy: str = Field(description="The policy text that triggered the issue")
issue: str = Field(description="Human-readable description of the problem")
severity: Literal["warning", "error"] = Field(
description="Issue severity (``'error'`` reserved for future stricter checks)",
)


def validate_policy_quality(
policies: tuple[str, ...],
) -> tuple[PolicyQualityIssue, ...]:
"""Check org policies for non-inferable principle violations.

Applies heuristic checks — results are advisory and never block
prompt construction.

Args:
policies: Org policy texts to validate.

Returns:
Tuple of quality issues found (empty if all policies pass).
"""
logger.debug(
PROMPT_POLICY_QUALITY_ISSUE,
phase="start",
policy_count=len(policies),
)
Comment on lines +107 to +110

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

validate_policy_quality() logs a DEBUG event using PROMPT_POLICY_QUALITY_ISSUE with phase="start". This means the same event name will be emitted even when there are zero issues, which can skew event-based analytics (counts of “quality issues”). Consider using a distinct start event constant or a differently named event for the start log.

Copilot uses AI. Check for mistakes.
issues: list[PolicyQualityIssue] = []
for policy in policies:
issues.extend(_check_single_policy(policy))

for issue in issues:
logger.warning(
PROMPT_POLICY_QUALITY_ISSUE,
policy=issue.policy[:80],
issue=issue.issue,
severity=issue.severity,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return tuple(issues)


_ACTION_VERB_RE: re.Pattern[str] = re.compile(
r"\b(?:" + "|".join(_ACTION_VERBS) + r")\b",
)
Comment on lines +126 to +128

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

_ACTION_VERB_RE is built from a frozenset, so the alternation order in the generated regex depends on hash iteration order and can vary across processes. Behavior is equivalent, but it makes the compiled pattern non-deterministic for debugging and can cause avoidable diffs if the pattern string is ever surfaced. Consider building it from sorted(_ACTION_VERBS) for deterministic output.

Copilot uses AI. Check for mistakes.


def _check_single_policy(policy: str) -> list[PolicyQualityIssue]:
"""Run all heuristic checks on a single policy string.

Args:
policy: The policy text to validate.

Returns:
List of quality issues found (empty if the policy passes all checks).
"""
found: list[PolicyQualityIssue] = []

if len(policy) < _MIN_POLICY_LENGTH:
found.append(
PolicyQualityIssue(
policy=policy,
issue=(
f"Too short ({len(policy)} chars) — likely not an actionable policy"
),
severity="warning",
),
)

if len(policy) > _MAX_POLICY_LENGTH:
found.append(
PolicyQualityIssue(
policy=policy,
issue=(
f"Too long ({len(policy)} chars) — "
f"may contain inferable context rather than a policy"
),
severity="warning",
),
)

for pattern in _CODE_PATTERNS:
if pattern.search(policy):
found.append(
PolicyQualityIssue(
policy=policy,
issue=(
"Contains code patterns (file paths, imports, or "
"definitions) — likely inferable from the codebase"
),
severity="warning",
),
)
break # One code-pattern match is sufficient.

policy_lower = policy.lower()
if not _ACTION_VERB_RE.search(policy_lower):
found.append(
PolicyQualityIssue(
policy=policy,
issue=(
"Missing action verbs (must, should, always, never, "
"etc.) — may not be an actionable policy"
),
severity="warning",
),
)

return found

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

Split _check_single_policy() into focused helpers.

This helper is already over the 50-line limit and now mixes length, code-pattern, and action-verb heuristics. Extract those checks into small helpers so future rule changes stay isolated and easier to test.

Refactor sketch
 def _check_single_policy(policy: str) -> list[PolicyQualityIssue]:
-    found: list[PolicyQualityIssue] = []
-
-    if len(policy) < _MIN_POLICY_LENGTH:
-        found.append(...)
-
-    if len(policy) > _MAX_POLICY_LENGTH:
-        found.append(...)
-
-    for pattern in _CODE_PATTERNS:
-        if pattern.search(policy):
-            found.append(...)
-            break
-
-    policy_lower = policy.lower()
-    if not _ACTION_VERB_RE.search(policy_lower):
-        found.append(...)
-
-    return found
+    return [
+        *_check_policy_length(policy),
+        *_check_code_patterns(policy),
+        *_check_action_verbs(policy),
+    ]

As per coding guidelines "Keep functions under 50 lines and files under 800 lines".

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

In `@src/ai_company/engine/policy_validation.py` around lines 124 - 185, Split the
long _check_single_policy function into focused helpers: implement helpers like
_check_policy_length(policy) (using _MIN_POLICY_LENGTH and _MAX_POLICY_LENGTH
and returning list[PolicyQualityIssue]), _check_policy_code_patterns(policy)
(using _CODE_PATTERNS and preserving the single-match break behavior), and
_check_policy_action_verbs(policy) (using _ACTION_VERB_RE), then have
_check_single_policy simply call and aggregate results from these helpers; keep
all existing messages/severity and the PolicyQualityIssue construction identical
so behavior and tests remain unchanged, add small docstrings for each helper and
update or add unit tests as needed.

Loading