//! LLM pool across two Ollama endpoints + decision schema + prompt building. //! //! Two routing modes: //! Shared (default) — every job round-robins across all workers on both //! machines; work notes come inline from the decision call. //! Split (`--split`) — decisions/social calls go ONLY to the remote 2060 //! (small talker model); every `work` action spawns a second call //! to the local big coding model that writes the actual work-log //! content. The barrier waits for both. use bevy::prelude::*; use serde::Deserialize; use serde_json::json; use std::collections::HashMap; use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use crate::company::{CompanyCfg, WriteKind}; pub const LOCAL_URL: &str = "http://localhost:11434"; pub const LOCAL_MODEL: &str = "spark-x2.5-4b"; pub const REMOTE_URL: &str = "http://192.168.4.22:11434"; pub const REMOTE_MODEL: &str = "qwen3.5:4b"; /// Local big model for work-log writing in split mode (16.6 GB — one or two /// concurrent calls on a 16 GB card; Ollama queues the rest internally). pub const CODER_MODEL: &str = "qwen3.8-27b-64k"; /// llama.cpp assignment server (OpenAI-compatible, auto-detected). pub const LLAMA_CODER_URL: &str = "http://localhost:8085"; pub const LLAMA_CODER_MODEL: &str = "Qwen3.8-27B-GSQ-RCO-IQ3_XXS-mtp.gguf"; const NUM_PREDICT: u32 = 350; const WRITE_NUM_PREDICT: u32 = 2800; // truncated FILE blocks were the hotfix of day 3 #[derive(Clone, Debug)] pub struct Endpoint { pub url: String, pub model: String, pub workers: usize, } impl Endpoint { pub fn new(url: &str, model: &str, workers: usize) -> Self { Self { url: url.into(), model: model.into(), workers, } } pub fn label(&self) -> String { format!("{} [{}]", self.url, self.model) } } /// Where each kind of job is allowed to run. #[derive(Resource, Clone)] pub struct Route { pub decide: Vec, /// Empty in shared mode → no work-content expansion. pub write: Vec, } impl Route { pub fn is_split(&self) -> bool { !self.write.is_empty() } } pub struct Job { pub person: String, /// Task id — only set for Write jobs. pub task: String, /// What the coder produces when this is a Write job. pub write_kind: WriteKind, pub system: String, pub user: String, /// Attached frame image (vision teams' `look`): absolute path to a PNG. pub image: Option, } pub enum JobResult { Decide { person: String, action: Result, elapsed: f32, endpoint: String, /// Verbatim dialogue transcript when action == Discuss. dialogue: Option, }, /// Written content from the coder model (split mode). Write { person: String, task: String, write_kind: WriteKind, content: Result, elapsed: f32, endpoint: String, }, } pub struct DecisionResult { pub person: String, pub action: Result, pub elapsed: f32, pub endpoint: String, /// A real multi-turn desk conversation (Action::Discuss), captured /// verbatim so apply can give BOTH parties the memory of it. pub dialogue: Option, } /// Verbatim turns of a desk conversation: (speaker, line). #[derive(Clone, Debug)] pub struct DialogueLog { pub other: String, pub turns: Vec<(String, String)>, pub summary: String, } #[derive(Debug, Clone, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum Action { GoTo { // default "" → walks to own desk; qwen3.5 often omits place #[serde(default)] place: String, #[serde(default)] reason: String, }, Work { task: String, #[serde(default)] note: String, /// Repo files actually written this session (split mode; skipped in serde). #[serde(skip)] files: Vec<(String, String)>, }, Email { to: String, subject: String, body: String, }, Talk { to: String, /// alias: qwen3.5 sends "topic" for talk utterances #[serde(alias = "topic")] message: String, }, Submit { task: String, #[serde(default)] summary: String, }, /// Manager only: pull a backlog task and give it to someone, with a due day. Assign { task: String, to: String, /// Omitted → day + task.effort_days due_day: Option, }, /// MANAGER ONLY, once at the start: align the company on what to build. Kickoff { #[serde(default)] plan: String, }, /// USER ONLY (the boss): a direct message routed into someone's prompt; /// applied by human_commands without touching an agent at all. BossChat { to: String, message: String, }, /// Manager only: approve (Done) or send back (ChangesRequested) + feedback mail. Review { task: String, /// qwen3.5 tends to answer everything via `topic` when unsure #[serde(default)] approve: Option, #[serde(default, alias = "note")] feedback: String, #[serde(default, alias = "message")] topic: String, }, /// Manager only: call everyone to the meeting room. Meeting { #[serde(default)] topic: String, }, /// Employee: walk over to a colleague's desk (if not there) and have a /// REAL back-and-forth conversation about a topic. Both people get the /// full transcript in their memories afterwards. Discuss { to: String, topic: String, }, /// VLM spot-check: look at captured frame and speak about it. Look { /// what to check about the frame (the user's ask or own thought) #[serde(default)] about: String, /// what the VLM saw (filled at collect time) #[serde(default)] note: String, }, /// Employee: push back on an unrealistic deadline/task. RaiseConcern { task: String, message: String, }, Rest { #[serde(default)] reason: String, }, Idle { #[serde(default)] reason: String, }, } #[derive(Resource)] pub struct LlmPool { pub decide_tx: Sender, /// Only present in split mode. pub write_tx: Option>, pub results_rx: Mutex>, pub in_flight: usize, pub completed: u64, pub failed: u64, pub endpoints: Vec, } pub fn start_pool(commands: &mut Commands, route: Route, roster: Vec<(String, String, String)>) { let _ = ROSTER.set(roster); let (decide_tx, decide_rx) = channel::(); let (write_opt, write_rx) = match &route.write { w if w.is_empty() => (None, None), _ => { let (tx, rx) = channel::(); (Some(tx), Some(rx)) } }; let (results_tx, results_rx) = channel::(); let mut endpoints = Vec::new(); let decide_rx = Arc::new(Mutex::new(decide_rx)); for ep in &route.decide { endpoints.push(format!("decide ← {}", ep.label())); for _ in 0..ep.workers { spawn_worker(Arc::clone(&decide_rx), results_tx.clone(), ep, Kind::Decide); } } if let (Some(rx), Some(first)) = (write_rx, route.write.first()) { let rx = Arc::new(Mutex::new(rx)); for ep in &route.write { endpoints.push(format!("write ← {}", ep.label())); for _ in 0..ep.workers { spawn_worker(Arc::clone(&rx), results_tx.clone(), ep, Kind::Write); } } let _ = first; } commands.insert_resource(LlmPool { decide_tx, write_tx: write_opt, results_rx: Mutex::new(results_rx), in_flight: 0, completed: 0, failed: 0, endpoints, }); } #[derive(Clone, Copy)] enum Kind { Decide, Write, } /// Roster for improvising conversation partners in the worker thread. /// Set once at pool start; workers read-only. static ROSTER: std::sync::OnceLock> = std::sync::OnceLock::new(); /// A live back-and-forth between two personas. Runs several plain-text /// calls on the SAME endpoint (already inside this worker thread), logs /// verbatim turns, then a one-line agreement summary both parties will /// remember. fn run_discussion( client: &reqwest::blocking::Client, url: &str, model: &str, me_system: &str, me_name: &str, to: &str, topic: &str, api_kind: &mut ApiKind, ) -> Option { let roster = ROSTER.get()?; let (p_name, p_role, p_personality) = roster .iter() .find(|(n, _, _)| n.eq_ignore_ascii_case(to))?; let partner_system = format!( "You are {p_name}, {p_role}. Personality: {p_personality}\n\ A colleague walked over to your desk. Reply in character: 1-2 natural \ sentences, no stage directions, no quoting. Stay constructive." ); let mut turns: Vec<(String, String)> = Vec::new(); let transcript = |turns: &[(String, String)]| -> String { turns .iter() .map(|(who, line)| format!("{who}: {line}\n")) .collect() }; for i in 0..4 { let speaker = if i % 2 == 0 { me_name } else { p_name }; let (system, prefix) = if i % 2 == 0 { ( me_system.to_string(), format!( "Turn {} of a live desk conversation between you and {to} about: \"{topic}\".\n\ Transcript so far:\n{}\nYour next line (1-2 sentences, in character):", i + 1, transcript(&turns) ), ) } else { ( partner_system.clone(), format!( "Turn {} — {me_name} walks to your desk to talk about \"{topic}\".\n\ Transcript so far:\n{}\nYour next reply (1-2 sentences, in character, be candid):", i + 1, transcript(&turns) ), ) }; // Reuse the writer's dual-dialect plumbing for plain text turns. let line = request_write(client, url, model, &system, &prefix, api_kind, None); match line { Ok(text) => { let text: String = text .lines() .filter(|l| { let t = l.trim().to_lowercase(); !(t.starts_with("turn ") || t.starts_with("transcript") || t.starts_with("your ")) }) .collect::>() .join(" ") .trim() .chars() .take(220) .collect(); if text.is_empty() { return None; } turns.push((speaker.to_string(), text.clone())); crate::activity::turn(me_name, speaker, &text); } Err(_) => return None, // transport/policy failure → caller falls back } } let summary_system = me_system.to_string(); let summary = match request_write( client, url, model, &summary_system, &format!( "One sentence, plain prose: what did you and {to} agree on about \"{topic}\"?\nTranscript:\n{}", transcript(&turns) ), api_kind, None, ) { Ok(s) => s.chars().take(160).collect(), Err(_) => turns .last() .map(|(_, line)| line.chars().take(120).collect()) .unwrap_or_default(), }; Some(DialogueLog { other: p_name.to_string(), turns, summary }) } fn spawn_worker(rx: Arc>>, tx: Sender, ep: &Endpoint, kind: Kind) { let (url, model) = (ep.url.clone(), ep.model.clone()); std::thread::spawn(move || worker_loop(rx, tx, &url, &model, kind)); } fn worker_loop( jobs_rx: Arc>>, results_tx: Sender, url: &str, model: &str, kind: Kind, ) { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(300)) .build() .expect("http client"); let mut consecutive_failures = 0usize; let mut api_kind = ApiKind::Detect; loop { let job = { let rx = jobs_rx.lock().unwrap_or_else(|e| e.into_inner()); rx.recv() }; let Ok(job) = job else { break }; let endpoint = format!("{url} [{model}]"); let started = Instant::now(); let elapsed = || started.elapsed().as_secs_f32(); // Send FIRST: every job must produce exactly one result or the // barrier round would hang waiting for it. Only transport errors // (endpoint down) trigger backoff — a model emitting malformed JSON // is not an unhealthy endpoint. let transport_err: Option = match kind { Kind::Decide => { crate::activity::started(&job.person, false, &endpoint); let mut action = request_action(&client, url, model, &job.system, &job.user, &mut api_kind); crate::activity::finish(&job.person, false, action.is_ok(), "…"); let mut dialogue: Option = None; // A real back-and-forth: run it synchronously NOW (still in // this worker thread), before the barrier result ships. If // dialoguing fails (transport oddity), fall back to a normal // one-shot talk so the round never stalls. if let Ok((Action::Discuss { to, topic }, _)) = &action { match run_discussion( &client, url, model, &job.system, &job.person, to, topic, &mut api_kind, ) { Some(dlg) => dialogue = Some(dlg), None => action = Ok((Action::Talk { to: to.clone(), message: topic.clone() }, String::new())), } } let (action, raw_text) = match action { Ok((a, raw)) => (Ok(a), raw), Err(e) => (Err(e), String::new()), }; crate::activity::finish(&job.person, false, action.is_ok(), &raw_text); let err = action.as_ref().err().cloned(); let _ = results_tx.send(JobResult::Decide { person: job.person, action, elapsed: elapsed(), endpoint: endpoint.clone(), dialogue, }); err } Kind::Write => { let key = format!("{} · write", job.person); crate::activity::started(&job.person, true, &endpoint); let content = request_write(&client, url, model, &job.system, &job.user, &mut api_kind, job.image.as_deref()); crate::activity::finish( &key, false, content.is_ok(), content.as_deref().unwrap_or("(failed)"), ); let err = content.as_ref().err().cloned(); let _ = results_tx.send(JobResult::Write { person: job.person.clone(), task: job.task.clone(), write_kind: job.write_kind, content, elapsed: elapsed(), endpoint: endpoint.clone(), }); err } }; let unhealthy = transport_err .as_deref() .is_some_and(|e| e.starts_with("http:") || e.starts_with("status")); if unhealthy { consecutive_failures += 1; // Never quit: in split mode this queue has no other consumer, so // a worker that exits strands every job until the deadman fires. if consecutive_failures >= 3 { eprintln!("[llm] endpoint {endpoint} unhealthy; backing off 5s"); std::thread::sleep(Duration::from_secs(5)); consecutive_failures = 0; } } else { consecutive_failures = 0; } } } fn request_action( client: &reqwest::blocking::Client, url: &str, model: &str, system: &str, user: &str, kind: &mut ApiKind, ) -> Result<(Action, String), String> { if *kind != ApiKind::Ollama { match action_via_ollama(client, url, model, system, user) { Ok(pair) => { *kind = ApiKind::Ollama; return Ok(pair); } Err(e) if *kind == ApiKind::Detect && e.starts_with("status 404") => { *kind = ApiKind::OpenAi; } Err(e) if *kind != ApiKind::Detect => { return action_via_openai(client, url, model, system, user) } Err(e) => return Err(e), } } action_via_openai(client, url, model, system, user) } fn action_via_ollama( client: &reqwest::blocking::Client, url: &str, model: &str, system: &str, user: &str, ) -> Result<(Action, String), String> { let schema = json!({ "type": "object", "properties": { "action": {"type": "string", "enum": [ "go_to", "work", "email", "talk", "discuss", "submit", "assign", "review", "meeting", "kickoff", "raise_concern", "rest", "idle", "look"]}, "place": {"type": "string", "description": "desk | meeting | break"}, "reason": {"type": "string"}, "task": {"type": "string", "description": "task id like T3"}, "note": {"type": "string", "description": "work progress note / work content written to your folder"}, "to": {"type": "string", "description": "person name"}, "subject": {"type": "string"}, "body": {"type": "string"}, "message": {"type": "string", "description": "1-3 full natural sentences, in character. Never keywords or fragments."}, "topic": {"type": "string", "description": "for discuss: what you two should talk through"}, "summary": {"type": "string"}, "due_day": {"type": "integer"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}, }, "required": ["action"] }); let body = json!({ "model": model, "stream": false, "think": false, "format": schema, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], "options": { "temperature": 0.8, "num_predict": NUM_PREDICT, "num_ctx": 8192, "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(|a| (a, content.to_string())) .map_err(|e| format!("{e}: {content:.140}")) } /// OpenAI-compatible decisions (llama.cpp serving the 27B): prompt carries /// the JSON contract; we strip code fences before parsing. fn action_via_openai( client: &reqwest::blocking::Client, url: &str, model: &str, system: &str, user: &str, ) -> Result<(Action, String), String> { let body = json!({ "model": model, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": format!("{user}\n\nReply with ONLY the JSON object described. No prose, no markdown fences.")}, ], "temperature": 0.7, "max_tokens": NUM_PREDICT, "stream": false, }); let resp = client .post(format!("{}/v1/chat/completions", url.trim_end_matches('/'))) .json(&body) .send() .map_err(|e| format!("http: {e}"))?; if !resp.status().is_success() { return Err(format!("status {}", resp.status())); } let v: serde_json::Value = resp.json().map_err(|e| format!("json: {e}"))?; let text = v["choices"][0]["message"]["content"] .as_str() .unwrap_or_default() .trim() .trim_start_matches("```json") .trim_start_matches("```") .trim_end_matches("```") .trim(); serde_json::from_str::(text) .map(|a| (a, text.to_string())) .map_err(|e| format!("{e}: {text:.140}")) } /// Which HTTP dialect an endpoint speaks. Detected on the first write call: /// llama.cpp/vLLM answer 404 on Ollama's /api/chat and get switched to the /// OpenAI-compatible route permanently for that worker. #[derive(Clone, Copy, PartialEq)] enum ApiKind { Detect, Ollama, OpenAi, } /// The coder model's job: turn a work intent into a real work-log entry. /// Free text (no JSON) — this is the content that lands in the work folder. /// Vision jobs get the frame attached as a data-URI image (OpenAI chat). fn request_write( client: &reqwest::blocking::Client, url: &str, model: &str, system: &str, user: &str, kind: &mut ApiKind, image: Option<&str>, ) -> Result { if *kind != ApiKind::OpenAi { match if image.is_some() { Err("status 404".to_string()) // ollama-compat with images unsupported → straight openai } else { write_via_ollama(client, url, model, system, user) } { Ok(text) => { *kind = ApiKind::Ollama; return Ok(text); } Err(e) if *kind == ApiKind::Detect && e.starts_with("status 404") => { *kind = ApiKind::OpenAi; // llama.cpp server → try OpenAI route } Err(e) => return Err(e), } } write_via_openai(client, url, model, system, user, image) } /// llama.cpp / vLLM / anything OpenAI-compatible (POST /v1/chat/completions). /// `image_path` attaches a real frame (vision spot-check). fn write_via_openai( client: &reqwest::blocking::Client, url: &str, model: &str, system: &str, user: &str, image_path: Option<&str>, ) -> Result { let messages = match image_path { Some(path) => { let bytes = std::fs::read(path).map_err(|e| format!("image: {e}"))?; let b64 = encode_b64(&bytes); json!([ {"role": "system", "content": system}, {"role": "user", "content": [ {"type": "text", "text": user}, {"type": "image_url", "image_url": {"url": format!("data:image/png;base64,{b64}")}} ]} ]) } None => json!([ {"role": "system", "content": system}, {"role": "user", "content": user}, ]), }; let body = json!({ "model": model, "messages": messages, "temperature": 0.6, "max_tokens": WRITE_NUM_PREDICT, "stream": false, }); let resp = client .post(format!("{}/v1/chat/completions", url.trim_end_matches('/'))) .json(&body) .send() .map_err(|e| format!("http: {e}"))?; if !resp.status().is_success() { return Err(format!("status {}", resp.status())); } let v: serde_json::Value = resp.json().map_err(|e| format!("json: {e}"))?; let text = v["choices"][0]["message"]["content"] .as_str() .unwrap_or_default(); let text = text.trim(); if text.is_empty() { return Err("empty content".into()); } Ok(text.to_string()) } fn encode_b64(bytes: &[u8]) -> String { // minimal base64 (std has none): tiny, correct, no deps const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); for chunk in bytes.chunks(3) { let b0 = chunk[0] as u32; let b1 = *chunk.get(1).unwrap_or(&0) as u32; let b2 = *chunk.get(2).unwrap_or(&0) as u32; let n = (b0 << 16) | (b1 << 8) | b2; out.push(TABLE[(n >> 18) as usize & 63] as char); out.push(TABLE[(n >> 12) as usize & 63] as char); out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' }); out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' }); } out } fn write_via_ollama( client: &reqwest::blocking::Client, url: &str, model: &str, system: &str, user: &str, ) -> Result { let body = json!({ "model": model, "stream": false, "think": false, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], "options": { "temperature": 0.6, "num_predict": WRITE_NUM_PREDICT, "num_ctx": 8192, "keep_alive": "1h", } }); 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 text = value["message"]["content"].as_str().unwrap_or_default(); let text = text.trim(); if text.is_empty() { return Err("empty content".into()); } Ok(text.to_string()) } /// Parse the writer protocol: `WORKLOG:` paragraph + `FILE: path` blocks. /// Bare ```fence lines inside file bodies are dropped. Paths sanitized. pub fn parse_written(raw: &str) -> (String, Vec<(String, String)>) { let mut entry = String::new(); let mut in_entry = false; let mut files: Vec<(String, String)> = Vec::new(); let mut cur_path: Option = None; let mut cur = String::new(); let mut flush = |files: &mut Vec<(String, String)>, cur_path: &mut Option, cur: &mut String| { if let Some(p) = cur_path.take() { let mut lines: Vec<&str> = cur.lines().collect(); while lines.first().is_some_and(|l| l.trim().starts_with("```")) { lines.remove(0); } while lines.last().is_some_and(|l| l.trim().starts_with("```")) { lines.pop(); } let body = lines.join("\n"); if !body.trim().is_empty() && files.len() < 5 { files.push((p, body)); } } cur.clear(); }; for line in raw.lines() { let trimmed = line.trim(); if trimmed.starts_with("WORKLOG:") { flush(&mut files, &mut cur_path, &mut cur); if entry.is_empty() { entry = trimmed["WORKLOG:".len()..].trim().to_string(); } in_entry = true; continue; } if let Some(rest) = trimmed.strip_prefix("FILE:") { flush(&mut files, &mut cur_path, &mut cur); // Fences the writer wraps file bodies in (```rust ...) are // stripped at start/end inside flush(). in_entry = false; cur_path = crate::company::sanitize_repo_path(rest); continue; } if cur_path.is_some() { if trimmed == "```" { continue; // stray fence marker } if cur.len() < 8000 { cur.push_str(line); cur.push('\n'); } } else if in_entry && entry.len() < 900 { entry.push(' '); entry.push_str(trimmed); } } flush(&mut files, &mut cur_path, &mut cur); (entry.trim().to_string(), files) } /// Prompt for the coder model when expanding a `work` decision (split mode). pub fn write_prompts( cfg: &CompanyCfg, person_name: &str, kind: crate::company::WriteKind, subject: &str, detail: &str, extra: &str, intent: &str, plan: &str, ) -> (String, String) { use crate::company::WriteKind; let person = cfg.people.iter().find(|p| p.name == person_name); let who = person .map(|p| format!("{} ({})", p.name, p.role)) .unwrap_or_else(|| person_name.to_string()); let personality = person .map(|p| p.personality.as_str()) .unwrap_or("professional"); let roster = roster_text(cfg); match kind { // ---- Day-1 plan: the company decides its product before coding ---- WriteKind::Plan => { let system = format!( "You are {who}, engineering manager of {company}.\n\ Mission: {mission}\nProduct: {product}\nStack: {stack}\n\n\ Today the company starts from ZERO: no repo, no plan, nothing built. \ Write the one-page plan.md the whole team will work from. 250-400 words, \ plain Markdown, exactly these sections:\n\n\ ## Product\n\n\ ## Scope\n\n\ ## Architecture\n\n\ ## Assignments\n\n\n\ Decisive and concrete: the team codes directly against this file. \ Output ONLY the Markdown document.", who = who, company = cfg.company.name, mission = cfg.company.mission, product = cfg.company.product, stack = cfg.company.stack, ); let user = format!( "Opening direction from the standup discussion:\n{intent}\n\n\ Backlog for scope reference:\n{extra}\n\nWrite plan.md now." ); (system, user) } // ---- Email body: substance instead of "Morning Standup" ---- WriteKind::Email => { let system = format!( "You are {who} at {company}.\nPersonality: {personality}\n\n\ Write the body of an email you are sending right now. 120-250 words, \ first person, fully in character: reference the actual work (task ids, \ repo file paths, numbers), ask what you need answered, end with a short \ sign-off line. Output ONLY the body text — no Subject line, no headers, \ no greeting block.", who = who, company = cfg.company.name, personality = personality, ); let user = format!( "To: {subject}\nAbout: {detail}\n\ Your notes on what to say:\n{intent}\n\n\ Company plan (context):\n{plan}\n\n\ You may ONLY reference these coworkers:\n{roster}\n\nWrite the email body now." ); (system, user) } // ---- Task work: work log entry + real repo files ---- WriteKind::WorkLog => { let system = format!( "You are {who} at {company}, working task {subject}.\n\ Mission: {mission}\nWe build with: {stack}\nPersonality: {personality}\n\n\ Company plan (the source of truth):\n{plan}\n\n\ Your output becomes TWO things in exactly this format — nothing before, \ nothing after:\n\n\ WORKLOG: \n\ FILE: \n\ \n\ FILE: \n\ \n\n\ Rules: 1-3 REAL files, fully written, using our stack — code the feature, \ or its tests, or its migration/doc; no placeholders, no TODOs; paths like \ src/sync/batch.py or docs/runbook.md. WORKLOG is plain text, no headings, \ no markdown.\n\ You can ONLY work with these coworkers (never invent a team):\n{roster}", who = who, company = cfg.company.name, mission = cfg.company.mission, stack = cfg.company.stack, personality = personality, ); let user = format!( "Task {subject}: {detail}\nDescription: {extra}\n\ Session plan (yours): {intent}\n\nWrite the WORKLOG and FILE blocks now." ); let system = format!("{system}\n\n{}", roster_text(cfg)); (system, user) } // ---- Eyes-on spot check: the frame image is attached separately ---- WriteKind::Look => { let system = format!( "You are {who} at {company}.\nPersonality: {personality}\n\n\ An actual frame from {context} the live camera/scene is attached. \ Look at it carefully and reply honestly about what you SEE: objects, \ motion, scene structure, anything suspicious (tracking artifacts, \ ghosts, replicate dots, jumpiness clues). 4-6 short factual sentences, \ plain text, in character. If you cannot tell something, SAY SO — \ never invent details you didn't see.\n\nYou may ONLY reference these \ coworkers: {roster}", who = who, company = cfg.company.name, personality = personality, context = subject, ); let user = format!("{intent}\n\nRespond about the attached image now."); let system = format!("{system}\n\n{}", roster_text(cfg)); (system, user) } } } // --------------------------------------------------------------------------- // Prompts // --------------------------------------------------------------------------- pub const ACTION_HELP: &str = r#"Choose ONE action as compact JSON (no prose, no markdown): {"action":"go_to","place":"desk|meeting|break","reason":"..."} — walk somewhere {"action":"work","task":"T3","note":"..."} — focused work; briefly state what you build and which repo files you touch (e.g. src/sync/batch.py) {"action":"email","to":"Name","subject":"...","body":"..."} — real email, lands in their inbox file {"action":"talk","to":"Name","message":""} — say something face to face (same area) {"action":"discuss","to":"Name","topic":""} — walk to a colleague and have a REAL conversation: several turns, both of you remember what was agreed. Use for design questions, disagreements, planning hand-offs. {"action":"submit","task":"T3","summary":"..."} — hand work in for review {"action":"assign","task":"T7","to":"Name","due_day":3} — MANAGER ONLY: give out backlog work (due_day optional) {"action":"review","task":"T3","approve":true,"feedback":"..."} — MANAGER ONLY: accept or bounce work {"action":"look","about":""} — only if the company has a camera+eyes: LOOK at an actual captured frame and report honestly what you see. Use before/after risky visualization claims. {"action":"meeting","topic":"..."} — MANAGER ONLY: call everyone to the meeting room {"action":"kickoff","plan":""} — MANAGER ONLY, day 1: write the company plan (nobody works without one) {"action":"raise_concern","task":"T3","message":"..."} — tell the manager an estimate/due date is unrealistic {"action":"rest","reason":"..."} — coffee break {"action":"idle","reason":"..."} — nothing right now Fill string fields with real content — `message` is the actual words spoken in FULL SENTENCES (never keywords like "request_work" — you are a person, not a terminal), `note` is what you will work on this session, `summary` is what you accomplished. No placeholders like "..." or "standup". Only reference task ids and people shown to you. Collaboration matters: tasks take real effort — work through teammates, discuss direction, and hand information on before someone leaves."#; pub fn roster_text(cfg: &CompanyCfg) -> String { let mut out = String::from( "The company has exactly these people (no other teams, no other engineers — \ never invent coworkers; address individuals by name):\n", ); for p in &cfg.people { out.push_str(&format!(" - {} — {}\n", p.name, p.role)); } out } /// The system prompt evolves with company state — this is the "current chapter". pub fn company_status(day: u32, has_plan: bool) -> String { let chapter = match day { 1 => "Day 1 — founding day. The repo is empty; the first work sessions CREATE it.", 2..=3 => "Days 2-3 — foundation week: get the skeleton right before features sprawl.", _ => "Week 2+ — features on a real codebase; keep it shippable, not demo-ware.", }; format!( "Company status: day {day}. {chapter} {}", if has_plan { "A sprint plan exists (work/plan.md) — treat it as law; raise_concern to change it." } else { "NO plan exists yet — the manager owes the team a kickoff before work starts." } ) } pub fn system_prompt(cfg: &CompanyCfg, person_idx: usize, manager: bool, day: u32, has_plan: bool) -> String { let p = &cfg.people[person_idx]; let manager_note = if manager { "\nYou are the MANAGER and this is a product team — decide the thing, ship the thing. \ Day 1 (or any morning without a company plan): your FIRST action is `kickoff` to \ write the sprint plan. Then at standup `assign` until every person has work, \ including tasks you assign yourself; never `work` an unassigned task. Through the \ day: `review` submitted work before it piles up, `meeting` when the room drifts. \ When reviewing, ALWAYS set approve true or false explicitly with one line of \ feedback — reviews without a verdict are ignored. Never re-review or re-submit \ tasks that are already done. Delegation is the job; a little hands-on work \ keeps you credible." } else { "\nYou report to the manager (see the task board for who that is). You CANNOT use \ assign, review, or meeting — only the manager does that. Your loop: `work` your \ assigned task until 100%, then `submit` it, then pick up the next thing — ask for \ work via `talk` to the manager or `email` if you have nothing assigned. Say what \ you'll do at standup; raise_concern when a deadline is genuinely unrealistic \ (this is respected here, not disloyalty). Rest and chat at lunchtime." }; format!( "You are {name}, {role}, at {company}.\n\ Company mission: {mission}\n\ What we're building: {product}\n\ Tech: {stack}\n\ Your personality: {personality}.{manager_note}\n\n\ THE COMPANY ROSTER:\n{roster}\n\ This is a workplace simulation. Each turn you get the current state and pick \ exactly one action.{help}", roster = roster_text(cfg), name = p.name, role = p.role, company = cfg.company.name, mission = cfg.company.mission, product = cfg.company.product, stack = cfg.company.stack, personality = p.personality, help = ACTION_HELP, ) }