diff --git a/libraries/python/getpatter/stream_handler.py b/libraries/python/getpatter/stream_handler.py index 7f87983b..8e6072c7 100644 --- a/libraries/python/getpatter/stream_handler.py +++ b/libraries/python/getpatter/stream_handler.py @@ -171,6 +171,42 @@ def _is_parked_ws_alive(ws: object) -> bool: } +def _augment_with_builtin_handoff_tools( + user_tools: list[dict] | None, + *, + transfer_fn: Any | None, + hangup_fn: Any | None, +) -> list[dict]: + """Return ``user_tools`` with the built-in ``transfer_call`` and + ``end_call`` tools appended, each wired with a handler closure that + routes to the telephony-level ``_transfer_fn`` / ``_hangup_fn`` + already attached to the stream handler. + + Used by pipeline mode to match the realtime path's tool surface + (see ``OpenAIRealtimeStreamHandler.start`` where the same two + built-ins are injected into ``session.update``). Without this the + pipeline LLM never sees the built-in tools and cannot initiate a + transfer or hangup regardless of system-prompt instructions. + + Tools are appended (not prepended) so user-provided tools keep their + original order. The handler signature ``(arguments, call_context)`` + matches the calling convention used by ``ToolExecutor._invoke_handler``. + """ + out: list[dict] = list(user_tools or []) + if transfer_fn is not None: + async def _transfer_handler(arguments: dict, call_context: dict) -> str: + number = (arguments or {}).get("number", "") + await transfer_fn(number) + return f"Transferring to {number}" if number else "Transfer rejected" + out.append({**TRANSFER_CALL_TOOL, "handler": _transfer_handler}) + if hangup_fn is not None: + async def _hangup_handler(arguments: dict, call_context: dict) -> str: + await hangup_fn() + return "Call ended" + out.append({**END_CALL_TOOL, "handler": _hangup_handler}) + return out + + # --------------------------------------------------------------------------- # Audio sender protocol — abstracts Twilio vs Telnyx audio output # --------------------------------------------------------------------------- @@ -2415,7 +2451,18 @@ async def _connect_stt() -> None: from getpatter.services.llm_loop import LLMLoop from getpatter.tools.tool_executor import ToolExecutor - tool_executor = ToolExecutor() if self.agent.tools else None + # Inject the built-in transfer_call / end_call tools — parity with + # the realtime path (see ``OpenAIRealtimeStreamHandler.start`` + # where ``openai_tools = agent_tools + [TRANSFER_CALL_TOOL, + # END_CALL_TOOL]``). Without this, pipeline-mode LLMs never see + # the built-ins and can't initiate a handoff or hangup no matter + # what the system prompt says. + combined_tools = _augment_with_builtin_handoff_tools( + self.agent.tools, + transfer_fn=self._transfer_fn, + hangup_fn=self._hangup_fn, + ) + tool_executor = ToolExecutor() if combined_tools else None llm_model = self.agent.model if "realtime" in llm_model: llm_model = "gpt-4o-mini" @@ -2423,7 +2470,7 @@ async def _connect_stt() -> None: openai_key=self._openai_key, model=llm_model, system_prompt=self.resolved_prompt, - tools=self.agent.tools, + tools=combined_tools, tool_executor=tool_executor, llm_provider=agent_llm, metrics=self.metrics, diff --git a/libraries/python/tests/test_pipeline_builtin_tools.py b/libraries/python/tests/test_pipeline_builtin_tools.py new file mode 100644 index 00000000..ec861558 --- /dev/null +++ b/libraries/python/tests/test_pipeline_builtin_tools.py @@ -0,0 +1,120 @@ +"""Regression for upstream issue #110. + +Pipeline mode previously passed only the user-provided tools to +``LLMLoop`` — the built-in ``transfer_call`` / ``end_call`` tools that +the realtime path injects were missing, so pipeline LLMs could never +initiate a handoff or hangup regardless of the system prompt. + +These tests exercise the helper that bolts the built-ins onto the +tool list with handler closures wired to the telephony-level +``transfer_fn`` / ``hangup_fn``. +""" + +from __future__ import annotations + +import pytest + +from getpatter.stream_handler import ( + END_CALL_TOOL, + TRANSFER_CALL_TOOL, + _augment_with_builtin_handoff_tools, +) + + +def test_augments_empty_user_tools(): + calls: list[tuple[str, str]] = [] + + async def fake_transfer(number: str) -> None: + calls.append(("transfer", number)) + + async def fake_hangup() -> None: + calls.append(("hangup", "")) + + tools = _augment_with_builtin_handoff_tools( + None, transfer_fn=fake_transfer, hangup_fn=fake_hangup + ) + names = [t["name"] for t in tools] + assert names == ["transfer_call", "end_call"] + # Schema preserved + assert tools[0]["parameters"] == TRANSFER_CALL_TOOL["parameters"] + assert tools[1]["parameters"] == END_CALL_TOOL["parameters"] + # Handlers attached + assert callable(tools[0]["handler"]) + assert callable(tools[1]["handler"]) + + +def test_preserves_user_tools_order(): + user_tools = [ + {"name": "lookup_customer", "description": "", "parameters": {"type": "object"}}, + {"name": "send_sms", "description": "", "parameters": {"type": "object"}}, + ] + tools = _augment_with_builtin_handoff_tools( + user_tools, + transfer_fn=lambda n: None, + hangup_fn=lambda: None, + ) + names = [t["name"] for t in tools] + assert names == ["lookup_customer", "send_sms", "transfer_call", "end_call"] + + +def test_skips_builtin_when_fn_missing(): + """If telephony adapter didn't supply a transfer_fn (e.g. non-Twilio + test harness), the corresponding built-in is not injected.""" + user_tools = [{"name": "lookup_customer", "description": "", "parameters": {}}] + tools = _augment_with_builtin_handoff_tools( + user_tools, transfer_fn=None, hangup_fn=None + ) + assert [t["name"] for t in tools] == ["lookup_customer"] + + +@pytest.mark.asyncio +async def test_transfer_handler_dispatches_to_transfer_fn(): + captured: list[str] = [] + + async def fake_transfer(number: str) -> None: + captured.append(number) + + tools = _augment_with_builtin_handoff_tools( + None, transfer_fn=fake_transfer, hangup_fn=None + ) + transfer = tools[0] + result = await transfer["handler"]( + {"number": "+14155551234"}, {"call_id": "CAtest"} + ) + assert captured == ["+14155551234"] + assert "+14155551234" in result + + +@pytest.mark.asyncio +async def test_hangup_handler_dispatches_to_hangup_fn(): + called = [] + + async def fake_hangup() -> None: + called.append(True) + + tools = _augment_with_builtin_handoff_tools( + None, transfer_fn=None, hangup_fn=fake_hangup + ) + end = tools[0] + assert end["name"] == "end_call" + result = await end["handler"]({}, {"call_id": "CAtest"}) + assert called == [True] + assert "ended" in result.lower() + + +@pytest.mark.asyncio +async def test_transfer_handler_handles_missing_number_gracefully(): + """LLM occasionally emits transfer_call without a number arg; the + handler must not crash.""" + called: list[str] = [] + + async def fake_transfer(number: str) -> None: + called.append(number) + + tools = _augment_with_builtin_handoff_tools( + None, transfer_fn=fake_transfer, hangup_fn=None + ) + result = await tools[0]["handler"]({}, {"call_id": "CAtest"}) + # Calls through with empty string (downstream _validate_e164 will reject) + assert called == [""] + assert "rejected" in result.lower() or result == "Transferring to "