Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-13 09:35:01 -04:00
commit 877cad006d
3 changed files with 217 additions and 1 deletions

View file

@ -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));
}
}

View file

@ -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(),
),
}

View file

@ -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(