Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions libraries/python/getpatter/stream_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2415,15 +2451,26 @@ 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"
self._llm_loop = LLMLoop(
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,
Expand Down
120 changes: 120 additions & 0 deletions libraries/python/tests/test_pipeline_builtin_tools.py
Original file line number Diff line number Diff line change
@@ -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 "
Loading