lite-agent 0.5.0__py3-none-any.whl → 0.8.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/runner.py CHANGED
@@ -1,59 +1,45 @@
1
1
  import json
2
+ import warnings
2
3
  from collections.abc import AsyncGenerator, Sequence
3
4
  from datetime import datetime, timedelta, timezone
4
5
  from os import PathLike
5
6
  from pathlib import Path
6
- from typing import Any, Literal
7
+ from typing import Any, Literal, cast
7
8
 
8
9
  from lite_agent.agent import Agent
10
+ from lite_agent.constants import CompletionMode, StreamIncludes, ToolName
9
11
  from lite_agent.loggers import logger
10
12
  from lite_agent.types import (
11
13
  AgentChunk,
12
14
  AgentChunkType,
13
- AssistantMessageContent,
14
15
  AssistantMessageMeta,
15
16
  AssistantTextContent,
16
17
  AssistantToolCall,
17
18
  AssistantToolCallResult,
19
+ FlexibleInputMessage,
18
20
  FlexibleRunnerMessage,
19
- MessageDict,
20
21
  MessageUsage,
21
22
  NewAssistantMessage,
22
23
  NewMessage,
23
24
  NewSystemMessage,
24
- # New structured message types
25
25
  NewUserMessage,
26
26
  ToolCall,
27
27
  ToolCallFunction,
28
- UserImageContent,
29
28
  UserInput,
30
- UserMessageContent,
31
29
  UserTextContent,
32
30
  )
33
- from lite_agent.types.events import AssistantMessageEvent
34
-
35
- DEFAULT_INCLUDES: tuple[AgentChunkType, ...] = (
36
- "completion_raw",
37
- "usage",
38
- "function_call",
39
- "function_call_output",
40
- "content_delta",
41
- "function_call_delta",
42
- "assistant_message",
43
- )
31
+ from lite_agent.types.events import AssistantMessageEvent, FunctionCallOutputEvent
32
+ from lite_agent.utils.message_builder import MessageBuilder
44
33
 
45
34
 
46
35
  class Runner:
47
- def __init__(self, agent: Agent, api: Literal["completion", "responses"] = "responses") -> None:
36
+ def __init__(self, agent: Agent, api: Literal["completion", "responses"] = "responses", *, streaming: bool = True) -> None:
48
37
  self.agent = agent
49
- self.messages: list[NewMessage] = []
38
+ self.messages: list[FlexibleRunnerMessage] = []
50
39
  self.api = api
40
+ self.streaming = streaming
51
41
  self._current_assistant_message: NewAssistantMessage | None = None
52
-
53
- @property
54
- def legacy_messages(self) -> list[NewMessage]:
55
- """Return messages in new format (legacy_messages is now an alias)."""
56
- return self.messages
42
+ self.usage = MessageUsage(input_tokens=0, output_tokens=0, total_tokens=0)
57
43
 
58
44
  def _start_assistant_message(self, content: str = "", meta: AssistantMessageMeta | None = None) -> None:
59
45
  """Start a new assistant message."""
@@ -66,7 +52,10 @@ class Runner:
66
52
  """Ensure current assistant message exists and return it."""
67
53
  if self._current_assistant_message is None:
68
54
  self._start_assistant_message()
69
- return self._current_assistant_message # type: ignore[return-value]
55
+ if self._current_assistant_message is None:
56
+ msg = "Failed to create current assistant message"
57
+ raise RuntimeError(msg)
58
+ return self._current_assistant_message
70
59
 
71
60
  def _add_to_current_assistant_message(self, content_item: AssistantTextContent | AssistantToolCall | AssistantToolCallResult) -> None:
72
61
  """Add content to the current assistant message."""
@@ -99,15 +88,20 @@ class Runner:
99
88
 
100
89
  if self.messages and isinstance(self.messages[-1], NewAssistantMessage):
101
90
  # Add to existing assistant message
102
- self.messages[-1].content.append(result)
91
+ last_message = cast("NewAssistantMessage", self.messages[-1])
92
+ last_message.content.append(result)
103
93
  else:
104
94
  # Create new assistant message with just the tool result
105
95
  assistant_message = NewAssistantMessage(content=[result])
106
96
  self.messages.append(assistant_message)
107
97
 
98
+ # For completion API compatibility, create a separate assistant message
99
+ # Note: In the new architecture, we store everything as NewMessage format
100
+ # The conversion to completion format happens when sending to LLM
101
+
108
102
  def _normalize_includes(self, includes: Sequence[AgentChunkType] | None) -> Sequence[AgentChunkType]:
109
103
  """Normalize includes parameter to default if None."""
110
- return includes if includes is not None else DEFAULT_INCLUDES
104
+ return includes if includes is not None else StreamIncludes.DEFAULT_INCLUDES
111
105
 
112
106
  def _normalize_record_path(self, record_to: PathLike | str | None) -> Path | None:
113
107
  """Normalize record_to parameter to Path object if provided."""
@@ -119,34 +113,68 @@ class Runner:
119
113
  return
120
114
 
121
115
  # Check for transfer_to_agent calls first
122
- transfer_calls = [tc for tc in tool_calls if tc.function.name == "transfer_to_agent"]
116
+ transfer_calls = [tc for tc in tool_calls if tc.function.name == ToolName.TRANSFER_TO_AGENT]
123
117
  if transfer_calls:
124
118
  # Handle all transfer calls but only execute the first one
125
119
  for i, tool_call in enumerate(transfer_calls):
126
120
  if i == 0:
127
121
  # Execute the first transfer
128
- await self._handle_agent_transfer(tool_call)
122
+ call_id, output = await self._handle_agent_transfer(tool_call)
123
+ # Generate function_call_output event if in includes
124
+ if "function_call_output" in includes:
125
+ yield FunctionCallOutputEvent(
126
+ tool_call_id=call_id,
127
+ name=tool_call.function.name,
128
+ content=output,
129
+ execution_time_ms=0, # Transfer operations are typically fast
130
+ )
129
131
  else:
130
132
  # Add response for additional transfer calls without executing them
133
+ output = "Transfer already executed by previous call"
131
134
  self._add_tool_call_result(
132
135
  call_id=tool_call.id,
133
- output="Transfer already executed by previous call",
136
+ output=output,
134
137
  )
138
+ # Generate function_call_output event if in includes
139
+ if "function_call_output" in includes:
140
+ yield FunctionCallOutputEvent(
141
+ tool_call_id=tool_call.id,
142
+ name=tool_call.function.name,
143
+ content=output,
144
+ execution_time_ms=0,
145
+ )
135
146
  return # Stop processing other tool calls after transfer
136
147
 
137
- return_parent_calls = [tc for tc in tool_calls if tc.function.name == "transfer_to_parent"]
148
+ return_parent_calls = [tc for tc in tool_calls if tc.function.name == ToolName.TRANSFER_TO_PARENT]
138
149
  if return_parent_calls:
139
150
  # Handle multiple transfer_to_parent calls (only execute the first one)
140
151
  for i, tool_call in enumerate(return_parent_calls):
141
152
  if i == 0:
142
153
  # Execute the first transfer
143
- await self._handle_parent_transfer(tool_call)
154
+ call_id, output = await self._handle_parent_transfer(tool_call)
155
+ # Generate function_call_output event if in includes
156
+ if "function_call_output" in includes:
157
+ yield FunctionCallOutputEvent(
158
+ tool_call_id=call_id,
159
+ name=tool_call.function.name,
160
+ content=output,
161
+ execution_time_ms=0, # Transfer operations are typically fast
162
+ )
144
163
  else:
145
164
  # Add response for additional transfer calls without executing them
165
+ output = "Transfer already executed by previous call"
146
166
  self._add_tool_call_result(
147
167
  call_id=tool_call.id,
148
- output="Transfer already executed by previous call",
168
+ output=output,
149
169
  )
170
+ # Generate function_call_output event if in includes
171
+ if "function_call_output" in includes:
172
+ yield FunctionCallOutputEvent(
173
+ tool_call_id=tool_call.id,
174
+ name=tool_call.function.name,
175
+ content=output,
176
+ execution_time_ms=0,
177
+ )
150
178
  return # Stop processing other tool calls after transfer
151
179
 
152
180
  async for tool_call_chunk in self.agent.handle_tool_calls(tool_calls, context=context):
@@ -162,23 +190,46 @@ class Runner:
162
190
  output=tool_call_chunk.content,
163
191
  execution_time_ms=tool_call_chunk.execution_time_ms,
164
192
  )
165
- self.messages[-1].content.append(tool_result)
193
+ last_message = cast("NewAssistantMessage", self.messages[-1])
194
+ last_message.content.append(tool_result)
195
+
196
+ # Note: For completion API compatibility, the conversion happens when sending to LLM
166
197
 
167
198
  async def _collect_all_chunks(self, stream: AsyncGenerator[AgentChunk, None]) -> list[AgentChunk]:
168
199
  """Collect all chunks from an async generator into a list."""
169
200
  return [chunk async for chunk in stream]
170
201
 
171
- def run( # noqa: PLR0913
202
+ def run(
172
203
  self,
173
- user_input: UserInput,
204
+ user_input: UserInput | None = None,
174
205
  max_steps: int = 20,
175
206
  includes: Sequence[AgentChunkType] | None = None,
176
207
  context: "Any | None" = None, # noqa: ANN401
177
208
  record_to: PathLike | str | None = None,
178
209
  agent_kwargs: dict[str, Any] | None = None,
179
210
  ) -> AsyncGenerator[AgentChunk, None]:
180
- """Run the agent and return a RunResponse object that can be asynchronously iterated for each chunk."""
211
+ """Run the agent and return a RunResponse object that can be asynchronously iterated for each chunk.
212
+
213
+ If user_input is None, the method will continue execution from the current state,
214
+ equivalent to calling the continue methods.
215
+ """
216
+ logger.debug(f"Runner.run called with streaming={self.streaming}, api={self.api}")
181
217
  includes = self._normalize_includes(includes)
218
+
219
+ # If no user input provided, use continue logic
220
+ if user_input is None:
221
+ logger.debug("No user input provided, using continue logic")
222
+ return self._run_continue_stream(max_steps, includes, self._normalize_record_path(record_to), context)
223
+
224
+ # Cancel any pending tool calls before processing new user input
225
+ # and yield cancellation events if they should be included
226
+ cancellation_events = self._cancel_pending_tool_calls()
227
+
228
+ # We need to handle this differently since run() is not async
229
+ # Store cancellation events to be yielded by _run
230
+ self._pending_cancellation_events = cancellation_events
231
+
232
+ # Process user input
182
233
  match user_input:
183
234
  case str():
184
235
  self.messages.append(NewUserMessage(content=[UserTextContent(text=user_input)]))
@@ -189,6 +240,7 @@ class Runner:
189
240
  case _:
190
241
  # Handle single message (BaseModel, TypedDict, or dict)
191
242
  self.append_message(user_input) # type: ignore[arg-type]
243
+ logger.debug("Messages prepared, calling _run")
192
244
  return self._run(max_steps, includes, self._normalize_record_path(record_to), context=context, agent_kwargs=agent_kwargs)
193
245
 
194
246
  async def _run(
@@ -201,21 +253,31 @@ class Runner:
201
253
  ) -> AsyncGenerator[AgentChunk, None]:
202
254
  """Run the agent and return a RunResponse object that can be asynchronously iterated for each chunk."""
203
255
  logger.debug(f"Running agent with messages: {self.messages}")
256
+
257
+ # First, yield any pending cancellation events
258
+ if hasattr(self, "_pending_cancellation_events"):
259
+ for cancellation_event in self._pending_cancellation_events:
260
+ if "function_call_output" in includes:
261
+ yield cancellation_event
262
+ # Clear the pending events after yielding
263
+ delattr(self, "_pending_cancellation_events")
264
+
204
265
  steps = 0
205
266
  finish_reason = None
206
267
 
207
268
  # Determine completion condition based on agent configuration
208
- completion_condition = getattr(self.agent, "completion_condition", "stop")
269
+ completion_condition = getattr(self.agent, "completion_condition", CompletionMode.STOP)
209
270
 
210
271
  def is_finish() -> bool:
211
- if completion_condition == "call":
272
+ if completion_condition == CompletionMode.CALL:
212
273
  # Check if wait_for_user was called in the last assistant message
213
274
  if self.messages and isinstance(self.messages[-1], NewAssistantMessage):
214
- for content_item in self.messages[-1].content:
215
- if content_item.type == "tool_call_result" and self._get_tool_call_name_by_id(content_item.call_id) == "wait_for_user":
275
+ last_message = self.messages[-1]
276
+ for content_item in last_message.content:
277
+ if isinstance(content_item, AssistantToolCallResult) and self._get_tool_call_name_by_id(content_item.call_id) == ToolName.WAIT_FOR_USER:
216
278
  return True
217
279
  return False
218
- return finish_reason == "stop"
280
+ return finish_reason == CompletionMode.STOP
219
281
 
220
282
  while not is_finish() and steps < max_steps:
221
283
  logger.debug(f"Step {steps}: finish_reason={finish_reason}, is_finish()={is_finish()}")
@@ -226,43 +288,52 @@ class Runner:
226
288
  if agent_kwargs:
227
289
  reasoning = agent_kwargs.get("reasoning")
228
290
 
291
+ logger.debug(f"Using API: {self.api}, streaming: {self.streaming}")
229
292
  match self.api:
230
293
  case "completion":
294
+ logger.debug("Calling agent.completion")
231
295
  resp = await self.agent.completion(
232
296
  self.messages,
233
297
  record_to_file=record_to,
234
298
  reasoning=reasoning,
299
+ streaming=self.streaming,
235
300
  )
236
301
  case "responses":
302
+ logger.debug("Calling agent.responses")
237
303
  resp = await self.agent.responses(
238
304
  self.messages,
239
305
  record_to_file=record_to,
240
306
  reasoning=reasoning,
307
+ streaming=self.streaming,
241
308
  )
242
309
  case _:
243
310
  msg = f"Unknown API type: {self.api}"
244
311
  raise ValueError(msg)
312
+ logger.debug(f"Received response from agent: {type(resp)}")
245
313
  async for chunk in resp:
246
314
  match chunk.type:
247
315
  case "assistant_message":
248
316
  # Start or update assistant message in new format
249
- meta = AssistantMessageMeta(
250
- sent_at=chunk.message.meta.sent_at,
251
- latency_ms=getattr(chunk.message.meta, "latency_ms", None),
252
- total_time_ms=getattr(chunk.message.meta, "output_time_ms", None),
253
- )
254
317
  # If we already have a current assistant message, just update its metadata
255
318
  if self._current_assistant_message is not None:
256
- self._current_assistant_message.meta = meta
319
+ # Preserve all existing metadata and only update specific fields
320
+ original_meta = self._current_assistant_message.meta
321
+ original_meta.sent_at = chunk.message.meta.sent_at
322
+ if hasattr(chunk.message.meta, "latency_ms"):
323
+ original_meta.latency_ms = chunk.message.meta.latency_ms
324
+ if hasattr(chunk.message.meta, "output_time_ms"):
325
+ original_meta.total_time_ms = chunk.message.meta.output_time_ms
326
+ # Preserve other metadata fields like model, usage, etc.
327
+ for attr in ["model", "usage", "input_tokens", "output_tokens"]:
328
+ if hasattr(chunk.message.meta, attr):
329
+ setattr(original_meta, attr, getattr(chunk.message.meta, attr))
257
330
  else:
258
- # Extract text content from the new message format
259
- text_content = ""
260
- if chunk.message.content:
261
- for item in chunk.message.content:
262
- if hasattr(item, "type") and item.type == "text":
263
- text_content = item.text
264
- break
265
- self._start_assistant_message(text_content, meta)
331
+ # For non-streaming mode, directly use the complete message from the response handler
332
+ self._current_assistant_message = chunk.message
333
+
334
+ # If model is None, try to get it from agent client
335
+ if self._current_assistant_message is not None and self._current_assistant_message.meta.model is None and hasattr(self.agent.client, "model"):
336
+ self._current_assistant_message.meta.model = self.agent.client.model
266
337
  # Only yield assistant_message chunk if it's in includes and has content
267
338
  if chunk.type in includes and self._current_assistant_message is not None:
268
339
  # Create a new chunk with the current assistant message content
@@ -289,28 +360,45 @@ class Runner:
289
360
  if chunk.type in includes:
290
361
  yield chunk
291
362
  case "usage":
292
- # Update the last assistant message with usage data and output_time_ms
363
+ # Update the current or last assistant message with usage data and output_time_ms
293
364
  usage_time = datetime.now(timezone.utc)
294
- for i in range(len(self.messages) - 1, -1, -1):
295
- current_message = self.messages[i]
296
- if isinstance(current_message, NewAssistantMessage):
297
- # Update usage information
298
- if current_message.meta.usage is None:
299
- current_message.meta.usage = MessageUsage()
300
- current_message.meta.usage.input_tokens = chunk.usage.input_tokens
301
- current_message.meta.usage.output_tokens = chunk.usage.output_tokens
302
- current_message.meta.usage.total_tokens = (chunk.usage.input_tokens or 0) + (chunk.usage.output_tokens or 0)
303
-
304
- # Calculate output_time_ms if latency_ms is available
305
- if current_message.meta.latency_ms is not None:
306
- # We need to calculate from first output to usage time
307
- # We'll calculate: usage_time - (sent_at - latency_ms)
308
- # This gives us the time from first output to usage completion
309
- # sent_at is when the message was completed, so sent_at - latency_ms approximates first output time
310
- first_output_time_approx = current_message.meta.sent_at - timedelta(milliseconds=current_message.meta.latency_ms)
311
- output_time_ms = int((usage_time - first_output_time_approx).total_seconds() * 1000)
312
- current_message.meta.total_time_ms = max(0, output_time_ms)
313
- break
365
+
366
+ # Always accumulate usage in runner first
367
+ self.usage.input_tokens = (self.usage.input_tokens or 0) + (chunk.usage.input_tokens or 0)
368
+ self.usage.output_tokens = (self.usage.output_tokens or 0) + (chunk.usage.output_tokens or 0)
369
+ self.usage.total_tokens = (self.usage.total_tokens or 0) + (chunk.usage.input_tokens or 0) + (chunk.usage.output_tokens or 0)
370
+
371
+ # Try to find the assistant message to update
372
+ target_message = None
373
+
374
+ # First check if we have a current assistant message
375
+ if self._current_assistant_message is not None:
376
+ target_message = self._current_assistant_message
377
+ else:
378
+ # Otherwise, look for the last assistant message in the list
379
+ for i in range(len(self.messages) - 1, -1, -1):
380
+ current_message = self.messages[i]
381
+ if isinstance(current_message, NewAssistantMessage):
382
+ target_message = current_message
383
+ break
384
+
385
+ # Update the target message with usage information
386
+ if target_message is not None:
387
+ if target_message.meta.usage is None:
388
+ target_message.meta.usage = MessageUsage()
389
+ target_message.meta.usage.input_tokens = chunk.usage.input_tokens
390
+ target_message.meta.usage.output_tokens = chunk.usage.output_tokens
391
+ target_message.meta.usage.total_tokens = (chunk.usage.input_tokens or 0) + (chunk.usage.output_tokens or 0)
392
+
393
+ # Calculate output_time_ms if latency_ms is available
394
+ if target_message.meta.latency_ms is not None:
395
+ # We need to calculate from first output to usage time
396
+ # We'll calculate: usage_time - (sent_at - latency_ms)
397
+ # This gives us the time from first output to usage completion
398
+ # sent_at is when the message was completed, so sent_at - latency_ms approximates first output time
399
+ first_output_time_approx = target_message.meta.sent_at - timedelta(milliseconds=target_message.meta.latency_ms)
400
+ output_time_ms = int((usage_time - first_output_time_approx).total_seconds() * 1000)
401
+ target_message.meta.total_time_ms = max(0, output_time_ms)
314
402
  # Always yield usage chunk if it's in includes
315
403
  if chunk.type in includes:
316
404
  yield chunk
@@ -333,7 +421,7 @@ class Runner:
333
421
  yield tool_chunk
334
422
  finish_reason = "tool_calls"
335
423
  else:
336
- finish_reason = "stop"
424
+ finish_reason = CompletionMode.STOP
337
425
  steps += 1
338
426
 
339
427
  async def has_require_confirm_tools(self):
@@ -350,6 +438,12 @@ class Runner:
350
438
  includes: list[AgentChunkType] | None = None,
351
439
  record_to: PathLike | str | None = None,
352
440
  ) -> list[AgentChunk]:
441
+ """Deprecated: Use run_until_complete(None) instead."""
442
+ warnings.warn(
443
+ "run_continue_until_complete is deprecated. Use run_until_complete(None) instead.",
444
+ DeprecationWarning,
445
+ stacklevel=2,
446
+ )
353
447
  resp = self.run_continue_stream(max_steps, includes, record_to=record_to)
354
448
  return await self._collect_all_chunks(resp)
355
449
 
@@ -360,6 +454,12 @@ class Runner:
360
454
  record_to: PathLike | str | None = None,
361
455
  context: "Any | None" = None, # noqa: ANN401
362
456
  ) -> AsyncGenerator[AgentChunk, None]:
457
+ """Deprecated: Use run(None) instead."""
458
+ warnings.warn(
459
+ "run_continue_stream is deprecated. Use run(None) instead.",
460
+ DeprecationWarning,
461
+ stacklevel=2,
462
+ )
363
463
  return self._run_continue_stream(max_steps, includes, record_to=record_to, context=context)
364
464
 
365
465
  async def _run_continue_stream(
@@ -394,7 +494,7 @@ class Runner:
394
494
 
395
495
  async def run_until_complete(
396
496
  self,
397
- user_input: UserInput,
497
+ user_input: UserInput | None = None,
398
498
  max_steps: int = 20,
399
499
  includes: list[AgentChunkType] | None = None,
400
500
  record_to: PathLike | str | None = None,
@@ -412,11 +512,12 @@ class Runner:
412
512
  tool_results = set()
413
513
  tool_call_names = {}
414
514
 
415
- for content_item in self.messages[-1].content:
416
- if content_item.type == "tool_call":
515
+ last_message = self.messages[-1]
516
+ for content_item in last_message.content:
517
+ if isinstance(content_item, AssistantToolCall):
417
518
  tool_calls[content_item.call_id] = content_item
418
519
  tool_call_names[content_item.call_id] = content_item.name
419
- elif content_item.type == "tool_call_result":
520
+ elif isinstance(content_item, AssistantToolCallResult):
420
521
  tool_results.add(content_item.call_id)
421
522
 
422
523
  # Return pending tool calls and tool call names map
@@ -433,6 +534,38 @@ class Runner:
433
534
  _, tool_call_names = self._analyze_last_assistant_message()
434
535
  return tool_call_names.get(call_id)
435
536
 
537
+ def _cancel_pending_tool_calls(self) -> list[FunctionCallOutputEvent]:
538
+ """Cancel all pending tool calls by adding cancellation results.
539
+
540
+ Returns:
541
+ List of FunctionCallOutputEvent for each cancelled tool call
542
+ """
543
+ pending_tool_calls = self._find_pending_tool_calls()
544
+ if not pending_tool_calls:
545
+ return []
546
+
547
+ logger.debug(f"Cancelling {len(pending_tool_calls)} pending tool calls due to new user input")
548
+
549
+ cancellation_events = []
550
+ for tool_call in pending_tool_calls:
551
+ output = "Operation cancelled by user - new input provided"
552
+ self._add_tool_call_result(
553
+ call_id=tool_call.call_id,
554
+ output=output,
555
+ execution_time_ms=0,
556
+ )
557
+
558
+ # Create cancellation event
559
+ cancellation_event = FunctionCallOutputEvent(
560
+ tool_call_id=tool_call.call_id,
561
+ name=tool_call.name,
562
+ content=output,
563
+ execution_time_ms=0,
564
+ )
565
+ cancellation_events.append(cancellation_event)
566
+
567
+ return cancellation_events
568
+
436
569
  def _convert_tool_calls_to_tool_calls(self, tool_calls: list[AssistantToolCall]) -> list[ToolCall]:
437
570
  """Convert AssistantToolCall objects to ToolCall objects for compatibility."""
438
571
  return [
@@ -448,7 +581,7 @@ class Runner:
448
581
  for i, tc in enumerate(tool_calls)
449
582
  ]
450
583
 
451
- def set_chat_history(self, messages: Sequence[FlexibleRunnerMessage], root_agent: Agent | None = None) -> None:
584
+ def set_chat_history(self, messages: Sequence[FlexibleInputMessage], root_agent: Agent | None = None) -> None:
452
585
  """Set the entire chat history and track the current agent based on function calls.
453
586
 
454
587
  This method analyzes the message history to determine which agent should be active
@@ -465,17 +598,54 @@ class Runner:
465
598
  current_agent = root_agent if root_agent is not None else self.agent
466
599
 
467
600
  # Add each message and track agent transfers
468
- for message in messages:
469
- self.append_message(message)
470
- current_agent = self._track_agent_transfer_in_message(message, current_agent)
601
+ for input_message in messages:
602
+ # Store length before adding to get the added message
603
+ prev_length = len(self.messages)
604
+ self.append_message(input_message)
605
+
606
+ # Track transfers using the converted message (now in self.messages)
607
+ if len(self.messages) > prev_length:
608
+ converted_message = self.messages[-1] # Get the last added message
609
+ current_agent = self._track_agent_transfer_in_message(converted_message, current_agent)
471
610
 
472
611
  # Set the current agent based on the tracked transfers
473
612
  self.agent = current_agent
474
613
  logger.info(f"Chat history set with {len(self.messages)} messages. Current agent: {self.agent.name}")
475
614
 
476
- def get_messages_dict(self) -> list[dict[str, Any]]:
615
+ def get_messages(self) -> list[NewMessage]:
616
+ """Get the messages as NewMessage objects.
617
+
618
+ Only returns NewMessage objects, filtering out any dict or other legacy formats.
619
+ """
620
+ return [msg for msg in self.messages if isinstance(msg, NewMessage)]
621
+
622
+ def get_dict_messages(self) -> list[dict[str, Any]]:
477
623
  """Get the messages in JSONL format."""
478
- return [msg.model_dump(mode="json") for msg in self.messages]
624
+ result = []
625
+ for msg in self.messages:
626
+ if hasattr(msg, "model_dump"):
627
+ result.append(msg.model_dump(mode="json"))
628
+ elif isinstance(msg, dict):
629
+ result.append(msg)
630
+ else:
631
+ # Fallback for any other message types
632
+ result.append(dict(msg))
633
+ return result
634
+
635
+ def add_user_message(self, text: str) -> None:
636
+ """Convenience method to add a user text message."""
637
+ message = NewUserMessage(content=[UserTextContent(text=text)])
638
+ self.append_message(message)
639
+
640
+ def add_assistant_message(self, text: str) -> None:
641
+ """Convenience method to add an assistant text message."""
642
+ message = NewAssistantMessage(content=[AssistantTextContent(text=text)])
643
+ self.append_message(message)
644
+
645
+ def add_system_message(self, content: str) -> None:
646
+ """Convenience method to add a system message."""
647
+ message = NewSystemMessage(content=content)
648
+ self.append_message(message)
479
649
 
480
650
  def _track_agent_transfer_in_message(self, message: FlexibleRunnerMessage, current_agent: Agent) -> Agent:
481
651
  """Track agent transfers in a single message.
@@ -487,8 +657,6 @@ class Runner:
487
657
  Returns:
488
658
  The agent that should be active after processing this message
489
659
  """
490
- if isinstance(message, dict):
491
- return self._track_transfer_from_dict_message(message, current_agent)
492
660
  if isinstance(message, NewAssistantMessage):
493
661
  return self._track_transfer_from_new_assistant_message(message, current_agent)
494
662
 
@@ -498,28 +666,13 @@ class Runner:
498
666
  """Track transfers from NewAssistantMessage objects."""
499
667
  for content_item in message.content:
500
668
  if content_item.type == "tool_call":
501
- if content_item.name == "transfer_to_agent":
669
+ if content_item.name == ToolName.TRANSFER_TO_AGENT:
502
670
  arguments = content_item.arguments if isinstance(content_item.arguments, str) else str(content_item.arguments)
503
671
  return self._handle_transfer_to_agent_tracking(arguments, current_agent)
504
- if content_item.name == "transfer_to_parent":
672
+ if content_item.name == ToolName.TRANSFER_TO_PARENT:
505
673
  return self._handle_transfer_to_parent_tracking(current_agent)
506
674
  return current_agent
507
675
 
508
- def _track_transfer_from_dict_message(self, message: dict[str, Any] | MessageDict, current_agent: Agent) -> Agent:
509
- """Track transfers from dictionary-format messages."""
510
- message_type = message.get("type")
511
- if message_type != "function_call":
512
- return current_agent
513
-
514
- function_name = message.get("name", "")
515
- if function_name == "transfer_to_agent":
516
- return self._handle_transfer_to_agent_tracking(message.get("arguments", ""), current_agent)
517
-
518
- if function_name == "transfer_to_parent":
519
- return self._handle_transfer_to_parent_tracking(current_agent)
520
-
521
- return current_agent
522
-
523
676
  def _handle_transfer_to_agent_tracking(self, arguments: str | dict, current_agent: Agent) -> Agent:
524
677
  """Handle transfer_to_agent function call tracking."""
525
678
  try:
@@ -575,145 +728,39 @@ class Runner:
575
728
 
576
729
  return None
577
730
 
578
- def append_message(self, message: FlexibleRunnerMessage) -> None:
731
+ def append_message(self, message: FlexibleInputMessage) -> None:
732
+ """Append a message to the conversation history.
733
+
734
+ Accepts both NewMessage format and dict format (which will be converted internally).
735
+ """
579
736
  if isinstance(message, NewMessage):
580
- # Already in new format
581
737
  self.messages.append(message)
582
738
  elif isinstance(message, dict):
583
- # Handle different message types from dict
584
- message_type = message.get("type")
585
- role = message.get("role")
586
-
739
+ # Convert dict to NewMessage using MessageBuilder
740
+ role = message.get("role", "").lower()
587
741
  if role == "user":
588
- content = message.get("content", "")
589
- if isinstance(content, str):
590
- user_message = NewUserMessage(content=[UserTextContent(text=content)])
591
- elif isinstance(content, list):
592
- # Handle complex content array
593
- user_content_items: list[UserMessageContent] = []
594
- for item in content:
595
- if isinstance(item, dict):
596
- item_type = item.get("type")
597
- if item_type in {"input_text", "text"}:
598
- user_content_items.append(UserTextContent(text=item.get("text", "")))
599
- elif item_type in {"input_image", "image_url"}:
600
- if item_type == "image_url":
601
- # Handle completion API format
602
- image_url = item.get("image_url", {})
603
- url = image_url.get("url", "") if isinstance(image_url, dict) else str(image_url)
604
- user_content_items.append(UserImageContent(image_url=url))
605
- else:
606
- # Handle response API format
607
- user_content_items.append(
608
- UserImageContent(
609
- image_url=item.get("image_url"),
610
- file_id=item.get("file_id"),
611
- detail=item.get("detail", "auto"),
612
- ),
613
- )
614
- elif hasattr(item, "type"):
615
- # Handle Pydantic models
616
- if item.type == "input_text":
617
- user_content_items.append(UserTextContent(text=item.text))
618
- elif item.type == "input_image":
619
- user_content_items.append(
620
- UserImageContent(
621
- image_url=getattr(item, "image_url", None),
622
- file_id=getattr(item, "file_id", None),
623
- detail=getattr(item, "detail", "auto"),
624
- ),
625
- )
626
- else:
627
- # Fallback: convert to text
628
- user_content_items.append(UserTextContent(text=str(item)))
629
-
630
- user_message = NewUserMessage(content=user_content_items)
631
- else:
632
- # Handle non-string, non-list content
633
- user_message = NewUserMessage(content=[UserTextContent(text=str(content))])
634
- self.messages.append(user_message)
635
- elif role == "system":
636
- content = message.get("content", "")
637
- system_message = NewSystemMessage(content=str(content))
638
- self.messages.append(system_message)
742
+ converted_message = MessageBuilder.build_user_message_from_dict(message)
639
743
  elif role == "assistant":
640
- content = message.get("content", "")
641
- assistant_content_items: list[AssistantMessageContent] = [AssistantTextContent(text=str(content))] if content else []
642
-
643
- # Handle tool calls if present
644
- if "tool_calls" in message:
645
- for tool_call in message.get("tool_calls", []):
646
- try:
647
- arguments = json.loads(tool_call["function"]["arguments"]) if isinstance(tool_call["function"]["arguments"], str) else tool_call["function"]["arguments"]
648
- except (json.JSONDecodeError, TypeError):
649
- arguments = tool_call["function"]["arguments"]
650
-
651
- assistant_content_items.append(
652
- AssistantToolCall(
653
- call_id=tool_call["id"],
654
- name=tool_call["function"]["name"],
655
- arguments=arguments,
656
- ),
657
- )
658
-
659
- assistant_message = NewAssistantMessage(content=assistant_content_items)
660
- self.messages.append(assistant_message)
661
- elif message_type == "function_call":
662
- # Handle function_call directly like AgentFunctionToolCallMessage
663
- # Type guard: ensure we have the right message type
664
- if "call_id" in message and "name" in message and "arguments" in message:
665
- function_call_msg = message # Type should be FunctionCallDict now
666
- if self.messages and isinstance(self.messages[-1], NewAssistantMessage):
667
- tool_call = AssistantToolCall(
668
- call_id=function_call_msg["call_id"], # type: ignore
669
- name=function_call_msg["name"], # type: ignore
670
- arguments=function_call_msg["arguments"], # type: ignore
671
- )
672
- self.messages[-1].content.append(tool_call)
673
- else:
674
- assistant_message = NewAssistantMessage(
675
- content=[
676
- AssistantToolCall(
677
- call_id=function_call_msg["call_id"], # type: ignore
678
- name=function_call_msg["name"], # type: ignore
679
- arguments=function_call_msg["arguments"], # type: ignore
680
- ),
681
- ],
682
- )
683
- self.messages.append(assistant_message)
684
- elif message_type == "function_call_output":
685
- # Handle function_call_output directly like AgentFunctionCallOutput
686
- # Type guard: ensure we have the right message type
687
- if "call_id" in message and "output" in message:
688
- function_output_msg = message # Type should be FunctionCallOutputDict now
689
- if self.messages and isinstance(self.messages[-1], NewAssistantMessage):
690
- tool_result = AssistantToolCallResult(
691
- call_id=function_output_msg["call_id"], # type: ignore
692
- output=function_output_msg["output"], # type: ignore
693
- )
694
- self.messages[-1].content.append(tool_result)
695
- else:
696
- assistant_message = NewAssistantMessage(
697
- content=[
698
- AssistantToolCallResult(
699
- call_id=function_output_msg["call_id"], # type: ignore
700
- output=function_output_msg["output"], # type: ignore
701
- ),
702
- ],
703
- )
704
- self.messages.append(assistant_message)
744
+ converted_message = MessageBuilder.build_assistant_message_from_dict(message)
745
+ elif role == "system":
746
+ converted_message = MessageBuilder.build_system_message_from_dict(message)
705
747
  else:
706
- msg = "Message must have a 'role' or 'type' field."
748
+ msg = f"Unsupported message role: {role}. Must be 'user', 'assistant', or 'system'."
707
749
  raise ValueError(msg)
750
+
751
+ self.messages.append(converted_message)
708
752
  else:
709
- msg = f"Unsupported message type: {type(message)}"
753
+ msg = f"Unsupported message type: {type(message)}. Supports NewMessage types and dict."
710
754
  raise TypeError(msg)
711
755
 
712
- async def _handle_agent_transfer(self, tool_call: ToolCall) -> None:
756
+ async def _handle_agent_transfer(self, tool_call: ToolCall) -> tuple[str, str]:
713
757
  """Handle agent transfer when transfer_to_agent tool is called.
714
758
 
715
759
  Args:
716
760
  tool_call: The transfer_to_agent tool call
761
+
762
+ Returns:
763
+ Tuple of (call_id, output) for the tool call result
717
764
  """
718
765
 
719
766
  # Parse the arguments to get the target agent name
@@ -722,31 +769,34 @@ class Runner:
722
769
  target_agent_name = arguments.get("name")
723
770
  except (json.JSONDecodeError, KeyError):
724
771
  logger.error("Failed to parse transfer_to_agent arguments: %s", tool_call.function.arguments)
772
+ output = "Failed to parse transfer arguments"
725
773
  # Add error result to messages
726
774
  self._add_tool_call_result(
727
775
  call_id=tool_call.id,
728
- output="Failed to parse transfer arguments",
776
+ output=output,
729
777
  )
730
- return
778
+ return tool_call.id, output
731
779
 
732
780
  if not target_agent_name:
733
781
  logger.error("No target agent name provided in transfer_to_agent call")
782
+ output = "No target agent name provided"
734
783
  # Add error result to messages
735
784
  self._add_tool_call_result(
736
785
  call_id=tool_call.id,
737
- output="No target agent name provided",
786
+ output=output,
738
787
  )
739
- return
788
+ return tool_call.id, output
740
789
 
741
790
  # Find the target agent in handoffs
742
791
  if not self.agent.handoffs:
743
792
  logger.error("Current agent has no handoffs configured")
793
+ output = "Current agent has no handoffs configured"
744
794
  # Add error result to messages
745
795
  self._add_tool_call_result(
746
796
  call_id=tool_call.id,
747
- output="Current agent has no handoffs configured",
797
+ output=output,
748
798
  )
749
- return
799
+ return tool_call.id, output
750
800
 
751
801
  target_agent = None
752
802
  for agent in self.agent.handoffs:
@@ -756,12 +806,13 @@ class Runner:
756
806
 
757
807
  if not target_agent:
758
808
  logger.error("Target agent '%s' not found in handoffs", target_agent_name)
809
+ output = f"Target agent '{target_agent_name}' not found in handoffs"
759
810
  # Add error result to messages
760
811
  self._add_tool_call_result(
761
812
  call_id=tool_call.id,
762
- output=f"Target agent '{target_agent_name}' not found in handoffs",
813
+ output=output,
763
814
  )
764
- return
815
+ return tool_call.id, output
765
816
 
766
817
  # Execute the transfer tool call to get the result
767
818
  try:
@@ -770,40 +821,49 @@ class Runner:
770
821
  tool_call.function.arguments or "",
771
822
  )
772
823
 
824
+ output = str(result)
773
825
  # Add the tool call result to messages
774
826
  self._add_tool_call_result(
775
827
  call_id=tool_call.id,
776
- output=str(result),
828
+ output=output,
777
829
  )
778
830
 
779
831
  # Switch to the target agent
780
832
  logger.info("Transferring conversation from %s to %s", self.agent.name, target_agent_name)
781
833
  self.agent = target_agent
782
834
 
835
+ return tool_call.id, output
836
+
783
837
  except Exception as e:
784
838
  logger.exception("Failed to execute transfer_to_agent tool call")
839
+ output = f"Transfer failed: {e!s}"
785
840
  # Add error result to messages
786
841
  self._add_tool_call_result(
787
842
  call_id=tool_call.id,
788
- output=f"Transfer failed: {e!s}",
843
+ output=output,
789
844
  )
845
+ return tool_call.id, output
790
846
 
791
- async def _handle_parent_transfer(self, tool_call: ToolCall) -> None:
847
+ async def _handle_parent_transfer(self, tool_call: ToolCall) -> tuple[str, str]:
792
848
  """Handle parent transfer when transfer_to_parent tool is called.
793
849
 
794
850
  Args:
795
851
  tool_call: The transfer_to_parent tool call
852
+
853
+ Returns:
854
+ Tuple of (call_id, output) for the tool call result
796
855
  """
797
856
 
798
857
  # Check if current agent has a parent
799
858
  if not self.agent.parent:
800
859
  logger.error("Current agent has no parent to transfer back to.")
860
+ output = "Current agent has no parent to transfer back to"
801
861
  # Add error result to messages
802
862
  self._add_tool_call_result(
803
863
  call_id=tool_call.id,
804
- output="Current agent has no parent to transfer back to",
864
+ output=output,
805
865
  )
806
- return
866
+ return tool_call.id, output
807
867
 
808
868
  # Execute the transfer tool call to get the result
809
869
  try:
@@ -812,20 +872,25 @@ class Runner:
812
872
  tool_call.function.arguments or "",
813
873
  )
814
874
 
875
+ output = str(result)
815
876
  # Add the tool call result to messages
816
877
  self._add_tool_call_result(
817
878
  call_id=tool_call.id,
818
- output=str(result),
879
+ output=output,
819
880
  )
820
881
 
821
882
  # Switch to the parent agent
822
883
  logger.info("Transferring conversation from %s back to parent %s", self.agent.name, self.agent.parent.name)
823
884
  self.agent = self.agent.parent
824
885
 
886
+ return tool_call.id, output
887
+
825
888
  except Exception as e:
826
889
  logger.exception("Failed to execute transfer_to_parent tool call")
890
+ output = f"Transfer to parent failed: {e!s}"
827
891
  # Add error result to messages
828
892
  self._add_tool_call_result(
829
893
  call_id=tool_call.id,
830
- output=f"Transfer to parent failed: {e!s}",
894
+ output=output,
831
895
  )
896
+ return tool_call.id, output