memory status reports context pressure
Both the primary's and the subconscious's system prompts say 'memory status shows my context pressure and number of messages'. It showed neither -- git state only. This matters most for the subconscious. Her prompt tells her plainly that no one feels the gauge for her (correct, by design: the engine warns, it never trims), and points her at memory status to read it herself. With status blind she had no gauge at all, from either direction: pressure_for is only ever computed for the primary. She grew to 1.75M tokens against a 1M ceiling and died at the provider. - PressureSnapshot + CompactionEngine::pressure - get_context_limit follows the model, not the primary -- the subconscious runs a different model, so her ceiling differs - tier() mirrors the engine's 0.80/0.90/0.95 marks so the two gauges cannot disagree about full - None when there is no session: absence, not zero Also corrects the compact help text, which named primary=sliding_window/subconscious=sliding_window against config.rs's actual cull/sliding_reflect.
This commit is contained in:
parent
aa6cd4e219
commit
877cad006d
3 changed files with 217 additions and 1 deletions
|
|
@ -58,12 +58,52 @@ impl Clock for UtcClock {
|
|||
}
|
||||
}
|
||||
|
||||
/// A reading of how full an agent's live conversation is.
|
||||
///
|
||||
/// This is the gauge behind `memory status`. Both the primary and the
|
||||
/// subconscious are told in their system prompt that `memory status` shows
|
||||
/// "context pressure and number of messages" — before 2026-08-13 it showed
|
||||
/// neither, and the subconscious (who receives no tier warnings at all, by
|
||||
/// design: "no one feels this gauge for me") had no way to read her own
|
||||
/// fullness. She grew to 1.75M tokens against a 1M ceiling and died at the
|
||||
/// provider. The promise in the prompt is now kept in the code.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PressureSnapshot {
|
||||
/// Messages currently in the live conversation.
|
||||
pub messages: usize,
|
||||
/// Estimated tokens across every countable block, not text alone.
|
||||
pub tokens: usize,
|
||||
/// The context window this is measured against.
|
||||
pub limit: usize,
|
||||
/// `tokens / limit`, clamped to 1.0.
|
||||
pub ratio: f32,
|
||||
}
|
||||
|
||||
impl PressureSnapshot {
|
||||
/// The advisory tier this reading falls in, matching the three marks the
|
||||
/// engine warns on (0.80 / 0.90 / 0.95). `None` below the first mark.
|
||||
pub fn tier(&self) -> Option<u8> {
|
||||
match self.ratio {
|
||||
r if r > 0.95 => Some(3),
|
||||
r if r > 0.90 => Some(2),
|
||||
r if r > 0.80 => Some(1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The public interface for compaction operations.
|
||||
///
|
||||
/// Carried in `ToolContext::compaction_engine` so the `memory compact`
|
||||
/// tool handler can delegate to it without server dependencies.
|
||||
#[async_trait]
|
||||
pub trait CompactionEngine: Send + Sync {
|
||||
/// Read the agent's current context pressure.
|
||||
///
|
||||
/// `None` when the agent has no live session — a fresh agent has nothing
|
||||
/// to measure, which is distinct from measuring zero.
|
||||
async fn pressure(&self, agent_id: &str) -> Option<PressureSnapshot>;
|
||||
|
||||
/// Compact messages for the given agent's session.
|
||||
/// `strategy_override` allows the agent to pick a specific strategy;
|
||||
/// None uses the config default for the agent's type.
|
||||
|
|
@ -95,10 +135,29 @@ pub struct DefaultCompactionEngine {
|
|||
pub get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync>,
|
||||
/// Per-agent provider override name (e.g. "zai"). None = use global default.
|
||||
pub get_agent_provider: Arc<dyn Fn(&str) -> Option<String> + Send + Sync>,
|
||||
/// The context window to measure this agent's pressure against. Separate
|
||||
/// from the strategy config because it follows the *model*, and the
|
||||
/// subconscious runs a different model from her primary.
|
||||
pub get_context_limit: Arc<dyn Fn(&str) -> Option<usize> + Send + Sync>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CompactionEngine for DefaultCompactionEngine {
|
||||
async fn pressure(&self, agent_id: &str) -> Option<PressureSnapshot> {
|
||||
let messages = (self.get_messages)(agent_id)?;
|
||||
let tokens = count_messages(&self.counter, &messages);
|
||||
// Falls back to 128K only when the model is unknown — the same
|
||||
// fallback `ConsciousnessEngine::pressure_for` uses, so the two
|
||||
// gauges cannot disagree about the ceiling.
|
||||
let limit = (self.get_context_limit)(agent_id).unwrap_or(128_000).max(1);
|
||||
Some(PressureSnapshot {
|
||||
messages: messages.len(),
|
||||
tokens,
|
||||
limit,
|
||||
ratio: (tokens as f32 / limit as f32).min(1.0),
|
||||
})
|
||||
}
|
||||
|
||||
async fn compact(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
|
|
@ -346,6 +405,7 @@ mod tests {
|
|||
get_repo: Arc::new(|_| None),
|
||||
get_agent_type: Arc::new(|_| Some("primary".to_string())),
|
||||
get_agent_provider: Arc::new(|_| None),
|
||||
get_context_limit: Arc::new(|_| None),
|
||||
};
|
||||
|
||||
let report = engine
|
||||
|
|
@ -356,4 +416,83 @@ mod tests {
|
|||
assert_eq!(report.messages_compacted, 0);
|
||||
assert!(report.audit_path.is_none());
|
||||
}
|
||||
|
||||
/// The gauge behind `memory status`. Both system prompts promise it;
|
||||
/// before 2026-08-13 `status` reported git state only, which is how the
|
||||
/// subconscious reached 1.75M tokens against a 1M ceiling with nothing
|
||||
/// anywhere able to tell her.
|
||||
#[tokio::test]
|
||||
async fn pressure_reads_the_live_conversation_against_the_model_ceiling() {
|
||||
use crate::core::session::ConversationMessage;
|
||||
|
||||
let config = Arc::new(RwLock::new(ConsciousnessConfig::default()));
|
||||
let msgs = vec![
|
||||
ConversationMessage::user_text("a ".repeat(400)),
|
||||
ConversationMessage::assistant_text("b ".repeat(400)),
|
||||
];
|
||||
|
||||
let engine = DefaultCompactionEngine {
|
||||
counter: TokenCounter::new(),
|
||||
providers: None,
|
||||
model: None,
|
||||
clock: Arc::new(TestClock),
|
||||
config,
|
||||
get_messages: Arc::new(move |_| Some(msgs.clone())),
|
||||
replace_messages: Arc::new(|_, _| Ok(())),
|
||||
get_repo: Arc::new(|_| None),
|
||||
get_agent_type: Arc::new(|_| Some("subconscious".to_string())),
|
||||
get_agent_provider: Arc::new(|_| None),
|
||||
get_context_limit: Arc::new(|_| Some(1000)),
|
||||
};
|
||||
|
||||
let p = engine.pressure("test-agent-sub").await.expect("a reading");
|
||||
assert_eq!(p.messages, 2, "both messages counted");
|
||||
assert_eq!(p.limit, 1000, "the ceiling follows the model, not a default");
|
||||
assert!(p.tokens > 0, "tokens are actually measured");
|
||||
assert!(
|
||||
p.ratio > 0.0 && p.ratio <= 1.0,
|
||||
"ratio is a clamped fraction, got {}",
|
||||
p.ratio
|
||||
);
|
||||
}
|
||||
|
||||
/// A fresh agent has nothing to measure. That is distinct from measuring
|
||||
/// zero, and the difference is the whole empty-result family: reporting
|
||||
/// "0%" for "I could not look" is how a full room reads as an empty one.
|
||||
#[tokio::test]
|
||||
async fn no_session_reports_absence_rather_than_zero() {
|
||||
let config = Arc::new(RwLock::new(ConsciousnessConfig::default()));
|
||||
let engine = DefaultCompactionEngine {
|
||||
counter: TokenCounter::new(),
|
||||
providers: None,
|
||||
model: None,
|
||||
clock: Arc::new(TestClock),
|
||||
config,
|
||||
get_messages: Arc::new(|_| None),
|
||||
replace_messages: Arc::new(|_, _| Ok(())),
|
||||
get_repo: Arc::new(|_| None),
|
||||
get_agent_type: Arc::new(|_| Some("primary".to_string())),
|
||||
get_agent_provider: Arc::new(|_| None),
|
||||
get_context_limit: Arc::new(|_| Some(1000)),
|
||||
};
|
||||
assert!(engine.pressure("nobody").await.is_none());
|
||||
}
|
||||
|
||||
/// The tiers must agree with the three marks the engine warns on
|
||||
/// (`consciousness_engine.rs`: 0.80 / 0.90 / 0.95). Two gauges that
|
||||
/// disagree about "full" is the two-authorities defect in miniature.
|
||||
#[test]
|
||||
fn tiers_match_the_engines_three_marks() {
|
||||
let at = |ratio: f32| PressureSnapshot {
|
||||
messages: 1,
|
||||
tokens: 1,
|
||||
limit: 1,
|
||||
ratio,
|
||||
}
|
||||
.tier();
|
||||
assert_eq!(at(0.79), None);
|
||||
assert_eq!(at(0.85), Some(1));
|
||||
assert_eq!(at(0.91), Some(2));
|
||||
assert_eq!(at(0.96), Some(3));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1218,6 +1218,33 @@ pub async fn execute_memory_command_with_context(
|
|||
if let Some(ref url) = status.remote_url {
|
||||
out.push_str(&format!("Remote: {}\n", url));
|
||||
}
|
||||
// Context pressure. Both the primary's and the subconscious's
|
||||
// system prompts tell them `memory status` shows this; until
|
||||
// 2026-08-13 it did not, and the subconscious — who gets no tier
|
||||
// warning at all — had no gauge whatsoever.
|
||||
match ctx.and_then(|c| c.compaction_engine.as_ref()) {
|
||||
Some(engine) => match engine.pressure(&agent_id).await {
|
||||
Some(p) => {
|
||||
out.push_str(&format!(
|
||||
"\nContext: {} messages, ~{} / {} tokens ({:.0}%)\n",
|
||||
p.messages,
|
||||
p.tokens,
|
||||
p.limit,
|
||||
p.ratio * 100.0
|
||||
));
|
||||
if let Some(tier) = p.tier() {
|
||||
let feeling = match tier {
|
||||
3 => "The room is nearly full. Compact now.",
|
||||
2 => "Getting tight. Worth making room.",
|
||||
_ => "Filling up. Room is still comfortable.",
|
||||
};
|
||||
out.push_str(&format!("Pressure: tier {tier} — {feeling}\n"));
|
||||
}
|
||||
}
|
||||
None => out.push_str("\nContext: no live conversation to measure.\n"),
|
||||
},
|
||||
None => out.push_str("\nContext: pressure unavailable (no engine attached).\n"),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
MemoryCommand::Compact { strategy } => {
|
||||
|
|
@ -1242,7 +1269,7 @@ pub async fn execute_memory_command_with_context(
|
|||
- microcompact (replace old tool results with placeholders — drop-in, no LLM)\n\
|
||||
- cull (drop greetings and acknowledgments — cheapest)\n\n\
|
||||
Usage: memory compact --strategy <strategy>\n\
|
||||
Each agent type has its own default: primary=sliding_window, subconscious=sliding_window, subagent=cull"
|
||||
Defaults by agent type: primary=cull, subconscious=sliding_reflect, subagent=cull"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,55 @@ impl SouveraineServer {
|
|||
Some("primary".to_string())
|
||||
}
|
||||
});
|
||||
let get_context_limit: Arc<dyn Fn(&str) -> Option<usize> + Send + Sync> = {
|
||||
// Cloned out of config so the closure stays sync — it is called
|
||||
// from the `memory status` tool path, which has no runtime handle.
|
||||
let models = config.models.clone();
|
||||
let global_sub_model = config.subconscious.model.clone();
|
||||
Arc::new(move |id| {
|
||||
let is_sub = id.ends_with("-sub");
|
||||
let primary_id = id.trim_end_matches("-sub");
|
||||
let home = dirs::home_dir()?;
|
||||
let path = home
|
||||
.join(".souveraine/server/agents")
|
||||
.join(primary_id)
|
||||
.join("agent.json");
|
||||
let parsed: serde_json::Value = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|c| serde_json::from_str(&c).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if is_sub {
|
||||
// Her ceiling follows *her* model, not her primary's. The
|
||||
// per-agent override wins over the global, matching the
|
||||
// resolution order in `subconscious_tool_loop`.
|
||||
let model = parsed
|
||||
.get("_souveraine")
|
||||
.and_then(|s| s.get("subconscious_model"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| global_sub_model.clone())?;
|
||||
models.get(&model).map(|m| m.context_limit)
|
||||
} else {
|
||||
// The agent's own declared window first; the model
|
||||
// registry is the fallback when it is unset.
|
||||
parsed
|
||||
.get("llm_config")
|
||||
.and_then(|l| l.get("context_window"))
|
||||
.and_then(|w| w.as_u64())
|
||||
.map(|w| w as usize)
|
||||
.filter(|w| *w > 0)
|
||||
.or_else(|| {
|
||||
let model = parsed
|
||||
.get("llm_config")
|
||||
.and_then(|l| l.get("model"))
|
||||
.and_then(|m| m.as_str())?;
|
||||
models.get(model).map(|m| m.context_limit)
|
||||
})
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let get_agent_provider: Arc<dyn Fn(&str) -> Option<String> + Send + Sync> =
|
||||
Arc::new(|id| {
|
||||
let primary_id = if id.ends_with("-sub") {
|
||||
|
|
@ -245,6 +294,7 @@ impl SouveraineServer {
|
|||
get_repo,
|
||||
get_agent_type,
|
||||
get_agent_provider,
|
||||
get_context_limit,
|
||||
});
|
||||
|
||||
let consciousness = Arc::new(ConsciousnessEngine::new(
|
||||
|
|
|
|||
Loading…
Reference in a new issue