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