pretensor 0.1.0__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.
- pretensor/__init__.py +50 -0
- pretensor/benchmark/__init__.py +54 -0
- pretensor/benchmark/cli.py +294 -0
- pretensor/benchmark/fixtures.py +84 -0
- pretensor/benchmark/l1/__init__.py +23 -0
- pretensor/benchmark/l1/metrics.py +141 -0
- pretensor/benchmark/l1/pipeline.py +188 -0
- pretensor/benchmark/l1/runner.py +245 -0
- pretensor/benchmark/l2/__init__.py +27 -0
- pretensor/benchmark/l2/gold.py +236 -0
- pretensor/benchmark/l2/metrics.py +146 -0
- pretensor/benchmark/l2/pipeline.py +124 -0
- pretensor/benchmark/l2/runner.py +530 -0
- pretensor/benchmark/l3/__init__.py +73 -0
- pretensor/benchmark/l3/agent.py +316 -0
- pretensor/benchmark/l3/db.py +188 -0
- pretensor/benchmark/l3/gold.py +85 -0
- pretensor/benchmark/l3/llm_client.py +395 -0
- pretensor/benchmark/l3/mcp_client.py +357 -0
- pretensor/benchmark/l3/pretensor_runner.py +456 -0
- pretensor/benchmark/l3/prompt.py +132 -0
- pretensor/benchmark/l3/runner.py +358 -0
- pretensor/benchmark/l3/sql_equivalence.py +176 -0
- pretensor/benchmark/release_gate.py +448 -0
- pretensor/benchmark/results.py +298 -0
- pretensor/benchmark/runner.py +109 -0
- pretensor/cli/__init__.py +1 -0
- pretensor/cli/commands/_source_runner.py +147 -0
- pretensor/cli/commands/analyze.py +201 -0
- pretensor/cli/commands/connections/__init__.py +7 -0
- pretensor/cli/commands/connections/add_remove.py +126 -0
- pretensor/cli/commands/connections/register.py +12 -0
- pretensor/cli/commands/export.py +131 -0
- pretensor/cli/commands/index.py +559 -0
- pretensor/cli/commands/list.py +76 -0
- pretensor/cli/commands/quickstart.py +207 -0
- pretensor/cli/commands/reindex.py +646 -0
- pretensor/cli/commands/semantic.py +190 -0
- pretensor/cli/commands/serve.py +144 -0
- pretensor/cli/commands/sync_grants.py +149 -0
- pretensor/cli/commands/validate.py +176 -0
- pretensor/cli/config_file.py +442 -0
- pretensor/cli/constants.py +10 -0
- pretensor/cli/dbt_enrichment.py +96 -0
- pretensor/cli/main.py +109 -0
- pretensor/cli/paths.py +43 -0
- pretensor/cli/plugin.py +52 -0
- pretensor/config.py +226 -0
- pretensor/connectors/__init__.py +29 -0
- pretensor/connectors/base.py +165 -0
- pretensor/connectors/bigquery.py +468 -0
- pretensor/connectors/inspect.py +321 -0
- pretensor/connectors/lineage_sqlglot.py +97 -0
- pretensor/connectors/models.py +130 -0
- pretensor/connectors/mysql.py +402 -0
- pretensor/connectors/pg_array_parse.py +53 -0
- pretensor/connectors/postgres.py +938 -0
- pretensor/connectors/registry.py +93 -0
- pretensor/connectors/snapshot.py +244 -0
- pretensor/connectors/snowflake.py +908 -0
- pretensor/core/__init__.py +1 -0
- pretensor/core/builder.py +307 -0
- pretensor/core/dsn_crypto.py +51 -0
- pretensor/core/graph_schema_manager.py +246 -0
- pretensor/core/graph_store.py +1226 -0
- pretensor/core/ids.py +101 -0
- pretensor/core/portable_export.py +276 -0
- pretensor/core/query_runner.py +67 -0
- pretensor/core/registry.py +209 -0
- pretensor/core/schema.py +473 -0
- pretensor/core/secure_io.py +93 -0
- pretensor/core/store.py +469 -0
- pretensor/enrichment/__init__.py +1 -0
- pretensor/enrichment/analyze/__init__.py +0 -0
- pretensor/enrichment/analyze/classify.py +49 -0
- pretensor/enrichment/analyze/extract_python.py +196 -0
- pretensor/enrichment/analyze/parse.py +141 -0
- pretensor/enrichment/analyze/pipeline.py +195 -0
- pretensor/enrichment/analyze/summary.py +38 -0
- pretensor/enrichment/analyze/walker.py +98 -0
- pretensor/enrichment/analyze/writers.py +214 -0
- pretensor/enrichment/dbt/__init__.py +30 -0
- pretensor/enrichment/dbt/lineage.py +100 -0
- pretensor/enrichment/dbt/manifest.py +300 -0
- pretensor/enrichment/dbt/metadata.py +263 -0
- pretensor/enrichment/dbt/pipeline.py +77 -0
- pretensor/enrichment/dbt/resolution.py +101 -0
- pretensor/enrichment/dbt/signals.py +305 -0
- pretensor/entities/__init__.py +27 -0
- pretensor/entities/builder.py +63 -0
- pretensor/entities/classifier.py +383 -0
- pretensor/entities/llm_extract.py +66 -0
- pretensor/errors.py +35 -0
- pretensor/graph_models/__init__.py +17 -0
- pretensor/graph_models/base.py +11 -0
- pretensor/graph_models/consumer.py +71 -0
- pretensor/graph_models/edge.py +35 -0
- pretensor/graph_models/entity.py +21 -0
- pretensor/graph_models/node.py +79 -0
- pretensor/graph_models/relationship.py +33 -0
- pretensor/integrations/__init__.py +42 -0
- pretensor/integrations/_base.py +138 -0
- pretensor/integrations/google_adk.py +49 -0
- pretensor/integrations/langchain.py +55 -0
- pretensor/integrations/llamaindex.py +53 -0
- pretensor/intelligence/__init__.py +33 -0
- pretensor/intelligence/cluster_labeler.py +425 -0
- pretensor/intelligence/clustering.py +168 -0
- pretensor/intelligence/combining.py +32 -0
- pretensor/intelligence/discovery.py +114 -0
- pretensor/intelligence/embeddings.py +317 -0
- pretensor/intelligence/graph_export.py +200 -0
- pretensor/intelligence/heuristic.py +544 -0
- pretensor/intelligence/join_paths/__init__.py +130 -0
- pretensor/intelligence/join_paths/on_demand.py +516 -0
- pretensor/intelligence/join_paths/storage.py +70 -0
- pretensor/intelligence/llm_infer.py +78 -0
- pretensor/intelligence/llm_runtime.py +62 -0
- pretensor/intelligence/metric_templates.py +193 -0
- pretensor/intelligence/pipeline.py +364 -0
- pretensor/intelligence/role_exemplars.py +263 -0
- pretensor/intelligence/schema_classification.py +360 -0
- pretensor/intelligence/scoring.py +76 -0
- pretensor/intelligence/semantic.py +240 -0
- pretensor/intelligence/shadow_alias.py +101 -0
- pretensor/intelligence/statistical.py +50 -0
- pretensor/intelligence/steps.py +191 -0
- pretensor/intelligence/steps_embedding.py +168 -0
- pretensor/introspection/__init__.py +6 -0
- pretensor/introspection/inspector.py +5 -0
- pretensor/introspection/models/__init__.py +0 -0
- pretensor/introspection/models/base.py +5 -0
- pretensor/introspection/models/config.py +237 -0
- pretensor/introspection/models/dsn.py +550 -0
- pretensor/introspection/models/plan.py +116 -0
- pretensor/introspection/models/schema.py +10 -0
- pretensor/introspection/models/semantic.py +121 -0
- pretensor/introspection/models/validation.py +116 -0
- pretensor/introspection/snapshot.py +46 -0
- pretensor/mcp/__init__.py +16 -0
- pretensor/mcp/config_json.py +24 -0
- pretensor/mcp/payload_types.py +274 -0
- pretensor/mcp/resources/__init__.py +17 -0
- pretensor/mcp/resources/markdown.py +314 -0
- pretensor/mcp/server.py +285 -0
- pretensor/mcp/service.py +49 -0
- pretensor/mcp/service_context.py +142 -0
- pretensor/mcp/service_registry.py +294 -0
- pretensor/mcp/store_cache.py +43 -0
- pretensor/mcp/tool_registry.py +136 -0
- pretensor/mcp/tools/__init__.py +1 -0
- pretensor/mcp/tools/_rank.py +244 -0
- pretensor/mcp/tools/_timed.py +26 -0
- pretensor/mcp/tools/compile_metric.py +144 -0
- pretensor/mcp/tools/consumers.py +161 -0
- pretensor/mcp/tools/context.py +1121 -0
- pretensor/mcp/tools/cypher.py +509 -0
- pretensor/mcp/tools/detect_changes.py +254 -0
- pretensor/mcp/tools/impact.py +271 -0
- pretensor/mcp/tools/list.py +131 -0
- pretensor/mcp/tools/schema.py +170 -0
- pretensor/mcp/tools/search.py +316 -0
- pretensor/mcp/tools/semantic_search.py +282 -0
- pretensor/mcp/tools/traverse.py +1027 -0
- pretensor/mcp/tools/validate_sql.py +150 -0
- pretensor/observability.py +203 -0
- pretensor/py.typed +0 -0
- pretensor/quickstart/README.md +29 -0
- pretensor/quickstart/__init__.py +6 -0
- pretensor/quickstart/docker-compose.yml +18 -0
- pretensor/quickstart/pagila_data.sql +63 -0
- pretensor/quickstart/pagila_ddl.sql +92 -0
- pretensor/search/__init__.py +6 -0
- pretensor/search/base.py +80 -0
- pretensor/search/index.py +435 -0
- pretensor/semantic/__init__.py +24 -0
- pretensor/semantic/base.py +123 -0
- pretensor/semantic/compiler.py +487 -0
- pretensor/semantic/yaml_layer.py +180 -0
- pretensor/skills/__init__.py +5 -0
- pretensor/skills/generator.py +235 -0
- pretensor/staleness/__init__.py +15 -0
- pretensor/staleness/graph_patcher.py +355 -0
- pretensor/staleness/impact_analyzer.py +162 -0
- pretensor/staleness/snapshot_store.py +38 -0
- pretensor/validation/__init__.py +9 -0
- pretensor/validation/query_validator.py +436 -0
- pretensor/visibility/__init__.py +23 -0
- pretensor/visibility/config.py +126 -0
- pretensor/visibility/filter.py +143 -0
- pretensor/visibility/kuzu_helpers.py +32 -0
- pretensor/visibility/runtime.py +36 -0
- pretensor/visibility/sync_grants.py +188 -0
- pretensor-0.1.0.dist-info/METADATA +251 -0
- pretensor-0.1.0.dist-info/RECORD +198 -0
- pretensor-0.1.0.dist-info/WHEEL +4 -0
- pretensor-0.1.0.dist-info/entry_points.txt +2 -0
- pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Sync wrapper around the MCP stdio client for the L3 pretensor runner.
|
|
2
|
+
|
|
3
|
+
The MCP Python SDK (``mcp``) is async-only — :class:`mcp.ClientSession`
|
|
4
|
+
exposes coroutines for ``initialize``, ``list_tools``, and ``call_tool``,
|
|
5
|
+
and the stdio transport is an async context manager. The L3 pretensor
|
|
6
|
+
runner is otherwise sync (mirrors the baseline runner shape and the
|
|
7
|
+
``BenchmarkResult`` writer), so we host one event loop in a daemon
|
|
8
|
+
worker thread and forward every operation through it.
|
|
9
|
+
|
|
10
|
+
Subprocess ownership lives in the SDK's :func:`stdio_client` context
|
|
11
|
+
manager: entering the context spawns ``pretensor serve …`` and pipes
|
|
12
|
+
its stdio; exiting sends EOF, waits for shutdown, and reaps the
|
|
13
|
+
subprocess. All paths through :class:`StdioMcpClient` go through
|
|
14
|
+
``__exit__`` even on test failure, so AC #6 (no orphan processes) is
|
|
15
|
+
held by the SDK rather than by hand-rolled signal handling.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import threading
|
|
23
|
+
import warnings
|
|
24
|
+
from contextlib import AsyncExitStack
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from types import TracebackType
|
|
28
|
+
from typing import Any, Protocol
|
|
29
|
+
|
|
30
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
31
|
+
from mcp.types import CallToolResult
|
|
32
|
+
from mcp.types import Tool as McpTool
|
|
33
|
+
|
|
34
|
+
from mcp import ClientSession
|
|
35
|
+
from pretensor.benchmark.l3.agent import AgentTool
|
|
36
|
+
from pretensor.errors import PretensorError
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"McpClient",
|
|
40
|
+
"McpClientError",
|
|
41
|
+
"McpToolResult",
|
|
42
|
+
"StdioMcpClient",
|
|
43
|
+
"tool_result_to_text",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_DEFAULT_STARTUP_TIMEOUT_S = 30.0
|
|
48
|
+
"""How long to wait for ``pretensor serve`` to respond to ``initialize``.
|
|
49
|
+
|
|
50
|
+
The serve handshake is fast on cold-cache machines; 30s is a generous
|
|
51
|
+
upper bound. A longer-than-30s startup almost always means a broken
|
|
52
|
+
graph dir or a version mismatch — surface those as clear errors rather
|
|
53
|
+
than waiting forever.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_DEFAULT_TEARDOWN_TIMEOUT_S = 30.0
|
|
57
|
+
"""How long to wait for the async session close + worker-thread join.
|
|
58
|
+
|
|
59
|
+
Distinct from :data:`_DEFAULT_STARTUP_TIMEOUT_S` so the two intents
|
|
60
|
+
read clearly even though the numeric values match today. ``stdio_client``
|
|
61
|
+
sends EOF and waits for the subprocess to exit; if the child hangs,
|
|
62
|
+
this cap keeps shutdown bounded.
|
|
63
|
+
|
|
64
|
+
Worst-case shutdown wall time is roughly **2x** this value:
|
|
65
|
+
:meth:`StdioMcpClient.__exit__` waits up to ``_DEFAULT_TEARDOWN_TIMEOUT_S``
|
|
66
|
+
for ``_async_close`` to finish, then ``_tear_down_loop`` joins the
|
|
67
|
+
worker thread for another ``_DEFAULT_TEARDOWN_TIMEOUT_S``. Both budgets
|
|
68
|
+
only get fully consumed when the subprocess refuses to exit AND the
|
|
69
|
+
loop thread itself is wedged — a real fault, not a normal slow run.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_DEFAULT_OUTER_LOOP_TIMEOUT_S = 90.0
|
|
73
|
+
"""Outer guard on the ``__enter__`` wait for the worker loop's response.
|
|
74
|
+
|
|
75
|
+
``asyncio.wait_for`` inside the loop already enforces
|
|
76
|
+
:data:`_DEFAULT_STARTUP_TIMEOUT_S`, so a healthy run never approaches
|
|
77
|
+
this bound. The guard exists to bound the main-thread wait when the
|
|
78
|
+
worker loop thread itself crashes (e.g. an unhandled exception in
|
|
79
|
+
``_run_loop``) — without it, ``concurrent.futures.Future.result()``
|
|
80
|
+
would block the caller indefinitely. 90s is generous: it's long
|
|
81
|
+
enough to ride out the inner timeout plus its cleanup, and short
|
|
82
|
+
enough that an operator notices when it fires.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
_DEFAULT_CALL_TIMEOUT_S = 60.0
|
|
86
|
+
"""Per-tool-call timeout submitted to ``ClientSession.call_tool``.
|
|
87
|
+
|
|
88
|
+
Some tools (``cypher`` over a 100k-node graph, ``traverse`` with high
|
|
89
|
+
``k``) take real time; the L3 runner already caps the LLM budget per
|
|
90
|
+
question elsewhere. 60s keeps any one stuck call from blocking the
|
|
91
|
+
whole question without short-circuiting healthy slow tools.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class McpClientError(PretensorError, RuntimeError):
|
|
96
|
+
"""Raised on subprocess startup, shutdown, or call-level failures."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class McpToolResult:
|
|
101
|
+
"""One MCP tool invocation's output, normalised for the agent loop.
|
|
102
|
+
|
|
103
|
+
``content`` is what we hand the LLM (a JSON-encoded snapshot of the
|
|
104
|
+
structured result, or the concatenated text blocks if the tool
|
|
105
|
+
returned plain text). ``is_error`` flags MCP-level failures so the
|
|
106
|
+
runner can record a tool-call trace entry even when the call faulted.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
content: str
|
|
110
|
+
is_error: bool
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class McpClient(Protocol):
|
|
114
|
+
"""Sync MCP surface used by the L3 pretensor runner.
|
|
115
|
+
|
|
116
|
+
Production callers use :class:`StdioMcpClient` (spawns
|
|
117
|
+
``pretensor serve`` and speaks stdio). Tests inject a fake.
|
|
118
|
+
Both ``list_tools`` and ``call_tool`` are sync because the runner
|
|
119
|
+
drives one tool at a time per question; concurrency would not
|
|
120
|
+
produce a faster benchmark.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def list_tools(self) -> list[AgentTool]: ...
|
|
124
|
+
|
|
125
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> McpToolResult: ...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class StdioMcpClient:
|
|
129
|
+
"""Spawn-and-drive a ``pretensor serve`` MCP subprocess over stdio.
|
|
130
|
+
|
|
131
|
+
Use as a context manager (``with StdioMcpClient(...) as client``):
|
|
132
|
+
enter starts the subprocess, runs the MCP handshake, and stages the
|
|
133
|
+
background event loop; exit tears down the session, signals EOF on
|
|
134
|
+
stdio, and joins the worker thread. Cleanup is in a try/finally
|
|
135
|
+
inside the SDK so a tool-call exception does not leak the subprocess.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
graph_dir: Path,
|
|
142
|
+
command: str = "pretensor",
|
|
143
|
+
extra_args: list[str] | None = None,
|
|
144
|
+
env: dict[str, str] | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
self._params = StdioServerParameters(
|
|
147
|
+
command=command,
|
|
148
|
+
args=["serve", "--graph-dir", str(graph_dir), "--no-print-config"]
|
|
149
|
+
+ list(extra_args or []),
|
|
150
|
+
env=env,
|
|
151
|
+
)
|
|
152
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
153
|
+
self._loop_thread: threading.Thread | None = None
|
|
154
|
+
self._session: ClientSession | None = None
|
|
155
|
+
self._exit_stack: AsyncExitStack | None = None
|
|
156
|
+
self._cached_tools: list[AgentTool] | None = None
|
|
157
|
+
|
|
158
|
+
def __enter__(self) -> StdioMcpClient:
|
|
159
|
+
self._loop = asyncio.new_event_loop()
|
|
160
|
+
|
|
161
|
+
def _run_loop() -> None:
|
|
162
|
+
assert self._loop is not None
|
|
163
|
+
asyncio.set_event_loop(self._loop)
|
|
164
|
+
self._loop.run_forever()
|
|
165
|
+
|
|
166
|
+
self._loop_thread = threading.Thread(
|
|
167
|
+
target=_run_loop, name="l3-mcp-stdio-loop", daemon=True
|
|
168
|
+
)
|
|
169
|
+
self._loop_thread.start()
|
|
170
|
+
# Push the startup timeout INSIDE the loop via ``asyncio.wait_for``.
|
|
171
|
+
# If the inner coroutine is suspended (e.g. ``stdio_client``
|
|
172
|
+
# spawned the child but ``initialize`` hasn't returned yet),
|
|
173
|
+
# ``wait_for`` cancels it cleanly: the task receives
|
|
174
|
+
# ``CancelledError`` at its next ``await``, ``_async_open``'s
|
|
175
|
+
# ``except BaseException: await stack.aclose()`` branch runs to
|
|
176
|
+
# completion (which calls ``stdio_client.__aexit__`` and reaps
|
|
177
|
+
# the subprocess), and only THEN is ``TimeoutError`` re-raised.
|
|
178
|
+
# An outer ``concurrent.futures`` timeout would mark the
|
|
179
|
+
# destination future cancelled and return immediately, leaving
|
|
180
|
+
# the source task to run its cleanup after the loop had already
|
|
181
|
+
# been torn down — orphaning the subprocess.
|
|
182
|
+
try:
|
|
183
|
+
asyncio.run_coroutine_threadsafe(
|
|
184
|
+
_wait_for_open(self, _DEFAULT_STARTUP_TIMEOUT_S), self._loop
|
|
185
|
+
).result(timeout=_DEFAULT_OUTER_LOOP_TIMEOUT_S)
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
self._tear_down_loop()
|
|
188
|
+
raise McpClientError(
|
|
189
|
+
f"Failed to start MCP subprocess `{self._params.command} "
|
|
190
|
+
f"{' '.join(self._params.args)}`: {exc}"
|
|
191
|
+
) from exc
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def __exit__(
|
|
195
|
+
self,
|
|
196
|
+
exc_type: type[BaseException] | None,
|
|
197
|
+
exc: BaseException | None,
|
|
198
|
+
tb: TracebackType | None,
|
|
199
|
+
) -> None:
|
|
200
|
+
if self._loop is None:
|
|
201
|
+
return
|
|
202
|
+
try:
|
|
203
|
+
asyncio.run_coroutine_threadsafe(self._async_close(), self._loop).result(
|
|
204
|
+
timeout=_DEFAULT_TEARDOWN_TIMEOUT_S
|
|
205
|
+
)
|
|
206
|
+
except Exception as cleanup_exc:
|
|
207
|
+
# ``AsyncExitStack.aclose()`` propagates the FIRST inner
|
|
208
|
+
# exception but still attempts to close every registered
|
|
209
|
+
# context — so ``stdio_client.__aexit__`` will normally
|
|
210
|
+
# have run and the subprocess will be reaped even when
|
|
211
|
+
# this branch fires. The exception we trap here is that
|
|
212
|
+
# propagated inner exception, OR a TimeoutError if the
|
|
213
|
+
# whole close took longer than the teardown budget.
|
|
214
|
+
#
|
|
215
|
+
# We don't re-raise: an exception in the original ``with``
|
|
216
|
+
# body would otherwise be silently shadowed by a cleanup
|
|
217
|
+
# error. We DO surface it as a warning so the run is not
|
|
218
|
+
# silent — the runner is non-interactive and a subprocess
|
|
219
|
+
# leak (e.g. a child that ignored EOF) would otherwise hide
|
|
220
|
+
# here. Operators see the warning on stderr and can
|
|
221
|
+
# investigate before the next run.
|
|
222
|
+
warnings.warn(
|
|
223
|
+
f"StdioMcpClient teardown error (subprocess may be "
|
|
224
|
+
f"orphaned if it did not exit on EOF): {cleanup_exc!r}",
|
|
225
|
+
ResourceWarning,
|
|
226
|
+
stacklevel=2,
|
|
227
|
+
)
|
|
228
|
+
finally:
|
|
229
|
+
self._tear_down_loop()
|
|
230
|
+
|
|
231
|
+
async def _async_open(self) -> None:
|
|
232
|
+
stack = AsyncExitStack()
|
|
233
|
+
try:
|
|
234
|
+
read, write = await stack.enter_async_context(stdio_client(self._params))
|
|
235
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
236
|
+
await session.initialize()
|
|
237
|
+
except BaseException:
|
|
238
|
+
await stack.aclose()
|
|
239
|
+
raise
|
|
240
|
+
self._session = session
|
|
241
|
+
self._exit_stack = stack
|
|
242
|
+
|
|
243
|
+
async def _async_close(self) -> None:
|
|
244
|
+
if self._exit_stack is not None:
|
|
245
|
+
await self._exit_stack.aclose()
|
|
246
|
+
self._exit_stack = None
|
|
247
|
+
self._session = None
|
|
248
|
+
|
|
249
|
+
def _tear_down_loop(self) -> None:
|
|
250
|
+
if self._loop is not None and self._loop.is_running():
|
|
251
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
252
|
+
if self._loop_thread is not None:
|
|
253
|
+
self._loop_thread.join(timeout=_DEFAULT_TEARDOWN_TIMEOUT_S)
|
|
254
|
+
if self._loop is not None:
|
|
255
|
+
self._loop.close()
|
|
256
|
+
self._loop = None
|
|
257
|
+
self._loop_thread = None
|
|
258
|
+
|
|
259
|
+
def list_tools(self) -> list[AgentTool]:
|
|
260
|
+
"""Return the full tool catalogue advertised by ``pretensor serve``.
|
|
261
|
+
|
|
262
|
+
The first call hits the server; subsequent calls return a
|
|
263
|
+
cached list — the OSS server does not register tools after
|
|
264
|
+
startup, so caching is safe and avoids repeating the round-trip
|
|
265
|
+
once per question.
|
|
266
|
+
"""
|
|
267
|
+
if self._cached_tools is not None:
|
|
268
|
+
return list(self._cached_tools)
|
|
269
|
+
if self._session is None or self._loop is None:
|
|
270
|
+
raise McpClientError(
|
|
271
|
+
"StdioMcpClient.list_tools called outside the with-block "
|
|
272
|
+
"(or after an open() failure)."
|
|
273
|
+
)
|
|
274
|
+
result = asyncio.run_coroutine_threadsafe(
|
|
275
|
+
self._session.list_tools(), self._loop
|
|
276
|
+
).result(timeout=_DEFAULT_CALL_TIMEOUT_S)
|
|
277
|
+
tools = [_to_agent_tool(t) for t in result.tools]
|
|
278
|
+
self._cached_tools = tools
|
|
279
|
+
return list(tools)
|
|
280
|
+
|
|
281
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> McpToolResult:
|
|
282
|
+
"""Invoke one MCP tool synchronously; surface server errors as ``is_error``."""
|
|
283
|
+
if self._session is None or self._loop is None:
|
|
284
|
+
raise McpClientError(
|
|
285
|
+
"StdioMcpClient.call_tool called outside the with-block "
|
|
286
|
+
"(or after an open() failure)."
|
|
287
|
+
)
|
|
288
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
289
|
+
self._session.call_tool(name, arguments=arguments), self._loop
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
result = future.result(timeout=_DEFAULT_CALL_TIMEOUT_S)
|
|
293
|
+
except Exception as exc:
|
|
294
|
+
# Build the error payload via ``json.dumps`` so an exception
|
|
295
|
+
# message containing quotes, backslashes, or newlines escapes
|
|
296
|
+
# cleanly — a raw f-string would emit invalid JSON the LLM
|
|
297
|
+
# tool-result decoder then misparses.
|
|
298
|
+
return McpToolResult(
|
|
299
|
+
content=json.dumps({"error": f"MCP transport failure: {exc}"}),
|
|
300
|
+
is_error=True,
|
|
301
|
+
)
|
|
302
|
+
return McpToolResult(
|
|
303
|
+
content=tool_result_to_text(result),
|
|
304
|
+
is_error=bool(result.isError),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _wait_for_open(client: StdioMcpClient, timeout_s: float) -> None:
|
|
309
|
+
"""Run ``client._async_open`` under an in-loop ``asyncio.wait_for``.
|
|
310
|
+
|
|
311
|
+
Module-level (not a method) so the patching pattern used by tests
|
|
312
|
+
— ``monkeypatch.setattr(StdioMcpClient, "_async_open", fake)`` — is
|
|
313
|
+
picked up via the bound method lookup at call time. Lives next to
|
|
314
|
+
the class because it is intimate with its lifecycle.
|
|
315
|
+
"""
|
|
316
|
+
await asyncio.wait_for(client._async_open(), timeout=timeout_s)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _to_agent_tool(tool: McpTool) -> AgentTool:
|
|
320
|
+
"""Down-cast :class:`mcp.types.Tool` into the L3-local representation.
|
|
321
|
+
|
|
322
|
+
MCP's ``Tool.description`` is optional; substituting an empty string
|
|
323
|
+
keeps the LLM payload well-formed without hiding a missing description
|
|
324
|
+
behind a placeholder that might get echoed back.
|
|
325
|
+
"""
|
|
326
|
+
return AgentTool(
|
|
327
|
+
name=tool.name,
|
|
328
|
+
description=tool.description or "",
|
|
329
|
+
input_schema=dict(tool.inputSchema or {}),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def tool_result_to_text(result: CallToolResult) -> str:
|
|
334
|
+
"""Flatten an MCP :class:`CallToolResult` into a single string for the LLM.
|
|
335
|
+
|
|
336
|
+
Preference order:
|
|
337
|
+
1. ``structuredContent`` — JSON-encode it. The pretensor server
|
|
338
|
+
returns dict payloads through this channel; JSON is the cheapest
|
|
339
|
+
lossless rendering for the LLM.
|
|
340
|
+
2. Concatenated ``text`` content blocks. Other content types
|
|
341
|
+
(image, audio, resource link) are skipped — the OSS server does
|
|
342
|
+
not produce them today.
|
|
343
|
+
|
|
344
|
+
A result with neither structured content nor text blocks is rendered
|
|
345
|
+
as an empty JSON object so the LLM still sees a well-formed tool
|
|
346
|
+
response.
|
|
347
|
+
"""
|
|
348
|
+
if result.structuredContent is not None:
|
|
349
|
+
return json.dumps(result.structuredContent, sort_keys=True, default=str)
|
|
350
|
+
parts: list[str] = []
|
|
351
|
+
for block in result.content or []:
|
|
352
|
+
text = getattr(block, "text", None)
|
|
353
|
+
if isinstance(text, str):
|
|
354
|
+
parts.append(text)
|
|
355
|
+
if parts:
|
|
356
|
+
return "".join(parts)
|
|
357
|
+
return "{}"
|