//! 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<Endpoint>,
/// Empty in shared mode → no work-content expansion.
pub write: Vec<Endpoint>,
}
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<String>,
}
pub enum JobResult {
Decide {
person: String,
action: Result<Action, String>,
elapsed: f32,
endpoint: String,
/// Verbatim dialogue transcript when action == Discuss.
dialogue: Option<DialogueLog>,
},
/// Written content from the coder model (split mode).
Write {
person: String,
task: String,
write_kind: WriteKind,
content: Result<String, String>,
elapsed: f32,
endpoint: String,
},
}
pub struct DecisionResult {
pub person: String,
pub action: Result<Action, String>,
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<DialogueLog>,
}
/// 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<u32>,
},
/// 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<bool>,
#[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<Job>,
/// Only present in split mode.
pub write_tx: Option<Sender<Job>>,
pub results_rx: Mutex<Receiver<JobResult>>,
pub in_flight: usize,
pub completed: u64,
pub failed: u64,
pub endpoints: Vec<String>,
}
pub fn start_pool(commands: &mut Commands, route: Route, roster: Vec<(String, String, String)>) {
let _ = ROSTER.set(roster);
let (decide_tx, decide_rx) = channel::<Job>();
let (write_opt, write_rx) = match &route.write {
w if w.is_empty() => (None, None),
_ => {
let (tx, rx) = channel::<Job>();
(Some(tx), Some(rx))
}
};
let (results_tx, results_rx) = channel::<JobResult>();
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<Vec<(String, String, String)>> = 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<DialogueLog> { 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::<Vec<_>>()
.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<Mutex<Receiver<Job>>>, tx: Sender<JobResult>, 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<Mutex<Receiver<Job>>>,
results_tx: Sender<JobResult>,
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<String> = 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<DialogueLog> = 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::<Action>(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::<Action>(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<String, String> {
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<String, String> {
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<String, String> {
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<String> = None;
let mut cur = String::new();
let mut flush = |files: &mut Vec<(String, String)>,
cur_path: &mut Option<String>,
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<what we ship, for whom, and why — 2-3 concrete sentences>\n\
## Scope\n<what is IN this sprint by task id (T1..T10), what is OUT>\n\
## Architecture\n<the repo structure we will create, component bullets>\n\
## Assignments\n<who starts on what: person and task id>\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: <one tight paragraph, max 120 words, first person>\n\
FILE: <relative repo path>\n\
<complete file contents>\n\
FILE: <another relative path>\n\
<complete file contents>\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":"<the actual words you say, in character>"} — say something face to face (same area)
{"action":"discuss","to":"Name","topic":"<what you two should talk through, one sentence>"} — 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":"<what to check in the actual live frame>"} — 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":"<sprint goal + rough plan, 2-4 sentences>"} — 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,
)
}