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.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. 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
@@ -0,0 +1,4 @@
1
+ from pulse.mcp.client import MCPClientManager
2
+ from pulse.mcp.local_tools import LocalToolLoader
3
+
4
+ __all__ = ["LocalToolLoader", "MCPClientManager"]
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 []
@@ -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