lite-agent 0.1.0__py3-none-any.whl → 0.2.0__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 lite-agent might be problematic. Click here for more details.
- lite_agent/__init__.py +7 -0
- lite_agent/agent.py +310 -16
- lite_agent/loggers.py +1 -1
- lite_agent/message_transfers.py +111 -0
- lite_agent/processors/__init__.py +1 -1
- lite_agent/processors/stream_chunk_processor.py +63 -42
- lite_agent/runner.py +465 -29
- lite_agent/stream_handlers/__init__.py +5 -0
- lite_agent/stream_handlers/litellm.py +106 -0
- lite_agent/types/__init__.py +55 -0
- lite_agent/types/chunks.py +89 -0
- lite_agent/types/messages.py +68 -0
- lite_agent/types/tool_calls.py +15 -0
- lite_agent-0.2.0.dist-info/METADATA +111 -0
- lite_agent-0.2.0.dist-info/RECORD +17 -0
- lite_agent/__main__.py +0 -110
- lite_agent/chunk_handler.py +0 -166
- lite_agent/types.py +0 -152
- lite_agent-0.1.0.dist-info/METADATA +0 -22
- lite_agent-0.1.0.dist-info/RECORD +0 -13
- {lite_agent-0.1.0.dist-info → lite_agent-0.2.0.dist-info}/WHEEL +0 -0
lite_agent/__init__.py
CHANGED
lite_agent/agent.py
CHANGED
|
@@ -1,36 +1,330 @@
|
|
|
1
|
-
from collections.abc import AsyncGenerator, Callable
|
|
1
|
+
from collections.abc import AsyncGenerator, Callable, Sequence
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Optional
|
|
2
4
|
|
|
3
5
|
import litellm
|
|
4
6
|
from funcall import Funcall
|
|
7
|
+
from litellm import CustomStreamWrapper
|
|
8
|
+
from pydantic import BaseModel
|
|
5
9
|
|
|
6
|
-
from
|
|
7
|
-
from
|
|
10
|
+
from lite_agent.loggers import logger
|
|
11
|
+
from lite_agent.stream_handlers import litellm_stream_handler
|
|
12
|
+
from lite_agent.types import AgentChunk, AgentSystemMessage, RunnerMessages, ToolCall, ToolCallChunk, ToolCallResultChunk
|
|
13
|
+
|
|
14
|
+
HANDOFFS_SOURCE_INSTRUCTIONS = """<ExtraGuide>
|
|
15
|
+
You are a parent agent that can assign tasks to sub-agents.
|
|
16
|
+
|
|
17
|
+
You can transfer conversations to other agents for specific tasks.
|
|
18
|
+
If you need to assign tasks to multiple agents, you should break down the tasks and assign them one by one.
|
|
19
|
+
You need to wait for one sub-agent to finish before assigning the task to the next sub-agent.
|
|
20
|
+
</ExtraGuide>"""
|
|
21
|
+
|
|
22
|
+
HANDOFFS_TARGET_INSTRUCTIONS = """<ExtraGuide>
|
|
23
|
+
You are a sub-agent that is assigned to a specific task by your parent agent.
|
|
24
|
+
|
|
25
|
+
Everything you output is intended for your parent agent to read.
|
|
26
|
+
When you finish your task, you should call `transfer_to_parent` to transfer back to parent agent.
|
|
27
|
+
</ExtraGuide>"""
|
|
8
28
|
|
|
9
29
|
|
|
10
30
|
class Agent:
|
|
11
|
-
def __init__(
|
|
31
|
+
def __init__( # noqa: PLR0913
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
model: str,
|
|
35
|
+
name: str,
|
|
36
|
+
instructions: str,
|
|
37
|
+
tools: list[Callable] | None = None,
|
|
38
|
+
handoffs: list["Agent"] | None = None,
|
|
39
|
+
message_transfer: Callable[[RunnerMessages], RunnerMessages] | None = None,
|
|
40
|
+
) -> None:
|
|
12
41
|
self.name = name
|
|
13
42
|
self.instructions = instructions
|
|
14
|
-
self.fc = Funcall(tools)
|
|
15
43
|
self.model = model
|
|
44
|
+
self.handoffs = handoffs if handoffs else []
|
|
45
|
+
self._parent: Agent | None = None
|
|
46
|
+
self.message_transfer = message_transfer
|
|
47
|
+
# Initialize Funcall with regular tools
|
|
48
|
+
self.fc = Funcall(tools)
|
|
16
49
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
50
|
+
# Set parent for handoff agents
|
|
51
|
+
if handoffs:
|
|
52
|
+
for handoff_agent in handoffs:
|
|
53
|
+
handoff_agent.parent = self
|
|
54
|
+
self._add_transfer_tools(handoffs)
|
|
55
|
+
|
|
56
|
+
# Add transfer_to_parent tool if this agent has a parent (for cases where parent is set externally)
|
|
57
|
+
if self.parent is not None:
|
|
58
|
+
self.add_transfer_to_parent_tool()
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def parent(self) -> Optional["Agent"]:
|
|
62
|
+
return self._parent
|
|
63
|
+
|
|
64
|
+
@parent.setter
|
|
65
|
+
def parent(self, value: Optional["Agent"]) -> None:
|
|
66
|
+
self._parent = value
|
|
67
|
+
if value is not None:
|
|
68
|
+
self.add_transfer_to_parent_tool()
|
|
69
|
+
|
|
70
|
+
def _add_transfer_tools(self, handoffs: list["Agent"]) -> None:
|
|
71
|
+
"""Add transfer function for handoff agents using dynamic tools.
|
|
72
|
+
|
|
73
|
+
Creates a single 'transfer_to_agent' function that accepts a 'name' parameter
|
|
74
|
+
to specify which agent to transfer the conversation to.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
handoffs: List of Agent objects that can be transferred to
|
|
78
|
+
"""
|
|
79
|
+
# Collect all agent names for validation
|
|
80
|
+
agent_names = [agent.name for agent in handoffs]
|
|
81
|
+
|
|
82
|
+
def transfer_handler(name: str) -> str:
|
|
83
|
+
"""Handler for transfer_to_agent function."""
|
|
84
|
+
if name in agent_names:
|
|
85
|
+
return f"Transferring to agent: {name}"
|
|
86
|
+
|
|
87
|
+
available_agents = ", ".join(agent_names)
|
|
88
|
+
return f"Agent '{name}' not found. Available agents: {available_agents}"
|
|
89
|
+
|
|
90
|
+
# Add single dynamic tool for all transfers
|
|
91
|
+
self.fc.add_dynamic_tool(
|
|
92
|
+
name="transfer_to_agent",
|
|
93
|
+
description="Transfer conversation to another agent.",
|
|
94
|
+
parameters={
|
|
95
|
+
"name": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "The name of the agent to transfer to",
|
|
98
|
+
"enum": agent_names,
|
|
99
|
+
},
|
|
22
100
|
},
|
|
23
|
-
|
|
101
|
+
required=["name"],
|
|
102
|
+
handler=transfer_handler,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def add_transfer_to_parent_tool(self) -> None:
|
|
106
|
+
"""Add transfer_to_parent function for agents that have a parent.
|
|
107
|
+
|
|
108
|
+
This tool allows the agent to transfer back to its parent when:
|
|
109
|
+
- The current task is completed
|
|
110
|
+
- The agent cannot solve the current problem
|
|
111
|
+
- Escalation to a higher level is needed
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def transfer_to_parent_handler() -> str:
|
|
115
|
+
"""Handler for transfer_to_parent function."""
|
|
116
|
+
if self.parent:
|
|
117
|
+
return f"Transferring back to parent agent: {self.parent.name}"
|
|
118
|
+
return "No parent agent found"
|
|
119
|
+
|
|
120
|
+
# Add dynamic tool for parent transfer
|
|
121
|
+
self.fc.add_dynamic_tool(
|
|
122
|
+
name="transfer_to_parent",
|
|
123
|
+
description="Transfer conversation back to parent agent when current task is completed or cannot be solved by current agent",
|
|
124
|
+
parameters={},
|
|
125
|
+
required=[],
|
|
126
|
+
handler=transfer_to_parent_handler,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def add_handoff(self, agent: "Agent") -> None:
|
|
130
|
+
"""Add a handoff agent after initialization.
|
|
131
|
+
|
|
132
|
+
This method allows adding handoff agents dynamically after the agent
|
|
133
|
+
has been constructed. It properly sets up parent-child relationships
|
|
134
|
+
and updates the transfer tools.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
agent: The agent to add as a handoff target
|
|
138
|
+
"""
|
|
139
|
+
# Add to handoffs list if not already present
|
|
140
|
+
if agent not in self.handoffs:
|
|
141
|
+
self.handoffs.append(agent)
|
|
142
|
+
|
|
143
|
+
# Set parent relationship
|
|
144
|
+
agent.parent = self
|
|
145
|
+
|
|
146
|
+
# Add transfer_to_parent tool to the handoff agent
|
|
147
|
+
agent.add_transfer_to_parent_tool()
|
|
148
|
+
|
|
149
|
+
# Remove existing transfer tool if it exists and recreate with all agents
|
|
150
|
+
try:
|
|
151
|
+
# Try to remove the existing transfer tool
|
|
152
|
+
if hasattr(self.fc, "remove_dynamic_tool"):
|
|
153
|
+
self.fc.remove_dynamic_tool("transfer_to_agent")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
# If removal fails, log and continue anyway
|
|
156
|
+
logger.debug(f"Failed to remove existing transfer tool: {e}")
|
|
157
|
+
|
|
158
|
+
# Regenerate transfer tools to include the new agent
|
|
159
|
+
self._add_transfer_tools(self.handoffs)
|
|
160
|
+
|
|
161
|
+
def prepare_completion_messages(self, messages: RunnerMessages) -> list[dict[str, str]]:
|
|
162
|
+
# Convert from responses format to completions format
|
|
163
|
+
converted_messages = self._convert_responses_to_completions_format(messages)
|
|
164
|
+
|
|
165
|
+
# Prepare instructions with handoff-specific additions
|
|
166
|
+
instructions = self.instructions
|
|
167
|
+
|
|
168
|
+
# Add source instructions if this agent can handoff to others
|
|
169
|
+
if self.handoffs:
|
|
170
|
+
instructions = HANDOFFS_SOURCE_INSTRUCTIONS + "\n\n" + instructions
|
|
171
|
+
|
|
172
|
+
# Add target instructions if this agent can be handed off to (has a parent)
|
|
173
|
+
if self.parent:
|
|
174
|
+
instructions = HANDOFFS_TARGET_INSTRUCTIONS + "\n\n" + instructions
|
|
175
|
+
|
|
176
|
+
return [
|
|
177
|
+
AgentSystemMessage(
|
|
178
|
+
role="system",
|
|
179
|
+
content=f"You are {self.name}. {instructions}",
|
|
180
|
+
).model_dump(),
|
|
181
|
+
*converted_messages,
|
|
24
182
|
]
|
|
25
183
|
|
|
26
|
-
async def
|
|
27
|
-
|
|
28
|
-
|
|
184
|
+
async def completion(self, messages: RunnerMessages, record_to_file: Path | None = None) -> AsyncGenerator[AgentChunk, None]:
|
|
185
|
+
# Apply message transfer callback if provided
|
|
186
|
+
processed_messages = messages
|
|
187
|
+
if self.message_transfer:
|
|
188
|
+
logger.debug(f"Applying message transfer callback for agent {self.name}")
|
|
189
|
+
processed_messages = self.message_transfer(messages)
|
|
190
|
+
|
|
191
|
+
self.message_histories = self.prepare_completion_messages(processed_messages)
|
|
192
|
+
tools = self.fc.get_tools(target="completion")
|
|
29
193
|
resp = await litellm.acompletion(
|
|
30
194
|
model=self.model,
|
|
31
195
|
messages=self.message_histories,
|
|
32
196
|
tools=tools,
|
|
33
|
-
tool_choice="auto",
|
|
197
|
+
tool_choice="auto", # TODO: make this configurable
|
|
34
198
|
stream=True,
|
|
35
199
|
)
|
|
36
|
-
|
|
200
|
+
|
|
201
|
+
# Ensure resp is a CustomStreamWrapper
|
|
202
|
+
if isinstance(resp, CustomStreamWrapper):
|
|
203
|
+
return litellm_stream_handler(resp, record_to=record_to_file)
|
|
204
|
+
msg = "Response is not a CustomStreamWrapper, cannot stream chunks."
|
|
205
|
+
raise TypeError(msg)
|
|
206
|
+
|
|
207
|
+
async def list_require_confirm_tools(self, tool_calls: Sequence[ToolCall] | None) -> Sequence[ToolCall]:
|
|
208
|
+
if not tool_calls:
|
|
209
|
+
return []
|
|
210
|
+
results = []
|
|
211
|
+
for tool_call in tool_calls:
|
|
212
|
+
tool_func = self.fc.function_registry.get(tool_call.function.name)
|
|
213
|
+
if not tool_func:
|
|
214
|
+
logger.warning("Tool function %s not found in registry", tool_call.function.name)
|
|
215
|
+
continue
|
|
216
|
+
tool_meta = self.fc.get_tool_meta(tool_call.function.name)
|
|
217
|
+
if tool_meta["require_confirm"]:
|
|
218
|
+
logger.debug('Tool call "%s" requires confirmation', tool_call.id)
|
|
219
|
+
results.append(tool_call)
|
|
220
|
+
return results
|
|
221
|
+
|
|
222
|
+
async def handle_tool_calls(self, tool_calls: Sequence[ToolCall] | None, context: Any | None = None) -> AsyncGenerator[ToolCallChunk | ToolCallResultChunk, None]: # noqa: ANN401
|
|
223
|
+
if not tool_calls:
|
|
224
|
+
return
|
|
225
|
+
if tool_calls:
|
|
226
|
+
for tool_call in tool_calls:
|
|
227
|
+
tool_func = self.fc.function_registry.get(tool_call.function.name)
|
|
228
|
+
if not tool_func:
|
|
229
|
+
logger.warning("Tool function %s not found in registry", tool_call.function.name)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
for tool_call in tool_calls:
|
|
233
|
+
try:
|
|
234
|
+
yield ToolCallChunk(
|
|
235
|
+
type="tool_call",
|
|
236
|
+
name=tool_call.function.name,
|
|
237
|
+
arguments=tool_call.function.arguments or "",
|
|
238
|
+
)
|
|
239
|
+
content = await self.fc.call_function_async(tool_call.function.name, tool_call.function.arguments or "", context)
|
|
240
|
+
yield ToolCallResultChunk(
|
|
241
|
+
type="tool_call_result",
|
|
242
|
+
tool_call_id=tool_call.id,
|
|
243
|
+
name=tool_call.function.name,
|
|
244
|
+
content=str(content),
|
|
245
|
+
)
|
|
246
|
+
except Exception as e: # noqa: PERF203
|
|
247
|
+
logger.exception("Tool call %s failed", tool_call.id)
|
|
248
|
+
yield ToolCallResultChunk(
|
|
249
|
+
type="tool_call_result",
|
|
250
|
+
tool_call_id=tool_call.id,
|
|
251
|
+
name=tool_call.function.name,
|
|
252
|
+
content=str(e),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _convert_responses_to_completions_format(self, messages: RunnerMessages) -> list[dict]:
|
|
256
|
+
"""Convert messages from responses API format to completions API format."""
|
|
257
|
+
converted_messages = []
|
|
258
|
+
i = 0
|
|
259
|
+
|
|
260
|
+
while i < len(messages):
|
|
261
|
+
message = messages[i]
|
|
262
|
+
message_dict = message.model_dump() if isinstance(message, BaseModel) else message
|
|
263
|
+
|
|
264
|
+
message_type = message_dict.get("type")
|
|
265
|
+
role = message_dict.get("role")
|
|
266
|
+
|
|
267
|
+
if role == "assistant":
|
|
268
|
+
# Look ahead for function_call messages
|
|
269
|
+
tool_calls = []
|
|
270
|
+
j = i + 1
|
|
271
|
+
|
|
272
|
+
while j < len(messages):
|
|
273
|
+
next_message = messages[j]
|
|
274
|
+
next_dict = next_message.model_dump() if isinstance(next_message, BaseModel) else next_message
|
|
275
|
+
|
|
276
|
+
if next_dict.get("type") == "function_call":
|
|
277
|
+
tool_call = {
|
|
278
|
+
"id": next_dict["function_call_id"],
|
|
279
|
+
"type": "function",
|
|
280
|
+
"function": {
|
|
281
|
+
"name": next_dict["name"],
|
|
282
|
+
"arguments": next_dict["arguments"],
|
|
283
|
+
},
|
|
284
|
+
"index": len(tool_calls),
|
|
285
|
+
}
|
|
286
|
+
tool_calls.append(tool_call)
|
|
287
|
+
j += 1
|
|
288
|
+
else:
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
# Create assistant message with tool_calls if any
|
|
292
|
+
assistant_msg = message_dict.copy()
|
|
293
|
+
if tool_calls:
|
|
294
|
+
assistant_msg["tool_calls"] = tool_calls
|
|
295
|
+
|
|
296
|
+
converted_messages.append(assistant_msg)
|
|
297
|
+
i = j # Skip the function_call messages we've processed
|
|
298
|
+
|
|
299
|
+
elif message_type == "function_call_output":
|
|
300
|
+
# Convert to tool message
|
|
301
|
+
converted_messages.append(
|
|
302
|
+
{
|
|
303
|
+
"role": "tool",
|
|
304
|
+
"tool_call_id": message_dict["call_id"],
|
|
305
|
+
"content": message_dict["output"],
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
i += 1
|
|
309
|
+
|
|
310
|
+
elif message_type == "function_call":
|
|
311
|
+
# This should have been processed with the assistant message
|
|
312
|
+
# Skip it if we encounter it standalone
|
|
313
|
+
i += 1
|
|
314
|
+
|
|
315
|
+
else:
|
|
316
|
+
# Regular message (user, system)
|
|
317
|
+
converted_messages.append(message_dict)
|
|
318
|
+
i += 1
|
|
319
|
+
|
|
320
|
+
return converted_messages
|
|
321
|
+
|
|
322
|
+
def set_message_transfer(self, message_transfer: Callable[[RunnerMessages], RunnerMessages] | None) -> None:
|
|
323
|
+
"""Set or update the message transfer callback function.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
message_transfer: A callback function that takes RunnerMessages as input
|
|
327
|
+
and returns RunnerMessages as output. This function will be
|
|
328
|
+
called before making API calls to allow preprocessing of messages.
|
|
329
|
+
"""
|
|
330
|
+
self.message_transfer = message_transfer
|
lite_agent/loggers.py
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Predefined message transfer functions for lite-agent.
|
|
3
|
+
|
|
4
|
+
This module provides common message transfer functions that can be used
|
|
5
|
+
with agents to preprocess messages before sending them to the API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from lite_agent.types import RunnerMessages
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def consolidate_history_transfer(messages: RunnerMessages) -> RunnerMessages:
|
|
12
|
+
"""Consolidate all message history into a single user message with XML format.
|
|
13
|
+
|
|
14
|
+
This message transfer function converts all message history into XML format
|
|
15
|
+
and creates a single user message asking what to do next. This is useful when
|
|
16
|
+
you want to summarize the entire conversation context in a single prompt.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
messages: The original messages to be processed
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
A single user message containing the consolidated history in XML format
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> agent = Agent(
|
|
26
|
+
... model="gpt-4",
|
|
27
|
+
... name="HistoryAgent",
|
|
28
|
+
... instructions="You are a helpful assistant.",
|
|
29
|
+
... message_transfer=consolidate_history_transfer
|
|
30
|
+
... )
|
|
31
|
+
"""
|
|
32
|
+
if not messages:
|
|
33
|
+
return messages
|
|
34
|
+
|
|
35
|
+
# Convert messages to XML format
|
|
36
|
+
xml_content = ["<conversation_history>"]
|
|
37
|
+
|
|
38
|
+
for message in messages:
|
|
39
|
+
xml_content.extend(_process_message_to_xml(message))
|
|
40
|
+
|
|
41
|
+
xml_content.append("</conversation_history>")
|
|
42
|
+
|
|
43
|
+
# Create the consolidated message
|
|
44
|
+
consolidated_content = "以下是目前发生的所有交互:\n\n" + "\n".join(xml_content) + "\n\n接下来该做什么?"
|
|
45
|
+
|
|
46
|
+
# Return a single user message
|
|
47
|
+
return [{"role": "user", "content": consolidated_content}]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _process_message_to_xml(message: dict | object) -> list[str]:
|
|
51
|
+
"""Process a single message and convert it to XML format.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
message: A single message to process
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of XML strings representing the message
|
|
58
|
+
"""
|
|
59
|
+
xml_lines = []
|
|
60
|
+
|
|
61
|
+
if isinstance(message, dict):
|
|
62
|
+
xml_lines.extend(_process_dict_message(message))
|
|
63
|
+
elif hasattr(message, "role"):
|
|
64
|
+
# Handle Pydantic model format messages
|
|
65
|
+
role = getattr(message, "role", "unknown")
|
|
66
|
+
content = getattr(message, "content", "")
|
|
67
|
+
if isinstance(content, str):
|
|
68
|
+
xml_lines.append(f" <message role='{role}'>{content}</message>")
|
|
69
|
+
elif hasattr(message, "type"):
|
|
70
|
+
# Handle function call messages
|
|
71
|
+
xml_lines.extend(_process_function_message(message))
|
|
72
|
+
|
|
73
|
+
return xml_lines
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _process_dict_message(message: dict) -> list[str]:
|
|
77
|
+
"""Process dictionary format message to XML."""
|
|
78
|
+
xml_lines = []
|
|
79
|
+
role = message.get("role", "unknown")
|
|
80
|
+
content = message.get("content", "")
|
|
81
|
+
message_type = message.get("type")
|
|
82
|
+
|
|
83
|
+
if message_type == "function_call":
|
|
84
|
+
name = message.get("name", "unknown")
|
|
85
|
+
arguments = message.get("arguments", "")
|
|
86
|
+
xml_lines.append(f" <function_call name='{name}' arguments='{arguments}' />")
|
|
87
|
+
elif message_type == "function_call_output":
|
|
88
|
+
call_id = message.get("call_id", "unknown")
|
|
89
|
+
output = message.get("output", "")
|
|
90
|
+
xml_lines.append(f" <function_result call_id='{call_id}'>{output}</function_result>")
|
|
91
|
+
elif role in ["user", "assistant", "system"]:
|
|
92
|
+
xml_lines.append(f" <message role='{role}'>{content}</message>")
|
|
93
|
+
|
|
94
|
+
return xml_lines
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _process_function_message(message: dict | object) -> list[str]:
|
|
98
|
+
"""Process function call message to XML."""
|
|
99
|
+
xml_lines = []
|
|
100
|
+
message_type = getattr(message, "type", "unknown")
|
|
101
|
+
|
|
102
|
+
if message_type == "function_call":
|
|
103
|
+
name = getattr(message, "name", "unknown")
|
|
104
|
+
arguments = getattr(message, "arguments", "")
|
|
105
|
+
xml_lines.append(f" <function_call name='{name}' arguments='{arguments}' />")
|
|
106
|
+
elif message_type == "function_call_output":
|
|
107
|
+
call_id = getattr(message, "call_id", "unknown")
|
|
108
|
+
output = getattr(message, "output", "")
|
|
109
|
+
xml_lines.append(f" <function_result call_id='{call_id}'>{output}</function_result>")
|
|
110
|
+
|
|
111
|
+
return xml_lines
|
|
@@ -1,53 +1,61 @@
|
|
|
1
1
|
import litellm
|
|
2
|
-
from
|
|
3
|
-
from litellm.types.utils import ChatCompletionDeltaToolCall, StreamingChoices
|
|
2
|
+
from litellm.types.utils import ChatCompletionDeltaToolCall, ModelResponseStream, StreamingChoices
|
|
4
3
|
|
|
5
|
-
from
|
|
6
|
-
from
|
|
4
|
+
from lite_agent.loggers import logger
|
|
5
|
+
from lite_agent.types import AssistantMessage, ToolCall, ToolCallFunction
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
class StreamChunkProcessor:
|
|
10
9
|
"""Processor for handling streaming responses"""
|
|
11
10
|
|
|
12
|
-
def __init__(self
|
|
13
|
-
self.
|
|
14
|
-
self.current_message: AssistantMessage = None
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._current_message: AssistantMessage | None = None
|
|
15
13
|
|
|
16
|
-
def initialize_message(self, chunk:
|
|
14
|
+
def initialize_message(self, chunk: ModelResponseStream, choice: StreamingChoices) -> None:
|
|
17
15
|
"""Initialize the message object"""
|
|
18
16
|
delta = choice.delta
|
|
19
|
-
|
|
17
|
+
if delta.role != "assistant":
|
|
18
|
+
logger.warning("Skipping chunk with role: %s", delta.role)
|
|
19
|
+
return
|
|
20
|
+
self._current_message = AssistantMessage(
|
|
20
21
|
id=chunk.id,
|
|
21
22
|
index=choice.index,
|
|
22
23
|
role=delta.role,
|
|
23
24
|
content="",
|
|
24
25
|
)
|
|
25
|
-
logger.debug(
|
|
26
|
+
logger.debug('Initialized new message: "%s"', self._current_message.id)
|
|
26
27
|
|
|
27
28
|
def update_content(self, content: str) -> None:
|
|
28
29
|
"""Update message content"""
|
|
29
|
-
if self.
|
|
30
|
-
self.
|
|
30
|
+
if self._current_message and content:
|
|
31
|
+
self._current_message.content += content
|
|
31
32
|
|
|
32
33
|
def _initialize_tool_calls(self, tool_calls: list[litellm.ChatCompletionMessageToolCall]) -> None:
|
|
33
34
|
"""Initialize tool calls"""
|
|
34
|
-
if not self.
|
|
35
|
+
if not self._current_message:
|
|
35
36
|
return
|
|
36
37
|
|
|
37
|
-
self.
|
|
38
|
+
self._current_message.tool_calls = []
|
|
38
39
|
for call in tool_calls:
|
|
39
40
|
logger.debug("Create new tool call: %s", call.id)
|
|
40
41
|
|
|
41
42
|
def _update_tool_calls(self, tool_calls: list[litellm.ChatCompletionMessageToolCall]) -> None:
|
|
42
43
|
"""Update existing tool calls"""
|
|
43
|
-
if not self.
|
|
44
|
+
if not self._current_message:
|
|
44
45
|
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
if not hasattr(self._current_message, "tool_calls"):
|
|
47
|
+
self._current_message.tool_calls = []
|
|
48
|
+
if not self._current_message.tool_calls:
|
|
49
|
+
return
|
|
50
|
+
if not tool_calls:
|
|
51
|
+
return
|
|
52
|
+
for current_call, new_call in zip(self._current_message.tool_calls, tool_calls, strict=False):
|
|
53
|
+
if new_call.function.arguments and current_call.function.arguments:
|
|
48
54
|
current_call.function.arguments += new_call.function.arguments
|
|
49
|
-
if new_call.type:
|
|
55
|
+
if new_call.type and new_call.type == "function":
|
|
50
56
|
current_call.type = new_call.type
|
|
57
|
+
elif new_call.type:
|
|
58
|
+
logger.warning("Unexpected tool call type: %s", new_call.type)
|
|
51
59
|
|
|
52
60
|
def update_tool_calls(self, tool_calls: list[ChatCompletionDeltaToolCall]) -> None:
|
|
53
61
|
"""Handle tool call updates"""
|
|
@@ -55,31 +63,44 @@ class StreamChunkProcessor:
|
|
|
55
63
|
return
|
|
56
64
|
for call in tool_calls:
|
|
57
65
|
if call.id:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
self.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
if call.type == "function":
|
|
67
|
+
new_tool_call = ToolCall(
|
|
68
|
+
id=call.id,
|
|
69
|
+
type=call.type,
|
|
70
|
+
function=ToolCallFunction(
|
|
71
|
+
name=call.function.name or "",
|
|
72
|
+
arguments=call.function.arguments,
|
|
73
|
+
),
|
|
74
|
+
index=call.index,
|
|
75
|
+
)
|
|
76
|
+
if self._current_message is not None:
|
|
77
|
+
if self._current_message.tool_calls is None:
|
|
78
|
+
self._current_message.tool_calls = []
|
|
79
|
+
self._current_message.tool_calls.append(new_tool_call)
|
|
80
|
+
else:
|
|
81
|
+
logger.warning("Unexpected tool call type: %s", call.type)
|
|
82
|
+
elif self._current_message is not None and self._current_message.tool_calls is not None and call.index is not None and 0 <= call.index < len(self._current_message.tool_calls):
|
|
83
|
+
existing_call = self._current_message.tool_calls[call.index]
|
|
72
84
|
if call.function.arguments:
|
|
85
|
+
if existing_call.function.arguments is None:
|
|
86
|
+
existing_call.function.arguments = ""
|
|
73
87
|
existing_call.function.arguments += call.function.arguments
|
|
88
|
+
else:
|
|
89
|
+
logger.warning("Cannot update tool call: current_message or tool_calls is None, or invalid index.")
|
|
74
90
|
|
|
75
|
-
def handle_usage_info(self, chunk:
|
|
91
|
+
def handle_usage_info(self, chunk: ModelResponseStream) -> litellm.Usage | None:
|
|
76
92
|
"""Handle usage info, return whether this chunk should be skipped"""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
return getattr(chunk, "usage", None)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def is_initialized(self) -> bool:
|
|
97
|
+
"""Check if the current message is initialized"""
|
|
98
|
+
return self._current_message is not None
|
|
81
99
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
@property
|
|
101
|
+
def current_message(self) -> AssistantMessage:
|
|
102
|
+
"""Get the current message being processed"""
|
|
103
|
+
if not self._current_message:
|
|
104
|
+
msg = "No current message initialized. Call initialize_message first."
|
|
105
|
+
raise ValueError(msg)
|
|
106
|
+
return self._current_message
|