@@ -92,8 +92,8 @@ use subc_protocol::{
9292use transform::ReductionDecision;
9393use transform::{transform_with_projection, DeclaredTrim, HistorianDiagnostics, TransformRequest};
9494
95- /// The per-route binding: the project, harness, session-slot value, and render budget
96- /// frozen at bind. Transform routes carry the durable session in `session`; MCP facade
95+ /// The per-route binding: the project, harness, session-slot value, and fallback render
96+ /// budget frozen at bind. Transform routes carry the durable session in `session`; MCP facade
9797/// routes carry an instance token there and must resolve it before touching the store.
9898/// The project is NEVER taken from a per-pass request field — a crafted request could
9999/// spoof it to read another project's memories — so it lives here, keyed by the route
@@ -105,10 +105,8 @@ pub struct SessionBinding {
105105 pub session: String,
106106 pub model_key: Option<String>,
107107 pub config: McModuleConfig,
108- /// The history budget (tokens) FROZEN at bind. Byte-affecting (a different budget → a
109- /// different m0 trim → different bytes), so it's read once and never per-pass. A
110- /// default for now (reading it from config is a later refinement); the freeze-once is
111- /// the load-bearing part — it can't change mid-session.
108+ /// The fallback history budget (tokens) frozen at bind. A transform request may carry
109+ /// a newer harness-resolved value because config can change while the route remains open.
112110 pub history_budget_tokens: f64,
113111}
114112
@@ -4113,6 +4111,19 @@ impl McHandler {
41134111 "inactive"
41144112 };
41154113 let historian = historian_status_summary(&loaded.meta.historian);
4114+ // When the Rust module is active, it manages the frozen m0 in its own store
4115+ // instead of the harness SQLite cache. Report the exact session-history slice so
4116+ // status attribution does not estimate size by summing all raw-history p1 rows.
4117+ let compartment_tokens = loaded
4118+ .core
4119+ .frozen_units
4120+ .iter()
4121+ .find(|unit| unit.key == "m0")
4122+ .and_then(|unit| {
4123+ decay_render::extract_m0_block(&unit.frozen_payload, "session-history")
4124+ })
4125+ .map(|block| mc_tokenizer::estimate_tokens(&block))
4126+ .unwrap_or(0);
41164127 let newest_pass_at = pass_trace
41174128 .as_ref()
41184129 .map(|trace| {
@@ -4152,6 +4163,7 @@ impl McHandler {
41524163 "row_version": loaded.row_version,
41534164 "boundary_present": !loaded.core.boundary_id.trim().is_empty(),
41544165 "compartment_count": compartment_count,
4166+ "compartment_tokens": compartment_tokens,
41554167 "pending_drop_count": pending_drop_count,
41564168 "usage": {
41574169 "current_total_input_tokens": loaded.meta.last_usage.as_ref().map_or(0, |usage| usage.current_total_input_tokens),
@@ -5174,7 +5186,13 @@ impl McHandler {
51745186 let producer_ctx = transform::ProducerContext {
51755187 project_path: &project_path,
51765188 project_directory: &project_path,
5177- history_budget_tokens: binding.history_budget_tokens,
5189+ // The authority adapter resolves this from the model context limit and
5190+ // sends it on each pass. Keep the bind-time value only for older callers
5191+ // that omit the field, and reject unusable values without disabling decay.
5192+ history_budget_tokens: parsed
5193+ .history_budget_tokens
5194+ .filter(|budget| budget.is_finite() && *budget >= 0.0)
5195+ .unwrap_or(binding.history_budget_tokens),
51785196 memory_enabled: binding.config.memory_enabled,
51795197 now_ms: pass_now,
51805198 execute_threshold_percentage: binding.config.execute_threshold_percentage,
@@ -6494,6 +6512,7 @@ impl McHandler {
64946512 mid_turn: parsed.pass_inputs.mid_turn,
64956513 prev_response_completed_at_ms: None,
64966514 request_observed_at_ms: None,
6515+ history_budget_tokens: None,
64976516 declared_trim: parsed.declared_trim.clone(),
64986517 };
64996518 let shadow_project = shadow_project_path(&binding.session);
@@ -7100,9 +7119,8 @@ impl ModuleHandler for McHandler {
71007119 session: req.identity.session.clone(),
71017120 model_key: None,
71027121 config,
7103- // Frozen at bind. Currently a default constant (reading it from config is a
7104- // later refinement); the load-bearing part is the freeze-once — a different
7105- // budget would change the rendered m0 bytes, so it can't move mid-session.
7122+ // Older callers may omit the per-pass budget. Keep a safe fallback on the
7123+ // route, while authority requests carry the harness-resolved value.
71067124 history_budget_tokens: memory_render::DEFAULT_HISTORY_BUDGET_TOKENS,
71077125 },
71087126 );
@@ -11992,6 +12010,42 @@ mod tests {
1199212010 .to_string()
1199312011 }
1199412012
12013+ #[tokio::test(flavor = "current_thread")]
12014+ async fn authority_transform_uses_request_history_budget_on_hard() {
12015+ let producer = Arc::new(ProducerState::default());
12016+ let (handler, store, _dir, _project) =
12017+ handler_with_store(Arc::clone(&producer), default_test_config());
12018+ store
12019+ .replace_compartments(
12020+ "ses",
12021+ &[
12022+ stored_comp(1, 1, 40, "m40", &"OLD ".repeat(200)),
12023+ stored_comp(2, 41, 80, "m80", &"NEW ".repeat(200)),
12024+ ],
12025+ )
12026+ .unwrap();
12027+ let mut request = request(big_messages());
12028+ request["history_budget_tokens"] = json!(300.0);
12029+
12030+ let response = call_transform_request(&handler, request).await;
12031+ assert_eq!(response["action"], "HARD");
12032+ let m0 = m0_text(&response);
12033+ assert!(m0.contains("NEW"), "newest compartment remains at P1: {m0}");
12034+ assert!(
12035+ !m0.contains("OLD"),
12036+ "request budget must reach the HARD decay renderer: {m0}"
12037+ );
12038+ let status = tool_body(handler.handle_session_status_value(
12039+ 7,
12040+ &json!({ "method": "session.status", "v": 1, "session_id": "ses" }),
12041+ ));
12042+ let history = decay_render::extract_m0_block(&m0, "session-history").unwrap();
12043+ assert_eq!(
12044+ status["compartment_tokens"],
12045+ json!(mc_tokenizer::estimate_tokens(&history))
12046+ );
12047+ }
12048+
1199512049 #[tokio::test(flavor = "current_thread")]
1199612050 async fn handler_full_autonomous_cycle_fires_publishes_and_next_pass_folds() {
1199712051 let producer = Arc::new(ProducerState::default());
0 commit comments