Spaces:
Sleeping
Action Coach (conversational action input) + orchestrator post-action guard (#6)
Browse files* Add post-action guard to orchestrator route + UI gate
The orchestrator clones a pristine Turn 1 to fork branches; clone_pristine
raises ValueError if the source has already played. The route silently
passed the request through, the worker swallowed the exception, and the UI
showed three failed branches with no usable signal. Now the route rejects
with 409 when game.world.turn > 1, the first turn has a player_action, or
phase != BRIEFING. The frontend hides the launch button under the same
conditions and shows a one-line helper pointing the user at fork.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add Action Coach: conversational action input that drafts 3-anchor ActionSubmissions
Adds a small LLM-driven coach that turns CEO prose into the engine's exact
action contract (intent + justification + 3 anchors of valid kind). Mirrors
the setup_agent shape: one structured-output call per turn, JSON session
storage under games_dir/action_coach_sessions/, capped at 2 conversational
rounds, downgrade-to-ready when the budget is exhausted. The coach is a
pre-validation layer β on commit it submits the structured action through
the existing loop.act path so the engine still owns validation and the
3-anchor contract is enforced at the Pydantic boundary.
New endpoints (all appended at end of routes.py to stay clear of the
orchestrator section another agent is patching):
- POST /games/{id}/actions/coach (start session with initial_prose)
- POST /games/{id}/actions/coach/{session_id}/answer (one CEO message)
- POST /games/{id}/actions/coach/{session_id}/commit (submit via engine)
- GET /games/{id}/actions/coach/{session_id}
Frontend adds a "Talk to coach" / "Manual" tab strip inside the existing
nh-composer block in GameView.tsx β Coach is the default, Manual is the
escape hatch into the form. The coach UI mirrors SetupCase.tsx's chat
shape (nh-chat / nh-msg classes). The orchestrator UI block is untouched.
Tests: 9 new offline tests via fake_llm covering one-shot ready, ask-then-
ready, round-cap forces ready, commit-via-engine, and the four endpoints.
156 -> 165 tests, all passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Action Coach: anchor the CEO's intent verbatim, no silent reframing
The original prompt let the LLM upgrade the move into the version it
considered wiser β turning a free-standing Transparenzbeirat into a
narrowed Stiftungsrat-mandated review, etc. CEO playtesters flagged
that as the coach overruling them.
Tighten the contract: the `intent` field is the CEO's move, faithfully
captured. Risk flags belong in `message_to_user`. The coach may only
rewrite the move when the prose is incoherent or when the CEO explicitly
asks for pushback.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- frontend/src/api.ts +43 -0
- frontend/src/screens/GameView.tsx +366 -8
- wargame/agents/action_coach.py +459 -0
- wargame/api/routes.py +190 -0
- wargame/api/schemas.py +41 -0
- wargame/prompts/action_coach.md +79 -0
- wargame/tests/conftest.py +88 -0
- wargame/tests/test_action_coach.py +341 -0
- wargame/tests/test_api.py +26 -0
|
@@ -67,6 +67,25 @@ export const api = {
|
|
| 67 |
req<OrchestrateStatus>(`/games/${game_id}/orchestrate/status`),
|
| 68 |
orchestrateResult: (game_id: string) =>
|
| 69 |
req<ScenarioMap>(`/games/${game_id}/orchestrate/result`),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
};
|
| 71 |
|
| 72 |
export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
|
|
@@ -151,3 +170,27 @@ export interface ScenarioMap {
|
|
| 151 |
branches_complete: number;
|
| 152 |
error: string | null;
|
| 153 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
req<OrchestrateStatus>(`/games/${game_id}/orchestrate/status`),
|
| 68 |
orchestrateResult: (game_id: string) =>
|
| 69 |
req<ScenarioMap>(`/games/${game_id}/orchestrate/result`),
|
| 70 |
+
|
| 71 |
+
// Action Coach β conversational action input that drafts a 3-anchor
|
| 72 |
+
// ActionSubmission and submits it through the same engine path.
|
| 73 |
+
actionCoachStart: (game_id: string, initial_prose: string) =>
|
| 74 |
+
req<ActionCoachSession>(`/games/${game_id}/actions/coach`, {
|
| 75 |
+
method: 'POST',
|
| 76 |
+
body: JSON.stringify({ initial_prose }),
|
| 77 |
+
}),
|
| 78 |
+
actionCoachLoad: (game_id: string, session_id: string) =>
|
| 79 |
+
req<ActionCoachSession>(`/games/${game_id}/actions/coach/${session_id}`),
|
| 80 |
+
actionCoachAnswer: (game_id: string, session_id: string, message: string) =>
|
| 81 |
+
req<ActionCoachSession>(`/games/${game_id}/actions/coach/${session_id}/answer`, {
|
| 82 |
+
method: 'POST',
|
| 83 |
+
body: JSON.stringify({ message }),
|
| 84 |
+
}),
|
| 85 |
+
actionCoachCommit: (game_id: string, session_id: string) =>
|
| 86 |
+
req<ActionAcceptedResponse>(`/games/${game_id}/actions/coach/${session_id}/commit`, {
|
| 87 |
+
method: 'POST',
|
| 88 |
+
}),
|
| 89 |
};
|
| 90 |
|
| 91 |
export type AnchorKind = 'prior_analogue' | 'stated_capability' | 'resource_committed';
|
|
|
|
| 170 |
branches_complete: number;
|
| 171 |
error: string | null;
|
| 172 |
}
|
| 173 |
+
|
| 174 |
+
// Action Coach β conversational action input.
|
| 175 |
+
export interface ActionDraft {
|
| 176 |
+
intent: string;
|
| 177 |
+
justification: string;
|
| 178 |
+
anchors: Anchor[];
|
| 179 |
+
}
|
| 180 |
+
export interface CoachMessage {
|
| 181 |
+
role: 'user' | 'coach';
|
| 182 |
+
content: string;
|
| 183 |
+
at: string;
|
| 184 |
+
}
|
| 185 |
+
export type ActionCoachStatus = 'gathering' | 'ready' | 'committed';
|
| 186 |
+
export interface ActionCoachSession {
|
| 187 |
+
session_id: string;
|
| 188 |
+
game_id: string;
|
| 189 |
+
turn: number;
|
| 190 |
+
status: ActionCoachStatus;
|
| 191 |
+
rounds_used: number;
|
| 192 |
+
last_coach_message: string;
|
| 193 |
+
synthesized_draft: ActionDraft | null;
|
| 194 |
+
committed: boolean;
|
| 195 |
+
messages: CoachMessage[];
|
| 196 |
+
}
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
import remarkGfm from 'remark-gfm';
|
| 4 |
-
import { api, Briefing, Anchor, AnchorKind, ScenarioMap, AgentInfo } from '../api';
|
| 5 |
import { navigate } from '../App';
|
| 6 |
import { Dossier, Panel, PhaseRail, Spinner } from '../components/Shell';
|
| 7 |
|
|
@@ -47,6 +47,15 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 47 |
const [submitBusy, setSubmitBusy] = useState(false);
|
| 48 |
const [actionSent, setActionSent] = useState(false);
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
// Advance
|
| 51 |
const [advanceBusy, setAdvanceBusy] = useState(false);
|
| 52 |
const [advanceLog, setAdvanceLog] = useState<string[]>([]);
|
|
@@ -161,6 +170,11 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 161 |
|
| 162 |
const anchorsFilled = anchors.filter((a) => a.detail.trim()).length;
|
| 163 |
const canSubmit = intent.trim() && why.trim() && anchorsFilled >= 3;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
async function askRecon() {
|
| 166 |
if (!reconQ.trim() || budget <= 0) return;
|
|
@@ -197,6 +211,67 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 197 |
setSubmitBusy(false);
|
| 198 |
}
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
async function advance() {
|
| 201 |
setAdvanceBusy(true);
|
| 202 |
setPhase('deliberation');
|
|
@@ -224,6 +299,7 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 224 |
]);
|
| 225 |
setReconLog([]);
|
| 226 |
setPhase('briefing');
|
|
|
|
| 227 |
await loadAll();
|
| 228 |
setAdvanceLog([]);
|
| 229 |
} catch (e) {
|
|
@@ -407,13 +483,33 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 407 |
onToggle={() => setOrchOpen((v) => !v)}
|
| 408 |
onRun={runOrchestrator}
|
| 409 |
onAdopt={adoptOpener}
|
|
|
|
| 410 |
/>
|
| 411 |
|
| 412 |
{/* ACTION COMPOSER */}
|
| 413 |
<section className={`nh-composer ${actionSent ? 'submitted' : ''}`}>
|
| 414 |
<div className="nh-composer__hd">
|
| 415 |
<h2>Your move, Turn {turn}</h2>
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
</div>
|
| 418 |
|
| 419 |
{actionSent ? (
|
|
@@ -429,6 +525,32 @@ export function GameView({ id, onMeta }: Props) {
|
|
| 429 |
</button>
|
| 430 |
</div>
|
| 431 |
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
) : (
|
| 433 |
<>
|
| 434 |
<div className="nh-formrow">
|
|
@@ -645,7 +767,7 @@ function VerdictHistory({
|
|
| 645 |
}
|
| 646 |
|
| 647 |
function OrchestratorBar({
|
| 648 |
-
map, error, open, onToggle, onRun, onAdopt,
|
| 649 |
}: {
|
| 650 |
map: ScenarioMap | null;
|
| 651 |
error: string | null;
|
|
@@ -653,6 +775,7 @@ function OrchestratorBar({
|
|
| 653 |
onToggle: () => void;
|
| 654 |
onRun: () => void;
|
| 655 |
onAdopt: (branch: ScenarioMap['branches'][number]) => void;
|
|
|
|
| 656 |
}) {
|
| 657 |
const running = map?.status === 'running' || map?.status === 'pending';
|
| 658 |
const failed = map?.status === 'failed';
|
|
@@ -699,9 +822,15 @@ function OrchestratorBar({
|
|
| 699 |
Three strategy vectors will play a proxy version of this turn at different skill temperatures.
|
| 700 |
You get a side-by-side outcome read before you commit your own move. Takes 3β5 minutes.
|
| 701 |
</p>
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
</div>
|
| 706 |
)}
|
| 707 |
|
|
@@ -752,7 +881,13 @@ function OrchestratorBar({
|
|
| 752 |
{failed && open && (
|
| 753 |
<div className="nh-orch__failed">
|
| 754 |
<p style={{ color: 'var(--oxblood)' }}>Orchestration failed: {map?.error || 'unknown error'}</p>
|
| 755 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
</div>
|
| 757 |
)}
|
| 758 |
|
|
@@ -825,7 +960,13 @@ function OrchestratorBar({
|
|
| 825 |
</details>
|
| 826 |
)}
|
| 827 |
<div className="nh-orch__ft">
|
| 828 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 829 |
</div>
|
| 830 |
</>
|
| 831 |
)}
|
|
@@ -842,3 +983,220 @@ function nowHM() {
|
|
| 842 |
const d = new Date();
|
| 843 |
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
| 844 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useEffect, useMemo, useRef, useState } from 'react';
|
| 2 |
import ReactMarkdown from 'react-markdown';
|
| 3 |
import remarkGfm from 'remark-gfm';
|
| 4 |
+
import { api, Briefing, Anchor, AnchorKind, ScenarioMap, AgentInfo, ActionCoachSession, CoachMessage } from '../api';
|
| 5 |
import { navigate } from '../App';
|
| 6 |
import { Dossier, Panel, PhaseRail, Spinner } from '../components/Shell';
|
| 7 |
|
|
|
|
| 47 |
const [submitBusy, setSubmitBusy] = useState(false);
|
| 48 |
const [actionSent, setActionSent] = useState(false);
|
| 49 |
|
| 50 |
+
// Action Coach (conversational mode) β defaults to on so the CEO can
|
| 51 |
+
// talk through the move; "Manual" is an escape hatch into the form.
|
| 52 |
+
const [composerMode, setComposerMode] = useState<'coach' | 'manual'>('coach');
|
| 53 |
+
const [coachSession, setCoachSession] = useState<ActionCoachSession | null>(null);
|
| 54 |
+
const [coachInput, setCoachInput] = useState('');
|
| 55 |
+
const [coachBusy, setCoachBusy] = useState(false);
|
| 56 |
+
const [coachErr, setCoachErr] = useState<string | null>(null);
|
| 57 |
+
const coachThreadRef = useRef<HTMLDivElement | null>(null);
|
| 58 |
+
|
| 59 |
// Advance
|
| 60 |
const [advanceBusy, setAdvanceBusy] = useState(false);
|
| 61 |
const [advanceLog, setAdvanceLog] = useState<string[]>([]);
|
|
|
|
| 170 |
|
| 171 |
const anchorsFilled = anchors.filter((a) => a.detail.trim()).length;
|
| 172 |
const canSubmit = intent.trim() && why.trim() && anchorsFilled >= 3;
|
| 173 |
+
const firstTurnAction = state?.turns?.[0]?.player_action ?? null;
|
| 174 |
+
const canRunOrchestrator =
|
| 175 |
+
!actionSent
|
| 176 |
+
&& (state?.world?.turn ?? 1) <= 1
|
| 177 |
+
&& firstTurnAction == null;
|
| 178 |
|
| 179 |
async function askRecon() {
|
| 180 |
if (!reconQ.trim() || budget <= 0) return;
|
|
|
|
| 211 |
setSubmitBusy(false);
|
| 212 |
}
|
| 213 |
|
| 214 |
+
// ---- Action Coach -----------------------------------------------------
|
| 215 |
+
|
| 216 |
+
async function startCoach() {
|
| 217 |
+
if (!coachInput.trim() || coachBusy) return;
|
| 218 |
+
setCoachBusy(true); setCoachErr(null);
|
| 219 |
+
const prose = coachInput.trim();
|
| 220 |
+
setCoachInput('');
|
| 221 |
+
try {
|
| 222 |
+
const s = await api.actionCoachStart(id, prose);
|
| 223 |
+
setCoachSession(s);
|
| 224 |
+
} catch (e) {
|
| 225 |
+
setCoachErr(String(e));
|
| 226 |
+
setCoachInput(prose);
|
| 227 |
+
}
|
| 228 |
+
setCoachBusy(false);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
async function answerCoach() {
|
| 232 |
+
if (!coachSession || !coachInput.trim() || coachBusy) return;
|
| 233 |
+
setCoachBusy(true); setCoachErr(null);
|
| 234 |
+
const msg = coachInput.trim();
|
| 235 |
+
setCoachInput('');
|
| 236 |
+
try {
|
| 237 |
+
const s = await api.actionCoachAnswer(id, coachSession.session_id, msg);
|
| 238 |
+
setCoachSession(s);
|
| 239 |
+
} catch (e) {
|
| 240 |
+
setCoachErr(String(e));
|
| 241 |
+
setCoachInput(msg);
|
| 242 |
+
}
|
| 243 |
+
setCoachBusy(false);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
async function commitCoach() {
|
| 247 |
+
if (!coachSession || coachSession.status !== 'ready' || coachBusy) return;
|
| 248 |
+
setCoachBusy(true); setCoachErr(null);
|
| 249 |
+
try {
|
| 250 |
+
await api.actionCoachCommit(id, coachSession.session_id);
|
| 251 |
+
// Mirror submitAction: flip to 'submitted' UI and let the user advance
|
| 252 |
+
setActionSent(true);
|
| 253 |
+
setPhase('action');
|
| 254 |
+
// Clear the coach session locally (server still has it for reference)
|
| 255 |
+
setCoachSession(null);
|
| 256 |
+
} catch (e) {
|
| 257 |
+
setCoachErr(String(e));
|
| 258 |
+
}
|
| 259 |
+
setCoachBusy(false);
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
function resetCoach() {
|
| 263 |
+
setCoachSession(null);
|
| 264 |
+
setCoachErr(null);
|
| 265 |
+
setCoachInput('');
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Auto-scroll the coach thread to the latest message.
|
| 269 |
+
useEffect(() => {
|
| 270 |
+
if (coachThreadRef.current) {
|
| 271 |
+
coachThreadRef.current.scrollTop = coachThreadRef.current.scrollHeight;
|
| 272 |
+
}
|
| 273 |
+
}, [coachSession?.messages.length, coachBusy]);
|
| 274 |
+
|
| 275 |
async function advance() {
|
| 276 |
setAdvanceBusy(true);
|
| 277 |
setPhase('deliberation');
|
|
|
|
| 299 |
]);
|
| 300 |
setReconLog([]);
|
| 301 |
setPhase('briefing');
|
| 302 |
+
resetCoach();
|
| 303 |
await loadAll();
|
| 304 |
setAdvanceLog([]);
|
| 305 |
} catch (e) {
|
|
|
|
| 483 |
onToggle={() => setOrchOpen((v) => !v)}
|
| 484 |
onRun={runOrchestrator}
|
| 485 |
onAdopt={adoptOpener}
|
| 486 |
+
canRun={canRunOrchestrator}
|
| 487 |
/>
|
| 488 |
|
| 489 |
{/* ACTION COMPOSER */}
|
| 490 |
<section className={`nh-composer ${actionSent ? 'submitted' : ''}`}>
|
| 491 |
<div className="nh-composer__hd">
|
| 492 |
<h2>Your move, Turn {turn}</h2>
|
| 493 |
+
{actionSent ? (
|
| 494 |
+
<span className="hint">Submitted</span>
|
| 495 |
+
) : (
|
| 496 |
+
<div role="tablist" aria-label="Composer mode" style={{ display: 'flex', gap: 6 }}>
|
| 497 |
+
<button
|
| 498 |
+
role="tab"
|
| 499 |
+
aria-selected={composerMode === 'coach'}
|
| 500 |
+
className={`btn sm ${composerMode === 'coach' ? 'primary' : 'ghost'}`}
|
| 501 |
+
onClick={() => setComposerMode('coach')}
|
| 502 |
+
disabled={submitBusy || coachBusy}
|
| 503 |
+
>Talk to coach</button>
|
| 504 |
+
<button
|
| 505 |
+
role="tab"
|
| 506 |
+
aria-selected={composerMode === 'manual'}
|
| 507 |
+
className={`btn sm ${composerMode === 'manual' ? 'primary' : 'ghost'}`}
|
| 508 |
+
onClick={() => setComposerMode('manual')}
|
| 509 |
+
disabled={submitBusy || coachBusy}
|
| 510 |
+
>Manual</button>
|
| 511 |
+
</div>
|
| 512 |
+
)}
|
| 513 |
</div>
|
| 514 |
|
| 515 |
{actionSent ? (
|
|
|
|
| 525 |
</button>
|
| 526 |
</div>
|
| 527 |
</>
|
| 528 |
+
) : composerMode === 'coach' ? (
|
| 529 |
+
<ActionCoachThread
|
| 530 |
+
session={coachSession}
|
| 531 |
+
input={coachInput}
|
| 532 |
+
setInput={setCoachInput}
|
| 533 |
+
busy={coachBusy}
|
| 534 |
+
err={coachErr}
|
| 535 |
+
threadRef={coachThreadRef}
|
| 536 |
+
onStart={startCoach}
|
| 537 |
+
onAnswer={answerCoach}
|
| 538 |
+
onCommit={commitCoach}
|
| 539 |
+
onReset={resetCoach}
|
| 540 |
+
onSwitchToManualWithDraft={() => {
|
| 541 |
+
// Pre-fill the manual form with the draft so the user can tweak.
|
| 542 |
+
const d = coachSession?.synthesized_draft;
|
| 543 |
+
if (d) {
|
| 544 |
+
setIntent(d.intent.slice(0, 160));
|
| 545 |
+
setWhy(d.justification.slice(0, 400));
|
| 546 |
+
if (Array.isArray(d.anchors) && d.anchors.length >= 3) {
|
| 547 |
+
setAnchors(d.anchors.slice(0, 5).map((a) => ({ kind: a.kind, detail: a.detail })));
|
| 548 |
+
}
|
| 549 |
+
}
|
| 550 |
+
setComposerMode('manual');
|
| 551 |
+
}}
|
| 552 |
+
turn={turn}
|
| 553 |
+
/>
|
| 554 |
) : (
|
| 555 |
<>
|
| 556 |
<div className="nh-formrow">
|
|
|
|
| 767 |
}
|
| 768 |
|
| 769 |
function OrchestratorBar({
|
| 770 |
+
map, error, open, onToggle, onRun, onAdopt, canRun,
|
| 771 |
}: {
|
| 772 |
map: ScenarioMap | null;
|
| 773 |
error: string | null;
|
|
|
|
| 775 |
onToggle: () => void;
|
| 776 |
onRun: () => void;
|
| 777 |
onAdopt: (branch: ScenarioMap['branches'][number]) => void;
|
| 778 |
+
canRun: boolean;
|
| 779 |
}) {
|
| 780 |
const running = map?.status === 'running' || map?.status === 'pending';
|
| 781 |
const failed = map?.status === 'failed';
|
|
|
|
| 822 |
Three strategy vectors will play a proxy version of this turn at different skill temperatures.
|
| 823 |
You get a side-by-side outcome read before you commit your own move. Takes 3β5 minutes.
|
| 824 |
</p>
|
| 825 |
+
{canRun ? (
|
| 826 |
+
<button className="btn primary" onClick={onRun}>
|
| 827 |
+
Run orchestrator β
|
| 828 |
+
</button>
|
| 829 |
+
) : (
|
| 830 |
+
<span className="help mono fg-3">
|
| 831 |
+
Orchestrator only runs pre-action β fork from Turn 1 to re-run.
|
| 832 |
+
</span>
|
| 833 |
+
)}
|
| 834 |
</div>
|
| 835 |
)}
|
| 836 |
|
|
|
|
| 881 |
{failed && open && (
|
| 882 |
<div className="nh-orch__failed">
|
| 883 |
<p style={{ color: 'var(--oxblood)' }}>Orchestration failed: {map?.error || 'unknown error'}</p>
|
| 884 |
+
{canRun ? (
|
| 885 |
+
<button className="btn ghost sm" onClick={onRun}>Retry β</button>
|
| 886 |
+
) : (
|
| 887 |
+
<span className="help mono fg-3">
|
| 888 |
+
Orchestrator only runs pre-action β fork from Turn 1 to re-run.
|
| 889 |
+
</span>
|
| 890 |
+
)}
|
| 891 |
</div>
|
| 892 |
)}
|
| 893 |
|
|
|
|
| 960 |
</details>
|
| 961 |
)}
|
| 962 |
<div className="nh-orch__ft">
|
| 963 |
+
{canRun ? (
|
| 964 |
+
<button className="btn ghost sm" onClick={onRun}>Re-run orchestrator β</button>
|
| 965 |
+
) : (
|
| 966 |
+
<span className="help mono fg-3">
|
| 967 |
+
Orchestrator only runs pre-action β fork from Turn 1 to re-run.
|
| 968 |
+
</span>
|
| 969 |
+
)}
|
| 970 |
</div>
|
| 971 |
</>
|
| 972 |
)}
|
|
|
|
| 983 |
const d = new Date();
|
| 984 |
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
|
| 985 |
}
|
| 986 |
+
|
| 987 |
+
// ---------------------------------------------------------------------------
|
| 988 |
+
// Action Coach β chat thread that turns prose into a 3-anchor ActionSubmission.
|
| 989 |
+
// Shape mirrors SetupCase.tsx's nh-chat / nh-msg classes for consistency.
|
| 990 |
+
// ---------------------------------------------------------------------------
|
| 991 |
+
|
| 992 |
+
function ActionCoachThread({
|
| 993 |
+
session,
|
| 994 |
+
input,
|
| 995 |
+
setInput,
|
| 996 |
+
busy,
|
| 997 |
+
err,
|
| 998 |
+
threadRef,
|
| 999 |
+
onStart,
|
| 1000 |
+
onAnswer,
|
| 1001 |
+
onCommit,
|
| 1002 |
+
onReset,
|
| 1003 |
+
onSwitchToManualWithDraft,
|
| 1004 |
+
turn,
|
| 1005 |
+
}: {
|
| 1006 |
+
session: ActionCoachSession | null;
|
| 1007 |
+
input: string;
|
| 1008 |
+
setInput: (s: string) => void;
|
| 1009 |
+
busy: boolean;
|
| 1010 |
+
err: string | null;
|
| 1011 |
+
threadRef: React.MutableRefObject<HTMLDivElement | null>;
|
| 1012 |
+
onStart: () => void;
|
| 1013 |
+
onAnswer: () => void;
|
| 1014 |
+
onCommit: () => void;
|
| 1015 |
+
onReset: () => void;
|
| 1016 |
+
onSwitchToManualWithDraft: () => void;
|
| 1017 |
+
turn: number;
|
| 1018 |
+
}) {
|
| 1019 |
+
const hasSession = session !== null;
|
| 1020 |
+
const ready = session?.status === 'ready';
|
| 1021 |
+
const draft = session?.synthesized_draft ?? null;
|
| 1022 |
+
|
| 1023 |
+
return (
|
| 1024 |
+
<div className="nh-chat-app" style={{ marginTop: 8 }}>
|
| 1025 |
+
<div className="nh-chat">
|
| 1026 |
+
{!hasSession ? (
|
| 1027 |
+
// Intro β collect initial prose before kicking off the coach.
|
| 1028 |
+
<div className="nh-chat__input">
|
| 1029 |
+
<p className="fg-2" style={{ marginTop: 0, marginBottom: 12 }}>
|
| 1030 |
+
Describe your move in plain prose. The coach will draft a structured action with three anchors β
|
| 1031 |
+
the matrix-game umpire scores plausibility against those anchors, so specificity matters. If a
|
| 1032 |
+
critical anchor is ambiguous, the coach will ask one focused question. Capped at two rounds.
|
| 1033 |
+
</p>
|
| 1034 |
+
<form onSubmit={(e) => { e.preventDefault(); onStart(); }}>
|
| 1035 |
+
<textarea
|
| 1036 |
+
value={input}
|
| 1037 |
+
rows={5}
|
| 1038 |
+
maxLength={4000}
|
| 1039 |
+
placeholder="e.g. Pre-empt Aergis by locking Helvetia at 14% effective before they sign the LoI. Anchor it on the 2022 Helvetia renewal β we have the Horizont-X UNI 11300 validation and a EUR 500k/quarter penalty cap signed by Lange."
|
| 1040 |
+
onChange={(e) => setInput(e.target.value)}
|
| 1041 |
+
onKeyDown={(e) => {
|
| 1042 |
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
| 1043 |
+
e.preventDefault(); onStart();
|
| 1044 |
+
}
|
| 1045 |
+
}}
|
| 1046 |
+
disabled={busy}
|
| 1047 |
+
/>
|
| 1048 |
+
<div className="nh-chat__submit">
|
| 1049 |
+
<span className="hint">{input.length} / 4000 Β· Ctrl/Cmd+Enter to send</span>
|
| 1050 |
+
<button
|
| 1051 |
+
type="submit"
|
| 1052 |
+
className="btn primary"
|
| 1053 |
+
disabled={!input.trim() || busy}
|
| 1054 |
+
>
|
| 1055 |
+
{busy ? <><Spinner /> Draftingβ¦</> : 'Talk to coach β'}
|
| 1056 |
+
</button>
|
| 1057 |
+
</div>
|
| 1058 |
+
</form>
|
| 1059 |
+
{err && <p style={{ color: 'var(--oxblood)', marginTop: 10 }}>{err}</p>}
|
| 1060 |
+
</div>
|
| 1061 |
+
) : (
|
| 1062 |
+
<>
|
| 1063 |
+
<div className="nh-chat__thread" ref={threadRef}>
|
| 1064 |
+
{(session?.messages ?? []).map((m, i) => (
|
| 1065 |
+
<CoachMsg key={i} m={m} />
|
| 1066 |
+
))}
|
| 1067 |
+
{busy && (
|
| 1068 |
+
<div className="nh-msg control">
|
| 1069 |
+
<div className="nh-msg__meta"><span>Coach</span> draftingβ¦</div>
|
| 1070 |
+
<div className="nh-msg__body"><Spinner /></div>
|
| 1071 |
+
</div>
|
| 1072 |
+
)}
|
| 1073 |
+
</div>
|
| 1074 |
+
|
| 1075 |
+
{/* Draft preview when one exists (partial during 'asking', complete on 'ready'). */}
|
| 1076 |
+
{draft && (
|
| 1077 |
+
<div className="nh-spec">
|
| 1078 |
+
<span className="eyebrow">
|
| 1079 |
+
Draft action {ready ? 'Β· ready to submit' : `Β· in progress (${session?.rounds_used ?? 0}/2 rounds)`}
|
| 1080 |
+
</span>
|
| 1081 |
+
<CoachDraftCard draft={draft} />
|
| 1082 |
+
<div className="nh-spec__fn" style={{ marginTop: 6 }}>
|
| 1083 |
+
Submitting locks this move for Turn {turn}.
|
| 1084 |
+
</div>
|
| 1085 |
+
</div>
|
| 1086 |
+
)}
|
| 1087 |
+
|
| 1088 |
+
{ready ? (
|
| 1089 |
+
<div className="nh-chat__approve">
|
| 1090 |
+
<div>
|
| 1091 |
+
<span className="eyebrow">Approve to submit</span>
|
| 1092 |
+
<div className="hd">Commit draft as your Turn {turn} action</div>
|
| 1093 |
+
</div>
|
| 1094 |
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
| 1095 |
+
<button className="btn ghost sm" onClick={onSwitchToManualWithDraft} disabled={busy}>
|
| 1096 |
+
Edit in Manual β
|
| 1097 |
+
</button>
|
| 1098 |
+
<button className="btn ghost sm" onClick={onReset} disabled={busy}>
|
| 1099 |
+
Restart
|
| 1100 |
+
</button>
|
| 1101 |
+
<button className="btn primary" onClick={onCommit} disabled={busy}>
|
| 1102 |
+
{busy ? <><Spinner /> Submittingβ¦</> : 'Submit action β'}
|
| 1103 |
+
</button>
|
| 1104 |
+
</div>
|
| 1105 |
+
</div>
|
| 1106 |
+
) : (
|
| 1107 |
+
<div className="nh-chat__input">
|
| 1108 |
+
<form onSubmit={(e) => { e.preventDefault(); onAnswer(); }}>
|
| 1109 |
+
<textarea
|
| 1110 |
+
value={input}
|
| 1111 |
+
rows={4}
|
| 1112 |
+
maxLength={4000}
|
| 1113 |
+
placeholder="Your reply⦠(Ctrl/Cmd+Enter to send)"
|
| 1114 |
+
onChange={(e) => setInput(e.target.value)}
|
| 1115 |
+
onKeyDown={(e) => {
|
| 1116 |
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
| 1117 |
+
e.preventDefault(); onAnswer();
|
| 1118 |
+
}
|
| 1119 |
+
}}
|
| 1120 |
+
disabled={busy}
|
| 1121 |
+
/>
|
| 1122 |
+
<div className="nh-chat__submit">
|
| 1123 |
+
<span className="hint">
|
| 1124 |
+
{input.length} / 4000 Β· round {(session?.rounds_used ?? 0) + 1} of 2
|
| 1125 |
+
</span>
|
| 1126 |
+
<button
|
| 1127 |
+
type="submit"
|
| 1128 |
+
className="btn primary"
|
| 1129 |
+
disabled={!input.trim() || busy}
|
| 1130 |
+
>
|
| 1131 |
+
{busy ? <><Spinner /> Sendingβ¦</> : 'Send β'}
|
| 1132 |
+
</button>
|
| 1133 |
+
</div>
|
| 1134 |
+
</form>
|
| 1135 |
+
<div style={{ marginTop: 8 }}>
|
| 1136 |
+
<button className="btn ghost sm" onClick={onReset} disabled={busy}>
|
| 1137 |
+
Restart conversation
|
| 1138 |
+
</button>
|
| 1139 |
+
</div>
|
| 1140 |
+
</div>
|
| 1141 |
+
)}
|
| 1142 |
+
{err && <p style={{ color: 'var(--oxblood)', margin: '10px 0' }}>{err}</p>}
|
| 1143 |
+
</>
|
| 1144 |
+
)}
|
| 1145 |
+
</div>
|
| 1146 |
+
</div>
|
| 1147 |
+
);
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
function CoachMsg({ m }: { m: CoachMessage }) {
|
| 1151 |
+
const who = m.role === 'user' ? 'user' : 'control';
|
| 1152 |
+
const label = m.role === 'user' ? 'You' : 'Coach';
|
| 1153 |
+
return (
|
| 1154 |
+
<div className={`nh-msg ${who}`}>
|
| 1155 |
+
<div className="nh-msg__meta"><span>{label}</span> {fmtCoachAt(m.at)}</div>
|
| 1156 |
+
<div className="nh-msg__body">
|
| 1157 |
+
{m.role === 'coach' ? (
|
| 1158 |
+
<div className="setup-md">
|
| 1159 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
|
| 1160 |
+
</div>
|
| 1161 |
+
) : (
|
| 1162 |
+
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content}</div>
|
| 1163 |
+
)}
|
| 1164 |
+
</div>
|
| 1165 |
+
</div>
|
| 1166 |
+
);
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
function CoachDraftCard({ draft }: { draft: NonNullable<ActionCoachSession['synthesized_draft']> }) {
|
| 1170 |
+
return (
|
| 1171 |
+
<div style={{ display: 'grid', gap: 10, fontSize: 14, lineHeight: 1.55 }}>
|
| 1172 |
+
<div>
|
| 1173 |
+
<div className="eyebrow" style={{ marginBottom: 2 }}>Intent</div>
|
| 1174 |
+
<div style={{ fontWeight: 600 }}>{draft.intent}</div>
|
| 1175 |
+
</div>
|
| 1176 |
+
<div>
|
| 1177 |
+
<div className="eyebrow" style={{ marginBottom: 2 }}>Why now</div>
|
| 1178 |
+
<div>{draft.justification}</div>
|
| 1179 |
+
</div>
|
| 1180 |
+
<div>
|
| 1181 |
+
<div className="eyebrow" style={{ marginBottom: 4 }}>Anchors Β· {draft.anchors.length} of 3</div>
|
| 1182 |
+
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
| 1183 |
+
{draft.anchors.map((a, i) => (
|
| 1184 |
+
<li key={i} style={{ marginBottom: 4 }}>
|
| 1185 |
+
<span className="mono" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
| 1186 |
+
{a.kind.replace(/_/g, ' ')}
|
| 1187 |
+
</span>{' '}
|
| 1188 |
+
β {a.detail}
|
| 1189 |
+
</li>
|
| 1190 |
+
))}
|
| 1191 |
+
</ul>
|
| 1192 |
+
</div>
|
| 1193 |
+
</div>
|
| 1194 |
+
);
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
function fmtCoachAt(at: string) {
|
| 1198 |
+
try {
|
| 1199 |
+
const d = new Date(at);
|
| 1200 |
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
| 1201 |
+
} catch { return ''; }
|
| 1202 |
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Action Coach β turn natural-language prose into a 3-anchor ActionSubmission.
|
| 2 |
+
|
| 3 |
+
The standard composer makes the player type the intent, justification, and
|
| 4 |
+
three structured anchors by hand. CEO playtesters find that too form-like.
|
| 5 |
+
The Action Coach is a small conversational agent that takes free-form prose
|
| 6 |
+
and either (a) returns a complete `ActionDraft` ready to commit, or (b) asks
|
| 7 |
+
ONE focused clarifying question when a critical anchor is genuinely
|
| 8 |
+
ambiguous. Capped at 2 conversational rounds β round 2 forces best-effort.
|
| 9 |
+
|
| 10 |
+
This is a *pre-validation* layer. It produces a valid `ActionSubmission`
|
| 11 |
+
that goes through the existing `loop.act` engine path, so the action
|
| 12 |
+
contract (3 anchors, all of valid kind) is still enforced at the engine
|
| 13 |
+
boundary.
|
| 14 |
+
|
| 15 |
+
Design shape mirrors `wargame/scaffold/setup_agent.py`:
|
| 16 |
+
- One LLM call per turn via `call_structured`.
|
| 17 |
+
- JSON-file persistence per session.
|
| 18 |
+
- Round budget tracked on the session; downgrade-to-ready when capped.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
from datetime import datetime, timezone
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import TYPE_CHECKING, Literal
|
| 27 |
+
from uuid import uuid4
|
| 28 |
+
|
| 29 |
+
from pydantic import BaseModel, Field
|
| 30 |
+
|
| 31 |
+
from wargame.agents.base import call_structured
|
| 32 |
+
from wargame.agents.prompts_loader import render
|
| 33 |
+
from wargame.config import settings
|
| 34 |
+
from wargame.engine.state import ActionSubmission, Anchor, AnchorKind
|
| 35 |
+
|
| 36 |
+
if TYPE_CHECKING:
|
| 37 |
+
from wargame.agents.registry import ScenarioConfig
|
| 38 |
+
from wargame.engine.state import Game
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# Maximum conversational rounds before forcing best-effort. A round is one
|
| 42 |
+
# user message + one coach reply. After 2 rounds, the coach must commit
|
| 43 |
+
# best-effort anchors and surface what was inferred vs. provided.
|
| 44 |
+
MAX_ROUNDS = 2
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Pydantic models β session state, draft shape, LLM output schema
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _now() -> str:
|
| 53 |
+
return datetime.now(timezone.utc).isoformat()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class CoachMessage(BaseModel):
|
| 57 |
+
role: Literal["user", "coach"]
|
| 58 |
+
content: str
|
| 59 |
+
at: str = Field(default_factory=_now)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ActionDraft(BaseModel):
|
| 63 |
+
"""A drafted action β same shape the engine accepts, minus actor.
|
| 64 |
+
|
| 65 |
+
Pydantic enforces exactly 3 anchors; each anchor's kind is one of the
|
| 66 |
+
three valid AnchorKind values via the engine-side Anchor model. This
|
| 67 |
+
matches the action contract one-to-one β when the user commits, we
|
| 68 |
+
just bolt `actor='player'` on top and submit through `loop.act`.
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
intent: str = Field(min_length=3, max_length=600)
|
| 72 |
+
justification: str = Field(min_length=3, max_length=1500)
|
| 73 |
+
anchors: list[Anchor] = Field(min_length=3, max_length=3)
|
| 74 |
+
|
| 75 |
+
def to_submission(self) -> ActionSubmission:
|
| 76 |
+
return ActionSubmission(
|
| 77 |
+
actor="player",
|
| 78 |
+
intent=self.intent,
|
| 79 |
+
justification=self.justification,
|
| 80 |
+
anchors=[a for a in self.anchors],
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class CoachTurnLLMOutput(BaseModel):
|
| 85 |
+
"""Structured LLM output for one turn of the action-coach conversation.
|
| 86 |
+
|
| 87 |
+
`status="ready"` means the coach has a confident draft and the user can
|
| 88 |
+
confirm or commit. `status="asking"` means the coach is asking ONE
|
| 89 |
+
focused clarifying question β `proposed_draft` may still be present
|
| 90 |
+
(partial), so the UI can show progress.
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
status: Literal["asking", "ready"]
|
| 94 |
+
message_to_user: str = Field(min_length=15, max_length=4000)
|
| 95 |
+
proposed_draft: ActionDraft | None = Field(
|
| 96 |
+
default=None,
|
| 97 |
+
description=(
|
| 98 |
+
"Required when status='ready'. Optional when status='asking' β "
|
| 99 |
+
"if present, surfaces the coach's current best draft so the UI "
|
| 100 |
+
"can show progress as anchors firm up."
|
| 101 |
+
),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class ActionCoachSession(BaseModel):
|
| 106 |
+
"""Multi-turn state for one action-coach conversation."""
|
| 107 |
+
|
| 108 |
+
id: str = Field(default_factory=lambda: uuid4().hex)
|
| 109 |
+
game_id: str
|
| 110 |
+
turn: int = Field(
|
| 111 |
+
ge=1,
|
| 112 |
+
description="The game turn this session is composing an action for.",
|
| 113 |
+
)
|
| 114 |
+
created_at: str = Field(default_factory=_now)
|
| 115 |
+
updated_at: str = Field(default_factory=_now)
|
| 116 |
+
messages: list[CoachMessage] = Field(default_factory=list)
|
| 117 |
+
rounds_used: int = Field(
|
| 118 |
+
default=0,
|
| 119 |
+
ge=0,
|
| 120 |
+
description=(
|
| 121 |
+
"Number of user-message / coach-reply rounds consumed so far. "
|
| 122 |
+
f"Capped at {MAX_ROUNDS}; round {MAX_ROUNDS + 1} forces ready."
|
| 123 |
+
),
|
| 124 |
+
)
|
| 125 |
+
status: Literal["gathering", "ready", "committed"] = "gathering"
|
| 126 |
+
synthesized_draft: ActionDraft | None = None
|
| 127 |
+
committed: bool = False
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Persistence
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _sessions_dir() -> Path:
|
| 136 |
+
d = settings().games_dir / "action_coach_sessions"
|
| 137 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 138 |
+
return d
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def session_path(session_id: str) -> Path:
|
| 142 |
+
return _sessions_dir() / f"{session_id}.json"
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def save_session(session: ActionCoachSession) -> Path:
|
| 146 |
+
session.updated_at = _now()
|
| 147 |
+
p = session_path(session.id)
|
| 148 |
+
p.write_text(session.model_dump_json(indent=2), encoding="utf-8")
|
| 149 |
+
return p
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def load_session(session_id: str) -> ActionCoachSession:
|
| 153 |
+
p = session_path(session_id)
|
| 154 |
+
if not p.exists():
|
| 155 |
+
raise FileNotFoundError(f"No action-coach session at {p}")
|
| 156 |
+
return ActionCoachSession.model_validate_json(p.read_text(encoding="utf-8"))
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def list_sessions() -> list[str]:
|
| 160 |
+
return sorted(p.name.removesuffix(".json") for p in _sessions_dir().glob("*.json"))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
# LLM-driven conversation turn
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _render_transcript(session: ActionCoachSession) -> str:
|
| 169 |
+
if not session.messages:
|
| 170 |
+
return "_(empty β this is the start of the conversation)_"
|
| 171 |
+
lines: list[str] = []
|
| 172 |
+
for m in session.messages:
|
| 173 |
+
label = "CEO" if m.role == "user" else "Coach"
|
| 174 |
+
lines.append(f"**{label}**:\n{m.content.strip()}")
|
| 175 |
+
return "\n\n".join(lines)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _briefing_summary_for_coach(game: Game) -> str:
|
| 179 |
+
"""Render the current turn's briefing as a compact prompt block.
|
| 180 |
+
|
| 181 |
+
The world bible is pinned via the system prompt; this user-message block
|
| 182 |
+
grounds the coach in *this* turn's situation so anchors can reference
|
| 183 |
+
named entities, numbers, and decisions actually on the table.
|
| 184 |
+
"""
|
| 185 |
+
current = game.current_turn
|
| 186 |
+
if current is None or current.briefing is None:
|
| 187 |
+
return "_(no active briefing β coach will work from world bible only)_"
|
| 188 |
+
b = current.briefing
|
| 189 |
+
lines: list[str] = [
|
| 190 |
+
f"### Headline\n{b.headline}\n",
|
| 191 |
+
f"### Exec summary\n{b.exec_summary}\n",
|
| 192 |
+
]
|
| 193 |
+
if b.sections:
|
| 194 |
+
lines.append("### Sections")
|
| 195 |
+
for s in b.sections:
|
| 196 |
+
lines.append(f"- **{s.heading}**: {s.body}")
|
| 197 |
+
if s.fog_of_war:
|
| 198 |
+
lines.append(f" - Fog of war: {s.fog_of_war}")
|
| 199 |
+
lines.append("")
|
| 200 |
+
if b.decision_points:
|
| 201 |
+
lines.append("### Decision points on the table")
|
| 202 |
+
for dp in b.decision_points:
|
| 203 |
+
lines.append(f"- **{dp.title}** β {dp.context}")
|
| 204 |
+
if dp.example_actions:
|
| 205 |
+
lines.append(
|
| 206 |
+
f" - Examples: {', '.join(dp.example_actions)}"
|
| 207 |
+
)
|
| 208 |
+
return "\n".join(lines)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _recon_summary_for_coach(game: Game) -> str:
|
| 212 |
+
"""Compact recon log for this turn so anchors can reference what was learned."""
|
| 213 |
+
current = game.current_turn
|
| 214 |
+
if current is None or not current.recon_queries:
|
| 215 |
+
return "_(no recon spent this turn yet)_"
|
| 216 |
+
lines = []
|
| 217 |
+
for r in current.recon_queries:
|
| 218 |
+
lines.append(
|
| 219 |
+
f"- **{r.answered_by}** asked: {r.question!r} β {r.answer}"
|
| 220 |
+
)
|
| 221 |
+
return "\n".join(lines)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _force_best_effort(
|
| 225 |
+
raw: CoachTurnLLMOutput,
|
| 226 |
+
*,
|
| 227 |
+
transcript: str,
|
| 228 |
+
) -> CoachTurnLLMOutput:
|
| 229 |
+
"""When the round budget is exhausted, force the coach to commit.
|
| 230 |
+
|
| 231 |
+
If the LLM's last reply was 'asking', we synthesize best-effort anchors
|
| 232 |
+
from whatever partial draft it provided. Empty/missing fields fall back
|
| 233 |
+
to the surrounding transcript prose. The user is told what was inferred
|
| 234 |
+
vs. provided so they can reject.
|
| 235 |
+
"""
|
| 236 |
+
if raw.status == "ready" and raw.proposed_draft:
|
| 237 |
+
return raw
|
| 238 |
+
|
| 239 |
+
partial = raw.proposed_draft
|
| 240 |
+
intent = (
|
| 241 |
+
partial.intent
|
| 242 |
+
if partial and partial.intent
|
| 243 |
+
else _fallback_intent_from_transcript(transcript)
|
| 244 |
+
)
|
| 245 |
+
justification = (
|
| 246 |
+
partial.justification
|
| 247 |
+
if partial and partial.justification
|
| 248 |
+
else (
|
| 249 |
+
"Best-effort synthesis from the prose. The coach was unable to "
|
| 250 |
+
"elicit a fully-grounded justification within the conversation "
|
| 251 |
+
"budget; review carefully before advancing."
|
| 252 |
+
)
|
| 253 |
+
)
|
| 254 |
+
anchors = _ensure_three_anchors(partial.anchors if partial else [])
|
| 255 |
+
|
| 256 |
+
note = (
|
| 257 |
+
raw.message_to_user
|
| 258 |
+
+ "\n\nβ **Round budget reached.** I've committed best-effort "
|
| 259 |
+
"anchors based on the prose; please review the inferred draft before "
|
| 260 |
+
"you submit, and edit any line that doesn't match your intent."
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
return CoachTurnLLMOutput(
|
| 264 |
+
status="ready",
|
| 265 |
+
message_to_user=note,
|
| 266 |
+
proposed_draft=ActionDraft(
|
| 267 |
+
intent=intent,
|
| 268 |
+
justification=justification,
|
| 269 |
+
anchors=anchors,
|
| 270 |
+
),
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _fallback_intent_from_transcript(transcript: str) -> str:
|
| 275 |
+
"""Pull the first non-empty CEO line as a last-resort intent."""
|
| 276 |
+
for block in transcript.split("\n\n"):
|
| 277 |
+
cleaned = block.strip()
|
| 278 |
+
if cleaned.startswith("**CEO**:"):
|
| 279 |
+
text = cleaned.split(":", 1)[-1].strip()
|
| 280 |
+
if text:
|
| 281 |
+
# Cap to first sentence-ish, max 400 chars (well within 600 cap).
|
| 282 |
+
first = text.split("\n", 1)[0].strip()
|
| 283 |
+
return first[:400] if first else "Action drawn from CEO prose."
|
| 284 |
+
return "Action drawn from CEO prose."
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _ensure_three_anchors(partial: list[Anchor]) -> list[Anchor]:
|
| 288 |
+
"""Pad / truncate a partial anchor list to exactly 3 of valid kinds.
|
| 289 |
+
|
| 290 |
+
Used only as a last-resort fallback when the round budget is exhausted
|
| 291 |
+
and the LLM still hasn't returned a complete draft. Each anchor's kind
|
| 292 |
+
is one of the three valid AnchorKind values; missing slots are filled
|
| 293 |
+
with explicit "best-effort" details so reviewers see they were inferred.
|
| 294 |
+
"""
|
| 295 |
+
out: list[Anchor] = []
|
| 296 |
+
used_kinds: set[AnchorKind] = set()
|
| 297 |
+
for a in partial[:3]:
|
| 298 |
+
if a.detail.strip():
|
| 299 |
+
out.append(a)
|
| 300 |
+
used_kinds.add(a.kind)
|
| 301 |
+
|
| 302 |
+
needed_kinds_order = [
|
| 303 |
+
AnchorKind.PRIOR_ANALOGUE,
|
| 304 |
+
AnchorKind.STATED_CAPABILITY,
|
| 305 |
+
AnchorKind.RESOURCE_COMMITTED,
|
| 306 |
+
]
|
| 307 |
+
for kind in needed_kinds_order:
|
| 308 |
+
if len(out) >= 3:
|
| 309 |
+
break
|
| 310 |
+
if kind in used_kinds:
|
| 311 |
+
continue
|
| 312 |
+
out.append(
|
| 313 |
+
Anchor(
|
| 314 |
+
kind=kind,
|
| 315 |
+
detail=(
|
| 316 |
+
f"Best-effort {kind.value} placeholder β the coach could "
|
| 317 |
+
"not extract a specific anchor of this kind from the prose. "
|
| 318 |
+
"Edit before submitting."
|
| 319 |
+
),
|
| 320 |
+
)
|
| 321 |
+
)
|
| 322 |
+
used_kinds.add(kind)
|
| 323 |
+
return out[:3]
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def advance_session(
|
| 327 |
+
session: ActionCoachSession,
|
| 328 |
+
user_message: str,
|
| 329 |
+
*,
|
| 330 |
+
game: Game,
|
| 331 |
+
scenario: ScenarioConfig,
|
| 332 |
+
) -> CoachTurnLLMOutput:
|
| 333 |
+
"""Append the user's message, call the LLM, save, return the structured turn."""
|
| 334 |
+
if session.committed or session.status == "committed":
|
| 335 |
+
raise ValueError("Session already committed β start a new one to iterate.")
|
| 336 |
+
|
| 337 |
+
session.messages.append(
|
| 338 |
+
CoachMessage(role="user", content=user_message.strip())
|
| 339 |
+
)
|
| 340 |
+
session.rounds_used += 1
|
| 341 |
+
transcript = _render_transcript(session)
|
| 342 |
+
|
| 343 |
+
system = render(
|
| 344 |
+
"action_coach",
|
| 345 |
+
world_bible=scenario.world_bible,
|
| 346 |
+
company_name=scenario.company_name,
|
| 347 |
+
rival_name=scenario.rival_name,
|
| 348 |
+
player_role=scenario.player_role,
|
| 349 |
+
max_rounds=str(MAX_ROUNDS),
|
| 350 |
+
)
|
| 351 |
+
user = (
|
| 352 |
+
f"## Game state\n\n"
|
| 353 |
+
f"- Turn: **{game.world.turn} of {game.world.max_turns}**\n"
|
| 354 |
+
f"- Rounds used in this coach session: **{session.rounds_used} of {MAX_ROUNDS}**\n"
|
| 355 |
+
f"- Public narrative: {game.world.public_narrative or '_(none yet)_'}\n"
|
| 356 |
+
f"- Media tone: {game.world.media_tone.value}\n\n"
|
| 357 |
+
f"## Current briefing\n\n{_briefing_summary_for_coach(game)}\n\n"
|
| 358 |
+
f"## Recon spent this turn\n\n{_recon_summary_for_coach(game)}\n\n"
|
| 359 |
+
f"## Conversation so far\n\n{transcript}\n\n"
|
| 360 |
+
f"## Your task\n\n"
|
| 361 |
+
f"Either ask exactly ONE tightly-scoped clarifying question "
|
| 362 |
+
f"(status='asking') or return the structured ActionDraft "
|
| 363 |
+
f"(status='ready', `proposed_draft` populated). Prefer 'ready' when "
|
| 364 |
+
f"you have 80%+ confidence β only ask when a critical anchor is "
|
| 365 |
+
f"genuinely ambiguous in the prose. Each anchor must be specific, "
|
| 366 |
+
f"verifiable, and reference named entities or numbers from the "
|
| 367 |
+
f"world bible / briefing where possible."
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
+
raw = call_structured(
|
| 371 |
+
schema=CoachTurnLLMOutput,
|
| 372 |
+
system=system,
|
| 373 |
+
user=user,
|
| 374 |
+
role="control",
|
| 375 |
+
temperature=0.5,
|
| 376 |
+
max_tokens=3500,
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
# Force a downgrade if the round budget is exhausted but the model still
|
| 380 |
+
# wants to ask another question. Round MAX_ROUNDS is the LAST allowed
|
| 381 |
+
# round, so any 'asking' response on or after that round must commit.
|
| 382 |
+
if session.rounds_used >= MAX_ROUNDS and raw.status == "asking":
|
| 383 |
+
raw = _force_best_effort(raw, transcript=transcript)
|
| 384 |
+
|
| 385 |
+
# Belt-and-braces: if the model says 'ready' but didn't include a draft,
|
| 386 |
+
# treat the same as the round-cap downgrade so we always have something
|
| 387 |
+
# the UI can show / commit.
|
| 388 |
+
if raw.status == "ready" and raw.proposed_draft is None:
|
| 389 |
+
raw = _force_best_effort(raw, transcript=transcript)
|
| 390 |
+
|
| 391 |
+
session.messages.append(
|
| 392 |
+
CoachMessage(role="coach", content=raw.message_to_user)
|
| 393 |
+
)
|
| 394 |
+
if raw.status == "ready":
|
| 395 |
+
session.status = "ready"
|
| 396 |
+
session.synthesized_draft = raw.proposed_draft
|
| 397 |
+
else:
|
| 398 |
+
session.status = "gathering"
|
| 399 |
+
# Track the partial draft for UI progress, even on 'asking'.
|
| 400 |
+
if raw.proposed_draft is not None:
|
| 401 |
+
session.synthesized_draft = raw.proposed_draft
|
| 402 |
+
|
| 403 |
+
save_session(session)
|
| 404 |
+
return raw
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def new_session(
|
| 408 |
+
*,
|
| 409 |
+
game: Game,
|
| 410 |
+
scenario: ScenarioConfig,
|
| 411 |
+
initial_prose: str,
|
| 412 |
+
) -> tuple[ActionCoachSession, CoachTurnLLMOutput]:
|
| 413 |
+
"""Start a session for the current turn of `game`. Runs the first LLM turn."""
|
| 414 |
+
session = ActionCoachSession(
|
| 415 |
+
game_id=game.id,
|
| 416 |
+
turn=game.world.turn,
|
| 417 |
+
messages=[],
|
| 418 |
+
)
|
| 419 |
+
save_session(session)
|
| 420 |
+
turn = advance_session(
|
| 421 |
+
session,
|
| 422 |
+
initial_prose,
|
| 423 |
+
game=game,
|
| 424 |
+
scenario=scenario,
|
| 425 |
+
)
|
| 426 |
+
return session, turn
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def commit_session(
|
| 430 |
+
session: ActionCoachSession,
|
| 431 |
+
*,
|
| 432 |
+
game: Game,
|
| 433 |
+
) -> ActionSubmission:
|
| 434 |
+
"""Submit the synthesized draft via the engine's `loop.act` path.
|
| 435 |
+
|
| 436 |
+
Returns the `ActionSubmission` that was accepted by the engine. Raises
|
| 437 |
+
if the session has no draft, the game is in the wrong phase, or the
|
| 438 |
+
engine rejects the action (e.g. invalid actor, wrong phase). The engine
|
| 439 |
+
is the authoritative validator β this function is just a thin wrapper.
|
| 440 |
+
"""
|
| 441 |
+
from wargame.engine import loop
|
| 442 |
+
|
| 443 |
+
if session.committed or session.status == "committed":
|
| 444 |
+
raise ValueError("Session has already been committed.")
|
| 445 |
+
if session.synthesized_draft is None:
|
| 446 |
+
raise ValueError(
|
| 447 |
+
"Session has no synthesized draft yet β call advance_session "
|
| 448 |
+
"until status='ready' first."
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
submission = session.synthesized_draft.to_submission()
|
| 452 |
+
# Engine validates phase + actor + anchors at the boundary. If anything
|
| 453 |
+
# is wrong, this raises β we surface it untouched to the caller.
|
| 454 |
+
loop.act(game, submission)
|
| 455 |
+
|
| 456 |
+
session.status = "committed"
|
| 457 |
+
session.committed = True
|
| 458 |
+
save_session(session)
|
| 459 |
+
return submission
|
|
@@ -18,6 +18,10 @@ from wargame.agents import registry as _registry
|
|
| 18 |
from wargame.agents.registry import load_scenario
|
| 19 |
from wargame.api.schemas import (
|
| 20 |
ActionAcceptedResponse,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
ActionRequest,
|
| 22 |
AdvanceResponse,
|
| 23 |
AgentInfo,
|
|
@@ -57,6 +61,7 @@ from wargame.api.schemas import (
|
|
| 57 |
)
|
| 58 |
from wargame.debrief.exporter import export_debrief
|
| 59 |
from wargame.engine import loop, persistence
|
|
|
|
| 60 |
|
| 61 |
_SCENARIO_ID_RE = re.compile(r"^[a-z][a-z0-9_]{2,59}$")
|
| 62 |
|
|
@@ -632,6 +637,22 @@ def post_orchestrate(
|
|
| 632 |
status_code=409,
|
| 633 |
detail="Orchestrate can only be run on main games, not sim branches.",
|
| 634 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
|
| 636 |
# Seed a pending ScenarioMap so status polls work even before the thread starts
|
| 637 |
scenario_map = ScenarioMap(
|
|
@@ -751,3 +772,172 @@ def get_turn_adjudication(game_id: str, turn: int) -> TurnAdjudicationResponse:
|
|
| 751 |
verdicts=target.verdicts,
|
| 752 |
challenges=target.challenges,
|
| 753 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
from wargame.agents.registry import load_scenario
|
| 19 |
from wargame.api.schemas import (
|
| 20 |
ActionAcceptedResponse,
|
| 21 |
+
ActionCoachAnswerRequest,
|
| 22 |
+
ActionCoachSessionResponse,
|
| 23 |
+
ActionCoachStartRequest,
|
| 24 |
+
ActionDraftResponse,
|
| 25 |
ActionRequest,
|
| 26 |
AdvanceResponse,
|
| 27 |
AgentInfo,
|
|
|
|
| 61 |
)
|
| 62 |
from wargame.debrief.exporter import export_debrief
|
| 63 |
from wargame.engine import loop, persistence
|
| 64 |
+
from wargame.engine.state import Phase
|
| 65 |
|
| 66 |
_SCENARIO_ID_RE = re.compile(r"^[a-z][a-z0-9_]{2,59}$")
|
| 67 |
|
|
|
|
| 637 |
status_code=409,
|
| 638 |
detail="Orchestrate can only be run on main games, not sim branches.",
|
| 639 |
)
|
| 640 |
+
first_turn = game.turns[0] if game.turns else None
|
| 641 |
+
if (
|
| 642 |
+
game.world.turn > 1
|
| 643 |
+
or first_turn is None
|
| 644 |
+
or first_turn.player_action is not None
|
| 645 |
+
or first_turn.phase != Phase.BRIEFING
|
| 646 |
+
):
|
| 647 |
+
phase_value = first_turn.phase.value if first_turn else "none"
|
| 648 |
+
raise HTTPException(
|
| 649 |
+
status_code=409,
|
| 650 |
+
detail=(
|
| 651 |
+
f"Orchestrator can only run on a pristine Turn 1 (no action submitted yet). "
|
| 652 |
+
f"This game is at turn {game.world.turn} with phase={phase_value} β "
|
| 653 |
+
f"fork to a new game from turn 1 if you want to re-run the orchestrator."
|
| 654 |
+
),
|
| 655 |
+
)
|
| 656 |
|
| 657 |
# Seed a pending ScenarioMap so status polls work even before the thread starts
|
| 658 |
scenario_map = ScenarioMap(
|
|
|
|
| 772 |
verdicts=target.verdicts,
|
| 773 |
challenges=target.challenges,
|
| 774 |
)
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
# ---------------------------------------------------------------------------
|
| 778 |
+
# Action Coach (conversational action input)
|
| 779 |
+
#
|
| 780 |
+
# A small LLM that turns CEO prose into the engine's exact action contract:
|
| 781 |
+
# intent + justification + 3 anchors. Mirrors the setup-agent shape β JSON
|
| 782 |
+
# session storage, advance/commit endpoints, capped at 2 conversational
|
| 783 |
+
# rounds. The actual action is submitted via the existing engine path on
|
| 784 |
+
# commit, so the action contract is enforced exactly as the manual composer.
|
| 785 |
+
# ---------------------------------------------------------------------------
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def _coach_response(session) -> ActionCoachSessionResponse:
|
| 789 |
+
"""Render an ActionCoachSession into the wire response shape."""
|
| 790 |
+
last_coach = next(
|
| 791 |
+
(m for m in reversed(session.messages) if m.role == "coach"),
|
| 792 |
+
None,
|
| 793 |
+
)
|
| 794 |
+
draft_response: ActionDraftResponse | None = None
|
| 795 |
+
if session.synthesized_draft is not None:
|
| 796 |
+
draft_response = ActionDraftResponse(
|
| 797 |
+
intent=session.synthesized_draft.intent,
|
| 798 |
+
justification=session.synthesized_draft.justification,
|
| 799 |
+
anchors=list(session.synthesized_draft.anchors),
|
| 800 |
+
)
|
| 801 |
+
return ActionCoachSessionResponse(
|
| 802 |
+
session_id=session.id,
|
| 803 |
+
game_id=session.game_id,
|
| 804 |
+
turn=session.turn,
|
| 805 |
+
status=session.status,
|
| 806 |
+
rounds_used=session.rounds_used,
|
| 807 |
+
last_coach_message=last_coach.content if last_coach else "",
|
| 808 |
+
synthesized_draft=draft_response,
|
| 809 |
+
committed=session.committed,
|
| 810 |
+
messages=[m.model_dump() for m in session.messages],
|
| 811 |
+
)
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
@router.post(
|
| 815 |
+
"/games/{game_id}/actions/coach",
|
| 816 |
+
response_model=ActionCoachSessionResponse,
|
| 817 |
+
status_code=201,
|
| 818 |
+
)
|
| 819 |
+
def post_action_coach_start(
|
| 820 |
+
game_id: str, body: ActionCoachStartRequest
|
| 821 |
+
) -> ActionCoachSessionResponse:
|
| 822 |
+
"""Start an action-coach session for the current turn of `game_id`."""
|
| 823 |
+
from wargame.agents.action_coach import new_session
|
| 824 |
+
from wargame.config import use_game_model
|
| 825 |
+
|
| 826 |
+
game = _load_or_404(game_id)
|
| 827 |
+
try:
|
| 828 |
+
scenario = load_scenario(game.scenario_id)
|
| 829 |
+
except FileNotFoundError as e:
|
| 830 |
+
raise HTTPException(status_code=404, detail=str(e)) from None
|
| 831 |
+
|
| 832 |
+
# Pin the per-game model so the coach LLM call uses the same model the
|
| 833 |
+
# rest of this game uses (matches the setup_agent pattern indirectly β
|
| 834 |
+
# setup runs pre-game so it doesn't pin, but the coach is mid-game).
|
| 835 |
+
with use_game_model(game.llm_model):
|
| 836 |
+
session, _turn = new_session(
|
| 837 |
+
game=game,
|
| 838 |
+
scenario=scenario,
|
| 839 |
+
initial_prose=body.initial_prose,
|
| 840 |
+
)
|
| 841 |
+
return _coach_response(session)
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
@router.get(
|
| 845 |
+
"/games/{game_id}/actions/coach/{session_id}",
|
| 846 |
+
response_model=ActionCoachSessionResponse,
|
| 847 |
+
)
|
| 848 |
+
def get_action_coach(
|
| 849 |
+
game_id: str, session_id: str
|
| 850 |
+
) -> ActionCoachSessionResponse:
|
| 851 |
+
"""Read the latest state of an action-coach session."""
|
| 852 |
+
from wargame.agents.action_coach import load_session
|
| 853 |
+
|
| 854 |
+
_load_or_404(game_id)
|
| 855 |
+
try:
|
| 856 |
+
session = load_session(session_id)
|
| 857 |
+
except FileNotFoundError as e:
|
| 858 |
+
raise HTTPException(status_code=404, detail=str(e)) from None
|
| 859 |
+
if session.game_id != game_id:
|
| 860 |
+
raise HTTPException(
|
| 861 |
+
status_code=404,
|
| 862 |
+
detail=f"Coach session {session_id} does not belong to game {game_id}.",
|
| 863 |
+
)
|
| 864 |
+
return _coach_response(session)
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
@router.post(
|
| 868 |
+
"/games/{game_id}/actions/coach/{session_id}/answer",
|
| 869 |
+
response_model=ActionCoachSessionResponse,
|
| 870 |
+
)
|
| 871 |
+
def post_action_coach_answer(
|
| 872 |
+
game_id: str,
|
| 873 |
+
session_id: str,
|
| 874 |
+
body: ActionCoachAnswerRequest,
|
| 875 |
+
) -> ActionCoachSessionResponse:
|
| 876 |
+
"""Submit one CEO message; the coach replies (asks one question or
|
| 877 |
+
returns a complete draft). Capped at 2 rounds β the coach forces a
|
| 878 |
+
best-effort draft on round 2."""
|
| 879 |
+
from wargame.agents.action_coach import advance_session, load_session
|
| 880 |
+
from wargame.config import use_game_model
|
| 881 |
+
|
| 882 |
+
game = _load_or_404(game_id)
|
| 883 |
+
try:
|
| 884 |
+
session = load_session(session_id)
|
| 885 |
+
except FileNotFoundError as e:
|
| 886 |
+
raise HTTPException(status_code=404, detail=str(e)) from None
|
| 887 |
+
if session.game_id != game_id:
|
| 888 |
+
raise HTTPException(
|
| 889 |
+
status_code=404,
|
| 890 |
+
detail=f"Coach session {session_id} does not belong to game {game_id}.",
|
| 891 |
+
)
|
| 892 |
+
try:
|
| 893 |
+
scenario = load_scenario(game.scenario_id)
|
| 894 |
+
except FileNotFoundError as e:
|
| 895 |
+
raise HTTPException(status_code=404, detail=str(e)) from None
|
| 896 |
+
try:
|
| 897 |
+
with use_game_model(game.llm_model):
|
| 898 |
+
advance_session(
|
| 899 |
+
session,
|
| 900 |
+
body.message,
|
| 901 |
+
game=game,
|
| 902 |
+
scenario=scenario,
|
| 903 |
+
)
|
| 904 |
+
except ValueError as e:
|
| 905 |
+
raise HTTPException(status_code=409, detail=str(e)) from None
|
| 906 |
+
return _coach_response(session)
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
@router.post(
|
| 910 |
+
"/games/{game_id}/actions/coach/{session_id}/commit",
|
| 911 |
+
response_model=ActionAcceptedResponse,
|
| 912 |
+
status_code=202,
|
| 913 |
+
)
|
| 914 |
+
def post_action_coach_commit(
|
| 915 |
+
game_id: str, session_id: str
|
| 916 |
+
) -> ActionAcceptedResponse:
|
| 917 |
+
"""Commit the synthesized draft via the engine's `loop.act` path.
|
| 918 |
+
|
| 919 |
+
Returns the same response shape as `POST /games/{id}/actions` β the
|
| 920 |
+
coach is a builder, the engine is the validator. If the draft is
|
| 921 |
+
incomplete or the engine rejects the action, returns 409.
|
| 922 |
+
"""
|
| 923 |
+
from wargame.agents.action_coach import commit_session, load_session
|
| 924 |
+
|
| 925 |
+
game = _load_or_404(game_id)
|
| 926 |
+
try:
|
| 927 |
+
session = load_session(session_id)
|
| 928 |
+
except FileNotFoundError as e:
|
| 929 |
+
raise HTTPException(status_code=404, detail=str(e)) from None
|
| 930 |
+
if session.game_id != game_id:
|
| 931 |
+
raise HTTPException(
|
| 932 |
+
status_code=404,
|
| 933 |
+
detail=f"Coach session {session_id} does not belong to game {game_id}.",
|
| 934 |
+
)
|
| 935 |
+
try:
|
| 936 |
+
submission = commit_session(session, game=game)
|
| 937 |
+
except ValueError as e:
|
| 938 |
+
raise HTTPException(status_code=409, detail=str(e)) from None
|
| 939 |
+
return ActionAcceptedResponse(
|
| 940 |
+
game_id=game.id,
|
| 941 |
+
turn=game.world.turn,
|
| 942 |
+
action=submission,
|
| 943 |
+
)
|
|
@@ -356,3 +356,44 @@ class ModelInfo(BaseModel):
|
|
| 356 |
class ModelsResponse(BaseModel):
|
| 357 |
models: list[ModelInfo]
|
| 358 |
default: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
class ModelsResponse(BaseModel):
|
| 357 |
models: list[ModelInfo]
|
| 358 |
default: str
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# ---------------------------------------------------------------------------
|
| 362 |
+
# Action Coach (conversational action input β 3-anchor ActionSubmission)
|
| 363 |
+
# ---------------------------------------------------------------------------
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
class ActionCoachStartRequest(BaseModel):
|
| 367 |
+
initial_prose: str = Field(
|
| 368 |
+
min_length=3,
|
| 369 |
+
max_length=4000,
|
| 370 |
+
description=(
|
| 371 |
+
"The CEO's free-form description of the move they want to make. "
|
| 372 |
+
"The coach extracts intent + justification + 3 anchors from this."
|
| 373 |
+
),
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
class ActionCoachAnswerRequest(BaseModel):
|
| 378 |
+
message: str = Field(min_length=1, max_length=4000)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
class ActionDraftResponse(BaseModel):
|
| 382 |
+
"""Mirror of the engine's `ActionDraft` for the wire β same shape the
|
| 383 |
+
coach assembles, the same shape the user-facing `ActionRequest` accepts."""
|
| 384 |
+
|
| 385 |
+
intent: str
|
| 386 |
+
justification: str
|
| 387 |
+
anchors: list[Anchor]
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
class ActionCoachSessionResponse(BaseModel):
|
| 391 |
+
session_id: str
|
| 392 |
+
game_id: str
|
| 393 |
+
turn: int
|
| 394 |
+
status: str # 'gathering' | 'ready' | 'committed'
|
| 395 |
+
rounds_used: int
|
| 396 |
+
last_coach_message: str
|
| 397 |
+
synthesized_draft: ActionDraftResponse | None = None
|
| 398 |
+
committed: bool = False
|
| 399 |
+
messages: list[dict] # raw dicts for clients to render the chat thread
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are the **Action Coach** for a CEO playing a strategic war game. Your sole job is to take the CEO's natural-language description of a move and convert it into a properly structured action β exactly the shape the war-game engine adjudicates against.
|
| 2 |
+
|
| 3 |
+
The CEO is playing as **{player_role}** at **{company_name}**, facing **{rival_name}** and the rest of the world. The full world bible (named entities, numbers, regulators, dates) follows below β every anchor you produce should reference those specifics rather than generic phrasing.
|
| 4 |
+
|
| 5 |
+
## The action contract you must produce
|
| 6 |
+
|
| 7 |
+
Every action carries:
|
| 8 |
+
|
| 9 |
+
- **`intent`** β a single sentence describing the move *the CEO actually described*, framed as something the CEO would say across a board table. No hedging, no "we should considerβ¦". One declarative move. **Anchor the move they described, not a safer or cleverer version of it.** You may tighten wording β turn "Do X-ish thing" into "Do X" β but you must not substitute a different move because you think theirs is exposed, naive, or politically risky. If you have concerns, raise them in `message_to_user`; the `intent` field is the CEO's move, faithfully captured.
|
| 10 |
+
- **`justification`** β 1 to 3 sentences answering "why this move, why now", grounded in the CEO's framing. This is the strategic logic β what changes if the CEO acts vs. waits one turn. Specific, not slogan-y. Don't replace the CEO's reasoning with your own; expand and sharpen theirs.
|
| 11 |
+
- **`anchors`** β exactly **THREE** anchors, one of each kind below. The matrix-game umpire (Control) scores plausibility against these; weak anchors get `no_effect` or `backfires`.
|
| 12 |
+
|
| 13 |
+
### The three anchor kinds
|
| 14 |
+
|
| 15 |
+
1. **`prior_analogue`** β A past case (yours or someone else's) where this move worked or a clearly comparable move worked. **Name** the case: company / situation / year, or a named regulator decision, or a documented playbook. "Sainsbury's 2022 pre-emptive buyback after a Czech-origin activist letter" beats "another company did this once".
|
| 16 |
+
|
| 17 |
+
2. **`stated_capability`** β A capability the company has *already stated or demonstrated*. Reference the world bible's named systems, certifications, named team members, or documented track record. "Horizont-X validated under UNI 11300 (Jun 2024)" beats "we have engineering capability".
|
| 18 |
+
|
| 19 |
+
3. **`resource_committed`** β Specific money, headcount, time, or contract authority being put behind this move. Numbers and names from the world bible where possible. "EUR 500k/quarter penalty cap, signed by Bettina Lange" beats "we'll dedicate resources".
|
| 20 |
+
|
| 21 |
+
## Your two possible outputs each turn
|
| 22 |
+
|
| 23 |
+
You always emit a `CoachTurnLLMOutput`. The fields:
|
| 24 |
+
|
| 25 |
+
- **`status`**: either `"asking"` (one focused question) or `"ready"` (commit the draft).
|
| 26 |
+
- **`message_to_user`**: 15β4000 chars, plain prose addressed to the CEO. When `asking`, this is your one question. When `ready`, this is a one-paragraph confirmation summarising what you drafted.
|
| 27 |
+
- **`proposed_draft`**: required when `status="ready"`. When `status="asking"`, optional but recommended β populate the fields you DO know so the UI can show progress.
|
| 28 |
+
|
| 29 |
+
### When to go `status="ready"`
|
| 30 |
+
|
| 31 |
+
Prefer ready when β₯80% of the prose maps cleanly to the three anchors. Specifically, return ready when:
|
| 32 |
+
|
| 33 |
+
- The intent is a single clear move you can paraphrase in one sentence.
|
| 34 |
+
- For each of the three anchor kinds, you can extract a *specific, named* anchor from the prose, the world bible, or the briefing (e.g. cite a named regulator, named analyst, named account, a number, a date, or a named precedent).
|
| 35 |
+
- The justification is implicit in the prose β even if the CEO didn't write "why now", you can lift the strategic frame from context.
|
| 36 |
+
|
| 37 |
+
If the prose only loosely sketches the move and you'd be guessing on at least one anchor, ask. But ask **only one** question, and make it the one that unblocks all three anchors.
|
| 38 |
+
|
| 39 |
+
### When to go `status="asking"`
|
| 40 |
+
|
| 41 |
+
Use this exclusively when a critical anchor is genuinely ambiguous and the world bible doesn't disambiguate it. Examples of good questions:
|
| 42 |
+
|
| 43 |
+
- "What prior analogue grounds this β the BBC Trust 2007 separation or the Schweizer Presserat? It changes the plausibility frame."
|
| 44 |
+
- "Is the resource you're committing the EUR 200M revolver or new debt issuance? Anchor specificity changes the verdict."
|
| 45 |
+
- "Are you naming a specific stakeholder to lead this β Bettina Lange the CFO, or someone else?"
|
| 46 |
+
|
| 47 |
+
Do **not** ask:
|
| 48 |
+
|
| 49 |
+
- Multi-part questions ("can you clarify A, B, and Cβ¦").
|
| 50 |
+
- Strategic-direction questions ("are you sure this is wise?"). The coach's job is structuring, not second-guessing.
|
| 51 |
+
- Questions whose answer is in the world bible. **Read it first.**
|
| 52 |
+
- Questions that just rephrase the prose.
|
| 53 |
+
|
| 54 |
+
You are budget-capped at **{max_rounds} rounds**. On round {max_rounds}, you MUST go `status="ready"` regardless. The system enforces this; if you try to ask a third question, your output gets rewritten as best-effort. So if you're going to ask, ask the *most unblocking* question first.
|
| 55 |
+
|
| 56 |
+
## Reframing β don't
|
| 57 |
+
|
| 58 |
+
The most common failure mode of an LLM in this seat is to silently *upgrade* the CEO's move into the version the LLM thinks is wiser. A free-standing oversight body becomes a "narrowed mandate"; a public counter-suit becomes a "measured statement"; a 90-day commitment becomes a "scoped 30-day pilot". **Do not do this.** The CEO chose the move. Your job is to anchor it, not to overrule it.
|
| 59 |
+
|
| 60 |
+
If the move has a real exposure (litigation discoverability, regulatory tripwire, named opponent's likely framing), surface that in `message_to_user` as one short flag β e.g. *"Heads up: Hafenecker will read this as governance overreach. Confirm the framing or tell me to dial it back."* β and let the CEO decide. If they confirm, anchor the original. If they pull back, then re-anchor.
|
| 61 |
+
|
| 62 |
+
The only times you may rewrite the move:
|
| 63 |
+
|
| 64 |
+
1. The prose is genuinely incoherent (contradicts itself or the world bible's hard facts) β ask a clarifying question instead.
|
| 65 |
+
2. The CEO explicitly asks you to "find a more defensible version" or "push back if this is naive". Default is anchor-the-intent.
|
| 66 |
+
|
| 67 |
+
## Tone
|
| 68 |
+
|
| 69 |
+
You're a sharp chief-of-staff drafting the move on behalf of the CEO. Direct, no preamble, no hedging. When you confirm a draft, name what you put in each anchor in one short sentence each so the CEO can spot mistakes fast.
|
| 70 |
+
|
| 71 |
+
Do **not** lecture, coach the CEO on strategy, or pad responses with filler. The CEO is mid-game and time-constrained.
|
| 72 |
+
|
| 73 |
+
## Pinned world bible
|
| 74 |
+
|
| 75 |
+
The full world bible follows. Pull from it relentlessly when constructing anchors β named accounts, named regulators, named dates, real numbers. Specificity is the coach's only superpower.
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
{world_bible}
|
|
@@ -428,6 +428,91 @@ def _fake_action_submission() -> dict[str, Any]:
|
|
| 428 |
}
|
| 429 |
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
def _fake_scenario_map_synthesis() -> dict[str, Any]:
|
| 432 |
return {
|
| 433 |
"markdown": (
|
|
@@ -477,6 +562,8 @@ def _dispatch(schema_name: str, user: str) -> Any:
|
|
| 477 |
return _fake_action_submission()
|
| 478 |
if schema_name == "ScenarioMapSynthesis":
|
| 479 |
return _fake_scenario_map_synthesis()
|
|
|
|
|
|
|
| 480 |
raise AssertionError(f"No fake canned for schema {schema_name!r}")
|
| 481 |
|
| 482 |
|
|
@@ -502,6 +589,7 @@ def fake_llm(monkeypatch: pytest.MonkeyPatch):
|
|
| 502 |
"wargame.agents.control",
|
| 503 |
"wargame.agents.role",
|
| 504 |
"wargame.agents.rival_ceo",
|
|
|
|
| 505 |
"wargame.scaffold.generator",
|
| 506 |
"wargame.scaffold.setup_agent",
|
| 507 |
"wargame.orchestrator.vectors",
|
|
|
|
| 428 |
}
|
| 429 |
|
| 430 |
|
| 431 |
+
def _fake_coach_turn(user: str) -> dict[str, Any]:
|
| 432 |
+
"""Fake LLM turn for the Action Coach.
|
| 433 |
+
|
| 434 |
+
Mirrors the SetupAgentTurn dispatch shape: counts how many coach
|
| 435 |
+
messages already appeared in the transcript so multi-turn tests can
|
| 436 |
+
exercise the ask-then-ready path deterministically.
|
| 437 |
+
|
| 438 |
+
The user message includes the briefing + recon log + transcript. To
|
| 439 |
+
keep the trigger logic deterministic, scan ONLY the lines that
|
| 440 |
+
represent CEO turns in the transcript (`**CEO**:` blocks).
|
| 441 |
+
"""
|
| 442 |
+
coach_replies = user.count("**Coach**:")
|
| 443 |
+
# Pull just the CEO transcript lines so trigger words inside the
|
| 444 |
+
# briefing or the world bible don't accidentally flip the response.
|
| 445 |
+
ceo_blocks: list[str] = []
|
| 446 |
+
for block in user.split("\n\n"):
|
| 447 |
+
cleaned = block.strip()
|
| 448 |
+
if cleaned.startswith("**CEO**:"):
|
| 449 |
+
ceo_blocks.append(cleaned[len("**CEO**:"):].strip().lower())
|
| 450 |
+
ceo_text = " ".join(ceo_blocks)
|
| 451 |
+
user_says_go = (
|
| 452 |
+
"looks right" in ceo_text
|
| 453 |
+
or " go " in f" {ceo_text} "
|
| 454 |
+
or "go." in ceo_text
|
| 455 |
+
or "go!" in ceo_text
|
| 456 |
+
or "approve" in ceo_text
|
| 457 |
+
or "commit it" in ceo_text
|
| 458 |
+
or "ship it" in ceo_text
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
ready_draft = {
|
| 462 |
+
"intent": "Lock Helvetia at 14% effective before Aergis signs an LoI.",
|
| 463 |
+
"justification": (
|
| 464 |
+
"Pre-empt Aergis's Italian sales push by closing the largest "
|
| 465 |
+
"renewal account first, removing a EUR 22M revenue risk and "
|
| 466 |
+
"anchoring the rest of the channel."
|
| 467 |
+
),
|
| 468 |
+
"anchors": [
|
| 469 |
+
{
|
| 470 |
+
"kind": "prior_analogue",
|
| 471 |
+
"detail": (
|
| 472 |
+
"Helvetia 2022 renewal β Norhard locked 16% effective on "
|
| 473 |
+
"a 3-year auto-renew structure when KlimaPro was probing."
|
| 474 |
+
),
|
| 475 |
+
},
|
| 476 |
+
{
|
| 477 |
+
"kind": "stated_capability",
|
| 478 |
+
"detail": (
|
| 479 |
+
"Horizont-X system validated under UNI 11300 (Jun 2024); "
|
| 480 |
+
"field service network already covers the Stuttgart plant."
|
| 481 |
+
),
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"kind": "resource_committed",
|
| 485 |
+
"detail": (
|
| 486 |
+
"EUR 500k/quarter penalty cap signed by Bettina Lange the "
|
| 487 |
+
"CFO; revolver capacity covers any shortfall."
|
| 488 |
+
),
|
| 489 |
+
},
|
| 490 |
+
],
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
if coach_replies >= 1 or user_says_go:
|
| 494 |
+
return {
|
| 495 |
+
"status": "ready",
|
| 496 |
+
"message_to_user": (
|
| 497 |
+
"I have a clean draft. Intent: lock Helvetia at 14%. "
|
| 498 |
+
"Justification: pre-empt Aergis. Anchors: 2022 Helvetia "
|
| 499 |
+
"renewal precedent / Horizont-X UNI 11300 validation / EUR "
|
| 500 |
+
"500k/quarter penalty cap signed by Lange. Ship it?"
|
| 501 |
+
),
|
| 502 |
+
"proposed_draft": ready_draft,
|
| 503 |
+
}
|
| 504 |
+
return {
|
| 505 |
+
"status": "asking",
|
| 506 |
+
"message_to_user": (
|
| 507 |
+
"One question to ground the prior analogue: are you anchoring "
|
| 508 |
+
"this on the 2022 Helvetia renewal (16% effective, 3-year auto) "
|
| 509 |
+
"or the broader 2018 KlimaPro skirmish? Tell me which and I'll "
|
| 510 |
+
"draft the move."
|
| 511 |
+
),
|
| 512 |
+
"proposed_draft": None,
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
|
| 516 |
def _fake_scenario_map_synthesis() -> dict[str, Any]:
|
| 517 |
return {
|
| 518 |
"markdown": (
|
|
|
|
| 562 |
return _fake_action_submission()
|
| 563 |
if schema_name == "ScenarioMapSynthesis":
|
| 564 |
return _fake_scenario_map_synthesis()
|
| 565 |
+
if schema_name == "CoachTurnLLMOutput":
|
| 566 |
+
return _fake_coach_turn(user)
|
| 567 |
raise AssertionError(f"No fake canned for schema {schema_name!r}")
|
| 568 |
|
| 569 |
|
|
|
|
| 589 |
"wargame.agents.control",
|
| 590 |
"wargame.agents.role",
|
| 591 |
"wargame.agents.rival_ceo",
|
| 592 |
+
"wargame.agents.action_coach",
|
| 593 |
"wargame.scaffold.generator",
|
| 594 |
"wargame.scaffold.setup_agent",
|
| 595 |
"wargame.orchestrator.vectors",
|
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Action Coach β conversational action input.
|
| 2 |
+
|
| 3 |
+
The coach turns CEO prose into a structured 3-anchor `ActionSubmission`,
|
| 4 |
+
either in one shot (status='ready') or after one focused clarification
|
| 5 |
+
(status='asking' β 'ready'). After 2 rounds the coach must commit
|
| 6 |
+
best-effort. On commit, the engine's `loop.act` validates and accepts.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from fastapi.testclient import TestClient
|
| 15 |
+
|
| 16 |
+
from wargame.api.app import create_app
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Module-level happy paths (offline, fake LLM)
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_one_shot_ready_when_prose_says_go(fake_llm) -> None:
|
| 25 |
+
"""When the initial prose includes a 'go' / 'ready' signal, the fake
|
| 26 |
+
LLM returns status='ready' immediately and the coach has a draft."""
|
| 27 |
+
from wargame.agents.action_coach import new_session
|
| 28 |
+
from wargame.agents.registry import load_scenario
|
| 29 |
+
from wargame.engine import loop
|
| 30 |
+
|
| 31 |
+
game = loop.new_game("competitor_disruption")
|
| 32 |
+
scenario = load_scenario(game.scenario_id)
|
| 33 |
+
|
| 34 |
+
session, turn = new_session(
|
| 35 |
+
game=game,
|
| 36 |
+
scenario=scenario,
|
| 37 |
+
initial_prose=(
|
| 38 |
+
"Go pre-empt Aergis by locking Helvetia at 14% effective before "
|
| 39 |
+
"they sign an LoI. Use the 2022 Helvetia renewal as the precedent."
|
| 40 |
+
),
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
assert session.status == "ready"
|
| 44 |
+
assert turn.status == "ready"
|
| 45 |
+
assert turn.proposed_draft is not None
|
| 46 |
+
assert len(turn.proposed_draft.anchors) == 3
|
| 47 |
+
kinds = {a.kind for a in turn.proposed_draft.anchors}
|
| 48 |
+
# The three valid anchor kinds, exactly.
|
| 49 |
+
assert len(kinds) == 3
|
| 50 |
+
assert session.synthesized_draft is not None
|
| 51 |
+
# Persistence: session is on disk
|
| 52 |
+
from wargame.agents.action_coach import load_session
|
| 53 |
+
|
| 54 |
+
reloaded = load_session(session.id)
|
| 55 |
+
assert reloaded.status == "ready"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_ask_then_ready_path(fake_llm) -> None:
|
| 59 |
+
"""First prose triggers a clarifying question; the second message flips
|
| 60 |
+
to ready with a populated draft."""
|
| 61 |
+
from wargame.agents.action_coach import advance_session, new_session
|
| 62 |
+
from wargame.agents.registry import load_scenario
|
| 63 |
+
from wargame.engine import loop
|
| 64 |
+
|
| 65 |
+
game = loop.new_game("competitor_disruption")
|
| 66 |
+
scenario = load_scenario(game.scenario_id)
|
| 67 |
+
|
| 68 |
+
# Round 1 β vague prose, no 'go' signal β coach asks one question
|
| 69 |
+
session, turn1 = new_session(
|
| 70 |
+
game=game,
|
| 71 |
+
scenario=scenario,
|
| 72 |
+
initial_prose=(
|
| 73 |
+
"I want to do something about Helvetia before the rival lands. "
|
| 74 |
+
"Lock them in somehow."
|
| 75 |
+
),
|
| 76 |
+
)
|
| 77 |
+
assert turn1.status == "asking"
|
| 78 |
+
assert session.status == "gathering"
|
| 79 |
+
assert session.rounds_used == 1
|
| 80 |
+
# Coach asked exactly one question (15-4000 chars enforced by schema).
|
| 81 |
+
assert len(turn1.message_to_user) >= 15
|
| 82 |
+
|
| 83 |
+
# Round 2 β answer the question β coach goes ready
|
| 84 |
+
turn2 = advance_session(
|
| 85 |
+
session,
|
| 86 |
+
"Anchor it on the 2022 Helvetia renewal β 16% effective, 3-year auto.",
|
| 87 |
+
game=game,
|
| 88 |
+
scenario=scenario,
|
| 89 |
+
)
|
| 90 |
+
assert turn2.status == "ready"
|
| 91 |
+
assert turn2.proposed_draft is not None
|
| 92 |
+
assert len(turn2.proposed_draft.anchors) == 3
|
| 93 |
+
assert session.status == "ready"
|
| 94 |
+
assert session.rounds_used == 2
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_round_cap_forces_ready(monkeypatch, fake_llm) -> None:
|
| 98 |
+
"""If the coach is still 'asking' on round MAX_ROUNDS, the session
|
| 99 |
+
forces a downgrade to 'ready' with best-effort anchors."""
|
| 100 |
+
from wargame.agents import action_coach
|
| 101 |
+
from wargame.agents.action_coach import (
|
| 102 |
+
MAX_ROUNDS,
|
| 103 |
+
advance_session,
|
| 104 |
+
new_session,
|
| 105 |
+
)
|
| 106 |
+
from wargame.agents.registry import load_scenario
|
| 107 |
+
from wargame.engine import loop
|
| 108 |
+
from wargame.engine.state import AnchorKind
|
| 109 |
+
|
| 110 |
+
# Patch call_structured at the action_coach module to ALWAYS return
|
| 111 |
+
# 'asking' β simulating an LLM that never closes out.
|
| 112 |
+
def always_asking(*, schema, system, user, role="agent", temperature=0.7, max_tokens=4000):
|
| 113 |
+
data = {
|
| 114 |
+
"status": "asking",
|
| 115 |
+
"message_to_user": (
|
| 116 |
+
"I still need one more clarification β which anchor "
|
| 117 |
+
"matters most for plausibility?"
|
| 118 |
+
),
|
| 119 |
+
"proposed_draft": None,
|
| 120 |
+
}
|
| 121 |
+
return schema.model_validate(data)
|
| 122 |
+
|
| 123 |
+
monkeypatch.setattr(action_coach, "call_structured", always_asking)
|
| 124 |
+
|
| 125 |
+
game = loop.new_game("competitor_disruption")
|
| 126 |
+
scenario = load_scenario(game.scenario_id)
|
| 127 |
+
|
| 128 |
+
session, turn1 = new_session(
|
| 129 |
+
game=game,
|
| 130 |
+
scenario=scenario,
|
| 131 |
+
initial_prose="A vague move that doesn't specify any anchor.",
|
| 132 |
+
)
|
| 133 |
+
# Round 1 stays 'asking' (under the cap).
|
| 134 |
+
assert turn1.status == "asking"
|
| 135 |
+
assert session.rounds_used == 1
|
| 136 |
+
assert session.status == "gathering"
|
| 137 |
+
|
| 138 |
+
# Round MAX_ROUNDS β even though the LLM still wants to ask, the session
|
| 139 |
+
# forces a downgrade to 'ready' with best-effort anchors.
|
| 140 |
+
final = advance_session(
|
| 141 |
+
session,
|
| 142 |
+
"Just figure it out from context, please.",
|
| 143 |
+
game=game,
|
| 144 |
+
scenario=scenario,
|
| 145 |
+
)
|
| 146 |
+
assert session.rounds_used == MAX_ROUNDS
|
| 147 |
+
assert final.status == "ready"
|
| 148 |
+
assert session.status == "ready"
|
| 149 |
+
assert session.synthesized_draft is not None
|
| 150 |
+
# The forced draft has exactly 3 valid-kind anchors (Pydantic enforced).
|
| 151 |
+
assert len(session.synthesized_draft.anchors) == 3
|
| 152 |
+
valid_kinds = {AnchorKind.PRIOR_ANALOGUE, AnchorKind.STATED_CAPABILITY, AnchorKind.RESOURCE_COMMITTED}
|
| 153 |
+
assert {a.kind for a in session.synthesized_draft.anchors} == valid_kinds
|
| 154 |
+
# The user is told the budget was reached
|
| 155 |
+
assert "Round budget reached" in final.message_to_user
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_commit_submits_action_engine_accepts(fake_llm) -> None:
|
| 159 |
+
"""Commit submits the synthesized ActionSubmission via `loop.act` and
|
| 160 |
+
the engine accepts it: turn flips to ACTION, player_action is set."""
|
| 161 |
+
from wargame.agents.action_coach import (
|
| 162 |
+
commit_session,
|
| 163 |
+
new_session,
|
| 164 |
+
)
|
| 165 |
+
from wargame.agents.registry import load_scenario
|
| 166 |
+
from wargame.engine import loop, persistence
|
| 167 |
+
from wargame.engine.state import Phase
|
| 168 |
+
|
| 169 |
+
game = loop.new_game("competitor_disruption")
|
| 170 |
+
scenario = load_scenario(game.scenario_id)
|
| 171 |
+
|
| 172 |
+
session, _turn = new_session(
|
| 173 |
+
game=game,
|
| 174 |
+
scenario=scenario,
|
| 175 |
+
initial_prose=(
|
| 176 |
+
"Go β lock Helvetia at 14% effective with the 2022 renewal precedent."
|
| 177 |
+
),
|
| 178 |
+
)
|
| 179 |
+
assert session.status == "ready"
|
| 180 |
+
|
| 181 |
+
submission = commit_session(session, game=game)
|
| 182 |
+
assert submission.actor == "player"
|
| 183 |
+
assert len(submission.anchors) == 3
|
| 184 |
+
assert session.committed is True
|
| 185 |
+
assert session.status == "committed"
|
| 186 |
+
|
| 187 |
+
# Engine state confirms the action landed
|
| 188 |
+
assert game.current_turn is not None
|
| 189 |
+
assert game.current_turn.phase == Phase.ACTION
|
| 190 |
+
assert game.current_turn.player_action is not None
|
| 191 |
+
assert game.current_turn.player_action.intent == submission.intent
|
| 192 |
+
|
| 193 |
+
# Reloaded snapshot persists the committed action
|
| 194 |
+
reloaded = persistence.load(game.id)
|
| 195 |
+
assert reloaded.current_turn is not None
|
| 196 |
+
assert reloaded.current_turn.player_action is not None
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
# Engine-side guarantees
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def test_committed_session_cannot_advance(fake_llm) -> None:
|
| 205 |
+
from wargame.agents.action_coach import (
|
| 206 |
+
advance_session,
|
| 207 |
+
commit_session,
|
| 208 |
+
new_session,
|
| 209 |
+
)
|
| 210 |
+
from wargame.agents.registry import load_scenario
|
| 211 |
+
from wargame.engine import loop
|
| 212 |
+
|
| 213 |
+
game = loop.new_game("competitor_disruption")
|
| 214 |
+
scenario = load_scenario(game.scenario_id)
|
| 215 |
+
|
| 216 |
+
session, _ = new_session(
|
| 217 |
+
game=game,
|
| 218 |
+
scenario=scenario,
|
| 219 |
+
initial_prose="Go and lock Helvetia at 14% with the 2022 precedent.",
|
| 220 |
+
)
|
| 221 |
+
commit_session(session, game=game)
|
| 222 |
+
|
| 223 |
+
with pytest.raises(ValueError, match="committed"):
|
| 224 |
+
advance_session(
|
| 225 |
+
session, "another message", game=game, scenario=scenario
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_commit_before_ready_raises(fake_llm) -> None:
|
| 230 |
+
"""A session in 'gathering' has no draft yet; commit must raise."""
|
| 231 |
+
from wargame.agents.action_coach import commit_session, new_session
|
| 232 |
+
from wargame.agents.registry import load_scenario
|
| 233 |
+
from wargame.engine import loop
|
| 234 |
+
|
| 235 |
+
game = loop.new_game("competitor_disruption")
|
| 236 |
+
scenario = load_scenario(game.scenario_id)
|
| 237 |
+
|
| 238 |
+
session, turn = new_session(
|
| 239 |
+
game=game,
|
| 240 |
+
scenario=scenario,
|
| 241 |
+
initial_prose="A vague move with no signal at all.",
|
| 242 |
+
)
|
| 243 |
+
# Default fake LLM returns 'asking' on round 1 with no draft.
|
| 244 |
+
assert turn.status == "asking"
|
| 245 |
+
assert session.synthesized_draft is None
|
| 246 |
+
with pytest.raises(ValueError, match="no synthesized draft"):
|
| 247 |
+
commit_session(session, game=game)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ---------------------------------------------------------------------------
|
| 251 |
+
# API contract
|
| 252 |
+
# ---------------------------------------------------------------------------
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_api_action_coach_full_flow(fake_llm) -> None:
|
| 256 |
+
"""Happy-path end-to-end via FastAPI: start β answer β commit β engine
|
| 257 |
+
accepts the action."""
|
| 258 |
+
client = TestClient(create_app())
|
| 259 |
+
|
| 260 |
+
# Create game
|
| 261 |
+
r = client.post("/games", json={"scenario_id": "competitor_disruption"})
|
| 262 |
+
assert r.status_code == 201
|
| 263 |
+
gid = r.json()["game_id"]
|
| 264 |
+
|
| 265 |
+
# Start coach session β vague prose triggers 'asking'
|
| 266 |
+
r = client.post(
|
| 267 |
+
f"/games/{gid}/actions/coach",
|
| 268 |
+
json={
|
| 269 |
+
"initial_prose": (
|
| 270 |
+
"I want to do something about Helvetia before Aergis lands."
|
| 271 |
+
)
|
| 272 |
+
},
|
| 273 |
+
)
|
| 274 |
+
assert r.status_code == 201, r.text
|
| 275 |
+
body = r.json()
|
| 276 |
+
sid = body["session_id"]
|
| 277 |
+
assert body["status"] == "gathering"
|
| 278 |
+
assert body["last_coach_message"]
|
| 279 |
+
assert body["synthesized_draft"] is None or body["synthesized_draft"]
|
| 280 |
+
|
| 281 |
+
# GET reflects current state
|
| 282 |
+
r = client.get(f"/games/{gid}/actions/coach/{sid}")
|
| 283 |
+
assert r.status_code == 200
|
| 284 |
+
|
| 285 |
+
# Answer with 'go' to trigger 'ready' on the fake
|
| 286 |
+
r = client.post(
|
| 287 |
+
f"/games/{gid}/actions/coach/{sid}/answer",
|
| 288 |
+
json={"message": "go β anchor it on the 2022 Helvetia renewal."},
|
| 289 |
+
)
|
| 290 |
+
assert r.status_code == 200, r.text
|
| 291 |
+
body = r.json()
|
| 292 |
+
assert body["status"] == "ready"
|
| 293 |
+
assert body["synthesized_draft"] is not None
|
| 294 |
+
assert len(body["synthesized_draft"]["anchors"]) == 3
|
| 295 |
+
|
| 296 |
+
# Commit β engine accepts β 202
|
| 297 |
+
r = client.post(f"/games/{gid}/actions/coach/{sid}/commit")
|
| 298 |
+
assert r.status_code == 202, r.text
|
| 299 |
+
body = r.json()
|
| 300 |
+
assert body["game_id"] == gid
|
| 301 |
+
assert body["action"]["actor"] == "player"
|
| 302 |
+
assert len(body["action"]["anchors"]) == 3
|
| 303 |
+
|
| 304 |
+
# Engine state shows the action committed
|
| 305 |
+
r = client.get(f"/games/{gid}/state")
|
| 306 |
+
assert r.status_code == 200
|
| 307 |
+
assert r.json()["current_phase"] == "action"
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_api_action_coach_unknown_session_returns_404(fake_llm) -> None:
|
| 311 |
+
client = TestClient(create_app())
|
| 312 |
+
r = client.post("/games", json={"scenario_id": "competitor_disruption"})
|
| 313 |
+
gid = r.json()["game_id"]
|
| 314 |
+
|
| 315 |
+
r = client.get(f"/games/{gid}/actions/coach/does_not_exist")
|
| 316 |
+
assert r.status_code == 404
|
| 317 |
+
r = client.post(
|
| 318 |
+
f"/games/{gid}/actions/coach/does_not_exist/answer",
|
| 319 |
+
json={"message": "hi"},
|
| 320 |
+
)
|
| 321 |
+
assert r.status_code == 404
|
| 322 |
+
r = client.post(f"/games/{gid}/actions/coach/does_not_exist/commit")
|
| 323 |
+
assert r.status_code == 404
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def test_api_action_coach_commit_before_ready_returns_409(fake_llm) -> None:
|
| 327 |
+
"""A session that's still gathering has no draft; commit returns 409."""
|
| 328 |
+
client = TestClient(create_app())
|
| 329 |
+
r = client.post("/games", json={"scenario_id": "competitor_disruption"})
|
| 330 |
+
gid = r.json()["game_id"]
|
| 331 |
+
|
| 332 |
+
# Start with vague prose β 'asking'
|
| 333 |
+
r = client.post(
|
| 334 |
+
f"/games/{gid}/actions/coach",
|
| 335 |
+
json={"initial_prose": "Vague move, no specifics at all"},
|
| 336 |
+
)
|
| 337 |
+
sid = r.json()["session_id"]
|
| 338 |
+
assert r.json()["status"] == "gathering"
|
| 339 |
+
|
| 340 |
+
r = client.post(f"/games/{gid}/actions/coach/{sid}/commit")
|
| 341 |
+
assert r.status_code == 409
|
|
@@ -140,6 +140,32 @@ def test_advance_before_action_returns_409(client: TestClient) -> None:
|
|
| 140 |
assert r.status_code == 409
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
def test_unknown_game_returns_404(client: TestClient) -> None:
|
| 144 |
r = client.get("/games/does-not-exist/state")
|
| 145 |
assert r.status_code == 404
|
|
|
|
| 140 |
assert r.status_code == 409
|
| 141 |
|
| 142 |
|
| 143 |
+
def test_orchestrate_after_action_returns_409(client: TestClient) -> None:
|
| 144 |
+
# Regression: orchestrator only runs on a pristine Turn 1. Without this
|
| 145 |
+
# guard, clone_pristine ValueErrors get swallowed by the worker and the UI
|
| 146 |
+
# silently sees three failed branches.
|
| 147 |
+
r = client.post("/games", json={"scenario_id": "competitor_disruption"})
|
| 148 |
+
gid = r.json()["game_id"]
|
| 149 |
+
action_body = {
|
| 150 |
+
"intent": "Counter-price selectively",
|
| 151 |
+
"justification": "Defend EBIT while stabilizing installer channel.",
|
| 152 |
+
"anchors": [
|
| 153 |
+
{"kind": "prior_analogue", "detail": "2018 KlimaPro price skirmish"},
|
| 154 |
+
{"kind": "stated_capability", "detail": "Finance can model in 48h"},
|
| 155 |
+
{"kind": "resource_committed", "detail": "EUR 2M margin buffer"},
|
| 156 |
+
],
|
| 157 |
+
}
|
| 158 |
+
r = client.post(f"/games/{gid}/actions", json=action_body)
|
| 159 |
+
assert r.status_code == 202
|
| 160 |
+
r = client.post(f"/games/{gid}/advance")
|
| 161 |
+
assert r.status_code == 200
|
| 162 |
+
r = client.post(
|
| 163 |
+
f"/games/{gid}/orchestrate", json={"branches": 3, "turns": 1}
|
| 164 |
+
)
|
| 165 |
+
assert r.status_code == 409
|
| 166 |
+
assert "pristine" in r.json()["detail"]
|
| 167 |
+
|
| 168 |
+
|
| 169 |
def test_unknown_game_returns_404(client: TestClient) -> None:
|
| 170 |
r = client.get("/games/does-not-exist/state")
|
| 171 |
assert r.status_code == 404
|