agent-framework-devui 1.0.0b251007__py3-none-any.whl → 1.0.0b251028__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 agent-framework-devui might be problematic. Click here for more details.

@@ -4,19 +4,37 @@
4
4
 
5
5
  import json
6
6
  import logging
7
+ import time
7
8
  import uuid
9
+ from collections import OrderedDict
8
10
  from collections.abc import Sequence
9
11
  from datetime import datetime
10
12
  from typing import Any, Union
13
+ from uuid import uuid4
14
+
15
+ from openai.types.responses import (
16
+ Response,
17
+ ResponseContentPartAddedEvent,
18
+ ResponseCreatedEvent,
19
+ ResponseError,
20
+ ResponseFailedEvent,
21
+ ResponseInProgressEvent,
22
+ )
11
23
 
12
24
  from .models import (
13
25
  AgentFrameworkRequest,
26
+ CustomResponseOutputItemAddedEvent,
27
+ CustomResponseOutputItemDoneEvent,
28
+ ExecutorActionItem,
14
29
  InputTokensDetails,
15
30
  OpenAIResponse,
16
31
  OutputTokensDetails,
32
+ ResponseCompletedEvent,
17
33
  ResponseErrorEvent,
18
34
  ResponseFunctionCallArgumentsDeltaEvent,
19
35
  ResponseFunctionResultComplete,
36
+ ResponseFunctionToolCall,
37
+ ResponseOutputItemAddedEvent,
20
38
  ResponseOutputMessage,
21
39
  ResponseOutputText,
22
40
  ResponseReasoningTextDeltaEvent,
@@ -24,7 +42,6 @@ from .models import (
24
42
  ResponseTextDeltaEvent,
25
43
  ResponseTraceEventComplete,
26
44
  ResponseUsage,
27
- ResponseUsageEventComplete,
28
45
  ResponseWorkflowEventComplete,
29
46
  )
30
47
 
@@ -34,19 +51,76 @@ logger = logging.getLogger(__name__)
34
51
  EventType = Union[
35
52
  ResponseStreamEvent,
36
53
  ResponseWorkflowEventComplete,
37
- ResponseFunctionResultComplete,
54
+ ResponseOutputItemAddedEvent,
38
55
  ResponseTraceEventComplete,
39
- ResponseUsageEventComplete,
40
56
  ]
41
57
 
42
58
 
59
+ def _serialize_content_recursive(value: Any) -> Any:
60
+ """Recursively serialize Agent Framework Content objects to JSON-compatible values.
61
+
62
+ This handles nested Content objects (like TextContent inside FunctionResultContent.result)
63
+ that can't be directly serialized by json.dumps().
64
+
65
+ Args:
66
+ value: Value to serialize (can be Content object, dict, list, primitive, etc.)
67
+
68
+ Returns:
69
+ JSON-serializable version with all Content objects converted to dicts/primitives
70
+ """
71
+ # Handle None and basic JSON-serializable types
72
+ if value is None or isinstance(value, (str, int, float, bool)):
73
+ return value
74
+
75
+ # Check if it's a SerializationMixin (includes all Content types)
76
+ # Content objects have to_dict() method
77
+ if hasattr(value, "to_dict") and callable(getattr(value, "to_dict", None)):
78
+ try:
79
+ return value.to_dict()
80
+ except Exception as e:
81
+ # If to_dict() fails, fall through to other methods
82
+ logger.debug(f"Failed to serialize with to_dict(): {e}")
83
+
84
+ # Handle dictionaries - recursively process values
85
+ if isinstance(value, dict):
86
+ return {key: _serialize_content_recursive(val) for key, val in value.items()}
87
+
88
+ # Handle lists and tuples - recursively process elements
89
+ if isinstance(value, (list, tuple)):
90
+ serialized = [_serialize_content_recursive(item) for item in value]
91
+ # For single-item lists containing text Content, extract just the text
92
+ # This handles the MCP case where result = [TextContent(text="Hello")]
93
+ # and we want output = "Hello" not output = '[{"type": "text", "text": "Hello"}]'
94
+ if len(serialized) == 1 and isinstance(serialized[0], dict) and serialized[0].get("type") == "text":
95
+ return serialized[0].get("text", "")
96
+ return serialized
97
+
98
+ # For other objects with model_dump(), try that
99
+ if hasattr(value, "model_dump") and callable(getattr(value, "model_dump", None)):
100
+ try:
101
+ return value.model_dump()
102
+ except Exception as e:
103
+ logger.debug(f"Failed to serialize with model_dump(): {e}")
104
+
105
+ # Return as-is and let json.dumps handle it (may raise TypeError for non-serializable types)
106
+ return value
107
+
108
+
43
109
  class MessageMapper:
44
110
  """Maps Agent Framework messages/responses to OpenAI format."""
45
111
 
46
- def __init__(self) -> None:
47
- """Initialize Agent Framework message mapper."""
112
+ def __init__(self, max_contexts: int = 1000) -> None:
113
+ """Initialize Agent Framework message mapper.
114
+
115
+ Args:
116
+ max_contexts: Maximum number of contexts to keep in memory (default: 1000)
117
+ """
48
118
  self.sequence_counter = 0
49
- self._conversion_contexts: dict[int, dict[str, Any]] = {}
119
+ self._conversion_contexts: OrderedDict[int, dict[str, Any]] = OrderedDict()
120
+ self._max_contexts = max_contexts
121
+
122
+ # Track usage per request for final Response.usage (OpenAI standard)
123
+ self._usage_accumulator: dict[str, dict[str, int]] = {}
50
124
 
51
125
  # Register content type mappers for all 12 Agent Framework content types
52
126
  self.content_mappers = {
@@ -93,9 +167,15 @@ class MessageMapper:
93
167
  )
94
168
  ]
95
169
 
170
+ # Handle Agent lifecycle events first
171
+ from .models._openai_custom import AgentCompletedEvent, AgentFailedEvent, AgentStartedEvent
172
+
173
+ if isinstance(raw_event, (AgentStartedEvent, AgentCompletedEvent, AgentFailedEvent)):
174
+ return await self._convert_agent_lifecycle_event(raw_event, context)
175
+
96
176
  # Import Agent Framework types for proper isinstance checks
97
177
  try:
98
- from agent_framework import AgentRunResponseUpdate, WorkflowEvent
178
+ from agent_framework import AgentRunResponse, AgentRunResponseUpdate, WorkflowEvent
99
179
  from agent_framework._workflows._events import AgentRunUpdateEvent
100
180
 
101
181
  # Handle AgentRunUpdateEvent - workflow event wrapping AgentRunResponseUpdate
@@ -107,6 +187,10 @@ class MessageMapper:
107
187
  # If no data, treat as generic workflow event
108
188
  return await self._convert_workflow_event(raw_event, context)
109
189
 
190
+ # Handle complete agent response (AgentRunResponse) - for non-streaming agent execution
191
+ if isinstance(raw_event, AgentRunResponse):
192
+ return await self._convert_agent_response(raw_event, context)
193
+
110
194
  # Handle agent updates (AgentRunResponseUpdate) - for direct agent execution
111
195
  if isinstance(raw_event, AgentRunResponseUpdate):
112
196
  return await self._convert_agent_update(raw_event, context)
@@ -159,17 +243,31 @@ class MessageMapper:
159
243
  status="completed",
160
244
  )
161
245
 
162
- # Create usage object
163
- input_token_count = len(str(request.input)) // 4 if request.input else 0
164
- output_token_count = len(full_content) // 4
165
-
166
- usage = ResponseUsage(
167
- input_tokens=input_token_count,
168
- output_tokens=output_token_count,
169
- total_tokens=input_token_count + output_token_count,
170
- input_tokens_details=InputTokensDetails(cached_tokens=0),
171
- output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
172
- )
246
+ # Get usage from accumulator (OpenAI standard)
247
+ request_id = str(id(request))
248
+ usage_data = self._usage_accumulator.get(request_id)
249
+
250
+ if usage_data:
251
+ usage = ResponseUsage(
252
+ input_tokens=usage_data["input_tokens"],
253
+ output_tokens=usage_data["output_tokens"],
254
+ total_tokens=usage_data["total_tokens"],
255
+ input_tokens_details=InputTokensDetails(cached_tokens=0),
256
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
257
+ )
258
+ # Cleanup accumulator
259
+ del self._usage_accumulator[request_id]
260
+ else:
261
+ # Fallback: estimate if no usage was tracked
262
+ input_token_count = len(str(request.input)) // 4 if request.input else 0
263
+ output_token_count = len(full_content) // 4
264
+ usage = ResponseUsage(
265
+ input_tokens=input_token_count,
266
+ output_tokens=output_token_count,
267
+ total_tokens=input_token_count + output_token_count,
268
+ input_tokens_details=InputTokensDetails(cached_tokens=0),
269
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
270
+ )
173
271
 
174
272
  return OpenAIResponse(
175
273
  id=f"resp_{uuid.uuid4().hex[:12]}",
@@ -186,10 +284,18 @@ class MessageMapper:
186
284
  except Exception as e:
187
285
  logger.exception(f"Error aggregating response: {e}")
188
286
  return await self._create_error_response(str(e), request)
287
+ finally:
288
+ # Cleanup: Remove context after aggregation to prevent memory leak
289
+ # This handles the common case where streaming completes successfully
290
+ request_key = id(request)
291
+ if self._conversion_contexts.pop(request_key, None):
292
+ logger.debug(f"Cleaned up context for request {request_key} after aggregation")
189
293
 
190
294
  def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, Any]:
191
295
  """Get or create conversion context for this request.
192
296
 
297
+ Uses LRU eviction when max_contexts is reached to prevent unbounded memory growth.
298
+
193
299
  Args:
194
300
  request: Request to get context for
195
301
 
@@ -197,13 +303,27 @@ class MessageMapper:
197
303
  Conversion context dictionary
198
304
  """
199
305
  request_key = id(request)
306
+
200
307
  if request_key not in self._conversion_contexts:
308
+ # Evict oldest context if at capacity (LRU eviction)
309
+ if len(self._conversion_contexts) >= self._max_contexts:
310
+ evicted_key, _ = self._conversion_contexts.popitem(last=False)
311
+ logger.debug(f"Evicted oldest context (key={evicted_key}) - at max capacity ({self._max_contexts})")
312
+
201
313
  self._conversion_contexts[request_key] = {
202
314
  "sequence_counter": 0,
203
315
  "item_id": f"msg_{uuid.uuid4().hex[:8]}",
204
316
  "content_index": 0,
205
317
  "output_index": 0,
318
+ "request_id": str(request_key), # For usage accumulation
319
+ "request": request, # Store the request for model name access
320
+ # Track active function calls: {call_id: {name, item_id, args_chunks}}
321
+ "active_function_calls": {},
206
322
  }
323
+ else:
324
+ # Move to end (mark as recently used for LRU)
325
+ self._conversion_contexts.move_to_end(request_key)
326
+
207
327
  return self._conversion_contexts[request_key]
208
328
 
209
329
  def _next_sequence(self, context: dict[str, Any]) -> int:
@@ -219,7 +339,7 @@ class MessageMapper:
219
339
  return int(context["sequence_counter"])
220
340
 
221
341
  async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> Sequence[Any]:
222
- """Convert AgentRunResponseUpdate to OpenAI events using comprehensive content mapping.
342
+ """Convert agent text updates to proper content part events.
223
343
 
224
344
  Args:
225
345
  update: Agent run response update
@@ -235,20 +355,73 @@ class MessageMapper:
235
355
  if not hasattr(update, "contents") or not update.contents:
236
356
  return events
237
357
 
358
+ # Check if we're streaming text content
359
+ has_text_content = any(content.__class__.__name__ == "TextContent" for content in update.contents)
360
+
361
+ # If we have text content and haven't created a message yet, create one
362
+ if has_text_content and "current_message_id" not in context:
363
+ message_id = f"msg_{uuid4().hex[:8]}"
364
+ context["current_message_id"] = message_id
365
+ context["output_index"] = context.get("output_index", -1) + 1
366
+
367
+ # Add message output item
368
+ events.append(
369
+ ResponseOutputItemAddedEvent(
370
+ type="response.output_item.added",
371
+ output_index=context["output_index"],
372
+ sequence_number=self._next_sequence(context),
373
+ item=ResponseOutputMessage(
374
+ type="message", id=message_id, role="assistant", content=[], status="in_progress"
375
+ ),
376
+ )
377
+ )
378
+
379
+ # Add content part for text
380
+ context["content_index"] = 0
381
+ events.append(
382
+ ResponseContentPartAddedEvent(
383
+ type="response.content_part.added",
384
+ output_index=context["output_index"],
385
+ content_index=context["content_index"],
386
+ item_id=message_id,
387
+ sequence_number=self._next_sequence(context),
388
+ part=ResponseOutputText(type="output_text", text="", annotations=[]),
389
+ )
390
+ )
391
+
392
+ # Process each content item
238
393
  for content in update.contents:
239
394
  content_type = content.__class__.__name__
240
395
 
241
- if content_type in self.content_mappers:
396
+ # Special handling for TextContent to use proper delta events
397
+ if content_type == "TextContent" and "current_message_id" in context:
398
+ # Stream text content via proper delta events
399
+ events.append(
400
+ ResponseTextDeltaEvent(
401
+ type="response.output_text.delta",
402
+ output_index=context["output_index"],
403
+ content_index=context.get("content_index", 0),
404
+ item_id=context["current_message_id"],
405
+ delta=content.text,
406
+ logprobs=[], # We don't have logprobs from Agent Framework
407
+ sequence_number=self._next_sequence(context),
408
+ )
409
+ )
410
+ elif content_type in self.content_mappers:
411
+ # Use existing mappers for other content types
242
412
  mapped_events = await self.content_mappers[content_type](content, context)
243
- if isinstance(mapped_events, list):
244
- events.extend(mapped_events)
245
- else:
246
- events.append(mapped_events)
413
+ if mapped_events is not None: # Handle None returns (e.g., UsageContent)
414
+ if isinstance(mapped_events, list):
415
+ events.extend(mapped_events)
416
+ else:
417
+ events.append(mapped_events)
247
418
  else:
248
419
  # Graceful fallback for unknown content types
249
420
  events.append(await self._create_unknown_content_event(content, context))
250
421
 
251
- context["content_index"] += 1
422
+ # Don't increment content_index for text deltas within the same part
423
+ if content_type != "TextContent":
424
+ context["content_index"] = context.get("content_index", 0) + 1
252
425
 
253
426
  except Exception as e:
254
427
  logger.warning(f"Error converting agent update: {e}")
@@ -256,8 +429,158 @@ class MessageMapper:
256
429
 
257
430
  return events
258
431
 
432
+ async def _convert_agent_response(self, response: Any, context: dict[str, Any]) -> Sequence[Any]:
433
+ """Convert complete AgentRunResponse to OpenAI events.
434
+
435
+ This handles non-streaming agent execution where agent.run() returns
436
+ a complete AgentRunResponse instead of streaming AgentRunResponseUpdate objects.
437
+
438
+ Args:
439
+ response: Agent run response (AgentRunResponse)
440
+ context: Conversion context
441
+
442
+ Returns:
443
+ List of OpenAI response stream events
444
+ """
445
+ events: list[Any] = []
446
+
447
+ try:
448
+ # Extract all messages from the response
449
+ messages = getattr(response, "messages", [])
450
+
451
+ # Convert each message's contents to streaming events
452
+ for message in messages:
453
+ if hasattr(message, "contents") and message.contents:
454
+ for content in message.contents:
455
+ content_type = content.__class__.__name__
456
+
457
+ if content_type in self.content_mappers:
458
+ mapped_events = await self.content_mappers[content_type](content, context)
459
+ if mapped_events is not None: # Handle None returns (e.g., UsageContent)
460
+ if isinstance(mapped_events, list):
461
+ events.extend(mapped_events)
462
+ else:
463
+ events.append(mapped_events)
464
+ else:
465
+ # Graceful fallback for unknown content types
466
+ events.append(await self._create_unknown_content_event(content, context))
467
+
468
+ context["content_index"] += 1
469
+
470
+ # Add usage information if present
471
+ usage_details = getattr(response, "usage_details", None)
472
+ if usage_details:
473
+ from agent_framework import UsageContent
474
+
475
+ usage_content = UsageContent(details=usage_details)
476
+ await self._map_usage_content(usage_content, context)
477
+ # Note: _map_usage_content returns None - it accumulates usage for final Response.usage
478
+
479
+ except Exception as e:
480
+ logger.warning(f"Error converting agent response: {e}")
481
+ events.append(await self._create_error_event(str(e), context))
482
+
483
+ return events
484
+
485
+ async def _convert_agent_lifecycle_event(self, event: Any, context: dict[str, Any]) -> Sequence[Any]:
486
+ """Convert agent lifecycle events to OpenAI response events.
487
+
488
+ Args:
489
+ event: AgentStartedEvent, AgentCompletedEvent, or AgentFailedEvent
490
+ context: Conversion context
491
+
492
+ Returns:
493
+ List of OpenAI response stream events
494
+ """
495
+ from .models._openai_custom import AgentCompletedEvent, AgentFailedEvent, AgentStartedEvent
496
+
497
+ try:
498
+ # Get model name from context (the agent name)
499
+ model_name = context.get("request", {}).model if context.get("request") else "agent"
500
+
501
+ if isinstance(event, AgentStartedEvent):
502
+ execution_id = f"agent_{uuid4().hex[:12]}"
503
+ context["execution_id"] = execution_id
504
+
505
+ # Create Response object
506
+ response_obj = Response(
507
+ id=f"resp_{execution_id}",
508
+ object="response",
509
+ created_at=float(time.time()),
510
+ model=model_name,
511
+ output=[],
512
+ status="in_progress",
513
+ parallel_tool_calls=False,
514
+ tool_choice="none",
515
+ tools=[],
516
+ )
517
+
518
+ # Emit both created and in_progress events
519
+ return [
520
+ ResponseCreatedEvent(
521
+ type="response.created", sequence_number=self._next_sequence(context), response=response_obj
522
+ ),
523
+ ResponseInProgressEvent(
524
+ type="response.in_progress", sequence_number=self._next_sequence(context), response=response_obj
525
+ ),
526
+ ]
527
+
528
+ if isinstance(event, AgentCompletedEvent):
529
+ execution_id = context.get("execution_id", f"agent_{uuid4().hex[:12]}")
530
+
531
+ response_obj = Response(
532
+ id=f"resp_{execution_id}",
533
+ object="response",
534
+ created_at=float(time.time()),
535
+ model=model_name,
536
+ output=[],
537
+ status="completed",
538
+ parallel_tool_calls=False,
539
+ tool_choice="none",
540
+ tools=[],
541
+ )
542
+
543
+ return [
544
+ ResponseCompletedEvent(
545
+ type="response.completed", sequence_number=self._next_sequence(context), response=response_obj
546
+ )
547
+ ]
548
+
549
+ if isinstance(event, AgentFailedEvent):
550
+ execution_id = context.get("execution_id", f"agent_{uuid4().hex[:12]}")
551
+
552
+ # Create error object
553
+ response_error = ResponseError(
554
+ message=str(event.error) if event.error else "Unknown error", code="server_error"
555
+ )
556
+
557
+ response_obj = Response(
558
+ id=f"resp_{execution_id}",
559
+ object="response",
560
+ created_at=float(time.time()),
561
+ model=model_name,
562
+ output=[],
563
+ status="failed",
564
+ error=response_error,
565
+ parallel_tool_calls=False,
566
+ tool_choice="none",
567
+ tools=[],
568
+ )
569
+
570
+ return [
571
+ ResponseFailedEvent(
572
+ type="response.failed", sequence_number=self._next_sequence(context), response=response_obj
573
+ )
574
+ ]
575
+
576
+ return []
577
+
578
+ except Exception as e:
579
+ logger.warning(f"Error converting agent lifecycle event: {e}")
580
+ return [await self._create_error_event(str(e), context)]
581
+
259
582
  async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> Sequence[Any]:
260
- """Convert workflow event to structured OpenAI events.
583
+ """Convert workflow events to standard OpenAI event objects.
261
584
 
262
585
  Args:
263
586
  event: Workflow event
@@ -267,22 +590,247 @@ class MessageMapper:
267
590
  List of OpenAI response stream events
268
591
  """
269
592
  try:
593
+ event_class = event.__class__.__name__
594
+
595
+ # Response-level events - construct proper OpenAI objects
596
+ if event_class == "WorkflowStartedEvent":
597
+ workflow_id = getattr(event, "workflow_id", str(uuid4()))
598
+ context["workflow_id"] = workflow_id
599
+
600
+ # Import Response type for proper construction
601
+ from openai.types.responses import Response
602
+
603
+ # Return proper OpenAI event objects
604
+ events: list[Any] = []
605
+
606
+ # Determine the model name - use request model or default to "workflow"
607
+ # The request model will be the agent name for agents, workflow name for workflows
608
+ model_name = context.get("request", {}).model if context.get("request") else "workflow"
609
+
610
+ # Create a full Response object with all required fields
611
+ response_obj = Response(
612
+ id=f"resp_{workflow_id}",
613
+ object="response",
614
+ created_at=float(time.time()),
615
+ model=model_name, # Use the actual model/agent name
616
+ output=[], # Empty output list initially
617
+ status="in_progress",
618
+ # Required fields with safe defaults
619
+ parallel_tool_calls=False,
620
+ tool_choice="none",
621
+ tools=[],
622
+ )
623
+
624
+ # First emit response.created
625
+ events.append(
626
+ ResponseCreatedEvent(
627
+ type="response.created", sequence_number=self._next_sequence(context), response=response_obj
628
+ )
629
+ )
630
+
631
+ # Then emit response.in_progress (reuse same response object)
632
+ events.append(
633
+ ResponseInProgressEvent(
634
+ type="response.in_progress", sequence_number=self._next_sequence(context), response=response_obj
635
+ )
636
+ )
637
+
638
+ return events
639
+
640
+ if event_class in ["WorkflowCompletedEvent", "WorkflowOutputEvent"]:
641
+ workflow_id = context.get("workflow_id", str(uuid4()))
642
+
643
+ # Import Response type for proper construction
644
+ from openai.types.responses import Response
645
+
646
+ # Get model name from context
647
+ model_name = context.get("request", {}).model if context.get("request") else "workflow"
648
+
649
+ # Create a full Response object for completed state
650
+ response_obj = Response(
651
+ id=f"resp_{workflow_id}",
652
+ object="response",
653
+ created_at=float(time.time()),
654
+ model=model_name,
655
+ output=[], # Output should be populated by this point from text streaming
656
+ status="completed",
657
+ parallel_tool_calls=False,
658
+ tool_choice="none",
659
+ tools=[],
660
+ )
661
+
662
+ return [
663
+ ResponseCompletedEvent(
664
+ type="response.completed", sequence_number=self._next_sequence(context), response=response_obj
665
+ )
666
+ ]
667
+
668
+ if event_class == "WorkflowFailedEvent":
669
+ workflow_id = context.get("workflow_id", str(uuid4()))
670
+ error_info = getattr(event, "error", None)
671
+
672
+ # Import Response and ResponseError types
673
+ from openai.types.responses import Response, ResponseError
674
+
675
+ # Get model name from context
676
+ model_name = context.get("request", {}).model if context.get("request") else "workflow"
677
+
678
+ # Create error object
679
+ error_message = str(error_info) if error_info else "Unknown error"
680
+
681
+ # Create ResponseError object (code must be one of the allowed values)
682
+ response_error = ResponseError(
683
+ message=error_message,
684
+ code="server_error", # Use generic server_error code for workflow failures
685
+ )
686
+
687
+ # Create a full Response object for failed state
688
+ response_obj = Response(
689
+ id=f"resp_{workflow_id}",
690
+ object="response",
691
+ created_at=float(time.time()),
692
+ model=model_name,
693
+ output=[],
694
+ status="failed",
695
+ error=response_error,
696
+ parallel_tool_calls=False,
697
+ tool_choice="none",
698
+ tools=[],
699
+ )
700
+
701
+ return [
702
+ ResponseFailedEvent(
703
+ type="response.failed", sequence_number=self._next_sequence(context), response=response_obj
704
+ )
705
+ ]
706
+
707
+ # Executor-level events (output items)
708
+ if event_class == "ExecutorInvokedEvent":
709
+ executor_id = getattr(event, "executor_id", "unknown")
710
+ item_id = f"exec_{executor_id}_{uuid4().hex[:8]}"
711
+ context[f"exec_item_{executor_id}"] = item_id
712
+ context["output_index"] = context.get("output_index", -1) + 1
713
+
714
+ # Create ExecutorActionItem with proper type
715
+ executor_item = ExecutorActionItem(
716
+ type="executor_action",
717
+ id=item_id,
718
+ executor_id=executor_id,
719
+ status="in_progress",
720
+ metadata=getattr(event, "metadata", {}),
721
+ )
722
+
723
+ # Use our custom event type that accepts ExecutorActionItem
724
+ return [
725
+ CustomResponseOutputItemAddedEvent(
726
+ type="response.output_item.added",
727
+ output_index=context["output_index"],
728
+ sequence_number=self._next_sequence(context),
729
+ item=executor_item,
730
+ )
731
+ ]
732
+
733
+ if event_class == "ExecutorCompletedEvent":
734
+ executor_id = getattr(event, "executor_id", "unknown")
735
+ item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown")
736
+
737
+ # Create ExecutorActionItem with completed status
738
+ # ExecutorCompletedEvent uses 'data' field, not 'result'
739
+ executor_item = ExecutorActionItem(
740
+ type="executor_action",
741
+ id=item_id,
742
+ executor_id=executor_id,
743
+ status="completed",
744
+ result=getattr(event, "data", None),
745
+ )
746
+
747
+ # Use our custom event type
748
+ return [
749
+ CustomResponseOutputItemDoneEvent(
750
+ type="response.output_item.done",
751
+ output_index=context.get("output_index", 0),
752
+ sequence_number=self._next_sequence(context),
753
+ item=executor_item,
754
+ )
755
+ ]
756
+
757
+ if event_class == "ExecutorFailedEvent":
758
+ executor_id = getattr(event, "executor_id", "unknown")
759
+ item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown")
760
+ error_info = getattr(event, "error", None)
761
+
762
+ # Create ExecutorActionItem with failed status
763
+ executor_item = ExecutorActionItem(
764
+ type="executor_action",
765
+ id=item_id,
766
+ executor_id=executor_id,
767
+ status="failed",
768
+ error={"message": str(error_info)} if error_info else None,
769
+ )
770
+
771
+ # Use our custom event type
772
+ return [
773
+ CustomResponseOutputItemDoneEvent(
774
+ type="response.output_item.done",
775
+ output_index=context.get("output_index", 0),
776
+ sequence_number=self._next_sequence(context),
777
+ item=executor_item,
778
+ )
779
+ ]
780
+
781
+ # Handle informational workflow events (status, warnings, errors)
782
+ if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent", "RequestInfoEvent"]:
783
+ # These are informational events that don't map to OpenAI lifecycle events
784
+ # Convert them to trace events for debugging visibility
785
+ event_data: dict[str, Any] = {}
786
+
787
+ # Extract relevant data based on event type
788
+ if event_class == "WorkflowStatusEvent":
789
+ event_data["state"] = str(getattr(event, "state", "unknown"))
790
+ elif event_class == "WorkflowWarningEvent":
791
+ event_data["message"] = str(getattr(event, "message", ""))
792
+ elif event_class == "WorkflowErrorEvent":
793
+ event_data["message"] = str(getattr(event, "message", ""))
794
+ event_data["error"] = str(getattr(event, "error", ""))
795
+ elif event_class == "RequestInfoEvent":
796
+ request_info = getattr(event, "data", {})
797
+ event_data["request_info"] = request_info if isinstance(request_info, dict) else str(request_info)
798
+
799
+ # Create a trace event for debugging
800
+ trace_event = ResponseTraceEventComplete(
801
+ type="response.trace.complete",
802
+ data={
803
+ "trace_type": "workflow_info",
804
+ "event_type": event_class,
805
+ "data": event_data,
806
+ "timestamp": datetime.now().isoformat(),
807
+ },
808
+ span_id=f"workflow_info_{uuid4().hex[:8]}",
809
+ item_id=context["item_id"],
810
+ output_index=context.get("output_index", 0),
811
+ sequence_number=self._next_sequence(context),
812
+ )
813
+
814
+ return [trace_event]
815
+
816
+ # For unknown/legacy events, still emit as workflow event for backward compatibility
270
817
  # Get event data and serialize if it's a SerializationMixin
271
- event_data = getattr(event, "data", None)
272
- if event_data is not None and hasattr(event_data, "to_dict"):
818
+ raw_event_data = getattr(event, "data", None)
819
+ serialized_event_data: dict[str, Any] | str | None = raw_event_data
820
+ if raw_event_data is not None and hasattr(raw_event_data, "to_dict"):
273
821
  # SerializationMixin objects - convert to dict for JSON serialization
274
822
  try:
275
- event_data = event_data.to_dict()
823
+ serialized_event_data = raw_event_data.to_dict()
276
824
  except Exception as e:
277
825
  logger.debug(f"Failed to serialize event data with to_dict(): {e}")
278
- event_data = str(event_data)
826
+ serialized_event_data = str(raw_event_data)
279
827
 
280
- # Create structured workflow event
828
+ # Create structured workflow event (keeping for backward compatibility)
281
829
  workflow_event = ResponseWorkflowEventComplete(
282
830
  type="response.workflow_event.complete",
283
831
  data={
284
832
  "event_type": event.__class__.__name__,
285
- "data": event_data,
833
+ "data": serialized_event_data,
286
834
  "executor_id": getattr(event, "executor_id", None),
287
835
  "timestamp": datetime.now().isoformat(),
288
836
  },
@@ -292,6 +840,7 @@ class MessageMapper:
292
840
  sequence_number=self._next_sequence(context),
293
841
  )
294
842
 
843
+ logger.debug(f"Unhandled workflow event type: {event_class}, emitting as legacy workflow event")
295
844
  return [workflow_event]
296
845
 
297
846
  except Exception as e:
@@ -317,44 +866,152 @@ class MessageMapper:
317
866
 
318
867
  async def _map_function_call_content(
319
868
  self, content: Any, context: dict[str, Any]
320
- ) -> list[ResponseFunctionCallArgumentsDeltaEvent]:
321
- """Map FunctionCallContent to ResponseFunctionCallArgumentsDeltaEvent(s)."""
322
- events = []
869
+ ) -> list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent]:
870
+ """Map FunctionCallContent to OpenAI events following Responses API spec.
871
+
872
+ Agent Framework emits FunctionCallContent in two patterns:
873
+ 1. First event: call_id + name + empty/no arguments
874
+ 2. Subsequent events: empty call_id/name + argument chunks
323
875
 
324
- # For streaming, need to chunk the arguments JSON
325
- args_str = json.dumps(content.arguments) if hasattr(content, "arguments") and content.arguments else "{}"
876
+ We emit:
877
+ 1. response.output_item.added (with full metadata) for the first event
878
+ 2. response.function_call_arguments.delta (referencing item_id) for chunks
879
+ """
880
+ events: list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent] = []
881
+
882
+ # CASE 1: New function call (has call_id and name)
883
+ # This is the first event that establishes the function call
884
+ if content.call_id and content.name:
885
+ # Use call_id as item_id (simpler, and call_id uniquely identifies the call)
886
+ item_id = content.call_id
887
+
888
+ # Track this function call for later argument deltas
889
+ context["active_function_calls"][content.call_id] = {
890
+ "item_id": item_id,
891
+ "name": content.name,
892
+ "arguments_chunks": [],
893
+ }
326
894
 
327
- # Chunk the JSON string for streaming
328
- for chunk in self._chunk_json_string(args_str):
895
+ logger.debug(f"New function call: {content.name} (call_id={content.call_id})")
896
+
897
+ # Emit response.output_item.added event per OpenAI spec
329
898
  events.append(
330
- ResponseFunctionCallArgumentsDeltaEvent(
331
- type="response.function_call_arguments.delta",
332
- delta=chunk,
333
- item_id=context["item_id"],
899
+ ResponseOutputItemAddedEvent(
900
+ type="response.output_item.added",
901
+ item=ResponseFunctionToolCall(
902
+ id=content.call_id, # Use call_id as the item id
903
+ call_id=content.call_id,
904
+ name=content.name,
905
+ arguments="", # Empty initially, will be filled by deltas
906
+ type="function_call",
907
+ status="in_progress",
908
+ ),
334
909
  output_index=context["output_index"],
335
910
  sequence_number=self._next_sequence(context),
336
911
  )
337
912
  )
338
913
 
914
+ # CASE 2: Argument deltas (content has arguments, possibly without call_id/name)
915
+ if content.arguments:
916
+ # Find the active function call for these arguments
917
+ active_call = self._get_active_function_call(content, context)
918
+
919
+ if active_call:
920
+ item_id = active_call["item_id"]
921
+
922
+ # Convert arguments to string if it's a dict (Agent Framework may send either)
923
+ delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments)
924
+
925
+ # Emit argument delta referencing the item_id
926
+ events.append(
927
+ ResponseFunctionCallArgumentsDeltaEvent(
928
+ type="response.function_call_arguments.delta",
929
+ delta=delta_str,
930
+ item_id=item_id,
931
+ output_index=context["output_index"],
932
+ sequence_number=self._next_sequence(context),
933
+ )
934
+ )
935
+
936
+ # Track chunk for debugging
937
+ active_call["arguments_chunks"].append(delta_str)
938
+ else:
939
+ logger.warning(f"Received function call arguments without active call: {content.arguments[:50]}...")
940
+
339
941
  return events
340
942
 
943
+ def _get_active_function_call(self, content: Any, context: dict[str, Any]) -> dict[str, Any] | None:
944
+ """Find the active function call for this content.
945
+
946
+ Uses call_id if present, otherwise falls back to most recent call.
947
+ Necessary because Agent Framework may send argument chunks without call_id.
948
+
949
+ Args:
950
+ content: FunctionCallContent with possible call_id
951
+ context: Conversion context with active_function_calls
952
+
953
+ Returns:
954
+ Active call dict or None
955
+ """
956
+ active_calls: dict[str, dict[str, Any]] = context["active_function_calls"]
957
+
958
+ # If content has call_id, use it to find the exact call
959
+ if hasattr(content, "call_id") and content.call_id:
960
+ result = active_calls.get(content.call_id)
961
+ return result if result is not None else None
962
+
963
+ # Otherwise, use the most recent call (last one added)
964
+ # This handles the case where Agent Framework sends argument chunks
965
+ # without call_id in subsequent events
966
+ if active_calls:
967
+ return list(active_calls.values())[-1]
968
+
969
+ return None
970
+
341
971
  async def _map_function_result_content(
342
972
  self, content: Any, context: dict[str, Any]
343
973
  ) -> ResponseFunctionResultComplete:
344
- """Map FunctionResultContent to structured event."""
974
+ """Map FunctionResultContent to DevUI custom event.
975
+
976
+ DevUI extension: The OpenAI Responses API doesn't stream function execution results
977
+ (in OpenAI's model, the application executes functions, not the API).
978
+ """
979
+ # Get call_id from content
980
+ call_id = getattr(content, "call_id", None)
981
+ if not call_id:
982
+ call_id = f"call_{uuid.uuid4().hex[:8]}"
983
+
984
+ # Extract result
985
+ result = getattr(content, "result", None)
986
+ exception = getattr(content, "exception", None)
987
+
988
+ # Convert result to string, handling nested Content objects from MCP tools
989
+ if isinstance(result, str):
990
+ output = result
991
+ elif result is not None:
992
+ # Recursively serialize any nested Content objects (e.g., from MCP tools)
993
+ serialized = _serialize_content_recursive(result)
994
+ # Convert to JSON string if still not a string
995
+ output = serialized if isinstance(serialized, str) else json.dumps(serialized)
996
+ else:
997
+ output = ""
998
+
999
+ # Determine status based on exception
1000
+ status = "incomplete" if exception else "completed"
1001
+
1002
+ # Generate item_id
1003
+ item_id = f"item_{uuid.uuid4().hex[:8]}"
1004
+
1005
+ # Return DevUI custom event
345
1006
  return ResponseFunctionResultComplete(
346
1007
  type="response.function_result.complete",
347
- data={
348
- "call_id": getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"),
349
- "result": getattr(content, "result", None),
350
- "status": "completed" if not getattr(content, "exception", None) else "failed",
351
- "exception": str(getattr(content, "exception", None)) if getattr(content, "exception", None) else None,
352
- "timestamp": datetime.now().isoformat(),
353
- },
354
- call_id=getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"),
355
- item_id=context["item_id"],
1008
+ call_id=call_id,
1009
+ output=output,
1010
+ status=status,
1011
+ item_id=item_id,
356
1012
  output_index=context["output_index"],
357
1013
  sequence_number=self._next_sequence(context),
1014
+ timestamp=datetime.now().isoformat(),
358
1015
  )
359
1016
 
360
1017
  async def _map_error_content(self, content: Any, context: dict[str, Any]) -> ResponseErrorEvent:
@@ -367,37 +1024,34 @@ class MessageMapper:
367
1024
  sequence_number=self._next_sequence(context),
368
1025
  )
369
1026
 
370
- async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> ResponseUsageEventComplete:
371
- """Map UsageContent to structured usage event."""
372
- # Store usage data in context for aggregation
373
- if "usage_data" not in context:
374
- context["usage_data"] = []
375
- context["usage_data"].append(content)
1027
+ async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> None:
1028
+ """Accumulate usage data for final Response.usage field.
376
1029
 
1030
+ OpenAI does NOT stream usage events. Usage appears only in final Response.
1031
+ This method accumulates usage data per request for later inclusion in Response.usage.
1032
+
1033
+ Returns:
1034
+ None - no event emitted (usage goes in final Response.usage)
1035
+ """
377
1036
  # Extract usage from UsageContent.details (UsageDetails object)
378
1037
  details = getattr(content, "details", None)
379
- total_tokens = 0
380
- prompt_tokens = 0
381
- completion_tokens = 0
1038
+ total_tokens = getattr(details, "total_token_count", 0) or 0
1039
+ prompt_tokens = getattr(details, "input_token_count", 0) or 0
1040
+ completion_tokens = getattr(details, "output_token_count", 0) or 0
382
1041
 
383
- if details:
384
- total_tokens = getattr(details, "total_token_count", 0) or 0
385
- prompt_tokens = getattr(details, "input_token_count", 0) or 0
386
- completion_tokens = getattr(details, "output_token_count", 0) or 0
1042
+ # Accumulate for final Response.usage
1043
+ request_id = context.get("request_id", "default")
1044
+ if request_id not in self._usage_accumulator:
1045
+ self._usage_accumulator[request_id] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
387
1046
 
388
- return ResponseUsageEventComplete(
389
- type="response.usage.complete",
390
- data={
391
- "usage_data": details.to_dict() if details and hasattr(details, "to_dict") else {},
392
- "total_tokens": total_tokens,
393
- "completion_tokens": completion_tokens,
394
- "prompt_tokens": prompt_tokens,
395
- "timestamp": datetime.now().isoformat(),
396
- },
397
- item_id=context["item_id"],
398
- output_index=context["output_index"],
399
- sequence_number=self._next_sequence(context),
400
- )
1047
+ self._usage_accumulator[request_id]["input_tokens"] += prompt_tokens
1048
+ self._usage_accumulator[request_id]["output_tokens"] += completion_tokens
1049
+ self._usage_accumulator[request_id]["total_tokens"] += total_tokens
1050
+
1051
+ logger.debug(f"Accumulated usage for {request_id}: {self._usage_accumulator[request_id]}")
1052
+
1053
+ # NO EVENT RETURNED - usage goes in final Response only
1054
+ return
401
1055
 
402
1056
  async def _map_data_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
403
1057
  """Map DataContent to structured trace event."""
@@ -462,15 +1116,24 @@ class MessageMapper:
462
1116
 
463
1117
  async def _map_approval_request_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]:
464
1118
  """Map FunctionApprovalRequestContent to custom event."""
1119
+ # Parse arguments to ensure they're always a dict, not a JSON string
1120
+ # This prevents double-escaping when the frontend calls JSON.stringify()
1121
+ arguments: dict[str, Any] = {}
1122
+ if hasattr(content, "function_call"):
1123
+ if hasattr(content.function_call, "parse_arguments"):
1124
+ # Use parse_arguments() to convert string arguments to dict
1125
+ arguments = content.function_call.parse_arguments() or {}
1126
+ else:
1127
+ # Fallback to direct access if parse_arguments doesn't exist
1128
+ arguments = getattr(content.function_call, "arguments", {})
1129
+
465
1130
  return {
466
1131
  "type": "response.function_approval.requested",
467
1132
  "request_id": getattr(content, "id", "unknown"),
468
1133
  "function_call": {
469
1134
  "id": getattr(content.function_call, "call_id", "") if hasattr(content, "function_call") else "",
470
1135
  "name": getattr(content.function_call, "name", "") if hasattr(content, "function_call") else "",
471
- "arguments": getattr(content.function_call, "arguments", {})
472
- if hasattr(content, "function_call")
473
- else {},
1136
+ "arguments": arguments,
474
1137
  },
475
1138
  "item_id": context["item_id"],
476
1139
  "output_index": context["output_index"],
@@ -510,19 +1173,15 @@ class MessageMapper:
510
1173
 
511
1174
  async def _create_unknown_event(self, event_data: Any, context: dict[str, Any]) -> ResponseStreamEvent:
512
1175
  """Create event for unknown event types."""
513
- text = f"Unknown event: {event_data!s}\\n"
1176
+ text = f"Unknown event: {event_data!s}\n"
514
1177
  return self._create_text_delta_event(text, context)
515
1178
 
516
1179
  async def _create_unknown_content_event(self, content: Any, context: dict[str, Any]) -> ResponseStreamEvent:
517
1180
  """Create event for unknown content types."""
518
1181
  content_type = content.__class__.__name__
519
- text = f"⚠️ Unknown content type: {content_type}\\n"
1182
+ text = f"Warning: Unknown content type: {content_type}\n"
520
1183
  return self._create_text_delta_event(text, context)
521
1184
 
522
- def _chunk_json_string(self, json_str: str, chunk_size: int = 50) -> list[str]:
523
- """Chunk JSON string for streaming."""
524
- return [json_str[i : i + chunk_size] for i in range(0, len(json_str), chunk_size)]
525
-
526
1185
  async def _create_error_response(self, error_message: str, request: AgentFrameworkRequest) -> OpenAIResponse:
527
1186
  """Create error response."""
528
1187
  error_text = f"Error: {error_message}"