Skip to content
2 changes: 2 additions & 0 deletions line/_harness_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,13 @@ class TransferOutput(BaseModel):
type: Literal["transfer"] = "transfer"
target_phone_number: str
responding_to: Optional[str] = None
interruptible: bool = True


class EndCallOutput(BaseModel):
type: Literal["end_call"] = "end_call"
responding_to: Optional[str] = None
interruptible: bool = True


class LogEventOutput(BaseModel):
Expand Down
2 changes: 2 additions & 0 deletions line/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,14 @@ class AgentToolReturned(BaseModel):
class AgentEndCall(BaseModel):
type: Literal["end_call"] = "end_call"
responding_to: Optional[str] = None
interruptible: bool = True


class AgentTransferCall(BaseModel):
type: Literal["agent_transfer_call"] = "agent_transfer_call"
target_phone_number: str
responding_to: Optional[str] = None
interruptible: bool = True


class AgentSendDtmf(BaseModel):
Expand Down
113 changes: 83 additions & 30 deletions line/llm_agent/tools/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,10 @@ class EndCallTool:
def __init__(
self,
description: Optional[str] = None,
interruptible: bool = True,
):
self.description = description if description else self.DEFAULT_DESCRIPTION
self.interruptible = interruptible
self._function_tool = self._create_function_tool()

@property
Expand All @@ -207,7 +209,7 @@ async def _end_call_impl(
ctx: ToolEnv,
reason: Annotated[str, "The reason for ending the call"],
):
yield AgentEndCall()
yield AgentEndCall(interruptible=self.interruptible)

return construct_function_tool(
_end_call_impl,
Expand All @@ -223,17 +225,18 @@ def as_function_tool(self) -> FunctionTool:
def __call__(
self,
description: Optional[str] = None,
interruptible: bool = True,
) -> "EndCallTool":
"""Create a configured EndCallTool instance.

Args:
description: Description that replaces the default. Use this to customize
when the LLM should end the call.

interruptible: Whether the end_call tool is interruptible.
Returns:
A new EndCallTool instance with the specified configuration.
"""
return EndCallTool(description=description)
return EndCallTool(description=description, interruptible=interruptible)


# Default instance - can be used directly or called to configure
Expand All @@ -243,6 +246,79 @@ def __call__(
end_call = EndCallTool()


class TransferCallTool:
"""
Configurable transfer_call tool with custom description.
"""

def __init__(self, message: Optional[str] = None, interruptible: bool = True):
self.message = message
self.interruptible = interruptible
self._function_tool = self._create_function_tool()

@property
def name(self) -> str:
"""Return the tool name."""
return "transfer_call"

def _create_function_tool(self) -> FunctionTool:
"""Create the underlying FunctionTool with the configured description."""

async def _transfer_call_impl(
ctx: ToolEnv,
target_phone_number: Annotated[
str, "The destination phone number in E.164 format (e.g., +14155551234)"
],
message: Annotated[Optional[str], "Optional message to say before transferring"] = None,
):
"""Transfer the call to another phone number."""
import phonenumbers

try:
parsed = phonenumbers.parse(target_phone_number)
if not phonenumbers.is_valid_number(parsed):
yield AgentSendText(text="I'm sorry, that phone number appears to be invalid.")
return
except phonenumbers.NumberParseException:
yield AgentSendText(text="I'm sorry, I couldn't understand that phone number format.")
return

# Normalize to E.164 format
normalized_number = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)

resolved_message = message if message is not None else self.message
if resolved_message:
yield AgentSendText(text=resolved_message, interruptible=self.interruptible)
yield AgentTransferCall(target_phone_number=normalized_number, interruptible=self.interruptible)
Comment thread
lucyliulee marked this conversation as resolved.

return construct_function_tool(
_transfer_call_impl,
name="transfer_call",
description=_transfer_call_impl.__doc__,
tool_type=ToolType.PASSTHROUGH,
)

def as_function_tool(self) -> FunctionTool:
"""Return the underlying FunctionTool for use in tool resolution."""
return self._function_tool

def __call__(self, message: Optional[str] = None, interruptible: bool = True) -> "TransferCallTool":
"""Create a configured TransferCallTool instance.

Args:
message: Optional default message to say before transferring.
interruptible: Whether the transfer_call tool is interruptible.
"""
return TransferCallTool(message=message, interruptible=interruptible)


# Default instance - can be used directly or called to configure
# Examples:
# transfer_call # Use default behavior
# transfer_call(interruptible=False) # Disable interruptibility
transfer_call = TransferCallTool()
Comment thread
lucyliulee marked this conversation as resolved.


@dataclass
class _McpServer:
"""Internal: holds MCP server connection config and the execute method."""
Expand Down Expand Up @@ -440,39 +516,14 @@ async def send_dtmf(
yield AgentSendDtmf(button=button)


@passthrough_tool
async def transfer_call(
ctx: ToolEnv,
target_phone_number: Annotated[str, "The destination phone number in E.164 format (e.g., +14155551234)"],
message: Annotated[Optional[str], "Optional message to say before transferring"] = None,
):
"""Transfer the call to another phone number."""
import phonenumbers

try:
parsed = phonenumbers.parse(target_phone_number)
if not phonenumbers.is_valid_number(parsed):
yield AgentSendText(text="I'm sorry, that phone number appears to be invalid.")
return
except phonenumbers.NumberParseException:
yield AgentSendText(text="I'm sorry, I couldn't understand that phone number format.")
return

# Normalize to E.164 format
normalized_number = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)

if message is not None:
yield AgentSendText(text=message)
yield AgentTransferCall(target_phone_number=normalized_number)


def agent_as_handoff(
agent: Agent,
*,
handoff_message: Optional[str] = None,
update_call: Optional[UpdateCallConfig] = None,
name: Optional[str] = None,
description: Optional[str] = None,
interruptible: bool = True,
) -> FunctionTool:
"""
Create a handoff tool from an Agent.
Expand All @@ -482,6 +533,7 @@ def agent_as_handoff(

Args:
agent: The agent to hand off to. Can be an AgentCallable or AgentClass.
interruptible: Whether the transfer is interruptible.
handoff_message: Optional message to send before handoff (e.g., "Transferring you now...").
update_call: Optional config to update call settings (voice, pronunciation) before handoff.
name: Tool name for LLM function calling. Defaults to agent class name or "transfer_to_agent".
Expand Down Expand Up @@ -525,7 +577,7 @@ async def _handoff_fn(ctx: ToolEnv, event):
if isinstance(event, AgentHandedOff):
# Send handoff message if provided
if handoff_message:
yield AgentSendText(text=handoff_message)
yield AgentSendText(text=handoff_message, interruptible=interruptible)

# Update call settings (e.g., voice) if provided
if update_call is not None:
Expand Down Expand Up @@ -556,6 +608,7 @@ async def _handoff_fn(ctx: ToolEnv, event):
__all__ = [
"DtmfButton",
"EndCallTool",
"TransferCallTool",
"UpdateCallConfig",
"WebSearchTool",
"web_search",
Expand Down
10 changes: 5 additions & 5 deletions line/llm_agent/tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def my_tool(ctx: ToolEnv, category: Annotated[Literal["a", "b", "c"], "pick one"
from line.events import InputEvent, OutputEvent

if TYPE_CHECKING:
from line.llm_agent.tools.system import EndCallTool, WebSearchTool
from line.llm_agent.tools.system import EndCallTool, TransferCallTool, WebSearchTool

# -------------------------
# Tool Type Enum
Expand Down Expand Up @@ -141,7 +141,7 @@ class FunctionTool:
# Type alias for tools that can be passed to LlmAgent/LlmProvider.
# Plain callables are automatically wrapped as loopback tools.
# Uses string literal because WebSearchTool/EndCallTool are TYPE_CHECKING-only imports.
ToolSpec = Union[FunctionTool, "WebSearchTool", "EndCallTool", Callable]
ToolSpec = Union[FunctionTool, "WebSearchTool", "EndCallTool", "TransferCallTool", Callable]


@dataclass
Expand Down Expand Up @@ -331,15 +331,15 @@ def _normalize_tools(
FunctionTool in the first list.
"""
from line.llm_agent.tools.decorators import loopback_tool
from line.llm_agent.tools.system import EndCallTool, WebSearchTool
from line.llm_agent.tools.system import EndCallTool, TransferCallTool, WebSearchTool

function_tools: List[FunctionTool] = []
web_search_tool: Optional[Any] = None

for tool in tool_specs:
if isinstance(tool, FunctionTool):
function_tools.append(tool)
elif isinstance(tool, EndCallTool):
elif isinstance(tool, (EndCallTool, TransferCallTool)):
function_tools.append(tool.as_function_tool())
elif isinstance(tool, WebSearchTool):
web_search_tool = tool
Expand All @@ -348,7 +348,7 @@ def _normalize_tools(
else:
raise TypeError(
f"Unsupported tool type: {type(tool).__name__}. "
f"Expected FunctionTool, EndCallTool, WebSearchTool, or callable."
f"Expected FunctionTool, EndCallTool, TransferCallTool, WebSearchTool, or callable."
)

web_search_options: Optional[Dict[str, Any]] = None
Expand Down
12 changes: 8 additions & 4 deletions line/voice_agent_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,12 +647,16 @@ def _map_output_event(self, event: OutputEvent) -> OutputMessage:
logger.info(f"<- 🤖🔔 Agent DTMF sent: {event.button}")
return DTMFOutput(button=event.button, responding_to=event.responding_to)
if isinstance(event, AgentEndCall):
logger.info("<- 📞 End call")
return EndCallOutput(responding_to=event.responding_to)
logger.info(f"<- 📞 End call (interruptible={event.interruptible})")
return EndCallOutput(responding_to=event.responding_to, interruptible=event.interruptible)
if isinstance(event, AgentTransferCall):
logger.info(f"<- 📱 Transfer to: {event.target_phone_number}")
logger.info(
f"<- 📱 Transfer to: {event.target_phone_number} (interruptible={event.interruptible})"
)
return TransferOutput(
target_phone_number=event.target_phone_number, responding_to=event.responding_to
target_phone_number=event.target_phone_number,
responding_to=event.responding_to,
interruptible=event.interruptible,
)
if isinstance(event, LogMetric):
logger.debug(f"<- 📈 Log metric: {event.name}={event.value}")
Expand Down
Loading
Loading