# Copyright 2026 Google LLC # # 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. """LogAnalyzerNode's ReAct loop: a helper tool's failure is structural (``ToolFailure``), never sniffed from the words of its text.""" from unittest.mock import AsyncMock, MagicMock, patch from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.tools import StructuredTool import pytest from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode from artemis.context import ArtemisContext from artemis.core.tool_failure import ToolFailure from artemis.tools.mobile.read_logs import ReadLogsArgs from artemis.tools.mobile.search_logs import SearchLogsArgs _STATUS_CASES = [ pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"), pytest.param( "Error: 12 matching log lines\nE/Foo: Error opening file", "success", id="plain_error_text" ), ] def _text_tool(name: str, result): async def _run(keyword: str) -> str: return result return StructuredTool.from_function(coroutine=_run, name=name, description=name) @pytest.mark.asyncio @pytest.mark.parametrize("result, expected_status", _STATUS_CASES) async def test_agent_loop_tool_message_status_is_structural(result, expected_status): ctx = MagicMock(spec=ArtemisContext) ctx.data_engine = None node = LogAnalyzerNode(ctx=ctx) tool = _text_tool("search_logs", result) base_llm = MagicMock() base_llm.bind_tools.return_value = base_llm tool_turn = AIMessage( content="", tool_calls=[{"name": "search_logs", "args": {"keyword": "Error"}, "id": "c1"}] ) final_turn = AIMessage(content="Summary.", tool_calls=[]) messages = [SystemMessage(content="s"), HumanMessage(content="h")] with patch( "artemis.agents.log_analyzer.log_analyzer.acomplete", new=AsyncMock(side_effect=[tool_turn, final_turn]), ): outcome = await node._run_agent_loop( base_llm=base_llm, tools_for_binding=[tool], tools=[tool], current_messages=messages, state=MagicMock(), max_iterations=5, agent_name="Log Analyzer", ) assert outcome == "Summary." tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] assert len(tool_msgs) == 1 assert tool_msgs[0].tool_call_id == "c1" assert tool_msgs[0].status == expected_status assert tool_msgs[0].content == str(result) @pytest.mark.asyncio async def test_log_cache_normalizes_numeric_tool_arguments_before_running_tools(): node = LogAnalyzerNode(ctx=MagicMock(spec=ArtemisContext)) read_calls = [] async def read_raw_logs(lines=200, **kwargs): read_calls.append(lines) return "before\nE/Settings: failed\nafter" async def unused_search(**kwargs): pytest.fail("Cached raw logs should be searched locally") read_tool = node._wrap_with_caching( StructuredTool.from_function( coroutine=read_raw_logs, name="read_logs", description="read", args_schema=ReadLogsArgs ) ) search_tool = node._wrap_with_caching( StructuredTool.from_function( coroutine=unused_search, name="search_logs", description="search", args_schema=SearchLogsArgs, ) ) assert await read_tool.coroutine(lines="200") == "before\nE/Settings: failed\nafter" assert await read_tool.coroutine(lines=200) == "before\nE/Settings: failed\nafter" assert await read_tool.coroutine(lines=None) == "before\nE/Settings: failed\nafter" assert read_calls == [200] result = await search_tool.coroutine(keyword="Settings", lines="200", context_lines="1") assert "before" in result and "after" in result assert "E/Settings: failed" in result assert "before" not in await search_tool.coroutine( keyword="Settings", lines="200", context_lines=None ) assert isinstance(await search_tool.coroutine(keyword="Settings", lines="many"), ToolFailure)