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.
@@ -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