From 0b5bb64f2691cff7092500c2bc2f218f6950b68a Mon Sep 17 00:00:00 2001 From: "Zhengxu (Joshua) Jin" <33077399+Jinzhengxu@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:36:05 +0800 Subject: [PATCH] fix(search): accept CLI queries that look like numbers (#612) * fix(search): accept CLI queries that look like numbers The CLI parses key=value arguments into typed values, so query=404 reached the search steps as an int and failed on .strip(), and query=NaN or query=Infinity became non-finite floats that the HTTP client cannot encode as JSON and the MCP client sends as null. Read the query as text in the four search steps, as ChatStep already does, and keep values that float() reads as NaN or infinity as strings. * fix(search): treat only a missing query as empty str(query or "") turned query=0 and query=0.0 into an empty string, so those searches still failed with "query cannot be empty". Check for None before converting instead. The BEAM and LME plugins replace the default search job with their own SearchV2Step, which still called .strip() on the raw value, so query=404 raised AttributeError when either plugin was enabled. Read the query the same way there. The numeric-query test is now parametrized over the six steps and passes 404, 0 and 0.0 through parse_kwargs first. --- AGENTS.md | 2 +- docs/en/configuration.md | 2 +- docs/zh/configuration.md | 2 +- plugins/beam/src/reme_beam/search_v2.py | 3 +- plugins/lme/src/reme_lme/search_v2.py | 3 +- reme/config/config_parser.py | 6 ++- reme/steps/index/bm25_search.py | 3 +- reme/steps/index/node_search.py | 3 +- reme/steps/index/search.py | 3 +- reme/steps/index/vector_search.py | 3 +- tests/unit/test_config_parser.py | 10 +++++ tests/unit/test_search_step.py | 52 ++++++++++++++++++++++--- 12 files changed, 76 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d17fe4cb..bb53cc0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ existing async and typing patterns unless the task explicitly requires a new con - CLI syntax is `reme ACTION key=value ...`; leading `-` or `--` on arguments is accepted. - Nested overrides use dot notation. Values support null, booleans, numbers, JSON collections, and quoted JSON strings; - leading-zero numeric-looking values remain strings. + leading-zero numeric-looking values, `nan`, and `inf` remain strings. - `config=` loads a discovered config name or a `.yaml`, `.yml`, or `.json` file. With no explicit config path, `default` is loaded when available. - Config files expand `${VAR}` and `${VAR:-default}` recursively. An undefined variable without a default is an error. diff --git a/docs/en/configuration.md b/docs/en/configuration.md index 002d2b11..304d140c 100644 --- a/docs/en/configuration.md +++ b/docs/en/configuration.md @@ -42,7 +42,7 @@ Arguments use `key=value`; leading `-` or `--` is accepted: reme start --service.port=8181 --service.web_enabled=false ``` -Values support null, booleans, numbers, JSON arrays and objects, quoted JSON strings, and plain strings. Numeric-looking values with leading zeroes, such as `007`, remain strings. Quote values such as `"true"` in JSON when they must remain strings. +Values support null, booleans, numbers, JSON arrays and objects, quoted JSON strings, and plain strings. Numeric-looking values with leading zeroes, such as `007`, remain strings, as do `nan`, `inf`, and `Infinity`. Quote values such as `"true"` in JSON when they must remain strings. ## Environment variables diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index dd046f4e..818f4f4e 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -47,7 +47,7 @@ reme start --service.port=8181 --service.web_enabled=false - JSON 引号字符串; - 普通字符串。 -类似 `007` 的前导零字符串不会被转换成数字。需要保留 `true`、`false` 等字面字符串时,使用 JSON 引号:`value='"true"'`。 +类似 `007` 的前导零字符串以及 `nan`、`inf`、`Infinity` 不会被转换成数字。需要保留 `true`、`false` 等字面字符串时,使用 JSON 引号:`value='"true"'`。 ## 环境变量 diff --git a/plugins/beam/src/reme_beam/search_v2.py b/plugins/beam/src/reme_beam/search_v2.py index 77c02027..aa606a69 100644 --- a/plugins/beam/src/reme_beam/search_v2.py +++ b/plugins/beam/src/reme_beam/search_v2.py @@ -99,7 +99,8 @@ class SearchV2Step(_ToolContextDedupMixin, BaseStep): async def execute(self): assert self.context is not None - query: str = (self.context.get("query", "") or "").strip() + raw_query = self.context.get("query") + query: str = "" if raw_query is None else str(raw_query).strip() limit: int = int(self.context.get("limit") or _default_limit()) min_score: float = float(self.context.get("min_score") or 0.0) # vector_weight: prefer agent-supplied context value; fallback to YAML kwargs / default 0.7. diff --git a/plugins/lme/src/reme_lme/search_v2.py b/plugins/lme/src/reme_lme/search_v2.py index 77c02027..aa606a69 100644 --- a/plugins/lme/src/reme_lme/search_v2.py +++ b/plugins/lme/src/reme_lme/search_v2.py @@ -99,7 +99,8 @@ class SearchV2Step(_ToolContextDedupMixin, BaseStep): async def execute(self): assert self.context is not None - query: str = (self.context.get("query", "") or "").strip() + raw_query = self.context.get("query") + query: str = "" if raw_query is None else str(raw_query).strip() limit: int = int(self.context.get("limit") or _default_limit()) min_score: float = float(self.context.get("min_score") or 0.0) # vector_weight: prefer agent-supplied context value; fallback to YAML kwargs / default 0.7. diff --git a/reme/config/config_parser.py b/reme/config/config_parser.py index ee06edd5..64db3f88 100644 --- a/reme/config/config_parser.py +++ b/reme/config/config_parser.py @@ -1,6 +1,7 @@ """Parser for YAML config with CLI argument overrides.""" import json +import math import os import re from collections.abc import Mapping @@ -101,6 +102,7 @@ def _convert_value(value_str: str) -> Any: Only converts "true"/"false" (case-insensitive) to boolean. Use JSON format (e.g., '"yes"', '"no"') to preserve these as strings. Leading-zero strings (e.g., "007", "00501") are kept as strings. + "nan", "inf" and "Infinity" are kept as strings: JSON has no such numbers. """ s = value_str.strip() lower = s.lower() @@ -117,9 +119,11 @@ def _convert_value(value_str: str) -> Any: if not _LEADING_ZERO_RE.match(s): for converter in (int, float): try: - return converter(s) + number = converter(s) except ValueError: continue + # float() also reads "nan" and "inf", which no JSON request can carry + return number if isinstance(number, int) or math.isfinite(number) else s # JSON handles lists, dicts, and explicitly-quoted strings try: diff --git a/reme/steps/index/bm25_search.py b/reme/steps/index/bm25_search.py index 3ac139c3..99ca8a4d 100644 --- a/reme/steps/index/bm25_search.py +++ b/reme/steps/index/bm25_search.py @@ -23,7 +23,8 @@ class Bm25SearchStep(_ToolContextDedupMixin, BaseStep): async def execute(self): assert self.context is not None - query: str = (self.context.get("query", "") or "").strip() + raw_query = self.context.get("query") + query: str = "" if raw_query is None else str(raw_query).strip() limit: int = int(self.context.get("limit") or 5) min_score: float = float(self.context.get("min_score") or 0.0) tool_context_id: str = (self.context.get("tool_context_id", "") or "").strip() diff --git a/reme/steps/index/node_search.py b/reme/steps/index/node_search.py index ae35e609..d6b50658 100644 --- a/reme/steps/index/node_search.py +++ b/reme/steps/index/node_search.py @@ -61,7 +61,8 @@ class NodeSearchStep(BaseStep): async def execute(self): assert self.context is not None - query: str = (self.context.get("query", "") or "").strip() + raw_query = self.context.get("query") + query: str = "" if raw_query is None else str(raw_query).strip() limit: int = int(self.context.get("limit") or 20) vector_weight: float = float(self.kwargs.get("vector_weight", 0.7)) candidate_multiplier: float = float(self.kwargs.get("candidate_multiplier", 5.0)) diff --git a/reme/steps/index/search.py b/reme/steps/index/search.py index b5aafd3b..8d644aa1 100644 --- a/reme/steps/index/search.py +++ b/reme/steps/index/search.py @@ -203,7 +203,8 @@ class SearchStep(BaseStep): async def execute(self): assert self.context is not None - query: str = (self.context.get("query", "") or "").strip() + raw_query = self.context.get("query") + query: str = "" if raw_query is None else str(raw_query).strip() limit: int = int(self.context.get("limit") or _default_limit()) min_score: float = float(self.context.get("min_score") or 0.0) # vector_weight: prefer agent-supplied context value; fallback to YAML kwargs / default 0.7. diff --git a/reme/steps/index/vector_search.py b/reme/steps/index/vector_search.py index 638344e1..5de5aa94 100644 --- a/reme/steps/index/vector_search.py +++ b/reme/steps/index/vector_search.py @@ -23,7 +23,8 @@ class VectorSearchStep(_ToolContextDedupMixin, BaseStep): async def execute(self): assert self.context is not None - query: str = (self.context.get("query", "") or "").strip() + raw_query = self.context.get("query") + query: str = "" if raw_query is None else str(raw_query).strip() limit: int = int(self.context.get("limit") or 5) min_score: float = float(self.context.get("min_score") or 0.0) tool_context_id: str = (self.context.get("tool_context_id", "") or "").strip() diff --git a/tests/unit/test_config_parser.py b/tests/unit/test_config_parser.py index 1176014e..35b1c174 100644 --- a/tests/unit/test_config_parser.py +++ b/tests/unit/test_config_parser.py @@ -165,6 +165,16 @@ def test_parse_args_separates_action_and_application_kwargs(): assert kwargs == {"query": "hello", "limit": 3} +@pytest.mark.parametrize("value", ["nan", "NaN", "inf", "-Infinity", "1e999"]) +def test_parse_kwargs_keeps_non_finite_numbers_as_strings(value): + """JSON has no NaN or Infinity, so these values stay text instead of becoming floats.""" + assert parse_kwargs(f"query={value}", "limit=5", "min_score=0.5") == { + "query": value, + "limit": 5, + "min_score": 0.5, + } + + @pytest.mark.parametrize("item", ["=1", ".a=1", "a.=1", "a..b=1"]) def test_parse_dot_notation_rejects_empty_key_segments(item): """Dot notation keys cannot contain empty path segments.""" diff --git a/tests/unit/test_search_step.py b/tests/unit/test_search_step.py index 5e218e4d..886ff67d 100644 --- a/tests/unit/test_search_step.py +++ b/tests/unit/test_search_step.py @@ -6,6 +6,7 @@ import asyncio import importlib.util from pathlib import Path +import pytest from agentscope.message import Msg from reme.components.file_store import BaseFileStore @@ -13,23 +14,32 @@ from reme.components.file_store.local_file_store import LocalFileStore from reme.components.tag_index import LocalTagIndex from reme.components import ApplicationContext from reme.components.runtime_context import RuntimeContext +from reme.config.config_parser import parse_kwargs from reme.enumeration import LinkScopeEnum from reme.schema import FileChunk, FileLink, FileNode, Response from reme.steps.index import ( AddDraftStep, Bm25SearchStep, + NodeSearchStep, ReadAllDraftStep, SearchStep, VectorSearchStep, ) from reme.steps.index._source_format import ALL_RETURNED_MESSAGE, NO_RESULTS_MESSAGE -_SEARCH_V2_PATH = Path(__file__).parents[2] / "plugins" / "beam" / "src" / "reme_beam" / "search_v2.py" -_SEARCH_V2_SPEC = importlib.util.spec_from_file_location("reme_beam.search_v2", _SEARCH_V2_PATH) -assert _SEARCH_V2_SPEC is not None and _SEARCH_V2_SPEC.loader is not None -_SEARCH_V2_MODULE = importlib.util.module_from_spec(_SEARCH_V2_SPEC) -_SEARCH_V2_SPEC.loader.exec_module(_SEARCH_V2_MODULE) -SearchV2Step = _SEARCH_V2_MODULE.SearchV2Step + +def _load_search_v2_step(plugin: str) -> type: + """Load SearchV2Step from a benchmark plugin's source file.""" + path = Path(__file__).parents[2] / "plugins" / plugin / "src" / f"reme_{plugin}" / "search_v2.py" + spec = importlib.util.spec_from_file_location(f"reme_{plugin}.search_v2", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.SearchV2Step + + +SearchV2Step = _load_search_v2_step("beam") +LmeSearchV2Step = _load_search_v2_step("lme") class FakeSearchStore(BaseFileStore): @@ -803,6 +813,36 @@ def test_search_steps_no_results_shows_no_results_message(): asyncio.run(run()) +_QUERY_STEP_CLASSES = [ + pytest.param(SearchStep, id="search"), + pytest.param(VectorSearchStep, id="vector_search"), + pytest.param(Bm25SearchStep, id="bm25_search"), + pytest.param(NodeSearchStep, id="node_search"), + pytest.param(SearchV2Step, id="beam_search_v2"), + pytest.param(LmeSearchV2Step, id="lme_search_v2"), +] + + +@pytest.mark.parametrize("value", ["404", "0", "0.0"]) +@pytest.mark.parametrize("step_cls", _QUERY_STEP_CLASSES) +def test_search_steps_search_for_the_text_of_a_numeric_query(step_cls, value): + """The CLI parses ``query=0`` as a number; the search steps still search for its text.""" + + async def run(): + store = FakeSearchStore( + vector_results=[_chunk("hit", "digest/a.md", f"Error {value} from the gateway", "vector", 0.9)], + keyword_results=[_chunk("hit", "digest/a.md", f"Error {value} from the gateway", "keyword", 4.0)], + ) + query = parse_kwargs(f"query={value}")["query"] + resp = await step_cls(file_store=store)(RuntimeContext(query=query, limit=5)) + + assert resp.success is True + assert store.calls and {call[1] for call in store.calls} == {value} + assert "digest/a.md" in resp.answer + + asyncio.run(run()) + + # --------------------------------------------------------------------------- # SearchStep tests — exercise the SearchStep (simple chunk.id dedup, # inline answer formatting).