| """Structured output for agent responses β two layers, one schema. |
| |
| Small models comply better under constraint. Asking for free prose is |
| where they drift. Asking for a specific JSON schema is where they stay |
| in character. |
| |
| Two paths share the same ``{kind, text, β¦}`` shape: |
| |
| - **Live path (validated).** ``build_output_model`` turns an agent's |
| ``may_emit`` grant + ``output_extra_fields`` into a Pydantic model whose |
| ``kind`` is constrained to the allowed kinds. The live provider asks the |
| model for *that* model and retries on validation failure, so the payload is |
| valid by construction β no malformed prose ever reaches the ledger. |
| - **Offline path (tolerant parse).** ``json_instruction`` appends a JSON |
| block to the prompt and ``parse_agent_output`` normalises whatever text the |
| deterministic stub returns, wrapping non-compliant prose in the fallback |
| kind. This keeps demos and tests fully offline with no dependency. |
| |
| Both paths are model/provider-agnostic: the live constraint rides on the same |
| ``{kind, text, β¦}`` contract the parser produces, so downstream |
| (``Event`` construction, conductor, ledger) is identical either way. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import re |
| from typing import TYPE_CHECKING, Any, Literal |
|
|
| if TYPE_CHECKING: |
| from pydantic import BaseModel |
|
|
|
|
| |
|
|
| class AgentOutputError(ValueError): |
| """Raised when output cannot be normalised to a valid event payload.""" |
|
|
|
|
| |
|
|
| def build_output_model( |
| allowed_kinds: list[str], |
| extra_fields: list[str] | None = None, |
| ) -> type["BaseModel"]: |
| """Build a Pydantic model for an agent's validated output. |
| |
| ``kind`` is constrained to *allowed_kinds* via a ``Literal``, so the model |
| cannot emit a kind it is not authorised for; ``text`` plus any *extra_fields* |
| are required strings. Used on the live path with structured output: the |
| provider retries on validation failure and returns a valid instance, which |
| means the malformed-prose ``_raw_fallback`` path is never taken. |
| |
| Args: |
| allowed_kinds: event kinds this agent may emit (the ``may_emit`` grant, |
| reflection excluded). Must be non-empty. |
| extra_fields: optional additional payload fields (e.g. ``"emotion"``), |
| each a required string alongside ``text``. |
| """ |
| if not allowed_kinds: |
| raise AgentOutputError("build_output_model requires at least one allowed kind") |
|
|
| from pydantic import create_model |
|
|
| |
| kind_type = Literal[tuple(allowed_kinds)] |
| fields: dict[str, Any] = { |
| "kind": (kind_type, ...), |
| "text": (str, ...), |
| } |
| for name in extra_fields or []: |
| fields[name] = (str, ...) |
|
|
| return create_model( |
| "AgentOutput", |
| __doc__="Validated agent event payload (kind constrained to may_emit).", |
| **fields, |
| ) |
|
|
|
|
| |
|
|
| def json_instruction(allowed_kinds: list[str], extra_fields: list[str] | None = None) -> str: |
| """Return the JSON constraint block appended to every agent prompt. |
| |
| Args: |
| allowed_kinds: event kinds this agent may emit. |
| extra_fields: optional additional payload fields (e.g. "emotion", "wants"). |
| """ |
| field_list = '", "'.join(["kind", "text"] + (extra_fields or [])) |
| kinds_str = " | ".join(allowed_kinds) |
| return ( |
| "\n\nOUTPUT FORMAT\n" |
| "Reply with a single JSON object and nothing else β no prose before or after.\n" |
| f'Schema: {{"{field_list}": "..."}}\n' |
| f"kind must be one of: {kinds_str}\n" |
| "text must be one or two sentences, vivid and specific.\n" |
| "Example: " |
| '{"kind": "' + allowed_kinds[0] + '", "text": "A brief, evocative response."}' |
| ) |
|
|
|
|
| |
|
|
| def parse_agent_output( |
| raw: str, |
| allowed_kinds: list[str], |
| fallback_kind: str, |
| ) -> dict[str, Any]: |
| """Parse raw model output into a validated event payload dict. |
| |
| Strategy: |
| 1. Try strict JSON parse. |
| 2. Try extracting the first {...} block from mixed prose+JSON output. |
| 3. Fall back to wrapping raw text in the fallback kind. |
| |
| Returns a dict with at least {"kind": str, "text": str}. |
| The caller is responsible for constructing the Event from this dict. |
| """ |
| raw = raw.strip() |
|
|
| |
| if raw.startswith("{"): |
| result = _try_parse(raw, allowed_kinds, fallback_kind) |
| if result is not None: |
| return result |
|
|
| |
| match = re.search(r"\{[^{}]+\}", raw, re.DOTALL) |
| if match: |
| result = _try_parse(match.group(), allowed_kinds, fallback_kind) |
| if result is not None: |
| return result |
|
|
| |
| return {"kind": fallback_kind, "text": raw[:512], "_raw_fallback": True} |
|
|
|
|
| def _try_parse(s: str, allowed_kinds: list[str], fallback_kind: str) -> dict[str, Any] | None: |
| try: |
| data = json.loads(s) |
| except json.JSONDecodeError: |
| return None |
|
|
| if not isinstance(data, dict): |
| return None |
|
|
| |
| kind = data.get("kind", fallback_kind) |
| if kind not in allowed_kinds: |
| kind = fallback_kind |
| data["kind"] = kind |
|
|
| |
| if "text" not in data or not isinstance(data.get("text"), str): |
| data["text"] = str(data.get("content", data.get("message", s[:200]))) |
|
|
| return data |
|
|