pulse-coding-agent 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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/interactive.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Responsive terminal input for the interactive Pulse shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shlex
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from prompt_toolkit import PromptSession
|
|
11
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
|
12
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
13
|
+
from prompt_toolkit.document import Document
|
|
14
|
+
from prompt_toolkit.formatted_text import HTML, FormattedText
|
|
15
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
16
|
+
from prompt_toolkit.input import Input
|
|
17
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
18
|
+
from prompt_toolkit.output import Output
|
|
19
|
+
from prompt_toolkit.shortcuts import CompleteStyle
|
|
20
|
+
from prompt_toolkit.styles import Style
|
|
21
|
+
|
|
22
|
+
_SHELL_COMMAND_HELP = {
|
|
23
|
+
"help": "Show all Pulse commands and usage examples.",
|
|
24
|
+
"clear": "Clear the terminal screen.",
|
|
25
|
+
"exit": "End the interactive Pulse session.",
|
|
26
|
+
}
|
|
27
|
+
_ROOT_COMMAND_PRIORITY = ("help", "status", "model", "keys", "clear", "exit")
|
|
28
|
+
|
|
29
|
+
_STYLE = Style.from_dict(
|
|
30
|
+
{
|
|
31
|
+
"prompt": "bold ansicyan",
|
|
32
|
+
"conversation": "ansibrightblack",
|
|
33
|
+
"bottom-toolbar": "bg:#20242b #aeb6c2",
|
|
34
|
+
"completion-menu.completion": "bg:#20242b #d7dae0",
|
|
35
|
+
"completion-menu.completion.current": "bg:#146b8c #ffffff bold",
|
|
36
|
+
"completion-menu.meta.completion": "bg:#20242b #8f9aa8",
|
|
37
|
+
"completion-menu.meta.completion.current": "bg:#146b8c #ffffff",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _subparsers(parser: argparse.ArgumentParser) -> argparse._SubParsersAction | None:
|
|
43
|
+
return next(
|
|
44
|
+
(action for action in parser._actions if isinstance(action, argparse._SubParsersAction)),
|
|
45
|
+
None,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _command_help(action: argparse._SubParsersAction) -> dict[str, str]:
|
|
50
|
+
return {
|
|
51
|
+
choice.dest: choice.help or ""
|
|
52
|
+
for choice in action._choices_actions
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SlashCommandCompleter(Completer):
|
|
57
|
+
"""Complete the live argparse command tree after a leading slash."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, parser: argparse.ArgumentParser) -> None:
|
|
60
|
+
self.parser = parser
|
|
61
|
+
|
|
62
|
+
def get_completions(self, document: Document, complete_event: object) -> Iterable[Completion]:
|
|
63
|
+
del complete_event
|
|
64
|
+
raw = document.text_before_cursor
|
|
65
|
+
if not raw.startswith("/") or "\n" in raw:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
body = raw[1:]
|
|
69
|
+
parts = body.split()
|
|
70
|
+
fragment = "" if body.endswith((" ", "\t")) else (parts[-1] if parts else "")
|
|
71
|
+
consumed = parts if not fragment else parts[:-1]
|
|
72
|
+
parser = self.parser
|
|
73
|
+
|
|
74
|
+
# Walk through each nested argparse subcommand already entered.
|
|
75
|
+
index = 0
|
|
76
|
+
while index < len(consumed):
|
|
77
|
+
subcommands = _subparsers(parser)
|
|
78
|
+
token = consumed[index]
|
|
79
|
+
if subcommands and token in subcommands.choices:
|
|
80
|
+
parser = subcommands.choices[token]
|
|
81
|
+
index += 1
|
|
82
|
+
|
|
83
|
+
previous = consumed[-1] if consumed else ""
|
|
84
|
+
for action in parser._actions:
|
|
85
|
+
if previous in action.option_strings and action.choices:
|
|
86
|
+
for choice in action.choices:
|
|
87
|
+
value = str(choice)
|
|
88
|
+
if value.startswith(fragment):
|
|
89
|
+
yield Completion(value, start_position=-len(fragment))
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
candidates: dict[str, str] = {}
|
|
93
|
+
subcommands = _subparsers(parser)
|
|
94
|
+
if subcommands:
|
|
95
|
+
command_help = _command_help(subcommands)
|
|
96
|
+
if parser is self.parser:
|
|
97
|
+
root_help = {**command_help, **_SHELL_COMMAND_HELP}
|
|
98
|
+
for command in _ROOT_COMMAND_PRIORITY:
|
|
99
|
+
candidates[command] = root_help[command]
|
|
100
|
+
for command, description in root_help.items():
|
|
101
|
+
candidates.setdefault(command, description)
|
|
102
|
+
else:
|
|
103
|
+
candidates.update(command_help)
|
|
104
|
+
if parser is not self.parser:
|
|
105
|
+
for action in parser._actions:
|
|
106
|
+
if action.help == argparse.SUPPRESS:
|
|
107
|
+
continue
|
|
108
|
+
for option in action.option_strings:
|
|
109
|
+
candidates[option] = action.help or ""
|
|
110
|
+
|
|
111
|
+
root_position = parser is self.parser and not consumed
|
|
112
|
+
for value, description in candidates.items():
|
|
113
|
+
shown = f"/{value}" if root_position else value
|
|
114
|
+
typed = raw if root_position else fragment
|
|
115
|
+
if shown.startswith(typed):
|
|
116
|
+
yield Completion(
|
|
117
|
+
shown,
|
|
118
|
+
start_position=-len(typed),
|
|
119
|
+
display_meta=description,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_key_bindings() -> KeyBindings:
|
|
124
|
+
"""Return shell-like bindings while keeping Enter fast and predictable."""
|
|
125
|
+
bindings = KeyBindings()
|
|
126
|
+
|
|
127
|
+
@bindings.add("enter")
|
|
128
|
+
def _submit(event: object) -> None:
|
|
129
|
+
event.current_buffer.validate_and_handle() # type: ignore[attr-defined]
|
|
130
|
+
|
|
131
|
+
@bindings.add("escape", "enter")
|
|
132
|
+
def _newline(event: object) -> None:
|
|
133
|
+
event.current_buffer.insert_text("\n") # type: ignore[attr-defined]
|
|
134
|
+
|
|
135
|
+
@bindings.add("c-space")
|
|
136
|
+
def _complete(event: object) -> None:
|
|
137
|
+
event.current_buffer.start_completion(select_first=False) # type: ignore[attr-defined]
|
|
138
|
+
|
|
139
|
+
@bindings.add("tab")
|
|
140
|
+
def _tab_complete(event: object) -> None:
|
|
141
|
+
buffer = event.current_buffer # type: ignore[attr-defined]
|
|
142
|
+
if buffer.complete_state:
|
|
143
|
+
buffer.complete_next()
|
|
144
|
+
else:
|
|
145
|
+
buffer.start_completion(select_first=True)
|
|
146
|
+
|
|
147
|
+
@bindings.add("s-tab")
|
|
148
|
+
def _previous_completion(event: object) -> None:
|
|
149
|
+
buffer = event.current_buffer # type: ignore[attr-defined]
|
|
150
|
+
if buffer.complete_state:
|
|
151
|
+
buffer.complete_previous()
|
|
152
|
+
else:
|
|
153
|
+
buffer.start_completion(select_first=True)
|
|
154
|
+
|
|
155
|
+
@bindings.add("c-c")
|
|
156
|
+
def _cancel(event: object) -> None:
|
|
157
|
+
buffer = event.current_buffer # type: ignore[attr-defined]
|
|
158
|
+
if buffer.text:
|
|
159
|
+
buffer.reset()
|
|
160
|
+
else:
|
|
161
|
+
event.app.exit(exception=KeyboardInterrupt) # type: ignore[attr-defined]
|
|
162
|
+
|
|
163
|
+
return bindings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class InteractivePrompt:
|
|
167
|
+
"""A reusable, testable prompt session with completion and safe history."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
workspace: Path,
|
|
172
|
+
parser: argparse.ArgumentParser,
|
|
173
|
+
*,
|
|
174
|
+
input: Input | None = None,
|
|
175
|
+
output: Output | None = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
history_dir = workspace / ".pulse"
|
|
178
|
+
try:
|
|
179
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
history = FileHistory(str(history_dir / "history"))
|
|
181
|
+
except OSError:
|
|
182
|
+
history = InMemoryHistory()
|
|
183
|
+
|
|
184
|
+
self.session: PromptSession[str] = PromptSession(
|
|
185
|
+
completer=SlashCommandCompleter(parser),
|
|
186
|
+
history=history,
|
|
187
|
+
auto_suggest=AutoSuggestFromHistory(),
|
|
188
|
+
key_bindings=build_key_bindings(),
|
|
189
|
+
style=_STYLE,
|
|
190
|
+
complete_while_typing=True,
|
|
191
|
+
complete_in_thread=False,
|
|
192
|
+
complete_style=CompleteStyle.COLUMN,
|
|
193
|
+
reserve_space_for_menu=12,
|
|
194
|
+
# prompt-toolkit disables live completion when history-prefix
|
|
195
|
+
# search is enabled. Ctrl-R and ordinary Up/Down history remain
|
|
196
|
+
# available without this conflicting option.
|
|
197
|
+
enable_history_search=False,
|
|
198
|
+
multiline=True,
|
|
199
|
+
prompt_continuation=lambda width, line, wrap: " " * max(0, width - 2) + "· ",
|
|
200
|
+
bottom_toolbar=HTML(
|
|
201
|
+
" <b>Tab</b> complete <b>↑↓</b> history <b>Ctrl-R</b> search "
|
|
202
|
+
"<b>Alt-Enter</b> newline <b>Ctrl-C</b> clear <b>/help</b> commands "
|
|
203
|
+
),
|
|
204
|
+
input=input,
|
|
205
|
+
output=output,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def read(self, conversation: str) -> str:
|
|
209
|
+
label = conversation[:28]
|
|
210
|
+
return self.session.prompt(
|
|
211
|
+
FormattedText(
|
|
212
|
+
[
|
|
213
|
+
("class:prompt", "pulse"),
|
|
214
|
+
("class:conversation", f" [{label}]"),
|
|
215
|
+
("", "> "),
|
|
216
|
+
]
|
|
217
|
+
)
|
|
218
|
+
).strip()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_slash_command(value: str) -> list[str]:
|
|
222
|
+
"""Parse a quoted slash command without shell execution or path mangling."""
|
|
223
|
+
if not value.startswith("/"):
|
|
224
|
+
raise ValueError("Interactive commands must start with '/'.")
|
|
225
|
+
try:
|
|
226
|
+
lexer = shlex.shlex(value[1:], posix=True)
|
|
227
|
+
lexer.whitespace_split = True
|
|
228
|
+
lexer.commenters = ""
|
|
229
|
+
# A backslash is a path separator in a cross-platform project CLI, not
|
|
230
|
+
# a shell escape. Quoting remains available for arguments with spaces.
|
|
231
|
+
lexer.escape = ""
|
|
232
|
+
return list(lexer)
|
|
233
|
+
except ValueError as error:
|
|
234
|
+
raise ValueError(f"Invalid command quoting: {error}") from error
|
pulse/mcp/__init__.py
ADDED
pulse/mcp/client.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
from pulse.subprocesses import isolated_process_kwargs, terminate_process
|
|
11
|
+
from pulse.tool_registry import ToolInvocation, ToolRegistry, ToolResult
|
|
12
|
+
|
|
13
|
+
MCP_MAX_CONFIG_BYTES = 1_048_576
|
|
14
|
+
MCP_MAX_TOOL_NAME_CHARS = 128
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _split_stdio_command(command: str) -> list[str]:
|
|
18
|
+
try:
|
|
19
|
+
parts = shlex.split(command, posix=False)
|
|
20
|
+
except ValueError:
|
|
21
|
+
return []
|
|
22
|
+
return [part for part in parts if part]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_loopback_http_endpoint(endpoint: str) -> bool:
|
|
26
|
+
parsed = urlparse(endpoint)
|
|
27
|
+
if parsed.scheme not in {"http", "https"}:
|
|
28
|
+
return False
|
|
29
|
+
host = (parsed.hostname or "").lower()
|
|
30
|
+
return host in {"127.0.0.1", "::1", "localhost"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _MCPTool:
|
|
34
|
+
"""An async tool_registry-compatible wrapper around an MCP server tool definition."""
|
|
35
|
+
|
|
36
|
+
requires_permission = True
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
name: str,
|
|
41
|
+
description: str,
|
|
42
|
+
server_name: str,
|
|
43
|
+
transport: str,
|
|
44
|
+
endpoint: str,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.name = name
|
|
47
|
+
self.description = description
|
|
48
|
+
self._server_name = server_name
|
|
49
|
+
self._transport = transport
|
|
50
|
+
self._endpoint = endpoint
|
|
51
|
+
|
|
52
|
+
def matches(self, invocation: ToolInvocation) -> bool:
|
|
53
|
+
return invocation.name == self.name
|
|
54
|
+
|
|
55
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
56
|
+
params = dict(invocation.arguments)
|
|
57
|
+
if self._transport == "stdio":
|
|
58
|
+
return await self._call_stdio(params)
|
|
59
|
+
return await self._call_http(params)
|
|
60
|
+
|
|
61
|
+
async def _call_stdio(self, params: dict[str, Any]) -> ToolResult:
|
|
62
|
+
cmd = _split_stdio_command(self._endpoint)
|
|
63
|
+
if not cmd:
|
|
64
|
+
return ToolResult("MCP stdio command is invalid.", metadata={"error": "invalid_command"})
|
|
65
|
+
payload = json.dumps({"method": self.name, "params": params}) + "\n"
|
|
66
|
+
process: asyncio.subprocess.Process | None = None
|
|
67
|
+
try:
|
|
68
|
+
process = await asyncio.create_subprocess_exec(
|
|
69
|
+
*cmd,
|
|
70
|
+
stdin=asyncio.subprocess.PIPE,
|
|
71
|
+
stdout=asyncio.subprocess.PIPE,
|
|
72
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
73
|
+
**isolated_process_kwargs(),
|
|
74
|
+
)
|
|
75
|
+
stdout, _ = await asyncio.wait_for(
|
|
76
|
+
process.communicate(payload.encode()),
|
|
77
|
+
timeout=30,
|
|
78
|
+
)
|
|
79
|
+
data = json.loads(stdout.decode(errors="replace"))
|
|
80
|
+
content = data.get("result") or data.get("content") or str(data)
|
|
81
|
+
return ToolResult(str(content), metadata={"server": self._server_name, "transport": "stdio"})
|
|
82
|
+
except TimeoutError:
|
|
83
|
+
await terminate_process(process)
|
|
84
|
+
return ToolResult(f"MCP tool '{self.name}' timed out.", metadata={"error": "timeout"})
|
|
85
|
+
except asyncio.CancelledError:
|
|
86
|
+
await terminate_process(process)
|
|
87
|
+
raise
|
|
88
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
89
|
+
except Exception: # noqa: BLE001
|
|
90
|
+
return ToolResult("MCP tool failed.", metadata={"error": "execution_failed"})
|
|
91
|
+
|
|
92
|
+
async def _call_http(self, params: dict[str, Any]) -> ToolResult:
|
|
93
|
+
try:
|
|
94
|
+
import httpx
|
|
95
|
+
except ImportError:
|
|
96
|
+
return ToolResult("httpx is required for HTTP MCP transport.", metadata={"error": "missing_dependency"})
|
|
97
|
+
if not _is_loopback_http_endpoint(self._endpoint):
|
|
98
|
+
return ToolResult("MCP HTTP endpoint must be loopback.", metadata={"error": "endpoint_not_allowed"})
|
|
99
|
+
|
|
100
|
+
payload = {"jsonrpc": "2.0", "id": 1, "method": self.name, "params": params}
|
|
101
|
+
try:
|
|
102
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
103
|
+
response = await client.post(self._endpoint, json=payload)
|
|
104
|
+
data = response.json()
|
|
105
|
+
content = data.get("result") or data.get("content") or str(data)
|
|
106
|
+
return ToolResult(str(content), metadata={"server": self._server_name, "transport": "http"})
|
|
107
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
return ToolResult("MCP tool failed.", metadata={"error": "execution_failed"})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class MCPClientManager:
|
|
113
|
+
"""Discovers and connects to standard stdio/HTTP MCP servers defined in agent.config.json under `mcp_servers`.
|
|
114
|
+
|
|
115
|
+
Converts external MCP tool definitions into async pulse.tool_registry tool instances.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, config_path: Path, registry: ToolRegistry) -> None:
|
|
119
|
+
self._config_path = config_path
|
|
120
|
+
self._registry = registry
|
|
121
|
+
|
|
122
|
+
def _load_mcp_servers(self) -> list[dict[str, Any]]:
|
|
123
|
+
if not self._config_path.exists():
|
|
124
|
+
return []
|
|
125
|
+
if self._config_path.is_symlink() or self._config_path.stat().st_size > MCP_MAX_CONFIG_BYTES:
|
|
126
|
+
return []
|
|
127
|
+
raw: dict[str, Any] = json.loads(self._config_path.read_text(encoding="utf-8"))
|
|
128
|
+
servers = raw.get("mcp_servers", [])
|
|
129
|
+
return servers if isinstance(servers, list) else []
|
|
130
|
+
|
|
131
|
+
async def discover_and_register_tools(self) -> int:
|
|
132
|
+
"""Probe each configured MCP server for its tool manifest and register all tools.
|
|
133
|
+
|
|
134
|
+
Returns the number of tools successfully registered.
|
|
135
|
+
"""
|
|
136
|
+
servers = self._load_mcp_servers()
|
|
137
|
+
registered = 0
|
|
138
|
+
for server in servers:
|
|
139
|
+
if not isinstance(server, dict):
|
|
140
|
+
continue
|
|
141
|
+
server_name = str(server.get("name", "unnamed"))[:MCP_MAX_TOOL_NAME_CHARS]
|
|
142
|
+
transport = str(server.get("transport", "stdio")).lower()
|
|
143
|
+
endpoint = str(server.get("endpoint", ""))
|
|
144
|
+
raw_tools_manifest = server.get("tools", [])
|
|
145
|
+
tools_manifest = raw_tools_manifest if isinstance(raw_tools_manifest, list) else []
|
|
146
|
+
|
|
147
|
+
if not tools_manifest and transport == "http" and endpoint:
|
|
148
|
+
tools_manifest = await self._probe_http_manifest(endpoint)
|
|
149
|
+
elif not tools_manifest and transport == "stdio" and endpoint:
|
|
150
|
+
tools_manifest = await self._probe_stdio_manifest(endpoint)
|
|
151
|
+
|
|
152
|
+
for tool_def in tools_manifest:
|
|
153
|
+
if not isinstance(tool_def, dict):
|
|
154
|
+
continue
|
|
155
|
+
tool_leaf = str(tool_def.get("name", "unknown"))[:MCP_MAX_TOOL_NAME_CHARS]
|
|
156
|
+
tool_name = f"{server_name}.{tool_leaf}"
|
|
157
|
+
description = tool_def.get("description", f"MCP tool from {server_name}")
|
|
158
|
+
tool = _MCPTool(
|
|
159
|
+
name=tool_name,
|
|
160
|
+
description=description,
|
|
161
|
+
server_name=server_name,
|
|
162
|
+
transport=transport,
|
|
163
|
+
endpoint=endpoint,
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
self._registry.register(tool)
|
|
167
|
+
registered += 1
|
|
168
|
+
except ValueError:
|
|
169
|
+
pass # Already registered; skip
|
|
170
|
+
|
|
171
|
+
return registered
|
|
172
|
+
|
|
173
|
+
async def _probe_http_manifest(self, endpoint: str) -> list[dict[str, Any]]:
|
|
174
|
+
if not _is_loopback_http_endpoint(endpoint):
|
|
175
|
+
return []
|
|
176
|
+
try:
|
|
177
|
+
import httpx
|
|
178
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
179
|
+
response = await client.get(endpoint.rstrip("/") + "/tools")
|
|
180
|
+
if response.is_success:
|
|
181
|
+
data = response.json()
|
|
182
|
+
return data if isinstance(data, list) else data.get("tools", [])
|
|
183
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
184
|
+
except Exception: # noqa: BLE001, S110
|
|
185
|
+
pass
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
async def _probe_stdio_manifest(self, command: str) -> list[dict[str, Any]]:
|
|
189
|
+
cmd = _split_stdio_command(command)
|
|
190
|
+
if not cmd:
|
|
191
|
+
return []
|
|
192
|
+
payload = json.dumps({"method": "tools/list", "params": {}}) + "\n"
|
|
193
|
+
process: asyncio.subprocess.Process | None = None
|
|
194
|
+
try:
|
|
195
|
+
process = await asyncio.create_subprocess_exec(
|
|
196
|
+
*cmd,
|
|
197
|
+
stdin=asyncio.subprocess.PIPE,
|
|
198
|
+
stdout=asyncio.subprocess.PIPE,
|
|
199
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
200
|
+
**isolated_process_kwargs(),
|
|
201
|
+
)
|
|
202
|
+
stdout, _ = await asyncio.wait_for(process.communicate(payload.encode()), timeout=10)
|
|
203
|
+
data = json.loads(stdout.decode(errors="replace"))
|
|
204
|
+
tools = data.get("result", data.get("tools", []))
|
|
205
|
+
return tools if isinstance(tools, list) else []
|
|
206
|
+
except TimeoutError:
|
|
207
|
+
await terminate_process(process)
|
|
208
|
+
return []
|
|
209
|
+
except asyncio.CancelledError:
|
|
210
|
+
await terminate_process(process)
|
|
211
|
+
raise
|
|
212
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
213
|
+
except Exception: # noqa: BLE001
|
|
214
|
+
await terminate_process(process)
|
|
215
|
+
return []
|
pulse/mcp/local_tools.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pulse.tool_registry import ToolInvocation, ToolRegistry, ToolResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _DynamicTool:
|
|
12
|
+
"""A tool_registry-compatible wrapper around a dynamically loaded workspace tool script."""
|
|
13
|
+
|
|
14
|
+
requires_permission = True
|
|
15
|
+
|
|
16
|
+
def __init__(self, name: str, description: str, module: Any) -> None:
|
|
17
|
+
self.name = name
|
|
18
|
+
self.description = description
|
|
19
|
+
self._module = module
|
|
20
|
+
|
|
21
|
+
def matches(self, invocation: ToolInvocation) -> bool:
|
|
22
|
+
return invocation.name == self.name
|
|
23
|
+
|
|
24
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
25
|
+
try:
|
|
26
|
+
execute_fn = getattr(self._module, "execute", None)
|
|
27
|
+
if execute_fn is None:
|
|
28
|
+
return ToolResult(
|
|
29
|
+
f"Tool '{self.name}' has no `execute` function.",
|
|
30
|
+
metadata={"error": "missing_execute"},
|
|
31
|
+
)
|
|
32
|
+
import asyncio
|
|
33
|
+
if asyncio.iscoroutinefunction(execute_fn):
|
|
34
|
+
result = await execute_fn(invocation)
|
|
35
|
+
else:
|
|
36
|
+
result = execute_fn(invocation)
|
|
37
|
+
|
|
38
|
+
if isinstance(result, ToolResult):
|
|
39
|
+
return result
|
|
40
|
+
return ToolResult(str(result))
|
|
41
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
42
|
+
except Exception as exc: # noqa: BLE001
|
|
43
|
+
return ToolResult(
|
|
44
|
+
f"Dynamic tool '{self.name}' raised: {exc}",
|
|
45
|
+
metadata={"error": str(exc)},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LocalToolLoader:
|
|
50
|
+
"""Automatically scans `.agent/tools/*.py` scripts in the workspace and registers
|
|
51
|
+
them as dynamically available tools at runtime.
|
|
52
|
+
|
|
53
|
+
Each tool script must define:
|
|
54
|
+
- `NAME: str` — tool identifier
|
|
55
|
+
- `DESCRIPTION: str` — human-readable description
|
|
56
|
+
- `execute(invocation: ToolInvocation) -> ToolResult` — sync or async callable
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, workspace: Path, registry: ToolRegistry) -> None:
|
|
60
|
+
self._workspace = workspace.resolve()
|
|
61
|
+
self._registry = registry
|
|
62
|
+
self._tools_dir = self._workspace / ".agent" / "tools"
|
|
63
|
+
|
|
64
|
+
def load(self) -> int:
|
|
65
|
+
"""Scan tools directory, import each script, and register valid tool modules.
|
|
66
|
+
|
|
67
|
+
Returns the number of tools successfully loaded.
|
|
68
|
+
"""
|
|
69
|
+
if not self._tools_dir.exists():
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
loaded = 0
|
|
73
|
+
for script in sorted(self._tools_dir.glob("*.py")):
|
|
74
|
+
if script.stem.startswith("_"):
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
module = self._import_module(script)
|
|
78
|
+
name: str = getattr(module, "NAME", script.stem)
|
|
79
|
+
description: str = getattr(module, "DESCRIPTION", f"Local tool: {script.stem}")
|
|
80
|
+
|
|
81
|
+
if not hasattr(module, "execute"):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
tool = _DynamicTool(name=name, description=description, module=module)
|
|
85
|
+
try:
|
|
86
|
+
self._registry.register(tool)
|
|
87
|
+
loaded += 1
|
|
88
|
+
except ValueError:
|
|
89
|
+
pass # Already registered; skip duplicate
|
|
90
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
91
|
+
except Exception: # noqa: BLE001, S112
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
return loaded
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _import_module(script: Path) -> Any:
|
|
98
|
+
module_name = f"_pulse_local_tool_{script.stem}"
|
|
99
|
+
spec = importlib.util.spec_from_file_location(module_name, script)
|
|
100
|
+
if spec is None or spec.loader is None:
|
|
101
|
+
raise ImportError(f"Cannot load spec for {script}")
|
|
102
|
+
module = importlib.util.module_from_spec(spec)
|
|
103
|
+
sys.modules[module_name] = module
|
|
104
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
105
|
+
return module
|