schipsy Claude Opus 4.7 (1M context) commited on
Commit
2ead142
Β·
unverified Β·
1 Parent(s): fa0970b

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 CHANGED
@@ -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
+ }
frontend/src/screens/GameView.tsx CHANGED
@@ -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
- <span className="hint">Three anchors required</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <button className="btn primary" onClick={onRun}>
703
- Run orchestrator β†’
704
- </button>
 
 
 
 
 
 
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
- <button className="btn ghost sm" onClick={onRun}>Retry β†’</button>
 
 
 
 
 
 
756
  </div>
757
  )}
758
 
@@ -825,7 +960,13 @@ function OrchestratorBar({
825
  </details>
826
  )}
827
  <div className="nh-orch__ft">
828
- <button className="btn ghost sm" onClick={onRun}>Re-run orchestrator β†’</button>
 
 
 
 
 
 
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
+ }
wargame/agents/action_coach.py ADDED
@@ -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
wargame/api/routes.py CHANGED
@@ -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
+ )
wargame/api/schemas.py CHANGED
@@ -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
wargame/prompts/action_coach.md ADDED
@@ -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}
wargame/tests/conftest.py CHANGED
@@ -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",
wargame/tests/test_action_coach.py ADDED
@@ -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
wargame/tests/test_api.py CHANGED
@@ -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