diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index c4a472ca9d..a13ff2192c 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -767,7 +767,7 @@ fn enable_context_engine_config(existing: &str) -> std::result::Result { + None | Some("compressor" | "tokensave") => { lines[engine_line] = " engine: tracedecay".to_string(); } Some("tracedecay") => {} @@ -3368,25 +3368,24 @@ def _translate_lcm_args(native_name: str, args: dict) -> dict: _ENGINE_DEFAULT_SESSION = "__default__" +def _content_has_text(content) -> bool: + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return any(_content_has_text(item) for item in content) + if isinstance(content, dict): + for key in ("text", "content"): + if _content_has_text(content.get(key)): + return True + return False + +def _message_has_compressible_content(message) -> bool: + return isinstance(message, dict) and _content_has_text(message.get("content")) + def _has_compressible_message_content(messages) -> bool: if not isinstance(messages, list): return False - - def _content_has_text(content) -> bool: - if isinstance(content, str): - return bool(content.strip()) - if isinstance(content, list): - return any(_content_has_text(item) for item in content) - if isinstance(content, dict): - for key in ("text", "content"): - if _content_has_text(content.get(key)): - return True - return False - - for message in messages: - if isinstance(message, dict) and _content_has_text(message.get("content")): - return True - return False + return any(_message_has_compressible_content(message) for message in messages) class _EngineSessionState: """Mutable per-conversation engine state. @@ -3718,14 +3717,15 @@ class TraceDecayContextEngine(ContextEngine): def has_content_to_compress(self, messages, current_tokens=None, **kwargs): """Optional Hermes hook: conservatively probe only eligible content.""" del current_tokens, kwargs - if not _has_compressible_message_content(messages): + if not isinstance(messages, list): return False - compressible = [ - message - for message in messages - if isinstance(message, dict) and _has_compressible_message_content([message]) - ] - return len(compressible) > 1 + compressible = 0 + for message in messages: + if _message_has_compressible_content(message): + compressible += 1 + if compressible > 1: + return True + return False def should_defer_preflight_to_real_usage(self, rough_tokens=None, **kwargs): """Suppress rough-token retry loops until post-compression usage arrives.""" diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index 835da553d4..4af09bb997 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -123,14 +123,7 @@ fn lcm_compress_tool_json(project_root: Option<&Path>, value: &Value) -> ToolRes if compact_text.len() <= MAX_RESPONSE_CHARS { compact_text } else { - let minimal = compact_lcm_compress_payload(value, formatted.len(), true, 8, 512); - let minimal_text = serde_json::to_string_pretty(&minimal).unwrap_or_default(); - if minimal_text.len() <= MAX_RESPONSE_CHARS { - minimal_text - } else { - let floor = compact_lcm_compress_payload(value, formatted.len(), true, 1, 64); - bounded_lcm_contract_text(&floor) - } + truncated_json_envelope_with_handle(project_root, &formatted) } } else { truncated_json_envelope_with_handle(project_root, &formatted) @@ -215,20 +208,17 @@ fn compact_lcm_compress_payload( compact_request.insert(key.to_string(), field.clone()); } } - let (source_messages, source_truncated, source_compacted) = compact_replay_messages( - summary_request.get("source_messages"), - replay_limit, - replay_content_chars, - ); - compact_request.insert("source_messages".to_string(), source_messages); - compact_request.insert( - "source_messages_truncated_for_mcp".to_string(), - json!(source_truncated), - ); - compact_request.insert( - "source_messages_compacted_for_mcp".to_string(), - json!(source_compacted), - ); + if let Some(field) = summary_request.get("source_messages") { + compact_request.insert("source_messages".to_string(), field.clone()); + compact_request.insert( + "source_messages_truncated_for_mcp".to_string(), + json!(false), + ); + compact_request.insert( + "source_messages_compacted_for_mcp".to_string(), + json!(false), + ); + } compact_request.insert("prompt_omitted_for_mcp".to_string(), json!(true)); compact_request.insert( "extraction_request_omitted_for_mcp".to_string(), diff --git a/tests/mcp_handler_test.rs b/tests/mcp_handler_test.rs index 0c4e5242e1..6d71198c67 100644 --- a/tests/mcp_handler_test.rs +++ b/tests/mcp_handler_test.rs @@ -5478,7 +5478,7 @@ async fn lcm_compress_oversized_needs_summary_preserves_bridge_contract() { "provider": "cursor", "session_id": "lcm-oversized-needs-summary", "messages": [ - {"id": "oversized-1", "role": "user", "content": huge_source}, + {"id": "oversized-1", "role": "user", "content": huge_source.clone()}, {"id": "oversized-2", "role": "assistant", "content": "acknowledged"}, {"id": "oversized-3", "role": "user", "content": "latest objective"} ], @@ -5495,7 +5495,26 @@ async fn lcm_compress_oversized_needs_summary_preserves_bridge_contract() { .unwrap(); let text = extract_text(&compress.value); - let payload: Value = serde_json::from_str(text).unwrap(); + let envelope: Value = serde_json::from_str(text).unwrap(); + assert_eq!(envelope["truncated"], true); + assert_eq!(envelope["retrieve_tool"], "tracedecay_retrieve"); + assert!(text.len() <= 15_000); + + let handle = envelope["handle"] + .as_str() + .expect("oversized needs-summary payload should include retrieve handle"); + let retrieved = handle_tool_call( + &cg, + "tracedecay_retrieve", + json!({ "handle": handle }), + None, + None, + ) + .await + .unwrap(); + let retrieved_payload: Value = serde_json::from_str(extract_text(&retrieved.value)).unwrap(); + let payload: Value = + serde_json::from_str(retrieved_payload["content"].as_str().unwrap()).unwrap(); assert_eq!(payload["status"], "needs_summary"); assert_eq!(payload["reason"], "hermes_auxiliary_not_available"); assert!( @@ -5508,20 +5527,13 @@ async fn lcm_compress_oversized_needs_summary_preserves_bridge_contract() { payload["summary_request"].is_object(), "bridge must retain summary request metadata, got {payload:#}" ); - assert_eq!(payload["mcp_response_truncated"], true); - assert_eq!(payload["contract_truncated"], true); - assert!(payload.get("truncated").is_none()); - assert!(extract_text(&compress.value).len() <= 15_000); let source_messages = payload["summary_request"]["source_messages"] .as_array() - .expect("compact needs-summary payload should retain bounded source messages"); + .expect("retrieved needs-summary payload should retain exact source messages"); assert!(!source_messages.is_empty()); - assert!(source_messages[0]["content"] - .as_str() - .is_some_and(|content| content.len() <= 512)); assert_eq!( - payload["summary_request"]["source_messages_compacted_for_mcp"], - true + source_messages[0]["content"].as_str(), + Some(huge_source.as_str()) ); }