lite-agent 0.9.0__py3-none-any.whl → 0.11.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/agent.py CHANGED
@@ -6,23 +6,20 @@ from typing import Any, Optional
6
6
  from funcall import Funcall
7
7
  from jinja2 import Environment, FileSystemLoader
8
8
 
9
- from lite_agent.client import BaseLLMClient, LiteLLMClient, ReasoningConfig
9
+ from lite_agent.client import BaseLLMClient, LiteLLMClient
10
10
  from lite_agent.constants import CompletionMode, ToolName
11
11
  from lite_agent.loggers import logger
12
12
  from lite_agent.response_handlers import CompletionResponseHandler, ResponsesAPIHandler
13
13
  from lite_agent.types import (
14
14
  AgentChunk,
15
- AssistantTextContent,
16
- AssistantToolCall,
17
- AssistantToolCallResult,
18
15
  FunctionCallEvent,
19
16
  FunctionCallOutputEvent,
20
17
  RunnerMessages,
21
18
  ToolCall,
22
- message_to_llm_dict,
23
19
  system_message_to_llm_dict,
24
20
  )
25
- from lite_agent.types.messages import NewAssistantMessage, NewSystemMessage, NewUserMessage
21
+ from lite_agent.types.messages import NewSystemMessage
22
+ from lite_agent.utils.message_converter import MessageFormatConverter, ResponsesFormatConverter
26
23
 
27
24
  TEMPLATES_DIR = Path(__file__).parent / "templates"
28
25
  jinja_env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=True)
@@ -43,12 +40,10 @@ class Agent:
43
40
  handoffs: list["Agent"] | None = None,
44
41
  message_transfer: Callable[[RunnerMessages], RunnerMessages] | None = None,
45
42
  completion_condition: str = "stop",
46
- reasoning: ReasoningConfig = None,
47
43
  stop_before_tools: list[str] | list[Callable] | None = None,
48
44
  ) -> None:
49
45
  self.name = name
50
46
  self.instructions = instructions
51
- self.reasoning = reasoning
52
47
  # Convert stop_before_functions to function names
53
48
  if stop_before_tools:
54
49
  self.stop_before_functions = set()
@@ -70,7 +65,6 @@ class Agent:
70
65
  # Otherwise, create a LitellmClient instance
71
66
  self.client = LiteLLMClient(
72
67
  model=model,
73
- reasoning=reasoning,
74
68
  )
75
69
  self.completion_condition = completion_condition
76
70
  self.handoffs = handoffs if handoffs else []
@@ -194,9 +188,8 @@ class Agent:
194
188
  # Regenerate transfer tools to include the new agent
195
189
  self._add_transfer_tools(self.handoffs)
196
190
 
197
- def prepare_completion_messages(self, messages: RunnerMessages) -> list[dict]:
198
- """Prepare messages for completions API (with conversion)."""
199
- converted_messages = self._convert_responses_to_completions_format(messages)
191
+ def _build_instructions(self) -> str:
192
+ """Build complete instructions with templates."""
200
193
  instructions = self.instructions
201
194
  if self.handoffs:
202
195
  instructions = HANDOFFS_SOURCE_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
@@ -204,6 +197,12 @@ class Agent:
204
197
  instructions = HANDOFFS_TARGET_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
205
198
  if self.completion_condition == "call":
206
199
  instructions = WAIT_FOR_USER_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
200
+ return instructions
201
+
202
+ def prepare_completion_messages(self, messages: RunnerMessages) -> list[dict]:
203
+ """Prepare messages for completions API (with conversion)."""
204
+ converted_messages = MessageFormatConverter.to_completion_format(messages)
205
+ instructions = self._build_instructions()
207
206
  return [
208
207
  system_message_to_llm_dict(
209
208
  NewSystemMessage(
@@ -215,96 +214,24 @@ class Agent:
215
214
 
216
215
  def prepare_responses_messages(self, messages: RunnerMessages) -> list[dict[str, Any]]:
217
216
  """Prepare messages for responses API (no conversion, just add system message if needed)."""
218
- instructions = self.instructions
219
- if self.handoffs:
220
- instructions = HANDOFFS_SOURCE_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
221
- if self.parent:
222
- instructions = HANDOFFS_TARGET_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
223
- if self.completion_condition == "call":
224
- instructions = WAIT_FOR_USER_INSTRUCTIONS_TEMPLATE.render(extra_instructions=None) + "\n\n" + instructions
225
- res: list[dict[str, Any]] = [
217
+ instructions = self._build_instructions()
218
+ converted_messages = ResponsesFormatConverter.to_responses_format(messages)
219
+ return [
226
220
  {
227
221
  "role": "system",
228
222
  "content": f"You are {self.name}. {instructions}",
229
223
  },
224
+ *converted_messages,
230
225
  ]
231
- for message in messages:
232
- if isinstance(message, NewAssistantMessage):
233
- for item in message.content:
234
- if isinstance(item, AssistantTextContent):
235
- res.append(
236
- {
237
- "role": "assistant",
238
- "content": item.text,
239
- },
240
- )
241
- elif isinstance(item, AssistantToolCall):
242
- res.append(
243
- {
244
- "type": "function_call",
245
- "call_id": item.call_id,
246
- "name": item.name,
247
- "arguments": item.arguments,
248
- },
249
- )
250
- elif isinstance(item, AssistantToolCallResult):
251
- res.append(
252
- {
253
- "type": "function_call_output",
254
- "call_id": item.call_id,
255
- "output": item.output,
256
- },
257
- )
258
- elif isinstance(message, NewSystemMessage):
259
- res.append(
260
- {
261
- "role": "system",
262
- "content": message.content,
263
- },
264
- )
265
- elif isinstance(message, NewUserMessage):
266
- contents = []
267
- for item in message.content:
268
- match item.type:
269
- case "text":
270
- contents.append(
271
- {
272
- "type": "input_text",
273
- "text": item.text,
274
- },
275
- )
276
- case "image":
277
- contents.append(
278
- {
279
- "type": "input_image",
280
- "image_url": item.image_url,
281
- },
282
- )
283
- case "file":
284
- contents.append(
285
- {
286
- "type": "input_file",
287
- "file_id": item.file_id,
288
- "file_name": item.file_name,
289
- },
290
- )
291
- res.append(
292
- {
293
- "role": message.role,
294
- "content": contents,
295
- },
296
- )
297
- return res
298
226
 
299
227
  async def completion(
300
228
  self,
301
229
  messages: RunnerMessages,
302
230
  record_to_file: Path | None = None,
303
- reasoning: ReasoningConfig = None,
304
231
  *,
305
232
  streaming: bool = True,
306
233
  ) -> AsyncGenerator[AgentChunk, None]:
307
- # Apply message transfer callback if provided - always use legacy format for LLM compatibility
234
+ # Apply message transfer callback if provided
308
235
  processed_messages = messages
309
236
  if self.message_transfer:
310
237
  logger.debug(f"Applying message transfer callback for agent {self.name}")
@@ -318,7 +245,6 @@ class Agent:
318
245
  messages=self.message_histories,
319
246
  tools=tools,
320
247
  tool_choice="auto", # TODO: make this configurable
321
- reasoning=reasoning,
322
248
  streaming=streaming,
323
249
  )
324
250
 
@@ -330,11 +256,10 @@ class Agent:
330
256
  self,
331
257
  messages: RunnerMessages,
332
258
  record_to_file: Path | None = None,
333
- reasoning: ReasoningConfig = None,
334
259
  *,
335
260
  streaming: bool = True,
336
261
  ) -> AsyncGenerator[AgentChunk, None]:
337
- # Apply message transfer callback if provided - always use legacy format for LLM compatibility
262
+ # Apply message transfer callback if provided
338
263
  processed_messages = messages
339
264
  if self.message_transfer:
340
265
  logger.debug(f"Applying message transfer callback for agent {self.name}")
@@ -347,7 +272,6 @@ class Agent:
347
272
  messages=self.message_histories,
348
273
  tools=tools,
349
274
  tool_choice="auto", # TODO: make this configurable
350
- reasoning=reasoning,
351
275
  streaming=streaming,
352
276
  )
353
277
  # Use response handler for unified processing
@@ -416,227 +340,6 @@ class Agent:
416
340
  execution_time_ms=execution_time_ms,
417
341
  )
418
342
 
419
- def _convert_responses_to_completions_format(self, messages: RunnerMessages) -> list[dict]:
420
- """Convert messages from responses API format to completions API format."""
421
- converted_messages = []
422
- i = 0
423
-
424
- while i < len(messages):
425
- message = messages[i]
426
- message_dict = message_to_llm_dict(message) if isinstance(message, (NewUserMessage, NewSystemMessage, NewAssistantMessage)) else message
427
-
428
- message_type = message_dict.get("type")
429
- role = message_dict.get("role")
430
-
431
- if role == "assistant":
432
- # For NewAssistantMessage, extract directly from the message object
433
- tool_calls = []
434
- tool_results = []
435
-
436
- if isinstance(message, NewAssistantMessage):
437
- # Process content directly from NewAssistantMessage
438
- for item in message.content:
439
- if item.type == "tool_call":
440
- tool_call = {
441
- "id": item.call_id,
442
- "type": "function",
443
- "function": {
444
- "name": item.name,
445
- "arguments": item.arguments,
446
- },
447
- "index": len(tool_calls),
448
- }
449
- tool_calls.append(tool_call)
450
- elif item.type == "tool_call_result":
451
- # Collect tool call results to be added as separate tool messages
452
- tool_results.append({
453
- "call_id": item.call_id,
454
- "output": item.output,
455
- })
456
-
457
- # Create assistant message with only text content and tool calls
458
- text_content = " ".join([item.text for item in message.content if item.type == "text"])
459
- message_dict = {
460
- "role": "assistant",
461
- "content": text_content if text_content else None,
462
- }
463
- if tool_calls:
464
- message_dict["tool_calls"] = tool_calls
465
- else:
466
- # Legacy handling for dict messages
467
- content = message_dict.get("content", [])
468
- # Handle both string and array content
469
- if isinstance(content, list):
470
- # Extract tool_calls and tool_call_results from content array and filter out non-text content
471
- filtered_content = []
472
- for item in content:
473
- if isinstance(item, dict):
474
- if item.get("type") == "tool_call":
475
- tool_call = {
476
- "id": item.get("call_id", ""),
477
- "type": "function",
478
- "function": {
479
- "name": item.get("name", ""),
480
- "arguments": item.get("arguments", "{}"),
481
- },
482
- "index": len(tool_calls),
483
- }
484
- tool_calls.append(tool_call)
485
- elif item.get("type") == "tool_call_result":
486
- # Collect tool call results to be added as separate tool messages
487
- tool_results.append({
488
- "call_id": item.get("call_id", ""),
489
- "output": item.get("output", ""),
490
- })
491
- elif item.get("type") == "text":
492
- filtered_content.append(item)
493
-
494
- # Update content to only include text items
495
- if filtered_content:
496
- message_dict = message_dict.copy()
497
- message_dict["content"] = filtered_content
498
- elif tool_calls:
499
- # If we have tool_calls but no text content, set content to None per OpenAI API spec
500
- message_dict = message_dict.copy()
501
- message_dict["content"] = None
502
-
503
- # Look ahead for function_call messages (legacy support)
504
- j = i + 1
505
- while j < len(messages):
506
- next_message = messages[j]
507
- next_dict = message_to_llm_dict(next_message) if isinstance(next_message, (NewUserMessage, NewSystemMessage, NewAssistantMessage)) else next_message
508
-
509
- if next_dict.get("type") == "function_call":
510
- tool_call = {
511
- "id": next_dict["call_id"], # type: ignore
512
- "type": "function",
513
- "function": {
514
- "name": next_dict["name"], # type: ignore
515
- "arguments": next_dict["arguments"], # type: ignore
516
- },
517
- "index": len(tool_calls),
518
- }
519
- tool_calls.append(tool_call)
520
- j += 1
521
- else:
522
- break
523
-
524
- # For legacy dict messages, create assistant message with tool_calls if any
525
- if not isinstance(message, NewAssistantMessage):
526
- assistant_msg = message_dict.copy()
527
- if tool_calls:
528
- assistant_msg["tool_calls"] = tool_calls # type: ignore
529
-
530
- # Convert content format for OpenAI API compatibility
531
- content = assistant_msg.get("content", [])
532
- if isinstance(content, list):
533
- # Extract text content and convert to string using list comprehension
534
- text_parts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
535
- assistant_msg["content"] = " ".join(text_parts) if text_parts else None
536
-
537
- message_dict = assistant_msg
538
-
539
- converted_messages.append(message_dict)
540
-
541
- # Add tool messages for any tool_call_results found in the assistant message
542
- converted_messages.extend([
543
- {
544
- "role": "tool",
545
- "tool_call_id": tool_result["call_id"],
546
- "content": tool_result["output"],
547
- }
548
- for tool_result in tool_results
549
- ])
550
-
551
- i = j # Skip the function_call messages we've processed
552
-
553
- elif message_type == "function_call_output":
554
- # Convert to tool message
555
- converted_messages.append(
556
- {
557
- "role": "tool",
558
- "tool_call_id": message_dict["call_id"], # type: ignore
559
- "content": message_dict["output"], # type: ignore
560
- },
561
- )
562
- i += 1
563
-
564
- elif message_type == "function_call":
565
- # This should have been processed with the assistant message
566
- # Skip it if we encounter it standalone
567
- i += 1
568
-
569
- else:
570
- # Regular message (user, system)
571
- converted_msg = message_dict.copy()
572
-
573
- # Handle new Response API format for user messages
574
- content = message_dict.get("content")
575
- if role == "user" and isinstance(content, list):
576
- converted_msg["content"] = self._convert_user_content_to_completions_format(content) # type: ignore
577
-
578
- converted_messages.append(converted_msg)
579
- i += 1
580
-
581
- return converted_messages
582
-
583
- def _convert_user_content_to_completions_format(self, content: list) -> list:
584
- """Convert user message content from Response API format to Completion API format."""
585
- # Handle the case where content might not actually be a list due to test mocking
586
- if type(content) is not list: # Use type() instead of isinstance() to avoid test mocking issues
587
- return content
588
-
589
- converted_content = []
590
- for item in content:
591
- # Convert Pydantic objects to dict first
592
- if hasattr(item, "model_dump"):
593
- item_dict = item.model_dump()
594
- elif hasattr(item, "dict"): # For older Pydantic versions
595
- item_dict = item.dict()
596
- elif isinstance(item, dict):
597
- item_dict = item
598
- else:
599
- # Handle non-dict items (shouldn't happen, but just in case)
600
- converted_content.append(item)
601
- continue
602
-
603
- item_type = item_dict.get("type")
604
- if item_type in ["input_text", "text"]:
605
- # Convert ResponseInputText or new text format to completion API format
606
- converted_content.append(
607
- {
608
- "type": "text",
609
- "text": item_dict["text"],
610
- },
611
- )
612
- elif item_type in ["input_image", "image"]:
613
- # Convert ResponseInputImage to completion API format
614
- if item_dict.get("file_id"):
615
- msg = "File ID input is not supported for Completion API"
616
- raise ValueError(msg)
617
-
618
- if not item_dict.get("image_url"):
619
- msg = "ResponseInputImage must have either file_id or image_url"
620
- raise ValueError(msg)
621
-
622
- # Build image_url object with detail inside
623
- image_data = {"url": item_dict["image_url"]}
624
- detail = item_dict.get("detail", "auto")
625
- if detail: # Include detail if provided
626
- image_data["detail"] = detail
627
-
628
- converted_content.append(
629
- {
630
- "type": "image_url",
631
- "image_url": image_data,
632
- },
633
- )
634
- else:
635
- # Keep existing format (text, image_url)
636
- converted_content.append(item_dict)
637
-
638
- return converted_content
639
-
640
343
  def set_message_transfer(self, message_transfer: Callable[[RunnerMessages], RunnerMessages] | None) -> None:
641
344
  """Set or update the message transfer callback function.
642
345
 
@@ -532,12 +532,15 @@ def _display_user_message_with_columns(
532
532
  lines = content.split("\n")
533
533
  for i, line in enumerate(lines):
534
534
  if i == 0:
535
- # 第一行显示完整信息
535
+ # 第一行显示 User: 标签
536
536
  table.add_row(
537
537
  f"[dim]{time_str:8}[/dim]",
538
538
  f"[dim]{index_str:4}[/dim]",
539
- f"[blue]User:[/blue] {line}",
539
+ "[blue]User:[/blue]",
540
540
  )
541
+ # 如果有内容,添加内容行
542
+ if line:
543
+ table.add_row("", "", line)
541
544
  else:
542
545
  # 续行只在内容列显示
543
546
  table.add_row("", "", line)
@@ -631,12 +634,15 @@ def _display_assistant_message_with_columns(
631
634
  lines = content.split("\n")
632
635
  for i, line in enumerate(lines):
633
636
  if i == 0:
634
- # 第一行显示完整信息
637
+ # 第一行显示 Assistant: 标签
635
638
  table.add_row(
636
639
  f"[dim]{time_str:8}[/dim]",
637
640
  f"[dim]{index_str:4}[/dim]",
638
- f"[green]Assistant:[/green]{meta_info} {line}",
641
+ f"[green]Assistant:[/green]{meta_info}",
639
642
  )
643
+ # 如果有内容,添加内容行
644
+ if line:
645
+ table.add_row("", "", line)
640
646
  first_row_added = True
641
647
  else:
642
648
  # 续行只在内容列显示
lite_agent/client.py CHANGED
@@ -1,6 +1,6 @@
1
1
  import abc
2
2
  import os
3
- from typing import Any, Literal
3
+ from typing import Any, Literal, NotRequired, TypedDict
4
4
 
5
5
  import litellm
6
6
  from openai.types.chat import ChatCompletionToolParam
@@ -8,12 +8,28 @@ from openai.types.responses import FunctionToolParam
8
8
  from pydantic import BaseModel
9
9
 
10
10
  ReasoningEffort = Literal["minimal", "low", "medium", "high"]
11
- ThinkingConfig = dict[str, Any] | None
11
+
12
+
13
+ class ThinkingConfigDict(TypedDict):
14
+ """Thinking configuration for reasoning models like Claude."""
15
+
16
+ type: Literal["enabled"] # 启用推理
17
+ budget_tokens: NotRequired[int] # 推理令牌预算,可选
18
+
19
+
20
+ class ReasoningEffortDict(TypedDict):
21
+ """Reasoning effort configuration."""
22
+
23
+ effort: ReasoningEffort
24
+
25
+
26
+ ThinkingConfig = ThinkingConfigDict | None
12
27
 
13
28
  # 统一的推理配置类型
14
29
  ReasoningConfig = (
15
- str
16
- | dict[str, Any] # {"type": "enabled", "budget_tokens": 2048} 或其他配置
30
+ ReasoningEffort # "minimal", "low", "medium", "high"
31
+ | ReasoningEffortDict # {"effort": "minimal"}
32
+ | ThinkingConfigDict # {"type": "enabled", "budget_tokens": 2048}
17
33
  | bool # True/False 简单开关
18
34
  | None # 不启用推理
19
35
  )
@@ -36,8 +52,9 @@ def parse_reasoning_config(reasoning: ReasoningConfig) -> tuple[ReasoningEffort
36
52
 
37
53
  Args:
38
54
  reasoning: 统一的推理配置
39
- - str: "minimal", "low", "medium", "high" -> reasoning_effort
40
- - dict: {"type": "enabled", "budget_tokens": N} -> thinking_config
55
+ - ReasoningEffort: "minimal", "low", "medium", "high" -> reasoning_effort
56
+ - ReasoningEffortDict: {"effort": "minimal"} -> reasoning_effort
57
+ - ThinkingConfigDict: {"type": "enabled", "budget_tokens": 2048} -> thinking_config
41
58
  - bool: True -> "medium", False -> None
42
59
  - None: 不启用推理
43
60
 
@@ -46,22 +63,39 @@ def parse_reasoning_config(reasoning: ReasoningConfig) -> tuple[ReasoningEffort
46
63
  """
47
64
  if reasoning is None:
48
65
  return None, None
49
- if isinstance(reasoning, str):
50
- # 字符串类型,使用 reasoning_effort
51
- # 确保字符串是有效的 ReasoningEffort 值
52
- if reasoning in ("minimal", "low", "medium", "high"):
53
- return reasoning, None # type: ignore[return-value]
54
- return None, None
55
- if isinstance(reasoning, dict):
56
- # 字典类型,使用 thinking_config
57
- return None, reasoning
66
+
67
+ if isinstance(reasoning, str) and reasoning in ("minimal", "low", "medium", "high"):
68
+ return reasoning, None # type: ignore[return-value]
69
+
58
70
  if isinstance(reasoning, bool):
59
- # 布尔类型,True 使用默认的 medium,False 不启用
60
- return "medium" if reasoning else None, None
61
- # 其他类型,默认不启用
71
+ return ("medium", None) if reasoning else (None, None)
72
+
73
+ if isinstance(reasoning, dict):
74
+ return _parse_dict_reasoning_config(reasoning)
75
+
76
+ # 其他类型或无效格式,默认不启用
62
77
  return None, None
63
78
 
64
79
 
80
+ def _parse_dict_reasoning_config(reasoning: ReasoningEffortDict | ThinkingConfigDict | dict[str, Any]) -> tuple[ReasoningEffort | None, ThinkingConfig]:
81
+ """解析字典格式的推理配置。"""
82
+ # 检查是否为 {"effort": "value"} 格式 (ReasoningEffortDict)
83
+ if "effort" in reasoning and len(reasoning) == 1:
84
+ effort = reasoning["effort"]
85
+ if isinstance(effort, str) and effort in ("minimal", "low", "medium", "high"):
86
+ return effort, None # type: ignore[return-value]
87
+
88
+ # 检查是否为 ThinkingConfigDict 格式
89
+ if "type" in reasoning and reasoning.get("type") == "enabled":
90
+ # 验证 ThinkingConfigDict 的结构
91
+ valid_keys = {"type", "budget_tokens"}
92
+ if all(key in valid_keys for key in reasoning):
93
+ return None, reasoning # type: ignore[return-value]
94
+
95
+ # 其他未知字典格式,仍尝试作为 thinking_config
96
+ return None, reasoning # type: ignore[return-value]
97
+
98
+
65
99
  class BaseLLMClient(abc.ABC):
66
100
  """Base class for LLM clients."""
67
101
 
@@ -233,8 +267,7 @@ class LiteLLMClient(BaseLLMClient):
233
267
 
234
268
  # Add reasoning parameters if specified
235
269
  if final_reasoning_effort is not None:
236
- response_params["reasoning_effort"] = final_reasoning_effort
270
+ response_params["reasoning"] = {"effort": final_reasoning_effort}
237
271
  if final_thinking_config is not None:
238
272
  response_params["thinking"] = final_thinking_config
239
-
240
273
  return await litellm.aresponses(**response_params) # type: ignore[return-value]