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
|
@@ -0,0 +1,598 @@
|
|
|
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 discovery and contract filtering for AgentScope MCP clients."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
import types
|
|
24
|
+
|
|
25
|
+
from pydantic import PrivateAttr
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from agentscope.mcp import HttpMCPConfig, MCPClient
|
|
29
|
+
from agentscope.message import ToolResultState
|
|
30
|
+
except ImportError as error:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"Polaris MCP support requires pip install 'polaris-python-ai[mcp]'."
|
|
33
|
+
) from error
|
|
34
|
+
|
|
35
|
+
from polaris.api.consumer import ConsumerAPI
|
|
36
|
+
from polaris.pkg.model.contract import (
|
|
37
|
+
GetServiceContractRequest,
|
|
38
|
+
ServiceFeatureStatus,
|
|
39
|
+
ServiceFeatureType,
|
|
40
|
+
)
|
|
41
|
+
from polaris.pkg.model.service import GetHealthyInstancesRequest, GetInstancesRequest, InstanceType
|
|
42
|
+
|
|
43
|
+
from polaris_ai._async import await_completion
|
|
44
|
+
from polaris_ai.log import get_ai_logger
|
|
45
|
+
from polaris_ai.metrics import PolarisCallReporter
|
|
46
|
+
|
|
47
|
+
MCP_STREAMABLE_HTTP = "mcp-streamable-http"
|
|
48
|
+
MCP_SSE = "mcp-sse"
|
|
49
|
+
MCP_PROTOCOLS = (MCP_STREAMABLE_HTTP, MCP_SSE)
|
|
50
|
+
MCP_SERVER_NAME = "mcp-server-name"
|
|
51
|
+
MCP_PROTOCOL_VERSION = "mcp-protocol-version"
|
|
52
|
+
MCP_ENDPOINT_PATH = "mcp-endpoint-path"
|
|
53
|
+
MCP_SCHEME = "mcp-scheme"
|
|
54
|
+
MCPPoolKey = tuple[str, str, str, str, tuple[str, ...]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _client_name(service: str) -> str:
|
|
58
|
+
name = re.sub(r"[^a-zA-Z0-9_-]", "-", service.strip())
|
|
59
|
+
return name or "polaris-mcp"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _versions(value: str) -> set[str]:
|
|
63
|
+
return {item.strip() for item in value.split(",") if item.strip()}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mcp_endpoint(instance, scheme: str = "") -> tuple[str, int, str]:
|
|
67
|
+
"""Build the AgentScope MCP URL, port and native protocol for an instance."""
|
|
68
|
+
protocol = instance.get_protocol()
|
|
69
|
+
if protocol not in MCP_PROTOCOLS:
|
|
70
|
+
raise ValueError(f"Unsupported MCP protocol {protocol!r}")
|
|
71
|
+
selected_scheme = scheme or instance.get_metadata().get(MCP_SCHEME, "http")
|
|
72
|
+
if selected_scheme not in ("http", "https"):
|
|
73
|
+
raise ValueError("MCP scheme must be http or https")
|
|
74
|
+
path = instance.get_metadata().get(MCP_ENDPOINT_PATH) or (
|
|
75
|
+
"/sse" if protocol == MCP_SSE else "/mcp"
|
|
76
|
+
)
|
|
77
|
+
if not path.startswith("/") or path.startswith("//") or "?" in path or "#" in path:
|
|
78
|
+
raise ValueError("MCP endpoint path must be an absolute path without query or fragment")
|
|
79
|
+
host = instance.get_host()
|
|
80
|
+
if ":" in host and not host.startswith("["):
|
|
81
|
+
host = f"[{host}]"
|
|
82
|
+
return f"{selected_scheme}://{host}:{instance.get_port()}{path}", instance.get_port(), protocol
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PolarisMCPClient(MCPClient):
|
|
86
|
+
"""AgentScope MCP client that reports successful and failed Tool calls."""
|
|
87
|
+
|
|
88
|
+
_polaris_reporter = PrivateAttr(default=None)
|
|
89
|
+
_polaris_service = PrivateAttr(default="")
|
|
90
|
+
_polaris_host = PrivateAttr(default="")
|
|
91
|
+
_polaris_port = PrivateAttr(default=0)
|
|
92
|
+
|
|
93
|
+
def __init__(self, *, reporter=None, service: str, host: str, port: int, **data):
|
|
94
|
+
super().__init__(**data)
|
|
95
|
+
self._polaris_reporter = reporter
|
|
96
|
+
self._polaris_service = service
|
|
97
|
+
self._polaris_host = host
|
|
98
|
+
self._polaris_port = port
|
|
99
|
+
|
|
100
|
+
async def get_tool(self, name: str):
|
|
101
|
+
tool = await super().get_tool(name)
|
|
102
|
+
reporter = self._polaris_reporter
|
|
103
|
+
if reporter is None:
|
|
104
|
+
return tool
|
|
105
|
+
original = tool.call
|
|
106
|
+
|
|
107
|
+
async def report(*, started_at, success, timeout=False, ret_code=None):
|
|
108
|
+
await reporter.report(
|
|
109
|
+
service=self._polaris_service,
|
|
110
|
+
host=self._polaris_host,
|
|
111
|
+
port=self._polaris_port,
|
|
112
|
+
method=name,
|
|
113
|
+
instance_type=InstanceType.MCP,
|
|
114
|
+
started_at=started_at,
|
|
115
|
+
success=success,
|
|
116
|
+
ret_code=ret_code,
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def reported_call(_tool, **kwargs):
|
|
121
|
+
started_at = time.monotonic()
|
|
122
|
+
try:
|
|
123
|
+
result = await original(**kwargs)
|
|
124
|
+
except BaseException as error:
|
|
125
|
+
await report(
|
|
126
|
+
started_at=started_at,
|
|
127
|
+
success=False,
|
|
128
|
+
timeout=isinstance(error, (asyncio.TimeoutError, TimeoutError)),
|
|
129
|
+
)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
if isinstance(result, types.AsyncGeneratorType):
|
|
133
|
+
async def reported_stream():
|
|
134
|
+
final_state = None
|
|
135
|
+
completed = False
|
|
136
|
+
try:
|
|
137
|
+
async for chunk in result:
|
|
138
|
+
if getattr(chunk, "is_last", False):
|
|
139
|
+
final_state = getattr(chunk, "state", None)
|
|
140
|
+
completed = True
|
|
141
|
+
yield chunk
|
|
142
|
+
except BaseException as error:
|
|
143
|
+
await report(
|
|
144
|
+
started_at=started_at,
|
|
145
|
+
success=False,
|
|
146
|
+
timeout=isinstance(error, (asyncio.TimeoutError, TimeoutError)),
|
|
147
|
+
)
|
|
148
|
+
raise
|
|
149
|
+
else:
|
|
150
|
+
success = completed and final_state not in (
|
|
151
|
+
ToolResultState.ERROR,
|
|
152
|
+
ToolResultState.INTERRUPTED,
|
|
153
|
+
ToolResultState.DENIED,
|
|
154
|
+
)
|
|
155
|
+
await report(
|
|
156
|
+
started_at=started_at,
|
|
157
|
+
success=success,
|
|
158
|
+
ret_code=None if success else -1,
|
|
159
|
+
)
|
|
160
|
+
finally:
|
|
161
|
+
await await_completion(result.aclose())
|
|
162
|
+
|
|
163
|
+
return reported_stream()
|
|
164
|
+
|
|
165
|
+
success = getattr(result, "state", None) not in (
|
|
166
|
+
ToolResultState.ERROR,
|
|
167
|
+
ToolResultState.INTERRUPTED,
|
|
168
|
+
ToolResultState.DENIED,
|
|
169
|
+
)
|
|
170
|
+
await report(
|
|
171
|
+
started_at=started_at,
|
|
172
|
+
success=success,
|
|
173
|
+
ret_code=None if success else -1,
|
|
174
|
+
)
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
tool.call = types.MethodType(reported_call, tool)
|
|
178
|
+
return tool
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class PolarisMCPResolver:
|
|
182
|
+
"""Resolve MCP instances and compose native AgentScope MCP clients."""
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
sdk_context,
|
|
187
|
+
namespace: str = "default",
|
|
188
|
+
*,
|
|
189
|
+
client_factory=PolarisMCPClient,
|
|
190
|
+
caller_service: str = "",
|
|
191
|
+
labels: str = "",
|
|
192
|
+
protocol_version: str = "",
|
|
193
|
+
scheme: str = "",
|
|
194
|
+
timeout: float = 30.0,
|
|
195
|
+
filter_contract: bool = True,
|
|
196
|
+
headers: dict[str, str] | None = None,
|
|
197
|
+
logger=None,
|
|
198
|
+
metrics_logger=None,
|
|
199
|
+
):
|
|
200
|
+
if sdk_context is None or sdk_context.is_destroy():
|
|
201
|
+
raise ValueError("An active SDKContext is required")
|
|
202
|
+
self.namespace = namespace.strip() or "default"
|
|
203
|
+
self.consumer = ConsumerAPI(sdk_context)
|
|
204
|
+
self.client_factory = client_factory
|
|
205
|
+
self.caller_service = caller_service
|
|
206
|
+
self.labels = labels
|
|
207
|
+
self.protocol_version = protocol_version
|
|
208
|
+
self.scheme = scheme
|
|
209
|
+
self.timeout = timeout
|
|
210
|
+
self.filter_contract = filter_contract
|
|
211
|
+
self.headers = dict(headers or {})
|
|
212
|
+
self.logger = logger if logger is not None else get_ai_logger("mcp")
|
|
213
|
+
self.metrics_logger = (
|
|
214
|
+
metrics_logger if metrics_logger is not None else get_ai_logger("metrics")
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def async_candidates(self, service: str, *, protocol_version: str | None = None):
|
|
218
|
+
if not isinstance(service, str) or not service.strip():
|
|
219
|
+
raise ValueError("MCP service is required")
|
|
220
|
+
self.logger.debug(
|
|
221
|
+
"Discovering MCP candidates: namespace=%s service=%s protocolVersion=%s",
|
|
222
|
+
self.namespace, service,
|
|
223
|
+
self.protocol_version if protocol_version is None else protocol_version,
|
|
224
|
+
)
|
|
225
|
+
response = await self.consumer.async_get_healthy_instances(
|
|
226
|
+
GetHealthyInstancesRequest(
|
|
227
|
+
namespace=self.namespace,
|
|
228
|
+
service=service.strip(),
|
|
229
|
+
exclude_offline_service=True,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
result = []
|
|
233
|
+
requested = _versions(
|
|
234
|
+
self.protocol_version if protocol_version is None else protocol_version
|
|
235
|
+
)
|
|
236
|
+
for instance in [] if response is None else response.get_instances():
|
|
237
|
+
if not instance.is_healthy() or instance.is_isolated():
|
|
238
|
+
continue
|
|
239
|
+
if instance.get_protocol() not in MCP_PROTOCOLS:
|
|
240
|
+
continue
|
|
241
|
+
versions = _versions(instance.get_metadata().get(MCP_PROTOCOL_VERSION, ""))
|
|
242
|
+
if requested and versions and not requested.intersection(versions):
|
|
243
|
+
continue
|
|
244
|
+
result.append(instance)
|
|
245
|
+
self.logger.debug(
|
|
246
|
+
"Discovered MCP candidates: service=%s healthy=%d",
|
|
247
|
+
service, len(result),
|
|
248
|
+
)
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
async def async_disabled_tools(self, service: str, *, protocol: str = "", version: str = ""):
|
|
252
|
+
try:
|
|
253
|
+
contract = await self.consumer.async_get_service_contract(
|
|
254
|
+
GetServiceContractRequest(
|
|
255
|
+
namespace=self.namespace,
|
|
256
|
+
service=service,
|
|
257
|
+
protocol=protocol,
|
|
258
|
+
version=version,
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
except Exception as error:
|
|
262
|
+
self.logger.warning_throttled(
|
|
263
|
+
("contract", service),
|
|
264
|
+
"MCP contract unavailable for %s; skipping Tool filtering: %s",
|
|
265
|
+
service,
|
|
266
|
+
error,
|
|
267
|
+
)
|
|
268
|
+
return []
|
|
269
|
+
if contract is None:
|
|
270
|
+
return []
|
|
271
|
+
disabled = [
|
|
272
|
+
feature.name
|
|
273
|
+
for feature in contract.service_features
|
|
274
|
+
if feature.type == ServiceFeatureType.MCP_TOOL
|
|
275
|
+
and feature.status == ServiceFeatureStatus.DISABLED
|
|
276
|
+
]
|
|
277
|
+
self.logger.debug(
|
|
278
|
+
"Loaded MCP contract filter: service=%s protocol=%s version=%s disabledTools=%d",
|
|
279
|
+
service, protocol, version, len(disabled),
|
|
280
|
+
)
|
|
281
|
+
return disabled
|
|
282
|
+
|
|
283
|
+
async def async_create_client(
|
|
284
|
+
self,
|
|
285
|
+
service: str,
|
|
286
|
+
*,
|
|
287
|
+
protocol_version: str | None = None,
|
|
288
|
+
scheme: str | None = None,
|
|
289
|
+
headers: dict[str, str] | None = None,
|
|
290
|
+
timeout: float | None = None,
|
|
291
|
+
filter_contract: bool | None = None,
|
|
292
|
+
):
|
|
293
|
+
instances = await self.async_candidates(
|
|
294
|
+
service,
|
|
295
|
+
protocol_version=(
|
|
296
|
+
self.protocol_version if protocol_version is None else protocol_version
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
if not instances:
|
|
300
|
+
raise LookupError(f"No healthy MCP instance for {self.namespace}/{service}")
|
|
301
|
+
self.logger.debug(
|
|
302
|
+
"Selecting MCP instance: service=%s host=%s port=%s",
|
|
303
|
+
service, instances[0].get_host(), instances[0].get_port(),
|
|
304
|
+
)
|
|
305
|
+
return await self.async_client_for_instance(
|
|
306
|
+
service,
|
|
307
|
+
instances[0],
|
|
308
|
+
scheme=self.scheme if scheme is None else scheme,
|
|
309
|
+
headers=self.headers if headers is None else headers,
|
|
310
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
311
|
+
filter_contract=(
|
|
312
|
+
self.filter_contract if filter_contract is None else filter_contract
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
async def async_client_for_instance(
|
|
317
|
+
self,
|
|
318
|
+
service: str,
|
|
319
|
+
instance,
|
|
320
|
+
*,
|
|
321
|
+
scheme: str = "",
|
|
322
|
+
headers: dict[str, str] | None = None,
|
|
323
|
+
timeout: float | None = 30.0,
|
|
324
|
+
filter_contract: bool = True,
|
|
325
|
+
disabled_tools: list[str] | None = None,
|
|
326
|
+
):
|
|
327
|
+
url, port, protocol = mcp_endpoint(instance, scheme)
|
|
328
|
+
self.logger.debug(
|
|
329
|
+
"Creating MCP client: service=%s endpoint=%s protocol=%s stateful=%s",
|
|
330
|
+
service, url, protocol, protocol == MCP_SSE,
|
|
331
|
+
)
|
|
332
|
+
if disabled_tools is None:
|
|
333
|
+
if filter_contract:
|
|
334
|
+
disabled = await self.async_disabled_tools(
|
|
335
|
+
service,
|
|
336
|
+
protocol=protocol,
|
|
337
|
+
version=instance.get_version(),
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
disabled = []
|
|
341
|
+
else:
|
|
342
|
+
disabled = list(disabled_tools)
|
|
343
|
+
reporter = PolarisCallReporter(
|
|
344
|
+
self.consumer,
|
|
345
|
+
self.namespace,
|
|
346
|
+
caller_service=self.caller_service,
|
|
347
|
+
labels=self.labels,
|
|
348
|
+
logger=self.metrics_logger,
|
|
349
|
+
)
|
|
350
|
+
client = self.client_factory(
|
|
351
|
+
name=_client_name(service),
|
|
352
|
+
is_stateful=protocol == MCP_SSE,
|
|
353
|
+
mcp_config=HttpMCPConfig(url=url, headers=headers, timeout=timeout),
|
|
354
|
+
disable_tools=disabled or None,
|
|
355
|
+
reporter=reporter,
|
|
356
|
+
service=service,
|
|
357
|
+
host=instance.get_host(),
|
|
358
|
+
port=port,
|
|
359
|
+
)
|
|
360
|
+
try:
|
|
361
|
+
if client.is_stateful:
|
|
362
|
+
await client.connect()
|
|
363
|
+
except BaseException:
|
|
364
|
+
try:
|
|
365
|
+
await await_completion(client.close())
|
|
366
|
+
except BaseException:
|
|
367
|
+
pass
|
|
368
|
+
raise
|
|
369
|
+
return client
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class PolarisMCPClientPool:
|
|
373
|
+
"""Maintain one MCP client per watched healthy Polaris instance."""
|
|
374
|
+
|
|
375
|
+
def __init__(self, resolver: PolarisMCPResolver, service: str, **client_options):
|
|
376
|
+
self.resolver = resolver
|
|
377
|
+
self.service = service
|
|
378
|
+
options = {
|
|
379
|
+
"scheme": resolver.scheme,
|
|
380
|
+
"headers": resolver.headers,
|
|
381
|
+
"timeout": resolver.timeout,
|
|
382
|
+
"filter_contract": resolver.filter_contract,
|
|
383
|
+
**client_options,
|
|
384
|
+
}
|
|
385
|
+
configured_protocol = options.pop("protocol_version", resolver.protocol_version)
|
|
386
|
+
self.protocol_version = (
|
|
387
|
+
resolver.protocol_version if configured_protocol is None else configured_protocol
|
|
388
|
+
)
|
|
389
|
+
self.client_options = options
|
|
390
|
+
self._clients: dict[MCPPoolKey, MCPClient] = {}
|
|
391
|
+
self._watch = None
|
|
392
|
+
self._refresh_event = asyncio.Event()
|
|
393
|
+
self._task = None
|
|
394
|
+
self._lock = asyncio.Lock()
|
|
395
|
+
self._closed = False
|
|
396
|
+
self._cursor = 0
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def clients(self):
|
|
400
|
+
return tuple(self._clients.values())
|
|
401
|
+
|
|
402
|
+
async def start(self):
|
|
403
|
+
"""Load clients once and subscribe to service cache lifecycle events."""
|
|
404
|
+
if self._closed:
|
|
405
|
+
raise RuntimeError("MCP client pool is closed")
|
|
406
|
+
if self._task is not None:
|
|
407
|
+
return self
|
|
408
|
+
loop = asyncio.get_running_loop()
|
|
409
|
+
|
|
410
|
+
def changed(_event):
|
|
411
|
+
loop.call_soon_threadsafe(self._refresh_event.set)
|
|
412
|
+
|
|
413
|
+
self._watch = await self.resolver.consumer.async_watch_service(
|
|
414
|
+
GetInstancesRequest(
|
|
415
|
+
namespace=self.resolver.namespace,
|
|
416
|
+
service=self.service,
|
|
417
|
+
exclude_offline_service=True,
|
|
418
|
+
),
|
|
419
|
+
changed,
|
|
420
|
+
)
|
|
421
|
+
try:
|
|
422
|
+
await self.refresh()
|
|
423
|
+
self._task = asyncio.create_task(self._run(), name=f"polaris-mcp-pool-{self.service}")
|
|
424
|
+
self.resolver.logger.info(
|
|
425
|
+
"Started MCP client pool: service=%s clients=%d",
|
|
426
|
+
self.service, len(self._clients),
|
|
427
|
+
)
|
|
428
|
+
except BaseException:
|
|
429
|
+
try:
|
|
430
|
+
await await_completion(self._cleanup_start_failure())
|
|
431
|
+
except asyncio.CancelledError:
|
|
432
|
+
pass
|
|
433
|
+
raise
|
|
434
|
+
return self
|
|
435
|
+
|
|
436
|
+
async def _cleanup_start_failure(self):
|
|
437
|
+
clients, self._clients = tuple(self._clients.values()), {}
|
|
438
|
+
watch, self._watch = self._watch, None
|
|
439
|
+
try:
|
|
440
|
+
await self._close_clients(clients)
|
|
441
|
+
finally:
|
|
442
|
+
if watch is not None:
|
|
443
|
+
await self.resolver.consumer.async_unwatch_service(watch)
|
|
444
|
+
|
|
445
|
+
async def _run(self):
|
|
446
|
+
while not self._closed:
|
|
447
|
+
await self._refresh_event.wait()
|
|
448
|
+
if self._closed:
|
|
449
|
+
return
|
|
450
|
+
self._refresh_event.clear()
|
|
451
|
+
try:
|
|
452
|
+
await self.refresh()
|
|
453
|
+
except Exception as error:
|
|
454
|
+
self.resolver.logger.warning_throttled(
|
|
455
|
+
("pool-refresh", self.service),
|
|
456
|
+
"Failed to refresh MCP pool for %s: %s", self.service, error,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
async def refresh(self):
|
|
460
|
+
"""Reconcile current healthy instances with the native client pool."""
|
|
461
|
+
async with self._lock:
|
|
462
|
+
if self._closed:
|
|
463
|
+
return
|
|
464
|
+
self.resolver.logger.debug(
|
|
465
|
+
"Refreshing MCP client pool: service=%s currentClients=%d",
|
|
466
|
+
self.service, len(self._clients),
|
|
467
|
+
)
|
|
468
|
+
instances = await self.resolver.async_candidates(
|
|
469
|
+
self.service,
|
|
470
|
+
protocol_version=self.protocol_version,
|
|
471
|
+
)
|
|
472
|
+
desired: dict[MCPPoolKey, tuple[object, str, list[str]]] = {}
|
|
473
|
+
for instance in instances:
|
|
474
|
+
try:
|
|
475
|
+
url, _port, protocol = mcp_endpoint(
|
|
476
|
+
instance, self.client_options.get("scheme", "")
|
|
477
|
+
)
|
|
478
|
+
except ValueError as error:
|
|
479
|
+
self.resolver.logger.warning_throttled(
|
|
480
|
+
("invalid-instance", self.service, instance.get_id()),
|
|
481
|
+
"Skipping invalid MCP instance %s: %s", instance.get_id(), error,
|
|
482
|
+
)
|
|
483
|
+
continue
|
|
484
|
+
disabled = (
|
|
485
|
+
await self.resolver.async_disabled_tools(
|
|
486
|
+
self.service,
|
|
487
|
+
protocol=protocol,
|
|
488
|
+
version=instance.get_version(),
|
|
489
|
+
)
|
|
490
|
+
if self.client_options["filter_contract"] else []
|
|
491
|
+
)
|
|
492
|
+
key: MCPPoolKey = (
|
|
493
|
+
url,
|
|
494
|
+
protocol,
|
|
495
|
+
instance.get_version(),
|
|
496
|
+
instance.get_metadata().get(MCP_PROTOCOL_VERSION, ""),
|
|
497
|
+
tuple(sorted(set(disabled))),
|
|
498
|
+
)
|
|
499
|
+
desired[key] = (instance, url, disabled)
|
|
500
|
+
|
|
501
|
+
next_clients: dict[MCPPoolKey, MCPClient] = {}
|
|
502
|
+
created = []
|
|
503
|
+
try:
|
|
504
|
+
for key, (instance, _url, disabled) in desired.items():
|
|
505
|
+
client = self._clients.get(key)
|
|
506
|
+
if client is None:
|
|
507
|
+
client = await self.resolver.async_client_for_instance(
|
|
508
|
+
self.service,
|
|
509
|
+
instance,
|
|
510
|
+
disabled_tools=disabled,
|
|
511
|
+
**self.client_options,
|
|
512
|
+
)
|
|
513
|
+
created.append(client)
|
|
514
|
+
next_clients[key] = client
|
|
515
|
+
except BaseException:
|
|
516
|
+
try:
|
|
517
|
+
await self._close_clients(created)
|
|
518
|
+
except asyncio.CancelledError:
|
|
519
|
+
pass
|
|
520
|
+
raise
|
|
521
|
+
|
|
522
|
+
if self._closed:
|
|
523
|
+
await self._close_clients(created)
|
|
524
|
+
self.resolver.logger.debug(
|
|
525
|
+
"Discarded MCP clients created during close: service=%s count=%d",
|
|
526
|
+
self.service, len(created),
|
|
527
|
+
)
|
|
528
|
+
return
|
|
529
|
+
|
|
530
|
+
previous_keys = set(self._clients)
|
|
531
|
+
next_keys = set(next_clients)
|
|
532
|
+
previous, self._clients = self._clients, next_clients
|
|
533
|
+
await self._close_clients(
|
|
534
|
+
tuple(client for key, client in previous.items() if key not in next_clients)
|
|
535
|
+
)
|
|
536
|
+
self.resolver.logger.debug(
|
|
537
|
+
"MCP client pool reconciled: service=%s added=%s removed=%s",
|
|
538
|
+
self.service, sorted(next_keys - previous_keys), sorted(previous_keys - next_keys),
|
|
539
|
+
)
|
|
540
|
+
if next_keys != previous_keys:
|
|
541
|
+
self.resolver.logger.info(
|
|
542
|
+
"MCP client pool changed: service=%s before=%d after=%d added=%d removed=%d",
|
|
543
|
+
self.service, len(previous_keys), len(next_keys),
|
|
544
|
+
len(next_keys - previous_keys), len(previous_keys - next_keys),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
async def _close_clients(self, clients):
|
|
548
|
+
async def close_all():
|
|
549
|
+
for client in clients:
|
|
550
|
+
try:
|
|
551
|
+
await client.close()
|
|
552
|
+
except Exception as error:
|
|
553
|
+
self.resolver.logger.warning_throttled(
|
|
554
|
+
("close-client", self.service, getattr(client, "name", "")),
|
|
555
|
+
"Failed to close MCP client for %s: %s", self.service, error,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
await await_completion(close_all())
|
|
559
|
+
|
|
560
|
+
async def _close_resources(self, task):
|
|
561
|
+
if task is not None:
|
|
562
|
+
await task
|
|
563
|
+
async with self._lock:
|
|
564
|
+
if self._watch is not None:
|
|
565
|
+
watch = self._watch
|
|
566
|
+
await self.resolver.consumer.async_unwatch_service(watch)
|
|
567
|
+
if self._watch is watch:
|
|
568
|
+
self._watch = None
|
|
569
|
+
clients, self._clients = tuple(self._clients.values()), {}
|
|
570
|
+
await self._close_clients(clients)
|
|
571
|
+
|
|
572
|
+
def choose_client(self):
|
|
573
|
+
"""Return the next connected client using round-robin selection."""
|
|
574
|
+
clients = list(self._clients.values())
|
|
575
|
+
if not clients:
|
|
576
|
+
raise LookupError(f"No MCP client is available for {self.service}")
|
|
577
|
+
self._cursor %= len(clients)
|
|
578
|
+
client = clients[self._cursor]
|
|
579
|
+
self._cursor = (self._cursor + 1) % len(clients)
|
|
580
|
+
return client
|
|
581
|
+
|
|
582
|
+
async def close(self):
|
|
583
|
+
"""Stop watch updates and close all owned MCP clients."""
|
|
584
|
+
self._closed = True
|
|
585
|
+
self._refresh_event.set()
|
|
586
|
+
task, self._task = self._task, None
|
|
587
|
+
self.resolver.logger.debug(
|
|
588
|
+
"Closing MCP client pool: service=%s clients=%d",
|
|
589
|
+
self.service, len(self._clients),
|
|
590
|
+
)
|
|
591
|
+
await await_completion(self._close_resources(task))
|
|
592
|
+
self.resolver.logger.info("Closed MCP client pool: service=%s", self.service)
|
|
593
|
+
|
|
594
|
+
async def __aenter__(self):
|
|
595
|
+
return await self.start()
|
|
596
|
+
|
|
597
|
+
async def __aexit__(self, *_):
|
|
598
|
+
await self.close()
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
"""AgentScope middleware helpers for Polaris-backed agents."""
|
|
17
|
+
|
|
18
|
+
from weakref import WeakKeyDictionary
|
|
19
|
+
|
|
20
|
+
from agentscope.middleware import MiddlewareBase
|
|
21
|
+
from agentscope.skill import SkillLoaderBase
|
|
22
|
+
|
|
23
|
+
from polaris_ai.log import get_ai_logger
|
|
24
|
+
|
|
25
|
+
_SHARED_SNAPSHOT_LOADERS = WeakKeyDictionary()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_shared_snapshot_loader(loader):
|
|
29
|
+
"""Return the snapshot associated with a loader, if middleware created it."""
|
|
30
|
+
return _SHARED_SNAPSHOT_LOADERS.get(loader)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _SnapshotSkillLoader(SkillLoaderBase):
|
|
34
|
+
"""Expose the middleware's last successful Skill snapshot to a Toolkit."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.last_skills = ()
|
|
38
|
+
|
|
39
|
+
async def list_skills(self):
|
|
40
|
+
return list(self.last_skills)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PolarisDynamicSkillMiddleware(MiddlewareBase):
|
|
44
|
+
"""Refresh Polaris Skills before each reply while reusing one Agent.
|
|
45
|
+
|
|
46
|
+
The loader applies its refresh interval, so replies within that interval do
|
|
47
|
+
not query Polaris again. Refresh failures are logged and the reply proceeds
|
|
48
|
+
with the loader's last successful state.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, loader: SkillLoaderBase, *, logger=None):
|
|
52
|
+
if not isinstance(loader, SkillLoaderBase):
|
|
53
|
+
raise TypeError("loader must implement SkillLoaderBase")
|
|
54
|
+
self.loader = loader
|
|
55
|
+
self.logger = logger if logger is not None else get_ai_logger("agentscope")
|
|
56
|
+
self.last_skills = ()
|
|
57
|
+
snapshot = get_shared_snapshot_loader(loader)
|
|
58
|
+
if snapshot is None:
|
|
59
|
+
snapshot = _SnapshotSkillLoader()
|
|
60
|
+
_SHARED_SNAPSHOT_LOADERS[loader] = snapshot
|
|
61
|
+
self._snapshot_loader = snapshot
|
|
62
|
+
|
|
63
|
+
async def on_reply(self, agent, input_kwargs, next_handler):
|
|
64
|
+
self.logger.debug("Refreshing Polaris Skills before Agent reply")
|
|
65
|
+
try:
|
|
66
|
+
self.last_skills = tuple(await self.loader.list_skills())
|
|
67
|
+
except Exception as error:
|
|
68
|
+
self.logger.warning_throttled(
|
|
69
|
+
("refresh", id(self.loader)),
|
|
70
|
+
"Failed to refresh Polaris Skills before reply: %s", error,
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
self._snapshot_loader.last_skills = self.last_skills
|
|
74
|
+
self.logger.debug(
|
|
75
|
+
"Refreshed Polaris Skill snapshot: count=%d names=%s",
|
|
76
|
+
len(self.last_skills), [skill.name for skill in self.last_skills],
|
|
77
|
+
)
|
|
78
|
+
self._sync_toolkit(agent)
|
|
79
|
+
async for event in next_handler():
|
|
80
|
+
yield event
|
|
81
|
+
|
|
82
|
+
def _sync_toolkit(self, agent):
|
|
83
|
+
toolkit = getattr(agent, "toolkit", None)
|
|
84
|
+
if toolkit is None:
|
|
85
|
+
self.logger.debug("Agent has no Toolkit; skipping Polaris Skill snapshot binding")
|
|
86
|
+
return
|
|
87
|
+
for group in getattr(toolkit, "tool_groups", ()):
|
|
88
|
+
if group.name != "basic":
|
|
89
|
+
continue
|
|
90
|
+
for index, item in enumerate(group.skills_or_loaders):
|
|
91
|
+
if item is self._snapshot_loader:
|
|
92
|
+
self.logger.debug(
|
|
93
|
+
"Polaris Skill snapshot already bound to Toolkit: category=%s",
|
|
94
|
+
group.name,
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
if item is self.loader:
|
|
98
|
+
group.skills_or_loaders[index] = self._snapshot_loader
|
|
99
|
+
self.logger.debug(
|
|
100
|
+
"Bound Polaris Skill snapshot to Toolkit: category=%s index=%d",
|
|
101
|
+
group.name, index,
|
|
102
|
+
)
|
|
103
|
+
return
|