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.
- agent_framework_devui/_conversations.py +473 -0
- agent_framework_devui/_discovery.py +295 -325
- agent_framework_devui/_executor.py +115 -246
- agent_framework_devui/_mapper.py +747 -88
- agent_framework_devui/_server.py +275 -240
- agent_framework_devui/_utils.py +150 -1
- agent_framework_devui/models/__init__.py +21 -10
- agent_framework_devui/models/_discovery_models.py +1 -2
- agent_framework_devui/models/_openai_custom.py +103 -83
- agent_framework_devui/ui/assets/index-CE4pGoXh.css +1 -0
- agent_framework_devui/ui/assets/index-D_Y1oSGu.js +577 -0
- agent_framework_devui/ui/index.html +2 -2
- agent_framework_devui-1.0.0b251028.dist-info/METADATA +321 -0
- agent_framework_devui-1.0.0b251028.dist-info/RECORD +23 -0
- agent_framework_devui/ui/assets/index-D0SfShuZ.js +0 -445
- agent_framework_devui/ui/assets/index-WsCIE0bH.css +0 -1
- agent_framework_devui-1.0.0b251007.dist-info/METADATA +0 -172
- agent_framework_devui-1.0.0b251007.dist-info/RECORD +0 -22
- {agent_framework_devui-1.0.0b251007.dist-info → agent_framework_devui-1.0.0b251028.dist-info}/WHEEL +0 -0
- {agent_framework_devui-1.0.0b251007.dist-info → agent_framework_devui-1.0.0b251028.dist-info}/entry_points.txt +0 -0
- {agent_framework_devui-1.0.0b251007.dist-info → agent_framework_devui-1.0.0b251028.dist-info}/licenses/LICENSE +0 -0
agent_framework_devui/_mapper.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
type="response.
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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]) ->
|
|
371
|
-
"""
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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":
|
|
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}
|
|
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"
|
|
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}"
|