Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions connectonion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -132,4 +133,5 @@
"on_error",
"on_complete",
"on_stop_signal",
"on_tool_not_found",
]
2 changes: 2 additions & 0 deletions connectonion/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions connectonion/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 28 additions & 1 deletion connectonion/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions connectonion/core/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
42 changes: 40 additions & 2 deletions docs/concepts/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
```
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"""
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/test_tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/test_tool_executor_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"}

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 = {}
Expand Down
Loading
Loading