From 612de1aaa821c505780e02b6da044130bb73aaa2 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 23 Jul 2026 09:30:48 -0400 Subject: [PATCH 01/10] add prefix to component tool name Signed-off-by: Akihiko Kuroda --- mellea/backends/tools.py | 52 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index 21ad24956..af7f15a54 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -367,7 +367,11 @@ 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 "componentN." to prevent naming collisions when + multiple components define tools with identical names. 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. @@ -377,21 +381,48 @@ def add_tools_from_context_actions( if ctx_actions is None: return + component_index = 0 for action in ctx_actions: if not isinstance(action, Component): continue # Only components have template representations. tr = action.format_for_llm() if not isinstance(tr, TemplateRepresentation) or tr.tools is None: + component_index += 1 continue - for tool_name, func in tr.tools.items(): - tools_dict[tool_name] = func + # Track mapping from original to prefixed names + name_mapping = {} + + for original_tool_name, tool_instance in tr.tools.items(): + # Auto-prefix tool name to avoid collisions + prefixed_name = f"component{component_index}.{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_index}); skipping tool '{original_tool_name}'" + ) + continue + + # Add tool with prefixed name + tools_dict[prefixed_name] = tool_instance + name_mapping[original_tool_name] = prefixed_name + + # Store mapping on template representation for JSON schema generation + if name_mapping and tr.tool_name_mapping is None: + tr.tool_name_mapping = name_mapping + + component_index += 1 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 +434,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]: From d4930a36484c626f1efd0462be1104f0d1d03829 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 23 Jul 2026 09:35:27 -0400 Subject: [PATCH 02/10] add prefix to component tool name Signed-off-by: Akihiko Kuroda --- mellea/core/base.py | 4 ++++ test/backends/test_tool_calls.py | 3 ++- test/backends/test_tool_helpers.py | 14 +++++++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/mellea/core/base.py b/mellea/core/base.py index 431499d21..641bc95c9 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -1693,6 +1693,9 @@ class TemplateRepresentation: args (dict): Named arguments extracted from the component for template substitution. tools (dict[str, AbstractMelleaTool] | None): Tools available for this representation, keyed by the tool's function name. Defaults to `None`. + tool_name_mapping (dict[str, str] | None): Optional mapping from original tool names to + renamed tool names (e.g., for conflict avoidance via prefixing like "search" → "component0.search"). + Used during tool extraction to support auto-prefixing of component tools. Defaults to `None`. fields (list[Any] | None): An optional ordered list of field values for positional templates. template (str | None): An optional Jinja2 template string to use when rendering. template_order (list[str] | None): An optional ordering hint for template sections/keys. @@ -1710,6 +1713,7 @@ class TemplateRepresentation: tools: dict[str, AbstractMelleaTool] | None = ( None # the key must be the name of the function. ) + tool_name_mapping: dict[str, str] | None = None fields: list[Any] | None = None template: str | None = None template_order: list[str] | None = None diff --git a/test/backends/test_tool_calls.py b/test/backends/test_tool_calls.py index 05aef52a0..0dccb5cc3 100644 --- a/test/backends/test_tool_calls.py +++ b/test/backends/test_tool_calls.py @@ -61,7 +61,8 @@ 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 to avoid collisions (component0.to_markdown) + assert "component0.to_markdown" in tools @pytest.mark.xfail( diff --git a/test/backends/test_tool_helpers.py b/test/backends/test_tool_helpers.py index a6c4bc611..6e672b3de 100644 --- a/test/backends/test_tool_helpers.py +++ b/test/backends/test_tool_helpers.py @@ -100,12 +100,16 @@ def test_add_tools_from_context_actions(): 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, tools with the same name no longer collide. + # Both are preserved with prefixed names: component0.tool1 and component1.tool1 + tool1_from_ftc1 = tools["component0.tool1"]._call_tool + assert tool1_from_ftc1 == ftc1.tool1, f"{tool1_from_ftc1} should == {ftc1.tool1}" + + tool1_from_ftc2 = tools["component1.tool1"]._call_tool + assert tool1_from_ftc2 == ftc2.tool1, f"{tool1_from_ftc2} should == {ftc2.tool1}" - # Check that tools that aren't overwritten are still there. - tool2 = tools["tool2"]._call_tool + # Check that tools that aren't duplicated are still there with prefixed names. + tool2 = tools["component0.tool2"]._call_tool assert tool2 == ftc1.tool2, f"{tool2} should == {ftc1.tool2}" From b3c98a023ea4e47e832d283c39a96c943b84e9b5 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 23 Jul 2026 10:18:52 -0400 Subject: [PATCH 03/10] add prefix to component tool name Signed-off-by: Akihiko Kuroda --- mellea/backends/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index af7f15a54..2dd9c3120 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -445,7 +445,7 @@ def convert_tools_to_json(tools: dict[str, AbstractMelleaTool]) -> list[dict]: tool_json["function"] = tool_json["function"].copy() tool_json["function"]["name"] = tool_name - result.append(tool_json) + result.append(tool_json) return result From a2b3411f7223642a1ea7685e4f6b21a47d638de8 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Fri, 24 Jul 2026 11:25:15 -0400 Subject: [PATCH 04/10] compoment id based prefixing Signed-off-by: Akihiko Kuroda --- mellea/backends/tools.py | 25 ++++++++++++++--------- mellea/core/base.py | 11 +++++++++- test/backends/test_tool_calls.py | 13 ++++++++++-- test/backends/test_tool_helpers.py | 32 +++++++++++++++++++++++++----- 4 files changed, 64 insertions(+), 17 deletions(-) diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index 2dd9c3120..35c4a8188 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -369,8 +369,9 @@ def add_tools_from_context_actions( ): """Extract and merge tools from component actions, with auto-prefixing to avoid collisions. - Tools from each component are prefixed with "componentN." to prevent naming collisions when - multiple components define tools with identical names. This allows safe composition of + 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: @@ -381,28 +382,36 @@ def add_tools_from_context_actions( if ctx_actions is None: return - component_index = 0 for action in ctx_actions: if not isinstance(action, Component): continue # Only components have template representations. tr = action.format_for_llm() if not isinstance(tr, TemplateRepresentation) or tr.tools is None: - component_index += 1 continue + # Extract component metadata for identification and observability + component_id = hex(id(action))[-8:] + component_type = type(action).__name__ + component_description = getattr(action, "description", None) + + # Store metadata on template representation + tr.component_id = component_id + tr.component_type = component_type + tr.component_description = component_description + # Track mapping from original to prefixed names name_mapping = {} for original_tool_name, tool_instance in tr.tools.items(): - # Auto-prefix tool name to avoid collisions - prefixed_name = f"component{component_index}.{original_tool_name}" + # 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_index}); skipping tool '{original_tool_name}'" + f"already exists (component {component_type} {component_id}); skipping tool '{original_tool_name}'" ) continue @@ -414,8 +423,6 @@ def add_tools_from_context_actions( if name_mapping and tr.tool_name_mapping is None: tr.tool_name_mapping = name_mapping - component_index += 1 - def convert_tools_to_json(tools: dict[str, AbstractMelleaTool]) -> list[dict]: """Convert tools to json dict representation. diff --git a/mellea/core/base.py b/mellea/core/base.py index 641bc95c9..853d92959 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -1694,7 +1694,7 @@ class TemplateRepresentation: tools (dict[str, AbstractMelleaTool] | None): Tools available for this representation, keyed by the tool's function name. Defaults to `None`. tool_name_mapping (dict[str, str] | None): Optional mapping from original tool names to - renamed tool names (e.g., for conflict avoidance via prefixing like "search" → "component0.search"). + renamed tool names (e.g., for conflict avoidance via prefixing like "search" → "component_a1b2c3d4.search"). Used during tool extraction to support auto-prefixing of component tools. Defaults to `None`. fields (list[Any] | None): An optional ordered list of field values for positional templates. template (str | None): An optional Jinja2 template string to use when rendering. @@ -1702,6 +1702,12 @@ class TemplateRepresentation: images (list[ImageBlock | ImageUrlBlock] | None): Optional list of image blocks associated with this representation. audio (list[AudioBlock | AudioUrlBlock] | None): Optional list of audio blocks associated with this representation. + component_id (str | None): Hex-encoded object identifier for the component (e.g., "a1b2c3d4"). + Used for stable component identification and tool prefixing across turns. Defaults to `None`. + component_type (str | None): The class name of the component (e.g., "Table", "Message"). + Useful for debugging and observability. Defaults to `None`. + component_description (str | None): Optional human-readable description of the component. + Defaults to `None`. """ @@ -1719,6 +1725,9 @@ class TemplateRepresentation: template_order: list[str] | None = None images: list[ImageBlock | ImageUrlBlock] | None = None audio: list[AudioBlock | AudioUrlBlock] | None = None + component_id: str | None = None + component_type: str | None = None + component_description: str | None = None @dataclass diff --git a/test/backends/test_tool_calls.py b/test/backends/test_tool_calls.py index 0dccb5cc3..a6967c63a 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,8 +63,15 @@ def test2(): ... assert "test2" in tools add_tools_from_context_actions(tools, m.ctx.actions_for_available_tools()) - # Component tools are now auto-prefixed to avoid collisions (component0.to_markdown) - assert "component0.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 6e672b3de..020106065 100644 --- a/test/backends/test_tool_helpers.py +++ b/test/backends/test_tool_helpers.py @@ -93,25 +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) - # With auto-prefixing, tools with the same name no longer collide. - # Both are preserved with prefixed names: component0.tool1 and component1.tool1 - tool1_from_ftc1 = tools["component0.tool1"]._call_tool + # 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" + + 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["component1.tool1"]._call_tool + 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 = tools["component0.tool2"]._call_tool + 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__]) From 83dd5c1b240b5e1b696ba74cacd1b506ffdc8ac6 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Fri, 24 Jul 2026 19:49:15 -0400 Subject: [PATCH 05/10] add duplicate tool name example Signed-off-by: Akihiko Kuroda --- docs/examples/components/README.md | 165 ++++++++++ .../components/duplicate_tool_names.py | 267 +++++++++++++++ .../components/pattern2_context_and_tools.py | 303 ++++++++++++++++++ mellea/telemetry/metrics.py | 19 +- 4 files changed, 752 insertions(+), 2 deletions(-) create mode 100644 docs/examples/components/README.md create mode 100644 docs/examples/components/duplicate_tool_names.py create mode 100644 docs/examples/components/pattern2_context_and_tools.py diff --git a/docs/examples/components/README.md b/docs/examples/components/README.md new file mode 100644 index 000000000..bfe837154 --- /dev/null +++ b/docs/examples/components/README.md @@ -0,0 +1,165 @@ +# 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` +**Main example** - Demonstrates how Mellea handles multiple components with identical tool names. + +**What it shows:** +- Two components (`DatabaseComponent` and `SearchComponent`) both define a `query` tool +- Automatic tool prefixing using component IDs (`component_{ID}.query`) +- LLM receiving and using both prefixed tools +- Proper tool execution via Mellea's pipeline (enables 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 `_call_tools()` to enable telemetry recording +- Both tools' component IDs visible in `mellea.tool.calls` metrics + +--- + +### `duplicate_tool_names_experiments.py` +**Advanced experiments** - Explores various scenarios and edge cases with component tool prefixing. + +**Experiments:** +1. **Three Components with Same Tool Name** - Verify scaling beyond 2 components +2. **Tool Name Mapping Inspection** - Examine the extracted tool objects and structure +3. **Prefixing Stability** - Show that same instances get same IDs, new instances get different IDs +4. **Selective Tool Access** - Filter tools before passing to LLM +5. **Tool Deduplication** - Behavior when same component added multiple times +6. **LLM with Filtered Tools** - Call LLM with a subset of available tools + +**Run it:** +```bash +uv run python docs/examples/components/duplicate_tool_names_experiments.py +uv run pytest docs/examples/components/duplicate_tool_names_experiments.py -v +``` + +**Key findings:** +- Component IDs are stable for the same instance (multi-turn ready) +- New component instances get new IDs (expected behavior) +- Duplicate component instances are gracefully handled with warnings +- Tool filtering works by subsetting the tools dict +- LLM respects prompt guidance even when given multiple tools + +--- + +### `pattern2_context_and_tools.py` +**Pattern 2 demonstration** - Shows how to combine components in context with explicit tool passing for tool calling. + +**What it shows:** +- Pattern 2 approach: Components in session context + explicit tools via `ModelOption.TOOLS` +- How to add context blocks and components to the session +- Separating concerns: context rendering vs. tool availability +- Proper tool execution via Mellea's pipeline (enables telemetry) +- Multi-turn stability with component ID-based prefixing + +**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 +``` + +**Key concepts:** +- Pattern 1: Extract tools only (simple tool calling) +- Pattern 2: Components in context + explicit tools (full control) +- Both patterns use component ID-based prefixing +- Tools must be explicitly passed even when components are in context +- Each tool call recorded in `mellea.tool.calls` metric with component_id + +--- + +### `telemetry_tool_calling_demo.py` +**Telemetry demonstration** - Shows how to enable and view tool calling metrics. + +**What it shows:** +- How to enable telemetry with environment variables +- Setting up console exporter for metric viewing +- Tool calls executed via `_call_tools()` to trigger telemetry hooks +- JSON format of OpenTelemetry metrics +- Component ID extraction in `mellea.tool.calls` metric +- Multi-exporter setup (Console, OTLP, Prometheus) + +**Run it (with telemetry):** +```bash +export MELLEA_METRICS_ENABLED=true +export MELLEA_METRICS_CONSOLE=true +uv run python docs/examples/components/telemetry_tool_calling_demo.py +``` + +**Run it (without telemetry):** +```bash +uv run python docs/examples/components/telemetry_tool_calling_demo.py +``` + +**Key outputs:** +- Tool calls listed with their component IDs +- OpenTelemetry JSON metrics showing `mellea.tool.calls` counter +- Each tool invocation tracked with: + - `tool`: Full tool name (e.g., `component_203e1b50.query`) + - `status`: `"success"` or `"failure"` + - `component_id`: Extracted from tool name (e.g., `203e1b50`) + +--- + +## 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/core/base.py` - `TemplateRepresentation` with component metadata +- `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..5703a45c3 --- /dev/null +++ b/docs/examples/components/duplicate_tool_names.py @@ -0,0 +1,267 @@ +# pytest: ollama, e2e +"""Example demonstrating two components with tools that have the same name. + +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)}, + ) + + 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)}, + ) + + 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/pattern2_context_and_tools.py b/docs/examples/components/pattern2_context_and_tools.py new file mode 100644 index 000000000..64d5fc3f0 --- /dev/null +++ b/docs/examples/components/pattern2_context_and_tools.py @@ -0,0 +1,303 @@ +# pytest: ollama, e2e +"""Example demonstrating Pattern 2: Components in context + explicit tools for tool calling. + +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, 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)}, + ) + + 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)}, + ) + + def _parse(self, computed: ModelOutputThunk) -> str: + """Parse the LLM response.""" + return str(computed.value) + + +def main(): + """Demonstrate Pattern 2: Components in context + explicit tools.""" + print("=" * 70) + print("PATTERN 2: Components in Context + Explicit Tools for Tool Calling") + print("=" * 70) + + # Create components + db_component = DatabaseComponent() + search_component = SearchComponent() + + print("\n" + "=" * 70) + print("STEP 1: Extract Tools from Components") + print("=" * 70) + + # Extract tools from components + ctx_actions = [db_component, search_component] + tools = {} + add_tools_from_context_actions(tools, ctx_actions) + + print("\nExtracted tools with ID-based prefixes:") + for tool_name in sorted(tools.keys()): + if tool_name.startswith("component_"): + print(f" - {tool_name}") + + query_tools = [k for k in tools if "query" in k] + print(f"\nTotal query tools: {len(query_tools)}") + + print("\n" + "=" * 70) + print("STEP 2: 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=ChatContext()) + + print("\nSession created") + + print("\n" + "=" * 70) + print("STEP 3: Add Context for Additional Information") + print("=" * 70) + + # Add context blocks to the session (for demonstration of Pattern 2) + # In a real scenario, components could be added here if they have templates + print("\nAdding context information...") + context_block = CBlock( + "Available systems:\n" + "1. Database: For querying user information\n" + "2. Search: For finding documentation" + ) + session.ctx = session.ctx.add(context_block) + print(" ✓ Added context block") + + # Verify items are in context + context_items = session.ctx.view_for_generation() + print(f"\nContext items: {len(context_items) if context_items else 0}") + if context_items: + for i, item in enumerate(context_items): + if isinstance(item, CBlock): + print(f" {i + 1}. CBlock: {item.value[:40]}...") + + 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}") + print(f"\nTools available: {sorted(tools.keys())}") + + # IMPORTANT: Must explicitly pass tools via ModelOption.TOOLS + print("\nCalling session.instruct() with:") + print(f" - Components in context: {len(context_items) if context_items else 0}") + print(f" - Tools via ModelOption.TOOLS: {sorted(tools.keys())}") + + response = session.instruct( + prompt, + model_options={ + ModelOption.TOOLS: tools, # ← EXPLICIT TOOLS REQUIRED + 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 + Explicit Tools + +Approach: + 1. Create components with tools + 2. Extract tools: add_tools_from_context_actions() + 3. Create session with ChatContext + 4. Add components to context: session.ctx = session.ctx.add(component) + 5. Pass tools explicitly: ModelOption.TOOLS = tools + 6. Call session.instruct() with tool_calls=True + +Benefits: + ✓ Components rendered in the prompt + ✓ Components' tools available for tool calling + ✓ ID-based prefixing prevents tool collisions + ✓ Multi-turn stable (same instances = same IDs) + +Key Point: + Even though components are in context, tools MUST be explicitly passed + via ModelOption.TOOLS. This is intentional design - two separate concerns: + - Context rendering (what the LLM sees in the conversation) + - Tool availability (what tools the LLM can call) + +When to Use Pattern 2: + - You need components to appear in the conversation + - You also need their tools available for calling + - You want full control over tool availability + """) + + print("=" * 70) + print("✓ Pattern 2 Successfully Demonstrated") + print("=" * 70) + + +if __name__ == "__main__": + main() diff --git a/mellea/telemetry/metrics.py b/mellea/telemetry/metrics.py index 30d90365d..4c147c69d 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -973,14 +973,29 @@ def record_tool_call(tool: str, status: str) -> None: This is a no-op when metrics are disabled, ensuring zero overhead. 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. + + Attributes recorded: + - tool: Full tool name + - status: Execution status + - component_id: Extracted from tool name if available (e.g., "203e1b50" from "component_203e1b50.query") """ if _meter is None: return + attributes: dict[str, str] = {"tool": tool, "status": status} + + # Extract component_id from prefixed tool name for better observability + # Format: component_{component_id}.{original_tool_name} + if tool.startswith("component_"): + parts = tool.split(".", 1) + if len(parts) == 2: + component_id = parts[0].replace("component_", "") + attributes["component_id"] = component_id + counter = _get_tool_calls_counter() - counter.add(1, {"gen_ai.tool.name": tool, "status": status}) + counter.add(1, attributes) __all__ = [ From 243d4753aaabf2604efe4ae95f7f503e8378ae41 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Fri, 24 Jul 2026 21:47:30 -0400 Subject: [PATCH 06/10] updated component tool usage Signed-off-by: Akihiko Kuroda --- docs/examples/components/README.md | 14 +-- .../components/duplicate_tool_names.py | 2 + .../components/pattern2_context_and_tools.py | 99 +++++++------------ 3 files changed, 48 insertions(+), 67 deletions(-) diff --git a/docs/examples/components/README.md b/docs/examples/components/README.md index bfe837154..c4f9fb95c 100644 --- a/docs/examples/components/README.md +++ b/docs/examples/components/README.md @@ -62,14 +62,15 @@ uv run pytest docs/examples/components/duplicate_tool_names_experiments.py -v --- ### `pattern2_context_and_tools.py` -**Pattern 2 demonstration** - Shows how to combine components in context with explicit tool passing for tool calling. +**Pattern 2 demonstration** - Shows how to combine components in context with automatic tool extraction for tool calling. **What it shows:** -- Pattern 2 approach: Components in session context + explicit tools via `ModelOption.TOOLS` -- How to add context blocks and components to the session -- Separating concerns: context rendering vs. tool availability +- 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 Mellea's pipeline (enables telemetry) - Multi-turn stability with component ID-based prefixing +- Components with templates render in the conversation **Run it:** ```bash @@ -86,9 +87,10 @@ uv run python docs/examples/components/pattern2_context_and_tools.py **Key concepts:** - Pattern 1: Extract tools only (simple tool calling) -- Pattern 2: Components in context + explicit tools (full control) +- Pattern 2: Components in context with auto-extraction (implicit tool passing) - Both patterns use component ID-based prefixing -- Tools must be explicitly passed even when components are in context +- NO explicit `ModelOption.TOOLS` needed - backend auto-extracts from context +- Components must have valid templates for rendering - Each tool call recorded in `mellea.tool.calls` metric with component_id --- diff --git a/docs/examples/components/duplicate_tool_names.py b/docs/examples/components/duplicate_tool_names.py index 5703a45c3..abdaae9a5 100644 --- a/docs/examples/components/duplicate_tool_names.py +++ b/docs/examples/components/duplicate_tool_names.py @@ -120,6 +120,7 @@ def format_for_llm(self) -> TemplateRepresentation | str: 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: @@ -143,6 +144,7 @@ def format_for_llm(self) -> TemplateRepresentation | str: 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: diff --git a/docs/examples/components/pattern2_context_and_tools.py b/docs/examples/components/pattern2_context_and_tools.py index 64d5fc3f0..e67f40366 100644 --- a/docs/examples/components/pattern2_context_and_tools.py +++ b/docs/examples/components/pattern2_context_and_tools.py @@ -21,7 +21,7 @@ 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.backends.tools import MelleaTool from mellea.core import CBlock, Component, ModelOutputThunk, TemplateRepresentation from mellea.core.base import AbstractMelleaTool from mellea.formatters import TemplateFormatter @@ -116,6 +116,7 @@ def format_for_llm(self) -> TemplateRepresentation | str: 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: @@ -139,6 +140,7 @@ def format_for_llm(self) -> TemplateRepresentation | str: 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: @@ -147,34 +149,35 @@ def _parse(self, computed: ModelOutputThunk) -> str: def main(): - """Demonstrate Pattern 2: Components in context + explicit tools.""" + """Demonstrate Pattern 2: Components in context + auto tool extraction.""" print("=" * 70) - print("PATTERN 2: Components in Context + Explicit Tools for Tool Calling") + 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 1: Extract Tools from Components") + print("STEP 2: Add Components to Context") print("=" * 70) - # Extract tools from components - ctx_actions = [db_component, search_component] - tools = {} - add_tools_from_context_actions(tools, ctx_actions) - - print("\nExtracted tools with ID-based prefixes:") - for tool_name in sorted(tools.keys()): - if tool_name.startswith("component_"): - print(f" - {tool_name}") - - query_tools = [k for k in tools if "query" in k] - print(f"\nTotal query tools: {len(query_tools)}") + # 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 2: Set Up Backend and Session") + print("STEP 3: Set Up Backend and Session") print("=" * 70) ollama_host = os.environ.get("OLLAMA_HOST", "localhost:11434") @@ -189,32 +192,9 @@ def main(): base_url=f"{ollama_host}/v1", api_key="ollama", ) - session = MelleaSession(backend, ctx=ChatContext()) - - print("\nSession created") - - print("\n" + "=" * 70) - print("STEP 3: Add Context for Additional Information") - print("=" * 70) - - # Add context blocks to the session (for demonstration of Pattern 2) - # In a real scenario, components could be added here if they have templates - print("\nAdding context information...") - context_block = CBlock( - "Available systems:\n" - "1. Database: For querying user information\n" - "2. Search: For finding documentation" - ) - session.ctx = session.ctx.add(context_block) - print(" ✓ Added context block") + session = MelleaSession(backend, ctx=session_ctx) - # Verify items are in context - context_items = session.ctx.view_for_generation() - print(f"\nContext items: {len(context_items) if context_items else 0}") - if context_items: - for i, item in enumerate(context_items): - if isinstance(item, CBlock): - print(f" {i + 1}. CBlock: {item.value[:40]}...") + print("\nSession created with components in context") print("\n" + "=" * 70) print("STEP 4: LLM Generation with Tool Calling") @@ -228,17 +208,16 @@ def main(): ) print(f"\nPrompt:\n{prompt}") - print(f"\nTools available: {sorted(tools.keys())}") - # IMPORTANT: Must explicitly pass tools via ModelOption.TOOLS + # IMPORTANT: NO ModelOption.TOOLS - backend auto-extracts from context! print("\nCalling session.instruct() with:") - print(f" - Components in context: {len(context_items) if context_items else 0}") - print(f" - Tools via ModelOption.TOOLS: {sorted(tools.keys())}") + 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.TOOLS: tools, # ← EXPLICIT TOOLS REQUIRED ModelOption.TOOL_CHOICE: "auto", ModelOption.MAX_NEW_TOKENS: 1000, }, @@ -266,32 +245,30 @@ def main(): print("Pattern 2 Summary") print("=" * 70) print(""" -PATTERN 2: Components in Context + Explicit Tools +PATTERN 2: Components in Context + Auto Tool Extraction Approach: - 1. Create components with tools - 2. Extract tools: add_tools_from_context_actions() - 3. Create session with ChatContext - 4. Add components to context: session.ctx = session.ctx.add(component) - 5. Pass tools explicitly: ModelOption.TOOLS = tools - 6. Call session.instruct() with tool_calls=True + 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 available for tool calling + ✓ 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: - Even though components are in context, tools MUST be explicitly passed - via ModelOption.TOOLS. This is intentional design - two separate concerns: - - Context rendering (what the LLM sees in the conversation) - - Tool availability (what tools the LLM can call) + 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 also need their tools available for calling - - You want full control over tool availability + - You want their tools available for calling automatically + - You prefer implicit over explicit tool passing """) print("=" * 70) From fe8fe84d39b2719cb169aa83b28f7976d4a3969a Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Sat, 25 Jul 2026 09:58:35 -0400 Subject: [PATCH 07/10] fix merge error Signed-off-by: Akihiko Kuroda --- mellea/telemetry/metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mellea/telemetry/metrics.py b/mellea/telemetry/metrics.py index 4c147c69d..634b4fe1a 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -977,14 +977,14 @@ def record_tool_call(tool: str, status: str) -> None: status: `"success"` if the tool executed without error, `"failure"` otherwise. Attributes recorded: - - tool: Full tool name + - gen_ai.tool.name: Full tool name (semantic convention) - status: Execution status - component_id: Extracted from tool name if available (e.g., "203e1b50" from "component_203e1b50.query") """ if _meter is None: return - attributes: dict[str, str] = {"tool": tool, "status": status} + attributes: dict[str, str] = {"gen_ai.tool.name": tool, "status": status} # Extract component_id from prefixed tool name for better observability # Format: component_{component_id}.{original_tool_name} From bb0bc478633c921faddd94f21364866b7664e379 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 27 Jul 2026 14:58:26 -0400 Subject: [PATCH 08/10] review comments Signed-off-by: Akihiko Kuroda --- docs/examples/components/README.md | 127 +++++---- .../components/duplicate_tool_names.py | 11 +- .../duplicate_tool_names_public_api.py | 249 +++++++++++++++++ .../components/pattern2_context_and_tools.py | 5 +- .../components/pattern2_public_api.py | 258 ++++++++++++++++++ mellea/backends/tools.py | 17 +- mellea/core/base.py | 13 - mellea/telemetry/metrics.py | 23 +- test/backends/test_tool_calls.py | 6 +- test/backends/test_tool_helpers.py | 10 +- 10 files changed, 602 insertions(+), 117 deletions(-) create mode 100644 docs/examples/components/duplicate_tool_names_public_api.py create mode 100644 docs/examples/components/pattern2_public_api.py diff --git a/docs/examples/components/README.md b/docs/examples/components/README.md index c4f9fb95c..1e229a121 100644 --- a/docs/examples/components/README.md +++ b/docs/examples/components/README.md @@ -4,14 +4,17 @@ This directory contains examples demonstrating Mellea's component system, partic ## Files -### `duplicate_tool_names.py` -**Main example** - Demonstrates how Mellea handles multiple components with identical tool names. +### `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`) -- LLM receiving and using both prefixed tools -- Proper tool execution via Mellea's pipeline (enables telemetry) +- 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:** @@ -28,47 +31,56 @@ uv run python docs/examples/components/duplicate_tool_names.py ``` **Key outputs:** -- Tools extracted with ID-based prefixes: `component_1adeba40.query`, `component_1c611a00.query` +- 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 `_call_tools()` to enable telemetry recording -- Both tools' component IDs visible in `mellea.tool.calls` metrics +- Tool calls executed via private `_call_tools()` to enable telemetry recording --- -### `duplicate_tool_names_experiments.py` -**Advanced experiments** - Explores various scenarios and edge cases with component tool prefixing. +### `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 -**Experiments:** -1. **Three Components with Same Tool Name** - Verify scaling beyond 2 components -2. **Tool Name Mapping Inspection** - Examine the extracted tool objects and structure -3. **Prefixing Stability** - Show that same instances get same IDs, new instances get different IDs -4. **Selective Tool Access** - Filter tools before passing to LLM -5. **Tool Deduplication** - Behavior when same component added multiple times -6. **LLM with Filtered Tools** - Call LLM with a subset of available tools +**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_experiments.py -uv run pytest docs/examples/components/duplicate_tool_names_experiments.py -v +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 ``` -**Key findings:** -- Component IDs are stable for the same instance (multi-turn ready) -- New component instances get new IDs (expected behavior) -- Duplicate component instances are gracefully handled with warnings -- Tool filtering works by subsetting the tools dict -- LLM respects prompt guidance even when given multiple tools +**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` -**Pattern 2 demonstration** - Shows how to combine components in context with automatic tool extraction for tool calling. +### `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 Mellea's pipeline (enables telemetry) +- Proper tool execution via private `_call_tools()` (enables telemetry) - Multi-turn stability with component ID-based prefixing - Components with templates render in the conversation @@ -85,46 +97,42 @@ export MELLEA_METRICS_CONSOLE=true uv run python docs/examples/components/pattern2_context_and_tools.py ``` -**Key concepts:** -- Pattern 1: Extract tools only (simple tool calling) -- Pattern 2: Components in context with auto-extraction (implicit tool passing) -- Both patterns use component ID-based prefixing -- NO explicit `ModelOption.TOOLS` needed - backend auto-extracts from context -- Components must have valid templates for rendering -- Each tool call recorded in `mellea.tool.calls` metric with component_id - --- -### `telemetry_tool_calling_demo.py` -**Telemetry demonstration** - Shows how to enable and view tool calling metrics. +### `pattern2_public_api.py` (Public API - Recommended) +**Modern example** - Shows Pattern 2 (components in context) using public APIs only. **What it shows:** -- How to enable telemetry with environment variables -- Setting up console exporter for metric viewing -- Tool calls executed via `_call_tools()` to trigger telemetry hooks -- JSON format of OpenTelemetry metrics -- Component ID extraction in `mellea.tool.calls` metric -- Multi-exporter setup (Console, OTLP, Prometheus) - -**Run it (with telemetry):** +- 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 -export MELLEA_METRICS_ENABLED=true -export MELLEA_METRICS_CONSOLE=true -uv run python docs/examples/components/telemetry_tool_calling_demo.py +uv run python docs/examples/components/pattern2_public_api.py +uv run pytest docs/examples/components/pattern2_public_api.py -v ``` -**Run it (without telemetry):** +**View telemetry metrics:** ```bash -uv run python docs/examples/components/telemetry_tool_calling_demo.py +export MELLEA_METRICS_ENABLED=true +export MELLEA_METRICS_CONSOLE=true +uv run python docs/examples/components/pattern2_public_api.py ``` -**Key outputs:** -- Tool calls listed with their component IDs -- OpenTelemetry JSON metrics showing `mellea.tool.calls` counter -- Each tool invocation tracked with: - - `tool`: Full tool name (e.g., `component_203e1b50.query`) - - `status`: `"success"` or `"failure"` - - `component_id`: Extracted from tool name (e.g., `203e1b50`) +**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 --- @@ -136,7 +144,7 @@ When multiple components define tools with the same name, Mellea prevents collis ``` Original: query, query -Prefixed: component_1adeba40.query, component_1c611a00.query +Prefixed: component_1adeba40__query, component_1c611a00__query ``` **How it works:** @@ -160,7 +168,6 @@ Prefixed: component_1adeba40.query, component_1c611a00.query ## Related Source Files - `mellea/backends/tools.py` - `add_tools_from_context_actions()` implementation -- `mellea/core/base.py` - `TemplateRepresentation` with component metadata - `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) diff --git a/docs/examples/components/duplicate_tool_names.py b/docs/examples/components/duplicate_tool_names.py index abdaae9a5..9876ccebb 100644 --- a/docs/examples/components/duplicate_tool_names.py +++ b/docs/examples/components/duplicate_tool_names.py @@ -1,8 +1,11 @@ # pytest: ollama, e2e -"""Example demonstrating two components with tools that have the same name. +"""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 +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: @@ -249,9 +252,9 @@ def main(): 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} + # Format: component_{component_id}__{tool_name} if tool_name.startswith("component_"): - parts = tool_name.split(".", 1) + parts = tool_name.split("__", 1) if len(parts) == 2: component_id = parts[0].replace("component_", "") original_name = parts[1] 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..bdeadf739 --- /dev/null +++ b/docs/examples/components/duplicate_tool_names_public_api.py @@ -0,0 +1,249 @@ +# 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 index e67f40366..4e54450bf 100644 --- a/docs/examples/components/pattern2_context_and_tools.py +++ b/docs/examples/components/pattern2_context_and_tools.py @@ -1,5 +1,8 @@ # pytest: ollama, e2e -"""Example demonstrating Pattern 2: Components in context + explicit tools for tool calling. +"""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) diff --git a/docs/examples/components/pattern2_public_api.py b/docs/examples/components/pattern2_public_api.py new file mode 100644 index 000000000..f2523092a --- /dev/null +++ b/docs/examples/components/pattern2_public_api.py @@ -0,0 +1,258 @@ +# 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/tools.py b/mellea/backends/tools.py index 35c4a8188..f801411d6 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -390,22 +390,12 @@ def add_tools_from_context_actions( if not isinstance(tr, TemplateRepresentation) or tr.tools is None: continue - # Extract component metadata for identification and observability component_id = hex(id(action))[-8:] component_type = type(action).__name__ - component_description = getattr(action, "description", None) - - # Store metadata on template representation - tr.component_id = component_id - tr.component_type = component_type - tr.component_description = component_description - - # Track mapping from original to prefixed names - name_mapping = {} 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}" + prefixed_name = f"component_{component_id}__{original_tool_name}" # Detect collision and warn if it still occurs (defensive) if prefixed_name in tools_dict: @@ -417,11 +407,6 @@ def add_tools_from_context_actions( # Add tool with prefixed name tools_dict[prefixed_name] = tool_instance - name_mapping[original_tool_name] = prefixed_name - - # Store mapping on template representation for JSON schema generation - if name_mapping and tr.tool_name_mapping is None: - tr.tool_name_mapping = name_mapping def convert_tools_to_json(tools: dict[str, AbstractMelleaTool]) -> list[dict]: diff --git a/mellea/core/base.py b/mellea/core/base.py index 853d92959..431499d21 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -1693,21 +1693,12 @@ class TemplateRepresentation: args (dict): Named arguments extracted from the component for template substitution. tools (dict[str, AbstractMelleaTool] | None): Tools available for this representation, keyed by the tool's function name. Defaults to `None`. - tool_name_mapping (dict[str, str] | None): Optional mapping from original tool names to - renamed tool names (e.g., for conflict avoidance via prefixing like "search" → "component_a1b2c3d4.search"). - Used during tool extraction to support auto-prefixing of component tools. Defaults to `None`. fields (list[Any] | None): An optional ordered list of field values for positional templates. template (str | None): An optional Jinja2 template string to use when rendering. template_order (list[str] | None): An optional ordering hint for template sections/keys. images (list[ImageBlock | ImageUrlBlock] | None): Optional list of image blocks associated with this representation. audio (list[AudioBlock | AudioUrlBlock] | None): Optional list of audio blocks associated with this representation. - component_id (str | None): Hex-encoded object identifier for the component (e.g., "a1b2c3d4"). - Used for stable component identification and tool prefixing across turns. Defaults to `None`. - component_type (str | None): The class name of the component (e.g., "Table", "Message"). - Useful for debugging and observability. Defaults to `None`. - component_description (str | None): Optional human-readable description of the component. - Defaults to `None`. """ @@ -1719,15 +1710,11 @@ class TemplateRepresentation: tools: dict[str, AbstractMelleaTool] | None = ( None # the key must be the name of the function. ) - tool_name_mapping: dict[str, str] | None = None fields: list[Any] | None = None template: str | None = None template_order: list[str] | None = None images: list[ImageBlock | ImageUrlBlock] | None = None audio: list[AudioBlock | AudioUrlBlock] | None = None - component_id: str | None = None - component_type: str | None = None - component_description: str | None = None @dataclass diff --git a/mellea/telemetry/metrics.py b/mellea/telemetry/metrics.py index 634b4fe1a..01db3d55b 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -970,30 +970,23 @@ 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 (e.g., "component_203e1b50.query" or "my_tool"). + 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. - - Attributes recorded: - - gen_ai.tool.name: Full tool name (semantic convention) - - status: Execution status - - component_id: Extracted from tool name if available (e.g., "203e1b50" from "component_203e1b50.query") """ if _meter is None: return attributes: dict[str, str] = {"gen_ai.tool.name": tool, "status": status} - # Extract component_id from prefixed tool name for better observability - # Format: component_{component_id}.{original_tool_name} - if tool.startswith("component_"): - parts = tool.split(".", 1) - if len(parts) == 2: - component_id = parts[0].replace("component_", "") - attributes["component_id"] = component_id - counter = _get_tool_calls_counter() counter.add(1, attributes) diff --git a/test/backends/test_tool_calls.py b/test/backends/test_tool_calls.py index a6967c63a..5974a5fa7 100644 --- a/test/backends/test_tool_calls.py +++ b/test/backends/test_tool_calls.py @@ -64,14 +64,14 @@ def test2(): ... add_tools_from_context_actions(tools, m.ctx.actions_for_available_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 + # 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") + 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]) + 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 020106065..49f33fdfd 100644 --- a/test/backends/test_tool_helpers.py +++ b/test/backends/test_tool_helpers.py @@ -107,9 +107,9 @@ def test_add_tools_from_context_actions(): add_tools_from_context_actions(tools, ctx_actions) # 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" + # 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" @@ -121,7 +121,7 @@ def test_add_tools_from_context_actions(): 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" + tool2_key = f"component_{ftc1_id}__tool2" assert tool2_key in tools, f"Expected {tool2_key} in tools" tool2 = tools[tool2_key]._call_tool @@ -130,7 +130,7 @@ def test_add_tools_from_context_actions(): # 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), ( + assert re.match(r"component_[0-9a-f]{8}__", tool_name), ( f"Tool name {tool_name} does not match ID-based prefix pattern" ) From e2f580b48a0131115079ae11c9ce6dcb21ed7932 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 27 Jul 2026 20:55:36 -0400 Subject: [PATCH 09/10] fix ollama schema handling Signed-off-by: Akihiko Kuroda --- mellea/backends/ollama.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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), From 0e9469c3868b28e6ee97ce7660589b8cf0c9a015 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 27 Jul 2026 21:05:03 -0400 Subject: [PATCH 10/10] fix lint error Signed-off-by: Akihiko Kuroda --- docs/examples/components/duplicate_tool_names_public_api.py | 4 +++- docs/examples/components/pattern2_public_api.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/examples/components/duplicate_tool_names_public_api.py b/docs/examples/components/duplicate_tool_names_public_api.py index bdeadf739..09eb100da 100644 --- a/docs/examples/components/duplicate_tool_names_public_api.py +++ b/docs/examples/components/duplicate_tool_names_public_api.py @@ -231,7 +231,9 @@ def main() -> None: # 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):") + 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}") diff --git a/docs/examples/components/pattern2_public_api.py b/docs/examples/components/pattern2_public_api.py index f2523092a..dd86a554f 100644 --- a/docs/examples/components/pattern2_public_api.py +++ b/docs/examples/components/pattern2_public_api.py @@ -230,7 +230,9 @@ def main() -> None: # 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):") + 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}")