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/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