> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.nvidia.com/nemo/labs-voice-agent/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.nvidia.com/nemo/labs-voice-agent/_mcp/server.

# nemo_voice_agent.utils.tool_calling.base

## Module Contents

### Classes

| Name                                                                                 | Description                                            |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------ |
| [`StandardSchemaTool`](#nemo_voice_agent-utils-tool_calling-base-StandardSchemaTool) | Base class for all standard tools with FunctionSchema. |

### Functions

| Name                                                                                                     | Description                                                         |
| -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [`_current_context_tool_names`](#nemo_voice_agent-utils-tool_calling-base-_current_context_tool_names)   | Extract the tool names the LLM actually sees in its current schema. |
| [`_normalize_empty_result`](#nemo_voice_agent-utils-tool_calling-base-_normalize_empty_result)           | Guard a tool result against pipecat's empty-result masking.         |
| [`_unknown_tool_handler`](#nemo_voice_agent-utils-tool_calling-base-_unknown_tool_handler)               | Catch-all handler for tool names the LLM hallucinated.              |
| [`register_schema_tools_to_llm`](#nemo_voice_agent-utils-tool_calling-base-register_schema_tools_to_llm) | Register standard schema tools to the LLM.                          |

### API

```python
class nemo_voice_agent.utils.tool_calling.base.StandardSchemaTool(
    description: typing.Optional[str] = None,
    name: typing.Optional[str] = None
)
```

Base class for all standard tools with FunctionSchema.

**`description`**

---

**`properties`**

Return the properties for the tool.

An example of the properties:

```python
properties = {
    "location": {
        "type": "string",
        "description": "The city and state, e.g. San Francisco, CA",
    },
    "format": {
            "type": "string",
            "enum": ["celsius", "fahrenheit"],
            "description": "The temperature unit to use.",
    },
}
```

---

**`required_properties`**

Return the required properties for the tool.

An example of the required properties:

```python
required_properties = ["location", "format"]
```

---

**`schema`**

Return the FunctionSchema for the tool. Refer to
[https://docs.pipecat.ai/guides/learn/function-calling#using-the-standard-schema-recommended](https://docs.pipecat.ai/guides/learn/function-calling#using-the-standard-schema-recommended)
for more details.

An example of the FunctionSchema:

```python
schema = FunctionSchema(
    name="get_current_weather",
    description="Get the current weather in a location",
    properties={
        "location": {
            "type": "string",
            "description": "The city and state, e.g. San Francisco, CA",
        },
        "format": {
            "type": "string",
            "enum": ["celsius", "fahrenheit"],
            "description": "The temperature unit to use.",
        },
    },
    required=["location", "format"]
)
```

---

```python
nemo_voice_agent.utils.tool_calling.base.StandardSchemaTool.__call__(
    params: pipecat.services.llm_service.FunctionCallParams
) -> None
```

async

Pipecat entry point. Owns everything framework-facing.

This is the *only* place a tool result is delivered. Subclasses
implement `_execute`, which takes the call arguments as plain
keyword arguments and returns a plain result — it never touches
`params` and never delivers.

Keeping delivery here makes double-delivery structurally impossible.
Previously `_execute` received `params` and each implementation
called `params.result_callback` itself, while this method *also*
delivered `_execute`'s return value — so every call sent its real
result and then a second, spurious `None` for the same
`tool_call_id`. Pipecat \<1.0 ignored the duplicate; 1.x tracks
in-flight tool calls and rejects it with "tool\_call\_id ... is not
running", which strands the aggregator's deferred context push and
stops the LLM being re-invoked with the tool output.

```python
nemo_voice_agent.utils.tool_calling.base.StandardSchemaTool._after_result(
    params: pipecat.services.llm_service.FunctionCallParams
) -> None
```

async

Hook for side effects that must run *after* the result is delivered.

Default is a no-op. Used by the end-conversation tools, which emit an
exit message once the LLM has been given the tool result.

```python
nemo_voice_agent.utils.tool_calling.base.StandardSchemaTool._execute(
    kwargs: typing.Any = {}
) -> typing.Any
```

async

Run the tool and RETURN its result. Pure logic, no framework types.

Receives the LLM-supplied call arguments as keyword arguments and
returns the result to send back. Do **not** import or touch
`FunctionCallParams`, and do **not** deliver the result yourself —
`__call__` owns delivery and calls it exactly once.

Raising is fine: `__call__` converts an exception into a structured
`&#123;"error": ...&#125;` result so the LLM gets a usable signal and the
aggregator is never left waiting.

An example of a get\_current\_weather tool:

```python
async def _execute(self, location: str, format: str = "celsius") -> dict:
    return {"location": location, "format": format}
```

```python
nemo_voice_agent.utils.tool_calling.base._current_context_tool_names(
    context: typing.Any
) -> typing.List[str]
```

Extract the tool names the LLM actually sees in its current schema.

`context._tools` (set by `LLMContext.set_tools`) is the LLM's
canonical view; `llm._functions` is the Python-side registry which
accumulates entries across bootstrap + per-scenario RTVI re-registrations.
The two diverge whenever `register_schema_tools_to_llm` is called with
`keep_existing_tools=False` (the per-scenario path in
`rtvi_actions.create_update_system_prompt_action`): `context._tools`
gets fully replaced, but Python-side `llm._functions` still has the
bootstrap entries hanging around. For an "unknown tool" message that
actually helps the LLM self-correct, we need the LLM's view.

Handles both shapes `context._tools` can take:

* `ToolsSchema(standard_tools=[FunctionSchema, ...])` — set by our
  `register_schema_tools_to_llm`.
* `List[ChatCompletionToolParam]` — raw OpenAI dicts when the caller
  bypasses `ToolsSchema`.

Returns an empty list (NOT a fallback to the registry) when the context
has no tools — the LLM was told it has no tools, so that's what we report.

```python
nemo_voice_agent.utils.tool_calling.base._normalize_empty_result(
    result: typing.Any
) -> typing.Any
```

Guard a tool result against pipecat's empty-result masking.

Pipecat's function-call handlers substitute the literal string
`"COMPLETED"` whenever a result is falsy — the check is ``if
frame.result:` (e.g. `pipecat/services/openai/llm.py``
`handle_function_call_result`). An empty `list`/`dict` is a
*meaningful* result for a read tool ("no records / no match found"), so
returning a bare `[]` or `&#123;&#125;` gets silently rewritten to
`"COMPLETED"` — which the LLM reads as *success*. That is the failure
behind an agent believing a name+DOB customer lookup that matched nobody
had "completed" successfully (observed in tau2\_telecom).

Wrap any falsy result in an explicit, non-falsy envelope so pipecat
serializes it verbatim and the model gets an honest "empty" signal.
Truthy results pass through unchanged.

Applied by `StandardSchemaTool.__call__` at the single pipecat-facing
boundary, so no tool can forget it. It must NOT be applied inside
`_execute`/`_do_work`/`invoke`: the sync `invoke` path feeds gold
replay and shadow-DB cross-side sync, which expect the raw return shape
(e.g. a bare list of matches); rewrapping there would corrupt DB-hash
comparison and `sync_state`.

```python
nemo_voice_agent.utils.tool_calling.base._unknown_tool_handler(
    params: pipecat.services.llm_service.FunctionCallParams
) -> None
```

async

Catch-all handler for tool names the LLM hallucinated.

Pipecat's `LLMService.run_function_calls` broadcasts a
`FunctionCallsStartedFrame` listing every call the LLM made — including
ones whose names aren't registered. The downstream aggregator stuffs each
`tool_call_id` into `_function_calls_in_progress` on receipt of that
frame. The service then loops and `continue`\s past the unregistered call
without producing a `FunctionCallResultFrame`, so the aggregator's
in-progress set stays non-empty forever — `run_llm = not bool(...)`
evaluates `False` for every subsequent message and the pipeline wedges.

The escape hatch pipecat designed for this is to register a function with
`function_name=None` — the service routes any unmatched name to it
(`llm_service.py:449`). We give the LLM a structured error listing the
**actual context-visible tools** so it can self-correct on the next turn.

Why we read from `context._tools` and not `llm._functions`: see the
`_current_context_tool_names` docstring. Surfaced live on 2026-06-03
during tau2\_retail bring-up — the first iteration of this handler read
`llm._functions`, which still contained the bootstrap
`GetCityWeatherTool`. The agent then announced "My available tools are
limited to functions like checking city weather", confusing the user
instead of redirecting to the actual retail toolset.

```python
nemo_voice_agent.utils.tool_calling.base.register_schema_tools_to_llm(
    llm: pipecat.services.openai.llm.OpenAILLMService,
    context: pipecat.processors.aggregators.llm_context.LLMContext,
    tools: typing.List[nemo_voice_agent.utils.tool_calling.base.StandardSchemaTool],
    cancel_on_interruption: bool = True,
    keep_existing_tools: bool = True,
    register_unknown_tool_handler: bool = True
) -> None
```

Register standard schema tools to the LLM.
Args:
llm: The LLM service to use.
context: The LLM context to use.
tools: The list of tools to register.
cancel\_on\_interruption: Whether to cancel an in-flight tool call when the
user interrupts. Keep the `True` default for ordinary synchronous
tools. In pipecat >=1.0 this flag does double duty: `False` also
marks the tool **asynchronous**, meaning the LLM does not wait for
the result — it continues the conversation immediately, the `tool`
message it receives is a `&#123;"status": "running"&#125;` placeholder, and
the real payload is injected later as a `developer` message. Only
pass `False` for a genuinely long-running, fire-and-forget tool
whose result the model must not block on. (In pipecat 0.x the flag
meant only "survive interruption"; there is no longer a way to get
that behaviour without also opting into the async protocol.)
keep\_existing\_tools: Whether to keep the existing tools in the context.
register\_unknown\_tool\_handler: When True (default), registers a catch-all
handler under `function_name=None` so any hallucinated tool call
gets a structured error result instead of wedging the aggregator.
See `_unknown_tool_handler` docstring for the deadlock chain this
avoids. Disable only if you've already registered a custom catch-all
via `llm.register_function(function_name=None, ...)`.