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/__init__.py
ADDED
pulse/__main__.py
ADDED
pulse/agent.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from pulse.agent_manager import AgentManager
|
|
7
|
+
from pulse.audit import AuditLog
|
|
8
|
+
from pulse.cli_ui import print_answer, print_error, print_info, print_warning
|
|
9
|
+
from pulse.context import ContextManager
|
|
10
|
+
from pulse.core.agent import Agent, AgentRequest
|
|
11
|
+
from pulse.memory import LongTermMemory, MemoryContextSource
|
|
12
|
+
from pulse.provider import ChatMessage, ModelProvider
|
|
13
|
+
from pulse.repository import RepositoryIndex
|
|
14
|
+
from pulse.sandbox import ProjectSandbox
|
|
15
|
+
from pulse.tool_registry import ToolRegistry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class FileContext:
|
|
20
|
+
file: str
|
|
21
|
+
content: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ProjectAgent:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
name: str,
|
|
28
|
+
sandbox: ProjectSandbox,
|
|
29
|
+
provider: ModelProvider,
|
|
30
|
+
audit: AuditLog,
|
|
31
|
+
tools: ToolRegistry | None = None,
|
|
32
|
+
repository: RepositoryIndex | None = None,
|
|
33
|
+
memory: LongTermMemory | None = None,
|
|
34
|
+
manager: AgentManager | None = None,
|
|
35
|
+
context_manager: ContextManager | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.name = name
|
|
38
|
+
self.sandbox = sandbox
|
|
39
|
+
self.provider = provider
|
|
40
|
+
self.audit = audit
|
|
41
|
+
self.repository = repository
|
|
42
|
+
self.memory = memory
|
|
43
|
+
self.manager = manager
|
|
44
|
+
self.tools = tools
|
|
45
|
+
self.context_manager = context_manager
|
|
46
|
+
self._orchestrator = Agent(
|
|
47
|
+
provider,
|
|
48
|
+
system_prompt=(
|
|
49
|
+
f"You are {name}, a single-model, read-only project assistant. "
|
|
50
|
+
"For normal conversation, answer directly without using project files. "
|
|
51
|
+
"For project work, use only approved project context. If more context is needed, say which file should be approved next.\n\n"
|
|
52
|
+
"When answering architecture, design, or overview questions, you must write a comprehensive and deeply detailed explanation based on the retrieved context files.\n"
|
|
53
|
+
"You must organize your answer using exactly these 6 headings:\n"
|
|
54
|
+
"1. Project overview\n2. Main components\n3. Data flow\n4. Agent workflow\n"
|
|
55
|
+
"5. Key technologies\n6. Execution flow\n\n"
|
|
56
|
+
"CRITICAL: Do not just output the headings. You MUST write at least one full paragraph of descriptive text under each heading, analyzing the project files. "
|
|
57
|
+
"Do not repeat these instructions, and do not return raw file contents."
|
|
58
|
+
), context_source=MemoryContextSource(memory) if memory else None, tool_registry=tools,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def ask(self, question: str, *, auto_approve_reads: bool = False) -> None:
|
|
62
|
+
if not question:
|
|
63
|
+
print_warning("Please pass a question.")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
self.audit.record("question", ".", "Question received.")
|
|
67
|
+
use_project_context = self._needs_project_context(question)
|
|
68
|
+
project_files = self.sandbox.list_files() if use_project_context else []
|
|
69
|
+
relevant_files: list[str] = []
|
|
70
|
+
if use_project_context and self.repository:
|
|
71
|
+
relevant = asyncio.run(self.repository.search(question))
|
|
72
|
+
relevant_files = [result.path for result in relevant]
|
|
73
|
+
project_files = relevant_files + [file for file in project_files if file not in relevant_files]
|
|
74
|
+
context = (
|
|
75
|
+
[]
|
|
76
|
+
if not use_project_context or self._is_file_listing_question(question)
|
|
77
|
+
else self._collect_context(question, project_files, relevant_files, auto_approve_reads=auto_approve_reads)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not self.provider.is_configured:
|
|
81
|
+
self._print_local_answer(question, project_files, context)
|
|
82
|
+
api_key_env_var = getattr(self.provider, "api_key_env_var", "the provider API key")
|
|
83
|
+
print_warning(
|
|
84
|
+
f"\nModel call skipped: run `pulse keys` to configure {api_key_env_var} securely."
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
self.audit.record("model-call", ".", f"Using {self.provider.config.provider}:{self.provider.config.name}.")
|
|
89
|
+
try:
|
|
90
|
+
approved_context = [f"File: {item.file}\n---\n{item.content}" for item in context]
|
|
91
|
+
if project_files and not approved_context:
|
|
92
|
+
approved_context.append("Project files:\n" + "\n".join(f"- {file}" for file in project_files))
|
|
93
|
+
|
|
94
|
+
# Prepend ranked, token-budgeted context from the ContextManager
|
|
95
|
+
# so the model receives the most relevant signals first.
|
|
96
|
+
if self.context_manager:
|
|
97
|
+
managed = asyncio.run(self.context_manager.as_strings(question))
|
|
98
|
+
approved_context = [*managed, *approved_context]
|
|
99
|
+
|
|
100
|
+
matched_tool = tools_match(self.tools, question)
|
|
101
|
+
if self.manager and not matched_tool and not self._is_architecture_question(question):
|
|
102
|
+
response_content = asyncio.run(self.manager.run(question, approved_context)).final_response
|
|
103
|
+
else:
|
|
104
|
+
response_content = asyncio.run(self._orchestrator.respond(AgentRequest(message=question, context=approved_context))).content
|
|
105
|
+
print_answer(response_content)
|
|
106
|
+
if self.memory:
|
|
107
|
+
asyncio.run(self.memory.remember_task(question, response_content))
|
|
108
|
+
except RuntimeError as error:
|
|
109
|
+
print_error(f"\nModel call failed: {error}")
|
|
110
|
+
print_error(self._provider_recovery_hint(error))
|
|
111
|
+
|
|
112
|
+
async def respond_remote(self, prompt: str, context: list[str]) -> str:
|
|
113
|
+
"""Serve an IDE/client prompt without importing any transport concerns."""
|
|
114
|
+
self.audit.record("remote-question", ".", "Remote question received.")
|
|
115
|
+
|
|
116
|
+
# Prepend managed context (ranked, token-budgeted) from ContextManager
|
|
117
|
+
# before caller-supplied context so the model sees the best signals first.
|
|
118
|
+
if self.context_manager:
|
|
119
|
+
managed = await self.context_manager.as_strings(prompt)
|
|
120
|
+
context = [*managed, *context]
|
|
121
|
+
|
|
122
|
+
matched_tool = tools_match(self.tools, prompt)
|
|
123
|
+
if self.manager and not matched_tool and not self._is_architecture_question(prompt):
|
|
124
|
+
memory_context = await self.memory.context_for(prompt) if self.memory else []
|
|
125
|
+
response = (await self.manager.run(prompt, (*context, *memory_context))).final_response
|
|
126
|
+
else:
|
|
127
|
+
response = (await self._orchestrator.respond(AgentRequest(message=prompt, context=context))).content
|
|
128
|
+
if self.memory:
|
|
129
|
+
await self.memory.remember_task(prompt, response)
|
|
130
|
+
return response
|
|
131
|
+
|
|
132
|
+
def _collect_context(self, question: str, project_files: list[str], relevant_files: list[str] | None = None, *, auto_approve_reads: bool) -> list[FileContext]:
|
|
133
|
+
selected = self._select_context_files(question, project_files, relevant_files or [])
|
|
134
|
+
context: list[FileContext] = []
|
|
135
|
+
|
|
136
|
+
for file in selected:
|
|
137
|
+
content = self.sandbox.read_file(
|
|
138
|
+
file,
|
|
139
|
+
f"{self.name} wants to inspect this file for your question.",
|
|
140
|
+
auto_approve=auto_approve_reads,
|
|
141
|
+
)
|
|
142
|
+
if content is not None:
|
|
143
|
+
context.append(FileContext(file=file, content=content))
|
|
144
|
+
|
|
145
|
+
return context
|
|
146
|
+
|
|
147
|
+
def _is_architecture_question(self, question: str) -> bool:
|
|
148
|
+
lower = question.lower()
|
|
149
|
+
return any(term in lower for term in ("architecture", "design", "overview", "structure"))
|
|
150
|
+
|
|
151
|
+
def _select_context_files(self, question: str, project_files: list[str], relevant_files: list[str] | None = None) -> list[str]:
|
|
152
|
+
lower = question.lower()
|
|
153
|
+
explicit = [file for file in project_files if file.lower() in lower]
|
|
154
|
+
if explicit:
|
|
155
|
+
return explicit[:6]
|
|
156
|
+
|
|
157
|
+
if self._is_architecture_question(question):
|
|
158
|
+
arch_files = {
|
|
159
|
+
"README.md",
|
|
160
|
+
"src/pulse/runtime.py",
|
|
161
|
+
"src/pulse/agent.py",
|
|
162
|
+
"src/pulse/agent_manager.py",
|
|
163
|
+
"src/pulse/context.py",
|
|
164
|
+
"src/pulse/reasoning.py",
|
|
165
|
+
}
|
|
166
|
+
results = [file for file in project_files if file.replace("\\", "/") in arch_files]
|
|
167
|
+
if relevant_files:
|
|
168
|
+
results.extend(file for file in relevant_files if file not in results)
|
|
169
|
+
return results[:6]
|
|
170
|
+
|
|
171
|
+
# The repository index is consulted before this point. Prefer its
|
|
172
|
+
# ranked candidates to a static starter set so project questions reach
|
|
173
|
+
# the model with the files most likely to answer them.
|
|
174
|
+
if relevant_files:
|
|
175
|
+
return relevant_files[:6]
|
|
176
|
+
|
|
177
|
+
useful = {
|
|
178
|
+
"README.md",
|
|
179
|
+
"pyproject.toml",
|
|
180
|
+
"agent.config.json",
|
|
181
|
+
".gitignore",
|
|
182
|
+
"src/pulse/cli.py",
|
|
183
|
+
}
|
|
184
|
+
return [file for file in project_files if file.replace("\\", "/") in useful][:6]
|
|
185
|
+
|
|
186
|
+
def _needs_project_context(self, question: str) -> bool:
|
|
187
|
+
lower = question.lower()
|
|
188
|
+
project_terms = {
|
|
189
|
+
"agent.config",
|
|
190
|
+
"bug",
|
|
191
|
+
"build",
|
|
192
|
+
"change",
|
|
193
|
+
"cli",
|
|
194
|
+
"code",
|
|
195
|
+
"debug",
|
|
196
|
+
"error",
|
|
197
|
+
"file",
|
|
198
|
+
"fix",
|
|
199
|
+
"implement",
|
|
200
|
+
"project",
|
|
201
|
+
"pyproject",
|
|
202
|
+
"readme",
|
|
203
|
+
"repo",
|
|
204
|
+
"repository",
|
|
205
|
+
"src/",
|
|
206
|
+
"test",
|
|
207
|
+
"traceback",
|
|
208
|
+
"update",
|
|
209
|
+
}
|
|
210
|
+
return any(term in lower for term in project_terms) or self._is_architecture_question(question)
|
|
211
|
+
|
|
212
|
+
def _is_file_listing_question(self, question: str) -> bool:
|
|
213
|
+
lower = question.lower()
|
|
214
|
+
return "file" in lower and any(term in lower for term in ("list", "show", "what files", "which files"))
|
|
215
|
+
|
|
216
|
+
def _build_messages(self, question: str, context: list[FileContext], project_files: list[str] | None = None) -> list[ChatMessage]:
|
|
217
|
+
file_blocks = "\n\n".join(f"File: {item.file}\n---\n{item.content}" for item in context)
|
|
218
|
+
project_file_block = "\n".join(f"- {file}" for file in (project_files or []))
|
|
219
|
+
context_block = file_blocks
|
|
220
|
+
if project_file_block and not file_blocks:
|
|
221
|
+
context_block = f"Project files:\n{project_file_block}"
|
|
222
|
+
|
|
223
|
+
system = (
|
|
224
|
+
f"You are {self.name}, a single-model, read-only project assistant. "
|
|
225
|
+
"For normal conversation, answer directly without using project files. "
|
|
226
|
+
"For project work, use only approved project context. If more context is needed, say which file should be approved next."
|
|
227
|
+
)
|
|
228
|
+
user = f"Question: {question}\n\n{context_block or 'No project context was requested or approved.'}"
|
|
229
|
+
return [ChatMessage(role="system", content=system), ChatMessage(role="user", content=user)]
|
|
230
|
+
|
|
231
|
+
def _print_local_answer(self, question: str, files: list[str], context: list[FileContext]) -> None:
|
|
232
|
+
print_info(f"\n{self.name} local answer:")
|
|
233
|
+
if "file" in question.lower():
|
|
234
|
+
print_info("\n".join(f"- {file}" for file in files) if files else "No project files found.")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
if not context:
|
|
238
|
+
print_warning("No model is configured yet, so I can only answer project file-listing questions locally.")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
read_files = ", ".join(item.file for item in context)
|
|
242
|
+
api_key_env_var = getattr(self.provider, "api_key_env_var", "the provider API key")
|
|
243
|
+
print_info(
|
|
244
|
+
f"I read {read_files}. Run `pulse keys` to configure {api_key_env_var} securely."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def _provider_recovery_hint(self, error: RuntimeError) -> str:
|
|
248
|
+
provider_name = self.provider.config.provider
|
|
249
|
+
if provider_name == "openrouter":
|
|
250
|
+
if "(402)" in str(error):
|
|
251
|
+
return (
|
|
252
|
+
"OpenRouter accepted the API key but cannot charge this request. "
|
|
253
|
+
"Add credits or use a model your OpenRouter account can access, then retry."
|
|
254
|
+
)
|
|
255
|
+
if "(401)" in str(error) or "(403)" in str(error):
|
|
256
|
+
return "OpenRouter rejected the API key or model access. Check OPENROUTER_API_KEY and the selected model."
|
|
257
|
+
return (
|
|
258
|
+
"OpenRouter returned an error. Check your OpenRouter credits/billing and API key, "
|
|
259
|
+
"or switch providers with `pulse model`."
|
|
260
|
+
)
|
|
261
|
+
return f"Check the {provider_name} API key, model name, and account status, then try again."
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def tools_match(tools: ToolRegistry | None, message: str) -> bool:
|
|
265
|
+
"""Keep explicit/local tool requests on the established tool execution path."""
|
|
266
|
+
if tools is None:
|
|
267
|
+
return False
|
|
268
|
+
from pulse.tool_registry import ToolInvocation
|
|
269
|
+
|
|
270
|
+
return tools.match(ToolInvocation(message=message)) is not None
|
pulse/agent_manager.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Production-grade Multi-Agent Collaboration System for Pulse.
|
|
2
|
+
|
|
3
|
+
Provides dynamic task assignment, parallel execution, conflict resolution,
|
|
4
|
+
and shared context messaging between specialized agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import AsyncGenerator
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pulse.context import ContextManager
|
|
16
|
+
from pulse.core.agent import Agent, AgentRequest
|
|
17
|
+
from pulse.core.protocols import LLMProvider
|
|
18
|
+
from pulse.streaming import CancellationToken, StreamEvent, StreamEventType
|
|
19
|
+
from pulse.task_manager import Task, TaskManager, TaskStatus
|
|
20
|
+
from pulse.tool_registry import ToolRegistry
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskCategory(Enum):
|
|
24
|
+
"""Categories of tasks for dynamic assignment to specialized agents."""
|
|
25
|
+
PLANNING = "planning"
|
|
26
|
+
CODING = "coding"
|
|
27
|
+
REVIEW = "review"
|
|
28
|
+
TESTING = "testing"
|
|
29
|
+
DOCUMENTATION = "documentation"
|
|
30
|
+
GIT = "git"
|
|
31
|
+
GENERAL = "general"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class AgentMessage:
|
|
36
|
+
"""A structured message passed between agents via the shared context."""
|
|
37
|
+
sender: str
|
|
38
|
+
recipient: str | None
|
|
39
|
+
category: TaskCategory
|
|
40
|
+
content: str
|
|
41
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CollaborationContext:
|
|
45
|
+
"""A blackboard for shared context and messages between agents."""
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.messages: list[AgentMessage] = []
|
|
48
|
+
|
|
49
|
+
def post_message(self, message: AgentMessage) -> None:
|
|
50
|
+
self.messages.append(message)
|
|
51
|
+
|
|
52
|
+
def get_messages(self, category: TaskCategory | None = None) -> list[AgentMessage]:
|
|
53
|
+
if category:
|
|
54
|
+
return [m for m in self.messages if m.category == category]
|
|
55
|
+
return self.messages
|
|
56
|
+
|
|
57
|
+
def format_for_prompt(self) -> str:
|
|
58
|
+
if not self.messages:
|
|
59
|
+
return ""
|
|
60
|
+
lines = ["--- Shared Collaboration Context ---"]
|
|
61
|
+
for msg in self.messages:
|
|
62
|
+
lines.append(f"[{msg.sender}]: {msg.content}")
|
|
63
|
+
return "\n".join(lines)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CollaborationAgent:
|
|
67
|
+
"""Base interface for specialized collaboration agents."""
|
|
68
|
+
name: str
|
|
69
|
+
capabilities: list[TaskCategory]
|
|
70
|
+
|
|
71
|
+
async def execute(
|
|
72
|
+
self,
|
|
73
|
+
task: Task,
|
|
74
|
+
shared_context: CollaborationContext,
|
|
75
|
+
cancellation_token: CancellationToken,
|
|
76
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BaseLLMCollaborationAgent(CollaborationAgent):
|
|
81
|
+
"""Base class for LLM-backed specialized agents."""
|
|
82
|
+
def __init__(self, name: str, capabilities: list[TaskCategory], provider: LLMProvider, role_prompt: str, tool_registry: ToolRegistry | None = None):
|
|
83
|
+
self.name = name
|
|
84
|
+
self.capabilities = capabilities
|
|
85
|
+
self._agent = Agent(provider, system_prompt=role_prompt, tool_registry=tool_registry)
|
|
86
|
+
|
|
87
|
+
async def execute(
|
|
88
|
+
self,
|
|
89
|
+
task: Task,
|
|
90
|
+
shared_context: CollaborationContext,
|
|
91
|
+
cancellation_token: CancellationToken,
|
|
92
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
93
|
+
|
|
94
|
+
cancellation_token.raise_if_cancelled()
|
|
95
|
+
|
|
96
|
+
yield StreamEvent(event_type=StreamEventType.TOOL_START, content=f"[{self.name}] Starting task: {task.title}")
|
|
97
|
+
|
|
98
|
+
prompt = f"Task Goal: {task.goal}\n\n{shared_context.format_for_prompt()}"
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
# We don't have a streaming respond in core Agent yet, so we just await respond
|
|
102
|
+
# In a real implementation we would stream it.
|
|
103
|
+
response = await self._agent.respond(AgentRequest(message=prompt))
|
|
104
|
+
shared_context.post_message(AgentMessage(
|
|
105
|
+
sender=self.name,
|
|
106
|
+
recipient=None,
|
|
107
|
+
category=self.capabilities[0],
|
|
108
|
+
content=response.content
|
|
109
|
+
))
|
|
110
|
+
yield StreamEvent(event_type=StreamEventType.TOOL_COMPLETE, content=f"[{self.name}] Completed task: {task.title}")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
yield StreamEvent(event_type=StreamEventType.TOOL_FAILED, content=f"[{self.name}] Failed task: {task.title}. Error: {e!s}")
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class PlannerAgent(BaseLLMCollaborationAgent):
|
|
117
|
+
def __init__(self, provider: LLMProvider, tool_registry: ToolRegistry | None = None):
|
|
118
|
+
super().__init__(
|
|
119
|
+
name="Planner",
|
|
120
|
+
capabilities=[TaskCategory.PLANNING, TaskCategory.GENERAL],
|
|
121
|
+
provider=provider,
|
|
122
|
+
role_prompt="You are Pulse's Planner Agent. Analyze the request and shared context, and produce a detailed dependency graph and implementation plan.",
|
|
123
|
+
tool_registry=tool_registry,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SoftwareEngineerAgent(BaseLLMCollaborationAgent):
|
|
128
|
+
def __init__(self, provider: LLMProvider, tool_registry: ToolRegistry | None = None):
|
|
129
|
+
super().__init__(
|
|
130
|
+
name="SoftwareEngineer",
|
|
131
|
+
capabilities=[TaskCategory.CODING],
|
|
132
|
+
provider=provider,
|
|
133
|
+
role_prompt="You are Pulse's Software Engineer Agent. Turn the shared context and assigned task into a precise, robust implementation. Edit files as necessary.",
|
|
134
|
+
tool_registry=tool_registry,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ReviewerAgent(BaseLLMCollaborationAgent):
|
|
139
|
+
def __init__(self, provider: LLMProvider, tool_registry: ToolRegistry | None = None):
|
|
140
|
+
super().__init__(
|
|
141
|
+
name="Reviewer",
|
|
142
|
+
capabilities=[TaskCategory.REVIEW],
|
|
143
|
+
provider=provider,
|
|
144
|
+
role_prompt="You are Pulse's Reviewer Agent. Review the proposed solution for correctness, safety, regressions, and missing tests. Propose fixes if necessary.",
|
|
145
|
+
tool_registry=tool_registry,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TestingAgent(BaseLLMCollaborationAgent):
|
|
150
|
+
__test__ = False
|
|
151
|
+
|
|
152
|
+
def __init__(self, provider: LLMProvider, tool_registry: ToolRegistry | None = None):
|
|
153
|
+
super().__init__(
|
|
154
|
+
name="Tester",
|
|
155
|
+
capabilities=[TaskCategory.TESTING],
|
|
156
|
+
provider=provider,
|
|
157
|
+
role_prompt="You are Pulse's Testing Agent. Assess the review and implementation, write and run necessary tests, and ensure full coverage.",
|
|
158
|
+
tool_registry=tool_registry,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Alias for backward compatibility (to prevent breaking runtime.py and other imports)
|
|
162
|
+
TesterAgent = TestingAgent
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class DocumentationAgent(BaseLLMCollaborationAgent):
|
|
166
|
+
def __init__(self, provider: LLMProvider, tool_registry: ToolRegistry | None = None):
|
|
167
|
+
super().__init__(
|
|
168
|
+
name="Documentation",
|
|
169
|
+
capabilities=[TaskCategory.DOCUMENTATION],
|
|
170
|
+
provider=provider,
|
|
171
|
+
role_prompt="You are Pulse's Documentation Agent. Ensure all docstrings, READMEs, and architecture docs are up-to-date with the latest code changes.",
|
|
172
|
+
tool_registry=tool_registry,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class GitAgent(BaseLLMCollaborationAgent):
|
|
177
|
+
def __init__(self, provider: LLMProvider, tool_registry: ToolRegistry | None = None):
|
|
178
|
+
super().__init__(
|
|
179
|
+
name="GitAgent",
|
|
180
|
+
capabilities=[TaskCategory.GIT],
|
|
181
|
+
provider=provider,
|
|
182
|
+
role_prompt="You are Pulse's Git Agent. Manage version control, create branches, stage changes, and write semantic commit messages.",
|
|
183
|
+
tool_registry=tool_registry,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class AgentManager:
|
|
188
|
+
"""Orchestrates specialized agents, task assignments, and parallel execution."""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
task_manager: TaskManager,
|
|
193
|
+
agents: list[CollaborationAgent] | None = None,
|
|
194
|
+
context_manager: ContextManager | None = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
self.task_manager = task_manager
|
|
197
|
+
self.agents = agents or []
|
|
198
|
+
self.context_manager = context_manager
|
|
199
|
+
self.shared_context = CollaborationContext()
|
|
200
|
+
|
|
201
|
+
def register_agent(self, agent: CollaborationAgent) -> None:
|
|
202
|
+
self.agents.append(agent)
|
|
203
|
+
|
|
204
|
+
def _select_agent_for_task(self, task: Task) -> CollaborationAgent:
|
|
205
|
+
# Simple heuristic: categorize based on keywords in title/goal
|
|
206
|
+
goal_lower = f"{task.title} {task.goal}".lower()
|
|
207
|
+
|
|
208
|
+
assigned_category = TaskCategory.GENERAL
|
|
209
|
+
if any(w in goal_lower for w in ["test", "verify", "validate"]):
|
|
210
|
+
assigned_category = TaskCategory.TESTING
|
|
211
|
+
elif any(w in goal_lower for w in ["review", "check", "lint"]):
|
|
212
|
+
assigned_category = TaskCategory.REVIEW
|
|
213
|
+
elif any(w in goal_lower for w in ["doc", "readme", "walkthrough"]):
|
|
214
|
+
assigned_category = TaskCategory.DOCUMENTATION
|
|
215
|
+
elif any(w in goal_lower for w in ["git", "commit", "branch", "pr "]):
|
|
216
|
+
assigned_category = TaskCategory.GIT
|
|
217
|
+
elif any(w in goal_lower for w in ["implement", "code", "write", "fix", "refactor"]):
|
|
218
|
+
assigned_category = TaskCategory.CODING
|
|
219
|
+
elif any(w in goal_lower for w in ["plan", "design", "architecture"]):
|
|
220
|
+
assigned_category = TaskCategory.PLANNING
|
|
221
|
+
|
|
222
|
+
# Find best agent
|
|
223
|
+
for agent in self.agents:
|
|
224
|
+
if assigned_category in agent.capabilities:
|
|
225
|
+
return agent
|
|
226
|
+
|
|
227
|
+
# Fallback to SoftwareEngineer or first available
|
|
228
|
+
for agent in self.agents:
|
|
229
|
+
if TaskCategory.CODING in agent.capabilities:
|
|
230
|
+
return agent
|
|
231
|
+
|
|
232
|
+
if self.agents:
|
|
233
|
+
return self.agents[0]
|
|
234
|
+
|
|
235
|
+
raise RuntimeError("No agents available in AgentManager.")
|
|
236
|
+
|
|
237
|
+
async def run(self, prompt: str, context: list[str]) -> Any:
|
|
238
|
+
@dataclass(slots=True)
|
|
239
|
+
class AgentManagerResult:
|
|
240
|
+
final_response: str
|
|
241
|
+
|
|
242
|
+
for ctx in context:
|
|
243
|
+
self.shared_context.post_message(AgentMessage(
|
|
244
|
+
sender="System",
|
|
245
|
+
recipient=None,
|
|
246
|
+
category=TaskCategory.GENERAL,
|
|
247
|
+
content=ctx
|
|
248
|
+
))
|
|
249
|
+
|
|
250
|
+
task = await self.task_manager.create_task(goal=prompt, title=prompt[:45])
|
|
251
|
+
await self.task_manager.queue_task(task.id)
|
|
252
|
+
|
|
253
|
+
async for event in self.execute_task_graph():
|
|
254
|
+
if event.event_type == StreamEventType.ERROR:
|
|
255
|
+
print(f"ERROR IN AGENT MANAGER: {event.content}")
|
|
256
|
+
|
|
257
|
+
final_response = "No response generated."
|
|
258
|
+
agent_msgs = [m for m in self.shared_context.messages if m.sender != "System"]
|
|
259
|
+
if agent_msgs:
|
|
260
|
+
final_response = agent_msgs[-1].content
|
|
261
|
+
|
|
262
|
+
return AgentManagerResult(final_response=final_response)
|
|
263
|
+
|
|
264
|
+
async def execute_task_graph(
|
|
265
|
+
self,
|
|
266
|
+
cancellation_token: CancellationToken | None = None
|
|
267
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
268
|
+
token = cancellation_token or CancellationToken()
|
|
269
|
+
|
|
270
|
+
while True:
|
|
271
|
+
token.raise_if_cancelled()
|
|
272
|
+
|
|
273
|
+
pending_tasks = self.task_manager.list_tasks(status=TaskStatus.QUEUED)
|
|
274
|
+
if not pending_tasks:
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# Filter tasks that have all dependencies met
|
|
278
|
+
ready_tasks = []
|
|
279
|
+
for t in pending_tasks:
|
|
280
|
+
unresolved = []
|
|
281
|
+
for dep_id in t.depends_on:
|
|
282
|
+
dep_task = self.task_manager.get_task(dep_id)
|
|
283
|
+
if dep_task and dep_task.status != TaskStatus.COMPLETED:
|
|
284
|
+
unresolved.append(dep_id)
|
|
285
|
+
if not unresolved:
|
|
286
|
+
ready_tasks.append(t)
|
|
287
|
+
|
|
288
|
+
if not ready_tasks:
|
|
289
|
+
yield StreamEvent(event_type=StreamEventType.ERROR, content="Deadlock detected: pending tasks exist but none are ready.")
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
# Execute ready tasks in parallel
|
|
293
|
+
tasks_to_run = []
|
|
294
|
+
for task in ready_tasks:
|
|
295
|
+
agent = self._select_agent_for_task(task)
|
|
296
|
+
|
|
297
|
+
tasks_to_run.append((task, agent))
|
|
298
|
+
|
|
299
|
+
# Since we need to yield StreamEvents, we can run them concurrently and merge the streams.
|
|
300
|
+
# A simple approach is to use a queue.
|
|
301
|
+
queue: asyncio.Queue[StreamEvent | Exception | None] = asyncio.Queue()
|
|
302
|
+
|
|
303
|
+
async def agent_runner(t: Task, a: CollaborationAgent) -> None:
|
|
304
|
+
try:
|
|
305
|
+
async def worker(leased_task: Task) -> str:
|
|
306
|
+
async for event in a.execute(leased_task, self.shared_context, token):
|
|
307
|
+
await queue.put(event) # noqa: B023
|
|
308
|
+
return "Agent execution finished"
|
|
309
|
+
|
|
310
|
+
await self.task_manager.execute_task(t.id, worker)
|
|
311
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
312
|
+
except Exception as e: # noqa: BLE001
|
|
313
|
+
await queue.put(e) # noqa: B023
|
|
314
|
+
|
|
315
|
+
runners = [asyncio.create_task(agent_runner(t, a)) for t, a in tasks_to_run]
|
|
316
|
+
|
|
317
|
+
async def watcher() -> None:
|
|
318
|
+
await asyncio.gather(*runners) # noqa: B023
|
|
319
|
+
await queue.put(None) # Sentinel to stop yielding # noqa: B023
|
|
320
|
+
|
|
321
|
+
watcher_task = asyncio.create_task(watcher())
|
|
322
|
+
|
|
323
|
+
while True:
|
|
324
|
+
item = await queue.get()
|
|
325
|
+
if item is None:
|
|
326
|
+
break
|
|
327
|
+
if isinstance(item, Exception):
|
|
328
|
+
# We might want to handle conflict resolution here.
|
|
329
|
+
# For now, we yield the error and continue.
|
|
330
|
+
yield StreamEvent(event_type=StreamEventType.ERROR, content=f"Agent execution failed: {item!s}")
|
|
331
|
+
else:
|
|
332
|
+
yield item
|
|
333
|
+
|
|
334
|
+
# Check for any unhandled exceptions in the watcher task
|
|
335
|
+
await watcher_task
|
pulse/audit.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pulse.sandbox.secrets import SecretScrubber
|
|
10
|
+
from pulse.telemetry import get_correlation_id
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AuditEntry:
|
|
15
|
+
schema_version: int = field(default=1, init=False)
|
|
16
|
+
timestamp: str
|
|
17
|
+
correlation_id: str
|
|
18
|
+
action: str
|
|
19
|
+
file: str
|
|
20
|
+
detail: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuditLog:
|
|
24
|
+
def __init__(self, path: Path, secrets: list[str] | None = None) -> None:
|
|
25
|
+
self.path = path
|
|
26
|
+
self.entries: list[AuditEntry] = []
|
|
27
|
+
self._scrubber = SecretScrubber(secrets)
|
|
28
|
+
|
|
29
|
+
def add_secret(self, secret: str | None) -> None:
|
|
30
|
+
if secret:
|
|
31
|
+
self._scrubber.add_secret(secret)
|
|
32
|
+
|
|
33
|
+
def record(self, action: str, file: str, detail: str) -> None:
|
|
34
|
+
entry = AuditEntry(
|
|
35
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
36
|
+
correlation_id=get_correlation_id(),
|
|
37
|
+
action=self._scrubber.redact(action),
|
|
38
|
+
file=self._scrubber.redact(file),
|
|
39
|
+
detail=self._scrubber.redact(detail),
|
|
40
|
+
)
|
|
41
|
+
self.entries.append(entry)
|
|
42
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
with self.path.open("a", encoding="utf-8") as handle:
|
|
44
|
+
handle.write(json.dumps(asdict(entry), separators=(",", ":")) + "\n")
|
|
45
|
+
try:
|
|
46
|
+
os.chmod(self.path, 0o600)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def print_summary(self) -> None:
|
|
51
|
+
if not self.entries:
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
from rich.console import Console
|
|
55
|
+
from rich.table import Table
|
|
56
|
+
|
|
57
|
+
from pulse.cli_ui import _box_style
|
|
58
|
+
|
|
59
|
+
console = Console()
|
|
60
|
+
table = Table(box=_box_style(), show_header=True, title="Session Audit Log")
|
|
61
|
+
table.add_column("Action", style="bold cyan")
|
|
62
|
+
table.add_column("File", style="yellow")
|
|
63
|
+
table.add_column("Detail", style="dim")
|
|
64
|
+
|
|
65
|
+
for entry in self.entries:
|
|
66
|
+
table.add_row(entry.action, entry.file, entry.detail)
|
|
67
|
+
|
|
68
|
+
console.print()
|
|
69
|
+
console.print(table)
|
|
70
|
+
console.print()
|