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
20 changes: 18 additions & 2 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@
"type": "object"
},
"AgentStatus": {
"description": "The Agent's lifecycle state.\n\nFour values:\n\n - `Defined` -- registered as config; NOT yet ready for\n invocation (8f-b's subscriber filters on\n Versioned only).\n - `Versioned` -- promoted to ready-for-invocation; rainbow-\n deploy-style signal. Multiple Versioned\n Agents may exist concurrently (different\n `id`s sharing `kind`).\n - `Suspended` -- non-terminal operator-pause from\n `Versioned`. Returns to\n `Versioned` via `resume_agent`. Config\n changes (tools, budget) still permitted so\n the operator can fix permissions while\n paused. Cannot re-Version from Suspended\n (resume is its own dedicated verb).\n - `Deprecated` -- terminal; cannot be re-Defined or re-\n Versioned. Future invocations must pick a\n non-Deprecated Agent of the same kind.\n Reachable from `Defined`, `Versioned`, or\n `Suspended`.",
"description": "The Agent's lifecycle state.\n\nFour values:\n\n - `Defined` -- registered as config; NOT yet ready for\n invocation (the LLM subscribers' lifecycle\n gate fires on Versioned only).\n - `Versioned` -- promoted to ready-for-invocation; rainbow-\n deploy-style signal. Multiple Versioned\n Agents may exist concurrently (different\n `id`s sharing `kind`).\n - `Suspended` -- non-terminal operator-pause from\n `Versioned`. Returns to\n `Versioned` via `resume_agent`. Config\n changes (tools, budget) still permitted so\n the operator can fix permissions while\n paused. Cannot re-Version from Suspended\n (resume is its own dedicated verb).\n - `Deprecated` -- terminal; cannot be re-Defined or re-\n Versioned. Future invocations must pick a\n non-Deprecated Agent of the same kind.\n Reachable from `Defined`, `Versioned`, or\n `Suspended`.",
"enum": [
"Defined",
"Versioned",
Expand Down Expand Up @@ -10754,6 +10754,7 @@
"type": "null"
}
],
"description": "OTel gen_ai.agent.id. Principal-bound: when set, it MUST equal the calling principal's id (agent-attributed entries are the spend ledger the AgentBudget gate sums; self-reporting only). Mismatches reject the whole batch with 403.",
"title": "Agent Id"
},
"agent_name": {
Expand All @@ -10780,6 +10781,19 @@
],
"title": "Conversation Id"
},
"cost_usd": {
"anyOf": [
{
"minimum": 0.0,
"type": "number"
},
{
"type": "null"
}
],
"description": "Actual call cost in USD computed from usage tokens and provider pricing (CORA custom; no OTel attribute exists for call cost).",
"title": "Cost Usd"
},
"duration": {
"anyOf": [
{
Expand Down Expand Up @@ -10811,6 +10825,7 @@
"input_tokens": {
"anyOf": [
{
"maximum": 1000000000.0,
"minimum": 0.0,
"type": "integer"
},
Expand Down Expand Up @@ -10849,6 +10864,7 @@
"output_tokens": {
"anyOf": [
{
"maximum": 1000000000.0,
"minimum": 0.0,
"type": "integer"
},
Expand Down Expand Up @@ -28164,7 +28180,7 @@
}
}
},
"description": "Authorize port denied the command."
"description": "Authorize port denied the command, or an entry's agent_id does not match the calling principal (agent-attributed entries must be self-reported)."
},
"404": {
"content": {
Expand Down
140 changes: 140 additions & 0 deletions apps/api/src/cora/agent/_budget_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Coarse post-hoc budget gate for LLM-backed agents.

The enforcement seam that turns a declared `AgentBudget` into a real
control at the coarse tier of the enforcement ladder: debit after each
call (the recorded `cost_usd` on the inference entry) and refuse the
NEXT call once a cap is exhausted. Overspend is bounded to about one
in-flight call per caller, adequate for the cheap, serialized
subscriber agents (RunDebriefer, CautionDrafter). Autonomous or
expensive-long-context callers need the per-call pre-estimate tier
before this gate alone is safe (one call can exhaust a balance before
a post-hoc gate fires).

## Windows

`monthly_usd_cap` is summed over the UTC calendar month containing
`as_of`; `daily_token_cap` over the UTC calendar day. `as_of` is the
triggering event's `occurred_at`, NOT wall clock, so a replayed work
item gates identically (the non-determinism rule: subscribers decide
from event facts, not ambient time).

## Failure direction

The spend sums undercount (NULL-cost legacy rows, unrecorded cache
tokens; see the SpendLookup port docstring), so the gate errs
PERMISSIVE. A cap of exactly zero refuses every call: the `AgentBudget`
value object already documents zero as recorded no-spend intent, and
the gate is what makes that intent real.

A SpendLookup ERROR, by contrast, propagates: the subscriber's apply
raises, the projection worker's retry loop with backoff is the failure
handler, and the bookmark does not advance. The gate never fails OPEN
on a lookup error; wrapping it in a permissive except would let a
database outage disable enforcement silently.
"""

from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from cora.agent.aggregates.agent import Agent
from cora.infrastructure.ports.spend_lookup import SpendLookup


@dataclass(frozen=True)
class BudgetBreach:
"""One exhausted cap: which, by how much, over which window."""

cap_kind: str # "monthly_usd_cap" | "daily_token_cap"
cap_value: float
spent: float
window_start: datetime
window_end: datetime

def describe(self) -> str:
"""One human-readable sentence for a deferred Decision's reasoning."""
return (
f"{self.cap_kind} of {self.cap_value:g} reached "
f"(spent {self.spent:g} in the window starting "
f"{self.window_start.isoformat()})"
)


def calendar_month_window(as_of: datetime) -> tuple[datetime, datetime]:
"""Half-open UTC calendar month `[first, first-of-next)` containing `as_of`."""
at = as_of.astimezone(UTC)
start = datetime(at.year, at.month, 1, tzinfo=UTC)
if at.month == 12:
end = datetime(at.year + 1, 1, 1, tzinfo=UTC)
else:
end = datetime(at.year, at.month + 1, 1, tzinfo=UTC)
return start, end


def calendar_day_window(as_of: datetime) -> tuple[datetime, datetime]:
"""Half-open UTC calendar day `[midnight, next-midnight)` containing `as_of`."""
at = as_of.astimezone(UTC)
start = datetime(at.year, at.month, at.day, tzinfo=UTC)
return start, start + timedelta(days=1)


async def find_budget_breach(
*,
agent: "Agent | None",
spend_lookup: "SpendLookup",
as_of: datetime,
) -> BudgetBreach | None:
"""Return the first exhausted cap for this agent, or None to permit.

An agent with no declared budget (or no Agent stream at all) is
unlimited: declaration is opt-in, so absence of caps must never
block. Caps are checked in declaration order (monthly USD, then
daily tokens); the first breach wins, one SpendLookup SUM per
declared cap.
"""
if agent is None or agent.budget is None:
return None
budget = agent.budget

if budget.monthly_usd_cap is not None:
window_start, window_end = calendar_month_window(as_of)
spend = await spend_lookup.find_agent_spend(
agent_id=agent.id,
window_start=window_start,
window_end=window_end,
)
if spend.usd_spent >= budget.monthly_usd_cap:
return BudgetBreach(
cap_kind="monthly_usd_cap",
cap_value=budget.monthly_usd_cap,
spent=spend.usd_spent,
window_start=window_start,
window_end=window_end,
)

if budget.daily_token_cap is not None:
window_start, window_end = calendar_day_window(as_of)
spend = await spend_lookup.find_agent_spend(
agent_id=agent.id,
window_start=window_start,
window_end=window_end,
)
if spend.tokens_spent >= budget.daily_token_cap:
return BudgetBreach(
cap_kind="daily_token_cap",
cap_value=float(budget.daily_token_cap),
spent=float(spend.tokens_spent),
window_start=window_start,
window_end=window_end,
)

return None


__all__ = [
"BudgetBreach",
"calendar_day_window",
"calendar_month_window",
"find_budget_breach",
]
15 changes: 8 additions & 7 deletions apps/api/src/cora/agent/adapters/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,15 +229,16 @@ async def chat(self, request: LLMChatRequest) -> LLMResponse:

# `record_llm_call` returns the computed USD cost; the
# adapter intentionally discards it here. The cost is
# already persisted on the `cora.agent.llm.cost.usd`
# histogram (where dashboards consume it), and surfacing
# it on `LLMResponse` would force every consumer to think
# about pricing semantics that are an observability
# concern, not a domain concern. Tests that want the
# cost value call `compute_cost_usd` directly.
# persisted on the `cora.agent.llm.cost.usd` histogram
# (where dashboards consume it), and durably by the LLM
# producers, which recompute it via `compute_cost_usd`
# (same pure function, same inputs) onto the inference
# entry's cost_usd. Surfacing it on `LLMResponse` would
# force every consumer to think about pricing semantics;
# consumers that want the value compute it from usage.
record_llm_call(
span,
system="anthropic",
provider_name="anthropic",
request_model_ref=request.model_ref,
response_model_id=response_model_id,
usage=usage,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cora/agent/aggregates/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
AgentNotFoundError,
AgentNotSeededError,
AgentStatus,
AgentSuspendedError,
AgentSuspensionReason,
AgentVersion,
InvalidAgentBudgetError,
Expand Down Expand Up @@ -129,6 +130,7 @@
"AgentResumed",
"AgentStatus",
"AgentSuspended",
"AgentSuspendedError",
"AgentSuspensionReason",
"AgentTargetPlanSet",
"AgentToolGranted",
Expand Down
46 changes: 32 additions & 14 deletions apps/api/src/cora/agent/aggregates/agent/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ class AgentStatus(StrEnum):
Four values:

- `Defined` -- registered as config; NOT yet ready for
invocation (8f-b's subscriber filters on
Versioned only).
invocation (the LLM subscribers' lifecycle
gate fires on Versioned only).
- `Versioned` -- promoted to ready-for-invocation; rainbow-
deploy-style signal. Multiple Versioned
Agents may exist concurrently (different
Expand Down Expand Up @@ -469,6 +469,24 @@ def __init__(self, agent_id: UUID) -> None:
self.agent_id = agent_id


class AgentSuspendedError(Exception):
"""Cross-aggregate state-gate failure: the Agent is `Suspended`.

Raised by operator-triggered slices (the regenerate path) when the
target agent is under the reversible operator pause: suspension
means the agent takes no actions, LLM calls included, and driving
it through an on-demand slice would defeat the pause. Recovery is
operator-side: `resume_agent` first.

Mirrors the subscribers' lifecycle gate (only a Versioned agent
acts).
"""

def __init__(self, agent_id: UUID) -> None:
super().__init__(f"Agent {agent_id} is suspended; resume via resume_agent before invoking")
self.agent_id = agent_id


# ---------------------------------------------------------------------------
# Bounded-text value objects
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -660,25 +678,25 @@ class ToolName:

@dataclass(frozen=True)
class AgentBudget:
"""Optional per-agent budget caps (declaration only at this layer).
"""Optional per-agent budget caps, enforced at the coarse post-hoc tier.

Both `monthly_usd_cap` and `daily_token_cap` are independently
nullable so the same VO covers "set both", "set one, clear the
other", and "set new monthly while keeping daily" cases. At least
one must be non-None at construction (the no-budget shape is
`Agent.budget = None` directly).

Enforcement is deferred to a future Budget BC (watch item in
[[project-agent-lifecycle-grants-design]]); these are
declaration-only fields today. Cost telemetry already lands on
`gen_ai.cost.usd` via the `gen_ai` observability helper so the
Budget BC can begin enforcement without further per-agent surface
work.

Zero caps allowed: recorded as the "no spend" intent, but NOT
enforced today. A 0 cap blocks nothing (same as any other cap) until
the Budget BC lands; the future enforcement layer can treat zero as a
hard stop. Negative caps rejected.
Enforcement: the coarse post-hoc gate (`cora.agent._budget_gate`,
consumed by the LLM subscribers) sums recorded spend from the
inference entries' `cost_usd` and refuses the next call once a cap
is exhausted; per-call telemetry also lands on the
`cora.agent.llm.cost.usd` histogram. Finer tiers (per-call
pre-estimate, reserve-post-void) and a full Budget BC remain
deferred (watch item in [[project-agent-lifecycle-grants-design]]).

Zero caps allowed and enforced as the recorded "no spend" intent:
the gate treats a 0 cap as a hard stop (zero spent >= zero cap
refuses every call). Negative caps rejected.
"""

monthly_usd_cap: float | None
Expand Down
25 changes: 23 additions & 2 deletions apps/api/src/cora/agent/features/regenerate_run_debrief/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@
HTTP 404 (already-mapped by Run BC's routes).
- RunDebriefer Agent's Actor must exist and be active; raises
`AgentNotSeededError` / `AgentDeactivatedError` (both in
`cora.agent.aggregates.agent.state`) -> HTTP 400.
`cora.agent.aggregates.agent.state`) -> HTTP 400. A Suspended
agent raises `AgentSuspendedError` (resume first); the budget
gate is deliberately NOT applied on this operator-triggered
path (conscious, human-accountable spend; still debited).
- When `parent_decision_id` is supplied: parent Decision must
exist (`DecisionParentNotFoundError` from
`cora.decision.aggregates.decision`; HTTP 409 per Decision
Expand Down Expand Up @@ -58,7 +61,13 @@
from uuid import UUID, uuid5

from cora.access.aggregates.actor import load_actor
from cora.agent.aggregates.agent import AgentDeactivatedError, AgentNotSeededError
from cora.agent.aggregates.agent import (
AgentDeactivatedError,
AgentNotSeededError,
AgentStatus,
AgentSuspendedError,
load_agent,
)
from cora.agent.errors import UnauthorizedError
from cora.agent.features.regenerate_run_debrief.command import RegenerateRunDebrief
from cora.agent.features.regenerate_run_debrief.context import RegenerateRunDebriefContext
Expand All @@ -82,6 +91,7 @@
from cora.infrastructure.event_envelope import to_new_event
from cora.infrastructure.kernel import Kernel
from cora.infrastructure.logging import get_logger
from cora.infrastructure.observability.gen_ai import compute_cost_usd
from cora.infrastructure.ports import (
AgentInferenceTrace,
Deny,
Expand Down Expand Up @@ -180,6 +190,16 @@ async def handler(
if not actor.active:
raise AgentDeactivatedError(RUN_DEBRIEFER_AGENT_ID)

# Suspension gate: the reversible operator pause means the agent
# takes no actions, and an on-demand regenerate must not defeat
# it; the operator resumes first. The BUDGET gate is deliberately
# absent here: an operator-triggered regenerate is a conscious,
# human-accountable spend (the coarse post-hoc tier targets the
# autonomous subscribers), and the call still debits the ledger.
agent = await load_agent(deps.event_store, RUN_DEBRIEFER_AGENT_ID)
if agent is not None and agent.status is AgentStatus.SUSPENDED:
raise AgentSuspendedError(RUN_DEBRIEFER_AGENT_ID)

# Pre-load parent Decision when ref set; enforce same-agent +
# same-Run scope.
if command.parent_decision_id is not None:
Expand Down Expand Up @@ -339,6 +359,7 @@ async def _record_inference(
finish_reasons=(response.stop_reason,) if response.stop_reason else (),
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
cost_usd=compute_cost_usd(request.model_ref, response.usage),
request_max_tokens=request.max_output_tokens,
agent_id=str(RUN_DEBRIEFER_AGENT_ID),
agent_name=RUN_DEBRIEFER_AGENT_NAME,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/cora/agent/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
AgentDeactivatedError,
AgentNotFoundError,
AgentNotSeededError,
AgentSuspendedError,
InvalidAgentBudgetError,
InvalidAgentCanonicalUriError,
InvalidAgentCapabilitiesError,
Expand Down Expand Up @@ -192,6 +193,7 @@ def register_agent_routes(app: FastAPI) -> None:
InvalidModelRefError,
AgentNotSeededError,
AgentDeactivatedError,
AgentSuspendedError,
# promote_caution_proposal validation errors.
DecisionNotCautionProposalError,
CautionProposalNotActionableError,
Expand Down
Loading
Loading