From f381e8d9a65800c4e0451b998e0192e18d4ae22f Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 23 Jul 2026 09:30:48 -0400 Subject: [PATCH 01/17] 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 0bc3d04d2c..de815132f7 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -367,7 +367,11 @@ def add_tools_from_model_options( def add_tools_from_context_actions( tools_dict: dict[str, AbstractMelleaTool], ctx_actions: list[Span] | 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. Dict keys are unique, @@ -379,21 +383,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. @@ -405,7 +436,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 7a8adaf9da808120e3e400e2d78a07f02a82ee7f Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 23 Jul 2026 09:35:27 -0400 Subject: [PATCH 02/17] 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 1080ef94b3..1a7e349920 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -1950,6 +1950,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. @@ -1981,6 +1984,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 05aef52a01..0dccb5cc3e 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 a6c4bc611c..6e672b3de1 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 3461925715d33d952d0d4804c2d8b7731a79ace3 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 23 Jul 2026 10:18:52 -0400 Subject: [PATCH 03/17] 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 de815132f7..fe6203277d 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -447,7 +447,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 6e91bff9b143118482dbbb6fa29751c651bb420e Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Fri, 24 Jul 2026 11:25:15 -0400 Subject: [PATCH 04/17] 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 fe6203277d..ae20415168 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: @@ -383,28 +384,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 @@ -416,8 +425,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 1a7e349920..f360751eb1 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -1951,7 +1951,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. @@ -1973,6 +1973,12 @@ class TemplateRepresentation: tool_name (str | None): For a `role="tool"` component, the name of the tool whose result this message carries (e.g. Ollama's tool-result turn keys on it). Defaults to `None`. + 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`. """ @@ -1995,6 +2001,9 @@ class TemplateRepresentation: tool_calls: list[dict[str, Any]] | None = None tool_call_id: str | None = None tool_name: str | 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 0dccb5cc3e..a6967c63ac 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 6e672b3de1..0201060657 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 32235420193afd7162896e818181d0d549c3c8c2 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Fri, 24 Jul 2026 19:49:15 -0400 Subject: [PATCH 05/17] 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 0000000000..bfe8371546 --- /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 0000000000..5703a45c36 --- /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 0000000000..64d5fc3f04 --- /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 26f990e1d9..bb0ff48baf 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -1162,14 +1162,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) _adapter_function_invocations_counter: Any = None From 69e7126f402e72d7b0e42544ddcfe03641ae45c0 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Fri, 24 Jul 2026 21:47:30 -0400 Subject: [PATCH 06/17] 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 bfe8371546..c4f9fb95cf 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 5703a45c36..abdaae9a5d 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 64d5fc3f04..e67f403667 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 1d2ce3bc342557ddbf9e1fe69ba89add140387e7 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Sat, 25 Jul 2026 09:58:35 -0400 Subject: [PATCH 07/17] 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 bb0ff48baf..490981f381 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -1166,14 +1166,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 9049be92db079643bc8e156a926eae1df26d4e3e Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 27 Jul 2026 14:58:26 -0400 Subject: [PATCH 08/17] 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 | 4 - mellea/telemetry/metrics.py | 23 +- test/backends/test_tool_calls.py | 6 +- test/backends/test_tool_helpers.py | 10 +- 10 files changed, 602 insertions(+), 108 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 c4f9fb95cf..1e229a1210 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 abdaae9a5d..9876ccebb7 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 0000000000..bdeadf739b --- /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 e67f403667..4e54450bf9 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 0000000000..f2523092a1 --- /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 ae20415168..2659cde29d 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -392,22 +392,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: @@ -419,11 +409,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 f360751eb1..9183efd822 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -1950,9 +1950,6 @@ 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. @@ -1990,7 +1987,6 @@ 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/mellea/telemetry/metrics.py b/mellea/telemetry/metrics.py index 490981f381..8092e26af2 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -1159,30 +1159,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 a6967c63ac..5974a5fa72 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 0201060657..49f33fdfdb 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 568ebfe8fcd2244b3d0bb3aff9d5d8d11c1f8163 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 27 Jul 2026 20:55:36 -0400 Subject: [PATCH 09/17] 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 2babc396ce..40fb45020e 100644 --- a/mellea/backends/ollama.py +++ b/mellea/backends/ollama.py @@ -43,7 +43,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 @@ -575,7 +579,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 672c94ee8fbb2fb5ecbedb923529291a327420e3 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 27 Jul 2026 21:05:03 -0400 Subject: [PATCH 10/17] 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 bdeadf739b..09eb100daa 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 f2523092a1..dd86a554f5 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}") From 9e85200654d802f1efce58893e414b4c8baab9b5 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 27 Aug 2026 14:55:35 -0400 Subject: [PATCH 11/17] fix merge error Signed-off-by: Akihiko Kuroda --- docs/examples/components/duplicate_tool_names_public_api.py | 2 +- docs/examples/components/pattern2_public_api.py | 2 +- 2 files changed, 2 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 09eb100daa..7e8a942cf7 100644 --- a/docs/examples/components/duplicate_tool_names_public_api.py +++ b/docs/examples/components/duplicate_tool_names_public_api.py @@ -230,7 +230,7 @@ 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(f"Tool calls requested: {[tc.name for tc in response.tool_calls]}") print( "\nExecuting tool calls via Mellea's pipeline (enables telemetry recording):" ) diff --git a/docs/examples/components/pattern2_public_api.py b/docs/examples/components/pattern2_public_api.py index dd86a554f5..c1bf75112e 100644 --- a/docs/examples/components/pattern2_public_api.py +++ b/docs/examples/components/pattern2_public_api.py @@ -229,7 +229,7 @@ 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(f"Tool calls requested: {[tc.name for tc in response.tool_calls]}") print( "\nExecuting tool calls via Mellea's pipeline (enables telemetry recording):" ) From 66b7e5f18d80e558af47b4140f81f0fa6ceea55c Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 27 Aug 2026 20:49:27 -0400 Subject: [PATCH 12/17] review comment Signed-off-by: Akihiko Kuroda --- docs/examples/components/README.md | 10 ++-- .../duplicate_tool_names_public_api.py | 10 ++-- .../components/pattern2_public_api.py | 10 ++-- mellea/backends/tools.py | 23 ++++++-- mellea/core/base.py | 9 ---- test/backends/test_tool_helpers.py | 54 +++++++++++++++++++ 6 files changed, 88 insertions(+), 28 deletions(-) diff --git a/docs/examples/components/README.md b/docs/examples/components/README.md index 1e229a1210..6f8953ac95 100644 --- a/docs/examples/components/README.md +++ b/docs/examples/components/README.md @@ -44,8 +44,8 @@ uv run python docs/examples/components/duplicate_tool_names.py - 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 +- Tool execution via public `call_tools()` API with telemetry support +- All public APIs, no private imports **Important:** Uses `ChatContext` instead of `SimpleContext` (required for tool extraction from session context). @@ -66,7 +66,7 @@ uv run python docs/examples/components/duplicate_tool_names_public_api.py - 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 +- **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 --- @@ -106,9 +106,9 @@ uv run python docs/examples/components/pattern2_context_and_tools.py - 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 +- Tool execution via public `call_tools()` API with telemetry support - Multi-component composition with stable component IDs -- No private API imports required +- All public APIs, no private imports **Important:** Uses `ChatContext` instead of `SimpleContext` (required for tool extraction from session context). diff --git a/docs/examples/components/duplicate_tool_names_public_api.py b/docs/examples/components/duplicate_tool_names_public_api.py index 7e8a942cf7..acbddcaab5 100644 --- a/docs/examples/components/duplicate_tool_names_public_api.py +++ b/docs/examples/components/duplicate_tool_names_public_api.py @@ -14,7 +14,7 @@ - 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 +- Tool calls are executed via call_tools() to enable telemetry recording To view tool calling telemetry metrics: export MELLEA_METRICS_ENABLED=true @@ -33,7 +33,7 @@ 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.functional import call_tools from mellea.stdlib.session import MelleaSession @@ -210,7 +210,7 @@ def main() -> None: 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") + print(" Then execute tools via call_tools() to record telemetry") # Create a task component that will request tool use action = TaskComponent() @@ -234,7 +234,7 @@ def main() -> None: print( "\nExecuting tool calls via Mellea's pipeline (enables telemetry recording):" ) - tool_messages = _call_tools(response, backend) + tool_messages = call_tools(response, backend) for msg in tool_messages: print(f" {msg.name}() → {msg.content}") print() @@ -243,7 +243,7 @@ def main() -> None: print("\n" + "=" * 70) print("✓ Component ID-based tool prefixing successfully demonstrated") - print("✓ Tools extracted from context and executed via _call_tools()") + print("✓ Tools extracted from context and executed via call_tools()") print("=" * 70) diff --git a/docs/examples/components/pattern2_public_api.py b/docs/examples/components/pattern2_public_api.py index c1bf75112e..3e68f62a23 100644 --- a/docs/examples/components/pattern2_public_api.py +++ b/docs/examples/components/pattern2_public_api.py @@ -11,7 +11,7 @@ 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 +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: @@ -31,7 +31,7 @@ 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.functional import call_tools from mellea.stdlib.session import MelleaSession @@ -210,7 +210,7 @@ def main() -> None: 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") + 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! @@ -233,7 +233,7 @@ def main() -> None: print( "\nExecuting tool calls via Mellea's pipeline (enables telemetry recording):" ) - tool_messages = _call_tools(response, backend) + tool_messages = call_tools(response, backend) for msg in tool_messages: print(f" {msg.name}() → {msg.content}") print() @@ -243,7 +243,7 @@ def main() -> None: 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("✓ Tool calls executed via call_tools() for telemetry") print("=" * 70) print("\nKey Concepts:") diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index 2659cde29d..c68e14b94f 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -369,15 +369,14 @@ 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 "component_{ID}." to prevent naming collisions when + Tools from each component are prefixed with "component_{ID}__" to prevent naming collisions when multiple components define tools with identical names. The component ID is derived from the component object's identity for multi-turn stability. This allows safe composition of multiple agents or tool-bearing components. Args: - tools_dict: Mutable mapping of tool name to tool instance; modified in-place. Dict keys are unique, - so if multiple components define tools with the same name, the last one wins (earlier definitions - are silently overwritten). + tools_dict: Mutable mapping of tool name to tool instance; modified in-place. Prefixed names + ensure tools from different components coexist without overwriting each other. ctx_actions: List of `Component`, `CBlock`, or `ModelOutputThunk` objects whose template representations may declare tools, or `None` to skip. """ @@ -399,6 +398,22 @@ def add_tools_from_context_actions( # Auto-prefix tool name using component ID to avoid collisions prefixed_name = f"component_{component_id}__{original_tool_name}" + # Validate function name length (OpenAI and most providers enforce 64-char limit) + if len(prefixed_name) > 64: + MelleaLogger.get_logger().warning( + f"Tool name exceeds 64-character limit (OpenAI/Ollama/HF constraint): " + f"'{prefixed_name}' ({len(prefixed_name)} chars). " + f"Original: '{original_tool_name}' ({len(original_tool_name)} chars). " + f"Providers may reject this tool call. Consider using shorter tool names." + ) + + # Validate function name pattern (OpenAI constraint: ^[a-zA-Z0-9_-]{1,64}$) + if not re.match(r"^[a-zA-Z0-9_-]{1,64}$", prefixed_name): + MelleaLogger.get_logger().warning( + f"Tool name contains invalid characters (allowed: [a-zA-Z0-9_-]): " + f"'{prefixed_name}'. Providers may reject this tool call." + ) + # Detect collision and warn if it still occurs (defensive) if prefixed_name in tools_dict: MelleaLogger.get_logger().warning( diff --git a/mellea/core/base.py b/mellea/core/base.py index 9183efd822..1080ef94b3 100644 --- a/mellea/core/base.py +++ b/mellea/core/base.py @@ -1970,12 +1970,6 @@ class TemplateRepresentation: tool_name (str | None): For a `role="tool"` component, the name of the tool whose result this message carries (e.g. Ollama's tool-result turn keys on it). Defaults to `None`. - 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`. """ @@ -1997,9 +1991,6 @@ class TemplateRepresentation: tool_calls: list[dict[str, Any]] | None = None tool_call_id: str | None = None tool_name: str | 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_helpers.py b/test/backends/test_tool_helpers.py index 49f33fdfdb..ab4d05331a 100644 --- a/test/backends/test_tool_helpers.py +++ b/test/backends/test_tool_helpers.py @@ -135,5 +135,59 @@ def test_add_tools_from_context_actions(): ) +def test_add_tools_from_context_actions_exceeds_length_limit(caplog): + """Verify warning when tool name exceeds 64-character provider limit.""" + import logging + + # Create a custom component with a very long tool name + # The tool name (key in tools dict) is what gets prefixed, so we need a 45+ char name + # to exceed the 64 char limit (20 char prefix + 45+ char name = 65+ chars) + class ComponentWithLongToolName(FakeToolComponent): + def format_for_llm(self) -> TemplateRepresentation: + long_tool_key = ( + "x" * 45 + ) # Will exceed 64 chars after prefixing (20 + 45 = 65) + long_tool = MelleaTool.from_callable(lambda: None, name="tool") + return TemplateRepresentation( + obj=self, args={"arg": None}, tools={long_tool_key: long_tool} + ) + + component = ComponentWithLongToolName() + tools = {} + with caplog.at_level(logging.WARNING, logger="mellea"): + add_tools_from_context_actions(tools, [component]) + + # Verify warning was logged for length constraint + assert any( + "exceeds 64-character limit" in record.message for record in caplog.records + ), f"Expected length warning in logs; got: {[r.message for r in caplog.records]}" + + +def test_add_tools_from_context_actions_invalid_characters(caplog): + """Verify warning when tool name contains invalid characters.""" + import logging + + # Create a custom component with a tool name containing invalid characters + class ComponentWithInvalidToolName(FakeToolComponent): + def format_for_llm(self) -> TemplateRepresentation: + # Invalid character: @ (not in [a-zA-Z0-9_-]) + # The key in tools dict is what gets prefixed, so use @ in the key + invalid_tool_key = "my@tool" + invalid_tool = MelleaTool.from_callable(lambda: None, name="tool") + return TemplateRepresentation( + obj=self, args={"arg": None}, tools={invalid_tool_key: invalid_tool} + ) + + component = ComponentWithInvalidToolName() + tools = {} + with caplog.at_level(logging.WARNING, logger="mellea"): + add_tools_from_context_actions(tools, [component]) + + # Verify warning was logged for invalid characters + assert any("invalid characters" in record.message for record in caplog.records), ( + f"Expected invalid character warning in logs; got: {[r.message for r in caplog.records]}" + ) + + if __name__ == "__main__": pytest.main([__file__]) From 8f6b98668df73075b9e29328f3a35277714e8520 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Thu, 27 Aug 2026 20:51:15 -0400 Subject: [PATCH 13/17] review comment Signed-off-by: Akihiko Kuroda --- mellea/telemetry/metrics.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/mellea/telemetry/metrics.py b/mellea/telemetry/metrics.py index 8092e26af2..26f990e1d9 100644 --- a/mellea/telemetry/metrics.py +++ b/mellea/telemetry/metrics.py @@ -1159,25 +1159,17 @@ 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. 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. + This is a no-op when metrics are disabled, ensuring zero overhead. 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. status: `"success"` if the tool executed without error, `"failure"` otherwise. """ if _meter is None: return - attributes: dict[str, str] = {"gen_ai.tool.name": tool, "status": status} - counter = _get_tool_calls_counter() - counter.add(1, attributes) + counter.add(1, {"gen_ai.tool.name": tool, "status": status}) _adapter_function_invocations_counter: Any = None From 194b37a055d309fff8338603af4cce3361d60022 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 31 Aug 2026 17:28:27 -0400 Subject: [PATCH 14/17] review comments Signed-off-by: Akihiko Kuroda --- .../components/duplicate_tool_names.py | 272 ----------------- .../components/pattern2_context_and_tools.py | 283 ------------------ mellea/backends/tools.py | 5 +- mellea/telemetry/metrics_plugins.py | 8 +- test/telemetry/test_metrics_plugins.py | 16 +- 5 files changed, 20 insertions(+), 564 deletions(-) delete mode 100644 docs/examples/components/duplicate_tool_names.py delete mode 100644 docs/examples/components/pattern2_context_and_tools.py diff --git a/docs/examples/components/duplicate_tool_names.py b/docs/examples/components/duplicate_tool_names.py deleted file mode 100644 index 9876ccebb7..0000000000 --- a/docs/examples/components/duplicate_tool_names.py +++ /dev/null @@ -1,272 +0,0 @@ -# pytest: ollama, e2e -"""Example demonstrating two components with tools that have the same name (using private APIs). - -⚠️ DEPRECATED: This example uses private _call_tools() API. For new code, prefer -`duplicate_tool_names_public_api.py` which uses the public MelleaSession.act() API. - -When multiple components define tools with identical names, Mellea automatically -prefixes each tool name with its component ID (component_{ID}__tool_name) to prevent -naming collisions. This allows safe composition of multiple components. - -In this example: -- DatabaseComponent has a "query" tool for querying data -- SearchComponent also has a "query" tool for searching -- Both tools are available to the LLM with prefixed names to avoid conflicts -- The LLM is prompted to use both tools and demonstrate collision handling - -To view tool calling telemetry metrics: - export MELLEA_METRICS_ENABLED=true - export MELLEA_METRICS_CONSOLE=true - uv run python this_script.py - -Tool calls are executed via Mellea's pipeline to enable telemetry recording. -""" - -import os -from typing import Any - -from mellea.backends import ModelOption -from mellea.backends.model_ids import IBM_GRANITE_4_HYBRID_MICRO -from mellea.backends.openai import OpenAIBackend -from mellea.backends.tools import MelleaTool, add_tools_from_context_actions -from mellea.core import CBlock, Component, ModelOutputThunk, TemplateRepresentation -from mellea.core.base import AbstractMelleaTool -from mellea.formatters import TemplateFormatter -from mellea.stdlib.context import ChatContext -from mellea.stdlib.functional import _call_tools -from mellea.stdlib.session import MelleaSession - - -class QueryDatabaseTool(AbstractMelleaTool): - """Tool for querying a database.""" - - name = "query" - - def run(self, sql: str) -> str: - """Execute a SQL query on the database. - - Args: - sql: The SQL query to execute - - Returns: - Mock query results as a string - """ - return f"Database query result: [{sql}] returned 42 rows" - - @property - def as_json_tool(self) -> dict[str, Any]: - """Return JSON schema for this tool.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": "Query a database with SQL", - "parameters": { - "type": "object", - "properties": { - "sql": {"type": "string", "description": "SQL query to execute"} - }, - "required": ["sql"], - }, - }, - } - - -class SearchIndexTool(AbstractMelleaTool): - """Tool for searching an index.""" - - name = "query" - - def run(self, text: str) -> str: - """Search the index for matching documents. - - Args: - text: The search query text - - Returns: - Mock search results as a string - """ - return f"Search results for '{text}': found 5 documents" - - @property - def as_json_tool(self) -> dict[str, Any]: - """Return JSON schema for this tool.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": "Search an index for documents", - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Search query text"} - }, - "required": ["text"], - }, - }, - } - - -class DatabaseComponent(Component): - """Component that provides database querying capabilities.""" - - description = "Database query interface" - _tool = QueryDatabaseTool() - - def parts(self) -> list[Component | CBlock | ModelOutputThunk]: - """Return parts of this component.""" - return [] - - def format_for_llm(self) -> TemplateRepresentation | str: - """Format component for LLM with database query tool.""" - return TemplateRepresentation( - obj=self, - args={"description": "Database query interface"}, - tools={"query": MelleaTool.from_callable(self._tool.run)}, - template="🗄️ **Database Interface**: {{description}}\nAvailable: SQL query tool", - ) - - def _parse(self, computed: ModelOutputThunk) -> str: - """Parse the LLM response.""" - return str(computed.value) - - -class SearchComponent(Component): - """Component that provides search capabilities.""" - - description = "Search interface" - _tool = SearchIndexTool() - - def parts(self) -> list[Component | CBlock | ModelOutputThunk]: - """Return parts of this component.""" - return [] - - def format_for_llm(self) -> TemplateRepresentation | str: - """Format component for LLM with search tool.""" - return TemplateRepresentation( - obj=self, - args={"description": "Search interface"}, - tools={"query": MelleaTool.from_callable(self._tool.run)}, - template="🔍 **Search Interface**: {{description}}\nAvailable: Document search tool", - ) - - def _parse(self, computed: ModelOutputThunk) -> str: - """Parse the LLM response.""" - return str(computed.value) - - -def main(): - """Demonstrate two components with the same tool name.""" - # Create two components with tools that have the same name - db_component = DatabaseComponent() - search_component = SearchComponent() - - print("=" * 70) - print("PART 1: Tool Extraction and Prefixing") - print("=" * 70) - - # Extract tools from components - they will have prefixed names to avoid collisions - ctx_actions = [db_component, search_component] - tools = {} - add_tools_from_context_actions(tools, ctx_actions) - - # Print available tools to show the prefixing in action - print("\nAvailable tools with component ID-based prefixes:") - query_tools = [] - for tool_name in sorted(tools.keys()): - if tool_name.startswith("component_"): - print(f" - {tool_name}") - if "query" in tool_name: - query_tools.append(tool_name) - - # Both "query" tools are now available with different prefixes - print(f"\nTotal query tools available: {len(query_tools)}") - assert len(query_tools) == 2, ( - f"Expected 2 query tools with different component IDs, got {len(query_tools)}" - ) - - print("\n" + "=" * 70) - print("PART 2: LLM Generation with Tool Calls") - print("=" * 70) - - # Set up backend and session - ollama_host = os.environ.get("OLLAMA_HOST", "localhost:11434") - if not ollama_host.startswith(("http://", "https://")): - ollama_host = f"http://{ollama_host}" - - backend = OpenAIBackend( - model_id=IBM_GRANITE_4_HYBRID_MICRO.ollama_name, # type: ignore[arg-type] - formatter=TemplateFormatter( - model_id=IBM_GRANITE_4_HYBRID_MICRO.hf_model_name # type: ignore[arg-type] - ), - base_url=f"{ollama_host}/v1", - api_key="ollama", - ) - # Create a session to call the LLM with tool definitions - session = MelleaSession(backend, ctx=ChatContext()) - - # Generate response with tool calls - prompt = ( - "I need information about users. First, query the database for all users " - "with the SQL query tool, then search for documentation about user " - "management with the search tool. Use both tools." - ) - - print(f"\nPrompt: {prompt}\n") - print(f"Tools available to LLM: {list(tools.keys())}\n") - - # Note: Tools must be explicitly passed via ModelOption.TOOLS for the LLM to use them. - # Tool extraction and LLM tool availability are separate concerns. - response = session.instruct( - prompt, - model_options={ - ModelOption.TOOLS: tools, - ModelOption.TOOL_CHOICE: "auto", - ModelOption.MAX_NEW_TOKENS: 1000, - }, - strategy=None, - tool_calls=True, - ) - - print(f"LLM Response:\n{response.value}\n") - - # Check if tool calls were requested - if response.tool_calls: - print(f"Tool calls requested by LLM: {list(response.tool_calls.keys())}") - print("\nExecuting tool calls via Mellea's pipeline (hooks enabled):") - # Execute tools through Mellea's pipeline to trigger telemetry hooks - tool_messages = _call_tools(response, backend) - for msg in tool_messages: - print(f" {msg.name}() → {msg.content}") - print("\nNote: Tool calls are executed via _call_tools() so telemetry") - print("hooks fire and metrics are recorded in mellea.tool.calls") - else: - print("No tool calls were requested by the LLM.") - - print("\n" + "=" * 70) - print("PART 3: Tool Attributes Demonstration") - print("=" * 70) - - # Demonstrate that component IDs are embedded in tool names and component - # metadata is available from the tool extraction - print("\nTool name analysis (component ID is embedded in the prefix):") - for tool_name in sorted(query_tools): - # Extract component ID from the prefixed tool name - # Format: component_{component_id}__{tool_name} - if tool_name.startswith("component_"): - parts = tool_name.split("__", 1) - if len(parts) == 2: - component_id = parts[0].replace("component_", "") - original_name = parts[1] - print(f" {tool_name}") - print(f" → component_id: {component_id}") - print(f" → original_name: {original_name}") - - print("\n" + "=" * 70) - print("✓ Successfully demonstrated component ID-based tool prefixing") - print("✓ Tool calling telemetry enabled (see MELLEA_METRICS* env vars above)") - print("=" * 70) - - -if __name__ == "__main__": - main() diff --git a/docs/examples/components/pattern2_context_and_tools.py b/docs/examples/components/pattern2_context_and_tools.py deleted file mode 100644 index 4e54450bf9..0000000000 --- a/docs/examples/components/pattern2_context_and_tools.py +++ /dev/null @@ -1,283 +0,0 @@ -# pytest: ollama, e2e -"""Example demonstrating Pattern 2: Components in context (using private APIs). - -⚠️ DEPRECATED: This example uses private _call_tools() API. For new code, prefer -`pattern2_public_api.py` which uses the public MelleaSession.act() API. - -Pattern 2 combines both approaches: -1. Add components to session context (for rendering in the prompt) -2. Extract and explicitly pass tools via ModelOption.TOOLS (for tool calling) - -This allows the LLM to see the components in the conversation AND use their tools. - -To view tool calling telemetry metrics: - export MELLEA_METRICS_ENABLED=true - export MELLEA_METRICS_CONSOLE=true - uv run python this_script.py - -Tool calls are executed via Mellea's pipeline to enable telemetry recording. -""" - -import os -from typing import Any - -from mellea.backends import ModelOption -from mellea.backends.model_ids import IBM_GRANITE_4_HYBRID_MICRO -from mellea.backends.openai import OpenAIBackend -from mellea.backends.tools import MelleaTool -from mellea.core import CBlock, Component, ModelOutputThunk, TemplateRepresentation -from mellea.core.base import AbstractMelleaTool -from mellea.formatters import TemplateFormatter -from mellea.stdlib.context import ChatContext -from mellea.stdlib.functional import _call_tools -from mellea.stdlib.session import MelleaSession - - -class QueryDatabaseTool(AbstractMelleaTool): - """Tool for querying a database.""" - - name = "query" - - def run(self, sql: str) -> str: - """Execute a SQL query on the database. - - Args: - sql: The SQL query to execute - - Returns: - Mock query results as a string - """ - return f"Database query result: [{sql}] returned 42 rows" - - @property - def as_json_tool(self) -> dict[str, Any]: - """Return JSON schema for this tool.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": "Query a database with SQL", - "parameters": { - "type": "object", - "properties": { - "sql": {"type": "string", "description": "SQL query to execute"} - }, - "required": ["sql"], - }, - }, - } - - -class SearchIndexTool(AbstractMelleaTool): - """Tool for searching an index.""" - - name = "query" - - def run(self, text: str) -> str: - """Search the index for matching documents. - - Args: - text: The search query text - - Returns: - Mock search results as a string - """ - return f"Search results for '{text}': found 5 documents" - - @property - def as_json_tool(self) -> dict[str, Any]: - """Return JSON schema for this tool.""" - return { - "type": "function", - "function": { - "name": self.name, - "description": "Search an index for documents", - "parameters": { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Search query text"} - }, - "required": ["text"], - }, - }, - } - - -class DatabaseComponent(Component): - """Component that provides database querying capabilities.""" - - description = "Database query interface" - _tool = QueryDatabaseTool() - - def parts(self) -> list[Component | CBlock | ModelOutputThunk]: - """Return parts of this component.""" - return [] - - def format_for_llm(self) -> TemplateRepresentation | str: - """Format component for LLM with database query tool.""" - return TemplateRepresentation( - obj=self, - args={"description": "Database query interface"}, - tools={"query": MelleaTool.from_callable(self._tool.run)}, - template="🗄️ **Database Interface**: {{description}}\nAvailable: SQL query tool", - ) - - def _parse(self, computed: ModelOutputThunk) -> str: - """Parse the LLM response.""" - return str(computed.value) - - -class SearchComponent(Component): - """Component that provides search capabilities.""" - - description = "Search interface" - _tool = SearchIndexTool() - - def parts(self) -> list[Component | CBlock | ModelOutputThunk]: - """Return parts of this component.""" - return [] - - def format_for_llm(self) -> TemplateRepresentation | str: - """Format component for LLM with search tool.""" - return TemplateRepresentation( - obj=self, - args={"description": "Search interface"}, - tools={"query": MelleaTool.from_callable(self._tool.run)}, - template="🔍 **Search Interface**: {{description}}\nAvailable: Document search tool", - ) - - def _parse(self, computed: ModelOutputThunk) -> str: - """Parse the LLM response.""" - return str(computed.value) - - -def main(): - """Demonstrate Pattern 2: Components in context + auto tool extraction.""" - print("=" * 70) - print("PATTERN 2: Components in Context + Auto Tool Extraction") - print("=" * 70) - - print("\n" + "=" * 70) - print("STEP 1: Create Components") - print("=" * 70) - - # Create components - db_component = DatabaseComponent() - search_component = SearchComponent() - print("✓ Created DatabaseComponent and SearchComponent with tools") - - print("\n" + "=" * 70) - print("STEP 2: Add Components to Context") - print("=" * 70) - - # Add components to context (no templates needed - already defined!) - print("\nAdding components to context...") - db_component = DatabaseComponent() - search_component = SearchComponent() - session_ctx = ChatContext() - session_ctx = session_ctx.add(db_component) - session_ctx = session_ctx.add(search_component) - print(" ✓ Added both components to context") - - print("\n" + "=" * 70) - print("STEP 3: Set Up Backend and Session") - print("=" * 70) - - ollama_host = os.environ.get("OLLAMA_HOST", "localhost:11434") - if not ollama_host.startswith(("http://", "https://")): - ollama_host = f"http://{ollama_host}" - - backend = OpenAIBackend( - model_id=IBM_GRANITE_4_HYBRID_MICRO.ollama_name, # type: ignore[arg-type] - formatter=TemplateFormatter( - model_id=IBM_GRANITE_4_HYBRID_MICRO.hf_model_name # type: ignore[arg-type] - ), - base_url=f"{ollama_host}/v1", - api_key="ollama", - ) - session = MelleaSession(backend, ctx=session_ctx) - - print("\nSession created with components in context") - - print("\n" + "=" * 70) - print("STEP 4: LLM Generation with Tool Calling") - print("=" * 70) - - prompt = ( - "I need to find information about users. Please:" - "\n1. Query the database to get all users" - "\n2. Search the documentation for user management best practices" - "\nUse the available tools to complete both tasks." - ) - - print(f"\nPrompt:\n{prompt}") - - # IMPORTANT: NO ModelOption.TOOLS - backend auto-extracts from context! - print("\nCalling session.instruct() with:") - print(" - Components in context: YES (database + search)") - print(" - ModelOption.TOOLS: NO (backend auto-extracts!)") - print(" - tool_calls: True") - - response = session.instruct( - prompt, - model_options={ - ModelOption.TOOL_CHOICE: "auto", - ModelOption.MAX_NEW_TOKENS: 1000, - }, - strategy=None, - tool_calls=True, - ) - - print(f"\nLLM Response:\n{response.value}\n") - - # Execute tool calls - if response.tool_calls: - print(f"Tool calls requested by LLM: {list(response.tool_calls.keys())}") - print("\nExecuting tool calls via Mellea's pipeline (hooks enabled):") - # Execute tools through Mellea's pipeline to trigger telemetry hooks - tool_messages = _call_tools(response, backend) - for msg in tool_messages: - print(f" {msg.name}() → {msg.content}") - print("\nNote: Tool calls are executed via _call_tools() so telemetry") - print("hooks fire and metrics are recorded in mellea.tool.calls") - print("\n✓ Tool calling SUCCESSFUL in Pattern 2") - else: - print("No tool calls were requested by the LLM.") - - print("\n" + "=" * 70) - print("Pattern 2 Summary") - print("=" * 70) - print(""" -PATTERN 2: Components in Context + Auto Tool Extraction - -Approach: - 1. Create components with tools and templates - 2. Create session with ChatContext - 3. Add components to context: session.ctx = session.ctx.add(component) - 4. Call session.instruct() with tool_calls=True (NO ModelOption.TOOLS!) - -Benefits: - ✓ Components rendered in the prompt - ✓ Components' tools automatically extracted and available - ✓ ID-based prefixing prevents tool collisions - ✓ Multi-turn stable (same instances = same IDs) - ✓ No explicit tool passing needed - -Key Point: - The backend automatically calls add_tools_from_context_actions() when - tool_calls=True, extracting tools from all components in the context. - This makes Pattern 2 truly implicit and elegant. - -When to Use Pattern 2: - - You need components to appear in the conversation - - You want their tools available for calling automatically - - You prefer implicit over explicit tool passing - """) - - print("=" * 70) - print("✓ Pattern 2 Successfully Demonstrated") - print("=" * 70) - - -if __name__ == "__main__": - main() diff --git a/mellea/backends/tools.py b/mellea/backends/tools.py index c68e14b94f..20495ace0a 100644 --- a/mellea/backends/tools.py +++ b/mellea/backends/tools.py @@ -407,8 +407,9 @@ def add_tools_from_context_actions( f"Providers may reject this tool call. Consider using shorter tool names." ) - # Validate function name pattern (OpenAI constraint: ^[a-zA-Z0-9_-]{1,64}$) - if not re.match(r"^[a-zA-Z0-9_-]{1,64}$", prefixed_name): + # Validate function name pattern (OpenAI constraint: characters must be [a-zA-Z0-9_-]) + # Length is checked separately above; this regex only validates character set + if not re.match(r"^[a-zA-Z0-9_-]+$", prefixed_name): MelleaLogger.get_logger().warning( f"Tool name contains invalid characters (allowed: [a-zA-Z0-9_-]): " f"'{prefixed_name}'. Providers may reject this tool call." diff --git a/mellea/telemetry/metrics_plugins.py b/mellea/telemetry/metrics_plugins.py index bca95ec6e0..748e659326 100644 --- a/mellea/telemetry/metrics_plugins.py +++ b/mellea/telemetry/metrics_plugins.py @@ -531,13 +531,17 @@ async def record_tool_call( """Record one tool invocation after it completes. Args: - payload: Contains model_tool_call (with name) and success flag. + payload: Contains model_tool_call (with func.name) and success flag. context: Plugin context (unused). + + Uses func.name (original tool name) not model_tool_call.name (prefixed name) + to keep metric label cardinality bounded. Prefixing is for LLM context only, + not for observability labels. """ from mellea.telemetry.metrics import record_tool_call tool_name = ( - payload.model_tool_call.name + payload.model_tool_call.func.name if payload.model_tool_call is not None else "unknown" ) diff --git a/test/telemetry/test_metrics_plugins.py b/test/telemetry/test_metrics_plugins.py index a952c22070..7cf36ab4ec 100644 --- a/test/telemetry/test_metrics_plugins.py +++ b/test/telemetry/test_metrics_plugins.py @@ -1158,11 +1158,17 @@ async def test_requirement_metrics_ignores_non_quick_check_events(requirement_pl # ToolMetricsPlugin tests -class _MockToolCall: +class _MockToolFunc: def __init__(self, name: str) -> None: self.name = name +class _MockToolCall: + def __init__(self, prefixed_name: str, original_name: str) -> None: + self.name = prefixed_name + self.func = _MockToolFunc(original_name) + + @pytest.fixture def tool_plugin(): return ToolMetricsPlugin() @@ -1170,9 +1176,9 @@ def tool_plugin(): @pytest.mark.asyncio async def test_tool_plugin_records_success(tool_plugin): - """Successful tool calls are recorded with status='success'.""" + """Successful tool calls are recorded with status='success' using original tool name.""" payload = ToolPostInvokePayload( - model_tool_call=_MockToolCall("search"), success=True + model_tool_call=_MockToolCall("component_abc123__search", "search"), success=True ) with patch("mellea.telemetry.metrics.record_tool_call") as mock_record: @@ -1183,9 +1189,9 @@ async def test_tool_plugin_records_success(tool_plugin): @pytest.mark.asyncio async def test_tool_plugin_records_failure(tool_plugin): - """Failed tool calls are recorded with status='failure'.""" + """Failed tool calls are recorded with status='failure' using original tool name.""" payload = ToolPostInvokePayload( - model_tool_call=_MockToolCall("calculator"), success=False + model_tool_call=_MockToolCall("component_def456__calculator", "calculator"), success=False ) with patch("mellea.telemetry.metrics.record_tool_call") as mock_record: From 40acb3b8285e1c8eac293316556e2835ba0a0ba6 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 31 Aug 2026 17:36:39 -0400 Subject: [PATCH 15/17] review comments Signed-off-by: Akihiko Kuroda --- test/telemetry/test_metrics_plugins.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/telemetry/test_metrics_plugins.py b/test/telemetry/test_metrics_plugins.py index 7cf36ab4ec..d4b555207c 100644 --- a/test/telemetry/test_metrics_plugins.py +++ b/test/telemetry/test_metrics_plugins.py @@ -1178,7 +1178,8 @@ def tool_plugin(): async def test_tool_plugin_records_success(tool_plugin): """Successful tool calls are recorded with status='success' using original tool name.""" payload = ToolPostInvokePayload( - model_tool_call=_MockToolCall("component_abc123__search", "search"), success=True + model_tool_call=_MockToolCall("component_abc123__search", "search"), + success=True, ) with patch("mellea.telemetry.metrics.record_tool_call") as mock_record: @@ -1191,7 +1192,8 @@ async def test_tool_plugin_records_success(tool_plugin): async def test_tool_plugin_records_failure(tool_plugin): """Failed tool calls are recorded with status='failure' using original tool name.""" payload = ToolPostInvokePayload( - model_tool_call=_MockToolCall("component_def456__calculator", "calculator"), success=False + model_tool_call=_MockToolCall("component_def456__calculator", "calculator"), + success=False, ) with patch("mellea.telemetry.metrics.record_tool_call") as mock_record: From c27a746c27ea2cb6e658a2c5c64ecb4e74cddea1 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Mon, 31 Aug 2026 19:46:50 -0400 Subject: [PATCH 16/17] review comment Signed-off-by: Akihiko Kuroda --- mellea/telemetry/metrics_plugins.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mellea/telemetry/metrics_plugins.py b/mellea/telemetry/metrics_plugins.py index 748e659326..5a9540a343 100644 --- a/mellea/telemetry/metrics_plugins.py +++ b/mellea/telemetry/metrics_plugins.py @@ -533,10 +533,6 @@ async def record_tool_call( Args: payload: Contains model_tool_call (with func.name) and success flag. context: Plugin context (unused). - - Uses func.name (original tool name) not model_tool_call.name (prefixed name) - to keep metric label cardinality bounded. Prefixing is for LLM context only, - not for observability labels. """ from mellea.telemetry.metrics import record_tool_call From a25968432dc29211d0d15d2e5d0318183ba711a8 Mon Sep 17 00:00:00 2001 From: Akihiko Kuroda Date: Tue, 1 Sep 2026 09:52:47 -0400 Subject: [PATCH 17/17] review comments Signed-off-by: Akihiko Kuroda --- docs/examples/components/README.md | 65 +----------------------------- 1 file changed, 2 insertions(+), 63 deletions(-) diff --git a/docs/examples/components/README.md b/docs/examples/components/README.md index 6f8953ac95..30ccacda4c 100644 --- a/docs/examples/components/README.md +++ b/docs/examples/components/README.md @@ -4,39 +4,6 @@ This directory contains examples demonstrating Mellea's component system, partic ## Files -### `duplicate_tool_names.py` (Private API) -**Reference example** - Demonstrates component ID-based tool prefixing using private APIs. - -**⚠️ Note:** This example uses the private `_call_tools()` function from `mellea.stdlib.functional`. For new code, prefer the public API examples below. - -**What it shows:** -- Two components (`DatabaseComponent` and `SearchComponent`) both define a `query` tool -- Automatic tool prefixing using component IDs (`component_{ID}__query`) -- Explicit tool extraction via `add_tools_from_context_actions()` -- Tool passing via `ModelOption.TOOLS` to the LLM -- Manual tool execution via private `_call_tools()` to enable telemetry -- Component ID extraction from prefixed tool names - -**Run it:** -```bash -uv run python docs/examples/components/duplicate_tool_names.py -uv run pytest docs/examples/components/duplicate_tool_names.py -v -``` - -**View telemetry metrics:** -```bash -export MELLEA_METRICS_ENABLED=true -export MELLEA_METRICS_CONSOLE=true -uv run python docs/examples/components/duplicate_tool_names.py -``` - -**Key outputs:** -- Tools extracted with ID-based prefixes: `component_1adeba40__query`, `component_1c611a00__query` -- LLM successfully calls both tools in a single prompt -- Tool calls executed via private `_call_tools()` to enable telemetry recording - ---- - ### `duplicate_tool_names_public_api.py` (Public API - Recommended) **Modern example** - Demonstrates component ID-based tool prefixing using public APIs only. @@ -71,34 +38,6 @@ uv run python docs/examples/components/duplicate_tool_names_public_api.py --- -### `pattern2_context_and_tools.py` (Private API) -**Reference example** - Shows how to combine components in context with automatic tool extraction using private APIs. - -**⚠️ Note:** This example uses the private `_call_tools()` function. For new code, prefer the public API example below. - -**What it shows:** -- Pattern 2 approach: Components in session context with automatic tool extraction -- How to add components to the session context -- Backend automatically extracts tools via `add_tools_from_context_actions()` when `tool_calls=True` -- Proper tool execution via private `_call_tools()` (enables telemetry) -- Multi-turn stability with component ID-based prefixing -- Components with templates render in the conversation - -**Run it:** -```bash -uv run python docs/examples/components/pattern2_context_and_tools.py -uv run pytest docs/examples/components/pattern2_context_and_tools.py -v -``` - -**View telemetry metrics:** -```bash -export MELLEA_METRICS_ENABLED=true -export MELLEA_METRICS_CONSOLE=true -uv run python docs/examples/components/pattern2_context_and_tools.py -``` - ---- - ### `pattern2_public_api.py` (Public API - Recommended) **Modern example** - Shows Pattern 2 (components in context) using public APIs only. @@ -131,7 +70,7 @@ uv run python docs/examples/components/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 +- **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 --- @@ -168,7 +107,7 @@ Prefixed: component_1adeba40__query, component_1c611a00__query ## Related Source Files - `mellea/backends/tools.py` - `add_tools_from_context_actions()` implementation -- `mellea/stdlib/functional.py` - `_call_tools()` implementation (executes tools via pipeline) +- `mellea/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