diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index 506c1a755..88c03d81a 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -296,6 +296,18 @@ def _resolve_auxiliary_client(agent=None): "tracedecay_lcm_preflight", )) +STANDARD_HERMES_LCM_PROVIDER = "cursor" + +LCM_PROVIDER_LOCAL_TOOL_NAMES = frozenset(( + "tracedecay_lcm_compress", + "tracedecay_lcm_describe", + "tracedecay_lcm_doctor", + "tracedecay_lcm_expand", + "tracedecay_lcm_expand_query", + "tracedecay_lcm_preflight", + "tracedecay_lcm_session_boundary", +)) + # Direct duplicates of the memory provider's own tool surface # (fact_store / fact_feedback / memory_status). Skipped at register() time # when tracedecay is the active memory.provider so the same store is not @@ -1858,7 +1870,9 @@ def _synthesize_expand_query_payload(retrieval, agent=None, **kwargs): def _handle_lcm_expand_query(args, **kwargs) -> str: kwargs = dict(kwargs) agent = kwargs.pop("agent", None) - retrieval = call_tracedecay_json("tracedecay_lcm_expand_query", args or {}, **kwargs) + args = dict(args or {}) + args.setdefault("provider", STANDARD_HERMES_LCM_PROVIDER) + retrieval = call_tracedecay_json("tracedecay_lcm_expand_query", args, **kwargs) payload = _synthesize_expand_query_payload(retrieval, agent=agent, **kwargs) return json.dumps(payload) @@ -2219,6 +2233,7 @@ def _report_compression_boundary(self, session_id, bound_session_id, kwargs): return args = _storage_args(self.project_root, self.hermes_home) args.update({ + "provider": STANDARD_HERMES_LCM_PROVIDER, "session_id": session_id, "old_session_id": old_session_id, "boundary_reason": boundary_reason, @@ -2261,6 +2276,7 @@ def _preflight_probe(self, messages, current_tokens=None, **kwargs): ) ) args.update({ + "provider": STANDARD_HERMES_LCM_PROVIDER, "session_id": self.active_session_id, "messages": messages, "current_tokens": current_tokens, @@ -2469,6 +2485,8 @@ def handle_tool_call(self, name, arguments=None, **kwargs) -> str: storage_args = _storage_args(self.project_root, self.hermes_home) for key, value in storage_args.items(): tool_args.setdefault(key, value) + if tracedecay_name in LCM_PROVIDER_LOCAL_TOOL_NAMES: + tool_args.setdefault("provider", STANDARD_HERMES_LCM_PROVIDER) if tracedecay_name == "tracedecay_lcm_compress" and self.project_root: tool_args.setdefault("response_handle_project_root", self.project_root) if native_name in ("lcm_status", "lcm_doctor"): @@ -2485,6 +2503,7 @@ def handle_tool_call(self, name, arguments=None, **kwargs) -> str: def expand_query(self, prompt, query=None, node_ids=None, **kwargs): kwargs = dict(kwargs) args = self._tool_args(kwargs.pop("session_id", None)) + args["provider"] = STANDARD_HERMES_LCM_PROVIDER args["prompt"] = prompt if query is not None: args["query"] = query @@ -2903,6 +2922,7 @@ def _compress_to_result(self, messages, current_tokens=None, focus_topic=None, * ) ) args.update({ + "provider": STANDARD_HERMES_LCM_PROVIDER, "messages": messages, "current_tokens": current_tokens, "focus_topic": focus_topic, @@ -3199,7 +3219,11 @@ def sync_turn(self, user_content, assistant_content, *, session_id="", messages= role = str(entry.get("role") or "user") entry["id"] = f"tracedecay_sync_{batch_id}_{timestamp_ns}_{idx}_{role}" args = _storage_args(self.project_root, self.hermes_home) - args.update({"session_id": sid, "messages": turn_messages}) + args.update({ + "provider": STANDARD_HERMES_LCM_PROVIDER, + "session_id": sid, + "messages": turn_messages, + }) try: tools.call_tracedecay_tool( "tracedecay_lcm_preflight", diff --git a/src/mcp/tools/definitions.rs b/src/mcp/tools/definitions.rs index cfa964e21..30c4e506c 100644 --- a/src/mcp/tools/definitions.rs +++ b/src/mcp/tools/definitions.rs @@ -2392,7 +2392,7 @@ fn def_lcm_status() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id to inspect (default: cursor)." + "description": "Optional provider id. Omit or use 'all' to inspect all providers." }, "session_id": { "type": "string", @@ -2420,7 +2420,7 @@ fn def_lcm_doctor() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id to inspect (default: cursor)." + "description": "Specific provider id to inspect or repair. Required; 'all' is not accepted for this lifecycle tool." }, "session_id": { "type": "string", @@ -2463,7 +2463,8 @@ fn def_lcm_doctor() -> ToolDefinition { "storage_scope": lcm_storage_scope_schema(), "hermes_home": lcm_hermes_home_schema() }, - "allOf": lcm_storage_scope_requires_hermes_home() + "allOf": lcm_storage_scope_requires_hermes_home(), + "required": ["provider"] }), ) } @@ -2478,7 +2479,7 @@ fn def_lcm_load_session() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id, default cursor." + "description": "Optional provider id. Omit or use 'all' to load messages for this session id across all providers." }, "session_id": { "type": "string", @@ -2616,7 +2617,7 @@ fn def_lcm_describe() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id, default cursor." + "description": "Specific provider id. Required because describe targets are provider-local." }, "session_id": { "type": "string", @@ -2644,7 +2645,7 @@ fn def_lcm_describe() -> ToolDefinition { "hermes_home": lcm_hermes_home_schema() }, "allOf": lcm_storage_scope_requires_hermes_home(), - "required": ["session_id"] + "required": ["provider", "session_id"] }), ) } @@ -2659,7 +2660,7 @@ fn def_lcm_expand() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id, default cursor." + "description": "Specific provider id. Required because expansion targets are provider-local." }, "session_id": { "type": "string", @@ -2714,7 +2715,7 @@ fn def_lcm_expand() -> ToolDefinition { "hermes_home": lcm_hermes_home_schema() }, "allOf": lcm_storage_scope_requires_hermes_home(), - "required": ["session_id", "target"] + "required": ["provider", "session_id", "target"] }), ) } @@ -2729,7 +2730,7 @@ fn def_lcm_expand_query() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id, default cursor." + "description": "Specific provider id. Required because retrieval context is provider-local." }, "session_id": { "type": "string", @@ -2775,7 +2776,7 @@ fn def_lcm_expand_query() -> ToolDefinition { "hermes_home": lcm_hermes_home_schema() }, "allOf": lcm_storage_scope_requires_hermes_home(), - "required": ["session_id", "prompt"] + "required": ["provider", "session_id", "prompt"] }), ) } @@ -2790,7 +2791,7 @@ fn def_lcm_preflight() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id, default cursor." + "description": "Specific provider id. Required for compression lifecycle operations." }, "session_id": { "type": "string", @@ -2865,7 +2866,8 @@ fn def_lcm_preflight() -> ToolDefinition { "storage_scope": lcm_storage_scope_schema(), "hermes_home": lcm_hermes_home_schema() }, - "allOf": lcm_storage_scope_requires_hermes_home() + "allOf": lcm_storage_scope_requires_hermes_home(), + "required": ["provider", "session_id"] }), ) } @@ -2880,7 +2882,7 @@ fn def_lcm_compress() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id, default cursor." + "description": "Specific provider id. Required for compression lifecycle operations." }, "session_id": { "type": "string", @@ -2978,7 +2980,7 @@ fn def_lcm_compress() -> ToolDefinition { "hermes_home": lcm_hermes_home_schema() }, "allOf": lcm_storage_scope_requires_hermes_home(), - "required": ["session_id"] + "required": ["provider", "session_id"] }), ) } @@ -2993,7 +2995,7 @@ fn def_lcm_session_boundary() -> ToolDefinition { "properties": { "provider": { "type": "string", - "description": "Provider id, default cursor." + "description": "Specific provider id. Required for compression lifecycle operations." }, "session_id": { "type": "string", @@ -3015,7 +3017,7 @@ fn def_lcm_session_boundary() -> ToolDefinition { "hermes_home": lcm_hermes_home_schema() }, "allOf": lcm_storage_scope_requires_hermes_home(), - "required": ["session_id"] + "required": ["provider", "session_id"] }), ) } diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index 525adb481..b66ffdd51 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -811,8 +811,18 @@ fn timestamp_argument_error(name: &str) -> TraceDecayError { )) } -fn provider_arg(args: &Value) -> &str { - string_arg(args, "provider").unwrap_or("cursor") +fn provider_or_all_arg(args: &Value) -> &str { + optional_search_provider_arg(args).unwrap_or("all") +} + +fn required_specific_provider_arg(args: &Value) -> Result<&str> { + match string_arg(args, "provider") { + Some("all") => Err(argument_error( + "provider must name a specific provider for this tool", + )), + Some(provider) => Ok(provider), + None => Err(argument_error("provider is required for this tool")), + } } fn optional_search_provider_arg(args: &Value) -> Option<&str> { @@ -1270,15 +1280,10 @@ fn parse_lcm_scope(args: &Value) -> Result { } } -fn lcm_grep_provider_arg(args: &Value, scope: LcmScope) -> &str { +fn lcm_grep_provider_arg(args: &Value) -> &str { if let Some(provider) = optional_search_provider_arg(args) { return provider; } - if matches!(scope, LcmScope::Current | LcmScope::Session) - && string_arg(args, "provider").is_none() - { - return provider_arg(args); - } "all" } @@ -1507,7 +1512,7 @@ pub(super) async fn handle_lcm_status( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = provider_or_all_arg(&args); let session_id = string_arg(&args, "session_id"); let deep = bool_arg(&args, "deep")?.unwrap_or(false); let gc_config = lcm_gc_config(&args)?; @@ -1534,7 +1539,7 @@ pub(super) async fn handle_lcm_doctor( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = required_specific_provider_arg(&args)?; let session_id = string_arg(&args, "session_id"); let mode = lcm_doctor_mode(&args)?; let apply = args.get("apply").and_then(Value::as_bool).unwrap_or(false); @@ -1629,7 +1634,7 @@ pub(super) async fn handle_lcm_load_session( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = provider_or_all_arg(&args); let session_id = required_string_arg(&args, "session_id")?; let (content_slice, content_limit_clamped_from) = lcm_load_content_slice(&args)?; let storage = lcm_open_storage_ro!(context, &args); @@ -1682,7 +1687,7 @@ pub(super) async fn handle_lcm_grep( // Validate scope before opening storage so argument errors are reported // even when the sessions DB does not exist yet. let scope = parse_lcm_scope(&args)?; - let provider = lcm_grep_provider_arg(&args, scope); + let provider = lcm_grep_provider_arg(&args); let storage = lcm_open_storage_ro!(context, &args); let hits = storage .db @@ -1721,7 +1726,7 @@ pub(super) async fn handle_lcm_describe( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = required_specific_provider_arg(&args)?; let session_id = required_string_arg(&args, "session_id")?; // Validate target before opening storage so argument errors are reported // even when the sessions DB does not exist yet. @@ -1751,7 +1756,7 @@ pub(super) async fn handle_lcm_expand( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = required_specific_provider_arg(&args)?; let session_id = required_string_arg(&args, "session_id")?; let target = parse_lcm_expand_target(&args)?; let storage = lcm_open_storage_ro!(context, &args); @@ -1782,7 +1787,7 @@ pub(super) async fn handle_lcm_expand_query( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = required_specific_provider_arg(&args)?; let session_id = required_string_arg(&args, "session_id")?; let prompt = required_string_arg(&args, "prompt")?; let max_results = @@ -1835,7 +1840,7 @@ pub(super) async fn handle_lcm_session_boundary( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = required_specific_provider_arg(&args)?; let session_id = required_string_arg(&args, "session_id")?; let storage = lcm_open_storage!(context, &args); let response = storage @@ -1866,7 +1871,7 @@ pub(super) async fn handle_lcm_preflight( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = required_specific_provider_arg(&args)?; let session_id = required_string_arg(&args, "session_id")?; let storage = lcm_open_storage!(context, &args); let response = storage @@ -1907,7 +1912,7 @@ pub(super) async fn handle_lcm_compress( context: LcmHandlerContext<'_>, args: Value, ) -> Result { - let provider = provider_arg(&args); + let provider = required_specific_provider_arg(&args)?; let session_id = required_string_arg(&args, "session_id")?; let response_handle_root = lcm_response_handle_root(context.project_root, &args); let storage = lcm_open_storage!(context, &args); diff --git a/src/sessions/lcm/query.rs b/src/sessions/lcm/query.rs index 169773d18..13f3bb2b3 100644 --- a/src/sessions/lcm/query.rs +++ b/src/sessions/lcm/query.rs @@ -99,6 +99,7 @@ pub(crate) async fn load_session( let limit = clamp_limit(request.limit); let fetch_limit = limit.saturating_add(1); let mut values = vec![ + Value::Text(request.provider.clone()), Value::Text(request.provider.clone()), Value::Text(request.session_id.clone()), Value::Integer(request.after_store_id.unwrap_or(0)), @@ -124,7 +125,7 @@ pub(crate) async fn load_session( timestamp, content, content_hash, storage_kind, payload_ref, snippet_text, legacy_source, legacy_truncated, metadata_json FROM lcm_raw_messages - WHERE provider = ? + WHERE (? = 'all' OR provider = ?) AND session_id = ? AND store_id > ? {role_clause} diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index cfa3437be..c9b575e2f 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -1152,7 +1152,7 @@ fn test_hermes_plugin_init_snapshot_matches_embedded_asset() { hasher.update(body.as_bytes()); assert_eq!( hex::encode(hasher.finalize()), - "a622c197db57fd32c5375c2345cf61dda52863b741b32d1921c59777812310c7", + "fe9f53b0721f9080ceb0fd0b16227efd66f550e1fca11a07b25abf8489e8435c", "templates/plugin_init.py payload hash changed — verify the edit is intentional and update this snapshot" ); } diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index 6b371d16e..bd9c29b07 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -1231,6 +1231,7 @@ args_index = argv.index("--args") args = json.loads(argv[args_index + 1]) assert args == { "project_root": "/tmp/project", + "provider": "cursor", "fresh_tail_count": 64, "leaf_chunk_tokens": 20000, "dynamic_leaf_chunk_enabled": False, @@ -1315,6 +1316,7 @@ assert argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_sessio args = json.loads(argv[argv.index("--args") + 1]) assert args == { "project_root": "/tmp/project", + "provider": "cursor", "session_id": "session-b", "old_session_id": "session-c", "boundary_reason": "compression", @@ -1423,6 +1425,7 @@ else: assert args == { "project_root": "/tmp/project", "response_handle_project_root": "/tmp/project", + "provider": "cursor", "fresh_tail_count": 64, "leaf_chunk_tokens": 20000, "dynamic_leaf_chunk_enabled": False, diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 9520149e6..254a55182 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -1330,12 +1330,16 @@ async fn schema_required_arguments_match_representative_handler_parsers() { .await; // Nested-object parser style. - assert_schema_requires(&tools, "tracedecay_lcm_expand", &["session_id", "target"]); + assert_schema_requires( + &tools, + "tracedecay_lcm_expand", + &["provider", "session_id", "target"], + ); assert_nested_schema_requires(&tools, "tracedecay_lcm_expand", &["target"], &["kind"]); expect_missing_argument_error( &cg, "tracedecay_lcm_expand", - json!({ "session_id": "session-1", "target": {} }), + json!({ "provider": "cursor", "session_id": "session-1", "target": {} }), "target.kind must be one of raw_message, summary_node, external_payload", ) .await; @@ -1443,6 +1447,10 @@ fn lcm_tool_schemas_are_registered_with_stable_names() { .find(|tool| tool.name == "tracedecay_lcm_load_session") .expect("tracedecay_lcm_load_session definition"); assert_eq!(load.input_schema["required"], json!(["session_id"])); + assert!(load.input_schema["properties"]["provider"]["description"] + .as_str() + .unwrap() + .contains("across all providers")); assert!(load.input_schema["properties"] .get("content_limit") .is_some()); @@ -1482,7 +1490,7 @@ fn lcm_tool_schemas_are_registered_with_stable_names() { .expect("tracedecay_lcm_expand definition"); assert_eq!( expand.input_schema["required"], - json!(["session_id", "target"]) + json!(["provider", "session_id", "target"]) ); assert!(expand.input_schema["properties"].get("target").is_some()); assert_eq!( @@ -4365,6 +4373,7 @@ async fn lcm_project_root_storage_arg_is_not_rejected_as_selector() { &cg, "tracedecay_lcm_preflight", json!({ + "provider": "cursor", "session_id": "stock-check-session", "project_root": project_root, "messages": [ @@ -4393,6 +4402,7 @@ async fn lcm_project_path_selector_is_rejected_before_dispatch() { &cg, "tracedecay_lcm_preflight", json!({ + "provider": "cursor", "session_id": "stock-check-session", "project_path": project_path, "messages": [ @@ -8975,17 +8985,41 @@ async fn lcm_session_handlers_expose_bounded_read_apis_and_placeholders() { let scoped_default_provider_grep_payload: Value = serde_json::from_str(extract_text(&scoped_default_provider_grep.value)).unwrap(); assert_eq!(scoped_default_provider_grep_payload["status"], "ok"); - assert_eq!(scoped_default_provider_grep_payload["provider"], "cursor"); - assert_eq!(scoped_default_provider_grep_payload["count"], 1); + assert_eq!(scoped_default_provider_grep_payload["provider"], "all"); + assert_eq!(scoped_default_provider_grep_payload["count"], 2); assert_eq!( scoped_default_provider_grep_payload["hits"][0]["provider"], - "cursor" + "codex" ); assert_eq!( - scoped_default_provider_grep_payload["hits"][0]["message_id"], - "cursor-provider-local-message" + scoped_default_provider_grep_payload["hits"][1]["provider"], + "cursor" ); + let provider_local_load = handle_tool_call( + &cg, + "tracedecay_lcm_load_session", + json!({ + "session_id": "provider-local-session", + "limit": 5 + }), + None, + None, + ) + .await + .unwrap(); + let provider_local_load_payload: Value = + serde_json::from_str(extract_text(&provider_local_load.value)).unwrap(); + assert_eq!(provider_local_load_payload["status"], "ok"); + assert_eq!(provider_local_load_payload["provider"], "all"); + let loaded_providers = provider_local_load_payload["messages"] + .as_array() + .unwrap() + .iter() + .map(|message| message["provider"].as_str().unwrap()) + .collect::>(); + assert_eq!(loaded_providers, vec!["cursor", "codex"]); + let described = handle_tool_call( &cg, "tracedecay_lcm_describe", @@ -13522,15 +13556,15 @@ async fn lcm_read_only_tools_return_not_ingested_without_creating_sessions_db() ), ( "tracedecay_lcm_describe", - json!({"session_id": "ghost-session"}), + json!({"provider": "cursor", "session_id": "ghost-session"}), ), ( "tracedecay_lcm_expand", - json!({"session_id": "ghost-session", "target": {"kind": "raw_message", "store_id": 1}}), + json!({"provider": "cursor", "session_id": "ghost-session", "target": {"kind": "raw_message", "store_id": 1}}), ), ( "tracedecay_lcm_expand_query", - json!({"session_id": "ghost-session", "prompt": "anything"}), + json!({"provider": "cursor", "session_id": "ghost-session", "prompt": "anything"}), ), ] { let result = handle_tool_call(&cg, tool, args.clone(), None, None) @@ -13583,6 +13617,7 @@ async fn lcm_expand_query_context_max_tokens_is_independent_of_max_tokens() { "tracedecay_lcm_expand_query", json!({ "session_id": "test-session", + "provider": "cursor", "prompt": "what did we discuss?", "max_tokens": 500, "context_max_tokens": 48000,