nemo_voice_agent.utils.tool_calling.base

View as Markdown

Module Contents

Classes

NameDescription
StandardSchemaToolBase class for all standard tools with FunctionSchema.

Functions

NameDescription
_current_context_tool_namesExtract the tool names the LLM actually sees in its current schema.
_normalize_empty_resultGuard a tool result against pipecat’s empty-result masking.
_unknown_tool_handlerCatch-all handler for tool names the LLM hallucinated.
register_schema_tools_to_llmRegister standard schema tools to the LLM.

API

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
= description if description is not None else ''
properties
Dict[str, Any]

Return the properties for the tool.

An example of the properties:

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
List[str]

Return the required properties for the tool.

An example of the required properties:

required_properties = ["location", "format"]
schema
FunctionSchema

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

An example of the FunctionSchema:

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"]
)
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.

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.

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:

async def _execute(self, location: str, format: str = "celsius") -> dict:
return {"location": location, "format": format}
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.

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.

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.

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, ...).