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.
- cartesia_line-0.0.1.dist-info/METADATA +25 -0
- cartesia_line-0.0.1.dist-info/RECORD +27 -0
- cartesia_line-0.0.1.dist-info/WHEEL +5 -0
- cartesia_line-0.0.1.dist-info/licenses/LICENSE +201 -0
- cartesia_line-0.0.1.dist-info/top_level.txt +1 -0
- line/__init__.py +29 -0
- line/bridge.py +348 -0
- line/bus.py +401 -0
- line/call_request.py +25 -0
- line/events.py +218 -0
- line/harness.py +257 -0
- line/harness_types.py +109 -0
- line/nodes/__init__.py +7 -0
- line/nodes/base.py +60 -0
- line/nodes/conversation_context.py +66 -0
- line/nodes/reasoning.py +223 -0
- line/routes.py +618 -0
- line/tools/__init__.py +9 -0
- line/tools/system_tools.py +120 -0
- line/tools/tool_types.py +39 -0
- line/user_bridge.py +200 -0
- line/utils/__init__.py +0 -0
- line/utils/aio.py +62 -0
- line/utils/gemini_utils.py +152 -0
- line/utils/openai_utils.py +122 -0
- line/voice_agent_app.py +147 -0
- line/voice_agent_system.py +230 -0
|
@@ -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()
|
line/tools/tool_types.py
ADDED
|
@@ -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
|