From fa33a07773ff6ae628434bc6a10009e04b06abb4 Mon Sep 17 00:00:00 2001 From: Sreekar Reddy Date: Fri, 13 Mar 2026 10:13:06 +1100 Subject: [PATCH] feat: Add on_tool_not_found event (#107) Add event that fires when LLM requests a tool that doesn't exist, enabling custom error messages, fuzzy matching suggestions, and tool usage analytics. --- CLAUDE.md | 2 +- connectonion/__init__.py | 2 + connectonion/core/__init__.py | 2 + connectonion/core/agent.py | 14 +- connectonion/core/events.py | 29 ++- connectonion/core/tool_executor.py | 15 +- docs/concepts/events.md | 42 +++- tests/unit/test_events.py | 3 +- tests/unit/test_tool_executor.py | 4 + tests/unit/test_tool_executor_errors.py | 4 + tests/unit/test_tool_not_found.py | 319 ++++++++++++++++++++++++ 11 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 tests/unit/test_tool_not_found.py diff --git a/CLAUDE.md b/CLAUDE.md index c75794d..4012fa8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ ConnectOnion is a Python framework for creating AI agents with automatic activit - **Tool Factory** (`connectonion/tool_factory.py`): Converts Python functions to OpenAI-compatible tool schemas automatically - **Logger** (`connectonion/logger.py`): Unified logging facade (terminal + plain text + YAML sessions) with `quiet` and `log` parameters - **Console** (`connectonion/console.py`): Low-level terminal output with Rich formatting (used internally by Logger) -- **Events** (`connectonion/events.py:11`): Lifecycle hooks (after_user_input, before_llm, after_llm, before_each_tool, before_tools, after_each_tool, after_tools, on_error, on_complete, on_stop_signal) +- **Events** (`connectonion/events.py:11`): Lifecycle hooks (after_user_input, before_llm, after_llm, before_each_tool, before_tools, after_each_tool, after_tools, on_error, on_complete, on_stop_signal, on_tool_not_found) - **Trust System** (`connectonion/network/trust/`): Three-level verification (open/careful/strict) with custom policy support - **XRay Debug** (`connectonion/xray.py:32`): Runtime context injection for interactive debugging with `@xray` decorator diff --git a/connectonion/__init__.py b/connectonion/__init__.py index e75a295..16bb3f1 100644 --- a/connectonion/__init__.py +++ b/connectonion/__init__.py @@ -45,6 +45,7 @@ on_error, on_complete, on_stop_signal, + on_tool_not_found, ) from .logger import Logger from .llm_do import llm_do @@ -132,4 +133,5 @@ "on_error", "on_complete", "on_stop_signal", + "on_tool_not_found", ] \ No newline at end of file diff --git a/connectonion/core/__init__.py b/connectonion/core/__init__.py index fc8945c..6e30890 100644 --- a/connectonion/core/__init__.py +++ b/connectonion/core/__init__.py @@ -34,6 +34,7 @@ on_error, on_complete, on_stop_signal, + on_tool_not_found, ) from .tool_factory import create_tool_from_function, extract_methods_from_instance, is_class_instance from .tool_registry import ToolRegistry @@ -59,6 +60,7 @@ "on_error", "on_complete", "on_stop_signal", + "on_tool_not_found", "create_tool_from_function", "extract_methods_from_instance", "is_class_instance", diff --git a/connectonion/core/agent.py b/connectonion/core/agent.py index df63eb1..9a7907c 100644 --- a/connectonion/core/agent.py +++ b/connectonion/core/agent.py @@ -4,7 +4,7 @@ Dependencies: imports from [llm.py, tool_factory.py, prompts.py, decorators.py, logger.py, tool_executor.py, tool_registry.py] | imported by [__init__.py, debug_agent/__init__.py] | tested by [tests/test_agent.py, tests/test_agent_prompts.py, tests/test_agent_workflows.py] Data flow: receives user prompt: str from Agent.input() → creates/extends current_session with messages → calls llm.complete() with tool schemas → receives LLMResponse with tool_calls → executes tools via tool_executor.execute_and_record_tools() → appends tool results to messages → repeats loop until no tool_calls or max_iterations → logger logs to .co/logs/{name}.log and .co/evals/{name}.yaml → returns final response: str State/Effects: modifies self.current_session['messages', 'trace', 'turn', 'iteration'] | writes to .co/logs/{name}.log and .co/evals/ via logger.py - Integration: exposes Agent(name, tools, system_prompt, model, log, quiet), .input(prompt), .execute_tool(name, args), .add_tool(func), .remove_tool(name), .list_tools(), .reset_conversation() | tools stored in ToolRegistry with attribute access (agent.tools.tool_name) and instance storage (agent.tools.gmail) | tool execution delegates to tool_executor module | log defaults to .co/logs/ (None), can be True (current dir), False (disabled), or custom path | quiet=True suppresses console but keeps eval logging | trust enforcement moved to host() for network access control + Integration: exposes Agent(name, tools, system_prompt, model, log, quiet), .input(prompt), .execute_tool(name, args), .add_tool(func), .remove_tool(name), .list_tools(), .reset_conversation(), ._invoke_events(event_type), ._invoke_events_with_return(event_type) | tools stored in ToolRegistry with attribute access (agent.tools.tool_name) and instance storage (agent.tools.gmail) | tool execution delegates to tool_executor module | log defaults to .co/logs/ (None), can be True (current dir), False (disabled), or custom path | quiet=True suppresses console but keeps eval logging | trust enforcement moved to host() for network access control Performance: max_iterations=100 default (configurable per-input) | session state persists across turns for multi-turn conversations | ToolRegistry provides O(1) tool lookup via .get() or attribute access Errors: LLM errors bubble up | tool execution errors captured in trace and returned to LLM for retry """ @@ -84,7 +84,8 @@ def __init__( 'after_tools': [], # Fires ONCE after ALL tools (safe for messages) 'on_error': [], 'on_complete': [], - 'on_stop_signal': [] + 'on_stop_signal': [], + 'on_tool_not_found': [], # Fires when LLM requests a non-existent tool } # Register plugin events (flatten list of lists) @@ -188,6 +189,15 @@ def _invoke_events(self, event_type: str): for handler in self.events.get(event_type, []): handler(self) + def _invoke_events_with_return(self, event_type: str): + """Invoke all event handlers for given type, return first non-None result.""" + result = None + for handler in self.events.get(event_type, []): + ret = handler(self) + if ret is not None and result is None: + result = ret + return result + def _register_event(self, event_func: EventHandler): """ Register a single event handler to appropriate event type. diff --git a/connectonion/core/events.py b/connectonion/core/events.py index 88c15e0..625651b 100644 --- a/connectonion/core/events.py +++ b/connectonion/core/events.py @@ -4,7 +4,7 @@ Dependencies: None (standalone module) | imported by [agent.py, __init__.py] | tested by [tests/test_events.py] Data flow: Wrapper functions tag event handlers with _event_type attribute → Agent organizes handlers by type → Agent invokes handlers at specific lifecycle points passing agent instance State/Effects: Event handlers receive agent instance and can modify agent.current_session (messages, trace, etc.) - Integration: exposes on_agent_ready(), after_user_input(), before_iteration(), after_iteration(), before_llm(), after_llm(), before_each_tool(), before_tools(), after_each_tool(), after_tools(), on_error(), on_complete(), on_stop_signal() + Integration: exposes on_agent_ready(), after_user_input(), before_iteration(), after_iteration(), before_llm(), after_llm(), before_each_tool(), before_tools(), after_each_tool(), after_tools(), on_error(), on_complete(), on_stop_signal(), on_tool_not_found() Performance: Minimal overhead - just function attribute checking and iteration over handler lists Errors: Event handler exceptions propagate and stop agent execution (fail fast) """ @@ -317,6 +317,33 @@ def log_done(agent): return funcs[0] if len(funcs) == 1 else list(funcs) +def on_tool_not_found(*funcs: EventHandler) -> Union[EventHandler, List[EventHandler]]: + """ + Mark function(s) as on_tool_not_found event handlers. + + Fires when LLM requests a tool that doesn't exist. + Handler can return a custom error message string to send to the LLM. + + Access the missing tool via agent.current_session['pending_tool']: + - name: Tool name that was not found + - arguments: Arguments passed to the tool + - id: Tool call ID + + Supports both decorator and wrapper syntax: + @on_tool_not_found + def suggest_tool(agent) -> str: + pending = agent.current_session['pending_tool'] + tool_name = pending['name'] + available = list(agent.tools._tools.keys()) + return f"Tool '{tool_name}' not found. Available: {available}" + + on_events=[on_tool_not_found(handler)] + """ + for fn in funcs: + fn._event_type = 'on_tool_not_found' # type: ignore + return funcs[0] if len(funcs) == 1 else list(funcs) + + def on_stop_signal(*funcs: EventHandler) -> Union[EventHandler, List[EventHandler]]: """ Mark function(s) as on_stop_signal event handlers. diff --git a/connectonion/core/tool_executor.py b/connectonion/core/tool_executor.py index d0031eb..2e66a7f 100644 --- a/connectonion/core/tool_executor.py +++ b/connectonion/core/tool_executor.py @@ -6,7 +6,7 @@ State/Effects: mutates agent.current_session['messages'] by appending assistant message with tool_calls and tool result messages | mutates agent.current_session['trace'] by appending tool_execution entries | calls logger.log_tool_call() and logger.log_tool_result() for user feedback | injects/clears xray context via thread-local storage Integration: exposes execute_and_record_tools(tool_calls, tools, agent, logger), execute_single_tool(...) | uses logger.log_tool_call(name, args) for natural function-call style output: greet(name='Alice') | creates trace entries with type, tool_name, arguments, call_id, result, status, timing, iteration, timestamp Performance: times each tool execution in milliseconds | executes tools sequentially (not parallel) | trace entry added BEFORE auto-trace so xray.trace() sees it | agent injection uses cached _needs_agent flag (set by tool_factory) instead of inspect.signature() for zero overhead - Errors: catches all tool execution exceptions | wraps errors in trace_entry with error, error_type fields | returns error message to LLM for retry | prints error to logger with red ✗ + Errors: catches all tool execution exceptions | wraps errors in trace_entry with error, error_type fields | returns error message to LLM for retry | prints error to logger with red ✗ | fires on_tool_not_found event (handler can return custom error message) when tool not in registry """ import time @@ -129,7 +129,18 @@ def execute_single_tool( # Check if tool exists tool_func = tools.get(tool_name) if tool_func is None: - error_msg = f"Tool '{tool_name}' not found" + # Store pending tool so on_tool_not_found handlers can access it + agent.current_session['pending_tool'] = { + 'name': tool_name, + 'arguments': tool_args, + 'id': tool_id, + } + + # Fire on_tool_not_found - handler can return a custom error message + custom_msg = agent._invoke_events_with_return('on_tool_not_found') + error_msg = custom_msg or f"Tool '{tool_name}' not found" + + agent.current_session.pop('pending_tool', None) trace_entry["result"] = error_msg trace_entry["status"] = "not_found" diff --git a/docs/concepts/events.md b/docs/concepts/events.md index aea3de8..9f52ebd 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -72,6 +72,7 @@ agent = Agent( | `after_each_tool` | After each tool completes | Per tool call | Log performance, side effects (no message changes!) | | `after_tools` | After ALL tools in round | Once per round | Add reflection, **ONLY place safe to modify messages** | | `on_error` | When tool fails | Per tool error | Custom error handling, retries | +| `on_tool_not_found`| When LLM requests unknown tool | Per missing tool | Custom error messages, fuzzy matching, tool suggestions | | `after_iteration` | End of iteration (after tools) | Once per iteration | Checkpoints, stop loop via `stop_loop_result` | | `on_stop_signal` | When stop_signal is set | Once per stop | Cleanup interrupted ops, save checkpoints, rollback | | `on_complete` | After agent finishes | Once per input() | Metrics, cleanup, final summary | @@ -117,6 +118,7 @@ TURN START │ ├─ after_llm │ └─ (no after_iteration - not continuing) │ +├─ on_tool_not_found ← if LLM called a tool that doesn't exist ├─ on_stop_signal ← if interrupted by stop_signal └─ on_complete ← turn ends ``` @@ -143,8 +145,8 @@ def my_event(agent): agent.current_session['turn'] # Current turn number agent.current_session['user_prompt'] # Current user input - # Only in before_each_tool events: - agent.current_session['pending_tool'] # Tool about to execute + # Only in before_each_tool and on_tool_not_found events: + agent.current_session['pending_tool'] # Tool about to execute (or missing) # {'name': 'bash', 'arguments': {'command': 'ls'}, 'id': 'call_123'} # Modify the agent: @@ -359,6 +361,42 @@ def handle_tool_error(agent): agent = Agent("assistant", tools=[search], on_events=[on_error(handle_tool_error)]) ``` +### Tool Not Found Handler (on_tool_not_found) + +Use `on_tool_not_found` to intercept cases where the LLM requests a tool that doesn't exist. Handlers can return a custom error message string that the LLM will receive instead of the default `"Tool 'x' not found"`. + +```python +from connectonion import Agent, on_tool_not_found + +@on_tool_not_found +def suggest_similar_tool(agent) -> str: + pending = agent.current_session['pending_tool'] + tool_name = pending['name'] + available = list(agent.tools._tools.keys()) + + # Simple prefix matching for suggestions + similar = [t for t in available if t.startswith(tool_name[:3])] + if similar: + return f"Tool '{tool_name}' not found. Did you mean: {', '.join(similar)}?" + return f"Tool '{tool_name}' not found. Available tools: {available}" + +agent = Agent("assistant", tools=[search, write], on_events=[suggest_similar_tool]) +``` + +**Key points:** +- `pending_tool` is set in session before the event fires: `{'name': ..., 'arguments': ..., 'id': ...}` +- Return a string from the handler to override the default error message +- Return `None` (or don't return) to use the default `"Tool 'x' not found"` message +- After `on_tool_not_found`, the `on_error` event also fires (since tool status is `not_found`) + +**Use cases for `on_tool_not_found`:** +- Fuzzy tool name matching ("srch" → "search") +- Dynamic tool loading on demand +- Better error messages listing available tools +- Tool usage analytics (track which tools the LLM tries to call) + +--- + ### Task Completion Handler (on_complete) Use `on_complete` to run logic after the agent finishes a task: diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py index 837a719..a173f3e 100644 --- a/tests/unit/test_events.py +++ b/tests/unit/test_events.py @@ -13,7 +13,7 @@ import pytest from unittest.mock import Mock -from connectonion import Agent, after_user_input, before_llm, after_llm, before_each_tool, before_tools, after_each_tool, after_tools, on_error, on_complete, on_stop_signal +from connectonion import Agent, after_user_input, before_llm, after_llm, before_each_tool, before_tools, after_each_tool, after_tools, on_error, on_complete, on_stop_signal, on_tool_not_found from connectonion.core.llm import LLMResponse, ToolCall from connectonion.core.usage import TokenUsage @@ -377,6 +377,7 @@ def handler(agent): assert on_error(handler)._event_type == 'on_error' assert on_complete(handler)._event_type == 'on_complete' assert on_stop_signal(handler)._event_type == 'on_stop_signal' + assert on_tool_not_found(handler)._event_type == 'on_tool_not_found' def test_event_validation_rejects_non_callable(self): """Test that non-callable events are rejected with clear error""" diff --git a/tests/unit/test_tool_executor.py b/tests/unit/test_tool_executor.py index 2ad7a03..daf6711 100644 --- a/tests/unit/test_tool_executor.py +++ b/tests/unit/test_tool_executor.py @@ -41,6 +41,10 @@ def _invoke_events(self, event_type: str): """Stub for event invocation - no-op in test.""" pass + def _invoke_events_with_return(self, event_type: str): + """Stub for event invocation with return - returns None in test.""" + return None + class TestToolExecutor: """Test minimal path of tool execution utility.""" diff --git a/tests/unit/test_tool_executor_errors.py b/tests/unit/test_tool_executor_errors.py index efc5092..42b831d 100644 --- a/tests/unit/test_tool_executor_errors.py +++ b/tests/unit/test_tool_executor_errors.py @@ -48,6 +48,7 @@ def create_mock_agent(): 'iteration': 1 } mock_agent._invoke_events = Mock() + mock_agent._invoke_events_with_return = Mock(return_value=None) # Add _record_trace method that actually adds to trace def record_trace(entry): @@ -71,6 +72,7 @@ def test_tool_not_found_creates_error_trace(self): 'trace': [], 'iteration': 1 } + mock_agent._invoke_events_with_return = Mock(return_value=None) logger = Logger("test", log=False) tools = {"existing_tool": lambda x: "result"} @@ -129,6 +131,7 @@ def test_tool_not_found_in_execute_and_record(self): 'iteration': 1 } mock_agent._invoke_events = Mock() + mock_agent._invoke_events_with_return = Mock(return_value=None) logger = Logger("test", log=False) tools = {"calc": lambda x: x * 2} @@ -627,6 +630,7 @@ def test_not_found_trace_entry_structure(self): 'trace': [], 'iteration': 1 } + mock_agent._invoke_events_with_return = Mock(return_value=None) logger = Logger("test", log=False) tools = {} diff --git a/tests/unit/test_tool_not_found.py b/tests/unit/test_tool_not_found.py new file mode 100644 index 0000000..a55351b --- /dev/null +++ b/tests/unit/test_tool_not_found.py @@ -0,0 +1,319 @@ +"""Unit tests for on_tool_not_found event. + +Tests cover: +- on_tool_not_found fires when tool is missing +- handler return value overrides default error message +- handler returning None uses default error message +- multiple handlers: first non-None return wins +- pending_tool set in session before event fires +- pending_tool cleared from session after event +- event exported from connectonion package +""" +""" +LLM-Note: Tests for on_tool_not_found event + +What it tests: +- on_tool_not_found event lifecycle + +Components under test: +- Module: tool_executor (execute_single_tool) +- Module: events (on_tool_not_found) +- Module: agent (_invoke_events_with_return) +""" + +import pytest +from connectonion.core.tool_executor import execute_single_tool +from connectonion.core.tool_registry import ToolRegistry +from connectonion.logger import Logger +from connectonion import on_tool_not_found + + +class FakeAgent: + def __init__(self, handlers=None): + self.name = "test-agent" + self.current_session = {"messages": [], "trace": [], "iteration": 1} + self.connection = None + self.io = None + self._trace_id = 0 + self._handlers = handlers or [] + + def _next_trace_id(self): + self._trace_id += 1 + return self._trace_id + + def _record_trace(self, entry: dict): + if 'id' not in entry: + entry['id'] = self._next_trace_id() + self.current_session['trace'].append(entry) + + def _invoke_events(self, event_type: str): + pass + + def _invoke_events_with_return(self, event_type: str): + result = None + for handler_type, handler in self._handlers: + if handler_type == event_type: + ret = handler(self) + if ret is not None and result is None: + result = ret + return result + + +class TestOnToolNotFoundEvent: + """Test on_tool_not_found fires when tool is missing.""" + + def test_fires_when_tool_missing(self): + """on_tool_not_found handler should be called for missing tool.""" + called_with = [] + + def handler(agent): + called_with.append(agent.current_session.get('pending_tool')) + + agent = FakeAgent(handlers=[('on_tool_not_found', handler)]) + tools = ToolRegistry() + logger = Logger("test", log=False) + + execute_single_tool("nonexistent_tool", {}, "call_1", tools, agent, logger) + + assert len(called_with) == 1 + assert called_with[0]['name'] == 'nonexistent_tool' + + def test_does_not_fire_for_existing_tool(self): + """on_tool_not_found should NOT fire when tool exists.""" + called = [] + + def handler(agent): + called.append(True) + + def my_tool() -> str: + return "ok" + + from connectonion.core.tool_factory import create_tool_from_function + tools = ToolRegistry() + tools.add(create_tool_from_function(my_tool)) + agent = FakeAgent(handlers=[('on_tool_not_found', handler)]) + logger = Logger("test", log=False) + + execute_single_tool("my_tool", {}, "call_1", tools, agent, logger) + + assert len(called) == 0 + + +class TestCustomErrorMessage: + """Test handler return value overrides default error message.""" + + def test_handler_return_overrides_default(self): + """Handler returning a string should use it as the error message.""" + def handler(agent): + return "Custom: tool not found, try 'search' instead" + + agent = FakeAgent(handlers=[('on_tool_not_found', handler)]) + tools = ToolRegistry() + logger = Logger("test", log=False) + + trace = execute_single_tool("missing_tool", {}, "call_1", tools, agent, logger) + + assert trace["result"] == "Custom: tool not found, try 'search' instead" + assert trace["error"] == "Custom: tool not found, try 'search' instead" + + def test_handler_returning_none_uses_default(self): + """Handler returning None should fall back to default error message.""" + def handler(agent): + return None + + agent = FakeAgent(handlers=[('on_tool_not_found', handler)]) + tools = ToolRegistry() + logger = Logger("test", log=False) + + trace = execute_single_tool("missing_tool", {}, "call_1", tools, agent, logger) + + assert trace["result"] == "Tool 'missing_tool' not found" + + def test_no_handler_uses_default_message(self): + """Without handlers, default error message should be used.""" + agent = FakeAgent() + tools = ToolRegistry() + logger = Logger("test", log=False) + + trace = execute_single_tool("unknown_tool", {}, "call_1", tools, agent, logger) + + assert trace["result"] == "Tool 'unknown_tool' not found" + assert trace["status"] == "not_found" + + def test_first_non_none_handler_wins(self): + """When multiple handlers, first non-None return should be used.""" + def handler_none(agent): + return None + + def handler_first(agent): + return "First handler message" + + def handler_second(agent): + return "Second handler message" + + agent = FakeAgent(handlers=[ + ('on_tool_not_found', handler_none), + ('on_tool_not_found', handler_first), + ('on_tool_not_found', handler_second), + ]) + tools = ToolRegistry() + logger = Logger("test", log=False) + + trace = execute_single_tool("missing_tool", {}, "call_1", tools, agent, logger) + + assert trace["result"] == "First handler message" + + +class TestPendingToolInSession: + """Test pending_tool is set and cleared correctly.""" + + def test_pending_tool_available_during_handler(self): + """pending_tool should be set in session when handler fires.""" + captured = {} + + def handler(agent): + captured.update(agent.current_session.get('pending_tool', {})) + + agent = FakeAgent(handlers=[('on_tool_not_found', handler)]) + tools = ToolRegistry() + logger = Logger("test", log=False) + + execute_single_tool("missing_tool", {"arg": "val"}, "call_99", tools, agent, logger) + + assert captured['name'] == 'missing_tool' + assert captured['arguments'] == {"arg": "val"} + assert captured['id'] == 'call_99' + + def test_pending_tool_cleared_after_handler(self): + """pending_tool should be removed from session after handler completes.""" + agent = FakeAgent() + tools = ToolRegistry() + logger = Logger("test", log=False) + + execute_single_tool("missing_tool", {}, "call_1", tools, agent, logger) + + assert 'pending_tool' not in agent.current_session + + def test_trace_status_is_not_found(self): + """Trace entry status should be 'not_found' for missing tools.""" + agent = FakeAgent() + tools = ToolRegistry() + logger = Logger("test", log=False) + + trace = execute_single_tool("missing_tool", {}, "call_1", tools, agent, logger) + + assert trace["status"] == "not_found" + assert "missing_tool" in trace["error"] + + +class TestEventWrapper: + """Test on_tool_not_found wrapper in events module.""" + + def test_decorator_sets_event_type(self): + """@on_tool_not_found decorator should set _event_type attribute.""" + @on_tool_not_found + def my_handler(agent): + pass + + assert my_handler._event_type == 'on_tool_not_found' + + def test_wrapper_syntax_sets_event_type(self): + """on_tool_not_found(fn) wrapper syntax should set _event_type.""" + def my_handler(agent): + pass + + wrapped = on_tool_not_found(my_handler) + assert wrapped._event_type == 'on_tool_not_found' + + def test_multiple_handlers_returns_list(self): + """on_tool_not_found(fn1, fn2) should return a list.""" + def h1(agent): pass + def h2(agent): pass + + result = on_tool_not_found(h1, h2) + assert isinstance(result, list) + assert len(result) == 2 + assert all(h._event_type == 'on_tool_not_found' for h in result) + + +class TestAgentIntegration: + """Test on_tool_not_found integrates with real Agent.""" + + def test_agent_registers_event(self): + """Agent should accept on_tool_not_found handlers via on_events.""" + from connectonion import Agent + from unittest.mock import Mock + from connectonion.core.llm import LLMResponse + from connectonion.core.usage import TokenUsage + + mock_llm = Mock() + mock_llm.model = "test-model" + mock_llm.complete.return_value = LLMResponse( + content="done", + tool_calls=[], + raw_response=None, + usage=TokenUsage(), + ) + + @on_tool_not_found + def my_handler(agent) -> str: + return "handled" + + agent = Agent("test", llm=mock_llm, on_events=[my_handler], log=False) + + assert 'on_tool_not_found' in agent.events + assert len(agent.events['on_tool_not_found']) == 1 + + def test_invoke_events_with_return_returns_first_non_none(self): + """_invoke_events_with_return should return first non-None handler result.""" + from connectonion import Agent + from unittest.mock import Mock + from connectonion.core.llm import LLMResponse + from connectonion.core.usage import TokenUsage + + mock_llm = Mock() + mock_llm.model = "test-model" + mock_llm.complete.return_value = LLMResponse( + content="done", + tool_calls=[], + raw_response=None, + usage=TokenUsage(), + ) + + @on_tool_not_found + def returns_none(agent): + return None + + @on_tool_not_found + def returns_msg(agent): + return "custom error" + + agent = Agent("test", llm=mock_llm, on_events=[returns_none, returns_msg], log=False) + result = agent._invoke_events_with_return('on_tool_not_found') + + assert result == "custom error" + + def test_invoke_events_with_return_all_none_returns_none(self): + """_invoke_events_with_return should return None when all handlers return None.""" + from connectonion import Agent + from unittest.mock import Mock + from connectonion.core.llm import LLMResponse + from connectonion.core.usage import TokenUsage + + mock_llm = Mock() + mock_llm.model = "test-model" + mock_llm.complete.return_value = LLMResponse( + content="done", + tool_calls=[], + raw_response=None, + usage=TokenUsage(), + ) + + @on_tool_not_found + def returns_none(agent): + return None + + agent = Agent("test", llm=mock_llm, on_events=[returns_none], log=False) + result = agent._invoke_events_with_return('on_tool_not_found') + + assert result is None