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,315 @@
|
|
|
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
|
+
"""A Polaris-routed wrapper around LangGraph's native ``RemoteGraph``."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from polaris.pkg.model.service import InstanceType
|
|
24
|
+
|
|
25
|
+
from polaris_ai._async import await_completion
|
|
26
|
+
from polaris_ai.log import get_ai_logger
|
|
27
|
+
from polaris_ai.metrics import PolarisCallReporter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_timeout(error: BaseException) -> bool:
|
|
31
|
+
seen = set()
|
|
32
|
+
current: BaseException | None = error
|
|
33
|
+
while current is not None and id(current) not in seen:
|
|
34
|
+
seen.add(id(current))
|
|
35
|
+
if isinstance(current, (TimeoutError, asyncio.TimeoutError)):
|
|
36
|
+
return True
|
|
37
|
+
if current.__class__.__name__ in {
|
|
38
|
+
"TimeoutException",
|
|
39
|
+
"ReadTimeout",
|
|
40
|
+
"ConnectTimeout",
|
|
41
|
+
"WriteTimeout",
|
|
42
|
+
"PoolTimeout",
|
|
43
|
+
}:
|
|
44
|
+
return True
|
|
45
|
+
current = current.__cause__ or current.__context__
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PolarisRemoteGraph:
|
|
50
|
+
"""Resolve a graph endpoint from Polaris and invoke LangGraph's RemoteGraph.
|
|
51
|
+
|
|
52
|
+
Each call refreshes discovery. The native client is reused while the selected
|
|
53
|
+
URL is unchanged and rebuilt after failover. The supplied resolver remains
|
|
54
|
+
borrowed unless ``owns_resolver=True``.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
resolver,
|
|
60
|
+
graph_id: str,
|
|
61
|
+
*,
|
|
62
|
+
service: str = "",
|
|
63
|
+
graph_factory=None,
|
|
64
|
+
api_key: str = "",
|
|
65
|
+
headers: dict[str, str] | None = None,
|
|
66
|
+
reporter=None,
|
|
67
|
+
caller_service: str = "",
|
|
68
|
+
labels: str = "",
|
|
69
|
+
owns_resolver: bool = False,
|
|
70
|
+
logger=None,
|
|
71
|
+
):
|
|
72
|
+
if resolver is None:
|
|
73
|
+
raise ValueError("A PolarisLangGraphResolver is required")
|
|
74
|
+
if not isinstance(graph_id, str) or not graph_id.strip():
|
|
75
|
+
raise ValueError("graph_id is required")
|
|
76
|
+
self.graph_id = graph_id.strip()
|
|
77
|
+
self.service = service.strip() or self.graph_id
|
|
78
|
+
self._resolver = resolver
|
|
79
|
+
self._graph_factory = graph_factory
|
|
80
|
+
self._api_key = api_key
|
|
81
|
+
self._headers = dict(headers or {})
|
|
82
|
+
self._owns_resolver = owns_resolver
|
|
83
|
+
self._graph = None
|
|
84
|
+
self._graph_url = ""
|
|
85
|
+
self._closed = False
|
|
86
|
+
self.logger = logger if logger is not None else get_ai_logger("langgraph")
|
|
87
|
+
self._reporter = reporter
|
|
88
|
+
if self._reporter is None and getattr(resolver, "consumer", None) is not None:
|
|
89
|
+
self._reporter = PolarisCallReporter(
|
|
90
|
+
resolver.consumer,
|
|
91
|
+
getattr(resolver, "namespace", "default"),
|
|
92
|
+
caller_service=caller_service,
|
|
93
|
+
labels=labels,
|
|
94
|
+
logger=self.logger,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _check_open(self):
|
|
98
|
+
if self._closed:
|
|
99
|
+
raise RuntimeError("Polaris remote graph is closed")
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _native_type():
|
|
103
|
+
try:
|
|
104
|
+
try:
|
|
105
|
+
from langgraph.pregel.remote import RemoteGraph
|
|
106
|
+
except ImportError:
|
|
107
|
+
from langgraph.pregel import RemoteGraph
|
|
108
|
+
except ImportError as error:
|
|
109
|
+
raise ImportError(
|
|
110
|
+
"LangGraph integration requires "
|
|
111
|
+
"pip install 'polaris-python-ai[langgraph]'."
|
|
112
|
+
) from error
|
|
113
|
+
return RemoteGraph
|
|
114
|
+
|
|
115
|
+
def _select_graph(self, endpoint):
|
|
116
|
+
if self._graph is not None and self._graph_url == endpoint.url:
|
|
117
|
+
return self._graph, None
|
|
118
|
+
factory = self._graph_factory or self._native_type()
|
|
119
|
+
self.logger.debug(
|
|
120
|
+
"Creating LangGraph client: graph=%s service=%s url=%s",
|
|
121
|
+
self.graph_id,
|
|
122
|
+
self.service,
|
|
123
|
+
endpoint.url,
|
|
124
|
+
)
|
|
125
|
+
previous = self._graph
|
|
126
|
+
self._graph = factory(
|
|
127
|
+
self.graph_id,
|
|
128
|
+
url=endpoint.url,
|
|
129
|
+
api_key=self._api_key or None,
|
|
130
|
+
headers=self._headers or None,
|
|
131
|
+
)
|
|
132
|
+
self._graph_url = endpoint.url
|
|
133
|
+
return self._graph, previous
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _close_sync_graph(graph):
|
|
137
|
+
client = getattr(graph, "sync_client", None)
|
|
138
|
+
close = getattr(client, "close", None)
|
|
139
|
+
if callable(close):
|
|
140
|
+
close()
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
async def _close_async_graph(graph):
|
|
144
|
+
client = getattr(graph, "client", None)
|
|
145
|
+
close = getattr(client, "aclose", None)
|
|
146
|
+
if callable(close):
|
|
147
|
+
await close()
|
|
148
|
+
sync_client = getattr(graph, "sync_client", None)
|
|
149
|
+
sync_close = getattr(sync_client, "close", None)
|
|
150
|
+
if callable(sync_close):
|
|
151
|
+
await await_completion(asyncio.to_thread(sync_close))
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def remote_graph(self):
|
|
155
|
+
return self._graph
|
|
156
|
+
|
|
157
|
+
def invoke(self, input, config=None, **kwargs):
|
|
158
|
+
"""Execute a synchronous graph call and report its Polaris outcome."""
|
|
159
|
+
self._check_open()
|
|
160
|
+
endpoint = self._resolver.resolve(self.graph_id, self.service)
|
|
161
|
+
started_at = time.monotonic()
|
|
162
|
+
try:
|
|
163
|
+
graph, previous = self._select_graph(endpoint)
|
|
164
|
+
if previous is not None:
|
|
165
|
+
self._close_sync_graph(previous)
|
|
166
|
+
result = graph.invoke(input, config, **kwargs)
|
|
167
|
+
except BaseException as error:
|
|
168
|
+
self._report_sync(
|
|
169
|
+
endpoint,
|
|
170
|
+
started_at,
|
|
171
|
+
method="langgraph.invoke",
|
|
172
|
+
success=False,
|
|
173
|
+
timeout=_is_timeout(error),
|
|
174
|
+
)
|
|
175
|
+
raise
|
|
176
|
+
self._report_sync(endpoint, started_at, method="langgraph.invoke", success=True)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
async def ainvoke(self, input, config=None, **kwargs):
|
|
180
|
+
"""Execute an asynchronous graph call and report its Polaris outcome."""
|
|
181
|
+
self._check_open()
|
|
182
|
+
endpoint = await self._resolver.async_resolve(self.graph_id, self.service)
|
|
183
|
+
started_at = time.monotonic()
|
|
184
|
+
try:
|
|
185
|
+
graph, previous = self._select_graph(endpoint)
|
|
186
|
+
if previous is not None:
|
|
187
|
+
await self._close_async_graph(previous)
|
|
188
|
+
result = await graph.ainvoke(input, config, **kwargs)
|
|
189
|
+
except BaseException as error:
|
|
190
|
+
await self._report(
|
|
191
|
+
endpoint,
|
|
192
|
+
started_at,
|
|
193
|
+
method="langgraph.invoke",
|
|
194
|
+
success=False,
|
|
195
|
+
timeout=_is_timeout(error),
|
|
196
|
+
)
|
|
197
|
+
raise
|
|
198
|
+
await self._report(endpoint, started_at, method="langgraph.invoke", success=True)
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def stream(self, input, config=None, **kwargs):
|
|
202
|
+
"""Stream a synchronous graph call, reporting after completion or failure."""
|
|
203
|
+
self._check_open()
|
|
204
|
+
endpoint = self._resolver.resolve(self.graph_id, self.service)
|
|
205
|
+
started_at = time.monotonic()
|
|
206
|
+
try:
|
|
207
|
+
graph, previous = self._select_graph(endpoint)
|
|
208
|
+
if previous is not None:
|
|
209
|
+
self._close_sync_graph(previous)
|
|
210
|
+
yield from graph.stream(input, config, **kwargs)
|
|
211
|
+
except BaseException as error:
|
|
212
|
+
self._report_sync(
|
|
213
|
+
endpoint,
|
|
214
|
+
started_at,
|
|
215
|
+
method="langgraph.stream",
|
|
216
|
+
success=False,
|
|
217
|
+
timeout=_is_timeout(error),
|
|
218
|
+
)
|
|
219
|
+
raise
|
|
220
|
+
self._report_sync(endpoint, started_at, method="langgraph.stream", success=True)
|
|
221
|
+
|
|
222
|
+
async def astream(self, input, config=None, **kwargs):
|
|
223
|
+
"""Asynchronously stream a graph call, reporting after completion or failure."""
|
|
224
|
+
self._check_open()
|
|
225
|
+
endpoint = await self._resolver.async_resolve(self.graph_id, self.service)
|
|
226
|
+
started_at = time.monotonic()
|
|
227
|
+
try:
|
|
228
|
+
graph, previous = self._select_graph(endpoint)
|
|
229
|
+
if previous is not None:
|
|
230
|
+
await self._close_async_graph(previous)
|
|
231
|
+
async for chunk in graph.astream(input, config, **kwargs):
|
|
232
|
+
yield chunk
|
|
233
|
+
except BaseException as error:
|
|
234
|
+
await self._report(
|
|
235
|
+
endpoint,
|
|
236
|
+
started_at,
|
|
237
|
+
method="langgraph.stream",
|
|
238
|
+
success=False,
|
|
239
|
+
timeout=_is_timeout(error),
|
|
240
|
+
)
|
|
241
|
+
raise
|
|
242
|
+
await self._report(endpoint, started_at, method="langgraph.stream", success=True)
|
|
243
|
+
|
|
244
|
+
def _report_sync(self, endpoint, started_at, *, method, success, timeout=False):
|
|
245
|
+
if self._reporter is None:
|
|
246
|
+
return
|
|
247
|
+
self._reporter.report_sync(
|
|
248
|
+
service=endpoint.service,
|
|
249
|
+
host=endpoint.host,
|
|
250
|
+
port=endpoint.port,
|
|
251
|
+
method=method,
|
|
252
|
+
instance_type=InstanceType.LANGGRAPH,
|
|
253
|
+
started_at=started_at,
|
|
254
|
+
success=success,
|
|
255
|
+
timeout=timeout,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def _report(self, endpoint, started_at, *, method, success, timeout=False):
|
|
259
|
+
if self._reporter is None:
|
|
260
|
+
return
|
|
261
|
+
await self._reporter.report(
|
|
262
|
+
service=endpoint.service,
|
|
263
|
+
host=endpoint.host,
|
|
264
|
+
port=endpoint.port,
|
|
265
|
+
method=method,
|
|
266
|
+
instance_type=InstanceType.LANGGRAPH,
|
|
267
|
+
started_at=started_at,
|
|
268
|
+
success=success,
|
|
269
|
+
timeout=timeout,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def invalidate(self):
|
|
273
|
+
"""Force endpoint discovery and native-client recreation on the next call."""
|
|
274
|
+
self._graph = None
|
|
275
|
+
self._graph_url = ""
|
|
276
|
+
self._resolver.invalidate(self.graph_id, self.service)
|
|
277
|
+
|
|
278
|
+
def close(self):
|
|
279
|
+
if self._closed:
|
|
280
|
+
return
|
|
281
|
+
self._closed = True
|
|
282
|
+
self._close_sync_graph(self._graph)
|
|
283
|
+
self._graph = None
|
|
284
|
+
self._graph_url = ""
|
|
285
|
+
if self._owns_resolver:
|
|
286
|
+
self._resolver.close()
|
|
287
|
+
|
|
288
|
+
async def aclose(self):
|
|
289
|
+
if self._closed:
|
|
290
|
+
return
|
|
291
|
+
if self._owns_resolver:
|
|
292
|
+
await await_completion(self._resolver.async_close())
|
|
293
|
+
await self._close_async_graph(self._graph)
|
|
294
|
+
self._closed = True
|
|
295
|
+
self._graph = None
|
|
296
|
+
self._graph_url = ""
|
|
297
|
+
else:
|
|
298
|
+
await self._close_async_graph(self._graph)
|
|
299
|
+
self._closed = True
|
|
300
|
+
self._graph = None
|
|
301
|
+
self._graph_url = ""
|
|
302
|
+
|
|
303
|
+
def __enter__(self):
|
|
304
|
+
self._check_open()
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def __exit__(self, *_):
|
|
308
|
+
self.close()
|
|
309
|
+
|
|
310
|
+
async def __aenter__(self):
|
|
311
|
+
self._check_open()
|
|
312
|
+
return self
|
|
313
|
+
|
|
314
|
+
async def __aexit__(self, *_):
|
|
315
|
+
await self.aclose()
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
"""Resolve healthy LangGraph Server endpoints through Polaris discovery."""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from threading import RLock
|
|
21
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
22
|
+
|
|
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 .registry import META_GRAPH_ID, META_PATH, META_PROTOCOL
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LangGraphEndpointNotFoundError(LookupError):
|
|
34
|
+
"""No healthy LangGraph instance matched the requested service and graph."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class LangGraphEndpoint:
|
|
39
|
+
namespace: str
|
|
40
|
+
service: str
|
|
41
|
+
graph_id: str
|
|
42
|
+
host: str
|
|
43
|
+
port: int
|
|
44
|
+
protocol: str
|
|
45
|
+
path: str
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def url(self) -> str:
|
|
49
|
+
host = f"[{self.host}]" if ":" in self.host and not self.host.startswith("[") else self.host
|
|
50
|
+
path = self.path if self.path.startswith("/") else f"/{self.path}"
|
|
51
|
+
return urlunsplit((self.protocol, f"{host}:{self.port}", path, "", ""))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PolarisLangGraphResolver:
|
|
55
|
+
"""Keep a sticky endpoint while refreshing discovery for every lookup."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
sdk_context,
|
|
60
|
+
namespace: str = "default",
|
|
61
|
+
*,
|
|
62
|
+
timeout: int = 0,
|
|
63
|
+
logger=None,
|
|
64
|
+
):
|
|
65
|
+
if sdk_context is None or sdk_context.is_destroy():
|
|
66
|
+
raise ValueError("An active SDKContext is required")
|
|
67
|
+
if type(timeout) is not int or timeout < 0:
|
|
68
|
+
raise ValueError("timeout must be a nonnegative integer")
|
|
69
|
+
self.namespace = namespace.strip() or "default"
|
|
70
|
+
self.timeout = timeout
|
|
71
|
+
self.logger = logger if logger is not None else get_ai_logger("langgraph")
|
|
72
|
+
self._context = sdk_context
|
|
73
|
+
self._consumer = ConsumerAPI(sdk_context)
|
|
74
|
+
self._cache: dict[tuple[str, str], LangGraphEndpoint] = {}
|
|
75
|
+
self._lock = RLock()
|
|
76
|
+
self._closed = False
|
|
77
|
+
|
|
78
|
+
def _check_open(self):
|
|
79
|
+
if self._closed or self._context.is_destroy():
|
|
80
|
+
raise RuntimeError("LangGraph resolver or SDKContext is closed")
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _endpoint_key(graph_id: str, service: str) -> tuple[str, str]:
|
|
84
|
+
if not isinstance(graph_id, str) or not graph_id.strip():
|
|
85
|
+
raise ValueError("graph_id is required")
|
|
86
|
+
if service is None:
|
|
87
|
+
service = ""
|
|
88
|
+
if not isinstance(service, str):
|
|
89
|
+
raise ValueError("service must be a string")
|
|
90
|
+
graph_id = graph_id.strip()
|
|
91
|
+
return (service.strip() or graph_id, graph_id)
|
|
92
|
+
|
|
93
|
+
def resolve(self, graph_id: str, service: str = "") -> LangGraphEndpoint:
|
|
94
|
+
"""Return a healthy LangGraph endpoint, retaining the previous ``host:port``."""
|
|
95
|
+
service, graph_id = self._endpoint_key(graph_id, service)
|
|
96
|
+
key = (service, graph_id)
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._check_open()
|
|
99
|
+
cached = self._cache.get(key)
|
|
100
|
+
try:
|
|
101
|
+
response = self._consumer.get_healthy_instances(
|
|
102
|
+
GetHealthyInstancesRequest(
|
|
103
|
+
namespace=self.namespace,
|
|
104
|
+
service=service,
|
|
105
|
+
timeout=self.timeout,
|
|
106
|
+
exclude_offline_service=True,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
except SDKError:
|
|
110
|
+
if cached is None:
|
|
111
|
+
raise
|
|
112
|
+
self.logger.warning_throttled(
|
|
113
|
+
("refresh", service, graph_id),
|
|
114
|
+
"Failed to refresh LangGraph %s; reusing %s:%s",
|
|
115
|
+
service,
|
|
116
|
+
cached.host,
|
|
117
|
+
cached.port,
|
|
118
|
+
)
|
|
119
|
+
return cached
|
|
120
|
+
|
|
121
|
+
instances = [] if response is None else list(response.get_instances())
|
|
122
|
+
instances = [item for item in instances if item.is_healthy() and not item.is_isolated()]
|
|
123
|
+
if cached is not None:
|
|
124
|
+
instances.sort(
|
|
125
|
+
key=lambda item: (
|
|
126
|
+
item.get_host(),
|
|
127
|
+
item.get_port(),
|
|
128
|
+
)
|
|
129
|
+
!= (cached.host, cached.port)
|
|
130
|
+
)
|
|
131
|
+
for instance in instances:
|
|
132
|
+
metadata = instance.get_metadata()
|
|
133
|
+
registered_graph = metadata.get(META_GRAPH_ID, "").strip()
|
|
134
|
+
if registered_graph and registered_graph != graph_id:
|
|
135
|
+
continue
|
|
136
|
+
protocol = metadata.get(META_PROTOCOL, "").strip() or instance.get_protocol().strip()
|
|
137
|
+
path = metadata.get(META_PATH, "").strip() or "/"
|
|
138
|
+
if protocol not in ("http", "https") or not path.startswith("/"):
|
|
139
|
+
self.logger.warning_throttled(
|
|
140
|
+
("invalid-endpoint", service, instance.get_host(), instance.get_port()),
|
|
141
|
+
"Skipping invalid LangGraph endpoint metadata for %s at %s:%s",
|
|
142
|
+
service,
|
|
143
|
+
instance.get_host(),
|
|
144
|
+
instance.get_port(),
|
|
145
|
+
)
|
|
146
|
+
continue
|
|
147
|
+
endpoint = LangGraphEndpoint(
|
|
148
|
+
namespace=self.namespace,
|
|
149
|
+
service=service,
|
|
150
|
+
graph_id=graph_id,
|
|
151
|
+
host=instance.get_host(),
|
|
152
|
+
port=instance.get_port(),
|
|
153
|
+
protocol=protocol,
|
|
154
|
+
path=path,
|
|
155
|
+
)
|
|
156
|
+
parsed = urlsplit(endpoint.url)
|
|
157
|
+
if not parsed.hostname or parsed.port is None:
|
|
158
|
+
continue
|
|
159
|
+
if cached is not None and (cached.host, cached.port) != (
|
|
160
|
+
endpoint.host,
|
|
161
|
+
endpoint.port,
|
|
162
|
+
):
|
|
163
|
+
self.logger.info(
|
|
164
|
+
"Switched LangGraph service %s/%s from %s:%s to %s:%s",
|
|
165
|
+
self.namespace,
|
|
166
|
+
service,
|
|
167
|
+
cached.host,
|
|
168
|
+
cached.port,
|
|
169
|
+
endpoint.host,
|
|
170
|
+
endpoint.port,
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
self.logger.debug(
|
|
174
|
+
"Selected LangGraph endpoint: service=%s graph=%s host=%s port=%s",
|
|
175
|
+
service,
|
|
176
|
+
graph_id,
|
|
177
|
+
endpoint.host,
|
|
178
|
+
endpoint.port,
|
|
179
|
+
)
|
|
180
|
+
self._cache[key] = endpoint
|
|
181
|
+
return endpoint
|
|
182
|
+
raise LangGraphEndpointNotFoundError(
|
|
183
|
+
f"No healthy LangGraph endpoint for {self.namespace}/{service} "
|
|
184
|
+
f"and graph {graph_id!r}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def async_resolve(self, graph_id: str, service: str = "") -> LangGraphEndpoint:
|
|
188
|
+
return await await_completion(asyncio.to_thread(self.resolve, graph_id, service))
|
|
189
|
+
|
|
190
|
+
def selected_endpoint(self, graph_id: str, service: str = "") -> LangGraphEndpoint | None:
|
|
191
|
+
with self._lock:
|
|
192
|
+
return self._cache.get(self._endpoint_key(graph_id, service))
|
|
193
|
+
|
|
194
|
+
def invalidate(self, graph_id: str, service: str = ""):
|
|
195
|
+
with self._lock:
|
|
196
|
+
self._cache.pop(self._endpoint_key(graph_id, service), None)
|
|
197
|
+
|
|
198
|
+
def invalidate_all(self):
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._cache.clear()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def consumer(self):
|
|
204
|
+
return self._consumer
|
|
205
|
+
|
|
206
|
+
def close(self):
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._closed = True
|
|
209
|
+
self._cache.clear()
|
|
210
|
+
|
|
211
|
+
async def async_close(self):
|
|
212
|
+
await await_completion(asyncio.to_thread(self.close))
|
|
213
|
+
|
|
214
|
+
def __enter__(self):
|
|
215
|
+
self._check_open()
|
|
216
|
+
return self
|
|
217
|
+
|
|
218
|
+
def __exit__(self, *_):
|
|
219
|
+
self.close()
|
|
220
|
+
|
|
221
|
+
async def __aenter__(self):
|
|
222
|
+
self._check_open()
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
async def __aexit__(self, *_):
|
|
226
|
+
await self.async_close()
|