""" Full multi-agent episode replay with both LLMs talking to each other. Runs ONE complete CounterFeint episode (Fraudster ⇄ Investigator ⇄ Auditor) with the *exact* policies the training pipeline uses, then prints every prompt, every raw completion, every parsed action and every reward in chronological order. Optionally writes the same transcript as a Markdown file under ``counterfeint/convo_logging/`` for easy sharing in slides / a PR. Why this exists --------------- Tests (``pytest``) prove the *individual* pieces work; the ``verify_*`` diagnostics prove each LLM produces schema-valid JSON in isolation. This script is the single tool that proves both LLMs **talk to each other coherently end-to-end** — the Fraudster proposes a ring, the Investigator (a ~5× smaller different-family model) sees the new ``evidence_ledger`` and (hopefully) starts linking accounts, the Heuristic Auditor scores the trace. Default setup matches the GRPO training cell in the notebook: * Fraudster: Ollama ``llama3.1:8b`` (frozen) * Investigator: Local ``Qwen/Qwen2.5-1.5B-Instruct`` in 4-bit (the trainable policy — same one the notebook trains) * Auditor: Heuristic (in-process Python) Run it ~~~~~~ :: # Default — both LLMs, full task_2 episode python -m counterfeint.diagnostics.replay_match # Pick a different task / seed / Investigator model python -m counterfeint.diagnostics.replay_match --task task_3 --seed 7 python -m counterfeint.diagnostics.replay_match --investigator-model Qwen/Qwen3.5-0.8B # Use the Ollama-backed HTTP Investigator (no transformers needed) python -m counterfeint.diagnostics.replay_match \\ --investigator-backend ollama --investigator-model llama3.1:8b # Don't write a transcript file (stdout only) python -m counterfeint.diagnostics.replay_match --no-save """ from __future__ import annotations import argparse import json import os import sys import textwrap import time from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple if __package__ in {None, ""}: _REPO_ROOT = Path(__file__).resolve().parent.parent.parent if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) from counterfeint.scripted import ( HeuristicAuditor, ReactiveFraudster, ScriptedInvestigator, ) from counterfeint.server.referee import RefereeEnvironment _DEFAULT_INVESTIGATOR_MODEL = os.getenv("INVESTIGATOR_MODEL", "Qwen/Qwen2.5-1.5B-Instruct") _DEFAULT_FRAUDSTER_MODEL = os.getenv("MODEL_NAME", "llama3.1:8b") _DEFAULT_OLLAMA_BASE = os.getenv("API_BASE_URL", "http://localhost:11434/v1") # ============================================================================= # Output helpers — both stdout and Markdown transcript writers # ============================================================================= class _TranscriptWriter: """Splits writes between stdout and an optional Markdown file.""" def __init__(self, md_path: Optional[Path]) -> None: self._md_path = md_path self._md_lines: List[str] = [] def stdout(self, text: str = "") -> None: print(text) def md(self, text: str = "") -> None: if self._md_path is not None: self._md_lines.append(text) def both(self, text: str = "") -> None: self.stdout(text) self.md(text) def hr_stdout(self, title: str = "") -> None: bar = "=" * 78 if title: self.stdout(f"\n{bar}\n{title}\n{bar}") else: self.stdout(bar) def section_md(self, title: str, level: int = 2) -> None: self.md(f"\n{'#' * level} {title}\n") def code_block( self, content: str, *, lang: str = "", indent_stdout: str = " " ) -> None: self.stdout(textwrap.indent(content, indent_stdout)) self.md(f"```{lang}") self.md(content) self.md("```") def flush(self) -> Optional[Path]: if self._md_path is None: return None self._md_path.parent.mkdir(parents=True, exist_ok=True) self._md_path.write_text("\n".join(self._md_lines) + "\n", encoding="utf-8") return self._md_path # ============================================================================= # Policy factories # ============================================================================= def _build_fraudster(backend: str, model: str, base_url: str) -> Tuple[Any, str]: """Return (policy, label) for the chosen Fraudster backend.""" if backend == "scripted": return ReactiveFraudster(seed=42), "ReactiveFraudster (scripted)" if backend == "ollama": from counterfeint.agents import LLMFraudster return ( LLMFraudster( model_name=model, api_base_url=base_url, api_key="ollama", temperature=0.2, max_tokens=128, timeout_s=120.0, retries=1, ), f"LLMFraudster (Ollama / {model})", ) raise ValueError(f"Unknown fraudster backend: {backend!r}") def _build_investigator( backend: str, *, hf_model: str, load_in_4bit: bool, ollama_model: str, ollama_base: str, ) -> Tuple[Any, str]: """Return (policy, label) for the chosen Investigator backend.""" if backend == "scripted": return ScriptedInvestigator(), "ScriptedInvestigator (scripted)" if backend == "hf": from counterfeint.agents import HFInvestigator print(f"[load] Loading HFInvestigator from {hf_model} (4bit={load_in_4bit}) ...") t0 = time.perf_counter() inv = HFInvestigator.from_pretrained( hf_model, load_in_4bit=load_in_4bit, max_new_tokens=128, temperature=0.3, do_sample=True, ) print(f"[load] OK in {time.perf_counter() - t0:.1f}s.") return inv, f"HFInvestigator (local / {hf_model})" if backend == "ollama": from counterfeint.agents import LLMInvestigator return ( LLMInvestigator( model_name=ollama_model, api_base_url=ollama_base, api_key="ollama", temperature=0.2, max_tokens=128, timeout_s=120.0, retries=1, ), f"LLMInvestigator (Ollama / {ollama_model})", ) raise ValueError(f"Unknown investigator backend: {backend!r}") # ============================================================================= # Per-turn dump # ============================================================================= def _record_turn( out: _TranscriptWriter, *, step_idx: int, role: str, policy_label: str, obs_summary: str, user_prompt: Optional[str], raw_completion: Optional[str], parsed_action: Any, reward: float, current_phase: str, prev_phase: str, include_system: bool, system_prompt: Optional[str], ) -> None: """Print one turn to stdout AND append to the Markdown transcript.""" role_upper = role.upper() title = f"Step {step_idx} — {role_upper} ({policy_label})" out.hr_stdout(title) out.section_md(title, level=2) out.stdout(f"obs: {obs_summary}") out.md(f"**Observation summary:** {obs_summary}") if include_system and system_prompt: out.stdout("\n[SYSTEM PROMPT]") out.md("\n**System prompt:**") out.code_block(system_prompt, lang="text") if user_prompt is not None: out.stdout("\n[USER PROMPT]") out.md("\n**User prompt:**") out.code_block(user_prompt, lang="text") if raw_completion is not None: out.stdout("\n[RAW COMPLETION]") out.md("\n**Raw completion:**") out.code_block(raw_completion if raw_completion.strip() else "(empty)", lang="text") elif role in {"fraudster", "investigator"}: out.stdout("\n[RAW COMPLETION] (none — fallback fired; scripted action used)") out.md("\n**Raw completion:** _(none — fallback fired; scripted action used)_") parsed_dict = parsed_action.model_dump(exclude_none=True) if parsed_action else {} out.stdout("\n[PARSED ACTION]") out.md("\n**Parsed action:**") out.code_block(json.dumps(parsed_dict, indent=2), lang="json") # Use ``current_phase`` (post-step) so a step that stayed in the # same role for another action is labelled clearly rather than # looking like a phase transition. Add a ``(continued)`` marker # when the role keeps the turn — this is the multi-action-per-turn # case that previously read as a confusing "new_phase=fraudster_turn" # right after a fraudster step. phase_note = ( f"current_phase={current_phase}" if current_phase != prev_phase else f"current_phase={current_phase} (continued — same role's turn)" ) out.both(f"\nreward={reward:+.3f} {phase_note}") # ============================================================================= # Main episode loop # ============================================================================= def _run_episode( *, task_id: str, seed: int, fraudster: Any, fraudster_label: str, investigator: Any, investigator_label: str, auditor: Any, auditor_label: str, out: _TranscriptWriter, max_steps: int, include_system: bool, ) -> Dict[str, Any]: env = RefereeEnvironment() env.reset_match(task_id=task_id, seed=seed) out.both(f"\n*Task:* `{task_id}` *Seed:* `{seed}`") out.both(f"*Fraudster:* `{fraudster_label}`") out.both(f"*Investigator:* `{investigator_label}`") out.both(f"*Auditor:* `{auditor_label}`") out.both( f"*Match knobs:* max_rounds={env.state.max_rounds}, " f"max_proposals={env.state.max_proposals}, " f"max_steps={max_steps}" ) role_handlers: Dict[str, Tuple[Any, str, Callable[[], Any], Callable[[Any], Any], str]] = { "fraudster_turn": ( fraudster, fraudster_label, env.build_fraudster_observation, env.step_as_fraudster, "fraudster", ), "investigator_turn": ( investigator, investigator_label, env.build_investigator_observation, env.step_as_investigator, "investigator", ), "audit_phase": ( auditor, auditor_label, env.build_auditor_observation, env.step_as_auditor, "auditor", ), } role_reward_acc: Dict[str, float] = { "fraudster": 0.0, "investigator": 0.0, "auditor": 0.0, } schema_fails: Dict[str, int] = { "fraudster": 0, "investigator": 0, "auditor": 0, } fallback_baseline: Dict[str, int] = { "fraudster": getattr(fraudster, "fallback_count", 0), "investigator": getattr(investigator, "fallback_count", 0), "auditor": 0, } step_idx = 0 while env.phase in role_handlers: policy, label, build_obs_fn, step_fn, role_name = role_handlers[env.phase] phase_before = env.phase # Auditor steps are cheap deterministic flag actions — don't # count them against the gameplay max_steps cap, otherwise the # submit_audit_report (which triggers grader scoring) may fall # outside the budget and grader_score stays null. if role_name != "auditor" and step_idx >= max_steps: break obs = build_obs_fn() obs_dict = obs.model_dump() obs_summary = _summarise_obs(obs_dict, role=role_name) # Snapshot the LLM trace slots before/after .act() so we can capture # what the model actually saw and emitted on this turn. for slot in ("last_prompt", "last_completion", "last_error"): if hasattr(policy, slot): setattr(policy, slot, None) try: action = policy.act(obs_dict) except Exception as exc: out.both( f"\n[POLICY CRASH] {role_name} ({label}): " f"{type(exc).__name__}: {exc}" ) schema_fails[role_name] += 1 break try: new_obs = step_fn(action) except Exception as exc: out.both( f"\n[ENV REJECTED] {role_name} action: " f"{type(exc).__name__}: {exc}" ) break new_obs_dict = new_obs.model_dump() if new_obs is not None else {} reward = float(new_obs_dict.get("reward", 0.0) or 0.0) role_reward_acc[role_name] += reward step_idx += 1 _record_turn( out, step_idx=step_idx, role=role_name, policy_label=label, obs_summary=obs_summary, user_prompt=getattr(policy, "last_prompt", None), raw_completion=getattr(policy, "last_completion", None), parsed_action=action, reward=reward, current_phase=env.phase, prev_phase=phase_before, include_system=include_system, system_prompt=getattr(policy, "system_prompt", None), ) if env.phase != "done": out.both(f"\n[STOP] Hit max_steps={max_steps}; episode not finished.") state = env.state final_summary = { "task_id": task_id, "seed": seed, "steps_played": step_idx, "stopped_phase": env.phase, "end_reason": getattr(state, "end_reason", None), "rewards_by_role": role_reward_acc, "fraudster_reward_total": getattr(state, "fraudster_reward", 0.0), "investigator_reward_total": getattr(state, "investigator_reward", 0.0), "auditor_reward_total": getattr(state, "auditor_reward", 0.0), "grader_score": getattr(state, "grader_score", None), "fallback_count_delta": { "fraudster": ( getattr(fraudster, "fallback_count", 0) - fallback_baseline["fraudster"] ), "investigator": ( getattr(investigator, "fallback_count", 0) - fallback_baseline["investigator"] ), }, } out.hr_stdout("EPISODE SUMMARY") out.section_md("Episode summary", level=2) out.code_block(json.dumps(final_summary, indent=2), lang="json") return final_summary def _summarise_obs(obs_dict: Dict[str, Any], *, role: str) -> str: """One-line preview to print before the full prompt block.""" if role == "fraudster": queue = obs_dict.get("current_queue") or [] verdicts = obs_dict.get("prior_verdicts") or [] my_signals = obs_dict.get("my_proposal_signals") or [] return ( f"round={obs_dict.get('round_number')} " f"actions_left={obs_dict.get('actions_left_this_turn')} " f"queue={len(queue)} prior_verdicts={len(verdicts)} " f"my_proposals={len(my_signals)}" ) if role == "investigator": queue_status = obs_dict.get("queue_status") or {} pending = obs_dict.get("available_ads") or [] ledger = obs_dict.get("evidence_ledger") or {} return ( f"task={queue_status.get('task_id')} " f"steps_remaining={queue_status.get('steps_remaining')} " f"pending_ads={len(pending)} ledger_rows={len(ledger)}" ) if role == "auditor": flags = obs_dict.get("pending_flags") or [] return ( f"phase={obs_dict.get('phase')} " f"investigator_actions={len(obs_dict.get('investigator_actions') or [])} " f"fraudster_proposals={len(obs_dict.get('fraudster_proposals') or [])} " f"existing_flags={len(flags)}" ) return f"role={role}" # ============================================================================= # CLI # ============================================================================= def main() -> int: parser = argparse.ArgumentParser( description=( "Replay one CounterFeint multi-agent episode end-to-end and " "dump the full LLM conversation." ) ) parser.add_argument( "--task", default="task_2", choices=("task_1", "task_2", "task_3", "task_3_unseen"), help="Task tier (default task_2 — has fraud rings + reactive verdicts).", ) parser.add_argument("--seed", type=int, default=42) parser.add_argument( "--investigator-backend", default="ollama", choices=("hf", "ollama", "scripted"), help=( "ollama = HTTP via Ollama (default), " "hf = local transformers (Qwen2.5-1.5B-Instruct, requires torch), " "scripted = no LLM." ), ) parser.add_argument( "--investigator-model", default=_DEFAULT_INVESTIGATOR_MODEL, help=( f"HF model id for backend=hf (default {_DEFAULT_INVESTIGATOR_MODEL!r}), " "or Ollama model name for backend=ollama." ), ) parser.add_argument( "--no-4bit", action="store_true", help="Load HFInvestigator in fp16/bf16 instead of 4-bit.", ) parser.add_argument( "--fraudster-backend", default="ollama", choices=("ollama", "scripted"), help="ollama (default — needs `ollama serve`) or scripted.", ) parser.add_argument( "--fraudster-model", default=_DEFAULT_FRAUDSTER_MODEL, help=f"Ollama model name for the Fraudster (default {_DEFAULT_FRAUDSTER_MODEL!r}).", ) parser.add_argument( "--ollama-base", default=_DEFAULT_OLLAMA_BASE, help=f"Ollama OpenAI-compat base URL (default {_DEFAULT_OLLAMA_BASE!r}).", ) parser.add_argument( "--max-steps", type=int, default=50, help="Hard cap on steps to play (safety net; episodes usually end earlier).", ) parser.add_argument( "--include-system-prompt", action="store_true", help="Also print the LLMs' (long) system prompt with every turn.", ) save_group = parser.add_mutually_exclusive_group() save_group.add_argument( "--save-transcript", default=None, help=( "Where to write the Markdown transcript. " "Default: counterfeint/convo_logging/replay__seed_.md" ), ) save_group.add_argument( "--no-save", action="store_true", help="Don't write a Markdown transcript (stdout only).", ) args = parser.parse_args() # ----- Resolve transcript path ----- md_path: Optional[Path] = None if not args.no_save: if args.save_transcript: md_path = Path(args.save_transcript).resolve() else: stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") convo_dir = Path(__file__).resolve().parent.parent / "convo_logging" md_path = convo_dir / f"replay_{args.task}_seed{args.seed}_{stamp}.md" out = _TranscriptWriter(md_path) out.hr_stdout("CounterFeint multi-agent episode replay") out.section_md(f"CounterFeint replay — `{args.task}` seed `{args.seed}`", level=1) out.md(f"_Generated: {datetime.now(timezone.utc).isoformat()}_ ") # ----- Build policies ----- print(f"\n[init] Building Fraudster (backend={args.fraudster_backend}) ...") fraudster, fraudster_label = _build_fraudster( args.fraudster_backend, args.fraudster_model, args.ollama_base, ) print(f"[init] Building Investigator (backend={args.investigator_backend}) ...") try: investigator, investigator_label = _build_investigator( args.investigator_backend, hf_model=args.investigator_model, load_in_4bit=not args.no_4bit, ollama_model=args.investigator_model, ollama_base=args.ollama_base, ) except Exception as exc: print( f"[FATAL] could not build Investigator backend={args.investigator_backend} " f"({type(exc).__name__}: {exc})" ) return 2 auditor = HeuristicAuditor() auditor_label = "HeuristicAuditor (in-process Python)" # ----- Run the episode ----- try: summary = _run_episode( task_id=args.task, seed=args.seed, fraudster=fraudster, fraudster_label=fraudster_label, investigator=investigator, investigator_label=investigator_label, auditor=auditor, auditor_label=auditor_label, out=out, max_steps=args.max_steps, include_system=args.include_system_prompt, ) except Exception as exc: print(f"\n[FATAL] episode crashed: {type(exc).__name__}: {exc}") out.flush() raise # ----- Persist transcript ----- written = out.flush() if written is not None: print(f"\n[transcript] Wrote {written} ({written.stat().st_size} bytes).") else: print("\n[transcript] --no-save was set; skipped writing.") # Exit non-zero only on hard crashes — schema fails are already printed. return 0 if summary.get("steps_played", 0) > 0 else 1 if __name__ == "__main__": sys.exit(main())