//! LLM brain: async worker pool talking to Ollama, wake scheduling, action //! application. The render loop never blocks on a model call. use bevy::prelude::*; use serde::Deserialize; use serde_json::json; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use crate::agent::{cell_of, world_of, Brain, Memory, Names, Needs, Pathing, Speech, Villager}; use crate::persona::MODEL; use crate::world::WorldMap; const WORKERS: usize = 6; // in-flight cap const WAKE_MIN: f32 = 45.0; // seconds between a villager's scheduled decisions const WAKE_JITTER: f32 = 75.0; const THINK_BUDGET: u32 = 64; // max new tokens per decision pub struct DecisionJob { pub entity: Entity, pub name: String, pub system: String, pub user: String, } pub struct DecisionResult { pub entity: Entity, pub name: String, pub action: Result, pub elapsed: f32, } #[derive(Debug, Clone, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum Action { GoTo { place: String, reason: String }, Talk { to: String, say: String, reason: String }, Work { reason: String }, Rest { reason: String }, Idle { reason: String }, } /// `Receiver` is `Send` but not `Sync`, so it rides behind a mutex to be a /// valid Bevy `Resource`. Workers share the job receiver the same way. #[derive(Resource)] pub struct LlmPool { pub jobs_tx: Sender, pub results_rx: Mutex>, pub in_flight: usize, pub completed: u64, pub failed: u64, pub peak_in_flight: usize, } pub fn start_pool(mut commands: Commands) { let (jobs_tx, jobs_rx) = channel::(); let (results_tx, results_rx) = channel::(); let url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".into()); let jobs_rx = Arc::new(Mutex::new(jobs_rx)); for _ in 0..WORKERS { let jobs_rx = Arc::clone(&jobs_rx); let results_tx = results_tx.clone(); let url = url.clone(); std::thread::spawn(move || worker_loop(jobs_rx, results_tx, &url)); } commands.insert_resource(LlmPool { jobs_tx, results_rx: Mutex::new(results_rx), in_flight: 0, completed: 0, failed: 0, peak_in_flight: 0, }); } fn worker_loop( jobs_rx: Arc>>, results_tx: Sender, url: &str, ) { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(120)) .build() .expect("http client"); loop { // Blocking recv while holding the lock is fine: workers take turns // waiting, and the handoff is instant when the queue is non-empty. let job = { let rx = jobs_rx.lock().unwrap_or_else(|e| e.into_inner()); rx.recv() }; let Ok(job) = job else { break }; let started = Instant::now(); let action = request_action(&client, url, &job.system, &job.user); let _ = results_tx.send(DecisionResult { entity: job.entity, name: job.name, action, elapsed: started.elapsed().as_secs_f32(), }); } } fn request_action( client: &reqwest::blocking::Client, url: &str, system: &str, user: &str, ) -> Result { let schema = json!({ "type": "object", "properties": { "action": {"type": "string", "enum": ["go_to", "talk", "work", "rest", "idle"]}, "place": {"type": "string"}, "to": {"type": "string"}, "say": {"type": "string"}, "reason": {"type": "string"} }, "required": ["action", "reason"] }); let body = json!({ "model": MODEL, "stream": false, "think": false, "format": schema, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], "options": { "temperature": 0.9, "num_predict": THINK_BUDGET, "num_ctx": 4096, "keep_alive": "30m", } }); let resp = client .post(format!("{url}/api/chat")) .json(&body) .send() .map_err(|e| format!("http: {e}"))?; if !resp.status().is_success() { return Err(format!("status {}", resp.status())); } let value: serde_json::Value = resp.json().map_err(|e| format!("json: {e}"))?; let content = value["message"]["content"].as_str().unwrap_or_default(); let content = content .trim() .trim_start_matches("```json") .trim_start_matches("```") .trim_end_matches("```") .trim(); serde_json::from_str::(content).map_err(|e| format!("{e}: {content:.120}")) } // --------------------------------------------------------------------------- // Perception helpers // --------------------------------------------------------------------------- /// Rough named place for a position (nearest named POI within 6 units). fn place_at(map: &WorldMap, pos: Vec3) -> String { let cell = cell_of(pos); let mut best = format!("({}, {})", cell.x, cell.y); let mut best_d = 6.0f32; for (name, poi) in &map.pois { if name.starts_with("home_") { continue; } let d = (world_of(*poi) - pos).length(); if d < best_d { best_d = d; best = name.clone(); } } best } fn build_nearby( map: &WorldMap, positions: &[(Entity, Vec3, String)], me: Entity, ) -> Vec<(String, String)> { let my_pos = match positions.iter().find(|(e, _, _)| *e == me) { Some((_, p, _)) => *p, None => return vec![], }; let mut out = Vec::new(); for (e, pos, name) in positions { if *e == me || pos.distance(my_pos) > 12.0 || out.len() >= 5 { continue; } out.push((name.clone(), place_at(map, *pos))); } out } // --------------------------------------------------------------------------- // Scheduling // --------------------------------------------------------------------------- pub fn schedule_wakes( time: Res