polaris-python-ai 0.0.1.dev2__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.
polaris_ai/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Polaris extensions for Python AI frameworks."""
17
+
18
+ from ._version import __version__
19
+ from .log import (
20
+ PolarisAILogger,
21
+ PolarisAILogOptions,
22
+ configure_ai_logging,
23
+ get_ai_logger,
24
+ use_ai_logger,
25
+ )
26
+
27
+ __all__ = [
28
+ "PolarisAILogOptions",
29
+ "PolarisAILogger",
30
+ "configure_ai_logging",
31
+ "get_ai_logger",
32
+ "use_ai_logger",
33
+ "__version__",
34
+ ]
polaris_ai/_async.py ADDED
@@ -0,0 +1,39 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Finish worker operations before returning cancellation to callers."""
17
+
18
+ import asyncio
19
+
20
+
21
+ async def drain_task(task):
22
+ while not task.done():
23
+ try:
24
+ await asyncio.shield(task)
25
+ except asyncio.CancelledError:
26
+ continue
27
+ except Exception:
28
+ break
29
+ if not task.cancelled():
30
+ task.exception()
31
+
32
+
33
+ async def await_completion(operation):
34
+ task = asyncio.create_task(operation)
35
+ try:
36
+ return await asyncio.shield(task)
37
+ except asyncio.CancelledError:
38
+ await drain_task(task)
39
+ raise
polaris_ai/_version.py ADDED
@@ -0,0 +1,18 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """The version of polaris-python-ai."""
17
+
18
+ __version__ = "0.0.1.dev2"
@@ -0,0 +1,31 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Framework-independent A2A registration and card discovery."""
17
+
18
+ from polaris_ai._version import __version__
19
+ from .registry import A2ATransport, PolarisA2ARegistrar, RegisteredInstance
20
+ from .resolver import AgentCardNotFoundError, PolarisA2AResolver
21
+ from .server import PolarisA2AServerRegistration
22
+
23
+ __all__ = [
24
+ "A2ATransport",
25
+ "PolarisA2ARegistrar",
26
+ "RegisteredInstance",
27
+ "AgentCardNotFoundError",
28
+ "PolarisA2AResolver",
29
+ "PolarisA2AServerRegistration",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,44 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Use the official A2A SDK's protobuf JSON and legacy card compatibility."""
17
+
18
+ import json
19
+
20
+ try:
21
+ from a2a.client.card_resolver import parse_agent_card
22
+ from a2a.types import AgentCard
23
+ except ImportError as error:
24
+ raise ImportError(
25
+ "A2A integration requires pip install 'polaris-python-ai[a2a]'."
26
+ ) from error
27
+
28
+ from google.protobuf.json_format import MessageToDict
29
+
30
+ META_AGENT_NAME = "ai-agent-name"
31
+ META_AGENT_CARD = "a2a.agent.card"
32
+ META_TRANSPORT = "a2a.transport"
33
+ META_PATH = "a2a.path"
34
+ MAX_METADATA_VALUE_CHARS = 65535
35
+
36
+
37
+ def encode_card(card: AgentCard) -> str:
38
+ if not isinstance(card, AgentCard) or not card.name.strip():
39
+ raise ValueError("A native a2a.types.AgentCard with a name is required")
40
+ return json.dumps(MessageToDict(card), ensure_ascii=False, separators=(",", ":"))
41
+
42
+
43
+ def decode_card(payload: str) -> AgentCard:
44
+ return parse_agent_card(json.loads(payload))
@@ -0,0 +1,240 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Register A2A transports using the public Polaris Provider API."""
17
+
18
+ import asyncio
19
+ import concurrent.futures
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+ from threading import Event, RLock
23
+
24
+ from polaris.api.provider import ProviderAPI
25
+ from polaris.pkg.model.server import InstanceDeRegisterRequest, InstanceRegisterRequest
26
+
27
+ from polaris_ai._async import await_completion, drain_task
28
+ from polaris_ai.log import get_ai_logger
29
+
30
+ from ._card import (
31
+ MAX_METADATA_VALUE_CHARS,
32
+ META_AGENT_CARD,
33
+ META_AGENT_NAME,
34
+ META_PATH,
35
+ META_TRANSPORT,
36
+ AgentCard,
37
+ encode_card,
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class A2ATransport:
43
+ """An already listening A2A endpoint; host is the explicit business instance address."""
44
+
45
+ host: str
46
+ port: int
47
+ transport: str = "JSONRPC"
48
+ path: str = "/"
49
+ support_tls: bool = False
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class RegisteredInstance:
54
+ service: str
55
+ host: str
56
+ port: int
57
+ instance_id: str
58
+
59
+
60
+ class PolarisA2ARegistrar:
61
+ """Track registrations and clean them up without closing the supplied SDKContext.
62
+
63
+ ``service_token`` is used for register, heartbeat and deregistration and is
64
+ distinct from the connector's authentication token. It may be empty for
65
+ registries without service authentication. Heartbeat and instance-not-found
66
+ re-registration are managed by ProviderAPI.register_instance.
67
+ """
68
+
69
+ def __init__(self, sdk_context, namespace: str = "default", *, service_token: str = "",
70
+ ttl: int = 5, logger=None):
71
+ if sdk_context is None or sdk_context.is_destroy():
72
+ raise ValueError("An active SDKContext is required")
73
+ if not isinstance(service_token, str):
74
+ raise ValueError("service_token must be a string")
75
+ if type(ttl) is not int or not 0 < ttl <= 0xFFFFFFFF:
76
+ raise ValueError("ttl must be a positive uint32 number of seconds")
77
+ self.namespace = namespace.strip() or "default"
78
+ self.ttl = ttl
79
+ self.service_token = service_token.strip()
80
+ self.logger = logger if logger is not None else get_ai_logger("a2a")
81
+ self._context = sdk_context
82
+ self._provider = ProviderAPI(sdk_context)
83
+ self._registered: list[RegisteredInstance] = []
84
+ self._lock = RLock()
85
+ self._closed = False
86
+
87
+ @property
88
+ def registered(self) -> tuple[RegisteredInstance, ...]:
89
+ with self._lock:
90
+ return tuple(self._registered)
91
+
92
+ def _check_open(self):
93
+ if self._closed or self._context.is_destroy():
94
+ raise RuntimeError("A2A registrar or SDKContext is closed")
95
+
96
+ def register(self, agent_card: AgentCard, transports: Sequence[A2ATransport]):
97
+ """Register one instance per transport; roll back successes on partial failure."""
98
+ return self._register(agent_card, tuple(transports))
99
+
100
+ def _register(self, agent_card, transports, cancelled: Event | None = None):
101
+ with self._lock:
102
+ self._check_open()
103
+ requests = self._requests(agent_card, transports)
104
+ self.logger.debug(
105
+ "Registering A2A agent: namespace=%s service=%s transports=%d",
106
+ self.namespace, agent_card.name, len(requests),
107
+ )
108
+ created = []
109
+ try:
110
+ for request in requests:
111
+ if cancelled is not None and cancelled.is_set():
112
+ raise concurrent.futures.CancelledError()
113
+ response = self._provider.register_instance(request)
114
+ instance = RegisteredInstance(
115
+ request.service, request.host, request.port, response.instance_id,
116
+ )
117
+ self._registered.append(instance)
118
+ created.append(instance)
119
+ self.logger.debug(
120
+ "Registered A2A transport: service=%s host=%s port=%s instanceId=%s",
121
+ instance.service, instance.host, instance.port, instance.instance_id,
122
+ )
123
+ if cancelled is not None and cancelled.is_set():
124
+ raise concurrent.futures.CancelledError()
125
+ self.logger.info(
126
+ "Registered A2A agent %s/%s with %d transport(s)",
127
+ self.namespace, agent_card.name, len(created),
128
+ )
129
+ return tuple(created)
130
+ except BaseException:
131
+ self.logger.warning_throttled(
132
+ ("register-rollback", agent_card.name),
133
+ "Rolling back %d A2A transport registration(s) for %s/%s",
134
+ len(created), self.namespace, agent_card.name,
135
+ )
136
+ self._deregister(created)
137
+ raise
138
+
139
+ def _requests(self, card, transports):
140
+ payload = encode_card(card)
141
+ if not transports:
142
+ raise ValueError("transports must not be empty")
143
+ requests = []
144
+ for transport in transports:
145
+ if not isinstance(transport, A2ATransport):
146
+ raise ValueError("transports must contain A2ATransport objects")
147
+ if (not isinstance(transport.host, str) or not transport.host.strip()
148
+ or type(transport.port) is not int or not 0 < transport.port < 65536
149
+ or type(transport.support_tls) is not bool
150
+ or not isinstance(transport.transport, str) or not transport.transport.strip()
151
+ or not isinstance(transport.path, str) or not transport.path.startswith("/")):
152
+ raise ValueError("A2A transport requires a host, valid port, binding and absolute path")
153
+ metadata = {
154
+ META_AGENT_NAME: card.name,
155
+ META_AGENT_CARD: payload,
156
+ META_TRANSPORT: transport.transport,
157
+ META_PATH: transport.path,
158
+ }
159
+ for name, value in metadata.items():
160
+ # Java String.length counts UTF-16 code units, including supplementary characters.
161
+ if len(value.encode("utf-16-le")) // 2 > MAX_METADATA_VALUE_CHARS:
162
+ raise ValueError(f"Metadata {name!r} exceeds the 65535 character limit")
163
+ request = InstanceRegisterRequest(
164
+ namespace=self.namespace, service=card.name, service_token=self.service_token,
165
+ host=transport.host, port=transport.port, version=card.version,
166
+ protocol="https" if transport.support_tls else "http",
167
+ metadata=metadata, ttl=self.ttl, auto_heartbeat=True,
168
+ )
169
+ request.validate()
170
+ requests.append(request)
171
+ return requests
172
+
173
+ def _deregister(self, instances):
174
+ for instance in instances:
175
+ request = InstanceDeRegisterRequest(
176
+ namespace=self.namespace, service=instance.service, service_token=self.service_token,
177
+ host=instance.host, port=instance.port, instance_id=instance.instance_id,
178
+ )
179
+ try:
180
+ self._provider.deregister_instance(request)
181
+ except Exception as error:
182
+ self.logger.warning_throttled(
183
+ ("deregister", instance.service, instance.host, instance.port),
184
+ "Failed to deregister A2A agent %s at %s:%s: %s",
185
+ instance.service, instance.host, instance.port, error,
186
+ )
187
+ else:
188
+ self.logger.debug(
189
+ "Deregistered A2A transport: service=%s host=%s port=%s instanceId=%s",
190
+ instance.service, instance.host, instance.port, instance.instance_id,
191
+ )
192
+ self._registered = [item for item in self._registered if item is not instance]
193
+
194
+ async def async_register(self, agent_card: AgentCard, transports: Sequence[A2ATransport]):
195
+ """Run SDK sync calls off the caller loop; cancellation waits for rollback."""
196
+ cancelled = Event()
197
+ task = asyncio.create_task(asyncio.to_thread(
198
+ self._register, agent_card, tuple(transports), cancelled,
199
+ ))
200
+ try:
201
+ return await asyncio.shield(task)
202
+ except asyncio.CancelledError:
203
+ cancelled.set()
204
+ await drain_task(task)
205
+ if not task.cancelled() and task.exception() is None:
206
+ # Cancellation can arrive after the worker's last check, before delivery.
207
+ cleanup = asyncio.create_task(asyncio.to_thread(self._rollback, task.result()))
208
+ await drain_task(cleanup)
209
+ raise
210
+
211
+ def _rollback(self, instances):
212
+ with self._lock:
213
+ self._deregister(instances)
214
+
215
+ def close(self):
216
+ """Retry every tracked deregistration; failures remain in ``registered``."""
217
+ with self._lock:
218
+ self._closed = True
219
+ self.logger.debug(
220
+ "Closing A2A registrar: namespace=%s remaining=%d",
221
+ self.namespace, len(self._registered),
222
+ )
223
+ self._deregister(tuple(self._registered))
224
+
225
+ async def async_close(self):
226
+ await await_completion(asyncio.to_thread(self.close))
227
+
228
+ def __enter__(self):
229
+ self._check_open()
230
+ return self
231
+
232
+ def __exit__(self, *_):
233
+ self.close()
234
+
235
+ async def __aenter__(self):
236
+ self._check_open()
237
+ return self
238
+
239
+ async def __aexit__(self, *_):
240
+ await self.async_close()
@@ -0,0 +1,198 @@
1
+ # Tencent is pleased to support the open source community by making polaris-python-ai available.
2
+ #
3
+ # Copyright (C) 2026 Tencent. All rights reserved.
4
+ #
5
+ # Licensed under the BSD 3-Clause License (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # https://opensource.org/licenses/BSD-3-Clause
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software distributed
12
+ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
13
+ # CONDITIONS OF ANY KIND, either express or implied. See the License for the
14
+ # specific language governing permissions and limitations under the License.
15
+
16
+ """Sticky AgentCard resolution using public Polaris discovery APIs."""
17
+
18
+ import asyncio
19
+ from dataclasses import dataclass
20
+ from threading import RLock
21
+
22
+ from google.protobuf.json_format import ParseError
23
+ from polaris.api.consumer import ConsumerAPI
24
+ from polaris.pkg.model.error import SDKError
25
+ from polaris.pkg.model.service import GetHealthyInstancesRequest
26
+
27
+ from polaris_ai._async import await_completion
28
+ from polaris_ai.log import get_ai_logger
29
+
30
+ from ._card import META_AGENT_CARD, AgentCard, decode_card
31
+
32
+
33
+ class AgentCardNotFoundError(LookupError):
34
+ """Discovery succeeded but no healthy instance supplied a usable matching AgentCard."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _CachedCard:
39
+ host: str
40
+ port: int
41
+ card: AgentCard
42
+
43
+
44
+ class PolarisA2AResolver:
45
+ """Resolve native AgentCards, retaining the selected healthy host and port.
46
+
47
+ Every lookup refreshes discovery and decodes the selected instance's metadata.
48
+ A Polaris SDKError can reuse the last successful card; a successful empty
49
+ discovery raises AgentCardNotFoundError. Card URLs are preserved as registered.
50
+ """
51
+
52
+ def __init__(self, sdk_context, namespace: str = "default", *, timeout: int = 0,
53
+ logger=None):
54
+ if sdk_context is None or sdk_context.is_destroy():
55
+ raise ValueError("An active SDKContext is required")
56
+ if type(timeout) is not int or timeout < 0:
57
+ raise ValueError("timeout must be a nonnegative integer")
58
+ self.namespace = namespace.strip() or "default"
59
+ self.timeout = timeout
60
+ self.logger = logger if logger is not None else get_ai_logger("a2a")
61
+ self._context = sdk_context
62
+ self._consumer = ConsumerAPI(sdk_context)
63
+ self._cache: dict[str, _CachedCard] = {}
64
+ self._lock = RLock()
65
+ self._closed = False
66
+
67
+ def _check_open(self):
68
+ if self._closed or self._context.is_destroy():
69
+ raise RuntimeError("A2A resolver or SDKContext is closed")
70
+
71
+ def resolve(self, agent_name: str) -> AgentCard:
72
+ """Return the latest card for the selected instance or select the first usable peer."""
73
+ if not isinstance(agent_name, str) or not agent_name.strip():
74
+ raise ValueError("agent_name is required")
75
+ agent_name = agent_name.strip()
76
+ with self._lock:
77
+ self._check_open()
78
+ cached = self._cache.get(agent_name)
79
+ self.logger.debug(
80
+ "Resolving A2A agent: namespace=%s service=%s sticky=%s",
81
+ self.namespace, agent_name, cached is not None,
82
+ )
83
+ try:
84
+ response = self._consumer.get_healthy_instances(GetHealthyInstancesRequest(
85
+ namespace=self.namespace, service=agent_name, timeout=self.timeout,
86
+ exclude_offline_service=True,
87
+ ))
88
+ except SDKError:
89
+ if cached is None:
90
+ raise
91
+ self.logger.warning_throttled(
92
+ ("refresh", agent_name),
93
+ "Failed to refresh agent %s; reusing %s:%s",
94
+ agent_name, cached.host, cached.port,
95
+ )
96
+ return cached.card
97
+ instances = [] if response is None else response.get_instances()
98
+ self.logger.debug(
99
+ "Discovered A2A candidates: namespace=%s service=%s count=%d",
100
+ self.namespace, agent_name, len(instances),
101
+ )
102
+ instances = [i for i in instances if i.is_healthy() and not i.is_isolated()]
103
+ if cached is not None:
104
+ sticky = [i for i in instances
105
+ if (i.get_host(), i.get_port()) == (cached.host, cached.port)]
106
+ instances = sticky + [i for i in instances if i not in sticky]
107
+ for instance in instances:
108
+ payload = instance.get_metadata().get(META_AGENT_CARD, "")
109
+ try:
110
+ card = decode_card(payload)
111
+ except (ValueError, TypeError, ParseError) as error:
112
+ self.logger.warning_throttled(
113
+ ("invalid-card", agent_name, instance.get_host(), instance.get_port()),
114
+ "Skipping invalid AgentCard for %s at %s:%s: %s",
115
+ agent_name, instance.get_host(), instance.get_port(), error,
116
+ )
117
+ continue
118
+ if card.name != agent_name:
119
+ self.logger.warning_throttled(
120
+ ("wrong-name", agent_name, instance.get_host(), instance.get_port()),
121
+ "Skipping AgentCard %r while resolving %r", card.name, agent_name,
122
+ )
123
+ continue
124
+ if not any(i.url.strip() and i.protocol_binding.strip()
125
+ for i in card.supported_interfaces):
126
+ self.logger.warning_throttled(
127
+ ("no-endpoint", agent_name, instance.get_host(), instance.get_port()),
128
+ "Skipping endpoint-less AgentCard for %s", agent_name,
129
+ )
130
+ continue
131
+ if cached is not None and (
132
+ cached.host != instance.get_host()
133
+ or cached.port != instance.get_port()
134
+ ):
135
+ self.logger.info(
136
+ "Switched A2A agent %s/%s from %s:%s to %s:%s",
137
+ self.namespace, agent_name, cached.host, cached.port,
138
+ instance.get_host(), instance.get_port(),
139
+ )
140
+ else:
141
+ self.logger.debug(
142
+ "Selected A2A instance: service=%s host=%s port=%s",
143
+ agent_name, instance.get_host(), instance.get_port(),
144
+ )
145
+ self._cache[agent_name] = _CachedCard(instance.get_host(), instance.get_port(), card)
146
+ return card
147
+ self.logger.debug(
148
+ "No usable A2A AgentCard: namespace=%s service=%s",
149
+ self.namespace, agent_name,
150
+ )
151
+ raise AgentCardNotFoundError(f"No usable AgentCard for {self.namespace}/{agent_name}")
152
+
153
+ async def async_resolve(self, agent_name: str) -> AgentCard:
154
+ return await await_completion(asyncio.to_thread(self.resolve, agent_name))
155
+
156
+ def invalidate(self, agent_name: str):
157
+ with self._lock:
158
+ self.logger.debug("Invalidating A2A card: service=%s", agent_name)
159
+ self._cache.pop(agent_name, None)
160
+
161
+ def invalidate_all(self):
162
+ with self._lock:
163
+ self.logger.debug("Invalidating all A2A cards: count=%d", len(self._cache))
164
+ self._cache.clear()
165
+
166
+ @property
167
+ def consumer(self):
168
+ return self._consumer
169
+
170
+ def selected_endpoint(self, agent_name: str):
171
+ """Return the sticky instance host/port for an already-resolved Agent."""
172
+ with self._lock:
173
+ cached = self._cache.get(agent_name)
174
+ return None if cached is None else (cached.host, cached.port)
175
+
176
+ def close(self):
177
+ """Clear local cards while leaving the borrowed context available."""
178
+ with self._lock:
179
+ self._closed = True
180
+ self.logger.debug("Closing A2A resolver: cached=%d", len(self._cache))
181
+ self._cache.clear()
182
+
183
+ async def async_close(self):
184
+ await await_completion(asyncio.to_thread(self.close))
185
+
186
+ def __enter__(self):
187
+ self._check_open()
188
+ return self
189
+
190
+ def __exit__(self, *_):
191
+ self.close()
192
+
193
+ async def __aenter__(self):
194
+ self._check_open()
195
+ return self
196
+
197
+ async def __aexit__(self, *_):
198
+ await self.async_close()