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/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,3 @@
1
+ from pulse.planner.execution_loop import AutonomousLoop, LoopResult, LoopStepState
2
+
3
+ __all__ = ["AutonomousLoop", "LoopResult", "LoopStepState"]
@@ -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
+ ]