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.
@@ -0,0 +1,313 @@
1
+ """Safe native tool activity and producer cleanup for the pinned SDK."""
2
+
3
+ import inspect
4
+ import logging
5
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
6
+ from contextlib import contextmanager
7
+ from contextvars import ContextVar
8
+ from typing import Any
9
+ from uuid import uuid4
10
+
11
+ from agent_framework import AgentResponseUpdate, BaseChatClient, ResponseStream
12
+ from ygo74.agent_runtime.domains.contracts.agent_output import Notification
13
+ from ygo74.agent_runtime.domains.contracts.stream_events import ContentEvent
14
+ from ygo74.agent_runtime.domains.sessions.continuity_diagnostics import (
15
+ ContinuationReference,
16
+ ContinuityDiagnostics,
17
+ )
18
+
19
+ from .conversion import ConversionDecision, ConversionStatus
20
+
21
+ _logger = logging.getLogger(__name__)
22
+ _active_scope: ContextVar["NativeStreamScope | None"] = ContextVar("native_stream_scope", default=None)
23
+ _PROVIDER_RESPONSE_METHOD = "_inner_get_response"
24
+
25
+
26
+ class _ProviderResponseCapture:
27
+ """Keep provider streams reachable without inspecting SDK generator frames.
28
+
29
+ Args:
30
+ get_response: Bound SDK 1.18 provider method, preserving native options.
31
+ """
32
+
33
+ def __init__(self, get_response: Callable[..., Any]) -> None:
34
+ """Wrap one provider method once.
35
+
36
+ Args:
37
+ get_response: Original bound provider implementation.
38
+ """
39
+ self._get_response = get_response
40
+
41
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
42
+ """Capture the provider response in the current invocation only.
43
+
44
+ Args:
45
+ args: Native positional arguments, forwarded unchanged.
46
+ kwargs: Native provider-specific arguments, forwarded unchanged.
47
+ """
48
+ scope = _active_scope.get()
49
+ call = scope.provider_call(kwargs) if scope is not None else 0
50
+ result = self._get_response(*args, **kwargs)
51
+ if scope is None:
52
+ return result
53
+ if isinstance(result, ResponseStream):
54
+ scope.track(result)
55
+ result.with_result_hook(lambda response: scope.provider_result(response, call))
56
+ return result
57
+ if inspect.isawaitable(result):
58
+ return self._capture_awaited(result, scope, call)
59
+ return result
60
+
61
+ @staticmethod
62
+ async def _capture_awaited(result: Awaitable[Any], scope: "NativeStreamScope", call: int) -> Any:
63
+ """Support providers that resolve their response asynchronously.
64
+
65
+ Args:
66
+ result: Original provider awaitable.
67
+ scope: Invocation owning any stream it resolves.
68
+ call: Provider call index within the sub-run.
69
+ """
70
+ response = await result
71
+ if isinstance(response, ResponseStream):
72
+ scope.track(response)
73
+ response.with_result_hook(lambda final: scope.provider_result(final, call))
74
+ else:
75
+ scope.provider_result(response, call)
76
+ return response
77
+
78
+
79
+ class NativeStreamScope:
80
+ """Own SDK provider streams hidden inside the function-invocation loop.
81
+
82
+ SDK 1.18 lacks a public ResponseStream.aclose and the function loop does not
83
+ close its local inner_stream on abandonment. A per-client provider-boundary
84
+ wrapper captures those streams during pulls. Context-local ownership avoids
85
+ mixing parallel sessions sharing a client; no agent is rebuilt.
86
+ """
87
+
88
+ def __init__(self, *, session_instance: str | None = None, turn: int = 0,
89
+ subrun: int = 0, storage_default: bool = False) -> None:
90
+ """Initialize provider ownership and structural correlation.
91
+
92
+ Args:
93
+ session_instance: Runtime-generated binding instance.
94
+ turn: User invocation number.
95
+ subrun: Approval sub-run number.
96
+ storage_default: Native client default storage behavior.
97
+ """
98
+ self._streams: list[object] = []
99
+ self._session_instance = session_instance
100
+ self._turn, self._subrun = turn, subrun
101
+ self._storage_default = storage_default
102
+ self._calls = 0
103
+ self.missing_continuation = False
104
+ self.last_continuation: ContinuationReference | None = None
105
+
106
+ @property
107
+ def provider_called(self) -> bool:
108
+ """Whether this sub-run reached the instrumented native provider."""
109
+ return self._calls > 0
110
+
111
+ def provider_call(self, kwargs: dict[str, Any]) -> int:
112
+ """Observe effective SDK options without changing the request.
113
+
114
+ Args:
115
+ kwargs: Original SDK boundary arguments; content is never logged.
116
+ """
117
+ self._calls += 1
118
+ options = kwargs.get("options") or {}
119
+ ref = ContinuationReference.of(options.get("conversation_id"))
120
+ messages = kwargs.get("messages") or ()
121
+ self._record("request", self._calls, ref,
122
+ provider_storage=options.get("store") if options.get("store") is not None else self._storage_default,
123
+ stream=kwargs.get("stream", False), message_count=len(messages),
124
+ user_message_count=sum(message.role == "user" for message in messages),
125
+ assistant_message_count=sum(message.role == "assistant" for message in messages))
126
+ return self._calls
127
+
128
+ def provider_result(self, response: Any, call: int) -> None:
129
+ """Observe only successfully finalized provider responses.
130
+
131
+ Args:
132
+ response: SDK ChatResponse; payloads and raw provider IDs stay unread.
133
+ call: Provider call index.
134
+ """
135
+ ref = ContinuationReference.of(getattr(response, "conversation_id", None))
136
+ self.last_continuation = ref
137
+ self.missing_continuation = self.missing_continuation or ref.fingerprint is None
138
+ self._record("response", call, ref)
139
+
140
+ def _record(self, phase: str, call: int, ref: ContinuationReference,
141
+ **fields: str | int | bool | None) -> None:
142
+ """Emit safe subcall links.
143
+
144
+ Args:
145
+ phase: Request or finalized response.
146
+ call: Subcall index.
147
+ ref: Safe continuation metadata.
148
+ fields: Structural request counters/options.
149
+ """
150
+ ContinuityDiagnostics.record(
151
+ _logger, "native provider continuity", session_instance=self._session_instance,
152
+ turn=self._turn, subrun=self._subrun, provider_call=call, phase=phase,
153
+ provider_continuation_kind=ref.kind, provider_continuation_fingerprint=ref.fingerprint, **fields,
154
+ )
155
+
156
+ @staticmethod
157
+ def attach(client: object) -> None:
158
+ """Install an idempotent SDK 1.18 provider-boundary wrapper.
159
+
160
+ Args:
161
+ client: Existing native client; custom non-SDK clients are unchanged.
162
+ """
163
+ if isinstance(client, BaseChatClient):
164
+ original = getattr(client, _PROVIDER_RESPONSE_METHOD)
165
+ if not isinstance(original, _ProviderResponseCapture):
166
+ setattr(client, _PROVIDER_RESPONSE_METHOD, _ProviderResponseCapture(original))
167
+
168
+ def track(self, stream: object) -> None:
169
+ """Retain a provider until native sub-run cleanup.
170
+
171
+ Args:
172
+ stream: Provider ResponseStream created during this invocation.
173
+ """
174
+ self._streams.append(stream)
175
+
176
+ @contextmanager
177
+ def activate(self) -> Iterator[None]:
178
+ """Capture only a synchronous creation or an awaited native pull."""
179
+ token = _active_scope.set(self)
180
+ try:
181
+ yield
182
+ finally:
183
+ _active_scope.reset(token)
184
+
185
+ async def updates(self, producer: AsyncIterator[AgentResponseUpdate]) -> AsyncIterator[AgentResponseUpdate]:
186
+ """Capture provider creation while pulling, never across consumer yields.
187
+
188
+ Args:
189
+ producer: Native stream being consumed.
190
+ """
191
+ while True:
192
+ with self.activate():
193
+ try:
194
+ update = await anext(producer)
195
+ except StopAsyncIteration:
196
+ return
197
+ yield update
198
+
199
+ async def close(self, producer: object) -> None:
200
+ """Close all captured providers even when outer SDK cleanup fails.
201
+
202
+ Args:
203
+ producer: Outermost native response stream.
204
+ """
205
+ try:
206
+ await NativeStreamCloser.close(producer)
207
+ finally:
208
+ try:
209
+ for stream in reversed(self._streams):
210
+ await NativeStreamCloser.close(stream)
211
+ finally:
212
+ self._streams.clear()
213
+
214
+
215
+ class ToolProgressPolicy:
216
+ """Expose only admitted tool names and non-sensitive lifecycle states.
217
+
218
+ Args:
219
+ tool_names: Trusted declarations; unrecognized names are never exposed.
220
+ """
221
+
222
+ def __init__(self, tool_names: tuple[str, ...] = ()) -> None:
223
+ """Initialize per-sub-run activity correlation.
224
+
225
+ Args:
226
+ tool_names: Tool names from trusted configuration.
227
+ """
228
+ self._allowed = frozenset(tool_names)
229
+ self._calls: dict[str, str] = {}
230
+ self._completed: set[str] = set()
231
+
232
+ def observe(self, update: AgentResponseUpdate) -> tuple[ContentEvent, ...]:
233
+ """Return safe notices, deduplicating partial/parallel tool updates.
234
+
235
+ Args:
236
+ update: Native update; arguments and results are deliberately unread.
237
+ """
238
+ events = []
239
+ for content in update.contents:
240
+ call_id = content.call_id
241
+ if not call_id:
242
+ continue
243
+ if content.type == "function_call" and content.name in self._allowed and call_id not in self._calls:
244
+ self._calls[call_id] = content.name
245
+ events.append(ContentEvent(uuid4().hex, Notification(f"Calling {content.name}…")))
246
+ elif content.type == "function_result" and call_id in self._calls and call_id not in self._completed:
247
+ self._completed.add(call_id)
248
+ state = "Failed" if content.exception else "Completed"
249
+ events.append(ContentEvent(uuid4().hex, Notification(f"{state} {self._calls[call_id]}.")))
250
+ return tuple(events)
251
+
252
+
253
+ class ConversionDiagnostics:
254
+ """Log reason codes with caller-generated correlation, never native payloads."""
255
+
256
+ @staticmethod
257
+ def record(decisions: tuple[ConversionDecision, ...], invocation_id: str) -> None:
258
+ """Record only structural conversion outcomes.
259
+
260
+ Args:
261
+ decisions: Adapter decisions without raw content.
262
+ invocation_id: Runtime-generated correlation identifier.
263
+ """
264
+ for decision in decisions:
265
+ if decision.status is not ConversionStatus.CONVERTED:
266
+ _logger.debug("native conversion", extra={
267
+ "invocation_id": invocation_id,
268
+ "conversion_status": decision.status.value,
269
+ "conversion_reason": decision.reason.value,
270
+ })
271
+
272
+
273
+ class NativeStreamCloser:
274
+ """Close SDK 1.18 producers on early exit.
275
+
276
+ SDK 1.18 ResponseStream has no public aclose. The compatibility shim walks
277
+ only its documented-in-source ownership fields and runs idempotent cleanup
278
+ hooks; it does not finalize an incomplete response as success.
279
+ """
280
+
281
+ @classmethod
282
+ async def close(cls, stream: object) -> None:
283
+ """Release nested ResponseStreams and their underlying async generators.
284
+
285
+ Args:
286
+ stream: Native producer owned by the binding.
287
+ """
288
+ seen: set[int] = set()
289
+ await cls._close(stream, seen)
290
+
291
+ @classmethod
292
+ async def _close(cls, stream: object, seen: set[int]) -> None:
293
+ """Visit each producer once, closing inner generators before hooks.
294
+
295
+ Args:
296
+ stream: Producer node.
297
+ seen: Identities already processed.
298
+ """
299
+ if id(stream) in seen:
300
+ return
301
+ seen.add(id(stream))
302
+ for name in ("_iterator", "_inner_stream", "_stream_source", "_inner_stream_source"):
303
+ child = getattr(stream, name, None)
304
+ if child is not None:
305
+ await cls._close(child, seen)
306
+ if inspect.iscoroutine(stream):
307
+ stream.close()
308
+ close: Callable[[], Any] | None = getattr(stream, "aclose", None)
309
+ if close is not None:
310
+ await close()
311
+ cleanup: Callable[[], Any] | None = getattr(stream, "_run_cleanup_hooks", None)
312
+ if cleanup is not None:
313
+ await cleanup()
@@ -0,0 +1,190 @@
1
+ """Per-invocation incremental SDK conversion, independent of HTTP protocols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from agent_framework import AgentResponseUpdate, Content
8
+ from ygo74.agent_runtime.domains.contracts.agent_output import (
9
+ ReasoningContent,
10
+ Termination,
11
+ TerminationStatus,
12
+ TextContent,
13
+ ToolCallContent,
14
+ ToolExecution,
15
+ )
16
+ from ygo74.agent_runtime.domains.contracts.stream_events import (
17
+ AgentStreamEvent,
18
+ ContentEnd,
19
+ ContentEvent,
20
+ ContentStart,
21
+ TerminalEvent,
22
+ TextDelta,
23
+ ToolArgumentsDelta,
24
+ UsageEvent,
25
+ )
26
+
27
+ from .conversion import (
28
+ ConversionDecision,
29
+ ConversionReason,
30
+ ConversionStatus,
31
+ UpdateConversion,
32
+ )
33
+ from .output_adapter import NativeContentType, NativeFinishReason, _ContentMapper
34
+
35
+
36
+ class AgentFrameworkStreamAdapter:
37
+ """Create one adapter per SDK invocation; call finish only after exhaustion.
38
+
39
+ Native finish reasons can terminate intermediate tool rounds, so updates
40
+ never emit a terminal event. The caller owns producer completion and policy.
41
+
42
+ Args:
43
+ expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
44
+ tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
45
+ """
46
+ def __init__(
47
+ self, *, expose_reasoning: bool = False,
48
+ tool_execution: ToolExecution = ToolExecution.INTERNAL,
49
+ ) -> None:
50
+ """Initialize the instance framework stream updates with supplied collaborators and configuration.
51
+
52
+ Args:
53
+ expose_reasoning (bool): Whether reasoning content may be included in client-visible output.
54
+ tool_execution (ToolExecution): Whether tool calls are internal or delegated to the client.
55
+ """
56
+ self._mapper = _ContentMapper(expose_reasoning=expose_reasoning, tool_execution=tool_execution)
57
+ self._active: dict[str, None] = {}
58
+ self._counter = 0
59
+ self._closed = False
60
+ self._termination = Termination()
61
+
62
+ def convert_update(self, update: AgentResponseUpdate) -> UpdateConversion:
63
+ """Translate one SDK update into correlated typed content and lifecycle events.
64
+
65
+ Args:
66
+ update (AgentResponseUpdate): Framework response update being translated into typed stream events.
67
+ """
68
+ # Route tool calls through their correlation path, exclude non-output roles, and translate text updates into one start followed by deltas for each active content identity.
69
+ if self._closed:
70
+ raise ValueError("cannot convert updates after finish")
71
+ if not isinstance(update, AgentResponseUpdate):
72
+ raise TypeError("update must be an AgentResponseUpdate")
73
+ if update.finish_reason is not None and self._termination.status is not TerminationStatus.FAILED:
74
+ self._termination = self._mapper.termination(update.finish_reason)
75
+ events: list[AgentStreamEvent] = []
76
+ decisions: list[ConversionDecision] = []
77
+ for index, content in enumerate(update.contents):
78
+ identity = content.id or f"{update.message_id or 'message'}:{content.type}:{index}"
79
+ if content.type == NativeContentType.CALL.value and content.call_id:
80
+ identity = f"tool:{content.call_id}"
81
+ if update.role is not None and update.role not in ("assistant", "tool"):
82
+ decisions.append(ConversionDecision(
83
+ identity, content.type, ConversionStatus.EXCLUDED, ConversionReason.NON_OUTPUT_ROLE,
84
+ ))
85
+ continue
86
+ if content.type == NativeContentType.CALL.value:
87
+ self._call(content, identity, events, decisions)
88
+ continue
89
+ mapped = self._mapper.map(content, identity)
90
+ decisions.append(mapped.decision)
91
+ annotation_decision = self._mapper.annotation_decision(content, identity)
92
+ if annotation_decision is not None:
93
+ decisions.append(annotation_decision)
94
+ if mapped.usage is not None:
95
+ events.append(UsageEvent(mapped.usage))
96
+ if mapped.termination is not None:
97
+ self._termination = mapped.termination
98
+ if mapped.content is None:
99
+ continue
100
+ if isinstance(mapped.content, (TextContent, ReasoningContent)):
101
+ if identity not in self._active:
102
+ initial = (ReasoningContent("", exposable=True)
103
+ if isinstance(mapped.content, ReasoningContent) else TextContent(""))
104
+ events.append(ContentStart(identity, initial))
105
+ self._active[identity] = None
106
+ events.append(TextDelta(identity, mapped.content.text))
107
+ continue
108
+ if content.type == NativeContentType.RESULT.value:
109
+ call_identity = f"tool:{content.call_id}"
110
+ if call_identity in self._active:
111
+ events.append(ContentEnd(call_identity))
112
+ del self._active[call_identity]
113
+ self._counter += 1
114
+ events.append(ContentEvent(f"{identity}:complete-{self._counter}", mapped.content))
115
+ if update.finish_reason is not None and update.finish_reason not in NativeFinishReason:
116
+ decisions.append(ConversionDecision(
117
+ "update:finish", "finish_reason", ConversionStatus.UNSUPPORTED, ConversionReason.UNKNOWN_FINISH_REASON,
118
+ ))
119
+ return UpdateConversion(tuple(events), tuple(decisions))
120
+
121
+ def _call(
122
+ self, content: Content, identity: str,
123
+ events: list[AgentStreamEvent], decisions: list[ConversionDecision],
124
+ ) -> None:
125
+ """Emit a tool call start and argument fragments while keeping the framework call identity stable.
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
+ events (list[AgentStreamEvent]): The ordered typed events that describe the streamed response.
131
+ decisions (list[ConversionDecision]): Per-content conversion or filtering decisions returned to the caller.
132
+ """
133
+ name = content.name or self._mapper.tool_names.get(content.call_id or "")
134
+ if not content.call_id or not name or content.exception:
135
+ reason = ConversionReason.TOOL_ERROR if content.exception else ConversionReason.MISSING_TOOL_IDENTITY
136
+ decisions.append(ConversionDecision(identity, content.type, ConversionStatus.UNSUPPORTED, reason))
137
+ return
138
+ try:
139
+ arguments = (content.arguments if isinstance(content.arguments, str)
140
+ else self._json_arguments(content.arguments))
141
+ except (ValueError, TypeError):
142
+ decisions.append(ConversionDecision(
143
+ identity, content.type, ConversionStatus.UNSUPPORTED, ConversionReason.INVALID_JSON,
144
+ ))
145
+ return
146
+ self._mapper.tool_names[content.call_id] = name
147
+ decisions.append(ConversionDecision(
148
+ identity, content.type, ConversionStatus.CONVERTED, ConversionReason.SUPPORTED,
149
+ ))
150
+ annotation_decision = self._mapper.annotation_decision(content, identity)
151
+ if annotation_decision is not None:
152
+ decisions.append(annotation_decision)
153
+ if identity not in self._active:
154
+ events.append(ContentStart(identity, ToolCallContent(
155
+ content.call_id, name, execution=self._mapper.tool_execution,
156
+ )))
157
+ self._active[identity] = None
158
+ if arguments:
159
+ events.append(ToolArgumentsDelta(identity, arguments))
160
+
161
+ @staticmethod
162
+ def _json_arguments(value: object) -> str:
163
+ """Serialize framework tool arguments to JSON text and report non-JSON values as unsupported.
164
+
165
+ Args:
166
+ value (object): The value being converted, checked, or serialized.
167
+ """
168
+ if value is None:
169
+ return ""
170
+ return json.dumps(_ContentMapper.json_value(value), separators=(",", ":"), allow_nan=False)
171
+
172
+ def finish(self, termination: Termination | None = None) -> tuple[AgentStreamEvent, ...]:
173
+ """Close active contents and emit exactly one caller-owned terminal.
174
+
175
+ Exceptions/cancellation are not success: propagate them or provide an
176
+ explicit failed/incomplete Termination rather than calling this on abort.
177
+
178
+ Args:
179
+ termination (Termination | None): Terminal outcome used to complete the result or stream.
180
+ """
181
+ if self._closed:
182
+ return ()
183
+ self._closed = True
184
+ events: list[AgentStreamEvent] = [ContentEnd(identity) for identity in self._active]
185
+ self._active.clear()
186
+ final = self._termination
187
+ if termination is not None and final.status is not TerminationStatus.FAILED:
188
+ final = termination
189
+ events.append(TerminalEvent(final))
190
+ return tuple(events)
@@ -0,0 +1,155 @@
1
+ """Native factory composition for the platform's managed reference worker."""
2
+
3
+ import inspect
4
+ from collections.abc import Callable
5
+ from contextlib import AbstractAsyncContextManager, AsyncExitStack
6
+ from dataclasses import dataclass
7
+
8
+ from agent_framework import Agent, InMemoryHistoryProvider, SupportsAgentRun
9
+ from fastapi import FastAPI
10
+ from ygo74.agent_runtime.domains.auth.agent_principal import AgentPrincipal
11
+ from ygo74.agent_runtime.domains.contracts.agent_definition import (
12
+ AgentDefinition,
13
+ AgentFactoryContext,
14
+ )
15
+ from ygo74.agent_runtime.domains.endpoints.managed_worker import (
16
+ ManagedAgentWorker,
17
+ TrustedFactoryLoader,
18
+ WorkerSettings,
19
+ )
20
+ from ygo74.agent_runtime.domains.humanapproval.commands import ConfirmationCommand
21
+ from ygo74.agent_runtime.domains.humanapproval.pending_renderer import (
22
+ PendingConfirmationRenderer,
23
+ )
24
+ from ygo74.agent_runtime.domains.humanapproval.tickets import (
25
+ InMemoryPendingConfirmationStore,
26
+ UnknownTicketError,
27
+ )
28
+ from ygo74.agent_runtime.domains.security.user_context import UserContext
29
+ from ygo74.agent_runtime.domains.sessions.agent_conversation import (
30
+ AgentConversation,
31
+ ConversationPort,
32
+ )
33
+
34
+ from .binding import AgentFrameworkSession
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class _NativeResources:
39
+ """Conversation scope, with no authorization inferred from native output.
40
+
41
+ Args:
42
+ user: Verified identity without any granted tool permissions.
43
+ scope: Factory dependency scope owned by this conversation.
44
+ """
45
+
46
+ user: UserContext
47
+ scope: AsyncExitStack
48
+
49
+ async def aclose(self) -> None:
50
+ """Close native factory dependencies exactly once."""
51
+ await self.scope.aclose()
52
+
53
+
54
+ class _NoConfirmations:
55
+ """Reject replay commands for native agents without a ticket bridge."""
56
+
57
+ async def run(self, command: ConfirmationCommand) -> str:
58
+ """Refuse commands that cannot correspond to a stored ticket.
59
+
60
+ Args:
61
+ command: Parsed confirmation command.
62
+ """
63
+ raise UnknownTicketError(command.ticket_id)
64
+
65
+
66
+ class AgentFrameworkWorker:
67
+ """Simple BYOA path: a native factory and definition, plus operator settings.
68
+
69
+ Args:
70
+ definition: Trusted native export and declarative identity/capabilities.
71
+ settings: Operator deployment/authentication/lifecycle configuration.
72
+ admission: Optional trusted deterministic tool-policy validation hook.
73
+ factory: Optional already-resolved native factory from trusted composition.
74
+ """
75
+
76
+ def __init__(
77
+ self, definition: AgentDefinition, settings: WorkerSettings, *,
78
+ admission: Callable[[AgentDefinition, SupportsAgentRun], None] | None = None,
79
+ factory: Callable[[AgentFactoryContext], object] | None = None,
80
+ ) -> None:
81
+ """Validate registration before any routes become available.
82
+
83
+ Args:
84
+ definition: Validated deployment definition.
85
+ settings: Runtime operator controls.
86
+ admission: Required for native tools; checks actual enforceable gates.
87
+ factory: Trusted DI override, never taken from request data.
88
+ """
89
+ if definition.tools and admission is None:
90
+ raise ValueError("native tools require an enforceable admission policy")
91
+ self._definition, self._settings, self._admission = definition, settings, admission
92
+ self._factory = factory or TrustedFactoryLoader.load(definition)
93
+ self.worker = ManagedAgentWorker(definition, settings, self._build)
94
+
95
+ def build_app(self) -> FastAPI:
96
+ """Build the platform-owned HTTP worker, not an agent-owned server."""
97
+ return self.worker.build_app()
98
+
99
+ async def _build(self, principal: AgentPrincipal, conversation_id: str) -> ConversationPort:
100
+ """Resolve a native agent with standard async dependency scopes.
101
+
102
+ Args:
103
+ principal: Verified caller associated with every factory dependency.
104
+ conversation_id: Caller-scoped history identifier.
105
+ """
106
+ if self._definition.require_email and not principal.email:
107
+ raise ValueError("native agent requires verified email")
108
+ context = AgentFactoryContext(principal, conversation_id, self._settings.deployment_id,
109
+ self._settings.identity_namespace)
110
+ scope = AsyncExitStack()
111
+ try:
112
+ resource = self._factory(context)
113
+ if inspect.isawaitable(resource):
114
+ resource = await resource
115
+ if isinstance(resource, AbstractAsyncContextManager):
116
+ resource = await scope.enter_async_context(resource)
117
+ if not isinstance(resource, SupportsAgentRun):
118
+ raise TypeError("native factory must return a runnable native agent or an async scope yielding one")
119
+ self._validate_agent(resource)
120
+ return AgentConversation(
121
+ _NativeResources(UserContext(user_id=principal.subject, session_id=conversation_id), scope),
122
+ AgentFrameworkSession(resource, session_id=conversation_id,
123
+ tool_names=tuple(tool.tool_name for tool in self._definition.tools)),
124
+ InMemoryPendingConfirmationStore(), _NoConfirmations(), conversation_id, PendingConfirmationRenderer(),
125
+ )
126
+ except BaseException:
127
+ await scope.aclose()
128
+ raise
129
+
130
+ def _validate_agent(self, agent: SupportsAgentRun) -> None:
131
+ """Reject undeclared native tools; declarations never grant permission.
132
+
133
+ Args:
134
+ agent: Factory output to check at the binding boundary.
135
+ """
136
+ if not isinstance(agent, Agent):
137
+ if self._admission is None:
138
+ raise ValueError("custom native agents require an explicit capability admission policy")
139
+ self._admission(self._definition, agent)
140
+ return
141
+ # Only the exact SDK history implementation has a known static surface.
142
+ # Other providers (including subclasses) can inject tools or middleware.
143
+ dynamic_context = any(type(provider) is not InMemoryHistoryProvider for provider in agent.context_providers)
144
+ if dynamic_context and self._admission is None:
145
+ raise ValueError("dynamic context providers require an explicit capability admission policy")
146
+ options = agent.default_options
147
+ native_tools = options.get("tools") or []
148
+ names = {getattr(tool, "name", None) for tool in native_tools}
149
+ declared = {tool.tool_name for tool in self._definition.tools}
150
+ if names != declared:
151
+ raise ValueError("native tools do not match admitted declarations")
152
+ if names and self._admission is None:
153
+ raise ValueError("native tools have no enforceable control point")
154
+ if self._admission is not None:
155
+ self._admission(self._definition, agent)