File size: 4,799 Bytes
0e23a69 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | // ── Agent Loop ────────────────────────────────────────────────────
// CEO agent: delegates to Qwen3 via the backend /qwen/decide proxy
// (which calls local Ollama). Falls back to greedy if Ollama is
// unavailable or returns an unexpected response.
// Uses refs to avoid stale-closure issues inside setTimeout.
import { useEffect, useRef, useCallback } from 'react'
import { apiQwenDecide } from '../services/api.js'
// ── Greedy fallback helpers (kept for local fallback path) ─────────
const NPC_KEYWORDS = {
'CTO': ['engineering', 'architecture', 'technical', 'team', 'morale', 'quality', 'platform', 'reliability'],
'CFO': ['burn', 'cash', 'runway', 'fiduciary', 'discipline', 'cost', 'savings', 'margin', 'capital'],
'Investor Rep': ['growth', 'scale', 'market', 'moat', 'ipo', 'exit', 'revenue', 'arr', 'bold', 'ambitious'],
'Independent': ['reputation', 'stakeholders', 'trust', 'transparent', 'ethics', 'long-term', 'governance', 'safety'],
}
const ROLE_WEIGHT = {
'CEO': 1.5, 'CTO': 1.2, 'CFO': 1.0, 'Investor Rep': 1.3, 'Independent': 0.8,
}
export function greedyPick(obs) {
if (!obs?.options?.length) return null
const tally = {}
for (const opt of obs.options) tally[opt] = 0
for (const npc of obs.npc_statements ?? []) {
if (npc.vote in tally) {
tally[npc.vote] += (ROLE_WEIGHT[npc.role] ?? 0.8) * (npc.confidence ?? 0.5)
}
}
return obs.options.reduce((best, opt) => (tally[opt] > tally[best] ? opt : best), obs.options[0])
}
export function buildPitch(obs, decision) {
if (!obs?.npc_statements) return ''
const opposed = obs.npc_statements.filter((n) => n.vote !== decision)
if (!opposed.length) return ''
return opposed.map((npc) => {
const kw = (NPC_KEYWORDS[npc.role] ?? []).slice(0, 3).join(', ')
return `Addressing ${npc.role}: this prioritises ${kw}.`
}).join(' ')
}
// ── Qwen decision wrapper ─────────────────────────────────────────
// Calls the backend /qwen/decide proxy (Ollama → qwen3:0.6b).
// If that fails for any reason, falls back to greedyPick + buildPitch.
async function qwenDecide(obs) {
try {
const result = await apiQwenDecide(obs)
console.info(`[CEO agent] source=${result.source}`, result)
const decision = result.decision || greedyPick(obs)
const pitch = result.coalition_pitch ?? buildPitch(obs, decision)
return { decision, pitch }
} catch (err) {
console.warn('[CEO agent] Qwen unreachable, falling back to greedy:', err.message)
const decision = greedyPick(obs)
const pitch = buildPitch(obs, decision)
return { decision, pitch }
}
}
/**
* useAgentLoop — drives the game using the Qwen3 CEO agent.
* Uses a ref to always read the latest state inside the timer callback,
* avoiding React stale-closure bugs.
*/
export function useAgentLoop(state, stepGame) {
const { paused, loading, done, speed } = state
// Always-fresh ref to current obs — safe to read inside setTimeout
const obsRef = useRef(state.obs)
const pausedRef = useRef(paused)
const loadingRef = useRef(loading)
const doneRef = useRef(done)
const timerRef = useRef(null)
// Keep refs in sync with latest state
useEffect(() => { obsRef.current = state.obs }, [state.obs])
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => { loadingRef.current = loading }, [loading])
useEffect(() => { doneRef.current = done }, [done])
// Stable tick function — reads fresh state from refs
const tick = useCallback(async () => {
// Guard: bail if state changed while timer was pending
if (pausedRef.current || loadingRef.current || doneRef.current) return
const obs = obsRef.current
if (!obs?.options?.length) return
// ── Qwen inference (falls back to greedy automatically) ──
const { decision, pitch } = await qwenDecide(obs)
if (decision) {
await stepGame(decision, pitch)
}
}, [stepGame])
// Schedule next tick whenever key state changes
useEffect(() => {
clearTimeout(timerRef.current)
// Don't schedule if blocked
if (paused || loading || done || !state.obs?.options?.length) return
const delay = Math.max(400, Math.round(1800 / speed))
timerRef.current = setTimeout(tick, delay)
return () => clearTimeout(timerRef.current)
}, [paused, loading, done, state.obs, speed, tick])
}
|