From cc5ace85bf4c0f008b6b12d40565ebcfc917b87f Mon Sep 17 00:00:00 2001 From: Luan Hospodarsky Date: Sat, 18 Oct 2025 04:20:02 -0300 Subject: [PATCH 1/4] Make the tool error message return to the caller even when there is no real exception --- src/fast_agent/agents/mcp_agent.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/fast_agent/agents/mcp_agent.py b/src/fast_agent/agents/mcp_agent.py index 91b03ebb..21b6365a 100644 --- a/src/fast_agent/agents/mcp_agent.py +++ b/src/fast_agent/agents/mcp_agent.py @@ -666,6 +666,15 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend try: # Use our aggregator to call the MCP tool result = await self.call_tool(tool_name, tool_args) + + if result.isError: + tool_loop_error = self._mark_tool_loop_error( + correlation_id=correlation_id, + error_message='\n'.join([str(r.text) for r in result.content]), + tool_results=tool_results, + ) + self.logger.error(tool_loop_error) + break tool_results[correlation_id] = result # Show tool result (like ToolAgent does) From e8683642c61e24ef08fa54eb42fd95859ec42e54 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:58:11 +0100 Subject: [PATCH 2/4] update e2e, improve tool error loop --- src/fast_agent/agents/mcp_agent.py | 66 -------------------------- src/fast_agent/agents/tool_agent.py | 14 +++++- tests/e2e/smoke/base/test_e2e_smoke.py | 33 +++++++++++++ tests/e2e/smoke/base/test_server.py | 5 ++ 4 files changed, 51 insertions(+), 67 deletions(-) diff --git a/src/fast_agent/agents/mcp_agent.py b/src/fast_agent/agents/mcp_agent.py index 21b6365a..14e20bf3 100644 --- a/src/fast_agent/agents/mcp_agent.py +++ b/src/fast_agent/agents/mcp_agent.py @@ -260,32 +260,6 @@ async def __call__( ) -> str: return await self.send(message) - # async def send( - # self, - # message: Union[ - # str, - # PromptMessage, - # PromptMessageExtended, - # Sequence[Union[str, PromptMessage, PromptMessageExtended]], - # ], - # request_params: RequestParams | None = None, - # ) -> str: - # """ - # Send a message to the agent and get a response. - - # Args: - # message: Message content in various formats: - # - String: Converted to a user PromptMessageExtended - # - PromptMessage: Converted to PromptMessageExtended - # - PromptMessageExtended: Used directly - # - request_params: Optional request parameters - - # Returns: - # The agent's response as a string - # """ - # response = await self.generate(message, request_params) - # return response.last_text() or "" - def _matches_pattern(self, name: str, pattern: str, server_name: str) -> bool: """ Check if a name matches a pattern for a specific server. @@ -666,15 +640,6 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend try: # Use our aggregator to call the MCP tool result = await self.call_tool(tool_name, tool_args) - - if result.isError: - tool_loop_error = self._mark_tool_loop_error( - correlation_id=correlation_id, - error_message='\n'.join([str(r.text) for r in result.content]), - tool_results=tool_results, - ) - self.logger.error(tool_loop_error) - break tool_results[correlation_id] = result # Show tool result (like ToolAgent does) @@ -721,37 +686,6 @@ async def apply_prompt_template(self, prompt_result: GetPromptResult, prompt_nam with self._tracer.start_as_current_span(f"Agent: '{self._name}' apply_prompt_template"): return await self._llm.apply_prompt_template(prompt_result, prompt_name) - # async def structured( - # self, - # messages: Union[ - # str, - # PromptMessage, - # PromptMessageExtended, - # Sequence[Union[str, PromptMessage, PromptMessageExtended]], - # ], - # model: Type[ModelT], - # request_params: RequestParams | None = None, - # ) -> Tuple[ModelT | None, PromptMessageExtended]: - # """ - # Apply the prompt and return the result as a Pydantic model. - # Normalizes input messages and delegates to the attached LLM. - - # Args: - # messages: Message(s) in various formats: - # - String: Converted to a user PromptMessageExtended - # - PromptMessage: Converted to PromptMessageExtended - # - PromptMessageExtended: Used directly - # - List of any combination of the above - # model: The Pydantic model class to parse the result into - # request_params: Optional parameters to configure the LLM request - - # Returns: - # An instance of the specified model, or None if coercion fails - # """ - - # with self._tracer.start_as_current_span(f"Agent: '{self._name}' structured"): - # return await super().structured(messages, model, request_params) - async def apply_prompt_messages( self, prompts: List[PromptMessageExtended], request_params: RequestParams | None = None ) -> str: diff --git a/src/fast_agent/agents/tool_agent.py b/src/fast_agent/agents/tool_agent.py index 9fb7d815..93c40069 100644 --- a/src/fast_agent/agents/tool_agent.py +++ b/src/fast_agent/agents/tool_agent.py @@ -96,7 +96,11 @@ async def generate_impl( if LlmStopReason.TOOL_USE == result.stop_reason: tool_message = await self.run_tools(result) + + # the error channel will be populated if the LLM call failed error_channel_messages = (tool_message.channels or {}).get(FAST_AGENT_ERROR_CHANNEL) + fatal_tool_error = False + if error_channel_messages: tool_result_contents = [ content @@ -107,8 +111,16 @@ async def generate_impl( if result.content is None: result.content = [] result.content.extend(tool_result_contents) - result.stop_reason = LlmStopReason.ERROR + result.stop_reason = LlmStopReason.ERROR + else: + fatal_tool_error = not bool(tool_message.tool_results) + + if fatal_tool_error: + break + elif not tool_message.tool_results: + # No tool results returned at all – treat as unrecoverable. break + if self.config.use_history: messages = [tool_message] else: diff --git a/tests/e2e/smoke/base/test_e2e_smoke.py b/tests/e2e/smoke/base/test_e2e_smoke.py index 86c69a15..fa97ccf3 100644 --- a/tests/e2e/smoke/base/test_e2e_smoke.py +++ b/tests/e2e/smoke/base/test_e2e_smoke.py @@ -5,6 +5,7 @@ import pytest from pydantic import BaseModel, Field +from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL from fast_agent.core.prompt import Prompt if TYPE_CHECKING: @@ -180,6 +181,38 @@ class WeatherForecast(BaseModel): summary: str = Field(..., description="Brief summary of the overall forecast") +@pytest.mark.integration +@pytest.mark.asyncio +@pytest.mark.e2e +@pytest.mark.parametrize( + "model_name", + ["haiku", "kimi"], +) +async def test_error_handling_e2e(fast_agent, model_name): + """Call a faulty tool and make sure the loop does as we expect.""" + fast = fast_agent + + # Define the agent + @fast.agent( + "agent", + instruction="SYSTEM PROMPT", + model=model_name, + servers=["test_server"], + ) + async def agent_function(): + async with fast.run() as agent: + result = await agent.agent.generate("fail please") + + # assert 4 == len(provider_history.get()) + assert result + + assert 4 == len(agent.agent.message_history) + # this makes sure that the user message has the tool result with the error + assert next(iter(agent.agent.message_history[-2].tool_results.values())).isError is True + + await agent_function() + + @pytest.mark.integration @pytest.mark.asyncio @pytest.mark.e2e diff --git a/tests/e2e/smoke/base/test_server.py b/tests/e2e/smoke/base/test_server.py index 42c9672e..c4e03541 100644 --- a/tests/e2e/smoke/base/test_server.py +++ b/tests/e2e/smoke/base/test_server.py @@ -32,6 +32,11 @@ def shirt_colour() -> str: return "blue polka dots" +@app.tool(name="fail_please", description="call when asked to fail") +def fail_please() -> str: + raise ValueError("Intentional failure") + + if __name__ == "__main__": # Run the server using stdio transport app.run(transport="stdio") From 11e60042f2497f079cf265da1337aad97a1c8b45 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:00:13 +0100 Subject: [PATCH 3/4] tidy text --- tests/e2e/smoke/base/test_e2e_smoke.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/e2e/smoke/base/test_e2e_smoke.py b/tests/e2e/smoke/base/test_e2e_smoke.py index fa97ccf3..f7f97a1f 100644 --- a/tests/e2e/smoke/base/test_e2e_smoke.py +++ b/tests/e2e/smoke/base/test_e2e_smoke.py @@ -201,10 +201,7 @@ async def test_error_handling_e2e(fast_agent, model_name): ) async def agent_function(): async with fast.run() as agent: - result = await agent.agent.generate("fail please") - - # assert 4 == len(provider_history.get()) - assert result + await agent.agent.generate("fail please") assert 4 == len(agent.agent.message_history) # this makes sure that the user message has the tool result with the error From f19496caa67e30822f7ebb73ddb69c560da83bfb Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Tue, 21 Oct 2025 22:00:39 +0100 Subject: [PATCH 4/4] lint --- tests/e2e/smoke/base/test_e2e_smoke.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/smoke/base/test_e2e_smoke.py b/tests/e2e/smoke/base/test_e2e_smoke.py index f7f97a1f..544662eb 100644 --- a/tests/e2e/smoke/base/test_e2e_smoke.py +++ b/tests/e2e/smoke/base/test_e2e_smoke.py @@ -5,7 +5,6 @@ import pytest from pydantic import BaseModel, Field -from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL from fast_agent.core.prompt import Prompt if TYPE_CHECKING: