# 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. """ B2 - matched-compute self-revision baseline (design §20.1.1). Per-tick mechanism: 1. Generate a proposed action with reasoning. (1 LLM call) 2. Critique the proposal. (1 LLM call) 3. Revise. (1 LLM call) 4. Repeat steps 2-3 up to K passes; K is chosen so the per-tick token budget is exhausted or nearly exhausted. 5. The final pass emits exactly one ``OuterAction``. Stop rule (§20.1.1): if the model emits a stop signal (deferred to Session 14 — see §9.5 in this session's proposal) or the per-tick budget is exhausted, the **last revised action** is emitted. Never emits mid-revision drafts. Token-budget enforcement: - Per-tick budget defaults to ``TICK_BUDGET = 6000`` (matches ``TaskConfig.cognition_budget_per_tick`` from the design's §6.5 task config). Override via ``B2MatchedComputeAgent(..., tick_budget=...)`` so future Session 14 evaluation can re-calibrate to Cortex's actual measured per-tick usage. - After each LLM call, accumulate ``response.prompt_tokens + completion_tokens`` into the per-tick total. Before starting a new (critique, revision) pair, compare the remaining budget against an estimate of the next pair's cost (``2 * _estimate_call_cost(...)``). If we can't afford it, stop and emit ``current_candidate`` (the last fully-parsed action). - No safety margin (Session 8 §9.3): the existing ``parse_action + current_candidate`` fallback already handles truncated revision responses cleanly. Submitting a parse-failed revision falls back to the prior pass's candidate; submitting on initial parse failure falls back to the synthetic V2-rejected marker. Sharing with B1 / Cortex: - System prompt for the initial generation is B1's ``build_system_prompt()``. Per §20.1.1 "shares the LLM client and action schema with Cortex" — reusing B1's prompt directly defends the matched-compute claim. - ``parse_action``, ``serialize_observation``, and ``parse_failure_marker`` are imported from ``baselines.flat_agent`` (public API after Session 8 rename). - The per-tick callback contract (``B1StepEvent`` / ``StepCallback``) is shared with B1: B2 fires the callback once per tick AFTER the final action is submitted to the env. Mid- revision drafts produce no events. """ from __future__ import annotations import sys import textwrap from typing import Any, Dict, List, Optional from baselines.flat_agent import ( B1StepEvent, ErrorKind, StepCallback, build_system_prompt, parse_action, parse_failure_marker, serialize_observation, ) from cortex.llm_client import ChatMessage from CrisisWorldCortex.models import ( CrisisworldcortexAction, OuterActionPayload, ) __all__ = ["B2MatchedComputeAgent"] # ============================================================================ # Constants # ============================================================================ # Per-design §6.5: every TaskConfig declares cognition_budget_per_tick=6000. # This is the per-tick LLM-token envelope B2 must match for the matched- # compute claim to hold against Cortex (which uses the same envelope). _DEFAULT_TICK_BUDGET = 6000 # Initial estimate before any in-tick LLM call has reported actual cost. # Used both for the first "can we afford a pair?" check and as the reset # value at the start of every tick. _INITIAL_CALL_COST_ESTIMATE = 600 # Moving-average window over the most recent in-tick LLM calls (Session 8 §9.4). _ESTIMATE_WINDOW = 3 # Hard cap on revision passes per tick — defensive against pathological # cost estimates that never converge. With every-call-300-tokens and # budget=6000, the natural exhaustion fires around K=9. 64 is far above # any realistic K; tripping it indicates a bug, not a budget exhaustion. _MAX_PASSES_PER_TICK = 64 # ============================================================================ # B2 prompt construction # ============================================================================ _CRITIC_SYSTEM_PROMPT = textwrap.dedent(""" You are reviewing a proposed action for an outbreak-control simulator. You will see: 1. The current observation (regions, resources, restrictions, etc.). 2. The action just proposed (one JSON object). Identify weaknesses: wrong region, wrong severity, missed cascade signal, depleted resources, blocked legal constraint, etc. Output PROSE (no JSON, no markdown). Be concise: bullet points listing concrete concerns. If the action is sound, say so in one line and stop. Do NOT propose a new action. The reviser will do that. """).strip() _REVISER_SYSTEM_PROMPT_HEADER = textwrap.dedent(""" You are revising your earlier proposed action in light of a critique. You will see: 1. The current observation. 2. Your previous proposed action. 3. The critique of that proposal. Emit ONE JSON action object — no markdown fences, no prose, no explanation. The action schema is unchanged from the initial pass: """).strip() def _build_critic_prompt() -> str: """Critic system prompt — no action schema (saves ~400 tokens vs re-including B1's full schema). The critic only emits prose.""" return _CRITIC_SYSTEM_PROMPT def _build_reviser_prompt() -> str: """Reviser system prompt = critic-orientation header + B1's full action schema (the reviser emits JSON, so it needs the schema).""" return _REVISER_SYSTEM_PROMPT_HEADER + "\n\n" + build_system_prompt() def _action_to_json_summary(action: OuterActionPayload) -> str: """Serialize an action for the critic / reviser user message.""" return action.model_dump_json() # ============================================================================ # Budget helpers # ============================================================================ def _estimate_call_cost(recent_call_tokens: List[int]) -> int: """Simple moving average over the last 3 LLM calls within this tick. Resets to the initial 600-token estimate at the start of each tick (when ``recent_call_tokens`` is empty). Stable but tracks the recent cost trajectory — if the model starts emitting longer responses (prompts grow as critique chain accumulates), the estimate adapts so the budget check stays honest. Per Session 8 §9.4: window = 3 calls, reset per tick. Documented here so future maintenance doesn't re-derive the choice. """ if not recent_call_tokens: return _INITIAL_CALL_COST_ESTIMATE window = recent_call_tokens[-_ESTIMATE_WINDOW:] return int(sum(window) / len(window)) # ============================================================================ # Agent # ============================================================================ class B2MatchedComputeAgent: """Matched-compute self-revision baseline. Args: env: Object exposing ``reset()`` / ``step(CrisisworldcortexAction) -> CrisisworldcortexObservation``. Production: a sync-wrapped ``CrisisworldcortexEnv`` HTTP client. Tests: an in-process adapter. llm: An ``LLMClient``-shaped object (``chat(caller_id, messages) -> ChatResponse``, ``tokens_used_for(caller_id)``, ``reset_counters(caller_id_prefix)``). tick_budget: Per-tick LLM-token cap. Defaults to ``TICK_BUDGET`` (6000 — matches Cortex's design envelope). Override for Session 14 evaluation when Cortex's actual measured per- tick consumption is known. """ CALLER_ID_PREFIX = "b2" TICK_BUDGET = _DEFAULT_TICK_BUDGET def __init__(self, env: Any, llm: Any, *, tick_budget: Optional[int] = None) -> None: self._env = env self._llm = llm self._tick_budget = tick_budget if tick_budget is not None else self.TICK_BUDGET self._initial_system_prompt = build_system_prompt() self._critic_system_prompt = _build_critic_prompt() self._reviser_system_prompt = _build_reviser_prompt() 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. Trajectory shape: task, seed, steps_taken, rewards: List[float], action_history: List[dict] (one per tick — submitted_kind, parse_failure, pass_count, tick_tokens_used, raw_initial, raw_revisions), pass_counts: List[int] (revision passes per tick), tick_token_totals: List[int] (tokens consumed per tick), tokens_total: int, parse_failure_count: int. """ self._llm.reset_counters(caller_id_prefix=f"{self.CALLER_ID_PREFIX}:") obs = self._env.reset() last_reward = 0.0 rewards: List[float] = [] action_history: List[Dict[str, Any]] = [] pass_counts: List[int] = [] tick_token_totals: List[int] = [] parse_failure_count = 0 steps_taken = 0 for tick in range(1, max_ticks + 1): steps_taken = tick decision = self._decide_action_for_tick(tick=tick, obs=obs, last_reward=last_reward) payload = decision["payload"] tick_parse_failure = decision["parse_failure"] tick_pass_count = decision["pass_count"] tick_tokens_used = decision["tokens_used"] tick_error: Optional[ErrorKind] = decision["error"] if tick_parse_failure: parse_failure_count += 1 obs = self._env.step(CrisisworldcortexAction(action=payload)) last_reward = obs.reward if obs.reward is not None else 0.0 rewards.append(last_reward) action_history.append( { "tick": tick, "submitted_kind": payload.kind, "parse_failure": tick_parse_failure, "pass_count": tick_pass_count, "tick_tokens_used": tick_tokens_used, "raw_initial": decision["raw_initial"], "raw_revisions": decision["raw_revisions"], } ) pass_counts.append(tick_pass_count) tick_token_totals.append(tick_tokens_used) 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=decision["raw_initial"], ) ) if obs.done: break return { "task": task, "seed": seed, "steps_taken": steps_taken, "rewards": rewards, "action_history": action_history, "pass_counts": pass_counts, "tick_token_totals": tick_token_totals, "tokens_total": sum(tick_token_totals), "parse_failure_count": parse_failure_count, } # ------------------------------------------------------------------ # Per-tick orchestration # ------------------------------------------------------------------ def _decide_action_for_tick(self, *, tick: int, obs: Any, last_reward: float) -> Dict[str, Any]: """Run the initial+revision loop for one tick. Return the chosen action plus per-tick telemetry. Returns a dict with keys: payload, parse_failure (bool), pass_count (int), tokens_used (int), error (Optional[ErrorKind]), raw_initial (str), raw_revisions (List[str]). """ observation_text = serialize_observation(obs, last_reward=last_reward) recent_call_tokens: List[int] = [] tick_tokens_used = 0 tick_error: Optional[ErrorKind] = None # ---------- Initial pass ---------- initial_caller_id = f"{self.CALLER_ID_PREFIX}:t{tick}:p0:initial" raw_initial, initial_tokens, initial_error = self._safe_chat( caller_id=initial_caller_id, messages=[ ChatMessage(role="system", content=self._initial_system_prompt), ChatMessage(role="user", content=observation_text), ], ) if initial_error is not None: tick_error = initial_error recent_call_tokens.append(initial_tokens) tick_tokens_used += initial_tokens current_candidate: Optional[OuterActionPayload] = parse_action(raw_initial) if current_candidate is None and tick_error is None: tick_error = "parse_failure" raw_revisions: List[str] = [] # ---------- Revision loop ---------- pass_count = 0 for pass_idx in range(1, _MAX_PASSES_PER_TICK + 1): remaining = self._tick_budget - tick_tokens_used estimated_pair = 2 * _estimate_call_cost(recent_call_tokens) if remaining < estimated_pair: break # not enough budget for another (critique, revision) pair # Critique critique_caller_id = f"{self.CALLER_ID_PREFIX}:t{tick}:p{pass_idx}:critique" critique_user_text = ( observation_text + "\n\n=== Proposed action ===\n" + ( _action_to_json_summary(current_candidate) if current_candidate is not None else "" ) ) raw_critique, critique_tokens, critique_error = self._safe_chat( caller_id=critique_caller_id, messages=[ ChatMessage(role="system", content=self._critic_system_prompt), ChatMessage(role="user", content=critique_user_text), ], ) if critique_error is not None and tick_error is None: tick_error = critique_error recent_call_tokens.append(critique_tokens) tick_tokens_used += critique_tokens # Revision revision_caller_id = f"{self.CALLER_ID_PREFIX}:t{tick}:p{pass_idx}:revision" revision_user_text = ( observation_text + "\n\n=== Previous proposal ===\n" + ( _action_to_json_summary(current_candidate) if current_candidate is not None else "" ) + "\n\n=== Critique ===\n" + raw_critique ) raw_revision, revision_tokens, revision_error = self._safe_chat( caller_id=revision_caller_id, messages=[ ChatMessage(role="system", content=self._reviser_system_prompt), ChatMessage(role="user", content=revision_user_text), ], ) if revision_error is not None and tick_error is None: tick_error = revision_error recent_call_tokens.append(revision_tokens) tick_tokens_used += revision_tokens raw_revisions.append(raw_revision) new_candidate = parse_action(raw_revision) if new_candidate is not None: # Only update current_candidate when the revision parses # cleanly. Per design §20.1.1: never emit mid-revision drafts. current_candidate = new_candidate # If a prior pass had set tick_error="parse_failure" but a # later revision succeeded, we now have a parseable # candidate — clear the error so the final state reflects # the actual submitted action's source. if tick_error == "parse_failure": tick_error = None pass_count = pass_idx # ---------- Submit ---------- if current_candidate is None: # No pass produced a parseable action. Use the synthetic # V2-rejected marker so r_policy=0 lands on this tick. payload: OuterActionPayload = parse_failure_marker() tick_parse_failure = True self._log_parse_failure(tick=tick, raw=raw_initial) if tick_error is None: tick_error = "parse_failure" else: payload = current_candidate tick_parse_failure = False return { "payload": payload, "parse_failure": tick_parse_failure, "pass_count": pass_count, "tokens_used": tick_tokens_used, "error": tick_error, "raw_initial": raw_initial, "raw_revisions": raw_revisions, } # ------------------------------------------------------------------ # Internal: LLM call + error handling # ------------------------------------------------------------------ def _safe_chat( self, *, caller_id: str, messages: List[ChatMessage] ) -> tuple[str, int, Optional[ErrorKind]]: """Call llm.chat with try/except. Returns (content, tokens, error). Mirrors B1's parse-failure-as-rejection contract: on LLM call failure, return empty content, zero tokens, and ``error="llm_call_failed"``. The caller's parse step then trips and the synthetic marker (or a prior revision's candidate) flows through as the submitted action. """ try: response = self._llm.chat(caller_id=caller_id, messages=messages) tokens = int(response.prompt_tokens) + int(response.completion_tokens) return response.content, tokens, None except Exception as exc: # pragma: no cover - exercised manually print( f"[WARN] b2: llm.chat failed at caller={caller_id!r}: {exc!r}", file=sys.stderr, flush=True, ) return "", 0, "llm_call_failed" @staticmethod def _log_parse_failure(*, tick: int, raw: str) -> None: snippet = (raw or "").strip().replace("\n", " ") if len(snippet) > 80: snippet = snippet[:77] + "..." print( f"[WARN] b2: parse_failure at tick={tick} raw={snippet!r}", file=sys.stderr, flush=True, )