lite-agent 0.1.0__py3-none-any.whl → 0.3.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 CHANGED
@@ -0,0 +1,8 @@
1
+ """Lite Agent - A lightweight AI agent framework."""
2
+
3
+ from .agent import Agent
4
+ from .message_transfers import consolidate_history_transfer
5
+ from .rich_helpers import print_chat_history, print_chat_summary
6
+ from .runner import Runner
7
+
8
+ __all__ = ["Agent", "Runner", "consolidate_history_transfer", "print_chat_history", "print_chat_summary"]
lite_agent/agent.py CHANGED
@@ -1,36 +1,411 @@
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
- import litellm
4
5
  from funcall import Funcall
6
+ from jinja2 import Environment, FileSystemLoader
7
+ from litellm import CustomStreamWrapper
8
+ from pydantic import BaseModel
5
9
 
6
- from open_agents.chunk_handler import AgentChunk, chunk_handler
7
- from open_agents.types import RunnerMessages
10
+ from lite_agent.client import BaseLLMClient, LiteLLMClient
11
+ from lite_agent.loggers import logger
12
+ from lite_agent.stream_handlers import litellm_stream_handler
13
+ from lite_agent.types import AgentChunk, AgentSystemMessage, RunnerMessages, ToolCall, ToolCallChunk, ToolCallResultChunk
14
+
15
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
16
+ jinja_env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=True)
17
+
18
+ HANDOFFS_SOURCE_INSTRUCTIONS_TEMPLATE = jinja_env.get_template("handoffs_source_instructions.xml.j2")
19
+ HANDOFFS_TARGET_INSTRUCTIONS_TEMPLATE = jinja_env.get_template("handoffs_target_instructions.xml.j2")
20
+ WAIT_FOR_USER_INSTRUCTIONS_TEMPLATE = jinja_env.get_template("wait_for_user_instructions.xml.j2")
8
21
 
9
22
 
10
23
  class Agent:
11
- def __init__(self, *, model: str, name: str, instructions: str, tools: list[Callable] | None = None) -> None:
24
+ def __init__( # noqa: PLR0913
25
+ self,
26
+ *,
27
+ model: str | BaseLLMClient,
28
+ name: str,
29
+ instructions: str,
30
+ tools: list[Callable] | None = None,
31
+ handoffs: list["Agent"] | None = None,
32
+ message_transfer: Callable[[RunnerMessages], RunnerMessages] | None = None,
33
+ completion_condition: str = "stop",
34
+ ) -> None:
12
35
  self.name = name
13
36
  self.instructions = instructions
37
+ if isinstance(model, BaseLLMClient):
38
+ # If model is a BaseLLMClient instance, use it directly
39
+ self.client = model
40
+ else:
41
+ # Otherwise, create a LitellmClient instance
42
+ self.client = LiteLLMClient(model=model)
43
+ self.completion_condition = completion_condition
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
14
48
  self.fc = Funcall(tools)
15
- self.model = model
16
49
 
17
- def prepare_messages(self, messages: RunnerMessages) -> list[dict]:
18
- return [
19
- {
20
- "role": "system",
21
- "content": f"You are {self.name}. {self.instructions}",
50
+ # Add wait_for_user tool if completion condition is "call"
51
+ if completion_condition == "call":
52
+ self._add_wait_for_user_tool()
53
+
54
+ # Set parent for handoff agents
55
+ if handoffs:
56
+ for handoff_agent in handoffs:
57
+ handoff_agent.parent = self
58
+ self._add_transfer_tools(handoffs)
59
+
60
+ # Add transfer_to_parent tool if this agent has a parent (for cases where parent is set externally)
61
+ if self.parent is not None:
62
+ self.add_transfer_to_parent_tool()
63
+
64
+ @property
65
+ def parent(self) -> Optional["Agent"]:
66
+ return self._parent
67
+
68
+ @parent.setter
69
+ def parent(self, value: Optional["Agent"]) -> None:
70
+ self._parent = value
71
+ if value is not None:
72
+ self.add_transfer_to_parent_tool()
73
+
74
+ def _add_transfer_tools(self, handoffs: list["Agent"]) -> None:
75
+ """Add transfer function for handoff agents using dynamic tools.
76
+
77
+ Creates a single 'transfer_to_agent' function that accepts a 'name' parameter
78
+ to specify which agent to transfer the conversation to.
79
+
80
+ Args:
81
+ handoffs: List of Agent objects that can be transferred to
82
+ """
83
+ # Collect all agent names for validation
84
+ agent_names = [agent.name for agent in handoffs]
85
+
86
+ def transfer_handler(name: str) -> str:
87
+ """Handler for transfer_to_agent function."""
88
+ if name in agent_names:
89
+ return f"Transferring to agent: {name}"
90
+
91
+ available_agents = ", ".join(agent_names)
92
+ return f"Agent '{name}' not found. Available agents: {available_agents}"
93
+
94
+ # Add single dynamic tool for all transfers
95
+ self.fc.add_dynamic_tool(
96
+ name="transfer_to_agent",
97
+ description="Transfer conversation to another agent.",
98
+ parameters={
99
+ "name": {
100
+ "type": "string",
101
+ "description": "The name of the agent to transfer to",
102
+ "enum": agent_names,
103
+ },
22
104
  },
23
- *messages,
105
+ required=["name"],
106
+ handler=transfer_handler,
107
+ )
108
+
109
+ def add_transfer_to_parent_tool(self) -> None:
110
+ """Add transfer_to_parent function for agents that have a parent.
111
+
112
+ This tool allows the agent to transfer back to its parent when:
113
+ - The current task is completed
114
+ - The agent cannot solve the current problem
115
+ - Escalation to a higher level is needed
116
+ """
117
+
118
+ def transfer_to_parent_handler() -> str:
119
+ """Handler for transfer_to_parent function."""
120
+ if self.parent:
121
+ return f"Transferring back to parent agent: {self.parent.name}"
122
+ return "No parent agent found"
123
+
124
+ # Add dynamic tool for parent transfer
125
+ self.fc.add_dynamic_tool(
126
+ name="transfer_to_parent",
127
+ description="Transfer conversation back to parent agent when current task is completed or cannot be solved by current agent",
128
+ parameters={},
129
+ required=[],
130
+ handler=transfer_to_parent_handler,
131
+ )
132
+
133
+ def add_handoff(self, agent: "Agent") -> None:
134
+ """Add a handoff agent after initialization.
135
+
136
+ This method allows adding handoff agents dynamically after the agent
137
+ has been constructed. It properly sets up parent-child relationships
138
+ and updates the transfer tools.
139
+
140
+ Args:
141
+ agent: The agent to add as a handoff target
142
+ """
143
+ # Add to handoffs list if not already present
144
+ if agent not in self.handoffs:
145
+ self.handoffs.append(agent)
146
+
147
+ # Set parent relationship
148
+ agent.parent = self
149
+
150
+ # Add transfer_to_parent tool to the handoff agent
151
+ agent.add_transfer_to_parent_tool()
152
+
153
+ # Remove existing transfer tool if it exists and recreate with all agents
154
+ try:
155
+ # Try to remove the existing transfer tool
156
+ if hasattr(self.fc, "remove_dynamic_tool"):
157
+ self.fc.remove_dynamic_tool("transfer_to_agent")
158
+ except Exception as e:
159
+ # If removal fails, log and continue anyway
160
+ logger.debug(f"Failed to remove existing transfer tool: {e}")
161
+
162
+ # Regenerate transfer tools to include the new agent
163
+ self._add_transfer_tools(self.handoffs)
164
+
165
+ def prepare_completion_messages(self, messages: RunnerMessages) -> list[dict[str, str]]:
166
+ # Convert from responses format to completions format
167
+ converted_messages = self._convert_responses_to_completions_format(messages)
168
+
169
+ # Prepare instructions with handoff-specific additions
170
+ instructions = self.instructions
171
+
172
+ # Add source instructions if this agent can handoff to others
173
+ if self.handoffs:
174
+ instructions = HANDOFFS_SOURCE_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
175
+
176
+ # Add target instructions if this agent can be handed off to (has a parent)
177
+ if self.parent:
178
+ instructions = HANDOFFS_TARGET_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
179
+
180
+ # Add wait_for_user instructions if completion condition is "call"
181
+ if self.completion_condition == "call":
182
+ instructions = WAIT_FOR_USER_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
183
+
184
+ return [
185
+ AgentSystemMessage(
186
+ role="system",
187
+ content=f"You are {self.name}. {instructions}",
188
+ ).model_dump(),
189
+ *converted_messages,
24
190
  ]
25
191
 
26
- async def stream_async(self, messages: RunnerMessages) -> AsyncGenerator[AgentChunk, None]:
27
- self.message_histories = self.prepare_messages(messages)
28
- tools = self.fc.get_tools(target="litellm")
29
- resp = await litellm.acompletion(
30
- model=self.model,
192
+ async def completion(self, messages: RunnerMessages, record_to_file: Path | None = None) -> AsyncGenerator[AgentChunk, None]:
193
+ # Apply message transfer callback if provided
194
+ processed_messages = messages
195
+ if self.message_transfer:
196
+ logger.debug(f"Applying message transfer callback for agent {self.name}")
197
+ processed_messages = self.message_transfer(messages)
198
+
199
+ self.message_histories = self.prepare_completion_messages(processed_messages)
200
+ tools = self.fc.get_tools(target="completion")
201
+ resp = await self.client.completion(
31
202
  messages=self.message_histories,
32
203
  tools=tools,
33
- tool_choice="auto",
34
- stream=True,
204
+ tool_choice="auto", # TODO: make this configurable
205
+ )
206
+
207
+ # Ensure resp is a CustomStreamWrapper
208
+ if isinstance(resp, CustomStreamWrapper):
209
+ return litellm_stream_handler(resp, record_to=record_to_file)
210
+ msg = "Response is not a CustomStreamWrapper, cannot stream chunks."
211
+ raise TypeError(msg)
212
+
213
+ async def list_require_confirm_tools(self, tool_calls: Sequence[ToolCall] | None) -> Sequence[ToolCall]:
214
+ if not tool_calls:
215
+ return []
216
+ results = []
217
+ for tool_call in tool_calls:
218
+ tool_func = self.fc.function_registry.get(tool_call.function.name)
219
+ if not tool_func:
220
+ logger.warning("Tool function %s not found in registry", tool_call.function.name)
221
+ continue
222
+ tool_meta = self.fc.get_tool_meta(tool_call.function.name)
223
+ if tool_meta["require_confirm"]:
224
+ logger.debug('Tool call "%s" requires confirmation', tool_call.id)
225
+ results.append(tool_call)
226
+ return results
227
+
228
+ async def handle_tool_calls(self, tool_calls: Sequence[ToolCall] | None, context: Any | None = None) -> AsyncGenerator[ToolCallChunk | ToolCallResultChunk, None]: # noqa: ANN401
229
+ if not tool_calls:
230
+ return
231
+ if tool_calls:
232
+ for tool_call in tool_calls:
233
+ tool_func = self.fc.function_registry.get(tool_call.function.name)
234
+ if not tool_func:
235
+ logger.warning("Tool function %s not found in registry", tool_call.function.name)
236
+ continue
237
+
238
+ for tool_call in tool_calls:
239
+ try:
240
+ yield ToolCallChunk(
241
+ type="tool_call",
242
+ name=tool_call.function.name,
243
+ arguments=tool_call.function.arguments or "",
244
+ )
245
+ content = await self.fc.call_function_async(tool_call.function.name, tool_call.function.arguments or "", context)
246
+ yield ToolCallResultChunk(
247
+ type="tool_call_result",
248
+ tool_call_id=tool_call.id,
249
+ name=tool_call.function.name,
250
+ content=str(content),
251
+ )
252
+ except Exception as e: # noqa: PERF203
253
+ logger.exception("Tool call %s failed", tool_call.id)
254
+ yield ToolCallResultChunk(
255
+ type="tool_call_result",
256
+ tool_call_id=tool_call.id,
257
+ name=tool_call.function.name,
258
+ content=str(e),
259
+ )
260
+
261
+ def _convert_responses_to_completions_format(self, messages: RunnerMessages) -> list[dict]:
262
+ """Convert messages from responses API format to completions API format."""
263
+ converted_messages = []
264
+ i = 0
265
+
266
+ while i < len(messages):
267
+ message = messages[i]
268
+ message_dict = message.model_dump() if isinstance(message, BaseModel) else message
269
+
270
+ message_type = message_dict.get("type")
271
+ role = message_dict.get("role")
272
+
273
+ if role == "assistant":
274
+ # Look ahead for function_call messages
275
+ tool_calls = []
276
+ j = i + 1
277
+
278
+ while j < len(messages):
279
+ next_message = messages[j]
280
+ next_dict = next_message.model_dump() if isinstance(next_message, BaseModel) else next_message
281
+
282
+ if next_dict.get("type") == "function_call":
283
+ tool_call = {
284
+ "id": next_dict["function_call_id"], # type: ignore
285
+ "type": "function",
286
+ "function": {
287
+ "name": next_dict["name"], # type: ignore
288
+ "arguments": next_dict["arguments"], # type: ignore
289
+ },
290
+ "index": len(tool_calls),
291
+ }
292
+ tool_calls.append(tool_call)
293
+ j += 1
294
+ else:
295
+ break
296
+
297
+ # Create assistant message with tool_calls if any
298
+ assistant_msg = message_dict.copy()
299
+ if tool_calls:
300
+ assistant_msg["tool_calls"] = tool_calls # type: ignore
301
+
302
+ converted_messages.append(assistant_msg)
303
+ i = j # Skip the function_call messages we've processed
304
+
305
+ elif message_type == "function_call_output":
306
+ # Convert to tool message
307
+ converted_messages.append(
308
+ {
309
+ "role": "tool",
310
+ "tool_call_id": message_dict["call_id"], # type: ignore
311
+ "content": message_dict["output"], # type: ignore
312
+ },
313
+ )
314
+ i += 1
315
+
316
+ elif message_type == "function_call":
317
+ # This should have been processed with the assistant message
318
+ # Skip it if we encounter it standalone
319
+ i += 1
320
+
321
+ else:
322
+ # Regular message (user, system)
323
+ converted_msg = message_dict.copy()
324
+
325
+ # Handle new Response API format for user messages
326
+ content = message_dict.get("content")
327
+ if role == "user" and isinstance(content, list):
328
+ converted_msg["content"] = self._convert_user_content_to_completions_format(content) # type: ignore
329
+
330
+ converted_messages.append(converted_msg)
331
+ i += 1
332
+
333
+ return converted_messages
334
+
335
+ def _convert_user_content_to_completions_format(self, content: list) -> list:
336
+ """Convert user message content from Response API format to Completion API format."""
337
+ # Handle the case where content might not actually be a list due to test mocking
338
+ if type(content) is not list: # Use type() instead of isinstance() to avoid test mocking issues
339
+ return content
340
+
341
+ converted_content = []
342
+ for item in content:
343
+ if isinstance(item, dict):
344
+ item_type = item.get("type")
345
+ if item_type == "input_text":
346
+ # Convert ResponseInputText to completion API format
347
+ converted_content.append(
348
+ {
349
+ "type": "text",
350
+ "text": item["text"],
351
+ },
352
+ )
353
+ elif item_type == "input_image":
354
+ # Convert ResponseInputImage to completion API format
355
+ if item.get("file_id"):
356
+ msg = "File ID input is not supported for Completion API. Please use image_url instead of file_id for image input."
357
+ raise ValueError(msg)
358
+
359
+ if not item.get("image_url"):
360
+ msg = "ResponseInputImage must have either file_id or image_url, but image_url is required for Completion API."
361
+ raise ValueError(msg)
362
+
363
+ # Build image_url object with detail inside
364
+ image_data = {"url": item["image_url"]}
365
+ detail = item.get("detail", "auto")
366
+ if detail: # Include detail if provided
367
+ image_data["detail"] = detail
368
+
369
+ converted_content.append(
370
+ {
371
+ "type": "image_url",
372
+ "image_url": image_data,
373
+ },
374
+ )
375
+ else:
376
+ # Keep existing format (text, image_url)
377
+ converted_content.append(item)
378
+ else:
379
+ # Handle non-dict items (shouldn't happen, but just in case)
380
+ converted_content.append(item)
381
+
382
+ return converted_content
383
+
384
+ def set_message_transfer(self, message_transfer: Callable[[RunnerMessages], RunnerMessages] | None) -> None:
385
+ """Set or update the message transfer callback function.
386
+
387
+ Args:
388
+ message_transfer: A callback function that takes RunnerMessages as input
389
+ and returns RunnerMessages as output. This function will be
390
+ called before making API calls to allow preprocessing of messages.
391
+ """
392
+ self.message_transfer = message_transfer
393
+
394
+ def _add_wait_for_user_tool(self) -> None:
395
+ """Add wait_for_user tool for agents with completion_condition='call'.
396
+
397
+ This tool allows the agent to signal when it has completed its task.
398
+ """
399
+
400
+ def wait_for_user_handler() -> str:
401
+ """Handler for wait_for_user function."""
402
+ return "Waiting for user input."
403
+
404
+ # Add dynamic tool for task completion
405
+ self.fc.add_dynamic_tool(
406
+ name="wait_for_user",
407
+ description="Call this function when you have completed your assigned task or need more information from the user.",
408
+ parameters={},
409
+ required=[],
410
+ handler=wait_for_user_handler,
35
411
  )
36
- return chunk_handler(resp, self.fc)
lite_agent/client.py ADDED
@@ -0,0 +1,34 @@
1
+ import abc
2
+ from typing import Any
3
+
4
+ import litellm
5
+ from openai.types.chat import ChatCompletionToolParam
6
+
7
+
8
+ class BaseLLMClient(abc.ABC):
9
+ """Base class for LLM clients."""
10
+
11
+ def __init__(self, *, model: str, api_key: str | None = None, api_base: str | None = None, api_version: str | None = None):
12
+ self.model = model
13
+ self.api_key = api_key
14
+ self.api_base = api_base
15
+ self.api_version = api_version
16
+
17
+ @abc.abstractmethod
18
+ async def completion(self, messages: list[Any], tools: list[ChatCompletionToolParam] | None = None, tool_choice: str = "auto") -> Any: # noqa: ANN401
19
+ """Perform a completion request to the LLM."""
20
+
21
+
22
+ class LiteLLMClient(BaseLLMClient):
23
+ async def completion(self, messages: list[Any], tools: list[ChatCompletionToolParam] | None = None, tool_choice: str = "auto") -> Any: # noqa: ANN401
24
+ """Perform a completion request to the Litellm API."""
25
+ return await litellm.acompletion(
26
+ model=self.model,
27
+ messages=messages,
28
+ tools=tools,
29
+ tool_choice=tool_choice,
30
+ api_version=self.api_version,
31
+ api_key=self.api_key,
32
+ api_base=self.api_base,
33
+ stream=True,
34
+ )
lite_agent/loggers.py CHANGED
@@ -1,3 +1,3 @@
1
1
  import logging
2
2
 
3
- logger = logging.getLogger("easy_agent")
3
+ logger = logging.getLogger("lite_agent")
@@ -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,3 +1,3 @@
1
- from open_agents.processors.stream_chunk_processor import StreamChunkProcessor
1
+ from lite_agent.processors.stream_chunk_processor import StreamChunkProcessor
2
2
 
3
3
  __all__ = ["StreamChunkProcessor"]