diff --git a/docs/MIGRATION_CALL_TOOLS.md b/docs/MIGRATION_CALL_TOOLS.md new file mode 100644 index 0000000000..f384483d23 --- /dev/null +++ b/docs/MIGRATION_CALL_TOOLS.md @@ -0,0 +1,128 @@ +# Migration Guide: `_call_tools` → `call_tools` (Public API) + +As of this release, `_call_tools()` and `_acall_tools()` have been promoted from +private functions to a stable public API: `call_tools()` and `acall_tools()`. + +## Overview + +The function signatures and behavior remain **unchanged** — this is a pure +naming/visibility upgrade. You can migrate at your own pace; the old names +continue to work with no deprecation warnings (yet). + +## Migration Steps + +### Step 1: Update imports + +Replace underscore-prefixed imports with the public names: + +```python +# ❌ Old (private API) +from mellea.stdlib.functional import _call_tools, _acall_tools + +# ✓ New (public API) +from mellea.stdlib.functional import call_tools, acall_tools +``` + +### Step 2: Update function calls + +Replace function calls throughout your code: + +```python +# ❌ Old +tool_messages = _call_tools(result, backend) + +# ✓ New +tool_messages = call_tools(result, backend) +``` + +```python +# ❌ Old +tool_messages = await _acall_tools(result, backend) + +# ✓ New +tool_messages = await acall_tools(result, backend) +``` + +## Timeline + +| Phase | Date | Action | +| --- | --- | --- | +| Current | Now | New public API; old names work as aliases | +| Deprecation (2-3 releases) | TBD | Add `DeprecationWarning` to old names | +| Removal (next major) | TBD | Remove `_call_tools` and `_acall_tools` | + +## Why the Change? + +- **Stability**: These functions are fundamental to Mellea's extensibility +- **Discoverability**: Public functions appear in generated API docs and IDE + autocompletion +- **Commitment**: Public APIs get stability guarantees; private ones can change + anytime + +## Backward Compatibility + +For now, `_call_tools` and `_acall_tools` continue to work: + +```python +# Still works (but migrate when convenient) +from mellea.stdlib.functional import _call_tools +result = _call_tools(mot, backend) +``` + +However, we recommend migrating during your next code review cycle to ensure +your documentation and examples are up-to-date. + +## Documentation + +After migration, see: + +- [How-To: Execute Tool Calls](docs/how-to/execute-tool-calls.md) — Reference + and patterns +- [How-To: Choosing Primitives vs High-Level APIs](docs/how-to/primitives-vs-high-level.md) + — When to use `call_tools()` +- [GitHub Discussion #1460](https://github.com/generative-computing/mellea/discussions/1460) + — Design discussion and rationale + +## Examples + +### Basic Migration + +**Before:** + +```python +from mellea.stdlib.functional import _call_tools + +tool_messages = _call_tools(result, backend) +``` + +**After:** + +```python +from mellea.stdlib.functional import call_tools + +tool_messages = call_tools(result, backend) +``` + +### Async Migration + +**Before:** + +```python +from mellea.stdlib.functional import _acall_tools + +tool_messages = await _acall_tools(result, backend) +``` + +**After:** + +```python +from mellea.stdlib.functional import acall_tools + +tool_messages = await acall_tools(result, backend) +``` + +## Questions? + +- Check the new documentation in `docs/docs/how-to/execute-tool-calls.md` +- See examples in `docs/examples/primitives/` +- Open an issue on GitHub diff --git a/docs/docs/how-to/execute-tool-calls.md b/docs/docs/how-to/execute-tool-calls.md new file mode 100644 index 0000000000..bfb1e1da73 --- /dev/null +++ b/docs/docs/how-to/execute-tool-calls.md @@ -0,0 +1,244 @@ +--- +title: Execute Tool Calls +description: Use call_tools and acall_tools to implement custom agentic loops with low-level tool execution control. +--- + +When building custom agentic patterns or advanced session management, you may need direct control over tool execution. Mellea provides `call_tools()` and `acall_tools()` primitives for this purpose. + +## Overview + +Most applications use high-level APIs like `act()`, `instruct()`, or the `MelleaSession` which handle context management and tool call generation. However, these APIs return the model's output—you must call `call_tools()` to execute the generated tool calls. When implementing a custom ReACT loop, multi-turn tool orchestration, or specialized context management, you can call `call_tools()` directly for fine-grained control. + +The `call_tools()` function: + +- Takes a model's tool call output and executes each tool +- Fires `TOOL_PRE_INVOKE` and `TOOL_POST_INVOKE` hooks for observability +- Returns a list of `ToolMessage` objects with results +- Does **not** manage context — you handle that yourself + +## When to Use + +Use `call_tools()` when you need: + +- **Custom agentic loops**: Implementing ReACT or similar patterns with specialized control flow +- **Advanced context management**: Managing multiple contexts or non-linear conversation flows +- **Fine-grained tool execution control**: Filtering, transforming, or inspecting tool calls before execution +- **Tool execution hooks**: Plugins that must observe or modify every tool call with full lifecycle visibility + +Use **higher-level APIs** (`act()`, `instruct()`, `chat()`) when you: + +- Want automatic context management +- Don't need to inspect or transform tool calls before execution +- Prefer simpler, more declarative code (single function calls vs. explicit tool execution + context management) + +## Basic Usage + +```python +from mellea.stdlib.functional import call_tools +from mellea.stdlib.context import SimpleContext + +# Step 1: Generate with tool calls enabled +result, ctx = instruct( + "Use the calculator to compute 2 + 2", + context, + backend, + tool_calls=True, # Enable tool calling +) + +# Step 2: Execute the tools +tool_messages = call_tools(result, backend) + +# Step 3: Add results to context manually +for tool_message in tool_messages: + ctx = ctx.add(tool_message) + +# Step 4: Continue the conversation +next_result, ctx = instruct( + "What was the result?", + ctx, + backend, +) +``` + +## Async Usage + +For async code, use `acall_tools()`: + +```python +from mellea.stdlib.functional import acall_tools + +# In async context +tool_messages = await acall_tools(result, backend) +``` + +## Understanding Return Values + +`call_tools()` returns `list[ToolMessage]`. Each `ToolMessage` contains: + +- `name`: Tool name (str) +- `content`: Formatted output (str) +- `_tool_output`: Raw Python object returned by the tool +- `arguments`: Arguments passed to the tool (Mapping) +- `_tool`: The `ModelToolCall` that was executed + +```python +tool_messages = call_tools(result, backend) + +for msg in tool_messages: + print(f"Tool: {msg.name}") + print(f"Arguments: {msg.arguments}") + print(f"Output: {msg._tool_output}") + print(f"Content: {msg.content}") +``` + +## Custom Context Management + +When using `call_tools()`, you manage context transitions yourself: + +```python +# Start with an empty context +ctx = SimpleContext() + +# Add initial message +ctx = ctx.add(Message("user", "What is the weather in Boston?")) + +# Generate with tools enabled +result, ctx = aact( + instruction, + ctx, + backend, + tool_calls=True, + await_result=True, +) + +# Execute tools +tool_messages = await acall_tools(result, backend) + +# Add all tool results to context +for tool_msg in tool_messages: + ctx = ctx.add(tool_msg) + +# Generate final response +final_result, ctx = aact( + Message("assistant", ""), # Placeholder for response synthesis + ctx, + backend, +) +``` + +## Hook Integration + +Tool execution fires two hooks you can use with the plugin system: + +### TOOL_PRE_INVOKE + +Fires **before** tool execution. Use for: + +- Validating or modifying arguments +- Implementing allowlists/denylists +- Logging or auditing + +```python +from mellea.plugins import hook, HookType + +@hook(HookType.TOOL_PRE_INVOKE) +async def validate_tool(payload, backend): + if payload.model_tool_call.name not in ALLOWED_TOOLS: + return block(f"Tool not allowed: {payload.model_tool_call.name}") +``` + +### TOOL_POST_INVOKE + +Fires **after** tool execution. Use for: + +- Processing or transforming results +- Logging execution metrics +- Error recovery + +```python +@hook(HookType.TOOL_POST_INVOKE) +async def log_execution(payload, backend): + print(f"Tool {payload.model_tool_call.name} took {payload.execution_time_ms}ms") + if payload.error: + print(f"Error: {payload.error}") +``` + +## Real-World Example: Simple ReACT + +Here's a minimal ReACT implementation using `call_tools()`: + +```python +from mellea.stdlib.functional import aact, acall_tools +from mellea.stdlib.components import Message +from mellea.stdlib.context import ChatContext + +async def simple_react(goal: str, backend, tools: list, max_steps: int = 5): + """Simple ReACT: Think → Act → Observe → Repeat""" + ctx = ChatContext().add(Message("user", f"Goal: {goal}")) + + for step in range(max_steps): + print(f"\n--- Step {step + 1} ---") + + # Think & Act: Generate with tool calls enabled + result, ctx = await aact( + Message("system", "Reason about the goal, then call a tool if needed."), + ctx, + backend, + tool_calls=True, + await_result=True, + ) + print(f"Thought: {result.value[:200]}...") + + # Check for final answer + if "FINAL ANSWER" in result.value: + return result.value + + # Observe: Execute tools + tool_messages = await acall_tools(result, backend) + if not tool_messages: + print("No tools called. Stopping.") + break + + # Add observations to context + for msg in tool_messages: + ctx = ctx.add(msg) + print(f"Observation: {msg.name} → {msg.content[:100]}...") + + return "Max steps reached" +``` + +## Comparison with Higher-Level APIs + +| Feature | `call_tools()` | `act()` | `instruct()` | +| --- | --- | --- | --- | +| Context management | Manual | Automatic | Automatic | +| Tool call generation | N/A | Automatic | Automatic | +| Tool execution | Manual | Manual (you call `call_tools()`) | Manual (you call `call_tools()`) | +| Hook support | Yes | Yes | Yes | +| Telemetry | Yes | Yes | Yes | +| Validation/repair loop | No | Optional | Optional | +| Use case | Control execution flow | General purpose generation | Tasks & instructions | +| Complexity | Higher | Medium | Low | + +## Migration Path + +If you're currently using private `_call_tools` or `_acall_tools`, migrate to the public API: + +```python +# Old (deprecated) +from mellea.stdlib.functional import _call_tools +result = _call_tools(mot, backend) + +# New (public) +from mellea.stdlib.functional import call_tools +result = call_tools(mot, backend) +``` + +The old names still work (aliased to the new ones) but are deprecated. Plan to migrate within the next major release. + +## See Also + +- [How-To: Act and Aact](act-and-aact.md) — Higher-level generation primitives +- [How-To: Use Context and Sessions](use-context-and-sessions.md) — Context management strategies +- [How-To: Debug with Plugins](debug-with-plugins.md) — Using tool hooks for observability +- [GitHub Discussion #1460](https://github.com/generative-computing/mellea/discussions/1460) — Design discussion on this API promotion diff --git a/docs/docs/how-to/primitives-vs-high-level.md b/docs/docs/how-to/primitives-vs-high-level.md new file mode 100644 index 0000000000..c05c7e1fa7 --- /dev/null +++ b/docs/docs/how-to/primitives-vs-high-level.md @@ -0,0 +1,291 @@ +--- +title: Choosing Between Primitives and High-Level APIs +description: Understand when to use call_tools/acall_tools versus act/instruct and MelleaSession. +--- + +Mellea provides multiple levels of abstraction for building generative applications. Understanding which to use depends on your needs. + +## The Abstraction Hierarchy + +```text +High-level (simplest) +│ +├─ MelleaSession (session management + context + generation) +├─ instruct() / chat() (generation + context management) +├─ act() / aact() (generation + basic context management) +│ +Low-level (most control) +├─ call_tools() / acall_tools() (tool execution only) +└─ generate_from_context() (raw model call) +``` + +Each level adds convenience by automating lower-level concerns. + +## Comparison Table + +| Aspect | `MelleaSession` | `act()`/`instruct()` | `call_tools()` | +| --- | --- | --- | --- | +| **Context management** | Automatic, stateful | Automatic, immutable | Manual | +| **Generation** | Built-in | Yes | No | +| **Tool call generation** | Built-in (via generation) | Built-in (via generation) | N/A | +| **Tool execution** | Manual (via loop or hooks) | Manual (you call `call_tools()`) | Manual | +| **Hook support** | Full | Full | Full | +| **Sampling/repair** | Via sampling strategy | Via sampling strategy | N/A | +| **Validation** | Via requirements | Via requirements | N/A | +| **Lines of code** | 5-10 | 10-20 | 20-50 | +| **Learning curve** | Low | Medium | High | +| **Flexibility** | Medium | High | Very high | + +## Decision Tree + +Start here to pick the right level: + +```text +Does your code need to: + +1. "Manage a multi-turn conversation"? + YES → Use MelleaSession + NO → Continue + +2. "Make a single LLM call with tools"? + YES → Use act() or instruct() + NO → Continue + +3. "Execute already-generated tool calls"? + YES → Use call_tools() + NO → Continue + +4. "Make a raw LLM call"? + YES → Use backend.generate_from_context() +``` + +## Detailed Scenarios + +### Scenario 1: Simple Single-Turn Chat + +**Task**: User asks a question, model answers. + +**Best choice**: `MelleaSession` or `chat()` + +```python +# With MelleaSession (simplest) +with start_session() as m: + result = m.chat("What is 2+2?") + print(result.value) + +# With chat() (explicit, no session state) +from mellea.stdlib.functional import chat +result, ctx = chat("What is 2+2?", ctx, backend) +``` + +✅ Why: Handles context automatically, minimal code. + +--- + +### Scenario 2: Multi-Turn Conversation + +**Task**: Build a chatbot that remembers previous messages. + +**Best choice**: `MelleaSession` + +```python +with start_session() as m: + m.chat("My name is Alice") + m.chat("What is my name?") # Remembers "Alice" + m.chat("What is 10 + 5?") +``` + +✅ Why: Context accumulates automatically, stateful by design. + +--- + +### Scenario 3: Controlled Tool Execution + +**Task**: Generate tool calls, then inspect and filter before execution. + +**Best choice**: `act()` or `instruct()` + `call_tools()` + +```python +# Generate with tools +result, ctx = instruct( + "Calculate 2 + 3", + ctx, + backend, + tool_calls=True, + model_options={ModelOption.TOOLS: [add, multiply]} +) + +# Inspect and filter before executing +safe_calls = [tc for tc in result.tool_calls if is_safe(tc)] +if safe_calls: + # Execute only safe tool calls + tool_messages = call_tools(result, backend) + for msg in tool_messages: + ctx = ctx.add(msg) +``` + +✅ Why: Generate with higher-level APIs, use `call_tools()` for execution control. + +--- + +### Scenario 4: Custom ReACT Loop + +**Task**: Implement "Reason → Act → Observe → Repeat" with custom logic. + +**Best choice**: `call_tools()` + `act()` in a loop + +```python +async def custom_react(goal, backend, tools): + ctx = ChatContext().add(Message("user", goal)) + + for step in range(max_steps): + # Think + result, ctx = await aact( + system_prompt, ctx, backend, tool_calls=True + ) + + # Act (manual tool execution gives you control) + tool_messages = await acall_tools(result, backend) + + # Observe + for msg in tool_messages: + ctx = ctx.add(msg) + # Can inspect/transform results before adding to context +``` + +✅ Why: You control the loop flow, context management, and tool filtering. + +--- + +### Scenario 5: Sampling/Validation Loop + +**Task**: Generate, validate, and repair until requirements are met. + +**Best choice**: `act()` with a `SamplingStrategy` + +```python +from mellea.stdlib.sampling import RejectionSamplingStrategy + +result = act( + instruction, + ctx, + backend, + requirements=[must_be_brief, must_be_json], + strategy=RejectionSamplingStrategy(loop_budget=3), +) +``` + +✅ Why: `act()` handles the validate-repair loop, you just provide requirements. + +--- + +### Scenario 6: Tool Execution with Plugin Hooks + +**Task**: Log, audit, or intercept every tool call. + +**Best choice**: Any level — hooks work everywhere + +```python +@hook(HookType.TOOL_PRE_INVOKE) +async def audit(payload, _): + log.info(f"Tool: {payload.model_tool_call.name}") + +# Hooks fire regardless of whether you use call_tools(), +# act(), instruct(), or MelleaSession +``` + +✅ Why: Hooks are orthogonal to the API level — use them everywhere. + +--- + +## Migration Patterns + +### From Raw API Calls → `act()` + +**Before** (manual context management): + +```python +result, ctx = backend.generate_from_context(component, ctx=ctx) +ctx = ctx.add(result) +``` + +**After** (automatic context management): + +```python +result, ctx = act(component, ctx, backend) +``` + +✅ Benefits: Cleaner, handles edge cases, telemetry included. + +--- + +### From `act()` → `MelleaSession` + +**Before** (passing context manually): + +```python +result1, ctx = act(msg1, ctx, backend) +result2, act(msg2, ctx, backend) +result3, ctx = act(msg3, ctx, backend) +``` + +**After** (context managed automatically): + +```python +with start_session() as m: + result1 = m.act(msg1, backend) + result2 = m.act(msg2, backend) + result3 = m.act(msg3, backend) +``` + +✅ Benefits: No context threading, cleaner, easier to reason about. + +--- + +### From `act()` → `call_tools()` for Specialized Loops + +**Before** (basic tool generation): + +```python +result, ctx = act(prompt, ctx, backend, tool_calls=True) +# Tool calls generated but not executed +# You'd need to manually call call_tools() to execute them +``` + +**After** (explicit execution control with inspection): + +```python +result, ctx = act(prompt, ctx, backend, tool_calls=True) + +# Inspect tool calls before execution +for tc in result.tool_calls: + print(f"Will execute: {tc.name} with {tc.args}") + +# Execute and add to context +tool_msgs = call_tools(result, backend) +for msg in tool_msgs: + if is_approved(msg): # Custom filtering + ctx = ctx.add(msg) +``` + +✅ Benefits: Full control over tool execution timing, inspection, and filtering. + +--- + +## Summary: Quick Pick + +| You want to... | Use... | +| --- | --- | +| Build a chatbot | `MelleaSession` | +| Make a single generation | `act()` or `instruct()` | +| Validate & repair | `act()` + `SamplingStrategy` | +| Custom agentic loop | `call_tools()` + your loop | +| Just execute tools | `call_tools()` | +| Raw LLM call | `backend.generate_from_context()` | +| Observe all tool calls | Add `@hook(HookType.TOOL_PRE/POST_INVOKE)` | + +## See Also + +- [How-To: Act and Aact](act-and-aact.md) — `act()` and `aact()` in detail +- [How-To: Execute Tool Calls](execute-tool-calls.md) — `call_tools()` and `acall_tools()` +- [How-To: Use Context and Sessions](use-context-and-sessions.md) — `MelleaSession` and context management diff --git a/docs/examples/plugins/tool_hooks.py b/docs/examples/plugins/tool_hooks.py index 96ec518baa..5afe4b96c5 100644 --- a/docs/examples/plugins/tool_hooks.py +++ b/docs/examples/plugins/tool_hooks.py @@ -31,7 +31,7 @@ block, hook, ) -from mellea.stdlib.functional import _call_tools +from mellea.stdlib.functional import call_tools from mellea.stdlib.requirements import uses_tool logging.basicConfig( @@ -326,7 +326,7 @@ def scenario_1_allowed_tool(all_tools): model_options={ModelOption.TOOLS: all_tools}, tool_calls=True, ) - tool_outputs = _call_tools(result, m.backend) + tool_outputs = call_tools(result, m.backend) if tool_outputs: log.info("Tool returned: %s", tool_outputs[0].content) else: @@ -342,7 +342,7 @@ def scenario_2_blocked_tool(all_tools): model_options={ModelOption.TOOLS: all_tools}, tool_calls=True, ) - tool_outputs = _call_tools(result, m.backend) + tool_outputs = call_tools(result, m.backend) if not tool_outputs: log.info("Tool call was blocked — outputs list is empty, as expected") else: @@ -358,7 +358,7 @@ def scenario_3_safe_calculator(all_tools): model_options={ModelOption.TOOLS: all_tools}, tool_calls=True, ) - tool_outputs = _call_tools(result, m.backend) + tool_outputs = call_tools(result, m.backend) if tool_outputs: log.info("Tool returned: %s", tool_outputs[0].content) else: @@ -377,7 +377,7 @@ def scenario_4_blocked_calculator(all_tools): model_options={ModelOption.TOOLS: all_tools}, tool_calls=True, ) - tool_outputs = _call_tools(result, m.backend) + tool_outputs = call_tools(result, m.backend) if not tool_outputs: log.info("Tool call was blocked — outputs list is empty, as expected") else: @@ -396,7 +396,7 @@ def scenario_5_sanitizer_calculator(all_tools): model_options={ModelOption.TOOLS: all_tools}, tool_calls=True, ) - tool_outputs = _call_tools(result, m.backend) + tool_outputs = call_tools(result, m.backend) if tool_outputs: log.info( "Sanitized expression evaluated — tool returned: %s", @@ -415,7 +415,7 @@ def scenario_6_sanitizer_location(all_tools): model_options={ModelOption.TOOLS: all_tools}, tool_calls=True, ) - tool_outputs = _call_tools(result, m.backend) + tool_outputs = call_tools(result, m.backend) if tool_outputs: log.info( "Weather fetched with normalised location — tool returned: %s", diff --git a/docs/examples/primitives/call_tools_basic.py b/docs/examples/primitives/call_tools_basic.py new file mode 100644 index 0000000000..c9ae56d3d7 --- /dev/null +++ b/docs/examples/primitives/call_tools_basic.py @@ -0,0 +1,107 @@ +# pytest: ollama, e2e +"""Basic example of using call_tools() to execute model-requested tool calls. + +This example shows how to use the low-level call_tools() primitive to manually +execute tools generated by a model. While higher-level APIs like act() and +instruct() generate tool calls automatically, they do not execute them—you must +call call_tools() yourself to run the generated tools. + +Use call_tools() when you need custom control over tool execution, such as +implementing a custom agentic loop or specializing context management. + +Run: + uv run python docs/examples/primitives/call_tools_basic.py +""" + +from mellea import start_session +from mellea.backends import ModelOption, tool +from mellea.stdlib.functional import acall_tools, call_tools + + +@tool +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@tool +def multiply(a: int, b: int) -> int: + """Multiply two numbers.""" + return a * b + + +def example_manual_tool_execution(): + """Show how to manually execute tool calls with call_tools().""" + with start_session() as m: + # Step 1: Generate with tool calls enabled + print("Step 1: Generate with tool_calls=True") + result = m.instruct( + description="What is (2 + 3) * 4?", + model_options={ModelOption.TOOLS: [add, multiply]}, + tool_calls=True, + ) + print(f" Model output: {result.value[:100]}...") + + # Step 2: Execute the tools using call_tools() + print("\nStep 2: Execute tools with call_tools()") + tool_messages = call_tools(result, m.backend) + print(f" Tool calls executed: {len(tool_messages)}") + + # Step 3: Show the results + print("\nStep 3: Tool results") + for i, tool_msg in enumerate(tool_messages): + print(f" Tool {i + 1}: {tool_msg.name}") + print(f" Arguments: {tool_msg.arguments}") + print(f" Output: {tool_msg._tool_output}") + print(f" Content: {tool_msg.content}") + + +def example_context_management(): + """Show how to manage context when using call_tools().""" + import asyncio + + from mellea.stdlib.components import Message + from mellea.stdlib.context import SimpleContext + from mellea.stdlib.functional import aact + + async def run(): + with start_session() as m: + ctx = SimpleContext() + + # Add an initial message + user_msg = Message("user", "Add 10 and 20") + ctx = ctx.add(user_msg) + print("Step 1: Added user message to context") + + # Generate with tools using functional API + result, ctx = await aact( + user_msg, + ctx, + m.backend, + model_options={ModelOption.TOOLS: [add, multiply]}, + tool_calls=True, + await_result=True, + ) + print("Step 2: Generated response with tool calls") + + # Execute tools and add results manually + tool_messages = await acall_tools(result, m.backend) + print(f"Step 3: Executed {len(tool_messages)} tool(s)") + + for tool_msg in tool_messages: + ctx = ctx.add(tool_msg) + print("Step 4: Added tool results to context") + + asyncio.run(run()) + + +if __name__ == "__main__": + print("=" * 60) + print("Example 1: Manual tool execution with call_tools()") + print("=" * 60) + example_manual_tool_execution() + + print("\n" + "=" * 60) + print("Example 2: Context management with call_tools()") + print("=" * 60) + example_context_management() diff --git a/docs/examples/primitives/call_tools_with_hooks.py b/docs/examples/primitives/call_tools_with_hooks.py new file mode 100644 index 0000000000..00abdb9968 --- /dev/null +++ b/docs/examples/primitives/call_tools_with_hooks.py @@ -0,0 +1,149 @@ +# pytest: ollama, e2e +"""Advanced: Using call_tools() with tool execution hooks. + +This example demonstrates using TOOL_PRE_INVOKE and TOOL_POST_INVOKE hooks +with call_tools() to observe and control tool execution. Hooks fire even +when using the low-level call_tools() primitive, providing full plugin support. + +Run: + uv run python docs/examples/primitives/call_tools_with_hooks.py +""" + +import logging + +from mellea import start_session +from mellea.backends import ModelOption, tool +from mellea.plugins import HookType, PluginMode, block, hook, register +from mellea.stdlib.functional import call_tools + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s" +) +log = logging.getLogger(__name__) + + +@tool +def search(query: str) -> str: + """Search for information.""" + return f"Search results for '{query}': Found 5 results" + + +@tool +def read_file(path: str) -> str: + """Read a file (simulated).""" + return f"File contents of {path}" + + +# --- Plugin 1: Tool allowlist --- + + +ALLOWED_TOOLS = frozenset({"search"}) + + +@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) +async def enforce_allowlist(payload, _): + """Block tools not in the allowlist.""" + if payload.is_control_flow: + return # Framework tools are exempt + tool_name = payload.model_tool_call.name + if tool_name not in ALLOWED_TOOLS: + log.warning(f"BLOCKED tool: {tool_name} (not in allowlist)") + return block(f"Tool '{tool_name}' is not permitted") + log.info(f"ALLOWED tool: {tool_name}") + + +# --- Plugin 2: Tool audit logger --- + + +@hook(HookType.TOOL_POST_INVOKE, mode=PluginMode.FIRE_AND_FORGET) +async def audit_tools(payload, _): + """Log every tool execution for audit purposes.""" + status = "OK" if payload.success else "ERROR" + log.info( + f"[AUDIT] tool={payload.model_tool_call.name} " + f"status={status} latency={payload.execution_time_ms}ms" + ) + if payload.error: + log.error(f"[AUDIT] Error: {payload.error}") + + +def example_hooks_with_call_tools(): + """Demonstrate tool hooks firing with call_tools().""" + log.info("=" * 60) + log.info("Example: Hooks with call_tools()") + log.info("=" * 60) + + with start_session() as m: + log.info("\n--- Allowed tool (search) ---") + register(enforce_allowlist) + register(audit_tools) + + result = m.instruct( + description="Search for information about Python.", + model_options={ModelOption.TOOLS: [search, read_file]}, + tool_calls=True, + ) + log.info(f"Model output: {result.value[:80]}...") + + # call_tools() fires both hooks + tool_messages = call_tools(result, m.backend) + log.info(f"Tool execution completed: {len(tool_messages)} tools executed") + + for msg in tool_messages: + log.info(f" Tool: {msg.name}, Output: {msg._tool_output}") + + +def example_plugin_modifications(): + """Show how plugins can modify tool arguments via hooks.""" + import dataclasses + + from mellea.plugins import PluginResult + + log.info("\n" + "=" * 60) + log.info("Example: Plugin modifies tool arguments") + log.info("=" * 60) + + @hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=10) + async def sanitize_paths(payload, _): + """Normalize file paths before execution.""" + if payload.model_tool_call.name != "read_file": + return + args = dict(payload.model_tool_call.args or {}) + raw_path = str(args.get("path", "")) + + # Simulate path sanitization + sanitized_path = raw_path.strip() + if sanitized_path != raw_path: + log.info(f"Sanitized path: '{raw_path}' → '{sanitized_path}'") + new_args = {**args, "path": sanitized_path} + new_call = dataclasses.replace(payload.model_tool_call, args=new_args) + return PluginResult( + continue_processing=True, + modified_payload=payload.model_copy( + update={"model_tool_call": new_call} + ), + ) + + register(sanitize_paths) + + with start_session() as m: + result = m.instruct( + description="Search for information about 'Sanitization example'", + model_options={ModelOption.TOOLS: [search, read_file]}, + tool_calls=True, + ) + log.info(f"Model output: {result.value[:80]}...") + + tool_messages = call_tools(result, m.backend) + log.info(f"Tool execution completed: {len(tool_messages)} tools executed") + + for msg in tool_messages: + log.info(f" Tool: {msg.name}, Output: {msg._tool_output}") + + +if __name__ == "__main__": + example_hooks_with_call_tools() + example_plugin_modifications() + log.info("\n" + "=" * 60) + log.info("Examples complete") + log.info("=" * 60) diff --git a/mellea/stdlib/__init__.py b/mellea/stdlib/__init__.py index fb4a4d1544..b916bd9272 100644 --- a/mellea/stdlib/__init__.py +++ b/mellea/stdlib/__init__.py @@ -18,6 +18,12 @@ its result type :class:`~mellea.stdlib.streaming.StreamChunkingResult` are also re-exported here, alongside the full :class:`~mellea.stdlib.streaming.StreamEvent` vocabulary for typed event observation. + +Low-level primitives for tool execution are available in `mellea.stdlib.functional`: +`call_tools` and `acall_tools` for executing model-requested tool calls with full +hook and telemetry support. Higher-level APIs like `act()`, `instruct()`, or +`chat()` generate tool calls but do not execute them—use `call_tools()` to run +the generated tools. These primitives are rarely needed outside custom agentic loops. """ from .chunking import ChunkingStrategy, ParagraphChunker, SentenceChunker, WordChunker diff --git a/mellea/stdlib/frameworks/react.py b/mellea/stdlib/frameworks/react.py index d97aa331ac..fc0ece9d1a 100644 --- a/mellea/stdlib/frameworks/react.py +++ b/mellea/stdlib/frameworks/react.py @@ -120,7 +120,7 @@ async def react( tool_responses: list[ToolMessage] = [] if step.tool_calls is not None: # Code below assumes the tool is called here. - tool_responses = mfuncs._call_tools(step, backend=backend) + tool_responses = await mfuncs.acall_tools(step, backend=backend) for tool_res in tool_responses: context = context.add(tool_res) if tool_res.name == MELLEA_FINALIZER_TOOL: diff --git a/mellea/stdlib/functional.py b/mellea/stdlib/functional.py index 32c0bd9b90..c5fa4ba266 100644 --- a/mellea/stdlib/functional.py +++ b/mellea/stdlib/functional.py @@ -470,7 +470,7 @@ def transform( tool_calls=True, ) - tools = _call_tools(transformed, backend) + tools = call_tools(transformed, backend) # Transform only supports calling one tool call since it cannot currently synthesize multiple outputs. # Attempt to choose the best one to call. @@ -1249,7 +1249,7 @@ async def atransform( await_result=True, # Must be computed for tool calls. ) - tools = await _acall_tools(transformed, backend) + tools = await acall_tools(transformed, backend) # Transform only supports calling one tool call since it cannot currently synthesize multiple outputs. # Attempt to choose the best one to call. @@ -1313,22 +1313,62 @@ def _parse_and_clean_image_args( return images -def _call_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolMessage]: +def call_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolMessage]: """Call all the tools requested in a result's tool calls object. + This is a low-level primitive for executing tool calls from model output. It fires + tool_pre_invoke and tool_post_invoke hooks, allowing plugins to observe and modify + tool execution. Higher-level APIs like `act()` or `instruct()` generate tool calls + but do not execute them—use this function to execute the generated tools. Use this + primitive when implementing a custom session or agentic loop where you handle both + tool execution and context management explicitly. + + Args: + result: A ModelOutputThunk containing tool calls to execute. + backend: The backend used to format tool outputs. + Returns: - list[ToolMessage]: A list of tool messages that can be empty. + list[ToolMessage]: A list of ToolMessage objects, one per tool call. May be empty + if no tool calls were present. Each ToolMessage contains the tool name, + arguments, raw output, and formatted content. + + Example: + ```python + from mellea.stdlib.functional import call_tools + + result, ctx = instruct("...", context, backend, tool_calls=True) + tool_messages = call_tools(result, backend) + for tool_msg in tool_messages: + ctx = ctx.add(tool_msg) + ``` + + See Also: + - `acall_tools`: Async version of this function. + - Hook Types: `TOOL_PRE_INVOKE`, `TOOL_POST_INVOKE` in `mellea.plugins.types`. """ - return _run_async_in_thread(_acall_tools(result, backend)) + return _run_async_in_thread(acall_tools(result, backend)) -async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolMessage]: - """Call all the tools requested in a result's tool calls object. +async def acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolMessage]: + """Async version of call_tools; executes all tool calls with hook support. + + Fires tool_pre_invoke and tool_post_invoke hooks before and after tool execution, + allowing plugins to observe and modify the execution flow. Higher-level APIs like + `aact()` or `ainstruct()` generate tool calls but do not execute them—use this + function to execute the generated tools. Tool calls execute sequentially; use this + primitive when implementing custom agentic loops with explicit context management. - Call tools with tool_pre_invoke / tool_post_invoke hook support. + Args: + result: A ModelOutputThunk containing tool calls to execute. + backend: The backend used to format tool outputs. Returns: - list[ToolMessage]: A list of tool messages that can be empty. + list[ToolMessage]: A list of ToolMessage objects, one per tool call. May be empty + if no tool calls were present. + + See Also: + - `call_tools`: Synchronous version of this function. + - Hook Types: `TOOL_PRE_INVOKE`, `TOOL_POST_INVOKE` in `mellea.plugins.types`. """ outputs: list[ToolMessage] = [] tool_calls = result.tool_calls @@ -1417,3 +1457,17 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM outputs.append(tool_msg) return outputs + + +# --- Backward compatibility: deprecated underscore-prefixed versions --- + +# These are aliases for the public API. Use call_tools() and acall_tools() directly. +# The underscore versions are deprecated and will be removed in a future major release. +# See: https://github.com/generative-computing/mellea/discussions/1460 +# +# Migration: Replace _call_tools with call_tools, _acall_tools with acall_tools +# Example: +# from mellea.stdlib.functional import _call_tools # ❌ old +# from mellea.stdlib.functional import call_tools # ✓ new +_call_tools = call_tools +_acall_tools = acall_tools diff --git a/test/backends/test_acall_tools_parallel_execution.py b/test/backends/test_acall_tools_parallel_execution.py index d5a80b03d8..12983625b5 100644 --- a/test/backends/test_acall_tools_parallel_execution.py +++ b/test/backends/test_acall_tools_parallel_execution.py @@ -1,17 +1,17 @@ # Copyright IBM Corp. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -"""Test for _acall_tools() execution with parallel same-name tool calls. +"""Test for acall_tools() execution with parallel same-name tool calls. This test fills the gap identified in PR #1431 review: verifying that the -automatic tool execution loop through _acall_tools() correctly processes +automatic tool execution loop through acall_tools() correctly processes multiple same-name tool calls from the list-based tool_calls structure. Coverage layers: - Extraction layer: test/helpers/test_openai_compatible_helpers.py::test_duplicate_same_name_tool_calls Verifies that extract_model_tool_requests() preserves both calls in the list. - Execution layer: this file - Verifies that _acall_tools() iterates and executes all calls from the list, + Verifies that acall_tools() iterates and executes all calls from the list, producing ToolMessages for each (not just the last one). """ @@ -20,7 +20,7 @@ from mellea.backends.ollama import OllamaModelBackend from mellea.backends.tools import MelleaTool from mellea.core.base import ModelOutputThunk, ModelToolCall -from mellea.stdlib.functional import _acall_tools +from mellea.stdlib.functional import acall_tools pytestmark = [pytest.mark.integration] @@ -29,7 +29,7 @@ def backend(): """Create an OllamaModelBackend for formatter.print() only. - Note: _acall_tools() only uses backend.formatter, not inference. + Note: acall_tools() only uses backend.formatter, not inference. Tests use local Python functions as tool implementations, no model calls. """ return OllamaModelBackend() @@ -37,10 +37,10 @@ def backend(): @pytest.mark.asyncio async def test_acall_tools_executes_all_parallel_same_name_calls(backend): - """Verify _acall_tools() executes all parallel same-name tool calls. + """Verify acall_tools() executes all parallel same-name tool calls. This is the execution-level regression test for PR #1431. - It directly tests _acall_tools() to ensure: + It directly tests acall_tools() to ensure: 1. All tool calls in the list are iterated (not lost to dict key collision) 2. Each produces a ToolMessage 3. The returned list has correct cardinality @@ -75,12 +75,12 @@ def search(query: str) -> str: value="I'll search for both topics.", tool_calls=[tool_call_1, tool_call_2] ) - # Call _acall_tools() - the automatic execution pipeline - tool_messages = await _acall_tools(mot, backend) + # Call acall_tools() - the automatic execution pipeline + tool_messages = await acall_tools(mot, backend) # Verify all tool calls were executed assert len(execution_log) == 2, ( - f"Both tool calls should execute via _acall_tools(), " + f"Both tool calls should execute via acall_tools(), " f"got {len(execution_log)} executions" ) @@ -99,7 +99,7 @@ def search(query: str) -> str: @pytest.mark.asyncio async def test_acall_tools_preserves_order_in_execution(backend): - """Verify _acall_tools() executes tool calls in order. + """Verify acall_tools() executes tool calls in order. Order preservation is critical for reproducibility and correctness, especially when tool results depend on prior execution (e.g., write then read). @@ -137,8 +137,8 @@ def log_operation(operation: str, index: int) -> str: mot = ModelOutputThunk(value="Running three operations.", tool_calls=tool_calls) - # Execute through _acall_tools() - tool_messages = await _acall_tools(mot, backend) + # Execute through acall_tools() + tool_messages = await acall_tools(mot, backend) # Verify execution happened in order assert len(execution_order) == 3 @@ -156,7 +156,7 @@ def log_operation(operation: str, index: int) -> str: @pytest.mark.asyncio async def test_acall_tools_with_mixed_tools(backend): - """Verify _acall_tools() handles multiple different tools alongside duplicates. + """Verify acall_tools() handles multiple different tools alongside duplicates. Realistic scenario: user calls search twice, calculate once, search again. tool_calls should be: [search, search, calculate, search] @@ -194,7 +194,7 @@ def calculate(expr: str) -> str: mot = ModelOutputThunk(value="Complex query", tool_calls=tool_calls) - tool_messages = await _acall_tools(mot, backend) + tool_messages = await acall_tools(mot, backend) # Verify all 4 executions happened assert len(executions) == 4 @@ -243,7 +243,7 @@ def dummy_tool(x: str) -> str: mot = ModelOutputThunk(value="Run N times", tool_calls=tool_calls) - tool_messages = await _acall_tools(mot, backend) + tool_messages = await acall_tools(mot, backend) # THE CRITICAL ASSERTION: cardinality must match assert len(tool_messages) == n_calls, ( diff --git a/test/plugins/test_internal_tool_hook_skip.py b/test/plugins/test_internal_tool_hook_skip.py index 8ed4c5ba7c..50b865b1e9 100644 --- a/test/plugins/test_internal_tool_hook_skip.py +++ b/test/plugins/test_internal_tool_hook_skip.py @@ -22,7 +22,7 @@ from mellea.plugins import block, hook, is_internal_tool, register from mellea.plugins.manager import shutdown_plugins from mellea.plugins.types import HookType, PluginMode -from mellea.stdlib.functional import _acall_tools +from mellea.stdlib.functional import acall_tools # --------------------------------------------------------------------------- # Helpers @@ -86,7 +86,7 @@ async def spy(payload, *_): register(spy) - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) assert len(captured) == 1 assert captured[0].model_tool_call.name == "final_answer" @@ -106,7 +106,7 @@ async def spy(payload, *_): register(spy) - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) assert len(captured) == 1 assert captured[0].is_control_flow is True @@ -125,7 +125,7 @@ async def spy(payload, *_): register(spy) - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) assert len(captured) == 1 assert captured[0].is_control_flow is False @@ -148,7 +148,7 @@ async def spy(payload, *_): register(spy) - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) assert len(captured) == 2 by_name = {p.model_tool_call.name: p for p in captured} @@ -181,7 +181,7 @@ async def enforce_allowlist(payload, _): ) result = _make_result(tc) - msgs = await _acall_tools(result, MagicMock()) + msgs = await acall_tools(result, MagicMock()) assert len(msgs) == 1 async def test_allowlist_still_blocks_unknown_user_tools(self) -> None: @@ -204,4 +204,4 @@ async def enforce_allowlist(payload, _): result = _make_result(tc) with pytest.raises(PluginViolationError, match="not permitted"): - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) diff --git a/test/plugins/test_tool_hooks_redaction.py b/test/plugins/test_tool_hooks_redaction.py index 24c779f2f2..45752ea046 100644 --- a/test/plugins/test_tool_hooks_redaction.py +++ b/test/plugins/test_tool_hooks_redaction.py @@ -7,7 +7,7 @@ - TOOL_POST_INVOKE: replace tool_output after the tool runs No LLM is required — the test constructs a ModelOutputThunk directly with a -pre-built tool_calls dict and exercises _acall_tools in isolation. +pre-built tool_calls dict and exercises acall_tools in isolation. """ from __future__ import annotations @@ -23,7 +23,7 @@ from mellea.core.base import AbstractMelleaTool, ModelOutputThunk, ModelToolCall from mellea.plugins import PluginResult, hook, register from mellea.plugins.types import HookType -from mellea.stdlib.functional import _acall_tools +from mellea.stdlib.functional import acall_tools # --------------------------------------------------------------------------- # Helpers @@ -83,7 +83,7 @@ async def redact_password(payload, *_): register(redact_password) - tool_messages = await _acall_tools(result, MagicMock()) + tool_messages = await acall_tools(result, MagicMock()) assert len(tool_messages) == 1 assert "[REDACTED]" in tool_messages[0].content @@ -105,7 +105,7 @@ async def capture_payload(payload, *_): register(capture_payload) - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) assert len(observed_args) == 1 assert observed_args[0]["token"] == "abc123" @@ -124,7 +124,7 @@ async def observe_only(*_): register(observe_only) - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) assert recording_tool.calls[0]["value"] == "keep-me" @@ -148,7 +148,7 @@ async def redact_pii(payload, *_): register(redact_pii) - tool_messages = await _acall_tools(result, MagicMock()) + tool_messages = await acall_tools(result, MagicMock()) assert len(tool_messages) == 1 assert "123-45-6789" not in tool_messages[0].content @@ -178,7 +178,7 @@ async def redact_token(payload, *_): register(redact_token) - tool_messages = await _acall_tools(result, MagicMock()) + tool_messages = await acall_tools(result, MagicMock()) assert len(tool_messages) == 1 assert "supersecret" not in tool_messages[0].content @@ -201,7 +201,7 @@ async def capture_output(payload, *_): register(capture_output) - await _acall_tools(result, MagicMock()) + await acall_tools(result, MagicMock()) assert len(observed_outputs) == 1 assert "password=hunter2" in observed_outputs[0] @@ -221,6 +221,6 @@ async def observe_only(*_): register(observe_only) - tool_messages = await _acall_tools(result, MagicMock()) + tool_messages = await acall_tools(result, MagicMock()) assert tool_messages[0].content == "safe result" diff --git a/test/stdlib/frameworks/test_react_framework.py b/test/stdlib/frameworks/test_react_framework.py index 52d8558a5a..6ee666c2f5 100644 --- a/test/stdlib/frameworks/test_react_framework.py +++ b/test/stdlib/frameworks/test_react_framework.py @@ -3,7 +3,7 @@ """Integration tests for mellea.stdlib.frameworks.react. -Uses a ScriptedBackend (fake) so that real aact() and _call_tools() run +Uses a ScriptedBackend (fake) so that real aact() and call_tools() run end-to-end — only LLM inference is faked. This makes the tests robust to internal refactors of react() while still verifying observable behaviour. """ diff --git a/test/stdlib/test_functional_unit.py b/test/stdlib/test_functional_unit.py index ed1afa449e..e2afa346db 100644 --- a/test/stdlib/test_functional_unit.py +++ b/test/stdlib/test_functional_unit.py @@ -416,7 +416,7 @@ async def test_aact_no_raise_without_requirements(): def _make_tool_message(name: str = "some_tool") -> ToolMessage: - """Return a real ToolMessage, as `_call_tools`/`_acall_tools` would produce.""" + """Return a real ToolMessage, as `call_tools`/`acall_tools` would produce.""" tool_call = ModelToolCall(name=name, func=MagicMock(), args={"arg": 1}) return ToolMessage( role="tool", @@ -448,7 +448,7 @@ def _assert_tool_message_persisted_after( assert result[-1] is tool_message -@patch("mellea.stdlib.functional._call_tools") +@patch("mellea.stdlib.functional.call_tools") @patch("mellea.stdlib.functional.act") def test_transform_persists_chosen_tool_message_in_context(mock_act, mock_call_tools): """The tool message transform() picks must survive in the returned Context. @@ -473,7 +473,7 @@ def test_transform_persists_chosen_tool_message_in_context(mock_act, mock_call_t @pytest.mark.asyncio -@patch("mellea.stdlib.functional._acall_tools", new_callable=AsyncMock) +@patch("mellea.stdlib.functional.acall_tools", new_callable=AsyncMock) @patch("mellea.stdlib.functional.aact", new_callable=AsyncMock) async def test_atransform_persists_chosen_tool_message_in_context( mock_aact, mock_acall_tools diff --git a/test/telemetry/test_tracing_tools.py b/test/telemetry/test_tracing_tools.py index 3a28d7668a..75d5fac24c 100644 --- a/test/telemetry/test_tracing_tools.py +++ b/test/telemetry/test_tracing_tools.py @@ -84,7 +84,7 @@ def test_session_tool_calls_emit_parented_spans_per_call(span_exporter): """A turn that calls two tools emits one `execute_tool` span per call, each parented under the session span, with per-call success/error status. - Only inference (`act`) is faked; the real `transform` -> `_call_tools` -> + Only inference (`act`) is faked; the real `transform` -> `call_tools` -> `tool_*_invoke` hooks -> `ToolTracingPlugin` path runs and emits the spans. """