turnloop 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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
turnloop/hooks/runner.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Hooks: user-configured commands that run at defined points.
|
|
2
|
+
|
|
3
|
+
The protocol is JSON on stdin, and three ways to respond:
|
|
4
|
+
|
|
5
|
+
* exit 0 — proceed. stdout is ignored unless it is JSON.
|
|
6
|
+
* exit 2 — block. stderr becomes the reason the model is told.
|
|
7
|
+
* exit 0 with JSON stdout — structured control:
|
|
8
|
+
{"decision": "block"|"approve", "reason": ..., "additionalContext": ...}
|
|
9
|
+
|
|
10
|
+
Two rules that matter. Hooks run through the same shell resolution as the Bash
|
|
11
|
+
tool, so a hook written as a POSIX one-liner does not hit the WSL stub on Windows
|
|
12
|
+
either. And a hook that crashes, hangs or writes garbage is logged and ignored: a
|
|
13
|
+
broken hook must not be able to take the session down with it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from fnmatch import fnmatchcase
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import anyio
|
|
26
|
+
|
|
27
|
+
from turnloop.config import HookSpec, Settings
|
|
28
|
+
from turnloop.tools.shell import child_env, decode_output, kill_tree, resolve_shell, spawn_kwargs
|
|
29
|
+
|
|
30
|
+
HOOK_EVENTS = (
|
|
31
|
+
"SessionStart",
|
|
32
|
+
"UserPromptSubmit",
|
|
33
|
+
"PreToolUse",
|
|
34
|
+
"PostToolUse",
|
|
35
|
+
"Stop",
|
|
36
|
+
"SubagentStop",
|
|
37
|
+
"PreCompact",
|
|
38
|
+
"SessionEnd",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class HookOutcome:
|
|
44
|
+
blocked: bool = False
|
|
45
|
+
reason: str = ""
|
|
46
|
+
additional_context: str = ""
|
|
47
|
+
outputs: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HookRunner:
|
|
51
|
+
def __init__(self, settings: Settings, cwd: Path, session_id: str = ""):
|
|
52
|
+
self.settings = settings
|
|
53
|
+
self.cwd = cwd
|
|
54
|
+
self.session_id = session_id
|
|
55
|
+
self.log: list[dict] = []
|
|
56
|
+
|
|
57
|
+
# --- event entry points ------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async def run_pre_tool(self, tool_name: str, args, ctx) -> HookOutcome | None:
|
|
60
|
+
payload = {
|
|
61
|
+
"event": "PreToolUse",
|
|
62
|
+
"tool_name": tool_name,
|
|
63
|
+
"tool_input": _dump(args),
|
|
64
|
+
"cwd": str(self.cwd),
|
|
65
|
+
"session_id": self.session_id,
|
|
66
|
+
}
|
|
67
|
+
return await self._run_event("PreToolUse", payload, matcher_value=tool_name)
|
|
68
|
+
|
|
69
|
+
async def run_post_tool(self, tool_name: str, args, output, ctx) -> HookOutcome | None:
|
|
70
|
+
payload = {
|
|
71
|
+
"event": "PostToolUse",
|
|
72
|
+
"tool_name": tool_name,
|
|
73
|
+
"tool_input": _dump(args),
|
|
74
|
+
"tool_output": (output.content or "")[:8_000],
|
|
75
|
+
"is_error": output.is_error,
|
|
76
|
+
"cwd": str(self.cwd),
|
|
77
|
+
"session_id": self.session_id,
|
|
78
|
+
}
|
|
79
|
+
return await self._run_event("PostToolUse", payload, matcher_value=tool_name)
|
|
80
|
+
|
|
81
|
+
async def run_user_prompt(self, prompt: str, session) -> HookOutcome | None:
|
|
82
|
+
payload = {
|
|
83
|
+
"event": "UserPromptSubmit",
|
|
84
|
+
"prompt": prompt,
|
|
85
|
+
"cwd": str(self.cwd),
|
|
86
|
+
"session_id": session.session_id,
|
|
87
|
+
}
|
|
88
|
+
return await self._run_event("UserPromptSubmit", payload)
|
|
89
|
+
|
|
90
|
+
async def run_stop(self, session, stop_reason: str) -> HookOutcome | None:
|
|
91
|
+
payload = {
|
|
92
|
+
"event": "Stop",
|
|
93
|
+
"stop_reason": stop_reason,
|
|
94
|
+
"turn": session.turn,
|
|
95
|
+
"session_id": session.session_id,
|
|
96
|
+
}
|
|
97
|
+
return await self._run_event("Stop", payload)
|
|
98
|
+
|
|
99
|
+
async def run_session_event(self, event: str, extra: dict | None = None) -> HookOutcome | None:
|
|
100
|
+
payload = {"event": event, "cwd": str(self.cwd), "session_id": self.session_id}
|
|
101
|
+
payload.update(extra or {})
|
|
102
|
+
return await self._run_event(event, payload)
|
|
103
|
+
|
|
104
|
+
# --- execution ---------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
async def _run_event(self, event: str, payload: dict,
|
|
107
|
+
matcher_value: str | None = None) -> HookOutcome | None:
|
|
108
|
+
matchers = self.settings.hooks.get(event) or []
|
|
109
|
+
if not matchers:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
outcome = HookOutcome()
|
|
113
|
+
for matcher in matchers:
|
|
114
|
+
if matcher_value is not None and not _matches(matcher.matcher, matcher_value):
|
|
115
|
+
continue
|
|
116
|
+
for spec in matcher.hooks:
|
|
117
|
+
result = await self._run_one(spec, payload, event)
|
|
118
|
+
if result is None:
|
|
119
|
+
continue
|
|
120
|
+
if result.blocked:
|
|
121
|
+
return result
|
|
122
|
+
if result.additional_context:
|
|
123
|
+
outcome.additional_context += (
|
|
124
|
+
("\n" if outcome.additional_context else "") + result.additional_context
|
|
125
|
+
)
|
|
126
|
+
outcome.outputs.extend(result.outputs)
|
|
127
|
+
return outcome
|
|
128
|
+
|
|
129
|
+
async def _run_one(self, spec: HookSpec, payload: dict, event: str) -> HookOutcome | None:
|
|
130
|
+
try:
|
|
131
|
+
shell = resolve_shell(self.settings.bash.shell)
|
|
132
|
+
except Exception as exc: # noqa: BLE001
|
|
133
|
+
self._record(event, spec.command, error=f"no shell: {exc}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
stdin_data = json.dumps(payload, default=str).encode("utf-8")
|
|
137
|
+
argv = shell.argv(spec.command)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
process = await anyio.open_process(
|
|
141
|
+
argv,
|
|
142
|
+
cwd=str(self.cwd),
|
|
143
|
+
env=child_env({"TURNLOOP_HOOK_EVENT": event}),
|
|
144
|
+
stdin=subprocess.PIPE,
|
|
145
|
+
stdout=subprocess.PIPE,
|
|
146
|
+
stderr=subprocess.PIPE,
|
|
147
|
+
**spawn_kwargs(),
|
|
148
|
+
)
|
|
149
|
+
except OSError as exc:
|
|
150
|
+
self._record(event, spec.command, error=str(exc))
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
stdout = b""
|
|
154
|
+
stderr = b""
|
|
155
|
+
code: int | None = None
|
|
156
|
+
try:
|
|
157
|
+
with anyio.fail_after(spec.timeout):
|
|
158
|
+
if process.stdin is not None:
|
|
159
|
+
await process.stdin.send(stdin_data)
|
|
160
|
+
await process.stdin.aclose()
|
|
161
|
+
if process.stdout is not None:
|
|
162
|
+
async for chunk in process.stdout:
|
|
163
|
+
stdout += chunk
|
|
164
|
+
if process.stderr is not None:
|
|
165
|
+
async for chunk in process.stderr:
|
|
166
|
+
stderr += chunk
|
|
167
|
+
code = await process.wait()
|
|
168
|
+
except TimeoutError:
|
|
169
|
+
kill_tree(process.pid)
|
|
170
|
+
self._record(event, spec.command, error=f"timed out after {spec.timeout}s")
|
|
171
|
+
return None
|
|
172
|
+
except Exception as exc: # noqa: BLE001 - a broken hook is never fatal
|
|
173
|
+
self._record(event, spec.command, error=str(exc))
|
|
174
|
+
return None
|
|
175
|
+
finally:
|
|
176
|
+
with anyio.CancelScope(shield=True):
|
|
177
|
+
try:
|
|
178
|
+
await process.aclose()
|
|
179
|
+
except Exception: # noqa: BLE001
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
out_text = decode_output(stdout).strip()
|
|
183
|
+
err_text = decode_output(stderr).strip()
|
|
184
|
+
self._record(event, spec.command, code=code, stdout=out_text[:2_000], stderr=err_text[:2_000])
|
|
185
|
+
|
|
186
|
+
# Exit code 2 is the documented "block" signal.
|
|
187
|
+
if code == 2:
|
|
188
|
+
return HookOutcome(blocked=True, reason=err_text or "blocked by hook")
|
|
189
|
+
|
|
190
|
+
if out_text.startswith("{"):
|
|
191
|
+
try:
|
|
192
|
+
data = json.loads(out_text)
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
return HookOutcome(outputs=[out_text])
|
|
195
|
+
decision = str(data.get("decision", "")).lower()
|
|
196
|
+
return HookOutcome(
|
|
197
|
+
blocked=decision == "block",
|
|
198
|
+
reason=str(data.get("reason", "")),
|
|
199
|
+
additional_context=str(data.get("additionalContext", "")),
|
|
200
|
+
outputs=[out_text],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return HookOutcome(outputs=[out_text] if out_text else [])
|
|
204
|
+
|
|
205
|
+
def _record(self, event: str, command: str, **rest: Any) -> None:
|
|
206
|
+
entry = {"event": event, "command": command, **rest}
|
|
207
|
+
self.log.append(entry)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _matches(pattern: str, value: str) -> bool:
|
|
211
|
+
if pattern in ("", "*"):
|
|
212
|
+
return True
|
|
213
|
+
# A comma-separated list is more natural than several matcher blocks.
|
|
214
|
+
return any(fnmatchcase(value, part.strip()) for part in pattern.split(",") if part.strip())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _dump(args) -> dict:
|
|
218
|
+
try:
|
|
219
|
+
return json.loads(args.model_dump_json())
|
|
220
|
+
except Exception: # noqa: BLE001
|
|
221
|
+
return {"repr": str(args)}
|
turnloop/mcp/__init__.py
ADDED
|
File without changes
|
turnloop/mcp/adapter.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Wrapping MCP tools as native Tool subclasses.
|
|
2
|
+
|
|
3
|
+
The `Args` model is generated at runtime from the server's JSON Schema, so an MCP
|
|
4
|
+
tool validates exactly like a builtin and produces the same readable error results.
|
|
5
|
+
|
|
6
|
+
When a schema cannot be turned into a model — and third-party schemas are often
|
|
7
|
+
wrong — the fallback is a permissive passthrough rather than a crash. A badly
|
|
8
|
+
written server should cost its own tool's validation, not the whole startup.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, create_model
|
|
16
|
+
|
|
17
|
+
from turnloop.config import Verbosity
|
|
18
|
+
from turnloop.mcp.client import MCPClient, MCPTool
|
|
19
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
20
|
+
|
|
21
|
+
_JSON_TO_PYTHON = {
|
|
22
|
+
"string": str,
|
|
23
|
+
"integer": int,
|
|
24
|
+
"number": float,
|
|
25
|
+
"boolean": bool,
|
|
26
|
+
"object": dict,
|
|
27
|
+
"array": list,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PassthroughArgs(BaseModel):
|
|
32
|
+
"""Accepts anything. Used when a server's schema is unusable."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="allow")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_args_model(tool: MCPTool) -> type[BaseModel]:
|
|
38
|
+
schema = tool.input_schema or {}
|
|
39
|
+
properties = schema.get("properties")
|
|
40
|
+
if not isinstance(properties, dict) or not properties:
|
|
41
|
+
return PassthroughArgs
|
|
42
|
+
|
|
43
|
+
required = set(schema.get("required") or [])
|
|
44
|
+
fields: dict[str, Any] = {}
|
|
45
|
+
|
|
46
|
+
for name, spec in properties.items():
|
|
47
|
+
if not isinstance(spec, dict) or not name.isidentifier():
|
|
48
|
+
return PassthroughArgs
|
|
49
|
+
annotation = _JSON_TO_PYTHON.get(str(spec.get("type", "")), Any)
|
|
50
|
+
description = str(spec.get("description", ""))[:400]
|
|
51
|
+
if name in required:
|
|
52
|
+
fields[name] = (annotation, Field(description=description))
|
|
53
|
+
else:
|
|
54
|
+
fields[name] = (annotation | None, Field(default=None, description=description))
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
return create_model(f"MCP_{tool.server}_{tool.name}_Args", **fields)
|
|
58
|
+
except Exception: # noqa: BLE001
|
|
59
|
+
return PassthroughArgs
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MCPToolAdapter(Tool):
|
|
63
|
+
# MCP tools are assumed to mutate: the protocol has no read-only annotation
|
|
64
|
+
# that can be trusted, and guessing permissively would put unaudited
|
|
65
|
+
# third-party code inside plan mode.
|
|
66
|
+
read_only = False
|
|
67
|
+
parallel_safe = False
|
|
68
|
+
|
|
69
|
+
def __init__(self, tool: MCPTool, client: MCPClient):
|
|
70
|
+
self.tool = tool
|
|
71
|
+
self.client = client
|
|
72
|
+
self.name = tool.qualified # type: ignore[misc]
|
|
73
|
+
self.Args = build_args_model(tool) # type: ignore[misc]
|
|
74
|
+
self.timeout_s = client.config.timeout_s # type: ignore[misc]
|
|
75
|
+
|
|
76
|
+
def description(self, verbosity: Verbosity = "normal") -> str:
|
|
77
|
+
base = self.tool.description or f"Tool {self.tool.name} from the {self.tool.server} MCP server."
|
|
78
|
+
if verbosity == "terse":
|
|
79
|
+
return base.split("\n\n")[0][:300]
|
|
80
|
+
return base
|
|
81
|
+
|
|
82
|
+
def permission_target(self, args) -> str:
|
|
83
|
+
return self.tool.name
|
|
84
|
+
|
|
85
|
+
def summary(self, args) -> str:
|
|
86
|
+
return f"{self.tool.server}: {self.tool.name}"
|
|
87
|
+
|
|
88
|
+
async def run(self, args, ctx: ToolContext) -> ToolOutput:
|
|
89
|
+
if ctx.readonly:
|
|
90
|
+
return ToolOutput.error(
|
|
91
|
+
"plan mode is read-only, and MCP tools are treated as mutating "
|
|
92
|
+
"because the protocol does not declare otherwise."
|
|
93
|
+
)
|
|
94
|
+
payload = args.model_dump(exclude_none=True) if isinstance(args, BaseModel) else dict(args)
|
|
95
|
+
text, is_error = await self.client.call_tool(self.tool.name, payload)
|
|
96
|
+
return ToolOutput(
|
|
97
|
+
content=text,
|
|
98
|
+
is_error=is_error,
|
|
99
|
+
display=f"{self.tool.server}:{self.tool.name}",
|
|
100
|
+
metrics={"mcp_server": self.tool.server, "mcp_tool": self.tool.name},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def adapters_for(manager) -> list[MCPToolAdapter]:
|
|
105
|
+
out: list[MCPToolAdapter] = []
|
|
106
|
+
for client in manager.clients.values():
|
|
107
|
+
for tool in client.tools:
|
|
108
|
+
out.append(MCPToolAdapter(tool, client))
|
|
109
|
+
return out
|
turnloop/mcp/client.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Model Context Protocol client: JSON-RPC 2.0 over stdio or SSE.
|
|
2
|
+
|
|
3
|
+
Hand-rolled rather than taking the SDK, for the same reason the LLM providers are:
|
|
4
|
+
the protocol surface this needs is `initialize`, `tools/list`, `tools/call` and
|
|
5
|
+
notification handling, which is about 150 lines, and owning it means a third-party
|
|
6
|
+
server behaving badly is a problem we can contain rather than one that surfaces as
|
|
7
|
+
an SDK exception mid-turn.
|
|
8
|
+
|
|
9
|
+
The containment rule: **a dead or misbehaving MCP server degrades to "that tool is
|
|
10
|
+
unavailable" and never blocks the agent loop.** Connections are lazy, every call is
|
|
11
|
+
bounded by a timeout, and a server whose schema cannot be parsed simply contributes
|
|
12
|
+
no tools.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import anyio
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from turnloop.config import MCPServerConfig
|
|
26
|
+
from turnloop.providers.sse import iter_sse
|
|
27
|
+
from turnloop.tools.shell import child_env
|
|
28
|
+
|
|
29
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
30
|
+
CLIENT_INFO = {"name": "turnloop", "version": "0.1.0"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class MCPTool:
|
|
35
|
+
name: str # the server's own name, unprefixed
|
|
36
|
+
description: str
|
|
37
|
+
input_schema: dict
|
|
38
|
+
server: str
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def qualified(self) -> str:
|
|
42
|
+
return f"mcp__{self.server}__{self.name}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class MCPClient:
|
|
47
|
+
name: str
|
|
48
|
+
config: MCPServerConfig
|
|
49
|
+
tools: list[MCPTool] = field(default_factory=list)
|
|
50
|
+
connected: bool = False
|
|
51
|
+
error: str | None = None
|
|
52
|
+
|
|
53
|
+
_process: Any = None
|
|
54
|
+
_http: httpx.AsyncClient | None = None
|
|
55
|
+
_endpoint: str | None = None
|
|
56
|
+
_next_id: int = 0
|
|
57
|
+
_lock: Any = None
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
self._lock = anyio.Lock()
|
|
61
|
+
|
|
62
|
+
# --- lifecycle ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
async def connect(self) -> bool:
|
|
65
|
+
"""Idempotent, lazy, and never raises.
|
|
66
|
+
|
|
67
|
+
Returns False on failure and records why, so `/mcp` can show the reason
|
|
68
|
+
instead of the user discovering it as a missing tool.
|
|
69
|
+
"""
|
|
70
|
+
if self.connected:
|
|
71
|
+
return True
|
|
72
|
+
try:
|
|
73
|
+
with anyio.fail_after(self.config.timeout_s):
|
|
74
|
+
if self.config.transport == "stdio":
|
|
75
|
+
await self._connect_stdio()
|
|
76
|
+
else:
|
|
77
|
+
await self._connect_sse()
|
|
78
|
+
await self._initialize()
|
|
79
|
+
await self._load_tools()
|
|
80
|
+
self.connected = True
|
|
81
|
+
self.error = None
|
|
82
|
+
return True
|
|
83
|
+
except Exception as exc: # noqa: BLE001 - containment is the point
|
|
84
|
+
self.error = f"{type(exc).__name__}: {exc}"
|
|
85
|
+
await self.aclose()
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
async def _connect_stdio(self) -> None:
|
|
89
|
+
if not self.config.command:
|
|
90
|
+
raise ValueError("stdio transport requires `command`")
|
|
91
|
+
argv = [self.config.command, *self.config.args]
|
|
92
|
+
self._process = await anyio.open_process(
|
|
93
|
+
argv,
|
|
94
|
+
stdin=subprocess.PIPE,
|
|
95
|
+
stdout=subprocess.PIPE,
|
|
96
|
+
stderr=subprocess.DEVNULL, # servers log freely to stderr; not our business
|
|
97
|
+
env=child_env(self.config.env),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
async def _connect_sse(self) -> None:
|
|
101
|
+
if not self.config.url:
|
|
102
|
+
raise ValueError("sse transport requires `url`")
|
|
103
|
+
self._http = httpx.AsyncClient(
|
|
104
|
+
timeout=httpx.Timeout(self.config.timeout_s, connect=10.0),
|
|
105
|
+
headers=self.config.headers,
|
|
106
|
+
)
|
|
107
|
+
# The SSE transport advertises its POST endpoint in an `endpoint` event.
|
|
108
|
+
self._endpoint = self.config.url.rstrip("/") + "/message"
|
|
109
|
+
|
|
110
|
+
async def aclose(self) -> None:
|
|
111
|
+
if self._process is not None:
|
|
112
|
+
try:
|
|
113
|
+
self._process.terminate()
|
|
114
|
+
except Exception: # noqa: BLE001
|
|
115
|
+
pass
|
|
116
|
+
try:
|
|
117
|
+
await self._process.aclose()
|
|
118
|
+
except Exception: # noqa: BLE001
|
|
119
|
+
pass
|
|
120
|
+
self._process = None
|
|
121
|
+
if self._http is not None:
|
|
122
|
+
await self._http.aclose()
|
|
123
|
+
self._http = None
|
|
124
|
+
self.connected = False
|
|
125
|
+
|
|
126
|
+
# --- protocol ----------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def _initialize(self) -> None:
|
|
129
|
+
await self._request(
|
|
130
|
+
"initialize",
|
|
131
|
+
{
|
|
132
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
133
|
+
"capabilities": {"tools": {}},
|
|
134
|
+
"clientInfo": CLIENT_INFO,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
await self._notify("notifications/initialized", {})
|
|
138
|
+
|
|
139
|
+
async def _load_tools(self) -> None:
|
|
140
|
+
result = await self._request("tools/list", {})
|
|
141
|
+
self.tools = []
|
|
142
|
+
for entry in (result or {}).get("tools", []):
|
|
143
|
+
schema = entry.get("inputSchema") or {"type": "object", "properties": {}}
|
|
144
|
+
self.tools.append(
|
|
145
|
+
MCPTool(
|
|
146
|
+
name=entry.get("name", ""),
|
|
147
|
+
description=(entry.get("description") or "").strip(),
|
|
148
|
+
input_schema=schema,
|
|
149
|
+
server=self.name,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
async def call_tool(self, tool: str, arguments: dict) -> tuple[str, bool]:
|
|
154
|
+
"""Call a tool. Returns (text, is_error) — never raises."""
|
|
155
|
+
if not self.connected and not await self.connect():
|
|
156
|
+
return (f"MCP server {self.name!r} is unavailable: {self.error}", True)
|
|
157
|
+
try:
|
|
158
|
+
with anyio.fail_after(self.config.timeout_s):
|
|
159
|
+
result = await self._request(
|
|
160
|
+
"tools/call", {"name": tool, "arguments": arguments}
|
|
161
|
+
)
|
|
162
|
+
except TimeoutError:
|
|
163
|
+
return (f"MCP call {self.name}/{tool} timed out", True)
|
|
164
|
+
except Exception as exc: # noqa: BLE001
|
|
165
|
+
self.connected = False # force a reconnect next time
|
|
166
|
+
return (f"MCP call {self.name}/{tool} failed: {exc}", True)
|
|
167
|
+
|
|
168
|
+
return _render_result(result or {})
|
|
169
|
+
|
|
170
|
+
# --- transport ---------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
async def _request(self, method: str, params: dict) -> dict | None:
|
|
173
|
+
async with self._lock:
|
|
174
|
+
self._next_id += 1
|
|
175
|
+
message = {
|
|
176
|
+
"jsonrpc": "2.0",
|
|
177
|
+
"id": self._next_id,
|
|
178
|
+
"method": method,
|
|
179
|
+
"params": params,
|
|
180
|
+
}
|
|
181
|
+
if self._process is not None:
|
|
182
|
+
return await self._stdio_roundtrip(message)
|
|
183
|
+
return await self._http_roundtrip(message)
|
|
184
|
+
|
|
185
|
+
async def _notify(self, method: str, params: dict) -> None:
|
|
186
|
+
message = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
187
|
+
if self._process is not None:
|
|
188
|
+
await self._write_line(message)
|
|
189
|
+
elif self._http is not None and self._endpoint is not None:
|
|
190
|
+
await self._http.post(self._endpoint, json=message)
|
|
191
|
+
|
|
192
|
+
async def _write_line(self, message: dict) -> None:
|
|
193
|
+
assert self._process is not None and self._process.stdin is not None
|
|
194
|
+
await self._process.stdin.send((json.dumps(message) + "\n").encode("utf-8"))
|
|
195
|
+
|
|
196
|
+
async def _stdio_roundtrip(self, message: dict) -> dict | None:
|
|
197
|
+
await self._write_line(message)
|
|
198
|
+
assert self._process is not None and self._process.stdout is not None
|
|
199
|
+
|
|
200
|
+
buffer = b""
|
|
201
|
+
target_id = message["id"]
|
|
202
|
+
async for chunk in self._process.stdout:
|
|
203
|
+
buffer += chunk
|
|
204
|
+
while b"\n" in buffer:
|
|
205
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
206
|
+
if not line.strip():
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
payload = json.loads(line.decode("utf-8", errors="replace"))
|
|
210
|
+
except json.JSONDecodeError:
|
|
211
|
+
continue # some servers print banners to stdout
|
|
212
|
+
if payload.get("id") != target_id:
|
|
213
|
+
continue # a notification or an unrelated response
|
|
214
|
+
if error := payload.get("error"):
|
|
215
|
+
raise RuntimeError(error.get("message", str(error)))
|
|
216
|
+
return payload.get("result")
|
|
217
|
+
raise RuntimeError("the MCP server closed its output stream")
|
|
218
|
+
|
|
219
|
+
async def _http_roundtrip(self, message: dict) -> dict | None:
|
|
220
|
+
assert self._http is not None and self._endpoint is not None
|
|
221
|
+
response = await self._http.post(self._endpoint, json=message)
|
|
222
|
+
response.raise_for_status()
|
|
223
|
+
|
|
224
|
+
content_type = response.headers.get("content-type", "")
|
|
225
|
+
if "text/event-stream" in content_type:
|
|
226
|
+
async def lines():
|
|
227
|
+
for line in response.text.splitlines():
|
|
228
|
+
yield line
|
|
229
|
+
|
|
230
|
+
async for frame in iter_sse(lines()):
|
|
231
|
+
payload = frame.json()
|
|
232
|
+
if payload and payload.get("id") == message["id"]:
|
|
233
|
+
if error := payload.get("error"):
|
|
234
|
+
raise RuntimeError(error.get("message", str(error)))
|
|
235
|
+
return payload.get("result")
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
payload = response.json()
|
|
239
|
+
if error := payload.get("error"):
|
|
240
|
+
raise RuntimeError(error.get("message", str(error)))
|
|
241
|
+
return payload.get("result")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _render_result(result: dict) -> tuple[str, bool]:
|
|
245
|
+
"""Flatten MCP content blocks into text the model can read."""
|
|
246
|
+
is_error = bool(result.get("isError"))
|
|
247
|
+
parts: list[str] = []
|
|
248
|
+
for item in result.get("content") or []:
|
|
249
|
+
kind = item.get("type")
|
|
250
|
+
if kind == "text":
|
|
251
|
+
parts.append(item.get("text", ""))
|
|
252
|
+
elif kind == "resource":
|
|
253
|
+
resource = item.get("resource") or {}
|
|
254
|
+
parts.append(resource.get("text") or f"(resource: {resource.get('uri', '?')})")
|
|
255
|
+
elif kind == "image":
|
|
256
|
+
parts.append(f"(image: {item.get('mimeType', 'unknown')}, not displayed)")
|
|
257
|
+
else:
|
|
258
|
+
parts.append(json.dumps(item)[:2_000])
|
|
259
|
+
if not parts and (structured := result.get("structuredContent")):
|
|
260
|
+
parts.append(json.dumps(structured, indent=2)[:8_000])
|
|
261
|
+
return ("\n".join(p for p in parts if p) or "(no content)", is_error)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class MCPManager:
|
|
265
|
+
"""Owns every configured server and hands their tools to the registry."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, servers: dict[str, MCPServerConfig]):
|
|
268
|
+
self.clients = {
|
|
269
|
+
name: MCPClient(name=name, config=cfg)
|
|
270
|
+
for name, cfg in servers.items()
|
|
271
|
+
if cfg.enabled
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async def connect_all(self) -> dict[str, bool]:
|
|
275
|
+
results: dict[str, bool] = {}
|
|
276
|
+
|
|
277
|
+
async def one(name: str, client: MCPClient) -> None:
|
|
278
|
+
results[name] = await client.connect()
|
|
279
|
+
|
|
280
|
+
async with anyio.create_task_group() as tg:
|
|
281
|
+
for name, client in self.clients.items():
|
|
282
|
+
tg.start_soon(one, name, client)
|
|
283
|
+
return results
|
|
284
|
+
|
|
285
|
+
def all_tools(self) -> list[MCPTool]:
|
|
286
|
+
return [tool for client in self.clients.values() for tool in client.tools]
|
|
287
|
+
|
|
288
|
+
async def aclose(self) -> None:
|
|
289
|
+
for client in self.clients.values():
|
|
290
|
+
await client.aclose()
|
|
291
|
+
|
|
292
|
+
def status(self) -> list[tuple[str, str]]:
|
|
293
|
+
rows = []
|
|
294
|
+
for name, client in self.clients.items():
|
|
295
|
+
if client.connected:
|
|
296
|
+
rows.append((name, f"{len(client.tools)} tool(s)"))
|
|
297
|
+
else:
|
|
298
|
+
rows.append((name, f"unavailable: {client.error or 'not connected'}"))
|
|
299
|
+
return rows
|
|
File without changes
|