Spaces:
Sleeping
Sleeping
| """LLM-facing action drafts — the JSON-string ``arguments`` workaround. | |
| This module isolates the fix for the *empty-`arguments` from the live model* | |
| problem. The real :class:`~control_plane.schema.ProposedAction` types | |
| ``arguments`` as a ``dict[str, Any]``, which compiles to a *property-less* JSON | |
| schema (``{"type": "object"}``). Models won't invent keys for a schema that names | |
| none, so the live model returns ``arguments={}`` and every argument-taking action | |
| fails. Models DO reliably fill a string field, so the agent emits these "draft" | |
| types where ``arguments`` is a JSON *string*, and :func:`draft_to_decision` parses | |
| that string back into a genuine ``ProposedAction``. | |
| The fix is contained entirely in the agent layer: ``ProposedAction``, the gate, | |
| grants, and the backends are untouched — they still receive a normal ``arguments`` | |
| dict. | |
| Why a separate module: the drafts are a serialization DTO shaped around a | |
| provider limitation, distinct from the governed loop itself. Keeping them here | |
| lets :mod:`agents.soc_agent` stay focused on the loop, and the draft↔decision | |
| conversion (plus its single error type) be tested in isolation. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from typing import Any | |
| from pydantic import BaseModel, ConfigDict, Field, ValidationError | |
| from control_plane.schema import ( | |
| AgentDecision, | |
| AutonomyTier, | |
| Backend, | |
| ProposedAction, | |
| RiskLevel, | |
| ) | |
| class MalformedAgentOutput(Exception): | |
| """Raised by a brain when the model's output cannot be validated into a turn. | |
| Pydantic-AI already retries the model on a schema violation (local | |
| validation/repair). This is raised only when that repair is *exhausted*, so | |
| the harness can surface it as an error result rather than letting it crash | |
| the run ("malformed output triggers local validation/repair, not a | |
| crash"). :func:`draft_to_decision` raises the same error for bad | |
| ``arguments_json`` so the loop has a single failure type to handle. | |
| """ | |
| class ProposedActionDraft(BaseModel): | |
| """LLM-facing twin of :class:`~control_plane.schema.ProposedAction` whose | |
| ``arguments`` is a JSON string the model can actually fill.""" | |
| model_config = ConfigDict(extra="forbid") | |
| # incident_id and agent_id are *context the harness owns*, not choices for the | |
| # model: the run already knows which incident it is about and which identity is | |
| # signing. They default to empty and are injected by draft_to_decision, so a | |
| # model that omits them (or returns "") can't break the run. Kept in the schema | |
| # (rather than removed) so older drafts that do send them still validate. | |
| incident_id: str = Field( | |
| default="", description="Set by the system — leave blank." | |
| ) | |
| agent_id: str = Field( | |
| default="", description="Set by the system — leave blank." | |
| ) | |
| autonomy_tier: AutonomyTier | |
| backend: Backend | |
| action_name: str | |
| arguments_json: str = Field( | |
| default="{}", | |
| description=( | |
| "The action's arguments as a JSON object string, e.g. " | |
| '\'{"alert_id": "ALERT-2001"}\'. Use "{}" when the action needs none.' | |
| ), | |
| ) | |
| reason: str | |
| evidence: list[str] = Field(default_factory=list) | |
| class AgentDecisionDraft(BaseModel): | |
| """LLM-facing twin of :class:`~control_plane.schema.AgentDecision`.""" | |
| model_config = ConfigDict(extra="forbid") | |
| summary: str | |
| risk_level: RiskLevel | |
| proposed_action: ProposedActionDraft | |
| prohibited_actions_detected: list[str] = Field(default_factory=list) | |
| def draft_to_decision( | |
| draft: AgentDecisionDraft, | |
| *, | |
| incident_id: str | None = None, | |
| agent_id: str | None = None, | |
| ) -> AgentDecision: | |
| """Convert a model-emitted draft into a real, validated :class:`AgentDecision`. | |
| Parses the JSON-string arguments back into a dict, then builds the genuine | |
| schema object (which re-runs all of ``ProposedAction``'s validation). Any bad | |
| JSON, a non-object payload, or a schema violation is raised as | |
| :class:`MalformedAgentOutput` so the harness surfaces it as a clean error | |
| instead of crashing the run. | |
| ``incident_id`` and ``agent_id`` are *injected by the harness from run context* | |
| when supplied — they are not the model's to choose: the run already knows which | |
| incident it concerns and which identity is signing (and the signature must match | |
| that identity). Injecting them means a model that leaves these blank can no | |
| longer abort the run, and the proposal can never claim a different identity than | |
| the one that signs it. When not supplied, the draft's own values are used | |
| (keeping older callers working). | |
| """ | |
| pa = draft.proposed_action | |
| try: | |
| arguments: Any = json.loads(pa.arguments_json.strip() or "{}") | |
| except json.JSONDecodeError as exc: | |
| raise MalformedAgentOutput(f"arguments_json is not valid JSON: {exc}") from exc | |
| if not isinstance(arguments, dict): | |
| raise MalformedAgentOutput("arguments_json must be a JSON object") | |
| # Harness-owned context wins over whatever the model put (or left blank). | |
| resolved_incident = incident_id if incident_id else pa.incident_id | |
| resolved_agent = agent_id if agent_id else pa.agent_id | |
| # Normalize the action name: models sometimes echo a signature-style name like | |
| # "get_asset(asset_id)" instead of the bare "get_asset" — which is not a real | |
| # action and would be denied. Strip anything from the first "(" so the bare verb | |
| # survives; the actual arguments come from arguments_json, not the name. | |
| resolved_action = pa.action_name.split("(", 1)[0].strip() | |
| try: | |
| return AgentDecision( | |
| summary=draft.summary, | |
| risk_level=draft.risk_level, | |
| proposed_action=ProposedAction( | |
| incident_id=resolved_incident, | |
| agent_id=resolved_agent, | |
| autonomy_tier=pa.autonomy_tier, | |
| backend=pa.backend, | |
| action_name=resolved_action, | |
| arguments=arguments, | |
| reason=pa.reason, | |
| evidence=pa.evidence, | |
| ), | |
| prohibited_actions_detected=draft.prohibited_actions_detected, | |
| ) | |
| except ValidationError as exc: | |
| raise MalformedAgentOutput(str(exc)) from exc | |