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,544 @@
|
|
|
1
|
+
"""Native MAF execution with one lifecycle driver for both response modes."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncGenerator, Callable
|
|
5
|
+
from contextlib import aclosing
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from agent_framework import (
|
|
11
|
+
Agent,
|
|
12
|
+
AgentResponse,
|
|
13
|
+
AgentSession,
|
|
14
|
+
BaseChatClient,
|
|
15
|
+
Message,
|
|
16
|
+
SupportsAgentRun,
|
|
17
|
+
)
|
|
18
|
+
from ygo74.agent_runtime.domains.contracts.agent_output import (
|
|
19
|
+
AgentOutput,
|
|
20
|
+
Termination,
|
|
21
|
+
TerminationStatus,
|
|
22
|
+
TextContent,
|
|
23
|
+
TokenUsage,
|
|
24
|
+
ToolCallContent,
|
|
25
|
+
ToolResultContent,
|
|
26
|
+
)
|
|
27
|
+
from ygo74.agent_runtime.domains.contracts.error_envelope import ErrorEnvelope
|
|
28
|
+
from ygo74.agent_runtime.domains.contracts.stream_events import (
|
|
29
|
+
AgentStreamEvent,
|
|
30
|
+
ContentEnd,
|
|
31
|
+
ContentEvent,
|
|
32
|
+
ContentStart,
|
|
33
|
+
TerminalEvent,
|
|
34
|
+
ToolArgumentsDelta,
|
|
35
|
+
UsageEvent,
|
|
36
|
+
)
|
|
37
|
+
from ygo74.agent_runtime.domains.humanapproval.approval_loop import (
|
|
38
|
+
DEFAULT_EXHAUSTED_MESSAGE,
|
|
39
|
+
DEFAULT_INTERRUPTED_MESSAGE,
|
|
40
|
+
ApprovalBudget,
|
|
41
|
+
ApprovalLoop,
|
|
42
|
+
)
|
|
43
|
+
from ygo74.agent_runtime.domains.sessions.continuity_diagnostics import (
|
|
44
|
+
ContinuationReference,
|
|
45
|
+
ContinuityDiagnostics,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
from .approval import ApprovalResolver, MafApprovalTranslator, PendingToolApproval
|
|
49
|
+
from .output_adapter import AgentFrameworkOutputAdapter
|
|
50
|
+
from .progress import ConversionDiagnostics, NativeStreamScope, ToolProgressPolicy
|
|
51
|
+
from .stream_adapter import AgentFrameworkStreamAdapter
|
|
52
|
+
|
|
53
|
+
_logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class _Round:
|
|
58
|
+
"""One native sub-run result.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
response: Native state used by the optional approval bridge.
|
|
62
|
+
output: Converted safe output and real reported usage.
|
|
63
|
+
local_approval: SDK-only approval dispatch with no provider invocation.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
response: AgentResponse[Any]
|
|
67
|
+
output: AgentOutput
|
|
68
|
+
local_approval: bool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class UsageAccumulator:
|
|
72
|
+
"""Sum real sub-run counts, preserving unknown optional counters."""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
"""Initialize an invocation with no reported usage."""
|
|
76
|
+
self.usage: TokenUsage | None = None
|
|
77
|
+
self._unreported = False
|
|
78
|
+
|
|
79
|
+
def add(self, usage: TokenUsage | None) -> None:
|
|
80
|
+
"""Add each sub-run exactly once.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
usage: Final native snapshot, or last update snapshot if absent.
|
|
84
|
+
"""
|
|
85
|
+
if usage is None:
|
|
86
|
+
if self.usage is not None:
|
|
87
|
+
raise RuntimeError("Native sub-runs must report usage consistently")
|
|
88
|
+
self._unreported = True
|
|
89
|
+
return
|
|
90
|
+
if self._unreported:
|
|
91
|
+
raise RuntimeError("Native sub-runs must report usage consistently")
|
|
92
|
+
if self.usage is None:
|
|
93
|
+
self.usage = usage
|
|
94
|
+
return
|
|
95
|
+
previous = self.usage
|
|
96
|
+
optional = (
|
|
97
|
+
"total_tokens", "cached_input_tokens", "reasoning_output_tokens", "cache_write_input_tokens",
|
|
98
|
+
)
|
|
99
|
+
counts = [None if getattr(previous, key) is None or getattr(usage, key) is None
|
|
100
|
+
else getattr(previous, key) + getattr(usage, key) for key in optional]
|
|
101
|
+
self.usage = TokenUsage(previous.input_tokens + usage.input_tokens,
|
|
102
|
+
previous.output_tokens + usage.output_tokens, *counts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AgentFrameworkSession:
|
|
106
|
+
"""Bind an existing native Agent; no skills, registry or HTTP knowledge needed.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
agent: Existing MAF agent, never reconstructed by the binding.
|
|
110
|
+
session_id: Conversation history identifier.
|
|
111
|
+
resolver: Optional advanced approval resolver.
|
|
112
|
+
translator: Optional native approval translator.
|
|
113
|
+
discard_authorizations: Domain grant cleanup for error/cancellation.
|
|
114
|
+
tool_names: Admitted tool names eligible for safe activity.
|
|
115
|
+
max_approval_rounds: Maximum actual human-question batches.
|
|
116
|
+
max_total_rounds: Maximum approval batches.
|
|
117
|
+
interrupted_message: Optional domain wording for a question-budget stop.
|
|
118
|
+
exhausted_message: Optional domain wording for a total-budget stop.
|
|
119
|
+
require_provider_continuation: Fail closed when provider-only history is unavailable.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self, agent: SupportsAgentRun, *, session_id: str | None = None,
|
|
124
|
+
resolver: ApprovalResolver | None = None, translator: MafApprovalTranslator | None = None,
|
|
125
|
+
discard_authorizations: Callable[[], None] | None = None, tool_names: tuple[str, ...] = (),
|
|
126
|
+
max_approval_rounds: int = 25, max_total_rounds: int = 200,
|
|
127
|
+
interrupted_message: str | None = None, exhausted_message: str | None = None,
|
|
128
|
+
require_provider_continuation: bool = False,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Create a native session and the shared approval driver.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
agent: Native agent to execute.
|
|
134
|
+
session_id: Native conversation identifier.
|
|
135
|
+
resolver: Optional advanced resolver.
|
|
136
|
+
translator: Optional bridge translator.
|
|
137
|
+
discard_authorizations: Deterministic fail-closed grant cleanup.
|
|
138
|
+
tool_names: Trusted public activity names.
|
|
139
|
+
max_approval_rounds: Prompt bound.
|
|
140
|
+
max_total_rounds: Resume bound.
|
|
141
|
+
interrupted_message: Optional prompt-limit explanation.
|
|
142
|
+
exhausted_message: Optional resume-limit explanation.
|
|
143
|
+
require_provider_continuation: Explicit provider-only history profile; never enables local fallback.
|
|
144
|
+
"""
|
|
145
|
+
self._agent = agent
|
|
146
|
+
self._require_provider_continuation = require_provider_continuation
|
|
147
|
+
if require_provider_continuation and isinstance(agent, Agent) and agent.default_options.get("store") is False:
|
|
148
|
+
raise ValueError("Provider history profile conflicts with local storage")
|
|
149
|
+
self._instance = uuid4().hex
|
|
150
|
+
self._turn = 0
|
|
151
|
+
self._subrun = 0
|
|
152
|
+
if isinstance(agent, Agent):
|
|
153
|
+
NativeStreamScope.attach(agent.client)
|
|
154
|
+
self._session: AgentSession = agent.create_session(session_id=session_id)
|
|
155
|
+
self._diagnose("create")
|
|
156
|
+
self._resolver = resolver
|
|
157
|
+
self._translator = translator or MafApprovalTranslator()
|
|
158
|
+
self._discard = discard_authorizations
|
|
159
|
+
self._tool_names = tool_names
|
|
160
|
+
self._broken = False
|
|
161
|
+
self._cleanup_usage: list[TokenUsage | None] = []
|
|
162
|
+
self._cleanup_failure: Termination | None = None
|
|
163
|
+
self._loop = ApprovalLoop(self, max_approval_rounds=max_approval_rounds,
|
|
164
|
+
max_total_rounds=max_total_rounds,
|
|
165
|
+
interrupted_message=interrupted_message or DEFAULT_INTERRUPTED_MESSAGE,
|
|
166
|
+
exhausted_message=exhausted_message or DEFAULT_EXHAUSTED_MESSAGE)
|
|
167
|
+
|
|
168
|
+
async def ask(self, message: str) -> str:
|
|
169
|
+
"""Answer a normal native run.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
message: Latest user text; history remains server-managed.
|
|
173
|
+
"""
|
|
174
|
+
output = await self.ask_output(message)
|
|
175
|
+
if output.termination.status is TerminationStatus.FAILED:
|
|
176
|
+
raise RuntimeError("Native agent execution failed")
|
|
177
|
+
return "".join(item.text for item in output.contents if isinstance(item, TextContent))
|
|
178
|
+
|
|
179
|
+
async def ask_output(self, message: str) -> AgentOutput:
|
|
180
|
+
"""Return typed output, preserving native termination and usage.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
message: Latest user text.
|
|
184
|
+
"""
|
|
185
|
+
async with aclosing(self._execute(message, stream=False)) as execution:
|
|
186
|
+
async for item in execution:
|
|
187
|
+
if isinstance(item, AgentOutput):
|
|
188
|
+
return item
|
|
189
|
+
raise RuntimeError("native execution produced no output")
|
|
190
|
+
|
|
191
|
+
async def ask_stream(
|
|
192
|
+
self, message: str, *, require_initial_usage: bool = False,
|
|
193
|
+
) -> AsyncGenerator[AgentStreamEvent]:
|
|
194
|
+
"""Stream real native updates through existing adapters.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
message: Latest user text.
|
|
198
|
+
require_initial_usage: Hold visible events until this stream reports real token counts.
|
|
199
|
+
"""
|
|
200
|
+
async with aclosing(self._execute(
|
|
201
|
+
message, stream=True, require_initial_usage=require_initial_usage,
|
|
202
|
+
)) as execution:
|
|
203
|
+
async for item in execution:
|
|
204
|
+
if not isinstance(item, AgentOutput):
|
|
205
|
+
yield item
|
|
206
|
+
|
|
207
|
+
async def _execute(
|
|
208
|
+
self, message: str, *, stream: bool, require_initial_usage: bool = False,
|
|
209
|
+
) -> AsyncGenerator[AgentStreamEvent | AgentOutput]:
|
|
210
|
+
"""Own native sub-runs and exactly one invocation terminal.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
message: Initial user text.
|
|
214
|
+
stream: Native response mode, not an output simulation flag.
|
|
215
|
+
require_initial_usage: Hold visible events until actual usage is available.
|
|
216
|
+
"""
|
|
217
|
+
if self._broken:
|
|
218
|
+
raise RuntimeError("native session was invalidated; create a fresh conversation")
|
|
219
|
+
budget, usage = ApprovalBudget(), UsageAccumulator()
|
|
220
|
+
self._turn += 1
|
|
221
|
+
self._subrun = 0
|
|
222
|
+
self._cleanup_usage.clear()
|
|
223
|
+
self._cleanup_failure = None
|
|
224
|
+
current: str | Message = message
|
|
225
|
+
completed = False
|
|
226
|
+
last_usage: TokenUsage | None = None
|
|
227
|
+
try:
|
|
228
|
+
while True:
|
|
229
|
+
result: _Round | None = None
|
|
230
|
+
async with aclosing(self._round(
|
|
231
|
+
current,
|
|
232
|
+
stream=stream,
|
|
233
|
+
previous_usage=usage.usage,
|
|
234
|
+
require_initial_usage=require_initial_usage,
|
|
235
|
+
)) as round_stream:
|
|
236
|
+
async for item in round_stream:
|
|
237
|
+
if isinstance(item, _Round):
|
|
238
|
+
result = item
|
|
239
|
+
else:
|
|
240
|
+
if isinstance(item, UsageEvent):
|
|
241
|
+
last_usage = item.usage
|
|
242
|
+
yield item
|
|
243
|
+
if result is None:
|
|
244
|
+
raise RuntimeError("native sub-run produced no response")
|
|
245
|
+
if not result.local_approval:
|
|
246
|
+
usage.add(result.output.usage)
|
|
247
|
+
output = replace(result.output, usage=usage.usage)
|
|
248
|
+
if output.termination.status is TerminationStatus.FAILED:
|
|
249
|
+
self.discard_authorizations()
|
|
250
|
+
self._broken = True
|
|
251
|
+
self._diagnose("invalidate", reason="native_failed")
|
|
252
|
+
break
|
|
253
|
+
step = await self._loop.advance(result.response, budget)
|
|
254
|
+
if step.interruption is not None:
|
|
255
|
+
try:
|
|
256
|
+
for cleanup_usage in self._cleanup_usage:
|
|
257
|
+
usage.add(cleanup_usage)
|
|
258
|
+
except RuntimeError:
|
|
259
|
+
if self._cleanup_failure is None:
|
|
260
|
+
raise
|
|
261
|
+
# Missing refusal counts cannot replace a native failure
|
|
262
|
+
# with a usage error or make a partial total authoritative.
|
|
263
|
+
usage = UsageAccumulator()
|
|
264
|
+
if self._cleanup_failure is not None:
|
|
265
|
+
output = AgentOutput((), usage.usage, self._cleanup_failure)
|
|
266
|
+
break
|
|
267
|
+
# An interrupted CLI conversation may continue, but must never
|
|
268
|
+
# reuse unresolved SDK approval state after best-effort cleanup.
|
|
269
|
+
self._session = self._agent.create_session(session_id=self._session.session_id)
|
|
270
|
+
self._diagnose("recreate", reason="approval_budget")
|
|
271
|
+
output = AgentOutput((TextContent(step.interruption),), usage.usage,
|
|
272
|
+
Termination(TerminationStatus.INCOMPLETE, "approval_budget"))
|
|
273
|
+
if stream:
|
|
274
|
+
yield ContentEvent(uuid4().hex, TextContent(step.interruption))
|
|
275
|
+
break
|
|
276
|
+
if not step.pending:
|
|
277
|
+
break
|
|
278
|
+
if self._resolver is None:
|
|
279
|
+
raise RuntimeError("native approvals require an explicit approval bridge")
|
|
280
|
+
answers = await self._resolver.resolve(step.pending)
|
|
281
|
+
current = self._translator.answer_message(answers.answers)
|
|
282
|
+
if stream:
|
|
283
|
+
if usage.usage is not None and usage.usage != last_usage:
|
|
284
|
+
yield UsageEvent(usage.usage)
|
|
285
|
+
completed = True
|
|
286
|
+
yield TerminalEvent(output.termination)
|
|
287
|
+
else:
|
|
288
|
+
completed = True
|
|
289
|
+
yield output
|
|
290
|
+
finally:
|
|
291
|
+
if not completed:
|
|
292
|
+
self._broken = True
|
|
293
|
+
self._diagnose("invalidate", reason="error_or_cancel")
|
|
294
|
+
self.discard_authorizations()
|
|
295
|
+
|
|
296
|
+
async def _round(
|
|
297
|
+
self, message: str | Message, *, stream: bool, previous_usage: TokenUsage | None,
|
|
298
|
+
require_initial_usage: bool = False,
|
|
299
|
+
) -> AsyncGenerator[AgentStreamEvent | _Round]:
|
|
300
|
+
"""Convert one sub-run and close producer/content lifecycle on exit.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
message: User text or native approval answers.
|
|
304
|
+
stream: Whether to invoke native streaming.
|
|
305
|
+
previous_usage: Completed sub-run counters, for cumulative snapshots.
|
|
306
|
+
require_initial_usage: Hold visible events until actual usage is available.
|
|
307
|
+
"""
|
|
308
|
+
invocation_id = uuid4().hex
|
|
309
|
+
self._subrun += 1
|
|
310
|
+
self._diagnose("before", stream=stream)
|
|
311
|
+
adapter = AgentFrameworkStreamAdapter()
|
|
312
|
+
progress = ToolProgressPolicy(self._tool_names)
|
|
313
|
+
snapshot: TokenUsage | None = None
|
|
314
|
+
pending_events: list[AgentStreamEvent] = []
|
|
315
|
+
usage_emitted = False
|
|
316
|
+
hidden: set[str] = set()
|
|
317
|
+
terminal: Termination | None = None
|
|
318
|
+
scope = NativeStreamScope(
|
|
319
|
+
session_instance=self._instance, turn=self._turn, subrun=self._subrun,
|
|
320
|
+
storage_default=bool(getattr(getattr(self._agent, "client", None), "STORES_BY_DEFAULT", False)),
|
|
321
|
+
)
|
|
322
|
+
if stream:
|
|
323
|
+
with scope.activate():
|
|
324
|
+
producer = self._agent.run(message, stream=True, session=self._session)
|
|
325
|
+
try:
|
|
326
|
+
async for update in scope.updates(producer):
|
|
327
|
+
for progress_event in progress.observe(update):
|
|
328
|
+
if not require_initial_usage or usage_emitted:
|
|
329
|
+
yield progress_event
|
|
330
|
+
else:
|
|
331
|
+
pending_events.append(progress_event)
|
|
332
|
+
conversion = adapter.convert_update(update)
|
|
333
|
+
ConversionDiagnostics.record(conversion.decisions, invocation_id)
|
|
334
|
+
for event in conversion.events:
|
|
335
|
+
if isinstance(event, UsageEvent):
|
|
336
|
+
snapshot = event.usage
|
|
337
|
+
cumulative = UsageAccumulator()
|
|
338
|
+
if previous_usage is not None:
|
|
339
|
+
cumulative.add(previous_usage)
|
|
340
|
+
cumulative.add(snapshot)
|
|
341
|
+
if cumulative.usage is not None:
|
|
342
|
+
yield UsageEvent(cumulative.usage)
|
|
343
|
+
if require_initial_usage:
|
|
344
|
+
usage_emitted = True
|
|
345
|
+
for pending in pending_events:
|
|
346
|
+
yield pending
|
|
347
|
+
pending_events.clear()
|
|
348
|
+
elif self._visible(event, hidden):
|
|
349
|
+
projected = self._namespace(event, invocation_id)
|
|
350
|
+
if not require_initial_usage or usage_emitted:
|
|
351
|
+
yield projected
|
|
352
|
+
else:
|
|
353
|
+
pending_events.append(projected)
|
|
354
|
+
with scope.activate():
|
|
355
|
+
response = await producer.get_final_response()
|
|
356
|
+
for event in adapter.finish():
|
|
357
|
+
if isinstance(event, TerminalEvent):
|
|
358
|
+
terminal = event.termination
|
|
359
|
+
elif self._visible(event, hidden):
|
|
360
|
+
projected = self._namespace(event, invocation_id)
|
|
361
|
+
if not require_initial_usage or usage_emitted:
|
|
362
|
+
yield projected
|
|
363
|
+
else:
|
|
364
|
+
pending_events.append(projected)
|
|
365
|
+
except Exception as error: # Translate the native execution boundary without exposing provider messages.
|
|
366
|
+
raise RuntimeError("Native agent execution failed") from error
|
|
367
|
+
finally:
|
|
368
|
+
await scope.close(producer)
|
|
369
|
+
else:
|
|
370
|
+
try:
|
|
371
|
+
with scope.activate():
|
|
372
|
+
response = await self._agent.run(message, session=self._session)
|
|
373
|
+
except Exception as error: # Translate the native execution boundary without exposing provider messages.
|
|
374
|
+
raise RuntimeError("Native agent execution failed") from error
|
|
375
|
+
self._diagnose("after", stream=stream)
|
|
376
|
+
self._validate_continuity(scope)
|
|
377
|
+
conversion_output = AgentFrameworkOutputAdapter().convert(response)
|
|
378
|
+
ConversionDiagnostics.record(conversion_output.decisions, invocation_id)
|
|
379
|
+
output = conversion_output.output
|
|
380
|
+
if stream and require_initial_usage and pending_events and not usage_emitted:
|
|
381
|
+
round_usage = output.usage or snapshot
|
|
382
|
+
if round_usage is not None:
|
|
383
|
+
cumulative = UsageAccumulator()
|
|
384
|
+
if previous_usage is not None:
|
|
385
|
+
cumulative.add(previous_usage)
|
|
386
|
+
cumulative.add(round_usage)
|
|
387
|
+
if cumulative.usage is not None:
|
|
388
|
+
yield UsageEvent(cumulative.usage)
|
|
389
|
+
usage_emitted = True
|
|
390
|
+
for pending in pending_events:
|
|
391
|
+
yield pending
|
|
392
|
+
termination = output.termination
|
|
393
|
+
if terminal is not None and terminal.status is not TerminationStatus.SUCCESS:
|
|
394
|
+
termination = terminal
|
|
395
|
+
if termination.error is not None:
|
|
396
|
+
termination = replace(termination, error=ErrorEnvelope(
|
|
397
|
+
code="native_execution_failed", category="handler_execution", message="Native agent execution failed",
|
|
398
|
+
))
|
|
399
|
+
contents = tuple(item for item in output.contents if not isinstance(item, (ToolCallContent, ToolResultContent)))
|
|
400
|
+
local_approval = (
|
|
401
|
+
isinstance(getattr(self._agent, "client", None), BaseChatClient)
|
|
402
|
+
and not scope.provider_called and output.usage is None and snapshot is None
|
|
403
|
+
and bool(response.messages)
|
|
404
|
+
and all(message.contents for message in response.messages)
|
|
405
|
+
and all(content.type == "function_approval_request"
|
|
406
|
+
for message in response.messages for content in message.contents)
|
|
407
|
+
)
|
|
408
|
+
# SDK middleware dispatches parallel approvals one at a time without
|
|
409
|
+
# inference. These rounds have no missing provider usage to accumulate.
|
|
410
|
+
yield _Round(response, AgentOutput(contents, output.usage or snapshot, termination), local_approval)
|
|
411
|
+
|
|
412
|
+
def _validate_continuity(self, scope: NativeStreamScope) -> None:
|
|
413
|
+
"""Fail closed only for the explicitly selected provider-history profile.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
scope: Finalized provider subcall observations; missing handles cannot reuse stale state.
|
|
417
|
+
"""
|
|
418
|
+
if not self._require_provider_continuation:
|
|
419
|
+
return
|
|
420
|
+
ref = ContinuationReference.of(self._session.service_session_id)
|
|
421
|
+
if scope.missing_continuation or ref.fingerprint is None:
|
|
422
|
+
self._diagnose("missing_continuation")
|
|
423
|
+
raise RuntimeError("Provider history continuation is unavailable; create a fresh conversation")
|
|
424
|
+
if scope.last_continuation is not None and scope.last_continuation != ref:
|
|
425
|
+
self._diagnose("continuation_mismatch")
|
|
426
|
+
raise RuntimeError("Provider history continuation was not retained; create a fresh conversation")
|
|
427
|
+
|
|
428
|
+
def _diagnose(self, phase: str, *, stream: bool | None = None, reason: str | None = None) -> None:
|
|
429
|
+
"""Record session continuity without exposing IDs or native state.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
phase: Lifecycle stage.
|
|
433
|
+
stream: Native response mode when known.
|
|
434
|
+
reason: Trusted lifecycle reason.
|
|
435
|
+
"""
|
|
436
|
+
ref = ContinuationReference.of(self._session.service_session_id)
|
|
437
|
+
ContinuityDiagnostics.record(
|
|
438
|
+
_logger, "native session continuity", phase=phase, reason=reason,
|
|
439
|
+
session_instance=self._instance, turn=self._turn, subrun=self._subrun, stream=stream,
|
|
440
|
+
conversation_fingerprint=ContinuityDiagnostics.fingerprint(self._session.session_id),
|
|
441
|
+
service_continuation_kind=ref.kind, service_continuation_fingerprint=ref.fingerprint,
|
|
442
|
+
require_provider_continuation=self._require_provider_continuation,
|
|
443
|
+
storage_explicit=(self._agent.default_options.get("store") if isinstance(self._agent, Agent) else None),
|
|
444
|
+
storage_default=bool(getattr(getattr(self._agent, "client", None), "STORES_BY_DEFAULT", False)),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
@staticmethod
|
|
448
|
+
def _namespace(event: AgentStreamEvent, run_id: str) -> AgentStreamEvent:
|
|
449
|
+
"""Prevent SDK-default content IDs from colliding across approval sub-runs.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
event: Visible content/lifecycle event.
|
|
453
|
+
run_id: Unique native sub-run identifier, also used in diagnostics.
|
|
454
|
+
"""
|
|
455
|
+
if isinstance(event, (UsageEvent, TerminalEvent)):
|
|
456
|
+
return event
|
|
457
|
+
return replace(event, content_id=f"{run_id}:{event.content_id}")
|
|
458
|
+
|
|
459
|
+
@staticmethod
|
|
460
|
+
def _visible(event: AgentStreamEvent, hidden: set[str]) -> bool:
|
|
461
|
+
"""Filter entire internal tool lifecycles rather than leaking payloads.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
event: Converted native event.
|
|
465
|
+
hidden: Tool content identities suppressed in this sub-run.
|
|
466
|
+
"""
|
|
467
|
+
if isinstance(event, (ContentStart, ContentEvent)) and isinstance(event.content, (ToolCallContent, ToolResultContent)):
|
|
468
|
+
hidden.add(event.content_id)
|
|
469
|
+
return False
|
|
470
|
+
if isinstance(event, ToolArgumentsDelta):
|
|
471
|
+
return False
|
|
472
|
+
return not (isinstance(event, ContentEnd) and event.content_id in hidden)
|
|
473
|
+
|
|
474
|
+
def pending(self, state: AgentResponse[Any]) -> tuple[PendingToolApproval, ...]:
|
|
475
|
+
"""Inspect native suspended calls.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
state: Completed native response.
|
|
479
|
+
"""
|
|
480
|
+
return self._translator.pending_approvals(state)
|
|
481
|
+
|
|
482
|
+
def will_question(self, pending: tuple[PendingToolApproval, ...]) -> bool:
|
|
483
|
+
"""Classify human prompts using the injected resolver.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
pending: Suspended batch.
|
|
487
|
+
"""
|
|
488
|
+
return self._resolver is not None and self._resolver.will_question(pending)
|
|
489
|
+
|
|
490
|
+
async def resume(self, pending: tuple[PendingToolApproval, ...]) -> AgentResponse[Any]:
|
|
491
|
+
"""Support the existing neutral normal-mode ApprovalLoop port.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
pending: Suspended calls.
|
|
495
|
+
"""
|
|
496
|
+
if self._resolver is None:
|
|
497
|
+
raise RuntimeError("native approvals require an explicit approval bridge")
|
|
498
|
+
answers = await self._resolver.resolve(pending)
|
|
499
|
+
return await self._agent.run(self._translator.answer_message(answers.answers), session=self._session)
|
|
500
|
+
|
|
501
|
+
async def decline(self, pending: tuple[PendingToolApproval, ...]) -> AgentResponse[Any]:
|
|
502
|
+
"""Explicitly decline outstanding calls during bounded abandonment.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
pending: Calls that must never execute.
|
|
506
|
+
"""
|
|
507
|
+
answers = tuple(item.answer(approved=False) for item in pending)
|
|
508
|
+
try:
|
|
509
|
+
async with aclosing(self._round(
|
|
510
|
+
self._translator.answer_message(answers), stream=False, previous_usage=None,
|
|
511
|
+
)) as execution:
|
|
512
|
+
async for item in execution:
|
|
513
|
+
if isinstance(item, _Round):
|
|
514
|
+
if not item.local_approval:
|
|
515
|
+
self._cleanup_usage.append(item.output.usage)
|
|
516
|
+
if item.output.termination.status is TerminationStatus.FAILED:
|
|
517
|
+
self._cleanup_failure = item.output.termination
|
|
518
|
+
self._broken = True
|
|
519
|
+
raise RuntimeError("Native agent execution failed")
|
|
520
|
+
return item.response
|
|
521
|
+
raise RuntimeError("native decline produced no response")
|
|
522
|
+
except Exception:
|
|
523
|
+
# ApprovalLoop's best-effort cleanup catches errors; retain failure so
|
|
524
|
+
# it cannot turn this native failure into an approval-budget result.
|
|
525
|
+
self._broken = True
|
|
526
|
+
self._cleanup_failure = self._cleanup_failure or Termination(
|
|
527
|
+
TerminationStatus.FAILED, error=ErrorEnvelope(
|
|
528
|
+
code="native_execution_failed", category="handler_execution", message="Native agent execution failed",
|
|
529
|
+
),
|
|
530
|
+
)
|
|
531
|
+
raise
|
|
532
|
+
|
|
533
|
+
def discard_authorizations(self) -> None:
|
|
534
|
+
"""Clear domain grants on abandonment, error or cancellation."""
|
|
535
|
+
if self._discard is not None:
|
|
536
|
+
self._discard()
|
|
537
|
+
|
|
538
|
+
def final_text(self, state: AgentResponse[Any]) -> str:
|
|
539
|
+
"""Extract normal-mode framework text.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
state: Completed native response.
|
|
543
|
+
"""
|
|
544
|
+
return state.text
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Inspectable conversion results, without retaining sensitive native payloads."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
from ygo74.agent_runtime.domains.contracts.agent_output import AgentOutput
|
|
7
|
+
from ygo74.agent_runtime.domains.contracts.stream_events import AgentStreamEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConversionStatus(StrEnum):
|
|
11
|
+
"""Names whether framework conversion succeeded, partially succeeded with unsupported content, or excluded the value.
|
|
12
|
+
"""
|
|
13
|
+
CONVERTED = "converted"
|
|
14
|
+
EXCLUDED = "excluded"
|
|
15
|
+
UNSUPPORTED = "unsupported"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConversionReason(StrEnum):
|
|
19
|
+
"""Stable diagnostic categories explaining why a native framework value was excluded or unsupported.
|
|
20
|
+
"""
|
|
21
|
+
SUPPORTED = "supported"
|
|
22
|
+
REASONING_NOT_SELECTED = "reasoning_not_selected"
|
|
23
|
+
PROTECTED_REASONING = "protected_reasoning"
|
|
24
|
+
NON_OUTPUT_ROLE = "non_output_role"
|
|
25
|
+
UNKNOWN_CONTENT = "unknown_content"
|
|
26
|
+
INVALID_JSON = "invalid_json"
|
|
27
|
+
MISSING_TOOL_IDENTITY = "missing_tool_identity"
|
|
28
|
+
STRUCTURED_TOOL_RESULT = "structured_tool_result"
|
|
29
|
+
TOOL_ERROR = "tool_error"
|
|
30
|
+
UNSUPPORTED_MEDIA = "unsupported_media"
|
|
31
|
+
INCOMPLETE_USAGE = "incomplete_usage"
|
|
32
|
+
UNKNOWN_FINISH_REASON = "unknown_finish_reason"
|
|
33
|
+
STRUCTURED_RESPONSE_VALUE = "structured_response_value"
|
|
34
|
+
PROVIDER_ERROR = "provider_error"
|
|
35
|
+
UNSUPPORTED_ANNOTATIONS = "unsupported_annotations"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ConversionDecision:
|
|
40
|
+
"""Records one native content item’s conversion status, reason, and safe diagnostic metadata.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
content_id (str): Stable identity correlating one content item across start, delta, and end events.
|
|
44
|
+
native_type (str): Framework-native content type being converted.
|
|
45
|
+
status (ConversionStatus): Success, incomplete, or failed outcome for the operation.
|
|
46
|
+
reason (ConversionReason): Reason code or message associated with this decision or failure.
|
|
47
|
+
"""
|
|
48
|
+
content_id: str
|
|
49
|
+
native_type: str
|
|
50
|
+
status: ConversionStatus
|
|
51
|
+
reason: ConversionReason
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class OutputConversion:
|
|
56
|
+
"""Bundles the neutral final result with all conversion decisions made while reading an SDK response.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
output (AgentOutput): Typed agent output being validated, filtered, or projected.
|
|
60
|
+
decisions (tuple[ConversionDecision, ...]): Per-content conversion or filtering decisions returned to the caller.
|
|
61
|
+
"""
|
|
62
|
+
output: AgentOutput
|
|
63
|
+
decisions: tuple[ConversionDecision, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class UpdateConversion:
|
|
68
|
+
"""Bundles typed stream events with the conversion decisions for one SDK update.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
events (tuple[AgentStreamEvent, ...]): The ordered typed events that describe the streamed response.
|
|
72
|
+
decisions (tuple[ConversionDecision, ...]): Per-content conversion or filtering decisions returned to the caller.
|
|
73
|
+
"""
|
|
74
|
+
events: tuple[AgentStreamEvent, ...]
|
|
75
|
+
decisions: tuple[ConversionDecision, ...]
|