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