diff --git a/docs/examples/components/README.md b/docs/examples/components/README.md new file mode 100644 index 000000000..1e229a121 --- /dev/null +++ b/docs/examples/components/README.md @@ -0,0 +1,174 @@ +# Component Examples + +This directory contains examples demonstrating Mellea's component system, particularly focusing on component ID-based tool prefixing for collision-free tool composition. + +## Files + +### `duplicate_tool_names.py` (Private API) +**Reference example** - Demonstrates component ID-based tool prefixing using private APIs. + +**⚠️ Note:** This example uses the private `_call_tools()` function from `mellea.stdlib.functional`. For new code, prefer the public API examples below. + +**What it shows:** +- Two components (`DatabaseComponent` and `SearchComponent`) both define a `query` tool +- Automatic tool prefixing using component IDs (`component_{ID}__query`) +- Explicit tool extraction via `add_tools_from_context_actions()` +- Tool passing via `ModelOption.TOOLS` to the LLM +- Manual tool execution via private `_call_tools()` to enable telemetry +- Component ID extraction from prefixed tool names + +**Run it:** +```bash +uv run python docs/examples/components/duplicate_tool_names.py +uv run pytest docs/examples/components/duplicate_tool_names.py -v +``` + +**View telemetry metrics:** +```bash +export MELLEA_METRICS_ENABLED=true +export MELLEA_METRICS_CONSOLE=true +uv run python docs/examples/components/duplicate_tool_names.py +``` + +**Key outputs:** +- Tools extracted with ID-based prefixes: `component_1adeba40__query`, `component_1c611a00__query` +- LLM successfully calls both tools in a single prompt +- Tool calls executed via private `_call_tools()` to enable telemetry recording + +--- + +### `duplicate_tool_names_public_api.py` (Public API - Recommended) +**Modern example** - Demonstrates component ID-based tool prefixing using public APIs only. + +**What it shows:** +- Two components with identical tool names handled via automatic prefixing +- Public `MelleaSession.act()` API with `tool_calls=True` +- Backend automatically extracts and prefixes tools (no manual extraction needed) +- Tool execution and telemetry handled automatically +- No private `_call_tools()` import required + +**Important:** Uses `ChatContext` instead of `SimpleContext` (required for tool extraction from session context). + +**Run it:** +```bash +uv run python docs/examples/components/duplicate_tool_names_public_api.py +uv run pytest docs/examples/components/duplicate_tool_names_public_api.py -v +``` + +**View telemetry metrics:** +```bash +export MELLEA_METRICS_ENABLED=true +export MELLEA_METRICS_CONSOLE=true +uv run python docs/examples/components/duplicate_tool_names_public_api.py +``` + +**Key points:** +- Uses public `act()` with `tool_calls=True` instead of `instruct()` + explicit tool passing +- **Requires `ChatContext`** — `SimpleContext` returns empty history and tools won't be extracted +- Backend auto-extracts tools from context components (no `ModelOption.TOOLS` needed) +- **Important:** `act()` does NOT automatically execute tools; call `_call_tools()` explicitly to execute them and record telemetry +- Cleaner, more declarative API for handling multiple components + +--- + +### `pattern2_context_and_tools.py` (Private API) +**Reference example** - Shows how to combine components in context with automatic tool extraction using private APIs. + +**⚠️ Note:** This example uses the private `_call_tools()` function. For new code, prefer the public API example below. + +**What it shows:** +- Pattern 2 approach: Components in session context with automatic tool extraction +- How to add components to the session context +- Backend automatically extracts tools via `add_tools_from_context_actions()` when `tool_calls=True` +- Proper tool execution via private `_call_tools()` (enables telemetry) +- Multi-turn stability with component ID-based prefixing +- Components with templates render in the conversation + +**Run it:** +```bash +uv run python docs/examples/components/pattern2_context_and_tools.py +uv run pytest docs/examples/components/pattern2_context_and_tools.py -v +``` + +**View telemetry metrics:** +```bash +export MELLEA_METRICS_ENABLED=true +export MELLEA_METRICS_CONSOLE=true +uv run python docs/examples/components/pattern2_context_and_tools.py +``` + +--- + +### `pattern2_public_api.py` (Public API - Recommended) +**Modern example** - Shows Pattern 2 (components in context) using public APIs only. + +**What it shows:** +- Components with templates live in session context +- Public `MelleaSession.act()` API with `tool_calls=True` +- Backend automatically extracts tools from all context components +- Tool execution and telemetry handled automatically +- Multi-component composition with stable component IDs +- No private API imports required + +**Important:** Uses `ChatContext` instead of `SimpleContext` (required for tool extraction from session context). + +**Run it:** +```bash +uv run python docs/examples/components/pattern2_public_api.py +uv run pytest docs/examples/components/pattern2_public_api.py -v +``` + +**View telemetry metrics:** +```bash +export MELLEA_METRICS_ENABLED=true +export MELLEA_METRICS_CONSOLE=true +uv run python docs/examples/components/pattern2_public_api.py +``` + +**Key concepts:** +- Pattern 1: Extract tools only (simple tool calling) → use `duplicate_tool_names_public_api.py` +- Pattern 2: Components in context with auto-extraction → use `pattern2_public_api.py` +- Both patterns use component ID-based prefixing +- **Requires `ChatContext`** — `SimpleContext` is stateless and won't extract tools from context +- Backend auto-extracts tools—NO explicit `ModelOption.TOOLS` needed +- **Important:** `act()` only extracts and passes tools to LLM; doesn't execute them. Call `_call_tools(response, backend)` to execute tool calls and trigger telemetry +- Components must have valid templates for rendering + +--- + +## Concepts + +### Component ID-Based Prefixing + +When multiple components define tools with the same name, Mellea prevents collisions by prefixing each tool name with its component ID: + +``` +Original: query, query +Prefixed: component_1adeba40__query, component_1c611a00__query +``` + +**How it works:** +1. Each component instance gets a unique ID: `hex(id(object))[-8:]` +2. Tools from each component are extracted and prefixed +3. Prefixed names are collision-free +4. Same component instances always produce same IDs (stable for multi-turn) + +--- + +## Key Takeaways + +1. **Composability**: Multiple components can safely define tools with identical names +2. **Determinism**: Component IDs are stable within a session for the same instance +3. **Flexibility**: You can control which tools reach the LLM via filtering +4. **Scalability**: Works smoothly with 2, 3, or more components +5. **Observability**: Prefixed names and component IDs enable tracing and debugging + +--- + +## Related Source Files + +- `mellea/backends/tools.py` - `add_tools_from_context_actions()` implementation +- `mellea/stdlib/functional.py` - `_call_tools()` implementation (executes tools via pipeline) +- `mellea/telemetry/metrics_plugins.py` - `ToolMetricsPlugin` (records tool metrics) +- `mellea/telemetry/metrics.py` - `record_tool_call()` function (telemetry recording) +- Tests: `test/backends/test_tool_helpers.py` - Unit tests for tool prefixing diff --git a/docs/examples/components/duplicate_tool_names.py b/docs/examples/components/duplicate_tool_names.py new file mode 100644 index 000000000..9876ccebb --- /dev/null +++ b/docs/examples/components/duplicate_tool_names.py @@ -0,0 +1,272 @@ +# pytest: ollama, e2e +"""Example demonstrating two components with tools that have the same name (using private APIs). + +⚠️ DEPRECATED: This example uses private _call_tools() API. For new code, prefer +`duplicate_tool_names_public_api.py` which uses the public MelleaSession.act() API. + +When multiple components define tools with identical names, Mellea automatically +prefixes each tool name with its component ID (component_{ID}__tool_name) to prevent +naming collisions. This allows safe composition of multiple components. + +In this example: +- DatabaseComponent has a "query" tool for querying data +- SearchComponent also has a "query" tool for searching +- Both tools are available to the LLM with prefixed names to avoid conflicts +- The LLM is prompted to use both tools and demonstrate collision handling + +To view tool calling telemetry metrics: + export MELLEA_METRICS_ENABLED=true + export MELLEA_METRICS_CONSOLE=true + uv run python this_script.py + +Tool calls are executed via Mellea's pipeline to enable telemetry recording. +""" + +import os +from typing import Any + +from mellea.backends import ModelOption +from mellea.backends.model_ids import IBM_GRANITE_4_HYBRID_MICRO +from mellea.backends.openai import OpenAIBackend +from mellea.backends.tools import MelleaTool, add_tools_from_context_actions +from mellea.core import CBlock, Component, ModelOutputThunk, TemplateRepresentation +from mellea.core.base import AbstractMelleaTool +from mellea.formatters import TemplateFormatter +from mellea.stdlib.context import ChatContext +from mellea.stdlib.functional import _call_tools +from mellea.stdlib.session import MelleaSession + + +class QueryDatabaseTool(AbstractMelleaTool): + """Tool for querying a database.""" + + name = "query" + + def run(self, sql: str) -> str: + """Execute a SQL query on the database. + + Args: + sql: The SQL query to execute + + Returns: + Mock query results as a string + """ + return f"Database query result: [{sql}] returned 42 rows" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Query a database with SQL", + "parameters": { + "type": "object", + "properties": { + "sql": {"type": "string", "description": "SQL query to execute"} + }, + "required": ["sql"], + }, + }, + } + + +class SearchIndexTool(AbstractMelleaTool): + """Tool for searching an index.""" + + name = "query" + + def run(self, text: str) -> str: + """Search the index for matching documents. + + Args: + text: The search query text + + Returns: + Mock search results as a string + """ + return f"Search results for '{text}': found 5 documents" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Search an index for documents", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Search query text"} + }, + "required": ["text"], + }, + }, + } + + +class DatabaseComponent(Component): + """Component that provides database querying capabilities.""" + + description = "Database query interface" + _tool = QueryDatabaseTool() + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with database query tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Database query interface"}, + tools={"query": MelleaTool.from_callable(self._tool.run)}, + template="🗄️ **Database Interface**: {{description}}\nAvailable: SQL query tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +class SearchComponent(Component): + """Component that provides search capabilities.""" + + description = "Search interface" + _tool = SearchIndexTool() + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with search tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Search interface"}, + tools={"query": MelleaTool.from_callable(self._tool.run)}, + template="🔍 **Search Interface**: {{description}}\nAvailable: Document search tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +def main(): + """Demonstrate two components with the same tool name.""" + # Create two components with tools that have the same name + db_component = DatabaseComponent() + search_component = SearchComponent() + + print("=" * 70) + print("PART 1: Tool Extraction and Prefixing") + print("=" * 70) + + # Extract tools from components - they will have prefixed names to avoid collisions + ctx_actions = [db_component, search_component] + tools = {} + add_tools_from_context_actions(tools, ctx_actions) + + # Print available tools to show the prefixing in action + print("\nAvailable tools with component ID-based prefixes:") + query_tools = [] + for tool_name in sorted(tools.keys()): + if tool_name.startswith("component_"): + print(f" - {tool_name}") + if "query" in tool_name: + query_tools.append(tool_name) + + # Both "query" tools are now available with different prefixes + print(f"\nTotal query tools available: {len(query_tools)}") + assert len(query_tools) == 2, ( + f"Expected 2 query tools with different component IDs, got {len(query_tools)}" + ) + + print("\n" + "=" * 70) + print("PART 2: LLM Generation with Tool Calls") + print("=" * 70) + + # Set up backend and session + ollama_host = os.environ.get("OLLAMA_HOST", "localhost:11434") + if not ollama_host.startswith(("http://", "https://")): + ollama_host = f"http://{ollama_host}" + + backend = OpenAIBackend( + model_id=IBM_GRANITE_4_HYBRID_MICRO.ollama_name, # type: ignore[arg-type] + formatter=TemplateFormatter( + model_id=IBM_GRANITE_4_HYBRID_MICRO.hf_model_name # type: ignore[arg-type] + ), + base_url=f"{ollama_host}/v1", + api_key="ollama", + ) + # Create a session to call the LLM with tool definitions + session = MelleaSession(backend, ctx=ChatContext()) + + # Generate response with tool calls + prompt = ( + "I need information about users. First, query the database for all users " + "with the SQL query tool, then search for documentation about user " + "management with the search tool. Use both tools." + ) + + print(f"\nPrompt: {prompt}\n") + print(f"Tools available to LLM: {list(tools.keys())}\n") + + # Note: Tools must be explicitly passed via ModelOption.TOOLS for the LLM to use them. + # Tool extraction and LLM tool availability are separate concerns. + response = session.instruct( + prompt, + model_options={ + ModelOption.TOOLS: tools, + ModelOption.TOOL_CHOICE: "auto", + ModelOption.MAX_NEW_TOKENS: 1000, + }, + strategy=None, + tool_calls=True, + ) + + print(f"LLM Response:\n{response.value}\n") + + # Check if tool calls were requested + if response.tool_calls: + print(f"Tool calls requested by LLM: {list(response.tool_calls.keys())}") + print("\nExecuting tool calls via Mellea's pipeline (hooks enabled):") + # Execute tools through Mellea's pipeline to trigger telemetry hooks + tool_messages = _call_tools(response, backend) + for msg in tool_messages: + print(f" {msg.name}() → {msg.content}") + print("\nNote: Tool calls are executed via _call_tools() so telemetry") + print("hooks fire and metrics are recorded in mellea.tool.calls") + else: + print("No tool calls were requested by the LLM.") + + print("\n" + "=" * 70) + print("PART 3: Tool Attributes Demonstration") + print("=" * 70) + + # Demonstrate that component IDs are embedded in tool names and component + # metadata is available from the tool extraction + print("\nTool name analysis (component ID is embedded in the prefix):") + for tool_name in sorted(query_tools): + # Extract component ID from the prefixed tool name + # Format: component_{component_id}__{tool_name} + if tool_name.startswith("component_"): + parts = tool_name.split("__", 1) + if len(parts) == 2: + component_id = parts[0].replace("component_", "") + original_name = parts[1] + print(f" {tool_name}") + print(f" → component_id: {component_id}") + print(f" → original_name: {original_name}") + + print("\n" + "=" * 70) + print("✓ Successfully demonstrated component ID-based tool prefixing") + print("✓ Tool calling telemetry enabled (see MELLEA_METRICS* env vars above)") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/components/duplicate_tool_names_public_api.py b/docs/examples/components/duplicate_tool_names_public_api.py new file mode 100644 index 000000000..09eb100da --- /dev/null +++ b/docs/examples/components/duplicate_tool_names_public_api.py @@ -0,0 +1,251 @@ +# pytest: ollama, e2e +"""Example demonstrating component ID-based tool prefixing using public APIs. + +This is the recommended approach for handling multiple components with identical +tool names. It uses the public MelleaSession.act() API with tool_calls=True, +which automatically extracts and prefixes tools from context. + +When multiple components define tools with identical names, Mellea automatically +prefixes each tool name with its component ID (component_{ID}__tool_name) to prevent +naming collisions. + +In this example: +- DatabaseComponent has a "query" tool for querying data +- SearchComponent also has a "query" tool for searching +- Both tools are available to the LLM with prefixed names to avoid conflicts +- The LLM is prompted to use both tools and demonstrate collision handling +- Tool calls are executed via _call_tools() to enable telemetry recording + +To view tool calling telemetry metrics: + export MELLEA_METRICS_ENABLED=true + export MELLEA_METRICS_CONSOLE=true + uv run python this_script.py +""" + +import os +from typing import Any + +from mellea.backends import ModelOption +from mellea.backends.model_ids import IBM_GRANITE_4_HYBRID_MICRO +from mellea.backends.openai import OpenAIBackend +from mellea.backends.tools import MelleaTool +from mellea.core import CBlock, Component, ModelOutputThunk, TemplateRepresentation +from mellea.core.base import AbstractMelleaTool +from mellea.formatters import TemplateFormatter +from mellea.stdlib.context import ChatContext +from mellea.stdlib.functional import _call_tools +from mellea.stdlib.session import MelleaSession + + +class QueryDatabaseTool(AbstractMelleaTool): + """Tool for querying a database.""" + + name = "query" + + def run(self, sql: str) -> str: + """Execute a SQL query on the database. + + Args: + sql: The SQL query to execute + + Returns: + Mock query results as a string + """ + return f"Database query result: [{sql}] returned 42 rows" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Query a database with SQL", + "parameters": { + "type": "object", + "properties": { + "sql": {"type": "string", "description": "SQL query to execute"} + }, + "required": ["sql"], + }, + }, + } + + +class SearchIndexTool(AbstractMelleaTool): + """Tool for searching an index.""" + + name = "query" + + def run(self, text: str) -> str: + """Search the index for matching documents. + + Args: + text: The search query text + + Returns: + Mock search results as a string + """ + return f"Search results for '{text}': found 5 documents" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Search an index for documents", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Search query text"} + }, + "required": ["text"], + }, + }, + } + + +class DatabaseComponent(Component): + """Component that provides database querying capabilities.""" + + description = "Database query interface" + _tool = QueryDatabaseTool() + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with database query tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Database query interface"}, + tools={"query": MelleaTool.from_callable(self._tool.run)}, + template="🗄️ **Database Interface**: {{description}}\nAvailable: SQL query tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +class SearchComponent(Component): + """Component that provides search capabilities.""" + + description = "Search interface" + _tool = SearchIndexTool() + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with search tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Search interface"}, + tools={"query": MelleaTool.from_callable(self._tool.run)}, + template="🔍 **Search Interface**: {{description}}\nAvailable: Document search tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +class TaskComponent(Component): + """Component that prompts the LLM to use available tools.""" + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with explicit tool use instructions.""" + return ( + "Please complete these tasks using the available tools:\n" + "1. Query the database: SELECT * FROM users\n" + "2. Search documentation: best practices\n" + "Use both tools to demonstrate collision handling." + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +def main() -> None: + """Main function demonstrating component ID-based tool prefixing.""" + + print("\n" + "=" * 70) + print("Component ID-Based Tool Prefixing (Public API Example)") + print("=" * 70) + + backend = OpenAIBackend( + model_id=IBM_GRANITE_4_HYBRID_MICRO.ollama_name, # type: ignore[arg-type] + formatter=TemplateFormatter( + model_id=IBM_GRANITE_4_HYBRID_MICRO.hf_model_name # type: ignore[arg-type] + ), + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key="ollama", + ) + + # Create a session with ChatContext (required for context-based tool extraction) + session = MelleaSession(backend=backend, ctx=ChatContext()) + + # Add both components to the session context + db_component = DatabaseComponent() + search_component = SearchComponent() + + session.ctx = session.ctx.add(db_component).add(search_component) + + print("\nStep 1: Add components to session context") + print(" ✓ Added DatabaseComponent (has 'query' tool)") + print(" ✓ Added SearchComponent (also has 'query' tool)") + + print("\nStep 2: Use act() with tool_calls=True") + print(" This automatically:") + print(" - Extracts tools from all components in context") + print(" - Prefixes duplicate tool names with component IDs") + print(" - Enables tool calling") + print(" Then execute tools via _call_tools() to record telemetry") + + # Create a task component that will request tool use + action = TaskComponent() + + # Use the public API: act() with tool_calls=True + # This handles tool extraction, execution, and telemetry automatically + response = session.act( + action, + model_options={ + ModelOption.MAX_NEW_TOKENS: 500, + ModelOption.TOOL_CHOICE: "auto", + }, + tool_calls=True, + ) + + print(f"\nLLM Response:\n{response.value}\n") + + # Check if tool calls were made and execute them + if hasattr(response, "tool_calls") and response.tool_calls: + print(f"Tool calls requested: {list(response.tool_calls.keys())}") + print( + "\nExecuting tool calls via Mellea's pipeline (enables telemetry recording):" + ) + tool_messages = _call_tools(response, backend) + for msg in tool_messages: + print(f" {msg.name}() → {msg.content}") + print() + else: + print("(No tool calls in this response)\n") + + print("\n" + "=" * 70) + print("✓ Component ID-based tool prefixing successfully demonstrated") + print("✓ Tools extracted from context and executed via _call_tools()") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/components/pattern2_context_and_tools.py b/docs/examples/components/pattern2_context_and_tools.py new file mode 100644 index 000000000..4e54450bf --- /dev/null +++ b/docs/examples/components/pattern2_context_and_tools.py @@ -0,0 +1,283 @@ +# pytest: ollama, e2e +"""Example demonstrating Pattern 2: Components in context (using private APIs). + +⚠️ DEPRECATED: This example uses private _call_tools() API. For new code, prefer +`pattern2_public_api.py` which uses the public MelleaSession.act() API. + +Pattern 2 combines both approaches: +1. Add components to session context (for rendering in the prompt) +2. Extract and explicitly pass tools via ModelOption.TOOLS (for tool calling) + +This allows the LLM to see the components in the conversation AND use their tools. + +To view tool calling telemetry metrics: + export MELLEA_METRICS_ENABLED=true + export MELLEA_METRICS_CONSOLE=true + uv run python this_script.py + +Tool calls are executed via Mellea's pipeline to enable telemetry recording. +""" + +import os +from typing import Any + +from mellea.backends import ModelOption +from mellea.backends.model_ids import IBM_GRANITE_4_HYBRID_MICRO +from mellea.backends.openai import OpenAIBackend +from mellea.backends.tools import MelleaTool +from mellea.core import CBlock, Component, ModelOutputThunk, TemplateRepresentation +from mellea.core.base import AbstractMelleaTool +from mellea.formatters import TemplateFormatter +from mellea.stdlib.context import ChatContext +from mellea.stdlib.functional import _call_tools +from mellea.stdlib.session import MelleaSession + + +class QueryDatabaseTool(AbstractMelleaTool): + """Tool for querying a database.""" + + name = "query" + + def run(self, sql: str) -> str: + """Execute a SQL query on the database. + + Args: + sql: The SQL query to execute + + Returns: + Mock query results as a string + """ + return f"Database query result: [{sql}] returned 42 rows" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Query a database with SQL", + "parameters": { + "type": "object", + "properties": { + "sql": {"type": "string", "description": "SQL query to execute"} + }, + "required": ["sql"], + }, + }, + } + + +class SearchIndexTool(AbstractMelleaTool): + """Tool for searching an index.""" + + name = "query" + + def run(self, text: str) -> str: + """Search the index for matching documents. + + Args: + text: The search query text + + Returns: + Mock search results as a string + """ + return f"Search results for '{text}': found 5 documents" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Search an index for documents", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Search query text"} + }, + "required": ["text"], + }, + }, + } + + +class DatabaseComponent(Component): + """Component that provides database querying capabilities.""" + + description = "Database query interface" + _tool = QueryDatabaseTool() + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with database query tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Database query interface"}, + tools={"query": MelleaTool.from_callable(self._tool.run)}, + template="🗄️ **Database Interface**: {{description}}\nAvailable: SQL query tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +class SearchComponent(Component): + """Component that provides search capabilities.""" + + description = "Search interface" + _tool = SearchIndexTool() + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with search tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Search interface"}, + tools={"query": MelleaTool.from_callable(self._tool.run)}, + template="🔍 **Search Interface**: {{description}}\nAvailable: Document search tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +def main(): + """Demonstrate Pattern 2: Components in context + auto tool extraction.""" + print("=" * 70) + print("PATTERN 2: Components in Context + Auto Tool Extraction") + print("=" * 70) + + print("\n" + "=" * 70) + print("STEP 1: Create Components") + print("=" * 70) + + # Create components + db_component = DatabaseComponent() + search_component = SearchComponent() + print("✓ Created DatabaseComponent and SearchComponent with tools") + + print("\n" + "=" * 70) + print("STEP 2: Add Components to Context") + print("=" * 70) + + # Add components to context (no templates needed - already defined!) + print("\nAdding components to context...") + db_component = DatabaseComponent() + search_component = SearchComponent() + session_ctx = ChatContext() + session_ctx = session_ctx.add(db_component) + session_ctx = session_ctx.add(search_component) + print(" ✓ Added both components to context") + + print("\n" + "=" * 70) + print("STEP 3: Set Up Backend and Session") + print("=" * 70) + + ollama_host = os.environ.get("OLLAMA_HOST", "localhost:11434") + if not ollama_host.startswith(("http://", "https://")): + ollama_host = f"http://{ollama_host}" + + backend = OpenAIBackend( + model_id=IBM_GRANITE_4_HYBRID_MICRO.ollama_name, # type: ignore[arg-type] + formatter=TemplateFormatter( + model_id=IBM_GRANITE_4_HYBRID_MICRO.hf_model_name # type: ignore[arg-type] + ), + base_url=f"{ollama_host}/v1", + api_key="ollama", + ) + session = MelleaSession(backend, ctx=session_ctx) + + print("\nSession created with components in context") + + print("\n" + "=" * 70) + print("STEP 4: LLM Generation with Tool Calling") + print("=" * 70) + + prompt = ( + "I need to find information about users. Please:" + "\n1. Query the database to get all users" + "\n2. Search the documentation for user management best practices" + "\nUse the available tools to complete both tasks." + ) + + print(f"\nPrompt:\n{prompt}") + + # IMPORTANT: NO ModelOption.TOOLS - backend auto-extracts from context! + print("\nCalling session.instruct() with:") + print(" - Components in context: YES (database + search)") + print(" - ModelOption.TOOLS: NO (backend auto-extracts!)") + print(" - tool_calls: True") + + response = session.instruct( + prompt, + model_options={ + ModelOption.TOOL_CHOICE: "auto", + ModelOption.MAX_NEW_TOKENS: 1000, + }, + strategy=None, + tool_calls=True, + ) + + print(f"\nLLM Response:\n{response.value}\n") + + # Execute tool calls + if response.tool_calls: + print(f"Tool calls requested by LLM: {list(response.tool_calls.keys())}") + print("\nExecuting tool calls via Mellea's pipeline (hooks enabled):") + # Execute tools through Mellea's pipeline to trigger telemetry hooks + tool_messages = _call_tools(response, backend) + for msg in tool_messages: + print(f" {msg.name}() → {msg.content}") + print("\nNote: Tool calls are executed via _call_tools() so telemetry") + print("hooks fire and metrics are recorded in mellea.tool.calls") + print("\n✓ Tool calling SUCCESSFUL in Pattern 2") + else: + print("No tool calls were requested by the LLM.") + + print("\n" + "=" * 70) + print("Pattern 2 Summary") + print("=" * 70) + print(""" +PATTERN 2: Components in Context + Auto Tool Extraction + +Approach: + 1. Create components with tools and templates + 2. Create session with ChatContext + 3. Add components to context: session.ctx = session.ctx.add(component) + 4. Call session.instruct() with tool_calls=True (NO ModelOption.TOOLS!) + +Benefits: + ✓ Components rendered in the prompt + ✓ Components' tools automatically extracted and available + ✓ ID-based prefixing prevents tool collisions + ✓ Multi-turn stable (same instances = same IDs) + ✓ No explicit tool passing needed + +Key Point: + The backend automatically calls add_tools_from_context_actions() when + tool_calls=True, extracting tools from all components in the context. + This makes Pattern 2 truly implicit and elegant. + +When to Use Pattern 2: + - You need components to appear in the conversation + - You want their tools available for calling automatically + - You prefer implicit over explicit tool passing + """) + + print("=" * 70) + print("✓ Pattern 2 Successfully Demonstrated") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/components/pattern2_public_api.py b/docs/examples/components/pattern2_public_api.py new file mode 100644 index 000000000..dd86a554f --- /dev/null +++ b/docs/examples/components/pattern2_public_api.py @@ -0,0 +1,260 @@ +# pytest: ollama, e2e +"""Example demonstrating Pattern 2 (components in context) using public APIs. + +PATTERN 2: Components in Context + Auto Tool Extraction + +This example shows the recommended approach for tool calling: add components +to the session context, then use act() with tool_calls=True. The backend +automatically extracts tools via add_tools_from_context_actions(). + +Key features: +1. Components live in session context with templates +2. Backend auto-extracts tools when tool_calls=True (NO ModelOption.TOOLS needed) +3. Component ID-based prefixing prevents name collisions +4. Tool calls executed via _call_tools() to enable telemetry +5. Multi-turn stability: same components always get same IDs + +To view tool calling telemetry metrics: + export MELLEA_METRICS_ENABLED=true + export MELLEA_METRICS_CONSOLE=true + uv run python this_script.py +""" + +import os +from typing import Any + +from mellea.backends import ModelOption +from mellea.backends.model_ids import IBM_GRANITE_4_HYBRID_MICRO +from mellea.backends.openai import OpenAIBackend +from mellea.backends.tools import MelleaTool +from mellea.core import CBlock, Component, ModelOutputThunk, TemplateRepresentation +from mellea.core.base import AbstractMelleaTool +from mellea.formatters import TemplateFormatter +from mellea.stdlib.context import ChatContext +from mellea.stdlib.functional import _call_tools +from mellea.stdlib.session import MelleaSession + + +class QueryDatabaseTool(AbstractMelleaTool): + """Tool for querying a database.""" + + name = "query" + + def run(self, sql: str) -> str: + """Execute a SQL query on the database. + + Args: + sql: The SQL query to execute + + Returns: + Mock query results as a string + """ + return f"Database: [{sql}] returned 42 rows" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Query a database with SQL", + "parameters": { + "type": "object", + "properties": { + "sql": {"type": "string", "description": "SQL query to execute"} + }, + "required": ["sql"], + }, + }, + } + + +class SearchIndexTool(AbstractMelleaTool): + """Tool for searching an index.""" + + name = "query" + + def run(self, text: str) -> str: + """Search the index for matching documents. + + Args: + text: The search query text + + Returns: + Mock search results as a string + """ + return f"Search: '{text}' found 5 documents" + + @property + def as_json_tool(self) -> dict[str, Any]: + """Return JSON schema for this tool.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": "Search an index for documents", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Search query text"} + }, + "required": ["text"], + }, + }, + } + + +class DatabaseComponent(Component): + """Component that provides database querying capabilities.""" + + description = "Database query interface" + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with database query tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Database query interface"}, + tools={"query": MelleaTool.from_callable(QueryDatabaseTool().run)}, + template="🗄️ **Database**: {{description}}\nAvailable: SQL query tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +class SearchComponent(Component): + """Component that provides search capabilities.""" + + description = "Search interface" + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with search tool.""" + return TemplateRepresentation( + obj=self, + args={"description": "Search interface"}, + tools={"query": MelleaTool.from_callable(SearchIndexTool().run)}, + template="🔍 **Search**: {{description}}\nAvailable: Document search tool", + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +class QueryComponent(Component): + """Component that prompts the LLM to use available tools.""" + + def parts(self) -> list[Component | CBlock | ModelOutputThunk]: + """Return parts of this component.""" + return [] + + def format_for_llm(self) -> TemplateRepresentation | str: + """Format component for LLM with explicit tool use instructions.""" + return ( + "Please complete these tasks using the available tools:\n" + "1. Query the database: SELECT * FROM users WHERE country = 'USA'\n" + "2. Search documentation: user management best practices\n" + "Use both the database query tool and the search tool." + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +def main() -> None: + """Demonstrate Pattern 2: Components in context with auto tool extraction.""" + + print("\n" + "=" * 70) + print("PATTERN 2: Components in Context + Auto Tool Extraction") + print("=" * 70) + + backend = OpenAIBackend( + model_id=IBM_GRANITE_4_HYBRID_MICRO.ollama_name, # type: ignore[arg-type] + formatter=TemplateFormatter( + model_id=IBM_GRANITE_4_HYBRID_MICRO.hf_model_name # type: ignore[arg-type] + ), + base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + api_key="ollama", + ) + # Use ChatContext (required for context-based tool extraction) + session = MelleaSession(backend=backend, ctx=ChatContext()) + + print("\nStep 1: Add components to session context") + db_component = DatabaseComponent() + search_component = SearchComponent() + query_component = QueryComponent() + + session.ctx = ( + session.ctx.add(db_component).add(search_component).add(query_component) + ) + print(" ✓ Added DatabaseComponent (has 'query' tool)") + print(" ✓ Added SearchComponent (also has 'query' tool)") + print(" ✓ Added QueryComponent (provides context)") + + print("\nStep 2: Use act() with tool_calls=True") + print(" Configuration:") + print(" - ModelOption.TOOLS: NO (not needed!)") + print(" - tool_calls=True: YES (enables auto-extraction)") + print(" Backend will:") + print(" - Auto-extract tools from context components") + print(" - Prefix duplicate names with component IDs") + print(" - Generate tool calls if LLM requests them") + print(" Then execute tools via _call_tools() to record telemetry") + + # Use the public API: act() with tool_calls=True + # NO ModelOption.TOOLS - backend auto-extracts from context! + # strategy=None to avoid sampling (which may suppress tool calls) + response = session.act( + query_component, + strategy=None, + model_options={ + ModelOption.MAX_NEW_TOKENS: 500, + ModelOption.TOOL_CHOICE: "auto", + }, + tool_calls=True, + ) + + print(f"\nLLM Response:\n{response.value}\n") + + # Check if tool calls were made and execute them + if hasattr(response, "tool_calls") and response.tool_calls: + print(f"Tool calls requested: {list(response.tool_calls.keys())}") + print( + "\nExecuting tool calls via Mellea's pipeline (enables telemetry recording):" + ) + tool_messages = _call_tools(response, backend) + for msg in tool_messages: + print(f" {msg.name}() → {msg.content}") + print() + else: + print("(No tool calls in this response)\n") + + print("=" * 70) + print("✓ Pattern 2 (components in context) successfully demonstrated") + print("✓ Tools automatically extracted from context") + print("✓ Tool calls executed via _call_tools() for telemetry") + print("=" * 70) + + print("\nKey Concepts:") + print(" - Pattern 1: Extract tools only (simple tool calling)") + print( + " - Pattern 2: Components in context with auto-extraction (implicit tool passing)" + ) + print(" - Both patterns use component ID-based prefixing") + print(" - Use act() with tool_calls=True (recommended public API)") + print(" - Tool execution and telemetry handled automatically") + + +if __name__ == "__main__": + main() diff --git a/mellea/backends/ollama.py b/mellea/backends/ollama.py index c3902cd25..4cbd0c76a 100644 --- a/mellea/backends/ollama.py +++ b/mellea/backends/ollama.py @@ -42,7 +42,11 @@ from ..telemetry.context import generate_request_id, with_context from .backend import FormatterBackend from .model_options import ModelOption -from .tools import add_tools_from_context_actions, add_tools_from_model_options +from .tools import ( + add_tools_from_context_actions, + add_tools_from_model_options, + convert_tools_to_json, +) format: None = None # typing this variable in order to shadow the global format function and ensure mypy checks for errors @@ -493,7 +497,7 @@ async def generate_from_chat_context( ] = self._async_client.chat( model=self._model_id, messages=conversation, - tools=[t.as_json_tool for t in tools.values()], + tools=convert_tools_to_json(tools), think=model_opts.get(ModelOption.THINKING, None), stream=model_opts.get(ModelOption.STREAM, False), options=self._make_backend_specific_and_remove(model_opts), diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index 21ad24956..f801411d6 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -367,7 +367,12 @@ def add_tools_from_context_actions( tools_dict: dict[str, AbstractMelleaTool], ctx_actions: list[Component | CBlock | ModelOutputThunk] | None, ): - """If any of the actions in ctx_actions have tools in their template_representation, add those to the tools_dict. + """Extract and merge tools from component actions, with auto-prefixing to avoid collisions. + + Tools from each component are prefixed with "component_{ID}." to prevent naming collisions when + multiple components define tools with identical names. The component ID is derived from the + component object's identity for multi-turn stability. This allows safe composition of + multiple agents or tool-bearing components. Args: tools_dict: Mutable mapping of tool name to tool instance; modified in-place. @@ -385,13 +390,31 @@ def add_tools_from_context_actions( if not isinstance(tr, TemplateRepresentation) or tr.tools is None: continue - for tool_name, func in tr.tools.items(): - tools_dict[tool_name] = func + component_id = hex(id(action))[-8:] + component_type = type(action).__name__ + + for original_tool_name, tool_instance in tr.tools.items(): + # Auto-prefix tool name using component ID to avoid collisions + prefixed_name = f"component_{component_id}__{original_tool_name}" + + # Detect collision and warn if it still occurs (defensive) + if prefixed_name in tools_dict: + MelleaLogger.get_logger().warning( + f"Tool name collision even after prefixing: '{prefixed_name}' " + f"already exists (component {component_type} {component_id}); skipping tool '{original_tool_name}'" + ) + continue + + # Add tool with prefixed name + tools_dict[prefixed_name] = tool_instance def convert_tools_to_json(tools: dict[str, AbstractMelleaTool]) -> list[dict]: """Convert tools to json dict representation. + Ensures that tool names in JSON schemas match the keys in the tools dict, + which is necessary when tools have been renamed (e.g., prefixed for conflict avoidance). + Args: tools: Mapping of tool name to `AbstractMelleaTool` instance. @@ -403,7 +426,20 @@ def convert_tools_to_json(tools: dict[str, AbstractMelleaTool]) -> list[dict]: - WatsonxAI uses `from langchain_ibm.chat_models import convert_to_openai_tool` in their demos, but it gives the same values. - OpenAI uses the same format / schema. """ - return [t.as_json_tool for t in tools.values()] + result = [] + for tool_name, tool_instance in tools.items(): + tool_json = tool_instance.as_json_tool.copy() + + # Update the function name in JSON to match the dict key (for prefixed names). + # This ensures the model sees and requests the prefixed name. + if tool_json.get("function", {}).get("name") != tool_name: + if "function" in tool_json: + tool_json["function"] = tool_json["function"].copy() + tool_json["function"]["name"] = tool_name + + result.append(tool_json) + + return result def json_extraction(text: str) -> Generator[dict, None, None]: diff --git a/mellea/telemetry/metrics.py b/mellea/telemetry/metrics.py index 30d90365d..01db3d55b 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -970,17 +970,25 @@ def _get_tool_calls_counter() -> Any: def record_tool_call(tool: str, status: str) -> None: """Record one tool invocation. - This is a no-op when metrics are disabled, ensuring zero overhead. + This is a no-op when metrics are disabled, ensuring zero overhead. Records + `gen_ai.tool.name` (full tool name) and `status` as metric attributes. + + Component identification is encoded in the tool name prefix (component_{id}__name). + We do not extract component_id as a separate metric label to avoid unbounded + cardinality explosion on long-lived servers. Component tracing belongs in + OpenTelemetry spans or structured logs, not metric labels. Args: - tool: Name of the tool that was invoked. + tool: Name of the tool that was invoked (e.g., "component_203e1b50__query" or "my_tool"). status: `"success"` if the tool executed without error, `"failure"` otherwise. """ if _meter is None: return + attributes: dict[str, str] = {"gen_ai.tool.name": tool, "status": status} + counter = _get_tool_calls_counter() - counter.add(1, {"gen_ai.tool.name": tool, "status": status}) + counter.add(1, attributes) __all__ = [ diff --git a/test/backends/test_tool_calls.py b/test/backends/test_tool_calls.py index 05aef52a0..5974a5fa7 100644 --- a/test/backends/test_tool_calls.py +++ b/test/backends/test_tool_calls.py @@ -41,6 +41,8 @@ def table() -> Table: def test_tool_called_from_context_action(m: MelleaSession, table: Table): """Make sure tools can be called from actions in the context.""" + import re + m.reset() # Insert a component with tools into the context. @@ -61,7 +63,15 @@ def test2(): ... assert "test2" in tools add_tools_from_context_actions(tools, m.ctx.actions_for_available_tools()) - assert "to_markdown" in tools + # Component tools are now auto-prefixed using component ID to avoid collisions + # Pattern: component_{ID}__tool_name where ID is hex-encoded object identity + table_tools = [ + k for k in tools if k.startswith("component_") and k.endswith("__to_markdown") + ] + assert len(table_tools) == 1, ( + f"Expected 1 table tool with ID-based prefix, found {table_tools}" + ) + assert re.match(r"component_[0-9a-f]{8}__to_markdown", table_tools[0]) @pytest.mark.xfail( diff --git a/test/backends/test_tool_helpers.py b/test/backends/test_tool_helpers.py index a6c4bc611..49f33fdfd 100644 --- a/test/backends/test_tool_helpers.py +++ b/test/backends/test_tool_helpers.py @@ -93,21 +93,47 @@ def get_weather(location: str) -> int: def test_add_tools_from_context_actions(): + import re + ftc1 = FakeToolComponentWithExtraTool() ftc2 = FakeToolComponent() + # Extract component IDs before adding tools (ID is based on Python object identity) + ftc1_id = hex(id(ftc1))[-8:] + ftc2_id = hex(id(ftc2))[-8:] + ctx_actions = [CBlock("Hello"), ftc1, ftc2] tools = {} add_tools_from_context_actions(tools, ctx_actions) - # Check that tools with the same name get properly overwritten in order of ctx. - tool1 = tools["tool1"]._call_tool - assert tool1 == ftc2.tool1, f"{tool1} should == {ftc2.tool1}" + # With auto-prefixing using component IDs, tools with the same name no longer collide. + # Both are preserved with prefixed names: component_{ID}__tool1 + tool1_key_ftc1 = f"component_{ftc1_id}__tool1" + tool1_key_ftc2 = f"component_{ftc2_id}__tool1" + + assert tool1_key_ftc1 in tools, f"Expected {tool1_key_ftc1} in tools" + assert tool1_key_ftc2 in tools, f"Expected {tool1_key_ftc2} in tools" - # Check that tools that aren't overwritten are still there. - tool2 = tools["tool2"]._call_tool + tool1_from_ftc1 = tools[tool1_key_ftc1]._call_tool + assert tool1_from_ftc1 == ftc1.tool1, f"{tool1_from_ftc1} should == {ftc1.tool1}" + + tool1_from_ftc2 = tools[tool1_key_ftc2]._call_tool + assert tool1_from_ftc2 == ftc2.tool1, f"{tool1_from_ftc2} should == {ftc2.tool1}" + + # Check that tools that aren't duplicated are still there with prefixed names. + tool2_key = f"component_{ftc1_id}__tool2" + assert tool2_key in tools, f"Expected {tool2_key} in tools" + + tool2 = tools[tool2_key]._call_tool assert tool2 == ftc1.tool2, f"{tool2} should == {ftc1.tool2}" + # Verify that all tool prefixes match the expected ID pattern + for tool_name in tools: + if tool_name.startswith("component_"): + assert re.match(r"component_[0-9a-f]{8}__", tool_name), ( + f"Tool name {tool_name} does not match ID-based prefix pattern" + ) + if __name__ == "__main__": pytest.main([__file__])