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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Pulse package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
pulse/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pulse.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()