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.
This commit is contained in:
Zhengxu (Joshua) Jin 2026-10-16 10:36:05 +08:00 • committed by GitHub
parent b616944265
commit 0b5bb64f26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 76 additions and 16 deletions

View file

@ -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=<name-or-path>` 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.

View file

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

View file

@ -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"'`。
## 环境变量

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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."""

View file

@ -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).