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 +34 -0
- polaris_ai/_async.py +39 -0
- polaris_ai/_version.py +18 -0
- polaris_ai/a2a/__init__.py +31 -0
- polaris_ai/a2a/_card.py +44 -0
- polaris_ai/a2a/registry.py +240 -0
- polaris_ai/a2a/resolver.py +198 -0
- polaris_ai/a2a/server.py +92 -0
- polaris_ai/agentscope/__init__.py +76 -0
- polaris_ai/agentscope/a2a.py +276 -0
- polaris_ai/agentscope/cards.py +106 -0
- polaris_ai/agentscope/config.py +221 -0
- polaris_ai/agentscope/context.py +195 -0
- polaris_ai/agentscope/mcp.py +598 -0
- polaris_ai/agentscope/middleware.py +103 -0
- polaris_ai/agentscope/runtime.py +291 -0
- polaris_ai/agentscope/skill.py +464 -0
- polaris_ai/langgraph/__init__.py +50 -0
- polaris_ai/langgraph/config.py +160 -0
- polaris_ai/langgraph/context.py +152 -0
- polaris_ai/langgraph/registry.py +258 -0
- polaris_ai/langgraph/remote.py +315 -0
- polaris_ai/langgraph/resolver.py +226 -0
- polaris_ai/log.py +408 -0
- polaris_ai/metrics.py +154 -0
- polaris_python_ai-0.0.1.dev2.dist-info/METADATA +351 -0
- polaris_python_ai-0.0.1.dev2.dist-info/RECORD +30 -0
- polaris_python_ai-0.0.1.dev2.dist-info/WHEEL +5 -0
- polaris_python_ai-0.0.1.dev2.dist-info/licenses/LICENSE +27 -0
- polaris_python_ai-0.0.1.dev2.dist-info/top_level.txt +1 -0
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
|
+
]
|
polaris_ai/a2a/_card.py
ADDED
|
@@ -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()
|