agent-framework-devui 1.0.0b251007__py3-none-any.whl → 1.0.0b251016__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.

@@ -5,6 +5,7 @@
5
5
  import json
6
6
  import logging
7
7
  import uuid
8
+ from collections import OrderedDict
8
9
  from collections.abc import Sequence
9
10
  from datetime import datetime
10
11
  from typing import Any, Union
@@ -17,6 +18,8 @@ from .models import (
17
18
  ResponseErrorEvent,
18
19
  ResponseFunctionCallArgumentsDeltaEvent,
19
20
  ResponseFunctionResultComplete,
21
+ ResponseFunctionToolCall,
22
+ ResponseOutputItemAddedEvent,
20
23
  ResponseOutputMessage,
21
24
  ResponseOutputText,
22
25
  ResponseReasoningTextDeltaEvent,
@@ -24,7 +27,6 @@ from .models import (
24
27
  ResponseTextDeltaEvent,
25
28
  ResponseTraceEventComplete,
26
29
  ResponseUsage,
27
- ResponseUsageEventComplete,
28
30
  ResponseWorkflowEventComplete,
29
31
  )
30
32
 
@@ -34,19 +36,26 @@ logger = logging.getLogger(__name__)
34
36
  EventType = Union[
35
37
  ResponseStreamEvent,
36
38
  ResponseWorkflowEventComplete,
37
- ResponseFunctionResultComplete,
39
+ ResponseOutputItemAddedEvent,
38
40
  ResponseTraceEventComplete,
39
- ResponseUsageEventComplete,
40
41
  ]
41
42
 
42
43
 
43
44
  class MessageMapper:
44
45
  """Maps Agent Framework messages/responses to OpenAI format."""
45
46
 
46
- def __init__(self) -> None:
47
- """Initialize Agent Framework message mapper."""
47
+ def __init__(self, max_contexts: int = 1000) -> None:
48
+ """Initialize Agent Framework message mapper.
49
+
50
+ Args:
51
+ max_contexts: Maximum number of contexts to keep in memory (default: 1000)
52
+ """
48
53
  self.sequence_counter = 0
49
- self._conversion_contexts: dict[int, dict[str, Any]] = {}
54
+ self._conversion_contexts: OrderedDict[int, dict[str, Any]] = OrderedDict()
55
+ self._max_contexts = max_contexts
56
+
57
+ # Track usage per request for final Response.usage (OpenAI standard)
58
+ self._usage_accumulator: dict[str, dict[str, int]] = {}
50
59
 
51
60
  # Register content type mappers for all 12 Agent Framework content types
52
61
  self.content_mappers = {
@@ -95,7 +104,7 @@ class MessageMapper:
95
104
 
96
105
  # Import Agent Framework types for proper isinstance checks
97
106
  try:
98
- from agent_framework import AgentRunResponseUpdate, WorkflowEvent
107
+ from agent_framework import AgentRunResponse, AgentRunResponseUpdate, WorkflowEvent
99
108
  from agent_framework._workflows._events import AgentRunUpdateEvent
100
109
 
101
110
  # Handle AgentRunUpdateEvent - workflow event wrapping AgentRunResponseUpdate
@@ -107,6 +116,10 @@ class MessageMapper:
107
116
  # If no data, treat as generic workflow event
108
117
  return await self._convert_workflow_event(raw_event, context)
109
118
 
119
+ # Handle complete agent response (AgentRunResponse) - for non-streaming agent execution
120
+ if isinstance(raw_event, AgentRunResponse):
121
+ return await self._convert_agent_response(raw_event, context)
122
+
110
123
  # Handle agent updates (AgentRunResponseUpdate) - for direct agent execution
111
124
  if isinstance(raw_event, AgentRunResponseUpdate):
112
125
  return await self._convert_agent_update(raw_event, context)
@@ -159,17 +172,31 @@ class MessageMapper:
159
172
  status="completed",
160
173
  )
161
174
 
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
- )
175
+ # Get usage from accumulator (OpenAI standard)
176
+ request_id = str(id(request))
177
+ usage_data = self._usage_accumulator.get(request_id)
178
+
179
+ if usage_data:
180
+ usage = ResponseUsage(
181
+ input_tokens=usage_data["input_tokens"],
182
+ output_tokens=usage_data["output_tokens"],
183
+ total_tokens=usage_data["total_tokens"],
184
+ input_tokens_details=InputTokensDetails(cached_tokens=0),
185
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
186
+ )
187
+ # Cleanup accumulator
188
+ del self._usage_accumulator[request_id]
189
+ else:
190
+ # Fallback: estimate if no usage was tracked
191
+ input_token_count = len(str(request.input)) // 4 if request.input else 0
192
+ output_token_count = len(full_content) // 4
193
+ usage = ResponseUsage(
194
+ input_tokens=input_token_count,
195
+ output_tokens=output_token_count,
196
+ total_tokens=input_token_count + output_token_count,
197
+ input_tokens_details=InputTokensDetails(cached_tokens=0),
198
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
199
+ )
173
200
 
174
201
  return OpenAIResponse(
175
202
  id=f"resp_{uuid.uuid4().hex[:12]}",
@@ -186,10 +213,18 @@ class MessageMapper:
186
213
  except Exception as e:
187
214
  logger.exception(f"Error aggregating response: {e}")
188
215
  return await self._create_error_response(str(e), request)
216
+ finally:
217
+ # Cleanup: Remove context after aggregation to prevent memory leak
218
+ # This handles the common case where streaming completes successfully
219
+ request_key = id(request)
220
+ if self._conversion_contexts.pop(request_key, None):
221
+ logger.debug(f"Cleaned up context for request {request_key} after aggregation")
189
222
 
190
223
  def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, Any]:
191
224
  """Get or create conversion context for this request.
192
225
 
226
+ Uses LRU eviction when max_contexts is reached to prevent unbounded memory growth.
227
+
193
228
  Args:
194
229
  request: Request to get context for
195
230
 
@@ -197,13 +232,26 @@ class MessageMapper:
197
232
  Conversion context dictionary
198
233
  """
199
234
  request_key = id(request)
235
+
200
236
  if request_key not in self._conversion_contexts:
237
+ # Evict oldest context if at capacity (LRU eviction)
238
+ if len(self._conversion_contexts) >= self._max_contexts:
239
+ evicted_key, _ = self._conversion_contexts.popitem(last=False)
240
+ logger.debug(f"Evicted oldest context (key={evicted_key}) - at max capacity ({self._max_contexts})")
241
+
201
242
  self._conversion_contexts[request_key] = {
202
243
  "sequence_counter": 0,
203
244
  "item_id": f"msg_{uuid.uuid4().hex[:8]}",
204
245
  "content_index": 0,
205
246
  "output_index": 0,
247
+ "request_id": str(request_key), # For usage accumulation
248
+ # Track active function calls: {call_id: {name, item_id, args_chunks}}
249
+ "active_function_calls": {},
206
250
  }
251
+ else:
252
+ # Move to end (mark as recently used for LRU)
253
+ self._conversion_contexts.move_to_end(request_key)
254
+
207
255
  return self._conversion_contexts[request_key]
208
256
 
209
257
  def _next_sequence(self, context: dict[str, Any]) -> int:
@@ -240,10 +288,11 @@ class MessageMapper:
240
288
 
241
289
  if content_type in self.content_mappers:
242
290
  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)
291
+ if mapped_events is not None: # Handle None returns (e.g., UsageContent)
292
+ if isinstance(mapped_events, list):
293
+ events.extend(mapped_events)
294
+ else:
295
+ events.append(mapped_events)
247
296
  else:
248
297
  # Graceful fallback for unknown content types
249
298
  events.append(await self._create_unknown_content_event(content, context))
@@ -256,6 +305,59 @@ class MessageMapper:
256
305
 
257
306
  return events
258
307
 
308
+ async def _convert_agent_response(self, response: Any, context: dict[str, Any]) -> Sequence[Any]:
309
+ """Convert complete AgentRunResponse to OpenAI events.
310
+
311
+ This handles non-streaming agent execution where agent.run() returns
312
+ a complete AgentRunResponse instead of streaming AgentRunResponseUpdate objects.
313
+
314
+ Args:
315
+ response: Agent run response (AgentRunResponse)
316
+ context: Conversion context
317
+
318
+ Returns:
319
+ List of OpenAI response stream events
320
+ """
321
+ events: list[Any] = []
322
+
323
+ try:
324
+ # Extract all messages from the response
325
+ messages = getattr(response, "messages", [])
326
+
327
+ # Convert each message's contents to streaming events
328
+ for message in messages:
329
+ if hasattr(message, "contents") and message.contents:
330
+ for content in message.contents:
331
+ content_type = content.__class__.__name__
332
+
333
+ if content_type in self.content_mappers:
334
+ mapped_events = await self.content_mappers[content_type](content, context)
335
+ if mapped_events is not None: # Handle None returns (e.g., UsageContent)
336
+ if isinstance(mapped_events, list):
337
+ events.extend(mapped_events)
338
+ else:
339
+ events.append(mapped_events)
340
+ else:
341
+ # Graceful fallback for unknown content types
342
+ events.append(await self._create_unknown_content_event(content, context))
343
+
344
+ context["content_index"] += 1
345
+
346
+ # Add usage information if present
347
+ usage_details = getattr(response, "usage_details", None)
348
+ if usage_details:
349
+ from agent_framework import UsageContent
350
+
351
+ usage_content = UsageContent(details=usage_details)
352
+ await self._map_usage_content(usage_content, context)
353
+ # Note: _map_usage_content returns None - it accumulates usage for final Response.usage
354
+
355
+ except Exception as e:
356
+ logger.warning(f"Error converting agent response: {e}")
357
+ events.append(await self._create_error_event(str(e), context))
358
+
359
+ return events
360
+
259
361
  async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> Sequence[Any]:
260
362
  """Convert workflow event to structured OpenAI events.
261
363
 
@@ -317,42 +419,141 @@ class MessageMapper:
317
419
 
318
420
  async def _map_function_call_content(
319
421
  self, content: Any, context: dict[str, Any]
320
- ) -> list[ResponseFunctionCallArgumentsDeltaEvent]:
321
- """Map FunctionCallContent to ResponseFunctionCallArgumentsDeltaEvent(s)."""
322
- events = []
422
+ ) -> list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent]:
423
+ """Map FunctionCallContent to OpenAI events following Responses API spec.
323
424
 
324
- # For streaming, need to chunk the arguments JSON
325
- args_str = json.dumps(content.arguments) if hasattr(content, "arguments") and content.arguments else "{}"
425
+ Agent Framework emits FunctionCallContent in two patterns:
426
+ 1. First event: call_id + name + empty/no arguments
427
+ 2. Subsequent events: empty call_id/name + argument chunks
326
428
 
327
- # Chunk the JSON string for streaming
328
- for chunk in self._chunk_json_string(args_str):
429
+ We emit:
430
+ 1. response.output_item.added (with full metadata) for the first event
431
+ 2. response.function_call_arguments.delta (referencing item_id) for chunks
432
+ """
433
+ events: list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent] = []
434
+
435
+ # CASE 1: New function call (has call_id and name)
436
+ # This is the first event that establishes the function call
437
+ if content.call_id and content.name:
438
+ # Use call_id as item_id (simpler, and call_id uniquely identifies the call)
439
+ item_id = content.call_id
440
+
441
+ # Track this function call for later argument deltas
442
+ context["active_function_calls"][content.call_id] = {
443
+ "item_id": item_id,
444
+ "name": content.name,
445
+ "arguments_chunks": [],
446
+ }
447
+
448
+ logger.debug(f"New function call: {content.name} (call_id={content.call_id})")
449
+
450
+ # Emit response.output_item.added event per OpenAI spec
329
451
  events.append(
330
- ResponseFunctionCallArgumentsDeltaEvent(
331
- type="response.function_call_arguments.delta",
332
- delta=chunk,
333
- item_id=context["item_id"],
452
+ ResponseOutputItemAddedEvent(
453
+ type="response.output_item.added",
454
+ item=ResponseFunctionToolCall(
455
+ id=content.call_id, # Use call_id as the item id
456
+ call_id=content.call_id,
457
+ name=content.name,
458
+ arguments="", # Empty initially, will be filled by deltas
459
+ type="function_call",
460
+ status="in_progress",
461
+ ),
334
462
  output_index=context["output_index"],
335
463
  sequence_number=self._next_sequence(context),
336
464
  )
337
465
  )
338
466
 
467
+ # CASE 2: Argument deltas (content has arguments, possibly without call_id/name)
468
+ if content.arguments:
469
+ # Find the active function call for these arguments
470
+ active_call = self._get_active_function_call(content, context)
471
+
472
+ if active_call:
473
+ item_id = active_call["item_id"]
474
+
475
+ # Convert arguments to string if it's a dict (Agent Framework may send either)
476
+ delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments)
477
+
478
+ # Emit argument delta referencing the item_id
479
+ events.append(
480
+ ResponseFunctionCallArgumentsDeltaEvent(
481
+ type="response.function_call_arguments.delta",
482
+ delta=delta_str,
483
+ item_id=item_id,
484
+ output_index=context["output_index"],
485
+ sequence_number=self._next_sequence(context),
486
+ )
487
+ )
488
+
489
+ # Track chunk for debugging
490
+ active_call["arguments_chunks"].append(delta_str)
491
+ else:
492
+ logger.warning(f"Received function call arguments without active call: {content.arguments[:50]}...")
493
+
339
494
  return events
340
495
 
496
+ def _get_active_function_call(self, content: Any, context: dict[str, Any]) -> dict[str, Any] | None:
497
+ """Find the active function call for this content.
498
+
499
+ Uses call_id if present, otherwise falls back to most recent call.
500
+ Necessary because Agent Framework may send argument chunks without call_id.
501
+
502
+ Args:
503
+ content: FunctionCallContent with possible call_id
504
+ context: Conversion context with active_function_calls
505
+
506
+ Returns:
507
+ Active call dict or None
508
+ """
509
+ active_calls: dict[str, dict[str, Any]] = context["active_function_calls"]
510
+
511
+ # If content has call_id, use it to find the exact call
512
+ if hasattr(content, "call_id") and content.call_id:
513
+ result = active_calls.get(content.call_id)
514
+ return result if result is not None else None
515
+
516
+ # Otherwise, use the most recent call (last one added)
517
+ # This handles the case where Agent Framework sends argument chunks
518
+ # without call_id in subsequent events
519
+ if active_calls:
520
+ return list(active_calls.values())[-1]
521
+
522
+ return None
523
+
341
524
  async def _map_function_result_content(
342
525
  self, content: Any, context: dict[str, Any]
343
526
  ) -> ResponseFunctionResultComplete:
344
- """Map FunctionResultContent to structured event."""
527
+ """Map FunctionResultContent to DevUI custom event.
528
+
529
+ DevUI extension: The OpenAI Responses API doesn't stream function execution results
530
+ (in OpenAI's model, the application executes functions, not the API).
531
+ """
532
+ # Get call_id from content
533
+ call_id = getattr(content, "call_id", None)
534
+ if not call_id:
535
+ call_id = f"call_{uuid.uuid4().hex[:8]}"
536
+
537
+ # Extract result
538
+ result = getattr(content, "result", None)
539
+ exception = getattr(content, "exception", None)
540
+
541
+ # Convert result to string
542
+ output = result if isinstance(result, str) else json.dumps(result) if result is not None else ""
543
+
544
+ # Determine status based on exception
545
+ status = "incomplete" if exception else "completed"
546
+
547
+ # Generate item_id
548
+ item_id = f"item_{uuid.uuid4().hex[:8]}"
549
+
550
+ # Return DevUI custom event
345
551
  return ResponseFunctionResultComplete(
346
552
  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"],
553
+ call_id=call_id,
554
+ output=output,
555
+ status=status,
556
+ item_id=item_id,
356
557
  output_index=context["output_index"],
357
558
  sequence_number=self._next_sequence(context),
358
559
  )
@@ -367,37 +568,34 @@ class MessageMapper:
367
568
  sequence_number=self._next_sequence(context),
368
569
  )
369
570
 
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)
571
+ async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> None:
572
+ """Accumulate usage data for final Response.usage field.
573
+
574
+ OpenAI does NOT stream usage events. Usage appears only in final Response.
575
+ This method accumulates usage data per request for later inclusion in Response.usage.
376
576
 
577
+ Returns:
578
+ None - no event emitted (usage goes in final Response.usage)
579
+ """
377
580
  # Extract usage from UsageContent.details (UsageDetails object)
378
581
  details = getattr(content, "details", None)
379
- total_tokens = 0
380
- prompt_tokens = 0
381
- completion_tokens = 0
582
+ total_tokens = getattr(details, "total_token_count", 0) or 0
583
+ prompt_tokens = getattr(details, "input_token_count", 0) or 0
584
+ completion_tokens = getattr(details, "output_token_count", 0) or 0
382
585
 
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
586
+ # Accumulate for final Response.usage
587
+ request_id = context.get("request_id", "default")
588
+ if request_id not in self._usage_accumulator:
589
+ self._usage_accumulator[request_id] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
387
590
 
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
- )
591
+ self._usage_accumulator[request_id]["input_tokens"] += prompt_tokens
592
+ self._usage_accumulator[request_id]["output_tokens"] += completion_tokens
593
+ self._usage_accumulator[request_id]["total_tokens"] += total_tokens
594
+
595
+ logger.debug(f"Accumulated usage for {request_id}: {self._usage_accumulator[request_id]}")
596
+
597
+ # NO EVENT RETURNED - usage goes in final Response only
598
+ return
401
599
 
402
600
  async def _map_data_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete:
403
601
  """Map DataContent to structured trace event."""
@@ -462,15 +660,24 @@ class MessageMapper:
462
660
 
463
661
  async def _map_approval_request_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]:
464
662
  """Map FunctionApprovalRequestContent to custom event."""
663
+ # Parse arguments to ensure they're always a dict, not a JSON string
664
+ # This prevents double-escaping when the frontend calls JSON.stringify()
665
+ arguments: dict[str, Any] = {}
666
+ if hasattr(content, "function_call"):
667
+ if hasattr(content.function_call, "parse_arguments"):
668
+ # Use parse_arguments() to convert string arguments to dict
669
+ arguments = content.function_call.parse_arguments() or {}
670
+ else:
671
+ # Fallback to direct access if parse_arguments doesn't exist
672
+ arguments = getattr(content.function_call, "arguments", {})
673
+
465
674
  return {
466
675
  "type": "response.function_approval.requested",
467
676
  "request_id": getattr(content, "id", "unknown"),
468
677
  "function_call": {
469
678
  "id": getattr(content.function_call, "call_id", "") if hasattr(content, "function_call") else "",
470
679
  "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 {},
680
+ "arguments": arguments,
474
681
  },
475
682
  "item_id": context["item_id"],
476
683
  "output_index": context["output_index"],
@@ -510,19 +717,15 @@ class MessageMapper:
510
717
 
511
718
  async def _create_unknown_event(self, event_data: Any, context: dict[str, Any]) -> ResponseStreamEvent:
512
719
  """Create event for unknown event types."""
513
- text = f"Unknown event: {event_data!s}\\n"
720
+ text = f"Unknown event: {event_data!s}\n"
514
721
  return self._create_text_delta_event(text, context)
515
722
 
516
723
  async def _create_unknown_content_event(self, content: Any, context: dict[str, Any]) -> ResponseStreamEvent:
517
724
  """Create event for unknown content types."""
518
725
  content_type = content.__class__.__name__
519
- text = f"⚠️ Unknown content type: {content_type}\\n"
726
+ text = f"⚠️ Unknown content type: {content_type}\n"
520
727
  return self._create_text_delta_event(text, context)
521
728
 
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
729
  async def _create_error_response(self, error_message: str, request: AgentFrameworkRequest) -> OpenAIResponse:
527
730
  """Create error response."""
528
731
  error_text = f"Error: {error_message}"