use crate::state::{ActiveDirective, AphroditeState};
pub fn build_turn_context(state:&mut AphroditeState, est_request_bytes:Option<usize>) -> String {
let _ = est_request_bytes;
let budget = state.flow_budget_chars;
let directives = crate::directives::build_directive_context(&state.directives, &state.active_directives);
let nudges = render_nudges(state);
let recall = if state.navigation_enabled {
#[cfg(feature = "navigation")]
{ crate::navigate::build_navigable_context(state) }
#[cfg(not(feature = "navigation"))]
{ crate::session::catalog_summary(state) }
} else {
crate::session::catalog_summary(state)
};
let bg_status = if state.poll_worker_enabled {
crate::poll_worker::render_bg_task_status(state)
} else {
String::new()
};
let mut sections:Vec<String> = Vec::new();
if !directives.is_empty() {
sections.push(directives.trim_end().to_string());
}
if !nudges.is_empty() {
sections.push(nudges);
}
if !bg_status.is_empty() {
sections.push(bg_status);
}
if !recall.is_empty() {
sections.push(format!("[recall]\n{}\n", recall.trim_end()));
}
let always_survive = directives.is_empty() as usize + nudges_present(state) as usize;
let always_survive = always_survive.min(sections.len());
while join_sections(§ions).len() > budget && sections.len() > always_survive {
sections.pop();
}
join_sections(§ions)
}
fn nudges_present(state:&AphroditeState) -> bool {
state
.ephemeral_directives
.iter()
.any(|e| e.inline.is_some() && e.expires_after_turn.is_none_or(|exp| exp >= state.turn_counter))
}
fn join_sections(sections:&[String]) -> String { sections.join("\n") }
pub fn render_nudges(state:&AphroditeState) -> String {
let mut lines:Vec<String> = state
.ephemeral_directives
.iter()
.rev() .filter(|e| e.inline.is_some())
.filter(|e| e.expires_after_turn.is_none_or(|exp| exp >= state.turn_counter))
.take(2)
.filter_map(|e| e.inline.as_ref().map(|t| format!("[nudge: {t}]")))
.collect();
lines.reverse();
lines.join("\n")
}
pub fn push_nudge(state:&mut AphroditeState, text:&str, ttl_turns:usize) {
let expires = Some(state.turn_counter + ttl_turns);
state.ephemeral_directives.push(ActiveDirective {
name:String::new(),
inline:Some(text.to_string()),
expires_after_turn:expires,
});
while state.ephemeral_directives.len() > 4 {
state.ephemeral_directives.remove(0);
}
}
pub fn purge_expired_nudges(state:&mut AphroditeState) {
let counter = state.turn_counter;
state
.ephemeral_directives
.retain(|e| e.expires_after_turn.is_none_or(|exp| exp >= counter));
}
const FNV_OFFSET:u64 = 0xCBF2_9CE4_8422_2325;
const FNV_PRIME:u64 = 0x0000_0100_0000_01B3;
fn fnv1a(bytes:&[u8]) -> u64 {
let mut hash = FNV_OFFSET;
for &b in bytes {
hash ^= b as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
const VOLATILE_KEYS:&[&str] = &["timeout", "timestamp", "session_id", "tool_call_id"];
pub fn normalize_args_sig(tool:&str, args:Option<&serde_json::Value>) -> u64 {
let mut buf = String::new();
buf.push_str(tool);
buf.push('\u{1}');
if let Some(v) = args {
if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
buf.push_str(cmd.trim_end());
} else if let Some(obj) = v.as_object() {
let mut keys:Vec<&String> = obj.keys().filter(|k| !VOLATILE_KEYS.contains(&k.as_str())).collect();
keys.sort();
for k in keys {
buf.push_str(k);
buf.push('=');
buf.push_str(&obj[k].to_string());
buf.push('\u{1f}');
}
} else {
buf.push_str(&v.to_string());
}
}
fnv1a(buf.as_bytes())
}
pub fn error_sig(error_type:Option<&str>, error_message:Option<&str>) -> u64 {
let mut buf = String::new();
buf.push_str(error_type.unwrap_or(""));
buf.push('\u{1}');
if let Some(msg) = error_message {
buf.push_str(msg.lines().next().unwrap_or("").trim());
}
fnv1a(buf.as_bytes())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WindowStats {
pub reads:usize,
pub writes:usize,
pub searches:usize,
pub errors:usize,
pub distinct_error_sigs:usize,
pub new_files:usize,
pub total_calls:usize,
}
pub fn turn_window(state:&AphroditeState, n:usize) -> WindowStats {
let floor = state.turn_counter.saturating_sub(n);
let mut stats = WindowStats::default();
let mut error_sigs = std::collections::HashSet::new();
let mut seen_paths = std::collections::HashSet::new();
for ev in state.tool_events.iter().filter(|e| e.turn > floor) {
stats.total_calls += 1;
if !ev.ok {
stats.errors += 1;
if let Some(sig) = ev.error_sig {
error_sigs.insert(sig);
}
}
if let Some(path) = &ev.wrote_path {
stats.writes += 1;
if seen_paths.insert(path.clone()) {
stats.new_files += 1;
}
} else {
match ev.tool.as_str() {
"read_file" => stats.reads += 1,
"search_files" => stats.searches += 1,
_ => {},
}
}
}
stats.distinct_error_sigs = error_sigs.len();
stats
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
directives::Directive,
state::{MarkerEntry, ToolEvent},
};
fn state_with_directive(name:&str, body:&str) -> AphroditeState {
let mut s = AphroditeState::default();
s.directives
.insert(name.into(), Directive { name:name.into(), content:body.into() });
s.active_directives = vec![name.into()];
s
}
fn add_marker(s:&mut AphroditeState, hash:&str, turn:usize) {
s.record_marker(MarkerEntry {
hash:hash.into(),
ccr_type:"text".into(),
size:100,
preview:"[text] some preview content here".into(),
turn,
center:None,
meta:None,
});
}
#[test]
fn test_budget_drops_catalog_before_directives() {
let mut s = state_with_directive("focus", "stay targeted, minimal tool usage");
for i in 0..50 {
add_marker(&mut s, &format!("hash{i:040}"), i);
}
s.flow_budget_chars = 90;
let ctx = build_turn_context(&mut s, None);
assert!(ctx.contains("[directives:"), "directives must survive: {ctx}");
assert!(
!ctx.contains("[recall]"),
"catalog must be dropped first under budget pressure: {ctx}"
);
}
#[test]
fn test_retrieve_hint_not_in_per_turn_context() {
let mut s = AphroditeState::default();
add_marker(&mut s, &"a".repeat(40), 0);
s.flow_budget_chars = 4000;
let ctx = build_turn_context(&mut s, None);
assert_eq!(
ctx.matches("retrieve: aphrodite_retrieve").count(),
0,
"retrieve hint must NOT appear in per-turn context (04-F2): {ctx}"
);
}
#[test]
fn test_empty_state_yields_empty_context() {
let mut s = AphroditeState::default();
assert_eq!(build_turn_context(&mut s, None), "");
}
#[test]
fn test_normalize_args_sig_stable_across_volatile_keys() {
let a = serde_json::json!({"path": "src/x.rs", "timeout": 30, "session_id": "abc"});
let b = serde_json::json!({"path": "src/x.rs", "timeout": 99, "session_id": "zzz"});
assert_eq!(
normalize_args_sig("read_file", Some(&a)),
normalize_args_sig("read_file", Some(&b)),
"volatile keys must not affect the signature"
);
let c = serde_json::json!({"path": "src/y.rs"});
assert_ne!(
normalize_args_sig("read_file", Some(&a)),
normalize_args_sig("read_file", Some(&c))
);
let cmd1 = serde_json::json!({"command": "cargo test "});
let cmd2 = serde_json::json!({"command": "cargo test"});
assert_eq!(
normalize_args_sig("terminal", Some(&cmd1)),
normalize_args_sig("terminal", Some(&cmd2))
);
}
#[test]
fn test_turn_window_counts_reads_writes_errors() {
let mut s = AphroditeState::default();
s.turn_counter = 10;
s.record_tool_event(ToolEvent {
turn:10,
tool:"read_file".into(),
sig:1,
ok:true,
error_sig:None,
bytes:100,
wrote_path:None,
});
s.record_tool_event(ToolEvent {
turn:10,
tool:"write_file".into(),
sig:2,
ok:true,
error_sig:None,
bytes:50,
wrote_path:Some("src/a.rs".into()),
});
s.record_tool_event(ToolEvent {
turn:9,
tool:"terminal".into(),
sig:3,
ok:false,
error_sig:Some(42),
bytes:20,
wrote_path:None,
});
s.record_tool_event(ToolEvent {
turn:2,
tool:"read_file".into(),
sig:4,
ok:true,
error_sig:None,
bytes:0,
wrote_path:None,
});
let w = turn_window(&s, 5);
assert_eq!(w.reads, 1);
assert_eq!(w.writes, 1);
assert_eq!(w.new_files, 1);
assert_eq!(w.errors, 1);
assert_eq!(w.distinct_error_sigs, 1);
assert_eq!(w.total_calls, 3, "the turn-2 event is outside the 5-turn window");
}
#[test]
fn test_build_turn_context_omits_poll_status_when_disabled() {
let mut s = AphroditeState::default();
s.poll_worker_enabled = false;
s.turn_counter = 5;
crate::poll_worker::insert_bg_task(
&mut s,
"t1".into(),
"terminal".into(),
"cargo build".into(),
1,
);
let ctx = build_turn_context(&mut s, None);
assert!(
!ctx.contains("[poll workers]"),
"disabled flag must omit poll worker status: {ctx}"
);
}
#[test]
fn test_build_turn_context_includes_poll_status_when_enabled() {
let mut s = AphroditeState::default();
s.poll_worker_enabled = true;
s.turn_counter = 5;
crate::poll_worker::insert_bg_task(
&mut s,
"t1".into(),
"terminal".into(),
"cargo build".into(),
1,
);
let ctx = build_turn_context(&mut s, None);
assert!(
ctx.contains("[poll workers]"),
"enabled flag must include poll worker status: {ctx}"
);
}
}