CrisisWorldCortex / baselines /flat_agent.py
Angshuman28's picture
Upload folder using huggingface_hub
53adefa verified
Raw
History Blame Contribute Delete
19.7 kB
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""
B1 - flat single-LLM-call baseline (design §20.1; baselines/CLAUDE.md).
One LLM call per tick. No conversation memory; the env's
``recent_action_log`` already gives the model an 8-deep history.
Production use: pass a ``CrisisworldcortexEnv`` HTTP client (per
baselines/CLAUDE.md, baselines never instantiate the env directly).
Tests pass an in-process adapter - see ``tests/test_baseline_b1.py``.
Parse-failure policy (Session 7a §6):
Parse-failure-as-rejection. When the LLM emits unparseable text,
B1 logs a [WARN] to stderr, then SUBMITS a synthetic
``PublicCommunication`` to the env. The env rejects it with
``accepted=False``, which lands as ``r_policy=0`` in
``outer_reward`` - making the reward signal punish parse failures
appropriately. Episode does NOT terminate; the agent gets a chance
to recover next tick. (The §19 parser-fail-terminate rule is for
wire-protocol malformations; this is client-side text-extraction
failure.)
Note on the user's "comment in recent_action_log" wording: the
forensic raw-text snippet is captured in B1's local trajectory
log (returned from ``run_episode``), not on the env's
``ExecutedAction`` (which has no note field - adding one would
touch the frozen wire-protocol class).
"""
from __future__ import annotations
import json
import re
import sys
import textwrap
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Literal, Optional, Protocol
from pydantic import TypeAdapter, ValidationError
from cortex.llm_client import ChatMessage
from CrisisWorldCortex.models import (
CrisisworldcortexAction,
CrisisworldcortexObservation,
OuterActionPayload,
PublicCommunication,
)
__all__ = [
"B1FlatAgent",
"B1StepEvent",
"ErrorKind",
"StepCallback",
"build_system_prompt",
"parse_action",
"parse_failure_marker",
"serialize_observation",
]
# ============================================================================
# Constants
# ============================================================================
# Approx tokens-per-char for the prompt-size sanity check. ~4 chars/token
# is the long-standing rule of thumb for English under BPE tokenizers.
_CHARS_PER_TOKEN_ESTIMATE = 4
# Approved Session 7a: warn if rendered prompt exceeds 1500 tokens for
# a 4-region observation. If it does, the prompt format needs trimming.
_PROMPT_TOKEN_WARN_THRESHOLD = 1500
# ============================================================================
# Per-tick callback contract (Session 8)
# ============================================================================
ErrorKind = Literal["parse_failure", "llm_call_failed", "env_step_failed"]
@dataclass(frozen=True)
class B1StepEvent:
"""Per-tick event handed to ``step_callback``. Domain shape (not wire shape).
Consumers (``inference.py``, B2's tracer, future Cortex harnesses)
receive this exactly once per tick AFTER the action has been
submitted to the env. Mid-revision drafts (B2) do not produce events
— only the final per-tick action does, matching the design §20.1.1
"Never emits mid-revision drafts" rule.
"""
tick: int # 1-indexed
action: OuterActionPayload # what was submitted (real or synthetic-rejection marker)
reward: float # obs.reward from env.step (in [0, 1])
done: bool # episode-termination flag
error: Optional[ErrorKind] # None on the happy path
parse_failure: bool # whether parse_action returned None this tick
raw_llm: str # raw LLM response (forensic; possibly empty on llm_call_failed)
StepCallback = Callable[[B1StepEvent], None]
# ============================================================================
# Synthetic V2-rejection marker (public API, shared by B1 / B2 / Cortex / B3)
# ============================================================================
def parse_failure_marker() -> PublicCommunication:
"""Synthetic V2-rejected action used to surface parse failures
through the env's reward signal as ``r_policy=0``.
Public API: used by B1, B2, Cortex (sessions 9+), and B3 (future).
Submitting this to ``env.step()`` causes the simulator to record
``accepted=False`` in ``recent_action_log`` (per design §6.3 / §19),
which lands as ``r_policy=0`` in ``outer_reward`` — making the
reward signal punish parse failures appropriately.
Changes to this function's signature or behavior require careful
review of all callers because the rejection contract is shared
across every harness in the project.
"""
return PublicCommunication(
audience="general",
message_class="informational",
honesty=0.0,
)
# ============================================================================
# Prompt construction
# ============================================================================
_SYSTEM_PROMPT_BODY = textwrap.dedent("""
You are an agent operating one outbreak-control simulator. You receive
an observation each tick and must respond with EXACTLY ONE JSON object —
no markdown fences, no prose around it, just the JSON.
== ACTION TYPES (kind + required fields) ==
1. {"kind": "no_op"}
Advance the tick without intervention.
2. {"kind": "deploy_resource", "region": "<id>",
"resource_type": "<type>", "quantity": <int>}
Deploy units of a resource to a region.
3. {"kind": "request_data", "region": "<id>",
"data_type": "case_survey" | "hospital_audit" | "compliance_check"}
Reduce telemetry noise for that region for a few ticks.
4. {"kind": "restrict_movement", "region": "<id>",
"severity": "none" | "light" | "moderate" | "strict"}
Apply a movement restriction. "strict" may be blocked by a
legal_constraints rule until escalate(national) has been invoked.
5. {"kind": "escalate", "to_authority": "regional" | "national"}
Escalate to a higher authority. Escalating to "national" unlocks
any LegalConstraint with rule_id mentioning strict severity.
6. {"kind": "reallocate_budget", "from_resource": "<type>",
"to_resource": "<type>", "amount": <int>}
Move resource units between types (small efficiency loss).
== ENUM VALUES ==
region: whatever ids appear in the observation (e.g. R1, R2, ...)
resource_type: test_kits, hospital_beds, mobile_units, vaccine_doses
severity: none, light, moderate, strict
to_authority: regional, national
data_type: case_survey, hospital_audit, compliance_check
== OBSERVATION FIELDS ==
Each tick you see per-region telemetry that is DELAYED by a few ticks
and noisy: reported_cases_d_ago is what was happening some ticks ago,
not now. hospital_load is current and operational. compliance_proxy
is a noisy estimate of how well restrictions are being followed.
Resources, active_restrictions, legal_constraints, and the recent
action log are all reported as-is.
== OUTPUT CONTRACT ==
Respond with ONLY the JSON action object. No explanation, no
surrounding text, no markdown.
== STRATEGY ==
Respond to the situation as it unfolds. Trade off across regions and
resource types as needed.
""").strip()
def build_system_prompt() -> str:
"""Return the static system prompt. Single source of truth — both
B1 and a future inference.py harness can import this."""
return _SYSTEM_PROMPT_BODY
def serialize_observation(
obs: CrisisworldcortexObservation,
last_reward: float,
) -> str:
"""Render an observation as a compact text prompt body.
Format markers: "Tick", "Resources", "Regions", "Active restrictions",
"Legal constraints", "Recent actions".
"""
parts: List[str] = []
parts.append(
f"Tick {obs.tick} | Ticks remaining: {obs.ticks_remaining} | Last reward: {last_reward:.2f}"
)
r = obs.resources
parts.append(
"=== Resources ===\n"
f"test_kits={r.test_kits} hospital_beds_free={r.hospital_beds_free} "
f"mobile_units={r.mobile_units} vaccine_doses={r.vaccine_doses}"
)
region_lines = ["=== Regions ==="]
for region in obs.regions:
region_lines.append(
f"- {region.region}: cases_d_ago={region.reported_cases_d_ago} "
f"hospital_load={region.hospital_load:.2f} "
f"compliance_proxy={region.compliance_proxy:.2f}"
)
parts.append("\n".join(region_lines))
restr_lines = ["=== Active restrictions ==="]
if obs.active_restrictions:
for restr in obs.active_restrictions:
restr_lines.append(
f"- {restr.region}: severity={restr.severity} "
f"ticks_remaining={restr.ticks_remaining}"
)
else:
restr_lines.append("(none)")
parts.append("\n".join(restr_lines))
legal_lines = ["=== Legal constraints ==="]
if obs.legal_constraints:
for lc in obs.legal_constraints:
legal_lines.append(
f"- {lc.rule_id}: blocks {lc.blocked_action} (unlock via {lc.unlock_via})"
)
else:
legal_lines.append("(none)")
parts.append("\n".join(legal_lines))
log_lines = ["=== Recent actions (last 8) ==="]
if obs.recent_action_log:
for entry in obs.recent_action_log:
kind = entry.action.kind
extra = _action_summary(entry.action)
log_lines.append(f"- tick={entry.tick} {kind}{extra} accepted={entry.accepted}")
else:
log_lines.append("(none yet)")
parts.append("\n".join(log_lines))
return "\n\n".join(parts)
def _action_summary(action: OuterActionPayload) -> str:
"""One-shot summary of an action's salient fields for the log."""
kind = action.kind
if kind == "deploy_resource":
return f"({action.region}, {action.resource_type}, qty={action.quantity})"
if kind == "request_data":
return f"({action.region}, {action.data_type})"
if kind == "restrict_movement":
return f"({action.region}, {action.severity})"
if kind == "escalate":
return f"({action.to_authority})"
if kind == "reallocate_budget":
return f"({action.from_resource} -> {action.to_resource}, amount={action.amount})"
return ""
# ============================================================================
# Parsing
# ============================================================================
_PAYLOAD_ADAPTER: TypeAdapter = TypeAdapter(OuterActionPayload)
def parse_action(raw_text: str) -> Optional[OuterActionPayload]:
"""Extract a typed ``OuterActionPayload`` from raw LLM text.
Pipeline:
1. Strip ```json ... ``` codeblock fences.
2. ``json.loads`` directly on the stripped text.
3. On JSON failure: brace-match (find the first balanced ``{...}``
block in the text and try again).
4. Validate via Pydantic ``TypeAdapter(OuterActionPayload)``.
Returns ``None`` on any failure. Caller decides recovery.
"""
if not raw_text or not raw_text.strip():
return None
text = raw_text.strip()
text = re.sub(r"```(?:json)?\s*", "", text)
text = re.sub(r"```\s*$", "", text)
text = text.strip()
data: Optional[Dict[str, Any]] = None
try:
candidate = json.loads(text)
if isinstance(candidate, dict):
data = candidate
except json.JSONDecodeError:
pass
if data is None:
start = text.find("{")
if start == -1:
return None
depth, end = 0, -1
for i, ch in enumerate(text[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i
break
if end == -1:
return None
try:
candidate = json.loads(text[start : end + 1])
if isinstance(candidate, dict):
data = candidate
except json.JSONDecodeError:
return None
if not isinstance(data, dict) or "kind" not in data:
return None
try:
return _PAYLOAD_ADAPTER.validate_python(data)
except ValidationError:
return None
# ============================================================================
# Env protocol — duck-typed minimal interface
# ============================================================================
class _EnvLike(Protocol):
"""Sync env interface B1 expects: ``reset()`` /
``step(action) -> CrisisworldcortexObservation``.
Production callers wrap ``CrisisworldcortexEnv`` (HTTP client) so
``.step(action)`` returns an observation directly. Tests pass an
in-process adapter (see ``_InProcessEnvAdapter`` in test_baseline_b1).
"""
def reset(self) -> CrisisworldcortexObservation: ...
def step(self, action: CrisisworldcortexAction) -> CrisisworldcortexObservation: ...
# ============================================================================
# Agent
# ============================================================================
class B1FlatAgent:
"""B1 baseline: one LLM call per tick, no conversation memory."""
CALLER_ID_PREFIX = "b1"
def __init__(self, env: _EnvLike, llm: Any) -> None:
self._env = env
self._llm = llm
self._system_prompt = build_system_prompt()
self._first_call_logged = False
def run_episode(
self,
task: str,
seed: int,
max_ticks: int = 12,
*,
step_callback: Optional[StepCallback] = None,
) -> Dict[str, Any]:
"""Run one episode. Returns a trajectory dict.
Side effects: calls ``self._llm.reset_counters(prefix='b1:')`` at
the start so per-episode token counts don't accumulate across
episodes. Per Session 7a §4: harness-driven reset, not auto.
Args:
task: Forwarded into the trajectory dict; reserved for the
future env that learns task selection at reset time.
seed: Same — forward-compat for reproducibility logging.
max_ticks: Episode length cap.
step_callback: Optional ``Callable[[B1StepEvent], None]``.
Fires exactly once per tick AFTER the action has been
submitted to the env, with a frozen ``B1StepEvent``
describing what happened. Used by ``inference.py`` for
streaming ``[STEP]`` lines and by B2's matched-compute
tracer for budget logging.
"""
self._llm.reset_counters(caller_id_prefix=f"{self.CALLER_ID_PREFIX}:")
self._first_call_logged = False
obs = self._env.reset()
last_reward = 0.0
rewards: List[float] = []
action_history: List[Dict[str, Any]] = []
parse_failure_count = 0
steps_taken = 0
for tick in range(1, max_ticks + 1):
steps_taken = tick
tick_error: Optional[ErrorKind] = None
user_prompt = serialize_observation(obs, last_reward=last_reward)
self._maybe_warn_prompt_size(self._system_prompt, user_prompt)
messages = [
ChatMessage(role="system", content=self._system_prompt),
ChatMessage(role="user", content=user_prompt),
]
caller_id = f"{self.CALLER_ID_PREFIX}:t{tick}"
# LLM-call failures (auth, network, rate-limit) are treated as
# an empty response that flows through the parse-failure path —
# synthetic V2-rejected marker keeps the episode going so the
# reward signal still penalises lost ticks (r_policy=0). The
# event records error='llm_call_failed' so observers can tell
# this apart from a "model emitted prose" parse failure.
raw_content = ""
try:
response = self._llm.chat(caller_id=caller_id, messages=messages)
raw_content = response.content
except Exception as exc: # pragma: no cover - exercised manually
print(
f"[WARN] b1: llm.chat failed at tick={tick} caller={caller_id!r}: {exc!r}",
file=sys.stderr,
flush=True,
)
tick_error = "llm_call_failed"
payload = parse_action(raw_content)
tick_parse_failure = payload is None
if tick_parse_failure:
parse_failure_count += 1
snippet = (raw_content or "").strip().replace("\n", " ")
if len(snippet) > 80:
snippet = snippet[:77] + "..."
if tick_error is None:
print(
f"[WARN] b1: parse_failure at tick={tick} caller={caller_id!r} raw={snippet!r}",
file=sys.stderr,
flush=True,
)
tick_error = "parse_failure"
payload = parse_failure_marker()
action_history.append(
{
"tick": tick,
"submitted_kind": payload.kind,
"parse_failure": tick_parse_failure,
"raw_llm": raw_content,
}
)
obs = self._env.step(CrisisworldcortexAction(action=payload))
last_reward = obs.reward if obs.reward is not None else 0.0
rewards.append(last_reward)
if step_callback is not None:
step_callback(
B1StepEvent(
tick=tick,
action=payload,
reward=last_reward,
done=bool(obs.done),
error=tick_error,
parse_failure=tick_parse_failure,
raw_llm=raw_content,
)
)
if obs.done:
break
return {
"task": task,
"seed": seed,
"steps_taken": steps_taken,
"rewards": rewards,
"action_history": action_history,
"parse_failure_count": parse_failure_count,
"tokens_total": sum(
self._llm.tokens_used_for(f"{self.CALLER_ID_PREFIX}:t{i}")
for i in range(1, steps_taken + 1)
),
}
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _maybe_warn_prompt_size(self, system_prompt: str, user_prompt: str) -> None:
"""One-shot prompt-size check on the first LLM call of an episode."""
if self._first_call_logged:
return
self._first_call_logged = True
approx_tokens = (len(system_prompt) + len(user_prompt)) // _CHARS_PER_TOKEN_ESTIMATE
if approx_tokens > _PROMPT_TOKEN_WARN_THRESHOLD:
print(
f"[WARN] b1: prompt approx_tokens={approx_tokens} exceeds "
f"{_PROMPT_TOKEN_WARN_THRESHOLD} - consider trimming the format",
file=sys.stderr,
flush=True,
)
else:
print(
f"[INFO] b1: prompt approx_tokens={approx_tokens}",
file=sys.stderr,
flush=True,
)