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/patch.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pulse.context import ContextManager
|
|
7
|
+
from pulse.edits import ApprovalHandler, EditWorkflow
|
|
8
|
+
from pulse.mutations import MutationTracker
|
|
9
|
+
from pulse.reasoning import ReasoningEngine
|
|
10
|
+
from pulse.safety.safety_manager import SafetyManager
|
|
11
|
+
from pulse.task_manager import TaskManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PatchEngine:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
edits: EditWorkflow,
|
|
18
|
+
safety_manager: SafetyManager,
|
|
19
|
+
mutations: MutationTracker,
|
|
20
|
+
context_manager: ContextManager,
|
|
21
|
+
reasoning_engine: ReasoningEngine,
|
|
22
|
+
task_manager: TaskManager,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.edits = edits
|
|
25
|
+
self.safety_manager = safety_manager
|
|
26
|
+
self.mutations = mutations
|
|
27
|
+
self.context_manager = context_manager
|
|
28
|
+
self.reasoning_engine = reasoning_engine
|
|
29
|
+
self.task_manager = task_manager
|
|
30
|
+
|
|
31
|
+
def locate_node(self, file_path: str | Path, target_name: str) -> tuple[int, int] | None:
|
|
32
|
+
"""Find the start and end line numbers of a target function or class."""
|
|
33
|
+
path = Path(file_path)
|
|
34
|
+
if not path.exists():
|
|
35
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
36
|
+
|
|
37
|
+
content = path.read_text(encoding="utf-8")
|
|
38
|
+
try:
|
|
39
|
+
tree = ast.parse(content)
|
|
40
|
+
except SyntaxError as e:
|
|
41
|
+
raise ValueError(f"Cannot parse {file_path}: syntax error: {e}")
|
|
42
|
+
|
|
43
|
+
for node in ast.walk(tree):
|
|
44
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): # noqa: SIM102
|
|
45
|
+
if node.name == target_name:
|
|
46
|
+
return (node.lineno, node.end_lineno)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def apply_patch(
|
|
50
|
+
self,
|
|
51
|
+
file_path: str,
|
|
52
|
+
target_name: str,
|
|
53
|
+
operation: str,
|
|
54
|
+
content: str | None,
|
|
55
|
+
approve: ApprovalHandler,
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""Apply a patch operation to a target function or class."""
|
|
58
|
+
path = Path(file_path)
|
|
59
|
+
|
|
60
|
+
# 1. Authorize via SafetyManager
|
|
61
|
+
is_safe = await self.safety_manager.authorize(
|
|
62
|
+
action="patch", target=file_path, detail=f"Patch {target_name} ({operation})"
|
|
63
|
+
)
|
|
64
|
+
if not is_safe:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
# 2. Locate AST node
|
|
68
|
+
loc = self.locate_node(path, target_name)
|
|
69
|
+
if not loc:
|
|
70
|
+
raise ValueError(f"Target '{target_name}' not found in {file_path}")
|
|
71
|
+
start_line, end_line = loc
|
|
72
|
+
|
|
73
|
+
# 3. Generate modified content
|
|
74
|
+
original_content = path.read_text(encoding="utf-8")
|
|
75
|
+
lines = original_content.splitlines(keepends=True)
|
|
76
|
+
|
|
77
|
+
# Calculate indent of the target line
|
|
78
|
+
target_line_str = lines[start_line - 1]
|
|
79
|
+
indent = len(target_line_str) - len(target_line_str.lstrip())
|
|
80
|
+
indent_str = target_line_str[:indent]
|
|
81
|
+
|
|
82
|
+
# Prepare payload
|
|
83
|
+
payload_lines = []
|
|
84
|
+
if content:
|
|
85
|
+
for line in content.splitlines(keepends=True):
|
|
86
|
+
payload_lines.append(indent_str + line if line.strip() else line)
|
|
87
|
+
|
|
88
|
+
if operation == "replace":
|
|
89
|
+
new_lines = lines[:start_line - 1] + payload_lines + lines[end_line:]
|
|
90
|
+
elif operation == "insert":
|
|
91
|
+
# Insert before the node
|
|
92
|
+
new_lines = lines[:start_line - 1] + payload_lines + lines[start_line - 1:]
|
|
93
|
+
elif operation == "delete":
|
|
94
|
+
new_lines = lines[:start_line - 1] + lines[end_line:]
|
|
95
|
+
elif operation == "rename":
|
|
96
|
+
if not content:
|
|
97
|
+
raise ValueError("Rename operation requires new name in content")
|
|
98
|
+
new_name = content.strip()
|
|
99
|
+
# Simple replace first line definition
|
|
100
|
+
first_line = lines[start_line - 1]
|
|
101
|
+
first_line = first_line.replace(f"def {target_name}", f"def {new_name}")
|
|
102
|
+
first_line = first_line.replace(f"class {target_name}", f"class {new_name}")
|
|
103
|
+
new_lines = lines[:start_line - 1] + [first_line] + lines[start_line:]
|
|
104
|
+
else:
|
|
105
|
+
raise ValueError(f"Unknown patch operation: {operation}")
|
|
106
|
+
|
|
107
|
+
modified_content = "".join(new_lines)
|
|
108
|
+
|
|
109
|
+
# 4. Validate syntax
|
|
110
|
+
try:
|
|
111
|
+
ast.parse(modified_content)
|
|
112
|
+
except SyntaxError as e:
|
|
113
|
+
# Syntax validation failed
|
|
114
|
+
raise ValueError(f"Patch would result in invalid Python syntax: {e}")
|
|
115
|
+
|
|
116
|
+
# 5. Apply via EditWorkflow
|
|
117
|
+
with self.mutations.transaction(command="pulse patch"):
|
|
118
|
+
result = await self.edits.request_and_apply(
|
|
119
|
+
file_path=file_path,
|
|
120
|
+
content=modified_content,
|
|
121
|
+
reason=f"Patch Engine: {operation} {target_name}",
|
|
122
|
+
approve=approve
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not result.applied:
|
|
126
|
+
# Need rollback if failed? Not applied means we didn't touch FS yet, just rejected.
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
return True
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class DAGTaskNode:
|
|
8
|
+
id: str
|
|
9
|
+
description: str
|
|
10
|
+
target_files: list[str] = field(default_factory=list)
|
|
11
|
+
inputs: list[str] = field(default_factory=list)
|
|
12
|
+
outputs: list[str] = field(default_factory=list)
|
|
13
|
+
dependencies: set[str] = field(default_factory=set)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DAGPlanner:
|
|
17
|
+
"""Decomposes multi-file features into a Directed Acyclic Graph of execution steps."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self.nodes: dict[str, DAGTaskNode] = {}
|
|
21
|
+
|
|
22
|
+
def add_task(
|
|
23
|
+
self,
|
|
24
|
+
task_id: str,
|
|
25
|
+
description: str,
|
|
26
|
+
target_files: list[str] | None = None,
|
|
27
|
+
inputs: list[str] | None = None,
|
|
28
|
+
outputs: list[str] | None = None,
|
|
29
|
+
dependencies: set[str] | None = None,
|
|
30
|
+
) -> DAGTaskNode:
|
|
31
|
+
if task_id in self.nodes:
|
|
32
|
+
raise ValueError(f"Task node already exists: {task_id}")
|
|
33
|
+
node = DAGTaskNode(
|
|
34
|
+
id=task_id,
|
|
35
|
+
description=description,
|
|
36
|
+
target_files=target_files or [],
|
|
37
|
+
inputs=inputs or [],
|
|
38
|
+
outputs=outputs or [],
|
|
39
|
+
dependencies=set(dependencies or []),
|
|
40
|
+
)
|
|
41
|
+
self.nodes[task_id] = node
|
|
42
|
+
return node
|
|
43
|
+
|
|
44
|
+
def add_dependency(self, task_id: str, depends_on_id: str) -> None:
|
|
45
|
+
if task_id not in self.nodes or depends_on_id not in self.nodes:
|
|
46
|
+
raise KeyError("Both task_id and depends_on_id must exist in DAG.")
|
|
47
|
+
if task_id == depends_on_id:
|
|
48
|
+
raise ValueError("A task cannot depend on itself.")
|
|
49
|
+
self.nodes[task_id].dependencies.add(depends_on_id)
|
|
50
|
+
if self._has_cycle():
|
|
51
|
+
self.nodes[task_id].dependencies.remove(depends_on_id)
|
|
52
|
+
raise ValueError("Adding this dependency creates a cycle in the DAG.")
|
|
53
|
+
|
|
54
|
+
def get_execution_order(self) -> list[DAGTaskNode]:
|
|
55
|
+
"""Returns topological sort order of tasks for execution."""
|
|
56
|
+
in_degree: dict[str, int] = {node_id: 0 for node_id in self.nodes}
|
|
57
|
+
graph: dict[str, list[str]] = {node_id: [] for node_id in self.nodes}
|
|
58
|
+
|
|
59
|
+
for node_id, node in self.nodes.items():
|
|
60
|
+
for dep in node.dependencies:
|
|
61
|
+
graph[dep].append(node_id)
|
|
62
|
+
in_degree[node_id] += 1
|
|
63
|
+
|
|
64
|
+
queue = [node_id for node_id, count in in_degree.items() if count == 0]
|
|
65
|
+
order: list[DAGTaskNode] = []
|
|
66
|
+
|
|
67
|
+
while queue:
|
|
68
|
+
current_id = queue.pop(0)
|
|
69
|
+
order.append(self.nodes[current_id])
|
|
70
|
+
for neighbor in graph[current_id]:
|
|
71
|
+
in_degree[neighbor] -= 1
|
|
72
|
+
if in_degree[neighbor] == 0:
|
|
73
|
+
queue.append(neighbor)
|
|
74
|
+
|
|
75
|
+
if len(order) != len(self.nodes):
|
|
76
|
+
raise ValueError("Cycle detected in DAG planner tasks.")
|
|
77
|
+
|
|
78
|
+
return order
|
|
79
|
+
|
|
80
|
+
def _has_cycle(self) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
self.get_execution_order()
|
|
83
|
+
return False
|
|
84
|
+
except ValueError:
|
|
85
|
+
return True
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pulse.context import ContextManager
|
|
8
|
+
from pulse.core.agent import AgentRequest, AgentResponse
|
|
9
|
+
from pulse.mutations import MutationTracker
|
|
10
|
+
from pulse.orchestration import AgentOrchestrator
|
|
11
|
+
from pulse.safety import SafetyManager
|
|
12
|
+
from pulse.verification import VerificationEngine, VerificationResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class LoopStepState:
|
|
17
|
+
step: int
|
|
18
|
+
prompt: str
|
|
19
|
+
response_content: str
|
|
20
|
+
tool_name: str | None
|
|
21
|
+
safety_approved: bool
|
|
22
|
+
verification_success: bool | None
|
|
23
|
+
mutations_count: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LoopResult:
|
|
28
|
+
success: bool
|
|
29
|
+
turns: int
|
|
30
|
+
final_response: str
|
|
31
|
+
history: list[LoopStepState] = field(default_factory=list)
|
|
32
|
+
verification: VerificationResult | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AutonomousLoop:
|
|
36
|
+
"""Manages multi-step autonomous execution loop for Pulse.
|
|
37
|
+
|
|
38
|
+
Loop steps:
|
|
39
|
+
1. Build ranked context via ContextManager (before each Planner/LLM call)
|
|
40
|
+
2. Evaluate step & execute via AgentOrchestrator
|
|
41
|
+
3. Check safety via SafetyManager
|
|
42
|
+
4. Run verification via VerificationEngine
|
|
43
|
+
5. Track file system mutations via MutationTracker
|
|
44
|
+
6. Checkpoint state
|
|
45
|
+
7. Invalidate the ContextManager cache so the next turn sees fresh state
|
|
46
|
+
|
|
47
|
+
The ``context_manager`` is optional. When present its cache is invalidated
|
|
48
|
+
between turns so that each step re-builds context from up-to-date Git,
|
|
49
|
+
repository, and memory state — critical for multi-step refactoring tasks
|
|
50
|
+
where files change between turns.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
orchestrator: AgentOrchestrator,
|
|
56
|
+
safety_manager: SafetyManager | None = None,
|
|
57
|
+
verification_engine: VerificationEngine | None = None,
|
|
58
|
+
mutation_tracker: MutationTracker | None = None,
|
|
59
|
+
checkpoint_dir: Path | None = None,
|
|
60
|
+
max_steps: int = 5,
|
|
61
|
+
context_manager: ContextManager | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.orchestrator = orchestrator
|
|
64
|
+
self.safety_manager = safety_manager or getattr(orchestrator, "safety_manager", None) or SafetyManager()
|
|
65
|
+
self.verification_engine = verification_engine
|
|
66
|
+
self.mutation_tracker = mutation_tracker
|
|
67
|
+
self.checkpoint_dir = checkpoint_dir
|
|
68
|
+
self.max_steps = max_steps
|
|
69
|
+
# Prefer an explicitly supplied ContextManager; fall back to the one
|
|
70
|
+
# already wired into the orchestrator (if any).
|
|
71
|
+
self.context_manager: ContextManager | None = context_manager or getattr(
|
|
72
|
+
orchestrator, "context_manager", None
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def run(self, initial_prompt: str) -> LoopResult:
|
|
76
|
+
step_history: list[LoopStepState] = []
|
|
77
|
+
current_prompt = initial_prompt
|
|
78
|
+
last_response: AgentResponse | None = None
|
|
79
|
+
last_verification: VerificationResult | None = None
|
|
80
|
+
|
|
81
|
+
for turn in range(1, self.max_steps + 1):
|
|
82
|
+
# 1. Invalidate the context cache before each turn so that Git, repo,
|
|
83
|
+
# and memory state changes since the previous step are picked up.
|
|
84
|
+
# The AgentOrchestrator's handle_request() will call
|
|
85
|
+
# context_manager.as_strings() and get a fresh build.
|
|
86
|
+
if self.context_manager and turn > 1:
|
|
87
|
+
await self.context_manager.invalidate_cache(current_prompt)
|
|
88
|
+
|
|
89
|
+
# 2. Evaluate step & execute via AgentOrchestrator
|
|
90
|
+
request = AgentRequest(message=current_prompt, metadata={"step": turn})
|
|
91
|
+
response = await self.orchestrator.handle_request(request)
|
|
92
|
+
last_response = response
|
|
93
|
+
|
|
94
|
+
# 3. Check safety via SafetyManager
|
|
95
|
+
tool_name = response.tool_name
|
|
96
|
+
safety_approved = True
|
|
97
|
+
if tool_name:
|
|
98
|
+
safety_approved = await self.safety_manager.authorize(
|
|
99
|
+
action=tool_name,
|
|
100
|
+
target=current_prompt,
|
|
101
|
+
detail=f"Turn {turn} tool execution: {tool_name}",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# 4. Track mutations & verify via VerificationEngine
|
|
105
|
+
mutations_count = 0
|
|
106
|
+
verification_ok: bool | None = None
|
|
107
|
+
|
|
108
|
+
if self.mutation_tracker:
|
|
109
|
+
events = self.mutation_tracker.latest_transaction()
|
|
110
|
+
mutations_count = len(events)
|
|
111
|
+
|
|
112
|
+
if self.verification_engine:
|
|
113
|
+
last_verification = await self.verification_engine.verify()
|
|
114
|
+
verification_ok = last_verification.success
|
|
115
|
+
|
|
116
|
+
step_state = LoopStepState(
|
|
117
|
+
step=turn,
|
|
118
|
+
prompt=current_prompt,
|
|
119
|
+
response_content=response.content,
|
|
120
|
+
tool_name=tool_name,
|
|
121
|
+
safety_approved=safety_approved,
|
|
122
|
+
verification_success=verification_ok,
|
|
123
|
+
mutations_count=mutations_count,
|
|
124
|
+
)
|
|
125
|
+
step_history.append(step_state)
|
|
126
|
+
|
|
127
|
+
# 5. Checkpoint state
|
|
128
|
+
self.checkpoint_state(turn, step_history)
|
|
129
|
+
|
|
130
|
+
# Termination conditions
|
|
131
|
+
if not tool_name or not safety_approved or (verification_ok is True):
|
|
132
|
+
return LoopResult(
|
|
133
|
+
success=safety_approved and (verification_ok is not False),
|
|
134
|
+
turns=turn,
|
|
135
|
+
final_response=response.content,
|
|
136
|
+
history=step_history,
|
|
137
|
+
verification=last_verification,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
current_prompt = f"Previous step output ({tool_name}): {response.content}\nContinue task execution."
|
|
141
|
+
|
|
142
|
+
return LoopResult(
|
|
143
|
+
success=False,
|
|
144
|
+
turns=self.max_steps,
|
|
145
|
+
final_response=last_response.content if last_response else "Max steps limit reached.",
|
|
146
|
+
history=step_history,
|
|
147
|
+
verification=last_verification,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def checkpoint_state(self, step: int, history: list[LoopStepState]) -> None:
|
|
151
|
+
if not self.checkpoint_dir:
|
|
152
|
+
return
|
|
153
|
+
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
checkpoint_file = self.checkpoint_dir / f"checkpoint_step_{step}.json"
|
|
155
|
+
data = {
|
|
156
|
+
"step": step,
|
|
157
|
+
"history": [asdict(item) for item in history],
|
|
158
|
+
}
|
|
159
|
+
checkpoint_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
pulse/production.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from pulse.config import AgentConfig
|
|
12
|
+
|
|
13
|
+
LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
14
|
+
_WEAK_TOKEN_MARKERS = ("change", "default", "example", "replace", "secret", "test", "token")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ProductionCheck:
|
|
19
|
+
name: str
|
|
20
|
+
ok: bool
|
|
21
|
+
detail: str
|
|
22
|
+
remediation: str
|
|
23
|
+
blocking: bool = True
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, object]:
|
|
26
|
+
return asdict(self)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class ProductionReport:
|
|
31
|
+
target: str
|
|
32
|
+
checks: tuple[ProductionCheck, ...]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def passed(self) -> bool:
|
|
36
|
+
return all(check.ok or not check.blocking for check in self.checks)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, object]:
|
|
39
|
+
return {
|
|
40
|
+
"target": self.target,
|
|
41
|
+
"passed": self.passed,
|
|
42
|
+
"checks": [check.to_dict() for check in self.checks],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_secure_remote_token(token: str) -> bool:
|
|
47
|
+
normalized = token.strip().lower()
|
|
48
|
+
return (
|
|
49
|
+
len(token.strip()) >= 32
|
|
50
|
+
and len(set(token.strip())) >= 12
|
|
51
|
+
and not any(marker in normalized for marker in _WEAK_TOKEN_MARKERS)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _inside(path: Path, parent: Path) -> bool:
|
|
56
|
+
try:
|
|
57
|
+
path.resolve().relative_to(parent.resolve())
|
|
58
|
+
except ValueError:
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _integer_check(
|
|
64
|
+
checks: list[ProductionCheck],
|
|
65
|
+
environ: Mapping[str, str],
|
|
66
|
+
name: str,
|
|
67
|
+
default: int,
|
|
68
|
+
minimum: int,
|
|
69
|
+
maximum: int,
|
|
70
|
+
) -> int | None:
|
|
71
|
+
raw = environ.get(name, str(default))
|
|
72
|
+
try:
|
|
73
|
+
value = int(raw)
|
|
74
|
+
except ValueError:
|
|
75
|
+
value = None
|
|
76
|
+
ok = value is not None and minimum <= value <= maximum
|
|
77
|
+
checks.append(
|
|
78
|
+
ProductionCheck(
|
|
79
|
+
name,
|
|
80
|
+
ok,
|
|
81
|
+
raw if ok else "invalid integer or outside the supported range",
|
|
82
|
+
f"Set {name} to an integer between {minimum} and {maximum}.",
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_production_checks(
|
|
89
|
+
workspace: Path,
|
|
90
|
+
config: AgentConfig,
|
|
91
|
+
*,
|
|
92
|
+
provider_configured: bool,
|
|
93
|
+
target: str = "local",
|
|
94
|
+
environ: Mapping[str, str] | None = None,
|
|
95
|
+
) -> ProductionReport:
|
|
96
|
+
if target not in {"local", "remote"}:
|
|
97
|
+
raise ValueError("Production target must be 'local' or 'remote'.")
|
|
98
|
+
env = environ if environ is not None else os.environ
|
|
99
|
+
root = workspace.resolve()
|
|
100
|
+
checks: list[ProductionCheck] = [
|
|
101
|
+
ProductionCheck(
|
|
102
|
+
"python_version",
|
|
103
|
+
sys.version_info >= (3, 11),
|
|
104
|
+
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
105
|
+
"Install Python 3.11 or newer.",
|
|
106
|
+
),
|
|
107
|
+
ProductionCheck(
|
|
108
|
+
"workspace",
|
|
109
|
+
root.is_dir(),
|
|
110
|
+
str(root),
|
|
111
|
+
"Run Pulse from an existing project directory.",
|
|
112
|
+
),
|
|
113
|
+
ProductionCheck(
|
|
114
|
+
"agent_config",
|
|
115
|
+
(root / "agent.config.json").is_file(),
|
|
116
|
+
"present" if (root / "agent.config.json").is_file() else "missing",
|
|
117
|
+
"Create agent.config.json from the documented production template.",
|
|
118
|
+
),
|
|
119
|
+
ProductionCheck(
|
|
120
|
+
"provider_credentials",
|
|
121
|
+
provider_configured,
|
|
122
|
+
"configured" if provider_configured else "missing or placeholder",
|
|
123
|
+
"Configure the selected provider credential in the process environment or OS keyring.",
|
|
124
|
+
),
|
|
125
|
+
ProductionCheck(
|
|
126
|
+
"model_selection",
|
|
127
|
+
bool(config.model.provider.strip() and config.model.name.strip()),
|
|
128
|
+
f"{config.model.provider}/{config.model.name}",
|
|
129
|
+
"Select an explicit provider and model with `pulse model`.",
|
|
130
|
+
),
|
|
131
|
+
ProductionCheck(
|
|
132
|
+
"workspace_boundary",
|
|
133
|
+
_inside(config.sandbox.workspace_root, root),
|
|
134
|
+
str(config.sandbox.workspace_root),
|
|
135
|
+
"Set sandbox.workspaceRoot to the project directory or one of its descendants.",
|
|
136
|
+
),
|
|
137
|
+
ProductionCheck(
|
|
138
|
+
"audit_log_boundary",
|
|
139
|
+
_inside(config.logging.action_log, root),
|
|
140
|
+
str(config.logging.action_log),
|
|
141
|
+
"Store the action log inside the protected project state directory.",
|
|
142
|
+
),
|
|
143
|
+
ProductionCheck(
|
|
144
|
+
"unsafe_host_execution",
|
|
145
|
+
env.get("PULSE_UNSAFE_HOST_EXECUTION", "").lower()
|
|
146
|
+
not in {"1", "true", "yes", "on"},
|
|
147
|
+
"disabled",
|
|
148
|
+
"Unset PULSE_UNSAFE_HOST_EXECUTION; production execution must use Docker or remote isolation.",
|
|
149
|
+
),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
pulse_path = shutil.which("pulse")
|
|
153
|
+
checks.append(
|
|
154
|
+
ProductionCheck(
|
|
155
|
+
"installed_cli",
|
|
156
|
+
pulse_path is not None,
|
|
157
|
+
pulse_path or "not found on PATH",
|
|
158
|
+
"Install the reviewed wheel with `uv tool install pulse-coding-agent`.",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
container_runtime = shutil.which("docker") or shutil.which("podman")
|
|
163
|
+
checks.append(
|
|
164
|
+
ProductionCheck(
|
|
165
|
+
"container_runtime",
|
|
166
|
+
container_runtime is not None,
|
|
167
|
+
container_runtime or "not found on PATH",
|
|
168
|
+
"Install Docker or Podman before enabling model-generated command execution.",
|
|
169
|
+
blocking=target == "remote",
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
endpoint = env.get("PULSE_REMOTE_URL", "").strip()
|
|
174
|
+
if endpoint:
|
|
175
|
+
parsed = urlparse(endpoint)
|
|
176
|
+
endpoint_ok = parsed.scheme == "wss" or (
|
|
177
|
+
parsed.scheme == "ws" and parsed.hostname in LOOPBACK_HOSTS
|
|
178
|
+
)
|
|
179
|
+
checks.append(
|
|
180
|
+
ProductionCheck(
|
|
181
|
+
"remote_endpoint_transport",
|
|
182
|
+
endpoint_ok,
|
|
183
|
+
f"{parsed.scheme or 'missing scheme'}://{parsed.hostname or 'missing host'}",
|
|
184
|
+
"Use wss:// with mTLS, or ws:// only for a loopback endpoint.",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if target == "remote":
|
|
189
|
+
host = env.get("PULSE_REMOTE_HOST", "127.0.0.1").strip()
|
|
190
|
+
tokens = [value.strip() for value in env.get("PULSE_REMOTE_TOKEN", "").split(",") if value.strip()]
|
|
191
|
+
checks.append(
|
|
192
|
+
ProductionCheck(
|
|
193
|
+
"remote_tokens",
|
|
194
|
+
bool(tokens) and all(is_secure_remote_token(token) for token in tokens),
|
|
195
|
+
f"{len(tokens)} token(s) configured" if tokens else "missing",
|
|
196
|
+
"Generate independent random remote tokens of at least 32 characters; do not use placeholders.",
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
_integer_check(checks, env, "PULSE_REMOTE_PORT", 8080, 1, 65535)
|
|
200
|
+
_integer_check(checks, env, "PULSE_REMOTE_MAX_CONCURRENCY", 10, 1, 128)
|
|
201
|
+
_integer_check(checks, env, "PULSE_REMOTE_RETENTION_HOURS", 24, 1, 8760)
|
|
202
|
+
|
|
203
|
+
workspace_root = Path(env.get("PULSE_REMOTE_WORKSPACE_ROOT", ""))
|
|
204
|
+
database_path = Path(env.get("PULSE_REMOTE_DB", ""))
|
|
205
|
+
checks.extend(
|
|
206
|
+
[
|
|
207
|
+
ProductionCheck(
|
|
208
|
+
"remote_workspace_root",
|
|
209
|
+
bool(str(workspace_root)) and workspace_root.is_absolute(),
|
|
210
|
+
str(workspace_root) or "missing",
|
|
211
|
+
"Set PULSE_REMOTE_WORKSPACE_ROOT to a dedicated absolute volume path.",
|
|
212
|
+
),
|
|
213
|
+
ProductionCheck(
|
|
214
|
+
"remote_database",
|
|
215
|
+
bool(str(database_path)) and database_path.is_absolute(),
|
|
216
|
+
str(database_path) or "missing",
|
|
217
|
+
"Set PULSE_REMOTE_DB to an absolute path on a backed-up durable volume.",
|
|
218
|
+
),
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if host not in LOOPBACK_HOSTS:
|
|
223
|
+
for variable in ("PULSE_TLS_CERT", "PULSE_TLS_KEY", "PULSE_TLS_CA"):
|
|
224
|
+
value = env.get(variable, "")
|
|
225
|
+
path = Path(value) if value else None
|
|
226
|
+
checks.append(
|
|
227
|
+
ProductionCheck(
|
|
228
|
+
variable.lower(),
|
|
229
|
+
bool(path and path.is_absolute() and path.is_file()),
|
|
230
|
+
"configured" if path else "missing",
|
|
231
|
+
f"Set {variable} to an existing absolute certificate path.",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return ProductionReport(target=target, checks=tuple(checks))
|
pulse/provider.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from pulse.config import ModelConfig
|
|
8
|
+
from pulse.core.protocols import LLMProvider
|
|
9
|
+
from pulse.providers.anthropic import AnthropicProvider
|
|
10
|
+
from pulse.providers.base import BaseProvider, ChatMessage
|
|
11
|
+
from pulse.providers.deepseek import DeepSeekProvider
|
|
12
|
+
from pulse.providers.gemini import GeminiProvider
|
|
13
|
+
from pulse.providers.groq import GroqProvider
|
|
14
|
+
from pulse.providers.openai import OpenAIProvider
|
|
15
|
+
from pulse.providers.openrouter import OpenRouterProvider
|
|
16
|
+
|
|
17
|
+
ModelProvider = LLMProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProviderFactory:
|
|
21
|
+
"""Create provider implementations without tying them to the CLI."""
|
|
22
|
+
|
|
23
|
+
def create(
|
|
24
|
+
self,
|
|
25
|
+
provider_name: str,
|
|
26
|
+
config: ModelConfig,
|
|
27
|
+
workspace_env_path: Path | str,
|
|
28
|
+
api_key: str | None = None,
|
|
29
|
+
) -> BaseProvider:
|
|
30
|
+
p = provider_name.lower().strip()
|
|
31
|
+
if p == "gemini":
|
|
32
|
+
return GeminiProvider(config, workspace_env_path, api_key)
|
|
33
|
+
if p == "openrouter":
|
|
34
|
+
return OpenRouterProvider(config, workspace_env_path, api_key)
|
|
35
|
+
if p == "openai":
|
|
36
|
+
return OpenAIProvider(config, workspace_env_path, api_key)
|
|
37
|
+
if p == "anthropic":
|
|
38
|
+
return AnthropicProvider(config, workspace_env_path, api_key)
|
|
39
|
+
if p == "groq":
|
|
40
|
+
return GroqProvider(config, workspace_env_path, api_key)
|
|
41
|
+
if p == "deepseek":
|
|
42
|
+
return DeepSeekProvider(config, workspace_env_path, api_key)
|
|
43
|
+
|
|
44
|
+
raise ValueError(f"Unsupported provider: {provider_name}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AnthropicProvider",
|
|
49
|
+
"BaseProvider",
|
|
50
|
+
"ChatMessage",
|
|
51
|
+
"DeepSeekProvider",
|
|
52
|
+
"GeminiProvider",
|
|
53
|
+
"GroqProvider",
|
|
54
|
+
"ModelProvider",
|
|
55
|
+
"OpenAIProvider",
|
|
56
|
+
"OpenRouterProvider",
|
|
57
|
+
"ProviderFactory",
|
|
58
|
+
"httpx",
|
|
59
|
+
]
|