cartesia-line 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cartesia-line might be problematic. Click here for more details.

@@ -0,0 +1,120 @@
1
+ """System tool definitions for Cartesia Voice Agents SDK."""
2
+
3
+ from typing import AsyncGenerator, Dict, Union
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from line.events import AgentResponse, EndCall
8
+ from line.tools.tool_types import ToolDefinition
9
+
10
+ try:
11
+ from google.genai import types as gemini_types
12
+ except ImportError:
13
+ gemini_types = None
14
+
15
+
16
+ class EndCallArgs(BaseModel):
17
+ """Arguments for the end_call tool."""
18
+
19
+ goodbye_message: str = Field(description="The final message to say before ending the call")
20
+
21
+
22
+ class EndCallTool(ToolDefinition):
23
+ """End call system tool definition.
24
+
25
+ Usage example (Gemini):
26
+ ```python
27
+ self.generation_config = GenerateContentConfig(
28
+ ...
29
+ tools=[EndCallTool.to_gemini_tool()],
30
+ )
31
+
32
+ async def process_context(
33
+ self, context: ConversationContext
34
+ ) -> AsyncGenerator[Union[AgentResponse, EndCall], None]:
35
+ ...
36
+ function_call = <LLM function call request>
37
+ if function_call.name == EndCallTool.name():
38
+ goodbye_message = function_call.args.get("goodbye_message", "Goodbye!")
39
+ args = EndCallArgs(goodbye_message=goodbye_message)
40
+ async for item in end_call(args):
41
+ yield item
42
+ """
43
+
44
+ @classmethod
45
+ def name(cls) -> str:
46
+ return "end_call"
47
+
48
+ @classmethod
49
+ def description(cls) -> str:
50
+ return (
51
+ "End the conversation with a goodbye message. "
52
+ "Call this when the user says something 'goodbye' or something similar indicating they are ready "
53
+ "to end the call."
54
+ "Before calling this tool, do not send any text back, just use the goodbye_message field."
55
+ )
56
+
57
+ @classmethod
58
+ def to_gemini_tool(cls) -> "gemini_types.Tool":
59
+ """Convert to Gemini tool format"""
60
+
61
+ return gemini_types.Tool(
62
+ function_declarations=[
63
+ gemini_types.FunctionDeclaration(
64
+ name=cls.name(),
65
+ description=cls.description(),
66
+ parameters={
67
+ "type": "object",
68
+ "properties": {
69
+ "goodbye_message": {
70
+ "type": "string",
71
+ "description": EndCallArgs.model_fields["goodbye_message"].description,
72
+ }
73
+ },
74
+ "required": ["goodbye_message"],
75
+ },
76
+ )
77
+ ]
78
+ )
79
+
80
+ @classmethod
81
+ def to_openai_tool(cls) -> Dict[str, object]:
82
+ """Convert to OpenAI tool format for Responses API.
83
+
84
+ Note: This returns the format expected by OpenAI's Responses API,
85
+ not the Chat Completions API format.
86
+ """
87
+ return {
88
+ "type": "function",
89
+ "name": cls.name(),
90
+ "description": cls.description(),
91
+ "parameters": {
92
+ "type": "object",
93
+ "properties": {
94
+ "goodbye_message": {
95
+ "type": "string",
96
+ "description": EndCallArgs.model_fields["goodbye_message"].description,
97
+ }
98
+ },
99
+ "required": ["goodbye_message"],
100
+ "additionalProperties": False,
101
+ },
102
+ "strict": True,
103
+ }
104
+
105
+
106
+ async def end_call(
107
+ args: EndCallArgs,
108
+ ) -> AsyncGenerator[Union[AgentResponse, EndCall], None]:
109
+ """
110
+ End the call with a goodbye message.
111
+
112
+ Yields:
113
+ AgentResponse: The goodbye message to be spoken to the user
114
+ EndCall: Event to end the call
115
+ """
116
+ # Send the goodbye message
117
+ yield AgentResponse(content=args.goodbye_message)
118
+
119
+ # End the call
120
+ yield EndCall()
@@ -0,0 +1,39 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict
3
+
4
+ try:
5
+ from google.genai import types as gemini_types
6
+ except ImportError:
7
+ gemini_types = None
8
+
9
+
10
+ class ToolDefinition(ABC):
11
+ """Abstract base class for static tool definitions.
12
+
13
+ This class should be implemented by all system tools. Each tool should define
14
+ its name, description, and return type as class methods.
15
+ """
16
+
17
+ @classmethod
18
+ @abstractmethod
19
+ def name(cls) -> str:
20
+ """Tool name for LLM usage."""
21
+ pass
22
+
23
+ @classmethod
24
+ @abstractmethod
25
+ def description(cls) -> str:
26
+ """Tool description for LLM understanding."""
27
+ pass
28
+
29
+ @classmethod
30
+ @abstractmethod
31
+ def to_gemini_tool(cls) -> "gemini_types.Tool":
32
+ """Map to Gemini tool format. https://ai.google.dev/gemini-api/docs/function-calling"""
33
+ pass
34
+
35
+ @classmethod
36
+ @abstractmethod
37
+ def to_openai_tool(cls) -> Dict[str, object]:
38
+ """Map to OpenAI tool format. https://platform.openai.com/docs/guides/tools?tool-type=function-calling"""
39
+ pass
line/user_bridge.py ADDED
@@ -0,0 +1,200 @@
1
+ """
2
+ UserBridge - Event routing bridge for user communication via harness.
3
+
4
+ Treats user as a regular bus participant with WebSocket transport.
5
+ Handles bidirectional communication and authorization:
6
+ - Inbound: WebSocket messages → bus events (via input routing)
7
+ - Outbound: Bus events → WebSocket messages (via event handlers)
8
+ - Authorization: Only authorized agent can send to user
9
+
10
+ Input routing eliminates manual async task management by automatically
11
+ converting continuous WebSocket input into bus events.
12
+
13
+ Examples:
14
+ Basic user bridge setup::
15
+
16
+ user_bridge = create_user_bridge(harness, authorized_node="coordinator")
17
+ bus.register_bridge("user", user_bridge)
18
+ await user_bridge.start_input_routing() # Start WebSocket → bus routing
19
+
20
+ Sending to user::
21
+
22
+ # Type-safe event creation with validation
23
+ message_event = UserMessageEvent(node_id="coordinator", content="Hello!")
24
+ await bus.broadcast("coordinator", message_event)
25
+
26
+ tool_event = UserToolCallEvent(
27
+ node_id="coordinator",
28
+ name="search",
29
+ args={},
30
+ result={"status": "success"}
31
+ )
32
+ await bus.broadcast("coordinator", tool_event)
33
+ """
34
+
35
+ import dataclasses
36
+ from typing import TYPE_CHECKING, Union
37
+
38
+ from loguru import logger
39
+ from pydantic import BaseModel
40
+
41
+ from line.bridge import Bridge
42
+ from line.bus import Message
43
+ from line.events import (
44
+ AgentError,
45
+ AgentResponse,
46
+ Authorize,
47
+ EndCall,
48
+ EventType,
49
+ LogMetric,
50
+ ToolCall,
51
+ ToolResult,
52
+ TransferCall,
53
+ )
54
+
55
+ if TYPE_CHECKING:
56
+ from line.harness import ConversationHarness
57
+
58
+
59
+ def create_user_bridge(harness: "ConversationHarness", authorized_node: str) -> Bridge:
60
+ """
61
+ Create event routing bridge for user communication.
62
+
63
+ Sets up bidirectional user communication with automatic input routing.
64
+ Uses NodeBridge input routing to eliminate manual WebSocket task management.
65
+
66
+ Args:
67
+ harness: ConversationHarness instance with get() method for WebSocket input.
68
+ authorized_node: Agent ID authorized to communicate with user.
69
+
70
+ Returns:
71
+ Configured NodeBridge with input routing and user communication handlers.
72
+
73
+ Examples:
74
+ >>> harness = ConversationHarness(websocket, shutdown_event)
75
+ >>> bridge = create_user_bridge(harness, "coordinator")
76
+ >>> bus.register_bridge("user", bridge)
77
+ >>> await bridge.start_input_routing() # Start WebSocket input routing
78
+ """
79
+
80
+ async def send_message(message: Message):
81
+ """Send text message to user."""
82
+ event: AgentResponse = message.event
83
+ logger.debug(f"Sending user message: {event.content}")
84
+ return await harness.send_message(event.content)
85
+
86
+ async def send_tool_call(message: Message):
87
+ """Send tool call result to user."""
88
+ event: Union[ToolCall, ToolResult] = message.event
89
+ if isinstance(event, ToolResult):
90
+ result = event.result_str if event.result_str is not None else event.error
91
+ else:
92
+ result = None
93
+ return await harness.send_tool_call(event.tool_name, event.tool_args, event.tool_call_id, result)
94
+
95
+ async def send_end_call(message: Message):
96
+ """End the call."""
97
+ return await harness.end_call()
98
+
99
+ async def send_error(message: Message):
100
+ """Send error message to user."""
101
+ event: AgentError = message.event
102
+ return await harness.send_error(event.error)
103
+
104
+ async def send_transfer_call(message: Message):
105
+ """Transfer call to destination."""
106
+ event: TransferCall = message.event
107
+ return await harness.transfer_call(event.destination)
108
+
109
+ async def send_log_metric(message: Message):
110
+ """Log metric via harness."""
111
+ event: LogMetric = message.event
112
+ return await harness.log_metric(event.name, event.value)
113
+
114
+ bridge = (
115
+ Bridge(harness)
116
+ .with_input_routing(harness) # Enable WebSocket → bus event routing
117
+ .authorize(authorized_node, "tools") # Allow both conversation and tools agents
118
+ )
119
+
120
+ (
121
+ bridge.on(AgentResponse)
122
+ .map(send_message)
123
+ .on(ToolCall)
124
+ .map(send_tool_call)
125
+ .on(ToolResult)
126
+ .map(send_tool_call)
127
+ .on(EndCall)
128
+ .map(send_end_call)
129
+ .on(AgentError)
130
+ .map(send_error)
131
+ .on(TransferCall)
132
+ .map(send_transfer_call)
133
+ .on(LogMetric)
134
+ .map(send_log_metric)
135
+ )
136
+
137
+ # Add authorization handler after creation.
138
+ bridge.on(Authorize).map(lambda msg: bridge.authorize(msg.event.agent))
139
+
140
+ return bridge
141
+
142
+
143
+ def register_observability_event(bridge: Bridge, harness: "ConversationHarness", event_type: EventType):
144
+ """
145
+ Register an event type for observability logging.
146
+
147
+ For Pydantic BaseModel types, automatically uses the class name as event name
148
+ and model_dump() as metadata. For dataclass types, uses the class name as event
149
+ name and asdict() as metadata. For other types, validates that the event type
150
+ has a `to_log_event` method and sets up routing to send log events to the harness.
151
+
152
+ Args:
153
+ bridge: The bridge to register the event on
154
+ harness: The ConversationHarness to send log events to
155
+ event_type: The event type to register
156
+
157
+ Raises:
158
+ ValueError: If the event type is not a BaseModel/dataclass and doesn't have a `to_log_event` method
159
+
160
+ Examples:
161
+ >>> bridge = create_user_bridge(harness, "coordinator")
162
+ >>> register_observability_event(bridge, harness, MyBaseModelEvent) # Uses class name + model_dump()
163
+ >>> register_observability_event(bridge, harness, MyDataclassEvent) # Uses class name + asdict()
164
+ >>> register_observability_event(bridge, harness, MyCustomEvent) # Uses to_log_event() method
165
+ """
166
+ # Check if the event type is a BaseModel subclass or dataclass
167
+ is_base_model = isinstance(event_type, type) and issubclass(event_type, BaseModel)
168
+ is_dataclass = isinstance(event_type, type) and dataclasses.is_dataclass(event_type)
169
+
170
+ if not is_base_model and not is_dataclass and not hasattr(event_type, "to_log_event"):
171
+ raise ValueError(
172
+ f"Event type {event_type} must be a pydantic BaseModel subclass, "
173
+ f"dataclass, or have a 'to_log_event' method."
174
+ )
175
+
176
+ async def send_log_event(message: Message):
177
+ """Convert event to log format and send to harness."""
178
+ if isinstance(message.event, BaseModel):
179
+ # For BaseModel types, use class name as event and model_dump as metadata
180
+ event_name = type(message.event).__name__
181
+ metadata = message.event.model_dump()
182
+ await harness.log_event(event_name, metadata)
183
+ elif dataclasses.is_dataclass(message.event):
184
+ # For dataclass types, use class name as event and asdict as metadata
185
+ event_name = type(message.event).__name__
186
+ metadata = dataclasses.asdict(message.event)
187
+ await harness.log_event(event_name, metadata)
188
+ else:
189
+ # For other types, use the to_log_event method
190
+ event_data = message.event.to_log_event()
191
+ if not isinstance(event_data, dict) or "event" not in event_data:
192
+ logger.error(f"Invalid log event data from {type(message.event).__name__}: {event_data}")
193
+ return
194
+
195
+ event_name = event_data.get("event")
196
+ metadata = event_data.get("metadata", None)
197
+ await harness.log_event(event_name, metadata)
198
+
199
+ # Register the event handler
200
+ bridge.on(event_type).map(send_log_event)
line/utils/__init__.py ADDED
File without changes
line/utils/aio.py ADDED
@@ -0,0 +1,62 @@
1
+ import asyncio
2
+ import sys
3
+ from typing import Collection, Union
4
+
5
+ if sys.version_info < (3, 11):
6
+
7
+ class ExceptionGroup(Exception):
8
+ """Simple ExceptionGroup implementation for Python < 3.11"""
9
+
10
+ def __init__(self, message: str, exceptions: list):
11
+ self.message = message
12
+ self.exceptions = exceptions
13
+ super().__init__(message)
14
+
15
+
16
+ async def cancel_tasks_safe(
17
+ tasks: Union[asyncio.Task, Collection[asyncio.Task]],
18
+ ) -> None:
19
+ """Cancel an asyncio task safely."""
20
+ if isinstance(tasks, asyncio.Task):
21
+ tasks = [tasks]
22
+
23
+ tasks = [task for task in tasks if task and not task.done()]
24
+
25
+ # Send message to cancel all tasks.
26
+ for task in tasks:
27
+ # Calling cancel on a task multiple times is ok because it is idempotent.
28
+ task.cancel()
29
+
30
+ results = await asyncio.gather(*tasks, return_exceptions=True)
31
+
32
+ errors = []
33
+ for result in results:
34
+ if isinstance(result, asyncio.CancelledError):
35
+ continue
36
+ elif isinstance(result, Exception):
37
+ errors.append(results)
38
+ if errors:
39
+ raise ExceptionGroup("Multiple errors occurred during task cancellation", errors)
40
+
41
+
42
+ async def await_tasks_safe(
43
+ tasks: Union[asyncio.Task, Collection[asyncio.Task]],
44
+ ) -> None:
45
+ """Wait for an asyncio task to complete.
46
+
47
+ If the task is cancelled, do not raise an exception.
48
+ """
49
+ if isinstance(tasks, asyncio.Task):
50
+ tasks = [tasks]
51
+
52
+ tasks = [task for task in tasks if task and not task.done()]
53
+ results = await asyncio.gather(*tasks, return_exceptions=True)
54
+
55
+ errors = []
56
+ for result in results:
57
+ if isinstance(result, asyncio.CancelledError):
58
+ continue
59
+ elif isinstance(result, Exception):
60
+ errors.append(results)
61
+ if errors:
62
+ raise ExceptionGroup("Multiple errors occurred during task cancellation", errors)
@@ -0,0 +1,152 @@
1
+ """
2
+ Utility functions for converting agent tools and messages to Gemini format
3
+ """
4
+
5
+ import json
6
+ from typing import Any, Callable, Dict, List
7
+ import warnings
8
+
9
+ from google.genai import types
10
+ from loguru import logger
11
+
12
+ from line.events import (
13
+ AgentResponse,
14
+ EventInstance,
15
+ EventType,
16
+ ToolResult,
17
+ UserTranscriptionReceived,
18
+ )
19
+
20
+ # Suppress aiohttp ResourceWarnings about unclosed sessions
21
+ # These are caused by the Gemini client's internal session management
22
+ warnings.filterwarnings("ignore", message="unclosed", category=ResourceWarning)
23
+
24
+
25
+ def convert_messages_to_gemini(
26
+ events: List[EventInstance],
27
+ handlers: Dict[EventType, Callable[[EventInstance], Dict[str, Any]]] = None,
28
+ text_events_only: bool = False,
29
+ ) -> List[Dict[str, Any]]:
30
+ """
31
+ Convert conversation events to Gemini format.
32
+
33
+ Note:
34
+ This method only handles these event types:
35
+ - `AgentResponse`
36
+ - `UserTranscriptionReceived`
37
+ - `ToolCall`
38
+ - `ToolResult`
39
+ To convert other events, add handlers.
40
+
41
+ Args:
42
+ messages: List of events.
43
+ handlers: Dictionary of event type to handler function.
44
+ The handler function should return a dictionary of Gemini-formatted messages.
45
+ text_events_only: Whether to only include User and Agent messages in the output.
46
+ Returns:
47
+ List of Gemini-formatted messages
48
+ """
49
+ handlers = handlers or {}
50
+
51
+ if not types:
52
+ raise ImportError("google.genai is required for Gemini integration")
53
+
54
+ gemini_messages = []
55
+
56
+ for event in events:
57
+ event_type = type(event)
58
+
59
+ if text_events_only and event_type not in (AgentResponse, UserTranscriptionReceived):
60
+ continue
61
+
62
+ if event_type in handlers:
63
+ gemini_messages.append(handlers[event_type](event))
64
+ elif isinstance(event, AgentResponse):
65
+ gemini_messages.append(types.ModelContent(parts=[types.Part.from_text(text=event.content)]))
66
+ elif isinstance(event, UserTranscriptionReceived):
67
+ gemini_messages.append(types.UserContent(parts=[types.Part.from_text(text=event.content)]))
68
+ elif isinstance(event, ToolResult):
69
+ # Gemini 400s if a ToolResult is the first message in the context, so don't add it:
70
+ if not gemini_messages:
71
+ logger.debug(f"Skipping orphaned ToolResult at start of context: {event.tool_name}")
72
+ continue
73
+
74
+ tool_name = event.tool_name or "unknown_tool"
75
+
76
+ function_response = types.Part.from_function_response(
77
+ name=tool_name, response={"output": event.result}
78
+ )
79
+
80
+ gemini_messages.append(types.ModelContent(parts=[function_response]))
81
+
82
+ logger.debug(f"Converted {len(events)} messages to {len(gemini_messages)} Gemini messages")
83
+ return gemini_messages
84
+
85
+
86
+ def log_gemini_messages(message: str, gemini_messages, statistics=None):
87
+ """
88
+ Log Gemini messages in a nice formatted way.
89
+ Analogous to log_conversation_history but for Gemini message format.
90
+ """
91
+ gemini_messages_str = message_to_str(gemini_messages)
92
+
93
+ to_log = f"\n====== BEGIN {message} ======\n"
94
+ to_log += gemini_messages_str
95
+ if statistics:
96
+ to_log += f"\n[Statistics: {statistics}]\n"
97
+ to_log += f"======== END {message} =========\n"
98
+
99
+ logger.info(f"Logging Gemini messages: {to_log}")
100
+
101
+
102
+ def message_to_str(gemini_messages) -> str:
103
+ """
104
+ Convert a list of Gemini messages to a formatted string representation.
105
+ Similar to _get_conversation_history_str but for Gemini message format.
106
+ """
107
+ ans = ""
108
+ prev_message = None
109
+
110
+ for message in gemini_messages:
111
+ # Get the role from the message type
112
+ if isinstance(message, types.UserContent):
113
+ role = "user"
114
+ elif isinstance(message, types.ModelContent):
115
+ role = "model"
116
+ else:
117
+ role = "unknown"
118
+
119
+ # Serialize all parts in the message
120
+ serialized_parts = [serialize_part(part) for part in message.parts]
121
+ content = (
122
+ serialized_parts[0] if len(serialized_parts) == 1 else json.dumps(serialized_parts, indent=2)
123
+ )
124
+
125
+ ans += f"{role}: {content}\n"
126
+
127
+ # Extra line between user and model turns
128
+ if role == "user" and prev_message is not None and (isinstance(prev_message, types.ModelContent)):
129
+ ans += "\n"
130
+ prev_message = message
131
+
132
+ return ans
133
+
134
+
135
+ def serialize_part(part: types.Part) -> str:
136
+ """
137
+ Serialize a Gemini Part object to a readable string representation.
138
+ Handles text, function calls, and function responses.
139
+ """
140
+ if hasattr(part, "text") and part.text is not None and part.text.strip() != "":
141
+ return part.text
142
+ elif hasattr(part, "function_call") and part.function_call is not None:
143
+ func_call = part.function_call
144
+ args_str = json.dumps(dict(func_call.args), indent=2) if func_call.args else "{}"
145
+ return f"[function call] {func_call.name}({args_str})"
146
+ elif hasattr(part, "function_response") and part.function_response is not None:
147
+ func_resp = part.function_response
148
+ response_str = json.dumps(dict(func_resp.response), indent=2) if func_resp.response else "{}"
149
+
150
+ return f"[function response] {func_resp.name}: {response_str}"
151
+ else:
152
+ return f"[unsupported part type] {str(part)}"
@@ -0,0 +1,122 @@
1
+ import json
2
+ from typing import Any, Callable, Dict, List
3
+
4
+ from openai.types.responses import (
5
+ Response,
6
+ ResponseFunctionToolCall,
7
+ ResponseOutputMessage,
8
+ ResponseOutputRefusal,
9
+ ResponseOutputText,
10
+ )
11
+
12
+ from line.events import (
13
+ AgentResponse,
14
+ EventInstance,
15
+ EventType,
16
+ ToolCall,
17
+ ToolResult,
18
+ UserTranscriptionReceived,
19
+ )
20
+
21
+
22
+ def convert_messages_to_openai(
23
+ events: List[EventInstance],
24
+ handlers: Dict[EventType, Callable[[EventInstance], Dict[str, Any]]] = None,
25
+ ) -> List[Dict[str, Any]]:
26
+ """Convert conversation messages to OpenAI format.
27
+
28
+ With OpenAI, all messages need to be in the context.
29
+
30
+ Args:
31
+ events: List of events.
32
+ handlers: Dictionary of event type to handler function.
33
+ The handler function should return a dictionary of OpenAI-formatted messages.
34
+
35
+ Returns:
36
+ List of messages in OpenAI format
37
+ """
38
+ handlers = handlers or {}
39
+
40
+ openai_messages = []
41
+ for event in events:
42
+ event_type = type(event)
43
+ if event_type in handlers:
44
+ openai_messages.append(handlers[event_type](event))
45
+ continue
46
+
47
+ if isinstance(event, AgentResponse):
48
+ openai_messages.append({"role": "assistant", "content": event.content})
49
+ elif isinstance(event, UserTranscriptionReceived):
50
+ openai_messages.append({"role": "user", "content": event.content})
51
+ elif isinstance(event, ToolCall):
52
+ if event.raw_response:
53
+ openai_messages.append(event.raw_response)
54
+ elif isinstance(event, ToolResult):
55
+ if event.tool_call_id:
56
+ openai_messages.append(
57
+ {
58
+ "type": "function_call_output",
59
+ "call_id": event.tool_call_id,
60
+ "output": event.result_str,
61
+ }
62
+ )
63
+
64
+ return openai_messages
65
+
66
+
67
+ def extract_text_from_response(response: Response) -> str:
68
+ """Extract all text content from OpenAI response output.
69
+
70
+ Args:
71
+ response: OpenAI response object
72
+
73
+ Returns:
74
+ Combined text content from the response
75
+ """
76
+ text_content = ""
77
+ for msg in response.output:
78
+ if isinstance(msg, ResponseOutputMessage):
79
+ for content in msg.content:
80
+ if isinstance(content, ResponseOutputText):
81
+ text_content += content.text
82
+ elif isinstance(content, ResponseOutputRefusal):
83
+ text_content += content.refusal
84
+ return text_content
85
+
86
+
87
+ def extract_tool_calls_from_response(response: Response) -> List[ToolCall]:
88
+ """Extract function tool calls from OpenAI response output.
89
+
90
+ Args:
91
+ response: OpenAI response object
92
+
93
+ Returns:
94
+ List of tool calls with name and arguments
95
+ """
96
+ tool_calls = []
97
+ for msg in response.output:
98
+ if isinstance(msg, ResponseFunctionToolCall):
99
+ tool_calls.append(
100
+ ToolCall(
101
+ tool_name=msg.name,
102
+ tool_args=json.loads(msg.arguments),
103
+ tool_call_id=msg.id,
104
+ raw_response=msg.model_dump(),
105
+ )
106
+ )
107
+ return tool_calls
108
+
109
+
110
+ def has_tool_calls(response: Response) -> bool:
111
+ """Check if response contains any tool calls.
112
+
113
+ Args:
114
+ response: OpenAI response object
115
+
116
+ Returns:
117
+ True if response contains tool calls, False otherwise
118
+ """
119
+ for msg in response.output:
120
+ if isinstance(msg, ResponseFunctionToolCall):
121
+ return True
122
+ return False