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/tools.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Built-in Pulse tools. They depend on services, never on the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pulse.config import AgentConfig
|
|
11
|
+
from pulse.edits import EditWorkflow
|
|
12
|
+
from pulse.git import GitIntelligence
|
|
13
|
+
from pulse.memory import LongTermMemory
|
|
14
|
+
from pulse.mutations import MutationTracker
|
|
15
|
+
from pulse.provider import ModelProvider
|
|
16
|
+
from pulse.repository import RepositoryIndex
|
|
17
|
+
from pulse.tool_policy import ArgumentKind, ToolArgument, ToolRisk, ToolSchema
|
|
18
|
+
from pulse.tool_registry import ToolInvocation, ToolResult
|
|
19
|
+
from pulse.verification import VerificationEngine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseTool:
|
|
23
|
+
requires_permission = False
|
|
24
|
+
risk = ToolRisk.LOW
|
|
25
|
+
schema: ToolSchema | None = None
|
|
26
|
+
|
|
27
|
+
def matches(self, invocation: ToolInvocation) -> bool:
|
|
28
|
+
return invocation.name == self.name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StatusTool(BaseTool):
|
|
32
|
+
name = "status"
|
|
33
|
+
description = "Show the active Pulse configuration."
|
|
34
|
+
schema = ToolSchema()
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: AgentConfig, provider: ModelProvider) -> None:
|
|
37
|
+
self.config, self.provider = config, provider
|
|
38
|
+
|
|
39
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
40
|
+
api_key = getattr(self.provider, "api_key_env_var", "Provider API key")
|
|
41
|
+
content = "\n".join((
|
|
42
|
+
f"Mode: {self.config.mode}", f"Provider: {self.config.model.provider}",
|
|
43
|
+
f"Model: {self.config.model.name}", f"Writes enabled: {self.config.sandbox.allow_writes}",
|
|
44
|
+
f"{api_key} present: {self.provider.is_configured}",
|
|
45
|
+
))
|
|
46
|
+
return ToolResult(content, metadata={"config": self.config})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DoctorTool(BaseTool):
|
|
50
|
+
name = "doctor"
|
|
51
|
+
description = "Check local Pulse configuration and provider readiness."
|
|
52
|
+
schema = ToolSchema()
|
|
53
|
+
|
|
54
|
+
def __init__(self, workspace: Path, config: AgentConfig, provider: ModelProvider) -> None:
|
|
55
|
+
self.workspace, self.config, self.provider = workspace, config, provider
|
|
56
|
+
|
|
57
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
58
|
+
checks = {
|
|
59
|
+
"workspace": self.workspace.exists(), "agent.config.json": (self.workspace / "agent.config.json").exists(),
|
|
60
|
+
".env": (self.workspace / ".env").exists(), "provider": self.provider.is_configured,
|
|
61
|
+
"uv": shutil.which("uv") is not None, "model": bool(self.config.model.name),
|
|
62
|
+
}
|
|
63
|
+
content = "\n".join(f"{name}: {'OK' if ok else 'Needs attention'}" for name, ok in checks.items())
|
|
64
|
+
return ToolResult(content, metadata={"checks": checks})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MutationsTool(BaseTool):
|
|
68
|
+
name = "mutations"
|
|
69
|
+
description = "Show tracked workspace mutations."
|
|
70
|
+
schema = ToolSchema((ToolArgument("last", ArgumentKind.BOOLEAN),))
|
|
71
|
+
|
|
72
|
+
def __init__(self, mutations: MutationTracker) -> None:
|
|
73
|
+
self.mutations = mutations
|
|
74
|
+
|
|
75
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
76
|
+
events = self.mutations.latest_transaction() if invocation.arguments.get("last") else list(self.mutations.history())
|
|
77
|
+
if not events:
|
|
78
|
+
return ToolResult("No tracked mutations found.", metadata={"events": []})
|
|
79
|
+
content = "\n".join(
|
|
80
|
+
f"{event.get('timestamp')} {event.get('action')} {event.get('file_path')}" for event in events
|
|
81
|
+
)
|
|
82
|
+
return ToolResult(content, metadata={"events": events})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EditTool(BaseTool):
|
|
86
|
+
name = "edit"
|
|
87
|
+
description = "Show a proposed file diff and apply it only after approval."
|
|
88
|
+
risk = ToolRisk.HIGH
|
|
89
|
+
schema = ToolSchema(
|
|
90
|
+
(
|
|
91
|
+
ToolArgument("file", ArgumentKind.STRING, required=True),
|
|
92
|
+
ToolArgument("content", ArgumentKind.STRING, required=True),
|
|
93
|
+
ToolArgument("reason", ArgumentKind.STRING),
|
|
94
|
+
ToolArgument("approve", ArgumentKind.CALLABLE, required=True),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def __init__(self, edits: EditWorkflow, git: GitIntelligence | None = None) -> None:
|
|
99
|
+
self.edits, self.git = edits, git
|
|
100
|
+
|
|
101
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
102
|
+
arguments = invocation.arguments
|
|
103
|
+
approve = arguments.get("approve")
|
|
104
|
+
if not callable(approve):
|
|
105
|
+
raise TypeError("Edit requires an async approval handler.")
|
|
106
|
+
before = await self.git.inspect() if self.git else None
|
|
107
|
+
result = await self.edits.request_and_apply(
|
|
108
|
+
str(arguments["file"]), str(arguments["content"]), str(arguments.get("reason", "Requested edit")), approve
|
|
109
|
+
)
|
|
110
|
+
after = await self.git.inspect() if self.git and result.applied else None
|
|
111
|
+
suggestion = after.commit_suggestion if after else None
|
|
112
|
+
content = "Edit applied." if result.applied else "Edit discarded."
|
|
113
|
+
if suggestion:
|
|
114
|
+
content += f" Suggested commit: {suggestion}"
|
|
115
|
+
return ToolResult(
|
|
116
|
+
content,
|
|
117
|
+
metadata={"proposal": result.proposal, "applied": result.applied, "git_before": before, "git_after": after, "commit_suggestion": suggestion},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class RollbackTool(BaseTool):
|
|
122
|
+
name = "rollback"
|
|
123
|
+
description = "Restore the last approved edit from its tracked snapshot."
|
|
124
|
+
requires_permission = True
|
|
125
|
+
risk = ToolRisk.HIGH
|
|
126
|
+
schema = ToolSchema()
|
|
127
|
+
|
|
128
|
+
def __init__(self, edits: EditWorkflow) -> None:
|
|
129
|
+
self.edits = edits
|
|
130
|
+
|
|
131
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
132
|
+
rolled_back = await self.edits.rollback_last()
|
|
133
|
+
return ToolResult("Last approved edit rolled back." if rolled_back else "No approved edit to roll back.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class VerifyTool(BaseTool):
|
|
137
|
+
name = "verify"
|
|
138
|
+
description = "Detect and run the project's test suite."
|
|
139
|
+
risk = ToolRisk.MEDIUM
|
|
140
|
+
schema = ToolSchema(
|
|
141
|
+
(
|
|
142
|
+
ToolArgument("query", ArgumentKind.STRING),
|
|
143
|
+
ToolArgument("request", ArgumentKind.STRING),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def __init__(self, verification: VerificationEngine) -> None:
|
|
148
|
+
self.verification = verification
|
|
149
|
+
|
|
150
|
+
def matches(self, invocation: ToolInvocation) -> bool:
|
|
151
|
+
return super().matches(invocation) or invocation.message.strip().lower() in {"verify", "run tests", "test project"}
|
|
152
|
+
|
|
153
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
154
|
+
result = await self.verification.verify()
|
|
155
|
+
if result.framework is None:
|
|
156
|
+
return ToolResult(result.analysis, metadata={"verification": result})
|
|
157
|
+
state = "passed" if result.success else "failed"
|
|
158
|
+
content = f"{result.framework} verification {state} after {result.attempts} attempt(s).\n{result.analysis}"
|
|
159
|
+
return ToolResult(content, metadata={"verification": result})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class GitTool(BaseTool):
|
|
163
|
+
name = "git"
|
|
164
|
+
description = "Show Git branch, status, diff summary, and a commit suggestion."
|
|
165
|
+
schema = ToolSchema()
|
|
166
|
+
|
|
167
|
+
def __init__(self, git: GitIntelligence) -> None:
|
|
168
|
+
self.git = git
|
|
169
|
+
|
|
170
|
+
def matches(self, invocation: ToolInvocation) -> bool:
|
|
171
|
+
return super().matches(invocation) or invocation.message.strip().lower() in {"git status", "git", "show git status"}
|
|
172
|
+
|
|
173
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
174
|
+
insight = await self.git.inspect()
|
|
175
|
+
if not insight.status.is_repository:
|
|
176
|
+
return ToolResult("This workspace is not a Git repository.", metadata={"git": insight})
|
|
177
|
+
branch = insight.status.branch or "detached HEAD"
|
|
178
|
+
summary = f"Branch: {branch}\nHEAD: {insight.status.head or 'unborn'}\nChanges: {insight.diff.files_changed} files, +{insight.diff.additions}/-{insight.diff.deletions}"
|
|
179
|
+
if insight.commit_suggestion:
|
|
180
|
+
summary += f"\nSuggested commit: {insight.commit_suggestion}"
|
|
181
|
+
return ToolResult(summary, metadata={"git": insight})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class MemoryTool(BaseTool):
|
|
185
|
+
name = "memory"
|
|
186
|
+
description = "Store preferences and inspect long-term project memory."
|
|
187
|
+
risk = ToolRisk.MEDIUM
|
|
188
|
+
schema = ToolSchema(
|
|
189
|
+
(
|
|
190
|
+
ToolArgument("preference_key", ArgumentKind.STRING),
|
|
191
|
+
ToolArgument("preference_value", ArgumentKind.STRING),
|
|
192
|
+
ToolArgument("query", ArgumentKind.STRING),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def __init__(self, memory: LongTermMemory) -> None:
|
|
197
|
+
self.memory = memory
|
|
198
|
+
|
|
199
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
200
|
+
arguments = invocation.arguments
|
|
201
|
+
key, value = arguments.get("preference_key"), arguments.get("preference_value")
|
|
202
|
+
if key is not None and value is not None:
|
|
203
|
+
await self.memory.set_preference(str(key), str(value))
|
|
204
|
+
return ToolResult(f"Remembered preference: {key}.")
|
|
205
|
+
query = str(arguments.get("query", ""))
|
|
206
|
+
preferences, entries = await asyncio.gather(self.memory.preferences(), self.memory.retrieve(query))
|
|
207
|
+
lines = [f"Preference: {key} = {value}" for key, value in preferences.items()]
|
|
208
|
+
lines.extend(f"{entry.category}: {entry.content}" for entry in entries)
|
|
209
|
+
return ToolResult("\n".join(lines) or "No long-term memory stored yet.", metadata={"preferences": preferences, "entries": entries})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class IndexTool(BaseTool):
|
|
213
|
+
name = "index"
|
|
214
|
+
description = "Incrementally index repository files, folders, imports, and symbols."
|
|
215
|
+
schema = ToolSchema()
|
|
216
|
+
|
|
217
|
+
def __init__(self, repository: RepositoryIndex) -> None:
|
|
218
|
+
self.repository = repository
|
|
219
|
+
|
|
220
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
221
|
+
report = await self.repository.index()
|
|
222
|
+
return ToolResult(
|
|
223
|
+
f"Indexed {report.files} files and {report.folders} folders ({report.indexed} changed, {report.unchanged} unchanged, {report.removed} removed).",
|
|
224
|
+
metadata={"report": report},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class SearchTool(BaseTool):
|
|
229
|
+
name = "search"
|
|
230
|
+
description = "Find repository files by filename and semantic terms."
|
|
231
|
+
schema = ToolSchema((ToolArgument("query", ArgumentKind.STRING, required=True),))
|
|
232
|
+
|
|
233
|
+
def __init__(self, repository: RepositoryIndex) -> None:
|
|
234
|
+
self.repository = repository
|
|
235
|
+
|
|
236
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
237
|
+
query = str(invocation.arguments["query"])
|
|
238
|
+
results = await self.repository.search(query)
|
|
239
|
+
return ToolResult("\n".join(f"{result.path} (score {result.score:g})" for result in results) or "No matching files.", metadata={"results": results})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class SymbolsTool(BaseTool):
|
|
243
|
+
name = "symbols"
|
|
244
|
+
description = "List imports, classes, and functions from one indexed file."
|
|
245
|
+
schema = ToolSchema((ToolArgument("file", ArgumentKind.STRING, required=True),))
|
|
246
|
+
|
|
247
|
+
def __init__(self, repository: RepositoryIndex) -> None:
|
|
248
|
+
self.repository = repository
|
|
249
|
+
|
|
250
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
251
|
+
file_path = str(invocation.arguments["file"])
|
|
252
|
+
details = await self.repository.details(file_path)
|
|
253
|
+
if not details:
|
|
254
|
+
return ToolResult("No indexed file found.", metadata={"symbols": []})
|
|
255
|
+
imports = [f"import {item}" for item in details.imports]
|
|
256
|
+
symbols = [f"{symbol.kind} {symbol.name}:{symbol.line}" for symbol in details.symbols]
|
|
257
|
+
return ToolResult("\n".join(imports + symbols) or "No symbols found.", metadata={"symbols": details.symbols, "imports": details.imports})
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TaskTool(BaseTool):
|
|
261
|
+
name = "task"
|
|
262
|
+
description = "Manage tasks: create, list, inspect, resume, or cancel tasks."
|
|
263
|
+
risk = ToolRisk.MEDIUM
|
|
264
|
+
schema = ToolSchema(
|
|
265
|
+
(
|
|
266
|
+
ToolArgument("id", ArgumentKind.STRING),
|
|
267
|
+
ToolArgument("action", ArgumentKind.STRING),
|
|
268
|
+
ToolArgument("status", ArgumentKind.STRING),
|
|
269
|
+
ToolArgument("reason", ArgumentKind.STRING),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def __init__(self, task_manager: Any) -> None:
|
|
274
|
+
self.task_manager = task_manager
|
|
275
|
+
|
|
276
|
+
def matches(self, invocation: ToolInvocation) -> bool:
|
|
277
|
+
return invocation.name in {"task", "tasks", "resume", "cancel"}
|
|
278
|
+
|
|
279
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
280
|
+
cmd = invocation.name
|
|
281
|
+
args = invocation.arguments
|
|
282
|
+
|
|
283
|
+
if cmd == "tasks" or (cmd == "task" and not args.get("id") and not args.get("action")):
|
|
284
|
+
status_filter = args.get("status")
|
|
285
|
+
status_enum = None
|
|
286
|
+
if status_filter:
|
|
287
|
+
from pulse.task_manager import TaskStatus
|
|
288
|
+
try:
|
|
289
|
+
status_enum = TaskStatus(str(status_filter).upper())
|
|
290
|
+
except ValueError:
|
|
291
|
+
pass
|
|
292
|
+
tasks = self.task_manager.list_tasks(status=status_enum)
|
|
293
|
+
if not tasks:
|
|
294
|
+
return ToolResult("No tasks found.", metadata={"tasks": []})
|
|
295
|
+
lines = [f"{t.id} [{t.status.value}] ({t.priority.name}) {t.title} - {t.progress:.0f}%" for t in tasks]
|
|
296
|
+
return ToolResult("\n".join(lines), metadata={"tasks": [t.to_dict() for t in tasks]})
|
|
297
|
+
|
|
298
|
+
task_id = str(args.get("id", ""))
|
|
299
|
+
action = str(args.get("action", cmd)).lower()
|
|
300
|
+
|
|
301
|
+
if action in {"resume", "cancel", "show", "get", "task"}:
|
|
302
|
+
if not task_id:
|
|
303
|
+
return ToolResult("Task ID is required.")
|
|
304
|
+
|
|
305
|
+
if action == "resume":
|
|
306
|
+
try:
|
|
307
|
+
task = await self.task_manager.resume_task(task_id)
|
|
308
|
+
return ToolResult(f"Resumed task {task_id} [{task.status.value}].", metadata={"task": task.to_dict()})
|
|
309
|
+
except ValueError as e:
|
|
310
|
+
return ToolResult(f"Error resuming task: {e}")
|
|
311
|
+
elif action == "cancel":
|
|
312
|
+
reason = str(args.get("reason", "CLI cancellation"))
|
|
313
|
+
try:
|
|
314
|
+
task = await self.task_manager.cancel_task(task_id, reason=reason)
|
|
315
|
+
return ToolResult(f"Cancelled task {task_id} [{task.status.value}].", metadata={"task": task.to_dict()})
|
|
316
|
+
except ValueError as e:
|
|
317
|
+
return ToolResult(f"Error cancelling task: {e}")
|
|
318
|
+
else:
|
|
319
|
+
task = self.task_manager.get_task(task_id)
|
|
320
|
+
if not task:
|
|
321
|
+
return ToolResult(f"Task '{task_id}' not found.")
|
|
322
|
+
info = (
|
|
323
|
+
f"ID: {task.id}\nTitle: {task.title}\nGoal: {task.goal}\n"
|
|
324
|
+
f"Status: {task.status.value}\nPriority: {task.priority.name}\n"
|
|
325
|
+
f"Progress: {task.progress:.1f}%\nRetries: {task.retries}/{task.max_retries}\n"
|
|
326
|
+
f"Checkpoints: {len(task.checkpoints)}\nCreated: {task.created_at}"
|
|
327
|
+
)
|
|
328
|
+
return ToolResult(info, metadata={"task": task.to_dict()})
|
|
329
|
+
|
|
330
|
+
return ToolResult(f"Unknown task action: {action}")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class SessionTool(BaseTool):
|
|
334
|
+
name = "session"
|
|
335
|
+
description = "Manage sessions: list, inspect, resume, or archive sessions."
|
|
336
|
+
risk = ToolRisk.MEDIUM
|
|
337
|
+
schema = ToolSchema((ToolArgument("id", ArgumentKind.STRING),))
|
|
338
|
+
|
|
339
|
+
def __init__(self, session_manager: Any) -> None:
|
|
340
|
+
self.session_manager = session_manager
|
|
341
|
+
|
|
342
|
+
def matches(self, invocation: ToolInvocation) -> bool:
|
|
343
|
+
return invocation.name in {"session", "sessions", "resume-session"}
|
|
344
|
+
|
|
345
|
+
async def execute(self, invocation: ToolInvocation) -> ToolResult:
|
|
346
|
+
cmd = invocation.name
|
|
347
|
+
args = invocation.arguments
|
|
348
|
+
|
|
349
|
+
if cmd == "sessions":
|
|
350
|
+
sessions = self.session_manager.store.list_all()
|
|
351
|
+
if not sessions:
|
|
352
|
+
return ToolResult("No sessions found.", metadata={"sessions": []})
|
|
353
|
+
lines = [f"Session {s.id} ({s.title}) - Status: {s.status.value}" for s in sessions]
|
|
354
|
+
return ToolResult("\n".join(lines), metadata={"sessions": [s.to_dict() for s in sessions]})
|
|
355
|
+
|
|
356
|
+
if cmd == "session":
|
|
357
|
+
session_id = str(args.get("id", ""))
|
|
358
|
+
try:
|
|
359
|
+
session = await self.session_manager.load_session(session_id)
|
|
360
|
+
return ToolResult(f"Session: {session.title}\nStatus: {session.status.value}\nTasks: {len(session.active_tasks)}", metadata={"session": session.to_dict()})
|
|
361
|
+
except ValueError as e:
|
|
362
|
+
return ToolResult(str(e))
|
|
363
|
+
|
|
364
|
+
if cmd == "resume-session":
|
|
365
|
+
session_id = str(args.get("id", ""))
|
|
366
|
+
try:
|
|
367
|
+
session = await self.session_manager.resume_session(session_id)
|
|
368
|
+
return ToolResult(f"Session {session_id} resumed.", metadata={"session": session.to_dict()})
|
|
369
|
+
except ValueError as e:
|
|
370
|
+
return ToolResult(str(e))
|
|
371
|
+
|
|
372
|
+
return ToolResult(f"Unknown session command: {cmd}")
|
pulse/verification.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Async project test discovery and verification, independent of Pulse interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pulse.sandbox.process import ProcessManager
|
|
11
|
+
from pulse.sandbox.resources import ResourcePolicy
|
|
12
|
+
from pulse.subprocesses import isolated_subprocess_environment
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class VerificationTarget:
|
|
17
|
+
framework: str
|
|
18
|
+
command: tuple[str, ...]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class VerificationResult:
|
|
23
|
+
"""The outcome of a verification run, including diagnostics and retries."""
|
|
24
|
+
|
|
25
|
+
success: bool
|
|
26
|
+
framework: str | None
|
|
27
|
+
command: tuple[str, ...] = ()
|
|
28
|
+
return_code: int | None = None
|
|
29
|
+
stdout: str = ""
|
|
30
|
+
stderr: str = ""
|
|
31
|
+
analysis: str = ""
|
|
32
|
+
attempts: int = 0
|
|
33
|
+
repairs_attempted: int = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
CommandRunner = Callable[[tuple[str, ...], Path], Awaitable[tuple[int, str, str]]]
|
|
37
|
+
RepairHandler = Callable[[VerificationResult], Awaitable[bool]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class VerificationEngine:
|
|
41
|
+
"""Detects and runs a project's native test command.
|
|
42
|
+
|
|
43
|
+
A repair handler is deliberately optional. An approved autonomous workflow
|
|
44
|
+
can use it to edit code after a failure and retry, while this service stays
|
|
45
|
+
independent of agents, providers, tools, and the CLI.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, workspace: Path, *, runner: CommandRunner | None = None, max_retries: int = 3) -> None:
|
|
49
|
+
if max_retries < 0:
|
|
50
|
+
raise ValueError("max_retries cannot be negative")
|
|
51
|
+
self.workspace = workspace.resolve()
|
|
52
|
+
self.max_retries = max_retries
|
|
53
|
+
self._runner = runner or self._run_command
|
|
54
|
+
|
|
55
|
+
def detect(self) -> VerificationTarget | None:
|
|
56
|
+
"""Select a deterministic test runner for the current workspace."""
|
|
57
|
+
if any((self.workspace / file).exists() for file in ("pyproject.toml", "pytest.ini", "setup.cfg", "tox.ini")):
|
|
58
|
+
return VerificationTarget("pytest", (sys.executable, "-m", "pytest"))
|
|
59
|
+
if (self.workspace / "package.json").exists():
|
|
60
|
+
return VerificationTarget("npm", ("npm", "test"))
|
|
61
|
+
if (self.workspace / "pom.xml").exists():
|
|
62
|
+
return VerificationTarget("maven", ("mvn", "test"))
|
|
63
|
+
if any((self.workspace / file).exists() for file in ("build.gradle", "build.gradle.kts", "gradlew", "gradlew.bat")):
|
|
64
|
+
return VerificationTarget("gradle", ("gradle", "test"))
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
async def verify(self, *, repair: RepairHandler | None = None) -> VerificationResult:
|
|
68
|
+
"""Run tests, analyze failures, and retry successful repairs up to three times."""
|
|
69
|
+
target = self.detect()
|
|
70
|
+
if target is None:
|
|
71
|
+
return VerificationResult(False, None, analysis="No supported test runner was detected.")
|
|
72
|
+
|
|
73
|
+
repairs = 0
|
|
74
|
+
while True:
|
|
75
|
+
return_code, stdout, stderr = await self._runner(target.command, self.workspace)
|
|
76
|
+
result = VerificationResult(
|
|
77
|
+
success=return_code == 0,
|
|
78
|
+
framework=target.framework,
|
|
79
|
+
command=target.command,
|
|
80
|
+
return_code=return_code,
|
|
81
|
+
stdout=stdout,
|
|
82
|
+
stderr=stderr,
|
|
83
|
+
analysis=self.analyze_errors(stdout, stderr, return_code),
|
|
84
|
+
attempts=repairs + 1,
|
|
85
|
+
repairs_attempted=repairs,
|
|
86
|
+
)
|
|
87
|
+
if result.success or repair is None or repairs >= self.max_retries:
|
|
88
|
+
return result
|
|
89
|
+
if not await repair(result):
|
|
90
|
+
return result
|
|
91
|
+
repairs += 1
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def analyze_errors(stdout: str, stderr: str, return_code: int) -> str:
|
|
95
|
+
"""Produce a stable short diagnostic suitable for a repair step."""
|
|
96
|
+
output = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
|
|
97
|
+
if return_code == 0:
|
|
98
|
+
return "Tests passed."
|
|
99
|
+
if not output:
|
|
100
|
+
return f"Test command failed with exit code {return_code} and produced no output."
|
|
101
|
+
markers = ("AssertionError", "ModuleNotFoundError", "ImportError", "SyntaxError", "TypeError", "FAIL", "ERROR")
|
|
102
|
+
relevant = [line.strip() for line in output.splitlines() if any(marker in line for marker in markers)]
|
|
103
|
+
excerpt = relevant or [line.strip() for line in output.splitlines() if line.strip()]
|
|
104
|
+
return "\n".join(excerpt[:8])
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
async def _run_command(command: tuple[str, ...], workspace: Path) -> tuple[int, str, str]:
|
|
108
|
+
result = await ProcessManager().execute(
|
|
109
|
+
list(command),
|
|
110
|
+
cwd=workspace,
|
|
111
|
+
env=isolated_subprocess_environment(),
|
|
112
|
+
limits=ResourcePolicy(
|
|
113
|
+
wall_time_seconds=600,
|
|
114
|
+
max_output_bytes=5_242_880,
|
|
115
|
+
),
|
|
116
|
+
apply_native_limits=False,
|
|
117
|
+
)
|
|
118
|
+
return result.exit_code, result.stdout, result.stderr
|