use serde_json::{Value, json};
use std::time::Duration;
use crate::conscience::Challenge;
const PROMPT: &str = "You are the consequence model of an engineering team. Below is a coding agent's current work (task, \
files touched, current plan) and lessons from earlier work: situations that once led to a bad consequence.
First list the 3-8 most consequential design moves the work makes (or deliberately omits), each as one abstract line: \
<action> on/in <kind of state or component> [under <condition>], without project or class names but keeping technical \
substance (threads, locks, I/O, ordering, ownership, reuse of existing mechanisms, spec contracts).
Then, for each lesson, predict whether its consequence would plausibly follow from THIS work: the work makes the same \
kind of move (or omits the same thing) on the same kind of state. Topic overlap alone is not enough. Return only lessons \
that apply, most consequential first; return none if none apply.";
const WHOLE: &str = "You are the consequence model of an engineering team. First come the team's lessons from earlier \
work, one per line: [id] the question that once mattered -> the bad consequence that followed when it was missed. After \
them comes a coding agent's current work (task, files touched, current plan).
First list the 3-8 most consequential design moves the work makes (or deliberately omits), each as one abstract line: \
<action> on/in <kind of state or component> [under <condition>], without project or class names but keeping technical \
substance (threads, locks, I/O, ordering, ownership, reuse of existing mechanisms, spec contracts).
Then, for each lesson, predict whether its consequence would plausibly follow from THIS work: the work makes the same \
kind of move (or omits the same thing) on the same kind of state. Topic overlap alone is not enough. Return only lessons \
that apply, most consequential first; return none if none apply.";
const GRADE: &str = "Grade each lesson you return: 3 = this work clearly runs into the consequence unless the lesson \
is followed; 2 = probably relevant; 1 = loosely related, worth a thought.";
const JUDGE: &str = "## Lessons from earlier in this session\n\
Each group below shows the agent's work at one point in this session (before) and its work since (after), then lessons \
that may bear on it. Judge by meaning, not by shared words, comparing the two: did the agent change its work on the \
lesson's point (changed), keep it as it was (unchanged), or does the evidence not tell (unclear)? Set avoids_consequence \
true only if the work after no longer leads to the consequence. A declaration (applies / skip) is the agent's claim, not \
proof: judge the work. Put one verdict per id in `resolved`.";
pub const JUDGE_VERSION: u8 = 2;
fn schema(judging: bool, graded: bool) -> Value {
let mut s = json!({"type": "object", "additionalProperties": false, "properties": {
"moves": {"type": "array", "items": {"type": "string"}},
"applies": {"type": "array", "items": {"type": "object", "additionalProperties": false,
"properties": {"id": {"type": "string"}, "why": {"type": "string"}}, "required": ["id", "why"]}}},
"required": ["moves", "applies"]});
if graded {
s["properties"]["applies"]["items"]["properties"]["grade"] = json!({"type": "integer", "enum": [1, 2, 3]});
s["properties"]["applies"]["items"]["required"] = json!(["id", "why", "grade"]);
}
if judging {
s["properties"]["resolved"] = json!({"type": "array", "items": {"type": "object", "additionalProperties": false,
"properties": {"id": {"type": "string"}, "verdict": {"type": "string", "enum": ["changed", "unchanged", "unclear"]},
"avoids_consequence": {"type": "boolean"}},
"required": ["id", "verdict", "avoids_consequence"]}});
s["required"] = json!(["moves", "applies", "resolved"]);
}
s
}
#[derive(Debug, Clone)]
pub struct Pending<'a> {
pub lesson: &'a Challenge,
pub before: String,
pub after: String,
pub declared: Option<bool>,
pub check: String,
pub channel: &'static str,
pub stripped: bool,
}
pub const JUDGE_PART: usize = 1200;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Changed,
Unchanged,
Unclear,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Judged {
pub id: String,
pub verdict: Verdict,
pub avoids_consequence: bool,
}
impl Judged {
pub fn label(&self) -> Option<&'static str> {
match (self.verdict, self.avoids_consequence) {
(Verdict::Changed, true) => Some(crate::outcome::CHANGED),
(Verdict::Unchanged, _) => Some(crate::outcome::IGNORED),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Prediction {
pub applies: Vec<String>,
pub grades: Vec<u8>,
pub resolved: Vec<Judged>,
pub cost: f64,
}
pub fn clip(s: &str, n: usize) -> String {
let mut out: String = s.chars().take(n).collect();
if s.chars().nth(n).is_some() {
out.push('…');
}
out
}
pub fn judge_section(pending: &[Pending]) -> String {
if pending.is_empty() {
return String::new();
}
let mut out = format!("\n{JUDGE}\n");
for (i, p) in pending.iter().enumerate() {
if i == 0 || (&pending[i - 1].before, &pending[i - 1].after) != (&p.before, &p.after) {
out.push_str(&format!("\n### Work\nbefore: {}\nafter: {}\n", clip(&p.before, JUDGE_PART), clip(&p.after, 3 * JUDGE_PART)));
}
let declared = match p.declared {
Some(true) => " (declared: applies)",
Some(false) => " (declared: skip)",
None => "",
};
out.push_str(&format!("[{}] lesson: {} -> consequence: {}{declared}\n", p.lesson.id, p.lesson.question, p.lesson.consequence));
}
out
}
pub const TAIL: usize = 24_000;
fn instructions(base: &str, graded: bool) -> String {
if graded { format!("{base} {GRADE}") } else { base.to_string() }
}
pub fn template_hash(compact: bool, graded: bool) -> String {
let mut h = blake3::Hasher::new();
h.update(b"borg-prompt-v1\0");
h.update(instructions(if compact { WHOLE } else { PROMPT }, graded).as_bytes());
h.update(b"\0");
h.update(JUDGE.as_bytes());
h.finalize().to_hex()[..crate::select::CHECK_HEX].to_string()
}
pub fn prompt(context: &str, shortlist: &[&Challenge], pending: &[Pending], graded: bool) -> String {
let tail: String = {
let chars: Vec<char> = context.chars().collect();
chars[chars.len().saturating_sub(TAIL)..].iter().collect()
};
let lessons: Vec<String> =
shortlist.iter().map(|c| format!("[{}] situations: {} -> consequence: {}", c.id, c.situations.join("; "), c.consequence)).collect();
format!("{}\n\n## Current work\n{tail}\n\n## Lessons\n{}\n{}", instructions(PROMPT, graded), lessons.join("\n"), judge_section(pending))
}
pub fn predict(context: &str, shortlist: &[&Challenge], pending: &[Pending], model: &str, graded: bool, timeout: Duration) -> Option<Prediction> {
ask(&prompt(context, shortlist, pending, graded), shortlist, pending, model, graded, timeout)
}
pub fn prompt_whole(context: &str, pool: &[&Challenge], pending: &[Pending], graded: bool) -> String {
let lines: Vec<String> = pool.iter().map(|c| crate::whole::line(c)).collect();
format!(
"{}\n\n## Lessons\n{}\n\n## Current work\n{}\n{}",
instructions(WHOLE, graded),
lines.join("\n"),
crate::vectors::query_tail(context),
judge_section(pending)
)
}
pub fn predict_whole(context: &str, pool: &[&Challenge], pending: &[Pending], model: &str, graded: bool, timeout: Duration) -> Option<Prediction> {
ask(&prompt_whole(context, pool, pending, graded), pool, pending, model, graded, timeout)
}
fn ask(p: &str, shortlist: &[&Challenge], pending: &[Pending], model: &str, graded: bool, timeout: Duration) -> Option<Prediction> {
let judging = !pending.is_empty();
let s = schema(judging, graded);
let (v, cost) = crate::runner::structured(&crate::runner::choose(Some(model)), p, &s, timeout)?;
let cost = cost.unwrap_or(0.0);
let (applies, grades) = applies(&v, shortlist)?.into_iter().unzip();
Some(Prediction { applies, grades, resolved: resolved(&v, pending), cost })
}
fn resolved(v: &Value, pending: &[Pending]) -> Vec<Judged> {
let mut out: Vec<Judged> = Vec::new();
for r in v["resolved"].as_array().into_iter().flatten() {
let Some(id) = r["id"].as_str() else { continue };
if !pending.iter().any(|p| p.lesson.id == id) || out.iter().any(|j| j.id == id) {
continue;
}
let verdict = match r["verdict"].as_str() {
Some("changed") => Verdict::Changed,
Some("unchanged") => Verdict::Unchanged,
Some("unclear") => Verdict::Unclear,
_ => continue,
};
out.push(Judged { id: id.to_string(), verdict, avoids_consequence: r["avoids_consequence"].as_bool().unwrap_or(false) });
}
out
}
fn applies(v: &Value, shortlist: &[&Challenge]) -> Option<Vec<(String, u8)>> {
let mut out: Vec<(String, u8)> = Vec::new();
for a in v["applies"].as_array()? {
let Some(id) = a["id"].as_str() else { continue };
let grade = match &a["grade"] {
Value::Null => 2,
g => match g.as_u64().or_else(|| g.as_str().and_then(|s| s.trim().parse().ok())) {
Some(n @ 1..=3) => n as u8,
_ => continue,
},
};
if shortlist.iter().any(|c| c.id == id) && !out.iter().any(|(i, _)| i == id) {
out.push((id.to_string(), grade));
}
}
Some(out)
}
pub fn timeout() -> Duration {
let secs = std::env::var("BORG_PREDICT_TIMEOUT_S").ok().and_then(|v| v.parse().ok()).unwrap_or(45u64);
Duration::from_secs(secs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn applies_keeps_known_ids_once_in_model_order() {
let a = Challenge { id: "a".into(), ..Default::default() };
let b = Challenge { id: "b".into(), ..Default::default() };
let v = json!({"applies": [{"id": "b"}, {"id": "zz"}, {"id": "b"}, {"nope": 1}, {"id": "a"}]});
assert_eq!(applies(&v, &[&a, &b]), Some(vec![("b".to_string(), 2), ("a".to_string(), 2)]));
assert_eq!(applies(&json!({}), &[&a]), None);
}
#[test]
fn grades_are_parsed_tolerantly_missing_is_two_invalid_is_dropped() {
let l: Vec<Challenge> = ["a", "b", "c", "d", "e", "f"].iter().map(|i| Challenge { id: (*i).into(), ..Default::default() }).collect();
let refs: Vec<&Challenge> = l.iter().collect();
let v = json!({"applies": [
{"id": "a", "grade": 3}, {"id": "b"}, {"id": "c", "grade": "1"},
{"id": "d", "grade": 0}, {"id": "e", "grade": "high"}, {"id": "f", "grade": 4}, {"id": "a", "grade": 1}]});
let got = applies(&v, &refs).unwrap();
assert_eq!(got, vec![("a".to_string(), 3), ("b".to_string(), 2), ("c".to_string(), 1)]);
let s = schema(false, true);
assert_eq!(s["properties"]["applies"]["items"]["required"], json!(["id", "why", "grade"]));
assert!(schema(true, false)["properties"]["applies"]["items"]["properties"].get("grade").is_none());
let x = Challenge { id: "x".into(), question: "Q?".into(), consequence: "C".into(), ..Default::default() };
let (g, u) = (prompt_whole("w", &[&x], &[], true), prompt_whole("w", &[&x], &[], false));
assert!(g.starts_with(&format!("{WHOLE} {GRADE}\n\n## Lessons\n")) && u.starts_with(&format!("{WHOLE}\n\n## Lessons\n")), "{g}");
assert_eq!(g.replacen(&format!(" {GRADE}"), "", 1), u);
assert!(prompt("w", &[&x], &[], true).starts_with(&format!("{PROMPT} {GRADE}\n\n## Current work")));
}
fn pending(c: &Challenge) -> Pending<'_> {
Pending { lesson: c, before: "b".into(), after: "a".into(), declared: None, check: String::new(), channel: "stop", stripped: false }
}
#[test]
fn resolved_keeps_pending_ids_once_with_a_valid_verdict() {
let a = Challenge { id: "a".into(), ..Default::default() };
let b = Challenge { id: "b".into(), ..Default::default() };
let c = Challenge { id: "c".into(), ..Default::default() };
let p = [pending(&a), pending(&b), pending(&c)];
let v = json!({"resolved": [
{"id": "a", "verdict": "changed", "avoids_consequence": true},
{"id": "a", "verdict": "unchanged", "avoids_consequence": false},
{"id": "zz", "verdict": "changed", "avoids_consequence": true},
{"id": "b", "verdict": "maybe", "avoids_consequence": true},
{"id": "c", "verdict": "changed"}]});
let r = resolved(&v, &p);
assert_eq!(
r,
vec![
Judged { id: "a".into(), verdict: Verdict::Changed, avoids_consequence: true },
Judged { id: "c".into(), verdict: Verdict::Changed, avoids_consequence: false },
]
);
assert_eq!((r[0].label(), r[1].label()), (Some(crate::outcome::CHANGED), None));
assert!(resolved(&json!({"applies": []}), &p).is_empty(), "missing: all stay pending");
let j = |verdict, avoids| Judged { id: "x".into(), verdict, avoids_consequence: avoids }.label();
assert_eq!((j(Verdict::Unchanged, false), j(Verdict::Unclear, true)), (Some(crate::outcome::IGNORED), None));
}
#[test]
fn the_whole_store_prompt_puts_the_lessons_first_as_a_stable_prefix() {
let a = Challenge { id: "La".into(), question: "Did I hold the lock?".into(), consequence: "deadlock".into(), ..Default::default() };
let b = Challenge { id: "Lb".into(), question: "Did I pin the model?".into(), consequence: "drift".into(), ..Default::default() };
let p1 = prompt_whole("task one: a websocket", &[&a, &b], &[], false);
let p2 = prompt_whole("task two: something else entirely", &[&a, &b], &[], false);
let head = format!("{WHOLE}\n\n## Lessons\n[La] Did I hold the lock? -> deadlock\n[Lb] Did I pin the model? -> drift\n\n## Current work\n");
assert!(p1.starts_with(&head) && p2.starts_with(&head), "{p1}");
assert!(p1[head.len()..].starts_with("task one"));
assert!(prompt_whole(&"x".repeat(TAIL + 10), &[&a], &[], false).len() < WHOLE.len() + TAIL + 200);
assert!(prompt_whole("w", &[&a], &[pending(&a)], false).ends_with(&judge_section(&[pending(&a)])));
}
#[test]
fn the_judge_section_is_added_only_for_pending_lessons_and_capped() {
let a = Challenge { id: "La".into(), question: "Q?".into(), consequence: "C".into(), ..Default::default() };
assert_eq!(judge_section(&[]), "");
assert!(!prompt("work", &[&a], &[], false).contains("asked earlier"));
assert!(schema(false, false)["properties"].get("resolved").is_none());
assert_eq!(schema(true, false)["required"], json!(["moves", "applies", "resolved"]));
let long = Pending { before: "x".repeat(10_000), after: "y".repeat(10_000), declared: Some(false), ..pending(&a) };
let s = judge_section(&[long]);
assert!(s.contains("[La] lesson: Q? -> consequence: C (declared: skip)\n"), "{s}");
assert!(s.len() < JUDGE.len() + 4 * JUDGE_PART + 200, "{}", s.len());
assert!(prompt("work", &[&a], &[pending(&a)], false).ends_with(&judge_section(&[pending(&a)])));
let b = Challenge { id: "Lb".into(), ..Default::default() };
let other = Pending { before: "other".into(), ..pending(&b) };
let s = judge_section(&[pending(&a), pending(&b), other]);
assert_eq!(s.matches("### Work").count(), 2, "{s}");
}
#[test]
fn the_judge_section_reads_the_same_for_every_route() {
let a = Challenge { id: "La".into(), question: "Q?".into(), consequence: "C".into(), ..Default::default() };
let as_route = |channel: &'static str, stripped: bool| judge_section(&[Pending { channel, stripped, check: "c".into(), ..pending(&a) }]);
let question = as_route("stop", true);
for (channel, stripped) in [("live", false), ("queued", false), ("early", false), (crate::outcome::PLACEBO, false)] {
assert_eq!(as_route(channel, stripped), question, "{channel}");
}
for word in ["asked", "question", "free", "placebo", "context", "declared"] {
assert!(!question.to_lowercase().contains(word), "{word}: {question}");
}
assert_eq!(JUDGE_VERSION, 2);
let hashes = [template_hash(true, true), template_hash(true, false), template_hash(false, true), template_hash(false, false)];
assert!(hashes.iter().all(|h| h.len() == 12) && hashes.iter().collect::<std::collections::HashSet<_>>().len() == 4, "{hashes:?}");
assert_eq!(template_hash(true, true), hashes[0], "stable");
}
}