CrisisWorldCortex / baselines /flat_agent_matched_compute.py
Angshuman28's picture
Upload folder using huggingface_hub
53adefa verified
Raw
History Blame Contribute Delete
18.9 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.
"""
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 "<no parseable proposal yet>"
)
)
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 "<no parseable proposal yet>"
)
+ "\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,
)