From 8968e2ba224f13ffc465e5a6e44b03b9ee4cc406 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:07:41 -0500 Subject: [PATCH 01/16] =?UTF-8?q?chore:=20fix=20seperate=20=E2=86=92=20sep?= =?UTF-8?q?arate=20typos=20in=20bench=20code=20(#797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/rai_bench/rai_bench/test_models.py | 2 +- .../rai_bench/manipulation_o3de/tasks/test_build_tower_task.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rai_bench/rai_bench/test_models.py b/src/rai_bench/rai_bench/test_models.py index 84622a5ad..0458a1dff 100644 --- a/src/rai_bench/rai_bench/test_models.py +++ b/src/rai_bench/rai_bench/test_models.py @@ -121,7 +121,7 @@ def test_models( raise ValueError("Number of passed models must match number of passed vendors") else: for bench_conf in benchmark_configs: - # for each bench configuration seperate run folder + # for each bench configuration separate run folder now = datetime.now() run_name = f"run_{now.strftime('%Y-%m-%d_%H-%M-%S')}" for i, model_name in enumerate(model_names): diff --git a/tests/rai_bench/manipulation_o3de/tasks/test_build_tower_task.py b/tests/rai_bench/manipulation_o3de/tasks/test_build_tower_task.py index 9ff534b39..63c6d89f0 100644 --- a/tests/rai_bench/manipulation_o3de/tasks/test_build_tower_task.py +++ b/tests/rai_bench/manipulation_o3de/tasks/test_build_tower_task.py @@ -63,7 +63,7 @@ def test_calculate_single_entity() -> None: assert incorrect == 1 -def test_calculate_seperate_entities() -> None: +def test_calculate_separate_entities() -> None: task = BuildCubeTowerTask(["red_cube"]) e1 = create_entity("cube1", "red_cube", 0.0, 0.0, 0.0) e2 = create_entity("cube2", "red_cube", 0.2, 0.2, 0.0) From fbb88c2136681b12ea9829a41b3d9af12fad3d81 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:08:32 -0500 Subject: [PATCH 02/16] fix(aggregators): avoid KeyError for unknown ROS log levels (#798) --- src/rai_core/rai/aggregators/ros2/aggregators.py | 2 +- tests/aggregators/test_ros2_logs_aggregator.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/rai_core/rai/aggregators/ros2/aggregators.py b/src/rai_core/rai/aggregators/ros2/aggregators.py index 9e1d63398..2e26c465d 100644 --- a/src/rai_core/rai/aggregators/ros2/aggregators.py +++ b/src/rai_core/rai/aggregators/ros2/aggregators.py @@ -37,7 +37,7 @@ def get(self) -> HumanMessage: prev_parsed = None counter = 0 for log in msgs: - level = self.levels[log.level] + level = self.levels.get(log.level, str(log.level)) parsed = f"[{log.name}] [{level}] [{log.function}] {log.msg}" if parsed == prev_parsed: counter += 1 diff --git a/tests/aggregators/test_ros2_logs_aggregator.py b/tests/aggregators/test_ros2_logs_aggregator.py index 8837b00bb..7fd9f04d8 100644 --- a/tests/aggregators/test_ros2_logs_aggregator.py +++ b/tests/aggregators/test_ros2_logs_aggregator.py @@ -193,3 +193,15 @@ def with_structured_output(self, *args, **kwargs): ) assert aggregator.get_buffer() == [] assert aggregator.get() is None + + +def test_ros2_logs_aggregator_unknown_log_level_does_not_keyerror(): + """ROS log levels outside the fixed map must not crash get().""" + aggregator = ROS2LogsAggregator() + aggregator( + DummyLog(level=25, name="demo_node", function="do_work", msg="custom severity") + ) + summary = aggregator.get() + assert isinstance(summary, HumanMessage) + assert "custom severity" in summary.content + assert "[25]" in summary.content From f672311bd7410acd016e191a239db82b630107dd Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:13:25 -0500 Subject: [PATCH 03/16] fix(ros2): slash-normalize available names in entity waiters (#801) --- .../rai/communication/ros2/waiters.py | 4 +- tests/communication/ros2/test_waiters.py | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/rai_core/rai/communication/ros2/waiters.py b/src/rai_core/rai/communication/ros2/waiters.py index 256f2558f..6effa109d 100644 --- a/src/rai_core/rai/communication/ros2/waiters.py +++ b/src/rai_core/rai/communication/ros2/waiters.py @@ -30,8 +30,8 @@ def get_missing_entities( callable_get_entities: Callable[[], List[str]], requested_entities: List[str], ) -> set[str]: - requested_set = set(requested_entities) - available_set = set(callable_get_entities()) + requested_set = {ensure_slash(name) for name in requested_entities} + available_set = {ensure_slash(name) for name in callable_get_entities()} return requested_set - available_set diff --git a/tests/communication/ros2/test_waiters.py b/tests/communication/ros2/test_waiters.py index 22ed71e1c..451e5149f 100644 --- a/tests/communication/ros2/test_waiters.py +++ b/tests/communication/ros2/test_waiters.py @@ -126,3 +126,43 @@ def test_wait_for_ros2_negative_timeout( with pytest.raises(ValueError): wait_func(connector, [name], time_interval=0.001, timeout=-0.01) + + +def test_wait_for_ros2_services_available_without_slash(monkeypatch): + """Discovery may omit leading slash; required names should still match.""" + connector = DummyConnector( + services_seq=[ + [("target_service", ["srv/Type"])], + ] + ) + monkeypatch.setattr(waiters.time, "sleep", lambda *_: None) + waiters.wait_for_ros2_services(connector, ["target_service"], time_interval=0, timeout=1.0) + + +def test_wait_for_ros2_topics_available_without_slash(monkeypatch): + connector = DummyConnector( + topics_seq=[ + [("topic_a", ["msg/A"])], + ] + ) + monkeypatch.setattr(waiters.time, "sleep", lambda *_: None) + waiters.wait_for_ros2_topics(connector, ["topic_a"], time_interval=0, timeout=1.0) + + +def test_wait_for_ros2_actions_available_without_slash(monkeypatch): + connector = DummyConnector( + actions_seq=[ + [("action_a", ["action/A"])], + ] + ) + monkeypatch.setattr(waiters.time, "sleep", lambda *_: None) + waiters.wait_for_ros2_actions(connector, ["action_a"], time_interval=0, timeout=1.0) + + +def test_wait_for_ros2_entities_negative_timeout(): + with pytest.raises(ValueError): + waiters.wait_for_ros2_entities( + requested=["/a"], + get_entities=lambda: [], + timeout=-1, + ) From 3501232dc7d050284e6674099442c9fd317c7183 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:15:40 -0500 Subject: [PATCH 04/16] fix(messages): honor db_path in store_artifacts (#804) --- src/rai_core/rai/messages/artifacts.py | 12 ++++----- tests/messages/test_artifacts_db_path.py | 34 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 tests/messages/test_artifacts_db_path.py diff --git a/src/rai_core/rai/messages/artifacts.py b/src/rai_core/rai/messages/artifacts.py index dbc34537a..870174344 100644 --- a/src/rai_core/rai/messages/artifacts.py +++ b/src/rai_core/rai/messages/artifacts.py @@ -26,18 +26,18 @@ def store_artifacts( tool_call_id: str, artifacts: List[Any], db_path="artifact_database.pkl" ): # TODO(boczekbartek): refactor - db_path = Path(db_path) - if not db_path.is_file(): - artifact_database = {} - with open("artifact_database.pkl", "wb") as file: + path = Path(db_path) + if not path.is_file(): + artifact_database: dict = {} + with path.open("wb") as file: pickle.dump(artifact_database, file) - with open("artifact_database.pkl", "rb") as file: + with path.open("rb") as file: artifact_database = pickle.load(file) if tool_call_id not in artifact_database: artifact_database[tool_call_id] = artifacts else: artifact_database[tool_call_id].extend(artifacts) - with open("artifact_database.pkl", "wb") as file: + with path.open("wb") as file: pickle.dump(artifact_database, file) diff --git a/tests/messages/test_artifacts_db_path.py b/tests/messages/test_artifacts_db_path.py new file mode 100644 index 000000000..bb11eb4f0 --- /dev/null +++ b/tests/messages/test_artifacts_db_path.py @@ -0,0 +1,34 @@ +# Copyright (C) 2025 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from rai.messages.artifacts import get_stored_artifacts, store_artifacts + + +def test_store_artifacts_honors_db_path(tmp_path: Path): + db = tmp_path / "custom_artifacts.pkl" + store_artifacts("tc1", ["a"], db_path=str(db)) + assert db.is_file() + assert get_stored_artifacts("tc1", db_path=str(db)) == ["a"] + store_artifacts("tc1", ["b"], db_path=str(db)) + assert get_stored_artifacts("tc1", db_path=str(db)) == ["a", "b"] + assert get_stored_artifacts("missing", db_path=str(db)) == [] + + +def test_store_artifacts_creates_file_at_path(tmp_path: Path): + db = tmp_path / "nested" / "db.pkl" + db.parent.mkdir(parents=True) + store_artifacts("x", [1], db_path=str(db)) + assert get_stored_artifacts("x", db_path=str(db)) == [1] From 4698adcab551af16104f40faf9c35308a37dac7f Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:16:12 -0500 Subject: [PATCH 05/16] fix(aggregators): reject non-positive max_size (#807) --- src/rai_core/rai/aggregators/base.py | 2 ++ tests/aggregators/test_base_aggregator.py | 42 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/aggregators/test_base_aggregator.py diff --git a/src/rai_core/rai/aggregators/base.py b/src/rai_core/rai/aggregators/base.py index 2b021c422..343c24ace 100644 --- a/src/rai_core/rai/aggregators/base.py +++ b/src/rai_core/rai/aggregators/base.py @@ -28,6 +28,8 @@ class BaseAggregator(ABC, Generic[T]): def __init__(self, max_size: int | None = None) -> None: super().__init__() + if max_size is not None and max_size <= 0: + raise ValueError(f"max_size must be positive or None, got {max_size}") self._buffer: Deque[T] = deque() self.max_size = max_size diff --git a/tests/aggregators/test_base_aggregator.py b/tests/aggregators/test_base_aggregator.py new file mode 100644 index 000000000..d60da3911 --- /dev/null +++ b/tests/aggregators/test_base_aggregator.py @@ -0,0 +1,42 @@ +# Copyright (C) 2025 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from langchain_core.messages import BaseMessage +from rai.aggregators.base import BaseAggregator + + +class DummyAggregator(BaseAggregator[int]): + def get(self) -> BaseMessage | None: + return None + + +def test_base_aggregator_rejects_nonpositive_max_size(): + with pytest.raises(ValueError, match="max_size must be positive"): + DummyAggregator(max_size=0) + with pytest.raises(ValueError, match="max_size must be positive"): + DummyAggregator(max_size=-1) + + +def test_base_aggregator_accepts_none_and_positive_max_size(): + unbounded = DummyAggregator(max_size=None) + assert unbounded.max_size is None + for i in range(5): + unbounded(i) + assert unbounded.get_buffer() == [0, 1, 2, 3, 4] + + bounded = DummyAggregator(max_size=2) + for i in range(5): + bounded(i) + assert bounded.get_buffer() == [3, 4] From 72ed265187d441dd7e4cfebb87a4542e3c0a740e Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:19:14 -0500 Subject: [PATCH 06/16] fix(agents): reject non-positive StateBasedConfig interval/workers (#816) --- .../rai/agents/langchain/state_based_agent.py | 16 +++++++- .../langchain/test_state_based_config.py | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/agents/langchain/test_state_based_config.py diff --git a/src/rai_core/rai/agents/langchain/state_based_agent.py b/src/rai_core/rai/agents/langchain/state_based_agent.py index f5860c0e8..657d42863 100644 --- a/src/rai_core/rai/agents/langchain/state_based_agent.py +++ b/src/rai_core/rai/agents/langchain/state_based_agent.py @@ -21,7 +21,7 @@ from langchain_core.language_models import BaseChatModel from langchain_core.messages import BaseMessage, HumanMessage from langchain_core.tools import BaseTool -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from rai.agents.langchain.agent import LangChainAgent, newMessageBehaviorType from rai.agents.langchain.core import ReActAgentState, create_state_based_runnable @@ -42,6 +42,20 @@ class StateBasedConfig(BaseModel): arbitrary_types_allowed=True, ) + @field_validator("time_interval") + @classmethod + def _time_interval_positive(cls, v: float) -> float: + if v <= 0: + raise ValueError(f"time_interval must be positive, got {v!r}") + return v + + @field_validator("max_workers") + @classmethod + def _max_workers_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError(f"max_workers must be positive, got {v!r}") + return v + class BaseStateBasedAgent(LangChainAgent, ABC): """ diff --git a/tests/agents/langchain/test_state_based_config.py b/tests/agents/langchain/test_state_based_config.py new file mode 100644 index 000000000..e2e1a2249 --- /dev/null +++ b/tests/agents/langchain/test_state_based_config.py @@ -0,0 +1,39 @@ +# Copyright (C) 2025 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from pydantic import ValidationError + +from rai.agents.langchain.state_based_agent import StateBasedConfig + + +def test_time_interval_must_be_positive(): + for bad in (0, -1.0, 0.0): + with pytest.raises(ValidationError): + StateBasedConfig(aggregators={}, time_interval=bad) + + +def test_max_workers_must_be_positive(): + for bad in (0, -1): + with pytest.raises(ValidationError): + StateBasedConfig(aggregators={}, max_workers=bad) + + +def test_defaults_and_positive_ok(): + cfg = StateBasedConfig(aggregators={}) + assert cfg.time_interval == 5.0 + assert cfg.max_workers == 8 + cfg2 = StateBasedConfig(aggregators={}, time_interval=0.1, max_workers=1) + assert cfg2.time_interval == 0.1 + assert cfg2.max_workers == 1 From 042cc25b214366b134a9dfdb022e5255c1dd4b90 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:20:06 -0500 Subject: [PATCH 07/16] fix(agents): reject non-positive HRICallbackHandler max_buffer_size (#821) Signed-off-by: Bartok9 --- src/rai_core/rai/agents/langchain/callback.py | 8 ++++ .../langchain/test_hri_callback_handler.py | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/agents/langchain/test_hri_callback_handler.py diff --git a/src/rai_core/rai/agents/langchain/callback.py b/src/rai_core/rai/agents/langchain/callback.py index 4978598ce..47c915e0e 100644 --- a/src/rai_core/rai/agents/langchain/callback.py +++ b/src/rai_core/rai/agents/langchain/callback.py @@ -34,6 +34,14 @@ def __init__( logger: Optional[logging.Logger] = None, stream_response: bool = True, ): + if isinstance(max_buffer_size, bool) or not isinstance(max_buffer_size, int): + raise TypeError( + f"max_buffer_size must be a positive int, got {type(max_buffer_size).__name__}" + ) + if max_buffer_size <= 0: + raise ValueError( + f"max_buffer_size must be positive, got {max_buffer_size!r}" + ) self.connectors = connectors self.aggregate_chunks = aggregate_chunks self.stream_response = stream_response diff --git a/tests/agents/langchain/test_hri_callback_handler.py b/tests/agents/langchain/test_hri_callback_handler.py new file mode 100644 index 000000000..1b11e90a0 --- /dev/null +++ b/tests/agents/langchain/test_hri_callback_handler.py @@ -0,0 +1,39 @@ +# Copyright (C) 2025 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +from rai.agents.langchain.callback import HRICallbackHandler + + +def test_hri_callback_handler_rejects_nonpositive_max_buffer_size(): + with pytest.raises(ValueError, match="max_buffer_size must be positive"): + HRICallbackHandler(connectors={}, max_buffer_size=0) + with pytest.raises(ValueError, match="max_buffer_size must be positive"): + HRICallbackHandler(connectors={}, max_buffer_size=-1) + + +def test_hri_callback_handler_rejects_non_int_max_buffer_size(): + with pytest.raises(TypeError, match="max_buffer_size must be a positive int"): + HRICallbackHandler(connectors={}, max_buffer_size=False) + with pytest.raises(TypeError, match="max_buffer_size must be a positive int"): + HRICallbackHandler(connectors={}, max_buffer_size=1.5) # type: ignore[arg-type] + with pytest.raises(TypeError, match="max_buffer_size must be a positive int"): + HRICallbackHandler(connectors={}, max_buffer_size="200") # type: ignore[arg-type] + + +def test_hri_callback_handler_accepts_positive_max_buffer_size(): + handler = HRICallbackHandler(connectors={}, max_buffer_size=1) + assert handler.max_buffer_size == 1 + default_handler = HRICallbackHandler(connectors={}) + assert default_handler.max_buffer_size == 200 From 81932de1db12a7f0108b3196f68b935ec1319589 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:21:15 -0500 Subject: [PATCH 08/16] fix(agents): reject empty AgentRunner agent list (#818) --- src/rai_core/pyproject.toml | 2 +- src/rai_core/rai/agents/runner.py | 6 ++++++ tests/agents/test_agent_runner.py | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/rai_core/pyproject.toml b/src/rai_core/pyproject.toml index d7c3e5ae8..981b67105 100644 --- a/src/rai_core/pyproject.toml +++ b/src/rai_core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rai_core" -version = "2.12.1" +version = "2.12.2" description = "Core functionality for RAI framework" readme = "README.md" requires-python = ">=3.10,<3.13" diff --git a/src/rai_core/rai/agents/runner.py b/src/rai_core/rai/agents/runner.py index 447d18d11..56a1a3049 100644 --- a/src/rai_core/rai/agents/runner.py +++ b/src/rai_core/rai/agents/runner.py @@ -37,6 +37,8 @@ def wait_for_shutdown(agents: List[BaseAgent]): an interrupt signal (SIGINT, e.g., Ctrl+C) or SIGTERM. It installs signal handlers to capture these events and invokes the agent's ``stop()`` method as part of the shutdown process. """ + if not agents: + raise ValueError("agents must be a non-empty list") shutdown_event = Event() def signal_handler(signum, frame): @@ -58,6 +60,8 @@ def run_agents(agents: List[BaseAgent]): Args: agents: List of agent instances """ + if not agents: + raise ValueError("agents must be a non-empty list") logger.info( "run_agents is an experimental function. \ If you believe that your agents are not running properly, \ @@ -84,6 +88,8 @@ def __init__(self, agents: List[BaseAgent]): agents : List[BaseAgent] List of agent instances to be managed by the runner. """ + if not agents: + raise ValueError("agents must be a non-empty list") self.agents = agents self.logger = logging.getLogger(__name__) diff --git a/tests/agents/test_agent_runner.py b/tests/agents/test_agent_runner.py index 60b3c739b..da03d2c24 100644 --- a/tests/agents/test_agent_runner.py +++ b/tests/agents/test_agent_runner.py @@ -61,3 +61,26 @@ def fake_signal(signum, handler): assert thread.is_alive() is False assert all(agent.stop_called for agent in agents) + + +def test_agent_runner_rejects_empty_agents(): + import pytest + + with pytest.raises(ValueError, match="non-empty"): + AgentRunner([]) + + +def test_wait_for_shutdown_rejects_empty_agents(): + import pytest + from rai.agents.runner import wait_for_shutdown + + with pytest.raises(ValueError, match="non-empty"): + wait_for_shutdown([]) + + +def test_run_agents_rejects_empty_agents(): + import pytest + from rai.agents.runner import run_agents + + with pytest.raises(ValueError, match="non-empty"): + run_agents([]) From 460367f3dac1981cf6d93fd0547f930108f9027d Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:24:32 -0500 Subject: [PATCH 09/16] fix(agents): reject non-positive LangChainAgent max_size (#815) --- src/rai_core/rai/agents/langchain/agent.py | 2 + .../test_langchain_agent_max_size.py | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/agents/langchain/test_langchain_agent_max_size.py diff --git a/src/rai_core/rai/agents/langchain/agent.py b/src/rai_core/rai/agents/langchain/agent.py index e67c309d7..832696045 100644 --- a/src/rai_core/rai/agents/langchain/agent.py +++ b/src/rai_core/rai/agents/langchain/agent.py @@ -115,6 +115,8 @@ def __init__( max_size: int = 100, ): super().__init__() + if max_size <= 0: + raise ValueError(f"max_size must be positive, got {max_size!r}") self.logger = logging.getLogger(__name__) self.agent = runnable self.stream_response = stream_response diff --git a/tests/agents/langchain/test_langchain_agent_max_size.py b/tests/agents/langchain/test_langchain_agent_max_size.py new file mode 100644 index 000000000..31ba92df0 --- /dev/null +++ b/tests/agents/langchain/test_langchain_agent_max_size.py @@ -0,0 +1,37 @@ +# Copyright (C) 2025 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock + +import pytest +from rai.agents.langchain.agent import LangChainAgent + + +def _make_agent(max_size: int) -> LangChainAgent: + return LangChainAgent( + target_connectors={}, + runnable=MagicMock(), + max_size=max_size, + ) + + +def test_max_size_must_be_positive(): + for bad in (0, -1, False): + with pytest.raises(ValueError, match="max_size must be positive"): + _make_agent(bad) # type: ignore[arg-type] + + +def test_max_size_accepts_positive(): + agent = _make_agent(1) + assert agent.max_size == 1 From ca14da2191158654d330aa7a307bdbe4aeaeb8b7 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:28:52 -0500 Subject: [PATCH 10/16] fix(ros2): reject non-positive timeout_sec in get_future_result (#808) --- .../rai/communication/ros2/ros_async.py | 2 ++ tests/communication/__init__.py | 0 .../ros2/test_get_future_result_timeout.py | 27 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 tests/communication/__init__.py create mode 100644 tests/communication/ros2/test_get_future_result_timeout.py diff --git a/src/rai_core/rai/communication/ros2/ros_async.py b/src/rai_core/rai/communication/ros2/ros_async.py index 67a036fb6..eec6b8754 100644 --- a/src/rai_core/rai/communication/ros2/ros_async.py +++ b/src/rai_core/rai/communication/ros2/ros_async.py @@ -26,6 +26,8 @@ def get_future_result( future: rclpy.task.Future, timeout_sec: float = 5.0 ) -> Any | None: """Replaces rclpy.spin_until_future_complete""" + if timeout_sec <= 0: + raise ValueError(f"timeout_sec must be positive, got {timeout_sec}") result = None event = threading.Event() diff --git a/tests/communication/__init__.py b/tests/communication/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/communication/ros2/test_get_future_result_timeout.py b/tests/communication/ros2/test_get_future_result_timeout.py new file mode 100644 index 000000000..8c620fa4c --- /dev/null +++ b/tests/communication/ros2/test_get_future_result_timeout.py @@ -0,0 +1,27 @@ +# Copyright (C) 2025 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from unittest.mock import MagicMock + +import pytest +from rai.communication.ros2.ros_async import get_future_result + + +def test_get_future_result_rejects_nonpositive_timeout_sec(): + future = MagicMock() + with pytest.raises(ValueError, match="timeout_sec must be positive"): + get_future_result(future, timeout_sec=0) + with pytest.raises(ValueError, match="timeout_sec must be positive"): + get_future_result(future, timeout_sec=-1.0) + future.add_done_callback.assert_not_called() From a51057b0693b5945382710b94b4a3ed115566c09 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:32:26 -0500 Subject: [PATCH 11/16] fix(aggregators): report original keyframe buffer size in VLM diff summary (#800) --- .../rai/aggregators/ros2/aggregators.py | 8 +++- .../aggregators/test_ros2_logs_aggregator.py | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/rai_core/rai/aggregators/ros2/aggregators.py b/src/rai_core/rai/aggregators/ros2/aggregators.py index 2e26c465d..21d47fbca 100644 --- a/src/rai_core/rai/aggregators/ros2/aggregators.py +++ b/src/rai_core/rai/aggregators/ros2/aggregators.py @@ -112,7 +112,7 @@ class ROS2ImgDescription(BaseModel): class ROS2ImgVLMDiffAggregator(BaseAggregator[Image | CompressedImage]): """ Returns the LLM analysis of the differences between 3 images in the - aggregation buffer: 1st, midden, last + aggregation buffer: 1st, middle, last """ SYSTEM_PROMPT = "You are an expert in image analysis and your speciality is the comparison of 3 images" @@ -142,6 +142,7 @@ def get(self) -> HumanMessage | None: return None b64_images = [convert_ros_img_to_base64(msg) for msg in msgs] + original_count = len(b64_images) self.clear_buffer() @@ -165,5 +166,8 @@ class ROS2ImgDiffOutput(BaseModel): llm = self.llm.with_structured_output(ROS2ImgDiffOutput) response = cast(ROS2ImgDiffOutput, llm.invoke(task)) return HumanMessage( - content=f"Result of the analysis of the {len(b64_images)} keyframes selected from {len(b64_images)} last images:\n{response}" + content=( + f"Result of the analysis of the {len(b64_images)} keyframes " + f"selected from {original_count} last images:\n{response}" + ) ) diff --git a/tests/aggregators/test_ros2_logs_aggregator.py b/tests/aggregators/test_ros2_logs_aggregator.py index 7fd9f04d8..79a0a8338 100644 --- a/tests/aggregators/test_ros2_logs_aggregator.py +++ b/tests/aggregators/test_ros2_logs_aggregator.py @@ -205,3 +205,42 @@ def test_ros2_logs_aggregator_unknown_log_level_does_not_keyerror(): assert isinstance(summary, HumanMessage) assert "custom severity" in summary.content assert "[25]" in summary.content + + +def test_ros2_img_vlm_diff_get_key_elements(): + assert ROS2ImgVLMDiffAggregator.get_key_elements([]) == [] + assert ROS2ImgVLMDiffAggregator.get_key_elements([1]) == [1] + assert ROS2ImgVLMDiffAggregator.get_key_elements([1, 2]) == [1, 2] + assert ROS2ImgVLMDiffAggregator.get_key_elements([1, 2, 3]) == [1, 2, 3] + assert ROS2ImgVLMDiffAggregator.get_key_elements([1, 2, 3, 4, 5]) == [1, 3, 5] + assert ROS2ImgVLMDiffAggregator.get_key_elements([1, 2, 3, 4, 5, 6]) == [1, 4, 6] + + +def test_ros2_img_vlm_diff_aggregator_original_count(ros2_image: Image): + """When buffer has >3 images, message must report original_count, not selected len.""" + + class ROS2ImgDiffOutput(BaseModel): + are_different: bool = Field(..., description="Whether the images are different") + differences: List[str] = Field(..., description="Description of the difference") + + class DummyModel(FakeChatModel): + def invoke(self, *args, **kwargs): + return ROS2ImgDiffOutput( + are_different=True, + differences=["moved"], + ) + + def with_structured_output(self, *args, **kwargs): + return self + + with patch( + "rai.aggregators.ros2.aggregators.get_llm_model", + side_effect=lambda *args, **kwargs: DummyModel(), + ): + aggregator = ROS2ImgVLMDiffAggregator() + for _ in range(5): + aggregator(ros2_image) + result = aggregator.get() + assert result is not None + assert "selected from 5 last images" in result.content + assert "analysis of the 3 keyframes" in result.content From 5e0a2bc3a3a33200f0c556b7f66a472cb5f9d7bd Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:32:51 -0500 Subject: [PATCH 12/16] fix(ros2): reject non-positive action server result_timeout (#822) Signed-off-by: Bartok9 --- src/rai_core/rai/communication/ros2/api/action.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/rai_core/rai/communication/ros2/api/action.py b/src/rai_core/rai/communication/ros2/api/action.py index ea8ac36b4..71ce0137a 100644 --- a/src/rai_core/rai/communication/ros2/api/action.py +++ b/src/rai_core/rai/communication/ros2/api/action.py @@ -152,8 +152,19 @@ def create_action_server( The handle for the created action server Raises: - ValueError: If the action server cannot be created + TypeError: If result_timeout is not a positive number (bool rejected). + ValueError: If the action server cannot be created or result_timeout <= 0. """ + if isinstance(result_timeout, bool) or not isinstance( + result_timeout, (int, float) + ): + raise TypeError( + f"result_timeout must be a positive number, got {type(result_timeout).__name__}" + ) + if result_timeout <= 0: + raise ValueError( + f"result_timeout must be positive, got {result_timeout!r}" + ) handle = self._generate_handle() action_ros_type = import_message_from_str(action_type) try: From e3c0ffeb35e3ec3a231db132cd9c3233e5058666 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:41:25 -0500 Subject: [PATCH 13/16] fix(tools): reject non-positive ROS2 CLI command timeout (#817) --- src/rai_core/rai/tools/ros2/cli.py | 6 ++ tests/tools/test_ros2_cli_timeout.py | 82 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/tools/test_ros2_cli_timeout.py diff --git a/src/rai_core/rai/tools/ros2/cli.py b/src/rai_core/rai/tools/ros2/cli.py index f83a73af6..78c26c5ea 100644 --- a/src/rai_core/rai/tools/ros2/cli.py +++ b/src/rai_core/rai/tools/ros2/cli.py @@ -22,6 +22,12 @@ def run_with_timeout(cmd: List[str], timeout_sec: int): + if isinstance(timeout_sec, bool) or not isinstance(timeout_sec, (int, float)): + raise TypeError( + f"timeout_sec must be a positive number, got {type(timeout_sec).__name__}" + ) + if timeout_sec <= 0: + raise ValueError(f"timeout_sec must be positive, got {timeout_sec!r}") proc = Popen(cmd, stdout=PIPE, stderr=PIPE) timer = Timer(timeout_sec, proc.kill) try: diff --git a/tests/tools/test_ros2_cli_timeout.py b/tests/tools/test_ros2_cli_timeout.py new file mode 100644 index 000000000..16c5778a8 --- /dev/null +++ b/tests/tools/test_ros2_cli_timeout.py @@ -0,0 +1,82 @@ +# Copyright (C) 2026 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Offline tests for ROS2 CLI timeout guards (no ROS_DISTRO required).""" + +import importlib.util +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +_CLI_PATH = ( + Path(__file__).resolve().parents[2] + / "src" + / "rai_core" + / "rai" + / "tools" + / "ros2" + / "cli.py" +) + + +def _load_cli_module(): + """Load cli.py directly, bypassing the package __init__ which requires ROS2 + sourced. langchain_core is a core rai dependency and is always installed.""" + name = "_rai_cli_timeout_ut" + if name in sys.modules: + return sys.modules[name] + + spec = importlib.util.spec_from_file_location(name, _CLI_PATH) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def cli(): + return _load_cli_module() + + +def test_run_with_timeout_rejects_zero(cli): + with pytest.raises(ValueError, match="positive"): + cli.run_with_timeout(["echo", "x"], 0) + + +def test_run_with_timeout_rejects_negative(cli): + with pytest.raises(ValueError, match="positive"): + cli.run_with_timeout(["echo", "x"], -1) + + +def test_run_with_timeout_rejects_bool(cli): + with pytest.raises(TypeError, match="positive number"): + cli.run_with_timeout(["echo", "x"], False) # type: ignore[arg-type] + + +def test_run_command_rejects_nonpositive(cli): + with pytest.raises(ValueError, match="positive"): + cli.run_command(["echo", "ok"], timeout=0) + + +def test_run_with_timeout_happy_path(cli, monkeypatch): + proc = MagicMock() + proc.communicate.return_value = (b"out\n", b"") + monkeypatch.setattr(cli, "Popen", lambda *a, **k: proc) + monkeypatch.setattr(cli, "Timer", lambda *a, **k: MagicMock()) + stdout, stderr = cli.run_with_timeout(["echo", "ok"], 1.0) + assert stdout == b"out\n" + assert stderr == b"" From 81f5a89f8ab24f380d6e97fc8b72aa1980bbf4c4 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:41:56 -0500 Subject: [PATCH 14/16] fix(communication): reject non-positive callback_max_workers (#813) --- src/rai_core/rai/communication/base_connector.py | 4 ++++ tests/communication/test_base_connector.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/rai_core/rai/communication/base_connector.py b/src/rai_core/rai/communication/base_connector.py index 51fd2f044..c99b6a2e8 100644 --- a/src/rai_core/rai/communication/base_connector.py +++ b/src/rai_core/rai/communication/base_connector.py @@ -72,6 +72,10 @@ class ParametrizedCallback(BaseModel, Generic[T]): class BaseConnector(Generic[T]): def __init__(self, callback_max_workers: int = 4): + if callback_max_workers <= 0: + raise ValueError( + f"callback_max_workers must be positive, got {callback_max_workers!r}" + ) self.callback_max_workers = callback_max_workers self.logger = logging.getLogger(self.__class__.__name__) self.registered_callbacks: Dict[str, Dict[str, ParametrizedCallback[T]]] = ( diff --git a/tests/communication/test_base_connector.py b/tests/communication/test_base_connector.py index ca4b24a02..f8ba9a2e4 100644 --- a/tests/communication/test_base_connector.py +++ b/tests/communication/test_base_connector.py @@ -138,3 +138,15 @@ class InternalDerivedConnector(BaseConnector[DerivedMessage]): connector = InternalDerivedConnector() assert connector.T_class == DerivedMessage + + +@pytest.mark.parametrize("bad", [0, -1, -4]) +def test_base_connector_rejects_nonpositive_callback_max_workers(bad): + with pytest.raises(ValueError, match="callback_max_workers must be positive"): + DummyConnector(callback_max_workers=bad) + + +def test_base_connector_accepts_positive_callback_max_workers(): + connector = DummyConnector(callback_max_workers=1) + assert connector.callback_max_workers == 1 + connector.shutdown() From 082be4b82a5a71e40bf138f04d204beaa352523c Mon Sep 17 00:00:00 2001 From: Maciej Majek <46171033+maciejmajek@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:44:27 +0200 Subject: [PATCH 15/16] chore: pre-commit & version bump lock (#825) --- src/rai_core/rai/communication/ros2/api/action.py | 4 +--- tests/agents/langchain/test_state_based_config.py | 7 +++---- tests/communication/ros2/test_waiters.py | 4 +++- uv.lock | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/rai_core/rai/communication/ros2/api/action.py b/src/rai_core/rai/communication/ros2/api/action.py index 71ce0137a..32df25c34 100644 --- a/src/rai_core/rai/communication/ros2/api/action.py +++ b/src/rai_core/rai/communication/ros2/api/action.py @@ -162,9 +162,7 @@ def create_action_server( f"result_timeout must be a positive number, got {type(result_timeout).__name__}" ) if result_timeout <= 0: - raise ValueError( - f"result_timeout must be positive, got {result_timeout!r}" - ) + raise ValueError(f"result_timeout must be positive, got {result_timeout!r}") handle = self._generate_handle() action_ros_type = import_message_from_str(action_type) try: diff --git a/tests/agents/langchain/test_state_based_config.py b/tests/agents/langchain/test_state_based_config.py index e2e1a2249..c3d3efc0e 100644 --- a/tests/agents/langchain/test_state_based_config.py +++ b/tests/agents/langchain/test_state_based_config.py @@ -14,16 +14,15 @@ import pytest from pydantic import ValidationError - from rai.agents.langchain.state_based_agent import StateBasedConfig def test_time_interval_must_be_positive(): for bad in (0, -1.0, 0.0): with pytest.raises(ValidationError): - StateBasedConfig(aggregators={}, time_interval=bad) - - + StateBasedConfig(aggregators={}, time_interval=bad) + + def test_max_workers_must_be_positive(): for bad in (0, -1): with pytest.raises(ValidationError): diff --git a/tests/communication/ros2/test_waiters.py b/tests/communication/ros2/test_waiters.py index 451e5149f..06dc5957b 100644 --- a/tests/communication/ros2/test_waiters.py +++ b/tests/communication/ros2/test_waiters.py @@ -136,7 +136,9 @@ def test_wait_for_ros2_services_available_without_slash(monkeypatch): ] ) monkeypatch.setattr(waiters.time, "sleep", lambda *_: None) - waiters.wait_for_ros2_services(connector, ["target_service"], time_interval=0, timeout=1.0) + waiters.wait_for_ros2_services( + connector, ["target_service"], time_interval=0, timeout=1.0 + ) def test_wait_for_ros2_topics_available_without_slash(monkeypatch): diff --git a/uv.lock b/uv.lock index a430dfcd9..65c20202e 100644 --- a/uv.lock +++ b/uv.lock @@ -4714,7 +4714,7 @@ requires-dist = [ [[package]] name = "rai-core" -version = "2.12.1" +version = "2.12.2" source = { editable = "src/rai_core" } dependencies = [ { name = "coloredlogs" }, From e1745a6a530db24cc868de9b99ba023b0ea0d940 Mon Sep 17 00:00:00 2001 From: Bartok Date: Wed, 22 Jul 2026 05:58:06 -0500 Subject: [PATCH 16/16] fix(ci): license header + align zero-timeout test with #808 (#826) --- tests/communication/__init__.py | 13 +++++++++++++ tests/communication/ros2/test_ros2_async.py | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/communication/__init__.py b/tests/communication/__init__.py index e69de29bb..97ceef6f0 100644 --- a/tests/communication/__init__.py +++ b/tests/communication/__init__.py @@ -0,0 +1,13 @@ +# Copyright (C) 2025 Robotec.AI +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/communication/ros2/test_ros2_async.py b/tests/communication/ros2/test_ros2_async.py index 25ec290c3..bd50055c9 100644 --- a/tests/communication/ros2/test_ros2_async.py +++ b/tests/communication/ros2/test_ros2_async.py @@ -125,10 +125,10 @@ def cancel_future(): # Edge case timeout tests def test_get_future_result_zero_timeout(): - """Test with zero timeout.""" + """Test that a zero timeout is rejected as non-positive.""" future = Future() - result = get_future_result(future, timeout_sec=0.0) - assert result is None + with pytest.raises(ValueError, match="timeout_sec must be positive"): + get_future_result(future, timeout_sec=0.0) def test_get_future_result_very_short_timeout():