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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ LOG_LEVEL=INFO
# alone must now also set LLM_ENABLED=true, or those two stop registering.
# Nothing crashes; a boot warning names the switch and /readyz reports "off".
#
# RUN_DEBRIEFER_AGENT_ID / CAUTION_DRAFTER_AGENT_ID let a deployment
# designate WHICH Agent each LLM subscriber acts as, instead of always
# acting as the seeded singleton. Unset (the default) means the seeded
# singleton, so nothing changes on upgrade. The named Agent must already
# exist: define it first through the gated `POST /agents` (`define_agent`)
# path, and its declared model must be reachable by the configured
# LLM_PROVIDER (`anthropic` / `argo` / `local`, see `Settings.llm_provider`),
# or every call the subscriber makes will be refused by the adapter at
# request time. This matters for a deployment whose network cannot reach
# the seeded agents' declared provider (eg. `anthropic` from a controls
# network with no internet): define an Agent against a reachable provider
# (`argo` or `local`) and name it here.
# RUN_DEBRIEFER_AGENT_ID=
# CAUTION_DRAFTER_AGENT_ID=
#
# NOTE this is a DIFFERENT axis from actuation (CONTROL_WRITES_ENABLED and
# COMPUTE_SUBSTRATE above). Those bound what CORA can MOVE; these bound the
# LLM path. A facility told "read-only" hears the first and would still
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/cora/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

from cora.agent._pricing_bridge import refresh_language_model_pricing
from cora.agent._projections import register_agent_projections
from cora.agent._subscribers import register_agent_subscribers
from cora.agent._subscribers import register_agent_subscribers, report_designated_agents
from cora.agent.aggregates.agent import load_agent
from cora.agent.build_llm import build_llm
from cora.agent.errors import (
Expand Down Expand Up @@ -135,6 +135,7 @@
"register_agent_routes",
"register_agent_subscribers",
"register_agent_tools",
"report_designated_agents",
"seed_authority_revocation_holder_agent",
"seed_calibration_watcher_agent",
"seed_campaign_watcher_agent",
Expand Down
66 changes: 65 additions & 1 deletion apps/api/src/cora/agent/_subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,27 @@
the `dismiss_event_in_reaction` slice. Further widening (a separate
`ReactionWorker` with its own pool budget) stays deferred behind the
next named triggers: 3rd Reaction OR first wedged-bookmark incident.

## Subscriber agent designation

`settings.run_debriefer_agent_id` / `settings.caution_drafter_agent_id`
let a deployment designate a non-seeded Agent for either LLM
subscriber to act as (a deployment whose configured `llm_provider`
cannot reach the seeded agents' declared provider defines its own
Agent and names it here). `report_designated_agents(deps)`, called
from `cora.api.main` after both singleton seeds, logs which Agent is
effective for each subscriber and warns on a provider mismatch; it is
a report, not a gate, so it never blocks boot.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from cora.agent.aggregates.agent import load_agent
from cora.agent.build_llm import llm_unwired_reason
from cora.agent.seed import RUN_DEBRIEFER_AGENT_ID
from cora.agent.seed_caution_drafter import CAUTION_DRAFTER_AGENT_ID
from cora.agent.subscribers.authority_revocation_holder import (
make_authority_revocation_holder_subscriber,
)
Expand Down Expand Up @@ -161,4 +175,54 @@ def register_agent_subscribers(registry: ProjectionRegistry, deps: Kernel) -> No
)


__all__ = ["register_agent_subscribers"]
async def report_designated_agents(deps: Kernel) -> None:
"""Log which Agent each LLM subscriber will act as, and whether its
declared provider matches `settings.llm_provider`.

Called from `cora.api.main` AFTER both singleton seeds, so the
default (unset designation) case always resolves an Agent. This is
a REPORT, never a gate: it does not skip, raise, or refuse boot on a
mismatch. The LLM adapter already refuses a request whose
`model_ref.provider` isn't its own and is the authority on whether
the pairing works; a second gate here would re-decide a verdict
that already exists. The warning exists only because the adapter's
refusal arrives per-event, hours later, as a deferred Decision that
never names the mismatch -- an operator reading the boot log should
see it up front instead.

A designated-but-missing Agent (misconfiguration) logs a warning
and moves on; the subscriber's own per-apply gate is what actually
skips work for that case.
"""
for subscriber_name, agent_id in (
("run_debriefer", deps.settings.run_debriefer_agent_id or RUN_DEBRIEFER_AGENT_ID),
("caution_drafter", deps.settings.caution_drafter_agent_id or CAUTION_DRAFTER_AGENT_ID),
):
agent = await load_agent(deps.event_store, agent_id)
if agent is None:
_log.warning(
"agent_subscriber.designated_agent_not_found",
subscriber=subscriber_name,
agent_id=str(agent_id),
)
continue
_log.info(
"agent_subscriber.designated_agent",
subscriber=subscriber_name,
agent_id=str(agent_id),
agent_name=agent.name.value,
agent_kind=agent.kind.value,
provider=agent.model_ref.provider,
model=agent.model_ref.model,
)
if agent.model_ref.provider != deps.settings.llm_provider:
_log.warning(
"agent_subscriber.designated_agent_provider_mismatch",
subscriber=subscriber_name,
agent_id=str(agent_id),
agent_provider=agent.model_ref.provider,
configured_llm_provider=deps.settings.llm_provider,
)


__all__ = ["register_agent_subscribers", "report_designated_agents"]
82 changes: 67 additions & 15 deletions apps/api/src/cora/agent/subscribers/caution_drafter.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@

from cora.access.aggregates.actor import load_actor
from cora.agent._budget_gate import find_allocation_breach, find_budget_breach
from cora.agent._model_ref import to_port_model_ref
from cora.agent._subscriber_lease import attempt_debrief_lease
from cora.agent.aggregates.agent import AgentStatus, load_agent
from cora.agent.prompts import (
Expand All @@ -90,6 +91,7 @@
ExistingCaution,
build_caution_drafter_chat_request,
)
from cora.agent.prompts.caution_drafter import DEFAULT_CAUTION_DRAFTER_MODEL
from cora.agent.seed_caution_drafter import (
CAUTION_DRAFTER_AGENT_ID,
CAUTION_DRAFTER_AGENT_KIND,
Expand Down Expand Up @@ -184,9 +186,11 @@ class CautionDrafterSubscriber:
satisfies the `Reaction` Protocol structurally.

Holds references to the LLM port, event store, and CautionLookup
port. The Decision's `actor_id` is the seeded CautionDrafter
Agent's id (== that agent's Actor.id per 8f-a's identity-sharing
invariant).
port. The Decision's `actor_id` is the CautionDrafter Agent this
subscriber acts as (== that agent's Actor.id per 8f-a's
identity-sharing invariant): the seeded singleton by default, or a
deployment-designated Agent when `settings.caution_drafter_agent_id`
names one (see `_agent_id`).

`batch_size = 1` for the same reason as RunDebriefer: the apply
path includes a slow LLM round-trip, so holding the bookmark
Expand All @@ -208,11 +212,17 @@ def __init__(
inference_recorder: InferenceRecorder | None = None,
spend_lookup: SpendLookup | None = None,
allocation_lookup: AllocationLookup | None = None,
agent_id: UUID = CAUTION_DRAFTER_AGENT_ID,
) -> None:
self.event_store = event_store
self.llm = llm
self.caution_lookup = caution_lookup
self.signer = signer
# Which Agent this subscriber acts as. Defaults to the seeded
# singleton so the class stays unit-testable without Settings;
# `make_caution_drafter_subscriber` passes the deployment's
# `settings.caution_drafter_agent_id` designation when set.
self._agent_id = agent_id
# Defaults to the no-op recorder so direct test construction stays
# inert; production wiring passes the Kernel's recorder via
# `make_caution_drafter_subscriber`.
Expand Down Expand Up @@ -267,32 +277,59 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None:

# Pre-load the Agent's Actor + revocation gate (mirrors
# RunDebriefer verbatim).
actor = await load_actor(self.event_store, CAUTION_DRAFTER_AGENT_ID)
actor = await load_actor(self.event_store, self._agent_id)
if actor is None:
# No Agent fold to name here (the Actor itself is missing),
# so the log carries the id only -- a bare `agent_name`
# constant would misname a designated Agent under
# designation.
log.warning(
"caution_drafter.skip.agent_actor_missing",
agent_id=str(CAUTION_DRAFTER_AGENT_ID),
agent_name=CAUTION_DRAFTER_AGENT_NAME,
agent_id=str(self._agent_id),
)
return
if not actor.active:
log.warning(
"caution_drafter.skip.agent_actor_deactivated",
agent_id=str(CAUTION_DRAFTER_AGENT_ID),
agent_name=CAUTION_DRAFTER_AGENT_NAME,
agent_id=str(self._agent_id),
)
return

# Lifecycle gate (mirrors RunDebriefer): only a Versioned agent
# acts; Suspended, Deprecated, and not-yet-promoted Defined all
# skip. A missing Agent stream stays permissive. The Agent fold
# also carries the declared budget the post-lease gate reads.
agent = await load_agent(self.event_store, CAUTION_DRAFTER_AGENT_ID)
agent = await load_agent(self.event_store, self._agent_id)

# Designation validation (mirrors RunDebriefer). Gated on
# `is_designated` so the seeded default stays exempt from the
# existence check, exactly as `regenerate_run_debrief` exempts it
# on the on-demand path: an explicitly named Agent is a
# deliberate choice and gets checked; the approved-model catalog
# gate is NOT re-checked here (`define_agent` already checked it).
is_designated = self._agent_id != CAUTION_DRAFTER_AGENT_ID
if is_designated:
if agent is None:
log.warning(
"caution_drafter.skip.designated_agent_missing",
agent_id=str(self._agent_id),
)
return
if agent.kind.value != CAUTION_DRAFTER_AGENT_KIND:
log.warning(
"caution_drafter.skip.designated_agent_wrong_kind",
agent_id=str(self._agent_id),
agent_name=agent.name.value,
expected_kind=CAUTION_DRAFTER_AGENT_KIND,
actual_kind=agent.kind.value,
)
return

if agent is not None and agent.status is not AgentStatus.VERSIONED:
log.warning(
"caution_drafter.skip.agent_not_versioned",
agent_id=str(CAUTION_DRAFTER_AGENT_ID),
agent_name=CAUTION_DRAFTER_AGENT_NAME,
agent_id=str(self._agent_id),
agent_name=agent.name.value,
agent_status=str(agent.status),
)
return
Expand All @@ -307,7 +344,7 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None:
lease_acquired, winning_agent_id = await attempt_debrief_lease(
self.event_store,
run_id=run_id,
debriefer_agent_id=CAUTION_DRAFTER_AGENT_ID,
debriefer_agent_id=self._agent_id,
debriefer_kind=CAUTION_DRAFTER_AGENT_KIND,
terminal_event=event,
occurred_at=event.occurred_at,
Expand Down Expand Up @@ -455,7 +492,19 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None:
candidate_targets=candidate_targets,
existing_cautions=existing_cautions,
)
request = build_caution_drafter_chat_request(payload)
# The Agent's declared model, not the module default: that
# declaration is what `define_agent` gated against the approved
# catalog, so serving anything else makes the gate decorative.
# `agent` is None only when the Agent stream was never seeded
# (the branch above tolerates it), and the default stands in.
request = build_caution_drafter_chat_request(
payload,
model_ref=(
to_port_model_ref(agent.model_ref)
if agent is not None
else DEFAULT_CAUTION_DRAFTER_MODEL
),
)

try:
response = await self.llm.chat(request)
Expand Down Expand Up @@ -500,6 +549,7 @@ async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None:
await self._record_inference(
decision_id=decision_id,
actor=actor,
agent_name=agent.name.value if agent is not None else CAUTION_DRAFTER_AGENT_NAME,
request=request,
response=response,
terminal_event=event,
Expand All @@ -511,6 +561,7 @@ async def _record_inference(
*,
decision_id: UUID,
actor: Actor,
agent_name: str,
request: LLMChatRequest,
response: LLMResponse,
terminal_event: StoredEvent,
Expand Down Expand Up @@ -540,8 +591,8 @@ async def _record_inference(
request_max_tokens=request.max_output_tokens,
request_temperature=request.temperature,
request_top_p=request.top_p,
agent_id=str(CAUTION_DRAFTER_AGENT_ID),
agent_name=CAUTION_DRAFTER_AGENT_NAME,
agent_id=str(self._agent_id),
agent_name=agent_name,
)
try:
await self.inference_recorder.record(
Expand Down Expand Up @@ -913,6 +964,7 @@ def make_caution_drafter_subscriber(deps: Kernel) -> CautionDrafterSubscriber:
inference_recorder=deps.inference_recorder,
spend_lookup=deps.spend_lookup,
allocation_lookup=deps.allocation_lookup,
agent_id=deps.settings.caution_drafter_agent_id or CAUTION_DRAFTER_AGENT_ID,
)


Expand Down
Loading
Loading