ygo74-agent-runtime-agentframework 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ygo74/agent_runtime/integrations/agentframework/__init__.py +23 -0
- ygo74/agent_runtime/integrations/agentframework/approval.py +193 -0
- ygo74/agent_runtime/integrations/agentframework/binding.py +544 -0
- ygo74/agent_runtime/integrations/agentframework/conversion.py +75 -0
- ygo74/agent_runtime/integrations/agentframework/output_adapter.py +422 -0
- ygo74/agent_runtime/integrations/agentframework/progress.py +313 -0
- ygo74/agent_runtime/integrations/agentframework/py.typed +0 -0
- ygo74/agent_runtime/integrations/agentframework/stream_adapter.py +190 -0
- ygo74/agent_runtime/integrations/agentframework/worker.py +155 -0
- ygo74_agent_runtime_agentframework-0.8.0.dist-info/METADATA +39 -0
- ygo74_agent_runtime_agentframework-0.8.0.dist-info/RECORD +13 -0
- ygo74_agent_runtime_agentframework-0.8.0.dist-info/WHEEL +5 -0
- ygo74_agent_runtime_agentframework-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Conversions of the Microsoft Agent Framework 1.18 unified Content API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
from agent_framework import AgentResponse, Content, UsageDetails
|
|
14
|
+
from ygo74.agent_runtime.domains.contracts.agent_output import (
|
|
15
|
+
AgentContent,
|
|
16
|
+
AgentOutput,
|
|
17
|
+
AudioContent,
|
|
18
|
+
ImageContent,
|
|
19
|
+
ReasoningContent,
|
|
20
|
+
Termination,
|
|
21
|
+
TerminationStatus,
|
|
22
|
+
TextContent,
|
|
23
|
+
TokenUsage,
|
|
24
|
+
ToolCallContent,
|
|
25
|
+
ToolExecution,
|
|
26
|
+
ToolResultContent,
|
|
27
|
+
)
|
|
28
|
+
from ygo74.agent_runtime.domains.contracts.error_envelope import ErrorEnvelope
|
|
29
|
+
from ygo74.agent_runtime.domains.contracts.json_value import JsonValue
|
|
30
|
+
from ygo74.agent_runtime.domains.contracts.media_content import (
|
|
31
|
+
AudioFormat,
|
|
32
|
+
EncodedMedia,
|
|
33
|
+
MediaUri,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from .conversion import (
|
|
37
|
+
ConversionDecision,
|
|
38
|
+
ConversionReason,
|
|
39
|
+
ConversionStatus,
|
|
40
|
+
OutputConversion,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NativeContentType(StrEnum):
|
|
45
|
+
"""Names Agent Framework content variants understood by the neutral output adapter.
|
|
46
|
+
"""
|
|
47
|
+
TEXT = "text"
|
|
48
|
+
REASONING = "text_reasoning"
|
|
49
|
+
DATA = "data"
|
|
50
|
+
URI = "uri"
|
|
51
|
+
CALL = "function_call"
|
|
52
|
+
RESULT = "function_result"
|
|
53
|
+
USAGE = "usage"
|
|
54
|
+
ERROR = "error"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NativeFinishReason(StrEnum):
|
|
58
|
+
"""Maps supported Agent Framework finish reasons to neutral termination states.
|
|
59
|
+
"""
|
|
60
|
+
STOP = "stop"
|
|
61
|
+
LENGTH = "length"
|
|
62
|
+
TOOL_CALLS = "tool_calls"
|
|
63
|
+
FILTER = "content_filter"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class _MappedContent:
|
|
68
|
+
"""Carries the converted content, conversion decision, usage, and termination extracted from one framework item.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
decision (ConversionDecision): Support or conversion decision produced for the current item.
|
|
72
|
+
content (AgentContent | None): The content item being interpreted or projected.
|
|
73
|
+
usage (TokenUsage | None): Token counters supplied by the framework or provider.
|
|
74
|
+
termination (Termination | None): Terminal outcome used to complete the result or stream.
|
|
75
|
+
"""
|
|
76
|
+
decision: ConversionDecision
|
|
77
|
+
content: AgentContent | None = None
|
|
78
|
+
usage: TokenUsage | None = None
|
|
79
|
+
termination: Termination | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _ContentMapper:
|
|
83
|
+
"""Maps Agent Framework content and metadata into typed provider-neutral values and validates JSON-compatible arguments.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
|
|
87
|
+
tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
|
|
88
|
+
"""
|
|
89
|
+
_USAGE_KEYS: tuple[str, ...] = (
|
|
90
|
+
"input_token_count", "output_token_count", "total_token_count",
|
|
91
|
+
"cache_read_input_token_count", "reasoning_output_token_count",
|
|
92
|
+
)
|
|
93
|
+
_OPTIONAL_USAGE_KEYS: tuple[str, ...] = (
|
|
94
|
+
"total_token_count", "cache_read_input_token_count", "reasoning_output_token_count",
|
|
95
|
+
)
|
|
96
|
+
_AUDIO_FORMATS: Mapping[str, AudioFormat] = {
|
|
97
|
+
"audio/wav": AudioFormat.WAV,
|
|
98
|
+
"audio/x-wav": AudioFormat.WAV,
|
|
99
|
+
"audio/mpeg": AudioFormat.MP3,
|
|
100
|
+
"audio/mp3": AudioFormat.MP3,
|
|
101
|
+
"audio/flac": AudioFormat.FLAC,
|
|
102
|
+
"audio/opus": AudioFormat.OPUS,
|
|
103
|
+
"audio/aac": AudioFormat.AAC,
|
|
104
|
+
"audio/pcm": AudioFormat.PCM16,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def __init__(self, *, expose_reasoning: bool, tool_execution: ToolExecution) -> None:
|
|
108
|
+
"""Initialize the instance framework output with supplied collaborators and configuration.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
|
|
112
|
+
tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
|
|
113
|
+
"""
|
|
114
|
+
self.expose_reasoning = expose_reasoning
|
|
115
|
+
self.tool_execution = tool_execution
|
|
116
|
+
self.tool_names: dict[str, str] = {}
|
|
117
|
+
self._usage_details: dict[str, int] = {}
|
|
118
|
+
self._unknown_usage: set[str] = set()
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _decision(
|
|
122
|
+
content: Content, identity: str, reason: ConversionReason,
|
|
123
|
+
status: ConversionStatus = ConversionStatus.UNSUPPORTED,
|
|
124
|
+
) -> _MappedContent:
|
|
125
|
+
"""Create a conversion decision for the native content type and its support outcome.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
content (Content): The content item being interpreted or projected.
|
|
129
|
+
identity (str): Identifier required to correlate a route, content item, tool call, or user.
|
|
130
|
+
reason (ConversionReason): Reason code or message associated with this decision or failure.
|
|
131
|
+
status (ConversionStatus): Success, incomplete, or failed outcome for the operation.
|
|
132
|
+
"""
|
|
133
|
+
return _MappedContent(ConversionDecision(identity, content.type, status, reason))
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def annotation_decision(content: Content, identity: str) -> ConversionDecision | None:
|
|
137
|
+
"""Report whether native annotations can be represented as neutral citations.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
content (Content): The content item being interpreted or projected.
|
|
141
|
+
identity (str): Identifier required to correlate a route, content item, tool call, or user.
|
|
142
|
+
"""
|
|
143
|
+
if not content.annotations:
|
|
144
|
+
return None
|
|
145
|
+
return ConversionDecision(
|
|
146
|
+
f"{identity}:annotations", "annotations", ConversionStatus.UNSUPPORTED,
|
|
147
|
+
ConversionReason.UNSUPPORTED_ANNOTATIONS,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def json_value(value: object) -> JsonValue:
|
|
152
|
+
"""Convert recursive framework values to JSON-compatible scalars, objects, and arrays or reject them.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
value (object): The value being converted, checked, or serialized.
|
|
156
|
+
"""
|
|
157
|
+
# Accept JSON scalars, finite numbers, string-keyed objects, and arrays recursively; reject values that cannot be represented faithfully as JSON.
|
|
158
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
159
|
+
return value
|
|
160
|
+
if isinstance(value, float) and math.isfinite(value):
|
|
161
|
+
return value
|
|
162
|
+
if isinstance(value, Mapping):
|
|
163
|
+
result: dict[str, JsonValue] = {}
|
|
164
|
+
for key, item in value.items():
|
|
165
|
+
if not isinstance(key, str):
|
|
166
|
+
raise TypeError("JSON object keys must be strings")
|
|
167
|
+
result[key] = _ContentMapper.json_value(item)
|
|
168
|
+
return result
|
|
169
|
+
if isinstance(value, (list, tuple)):
|
|
170
|
+
return [_ContentMapper.json_value(item) for item in value]
|
|
171
|
+
raise ValueError("Value is not JSON-compatible")
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def usage(details: Mapping[str, object] | None) -> TokenUsage | None:
|
|
175
|
+
"""Project usage information framework output into the typed usage contract or provider-specific counters.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
details (Mapping[str, object] | None): Framework-provided token counters to validate and aggregate.
|
|
179
|
+
"""
|
|
180
|
+
if details is None:
|
|
181
|
+
return None
|
|
182
|
+
incoming = details.get("input_token_count")
|
|
183
|
+
outgoing = details.get("output_token_count")
|
|
184
|
+
total = details.get("total_token_count")
|
|
185
|
+
cached = details.get("cache_read_input_token_count")
|
|
186
|
+
reasoning = details.get("reasoning_output_token_count")
|
|
187
|
+
if any(isinstance(count, bool) or not isinstance(count, int) or count < 0
|
|
188
|
+
for count in (incoming, outgoing)):
|
|
189
|
+
return None
|
|
190
|
+
if any(value is not None and (
|
|
191
|
+
isinstance(value, bool) or not isinstance(value, int) or value < 0
|
|
192
|
+
) for value in (total, cached, reasoning)):
|
|
193
|
+
return None
|
|
194
|
+
assert isinstance(incoming, int) and isinstance(outgoing, int)
|
|
195
|
+
assert total is None or isinstance(total, int)
|
|
196
|
+
assert cached is None or isinstance(cached, int)
|
|
197
|
+
assert reasoning is None or isinstance(reasoning, int)
|
|
198
|
+
return TokenUsage(
|
|
199
|
+
incoming, outgoing, total,
|
|
200
|
+
cached_input_tokens=cached, reasoning_output_tokens=reasoning,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def termination(reason: str | None) -> Termination:
|
|
205
|
+
"""Project termination status framework output into the provider or framework representation.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
reason (str | None): Reason code or message associated with this decision or failure.
|
|
209
|
+
"""
|
|
210
|
+
if reason in (NativeFinishReason.LENGTH, NativeFinishReason.FILTER):
|
|
211
|
+
return Termination(TerminationStatus.INCOMPLETE, reason=reason)
|
|
212
|
+
if reason is None or reason in (NativeFinishReason.STOP, NativeFinishReason.TOOL_CALLS):
|
|
213
|
+
return Termination()
|
|
214
|
+
return Termination(TerminationStatus.INCOMPLETE, reason="unknown_sdk_finish_reason")
|
|
215
|
+
|
|
216
|
+
def _consume_usage(self, details: UsageDetails | None) -> TokenUsage | None:
|
|
217
|
+
"""Accumulate valid framework token counters while preserving unknown optional totals.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
details (UsageDetails | None): Framework-provided token counters to validate and aggregate.
|
|
221
|
+
"""
|
|
222
|
+
# Validate counters before accumulating them, and remember missing optional counters so the combined usage never presents an unknown total as complete.
|
|
223
|
+
if details is None:
|
|
224
|
+
return None
|
|
225
|
+
for key in self._USAGE_KEYS:
|
|
226
|
+
value = details.get(key)
|
|
227
|
+
if value is None:
|
|
228
|
+
continue
|
|
229
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
230
|
+
return None
|
|
231
|
+
for key in self._OPTIONAL_USAGE_KEYS:
|
|
232
|
+
if details.get(key) is None:
|
|
233
|
+
self._unknown_usage.add(key)
|
|
234
|
+
for key in self._USAGE_KEYS:
|
|
235
|
+
value = details.get(key)
|
|
236
|
+
if isinstance(value, int):
|
|
237
|
+
self._usage_details[key] = (self._usage_details.get(key) or 0) + value
|
|
238
|
+
return self.usage({key: value for key, value in self._usage_details.items()
|
|
239
|
+
if key not in self._unknown_usage})
|
|
240
|
+
|
|
241
|
+
def map(self, content: Content, identity: str) -> _MappedContent:
|
|
242
|
+
"""Map framework output between the source representation and the target contract.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
content (Content): The content item being interpreted or projected.
|
|
246
|
+
identity (str): Identifier required to correlate a route, content item, tool call, or user.
|
|
247
|
+
"""
|
|
248
|
+
decision = ConversionDecision(identity, content.type, ConversionStatus.CONVERTED, ConversionReason.SUPPORTED)
|
|
249
|
+
if content.type == NativeContentType.TEXT.value:
|
|
250
|
+
return _MappedContent(decision, TextContent(content.text or ""))
|
|
251
|
+
if content.type == NativeContentType.REASONING.value:
|
|
252
|
+
if content.protected_data is not None:
|
|
253
|
+
return self._decision(content, identity, ConversionReason.PROTECTED_REASONING, ConversionStatus.EXCLUDED)
|
|
254
|
+
if not self.expose_reasoning:
|
|
255
|
+
return self._decision(content, identity, ConversionReason.REASONING_NOT_SELECTED, ConversionStatus.EXCLUDED)
|
|
256
|
+
return _MappedContent(decision, ReasoningContent(content.text or "", exposable=True))
|
|
257
|
+
if content.type == NativeContentType.CALL.value:
|
|
258
|
+
return self._call(content, identity, decision)
|
|
259
|
+
if content.type == NativeContentType.RESULT.value:
|
|
260
|
+
return self._result(content, identity, decision)
|
|
261
|
+
if content.type in (NativeContentType.DATA, NativeContentType.URI):
|
|
262
|
+
return self._media(content, identity, decision)
|
|
263
|
+
if content.type == NativeContentType.USAGE.value:
|
|
264
|
+
usage = self._consume_usage(content.usage_details)
|
|
265
|
+
if usage is None:
|
|
266
|
+
return self._decision(content, identity, ConversionReason.INCOMPLETE_USAGE)
|
|
267
|
+
return _MappedContent(decision, usage=usage)
|
|
268
|
+
if content.type == NativeContentType.ERROR.value:
|
|
269
|
+
return _MappedContent(decision, termination=Termination(
|
|
270
|
+
TerminationStatus.FAILED, reason="sdk_error",
|
|
271
|
+
error=ErrorEnvelope(
|
|
272
|
+
code=content.error_code or "agent_framework_error",
|
|
273
|
+
category="handler_execution", message=content.message or "Agent Framework execution failed",
|
|
274
|
+
),
|
|
275
|
+
))
|
|
276
|
+
return self._decision(content, identity, ConversionReason.UNKNOWN_CONTENT)
|
|
277
|
+
|
|
278
|
+
def _call(self, content: Content, identity: str, decision: ConversionDecision) -> _MappedContent:
|
|
279
|
+
"""Convert a framework function call to typed tool-call content with its execution ownership.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
content (Content): The content item being interpreted or projected.
|
|
283
|
+
identity (str): Identifier required to correlate a route, content item, tool call, or user.
|
|
284
|
+
decision (ConversionDecision): Support or conversion decision produced for the current item.
|
|
285
|
+
"""
|
|
286
|
+
if not content.call_id or not content.name:
|
|
287
|
+
return self._decision(content, identity, ConversionReason.MISSING_TOOL_IDENTITY)
|
|
288
|
+
if content.exception:
|
|
289
|
+
return self._decision(content, identity, ConversionReason.TOOL_ERROR)
|
|
290
|
+
try:
|
|
291
|
+
arguments = self.json_value(json.loads(content.arguments) if isinstance(content.arguments, str)
|
|
292
|
+
else content.arguments if content.arguments is not None else {})
|
|
293
|
+
except (ValueError, TypeError):
|
|
294
|
+
return self._decision(content, identity, ConversionReason.INVALID_JSON)
|
|
295
|
+
self.tool_names[content.call_id] = content.name
|
|
296
|
+
return _MappedContent(decision, ToolCallContent(
|
|
297
|
+
content.call_id, content.name, arguments, execution=self.tool_execution,
|
|
298
|
+
))
|
|
299
|
+
|
|
300
|
+
def _result(self, content: Content, identity: str, decision: ConversionDecision) -> _MappedContent:
|
|
301
|
+
"""Convert a framework function result and correlate it with the original tool call.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
content (Content): The content item being interpreted or projected.
|
|
305
|
+
identity (str): Identifier required to correlate a route, content item, tool call, or user.
|
|
306
|
+
decision (ConversionDecision): Support or conversion decision produced for the current item.
|
|
307
|
+
"""
|
|
308
|
+
name = content.name or self.tool_names.get(content.call_id or "")
|
|
309
|
+
if not content.call_id or not name:
|
|
310
|
+
return self._decision(content, identity, ConversionReason.MISSING_TOOL_IDENTITY)
|
|
311
|
+
if content.exception:
|
|
312
|
+
return self._decision(content, identity, ConversionReason.TOOL_ERROR)
|
|
313
|
+
if content.items is not None:
|
|
314
|
+
if any(item.type != NativeContentType.TEXT.value for item in content.items):
|
|
315
|
+
return self._decision(content, identity, ConversionReason.STRUCTURED_TOOL_RESULT)
|
|
316
|
+
result: JsonValue = "\n".join(item.text or "" for item in content.items)
|
|
317
|
+
else:
|
|
318
|
+
try:
|
|
319
|
+
result = self.json_value(content.result)
|
|
320
|
+
except (ValueError, TypeError):
|
|
321
|
+
return self._decision(content, identity, ConversionReason.INVALID_JSON)
|
|
322
|
+
return _MappedContent(decision, ToolResultContent(
|
|
323
|
+
content.call_id, name, result, execution=self.tool_execution,
|
|
324
|
+
))
|
|
325
|
+
|
|
326
|
+
def _media(self, content: Content, identity: str, decision: ConversionDecision) -> _MappedContent:
|
|
327
|
+
"""Convert native media references or encoded data while preserving MIME and format metadata.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
content (Content): The content item being interpreted or projected.
|
|
331
|
+
identity (str): Identifier required to correlate a route, content item, tool call, or user.
|
|
332
|
+
decision (ConversionDecision): Support or conversion decision produced for the current item.
|
|
333
|
+
"""
|
|
334
|
+
uri, mime = content.uri, content.media_type
|
|
335
|
+
if not uri or not mime:
|
|
336
|
+
return self._decision(content, identity, ConversionReason.UNSUPPORTED_MEDIA)
|
|
337
|
+
source: MediaUri | EncodedMedia
|
|
338
|
+
if uri.startswith("data:"):
|
|
339
|
+
header, separator, data = uri.partition(",")
|
|
340
|
+
if not separator or header != f"data:{mime};base64":
|
|
341
|
+
return self._decision(content, identity, ConversionReason.UNSUPPORTED_MEDIA)
|
|
342
|
+
try:
|
|
343
|
+
base64.b64decode(data, validate=True)
|
|
344
|
+
except (ValueError, binascii.Error):
|
|
345
|
+
return self._decision(content, identity, ConversionReason.UNSUPPORTED_MEDIA)
|
|
346
|
+
source = EncodedMedia(data, mime)
|
|
347
|
+
else:
|
|
348
|
+
source = MediaUri(uri, mime)
|
|
349
|
+
if mime.startswith("image/"):
|
|
350
|
+
return _MappedContent(decision, ImageContent(source))
|
|
351
|
+
audio_format = self._AUDIO_FORMATS.get(mime)
|
|
352
|
+
if audio_format is not None:
|
|
353
|
+
return _MappedContent(decision, AudioContent(source, content.id or identity, format=audio_format))
|
|
354
|
+
return self._decision(content, identity, ConversionReason.UNSUPPORTED_MEDIA)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class AgentFrameworkOutputAdapter:
|
|
358
|
+
"""Convert final SDK messages; reasoning exposure and client tools are opt-in.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
|
|
362
|
+
tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
|
|
363
|
+
"""
|
|
364
|
+
def __init__(
|
|
365
|
+
self, *, expose_reasoning: bool = False,
|
|
366
|
+
tool_execution: ToolExecution = ToolExecution.INTERNAL,
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Initialize the instance framework output with supplied collaborators and configuration.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
|
|
372
|
+
tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
|
|
373
|
+
"""
|
|
374
|
+
self._expose_reasoning = expose_reasoning
|
|
375
|
+
self._tool_execution = tool_execution
|
|
376
|
+
|
|
377
|
+
def convert(self, response: AgentResponse) -> OutputConversion:
|
|
378
|
+
"""Convert assistant and tool messages into the provider-neutral result contract in source order.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
response (AgentResponse): The response value to validate, transform, or return.
|
|
382
|
+
"""
|
|
383
|
+
# Preserve message and content order while excluding non-output roles; collect conversion diagnostics alongside typed content and prefer the response-level usage snapshot.
|
|
384
|
+
if not isinstance(response, AgentResponse):
|
|
385
|
+
raise TypeError("response must be an AgentResponse")
|
|
386
|
+
mapper = _ContentMapper(expose_reasoning=self._expose_reasoning, tool_execution=self._tool_execution)
|
|
387
|
+
contents: list[AgentContent] = []
|
|
388
|
+
decisions: list[ConversionDecision] = []
|
|
389
|
+
usage = mapper.usage(response.usage_details)
|
|
390
|
+
termination = mapper.termination(response.finish_reason)
|
|
391
|
+
for message_index, message in enumerate(response.messages):
|
|
392
|
+
for content_index, content in enumerate(message.contents):
|
|
393
|
+
identity = f"{message.message_id or f'message-{message_index}'}:content-{content_index}"
|
|
394
|
+
if message.role not in ("assistant", "tool"):
|
|
395
|
+
decisions.append(ConversionDecision(
|
|
396
|
+
identity, content.type, ConversionStatus.EXCLUDED, ConversionReason.NON_OUTPUT_ROLE,
|
|
397
|
+
))
|
|
398
|
+
continue
|
|
399
|
+
mapped = mapper.map(content, identity)
|
|
400
|
+
decisions.append(mapped.decision)
|
|
401
|
+
annotation_decision = mapper.annotation_decision(content, identity)
|
|
402
|
+
if annotation_decision is not None:
|
|
403
|
+
decisions.append(annotation_decision)
|
|
404
|
+
if mapped.content is not None:
|
|
405
|
+
contents.append(mapped.content)
|
|
406
|
+
if mapped.usage is not None and response.usage_details is None:
|
|
407
|
+
usage = mapped.usage
|
|
408
|
+
if mapped.termination is not None:
|
|
409
|
+
termination = mapped.termination
|
|
410
|
+
if response.usage_details is not None and usage is None:
|
|
411
|
+
decisions.append(ConversionDecision(
|
|
412
|
+
"response:usage", NativeContentType.USAGE, ConversionStatus.UNSUPPORTED, ConversionReason.INCOMPLETE_USAGE,
|
|
413
|
+
))
|
|
414
|
+
if response.value is not None:
|
|
415
|
+
decisions.append(ConversionDecision(
|
|
416
|
+
"response:value", "value", ConversionStatus.UNSUPPORTED, ConversionReason.STRUCTURED_RESPONSE_VALUE,
|
|
417
|
+
))
|
|
418
|
+
if response.finish_reason is not None and response.finish_reason not in NativeFinishReason:
|
|
419
|
+
decisions.append(ConversionDecision(
|
|
420
|
+
"response:finish", "finish_reason", ConversionStatus.UNSUPPORTED, ConversionReason.UNKNOWN_FINISH_REASON,
|
|
421
|
+
))
|
|
422
|
+
return OutputConversion(AgentOutput(tuple(contents), usage, termination), tuple(decisions))
|