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/streaming.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Production-grade Streaming Execution Engine for Pulse.
|
|
2
|
+
|
|
3
|
+
Provides token-by-token LLM streaming, real-time tool execution progress,
|
|
4
|
+
structured event broadcasts (reasoning, planning, verification, task status),
|
|
5
|
+
cancellation token interrupts, and RPC / Telemetry / TaskManager integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import AsyncGenerator
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pulse.core.protocols import LLMProvider, StreamChunk
|
|
19
|
+
from pulse.reasoning import ReasoningEngine, ReasoningResult
|
|
20
|
+
from pulse.safety.safety_manager import SafetyManager
|
|
21
|
+
from pulse.sandbox.secrets import SecretScrubber
|
|
22
|
+
from pulse.tool_registry import ToolInvocation, ToolRegistry, ToolResult
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Enums & Data Models
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StreamEventType(Enum):
|
|
33
|
+
"""Event types emitted during streaming execution."""
|
|
34
|
+
|
|
35
|
+
REASONING_START = "reasoning_start"
|
|
36
|
+
REASONING_STEP = "reasoning_step"
|
|
37
|
+
PLANNING_START = "planning_start"
|
|
38
|
+
PLANNING_STEP = "planning_step"
|
|
39
|
+
LLM_TOKEN = "llm_token"
|
|
40
|
+
TOOL_START = "tool_start"
|
|
41
|
+
TOOL_PROGRESS = "tool_progress"
|
|
42
|
+
TOOL_COMPLETE = "tool_complete"
|
|
43
|
+
TOOL_FAILED = "tool_failed"
|
|
44
|
+
VERIFICATION_START = "verification_start"
|
|
45
|
+
VERIFICATION_COMPLETE = "verification_complete"
|
|
46
|
+
TASK_PROGRESS = "task_progress"
|
|
47
|
+
COMPLETION = "completion"
|
|
48
|
+
CANCELLED = "cancelled"
|
|
49
|
+
ERROR = "error"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(slots=True)
|
|
53
|
+
class StreamEvent:
|
|
54
|
+
"""A single structured streaming event emitted to clients."""
|
|
55
|
+
|
|
56
|
+
event_type: StreamEventType
|
|
57
|
+
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
58
|
+
content: str = ""
|
|
59
|
+
delta: str = ""
|
|
60
|
+
step_number: int | None = None
|
|
61
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
"""Serialize event to a JSON-compatible dictionary."""
|
|
65
|
+
return {
|
|
66
|
+
"event_type": self.event_type.value,
|
|
67
|
+
"timestamp": self.timestamp,
|
|
68
|
+
"content": self.content,
|
|
69
|
+
"delta": self.delta,
|
|
70
|
+
"step_number": self.step_number,
|
|
71
|
+
"metadata": self.metadata,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CancellationToken:
|
|
76
|
+
"""Thread- and async-safe token used to signal stream cancellation."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self._is_cancelled = False
|
|
80
|
+
self._reason = ""
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_cancelled(self) -> bool:
|
|
84
|
+
return self._is_cancelled
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def reason(self) -> str:
|
|
88
|
+
return self._reason
|
|
89
|
+
|
|
90
|
+
def cancel(self, reason: str = "Execution cancelled by user") -> None:
|
|
91
|
+
self._is_cancelled = True
|
|
92
|
+
self._reason = reason
|
|
93
|
+
|
|
94
|
+
def raise_if_cancelled(self) -> None:
|
|
95
|
+
if self._is_cancelled:
|
|
96
|
+
raise asyncio.CancelledError(self._reason or "Execution cancelled")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Streaming Execution Engine
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class StreamingExecutionEngine:
|
|
105
|
+
"""Core async streaming engine for Pulse.
|
|
106
|
+
|
|
107
|
+
Coordinates ReasoningEngine, TaskManager, LLM token streaming, Tool execution,
|
|
108
|
+
Verification, Telemetry, and CancellationTokens in a unified event stream.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
reasoning_engine: ReasoningEngine | None = None,
|
|
115
|
+
provider: LLMProvider | Any | None = None,
|
|
116
|
+
orchestrator: Any | None = None,
|
|
117
|
+
task_manager: Any | None = None,
|
|
118
|
+
telemetry: Any | None = None,
|
|
119
|
+
verification_engine: Any | None = None,
|
|
120
|
+
safety_manager: SafetyManager | None = None,
|
|
121
|
+
tool_registry: ToolRegistry | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.reasoning_engine = reasoning_engine or ReasoningEngine(
|
|
124
|
+
provider=provider, safety_manager=safety_manager, tool_registry=tool_registry
|
|
125
|
+
)
|
|
126
|
+
self.provider = provider or getattr(self.reasoning_engine, "provider", None)
|
|
127
|
+
self.orchestrator = orchestrator
|
|
128
|
+
self.task_manager = task_manager
|
|
129
|
+
self.telemetry = telemetry
|
|
130
|
+
self.verification_engine = verification_engine
|
|
131
|
+
self.safety_manager = safety_manager or getattr(self.reasoning_engine, "safety_manager", None)
|
|
132
|
+
self.tool_registry = tool_registry or getattr(self.reasoning_engine, "tool_registry", None)
|
|
133
|
+
self._scrubber = SecretScrubber(
|
|
134
|
+
[str(provider.api_key)]
|
|
135
|
+
if provider is not None and getattr(provider, "api_key", None)
|
|
136
|
+
else None
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def execute_stream(
|
|
140
|
+
self,
|
|
141
|
+
request: str,
|
|
142
|
+
*,
|
|
143
|
+
active_file: str | None = None,
|
|
144
|
+
task_id: str | None = None,
|
|
145
|
+
cancellation_token: CancellationToken | None = None,
|
|
146
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
147
|
+
"""Main async generator streaming real-time execution events for *request*."""
|
|
148
|
+
token = cancellation_token or CancellationToken()
|
|
149
|
+
start_time = datetime.now(UTC)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
token.raise_if_cancelled()
|
|
153
|
+
|
|
154
|
+
# 1. Reasoning Phase
|
|
155
|
+
yield StreamEvent(
|
|
156
|
+
event_type=StreamEventType.REASONING_START,
|
|
157
|
+
content="Analyzing request intent and execution strategy...",
|
|
158
|
+
metadata={"request": request, "task_id": task_id},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
reasoning_res: ReasoningResult = await self.reasoning_engine.reason(
|
|
162
|
+
request, active_file=active_file
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
for step in reasoning_res.reasoning_steps:
|
|
166
|
+
token.raise_if_cancelled()
|
|
167
|
+
yield StreamEvent(
|
|
168
|
+
event_type=StreamEventType.REASONING_STEP,
|
|
169
|
+
step_number=step.step_number,
|
|
170
|
+
content=f"[{step.title}] {step.rationale}",
|
|
171
|
+
metadata={"action": step.action, "status": step.status},
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Task progress update if task_id supplied
|
|
175
|
+
if task_id and self.task_manager:
|
|
176
|
+
try:
|
|
177
|
+
await self.task_manager.update_progress(task_id, 25.0, "Reasoning complete")
|
|
178
|
+
yield StreamEvent(
|
|
179
|
+
event_type=StreamEventType.TASK_PROGRESS,
|
|
180
|
+
content=f"Task {task_id} progress updated to 25%",
|
|
181
|
+
metadata={"task_id": task_id, "progress": 25.0},
|
|
182
|
+
)
|
|
183
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
184
|
+
except Exception: # noqa: BLE001
|
|
185
|
+
logger.warning("TaskManager progress update failed.")
|
|
186
|
+
|
|
187
|
+
token.raise_if_cancelled()
|
|
188
|
+
|
|
189
|
+
# 2. Planning Phase (if required by strategy)
|
|
190
|
+
if reasoning_res.strategy.requires_planning and reasoning_res.execution_plan:
|
|
191
|
+
yield StreamEvent(
|
|
192
|
+
event_type=StreamEventType.PLANNING_START,
|
|
193
|
+
content=f"Formulated goal: {reasoning_res.execution_plan.goal}",
|
|
194
|
+
metadata={"steps_count": len(reasoning_res.execution_plan.steps)},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for index, step in enumerate(reasoning_res.execution_plan.steps, start=1):
|
|
198
|
+
token.raise_if_cancelled()
|
|
199
|
+
yield StreamEvent(
|
|
200
|
+
event_type=StreamEventType.PLANNING_STEP,
|
|
201
|
+
step_number=index,
|
|
202
|
+
content=f"Step {index}: {step.description}",
|
|
203
|
+
metadata={"step_id": step.id, "action": step.action},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# 3. Tool Execution Stream (if tools selected)
|
|
207
|
+
if reasoning_res.strategy.selected_tools and self.tool_registry:
|
|
208
|
+
for tool_name in reasoning_res.strategy.selected_tools:
|
|
209
|
+
token.raise_if_cancelled()
|
|
210
|
+
tool = self.tool_registry.get(tool_name)
|
|
211
|
+
if not tool:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# Safety authorization check
|
|
215
|
+
if self.safety_manager:
|
|
216
|
+
authorized = await self.safety_manager.authorize(tool_name, request)
|
|
217
|
+
if not authorized:
|
|
218
|
+
yield StreamEvent(
|
|
219
|
+
event_type=StreamEventType.TOOL_FAILED,
|
|
220
|
+
content=f"Tool '{tool_name}' unauthorized by user safety policy.",
|
|
221
|
+
metadata={"tool_name": tool_name, "reason": "unauthorized"},
|
|
222
|
+
)
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
yield StreamEvent(
|
|
226
|
+
event_type=StreamEventType.TOOL_START,
|
|
227
|
+
content=f"Invoking tool '{tool_name}'...",
|
|
228
|
+
metadata={"tool_name": tool_name},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
yield StreamEvent(
|
|
232
|
+
event_type=StreamEventType.TOOL_PROGRESS,
|
|
233
|
+
content=f"Running tool '{tool_name}' execution...",
|
|
234
|
+
metadata={"tool_name": tool_name, "progress": 50.0},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
tool_invocation = ToolInvocation(name=tool_name, arguments={"query": request, "request": request})
|
|
238
|
+
try:
|
|
239
|
+
res: ToolResult | None = await self.tool_registry.execute(tool_invocation)
|
|
240
|
+
content_str = res.content if res else "Tool executed."
|
|
241
|
+
yield StreamEvent(
|
|
242
|
+
event_type=StreamEventType.TOOL_COMPLETE,
|
|
243
|
+
content=content_str,
|
|
244
|
+
metadata={"tool_name": tool_name, "success": True},
|
|
245
|
+
)
|
|
246
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
247
|
+
except Exception as tool_err: # noqa: BLE001
|
|
248
|
+
yield StreamEvent(
|
|
249
|
+
event_type=StreamEventType.TOOL_FAILED,
|
|
250
|
+
content=f"Tool '{tool_name}' error: {tool_err}",
|
|
251
|
+
metadata={"tool_name": tool_name, "success": False},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Task progress update
|
|
255
|
+
if task_id and self.task_manager:
|
|
256
|
+
try:
|
|
257
|
+
await self.task_manager.update_progress(task_id, 60.0, "Execution phase complete")
|
|
258
|
+
yield StreamEvent(
|
|
259
|
+
event_type=StreamEventType.TASK_PROGRESS,
|
|
260
|
+
content=f"Task {task_id} progress updated to 60%",
|
|
261
|
+
metadata={"task_id": task_id, "progress": 60.0},
|
|
262
|
+
)
|
|
263
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
264
|
+
except Exception as err: # noqa: BLE001
|
|
265
|
+
logger.warning(f"TaskManager progress update failed: {err}")
|
|
266
|
+
|
|
267
|
+
token.raise_if_cancelled()
|
|
268
|
+
|
|
269
|
+
# 4. Token-by-Token LLM Response Streaming
|
|
270
|
+
full_response_text = ""
|
|
271
|
+
if self.provider and hasattr(self.provider, "generate_stream") and self.provider.is_configured:
|
|
272
|
+
messages = [{"role": "user", "content": request}]
|
|
273
|
+
async for chunk in self.provider.generate_stream(messages, temperature=0.2):
|
|
274
|
+
token.raise_if_cancelled()
|
|
275
|
+
chunk_text = chunk.content if isinstance(chunk, StreamChunk) else str(chunk)
|
|
276
|
+
full_response_text += chunk_text
|
|
277
|
+
yield StreamEvent(
|
|
278
|
+
event_type=StreamEventType.LLM_TOKEN,
|
|
279
|
+
delta=chunk_text,
|
|
280
|
+
content=full_response_text,
|
|
281
|
+
)
|
|
282
|
+
elif reasoning_res.response_text:
|
|
283
|
+
# Simulated streaming chunk delivery for direct response
|
|
284
|
+
for chunk in self._chunk_text(reasoning_res.response_text, chunk_size=15):
|
|
285
|
+
token.raise_if_cancelled()
|
|
286
|
+
full_response_text += chunk
|
|
287
|
+
yield StreamEvent(
|
|
288
|
+
event_type=StreamEventType.LLM_TOKEN,
|
|
289
|
+
delta=chunk,
|
|
290
|
+
content=full_response_text,
|
|
291
|
+
)
|
|
292
|
+
await asyncio.sleep(0.01)
|
|
293
|
+
|
|
294
|
+
token.raise_if_cancelled()
|
|
295
|
+
|
|
296
|
+
# 5. Verification Phase
|
|
297
|
+
if self.verification_engine:
|
|
298
|
+
yield StreamEvent(
|
|
299
|
+
event_type=StreamEventType.VERIFICATION_START,
|
|
300
|
+
content="Running project verification suite...",
|
|
301
|
+
)
|
|
302
|
+
try:
|
|
303
|
+
verif_res = await self.verification_engine.verify()
|
|
304
|
+
success = getattr(verif_res, "success", True)
|
|
305
|
+
analysis = getattr(verif_res, "analysis", "Verification completed.")
|
|
306
|
+
yield StreamEvent(
|
|
307
|
+
event_type=StreamEventType.VERIFICATION_COMPLETE,
|
|
308
|
+
content=analysis,
|
|
309
|
+
metadata={"success": success},
|
|
310
|
+
)
|
|
311
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
312
|
+
except Exception: # noqa: BLE001
|
|
313
|
+
yield StreamEvent(
|
|
314
|
+
event_type=StreamEventType.VERIFICATION_COMPLETE,
|
|
315
|
+
content="Verification failed with an internal error.",
|
|
316
|
+
metadata={"success": False},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Task completion update
|
|
320
|
+
if task_id and self.task_manager:
|
|
321
|
+
try:
|
|
322
|
+
await self.task_manager.complete_task(task_id, full_response_text or "Stream complete")
|
|
323
|
+
yield StreamEvent(
|
|
324
|
+
event_type=StreamEventType.TASK_PROGRESS,
|
|
325
|
+
content=f"Task {task_id} completed",
|
|
326
|
+
metadata={"task_id": task_id, "progress": 100.0},
|
|
327
|
+
)
|
|
328
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
329
|
+
except Exception: # noqa: BLE001
|
|
330
|
+
logger.warning("TaskManager completion failed.")
|
|
331
|
+
|
|
332
|
+
# Telemetry logging
|
|
333
|
+
duration_ms = (datetime.now(UTC) - start_time).total_seconds() * 1000.0
|
|
334
|
+
self._log_telemetry("stream_completed", duration_ms=duration_ms, task_id=task_id)
|
|
335
|
+
|
|
336
|
+
# 6. Completion Event
|
|
337
|
+
yield StreamEvent(
|
|
338
|
+
event_type=StreamEventType.COMPLETION,
|
|
339
|
+
content=full_response_text or "Streaming execution complete.",
|
|
340
|
+
metadata={"duration_ms": round(duration_ms, 2)},
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
except asyncio.CancelledError as cancel_err:
|
|
344
|
+
reason_msg = self._scrubber.redact(
|
|
345
|
+
str(cancel_err) or token.reason or "Cancelled"
|
|
346
|
+
)
|
|
347
|
+
self._log_telemetry("stream_cancelled", reason=reason_msg, task_id=task_id)
|
|
348
|
+
if task_id and self.task_manager:
|
|
349
|
+
try:
|
|
350
|
+
await self.task_manager.cancel_task(task_id, reason=reason_msg)
|
|
351
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
352
|
+
except Exception: # noqa: BLE001, S110
|
|
353
|
+
pass
|
|
354
|
+
yield StreamEvent(
|
|
355
|
+
event_type=StreamEventType.CANCELLED,
|
|
356
|
+
content=f"Streaming interrupted: {reason_msg}",
|
|
357
|
+
metadata={"reason": reason_msg},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
361
|
+
except Exception: # noqa: BLE001
|
|
362
|
+
logger.error("Streaming execution failed with an internal error.")
|
|
363
|
+
self._log_telemetry("stream_error", error="internal_error", task_id=task_id)
|
|
364
|
+
yield StreamEvent(
|
|
365
|
+
event_type=StreamEventType.ERROR,
|
|
366
|
+
content="Streaming failed with an internal error.",
|
|
367
|
+
metadata={"error": "internal_error"},
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# Internal Helpers
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _chunk_text(text: str, chunk_size: int = 15) -> list[str]:
|
|
376
|
+
"""Split text into uniform character chunks for simulated token streaming."""
|
|
377
|
+
return [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)]
|
|
378
|
+
|
|
379
|
+
def _log_telemetry(self, event_type: str, **kwargs: Any) -> None:
|
|
380
|
+
if self.telemetry and hasattr(self.telemetry, "log_event"):
|
|
381
|
+
try:
|
|
382
|
+
self.telemetry.log_event(event_type=f"streaming_{event_type}", **kwargs)
|
|
383
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
384
|
+
except Exception as err: # noqa: BLE001
|
|
385
|
+
logger.warning(f"Telemetry logging failed: {err}")
|
pulse/subprocesses.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Cross-platform subprocess isolation and cleanup helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_PORTABLE_ENV_ALLOWLIST = frozenset(
|
|
11
|
+
{
|
|
12
|
+
"APPDATA",
|
|
13
|
+
"COMSPEC",
|
|
14
|
+
"HOME",
|
|
15
|
+
"LANG",
|
|
16
|
+
"LC_ALL",
|
|
17
|
+
"LOCALAPPDATA",
|
|
18
|
+
"PATH",
|
|
19
|
+
"PATHEXT",
|
|
20
|
+
"SYSTEMROOT",
|
|
21
|
+
"TEMP",
|
|
22
|
+
"TMP",
|
|
23
|
+
"TMPDIR",
|
|
24
|
+
"USERPROFILE",
|
|
25
|
+
"WINDIR",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def isolated_subprocess_environment(
|
|
31
|
+
extra: dict[str, str] | None = None,
|
|
32
|
+
) -> dict[str, str]:
|
|
33
|
+
"""Return a minimal cross-platform environment with no host credentials."""
|
|
34
|
+
environment = {
|
|
35
|
+
key: value
|
|
36
|
+
for key, value in os.environ.items()
|
|
37
|
+
if key.upper() in _PORTABLE_ENV_ALLOWLIST
|
|
38
|
+
}
|
|
39
|
+
environment["PYTHONNOUSERSITE"] = "1"
|
|
40
|
+
if extra:
|
|
41
|
+
environment.update(extra)
|
|
42
|
+
return environment
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def isolated_process_kwargs() -> dict[str, Any]:
|
|
46
|
+
"""Return platform options that isolate child console control events.
|
|
47
|
+
|
|
48
|
+
Windows children receive a distinct process-group identifier and therefore
|
|
49
|
+
cannot deliver a group-scoped Ctrl+C event to the Pulse or pytest parent.
|
|
50
|
+
POSIX callers use their existing session/process-group policy.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
if os.name != "nt":
|
|
54
|
+
return {}
|
|
55
|
+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def terminate_process(
|
|
59
|
+
process: asyncio.subprocess.Process | None,
|
|
60
|
+
*,
|
|
61
|
+
grace_seconds: float = 1.0,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Terminate one child without generating console control events."""
|
|
64
|
+
|
|
65
|
+
if process is None or process.returncode is not None:
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
process.terminate()
|
|
69
|
+
except ProcessLookupError:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
await asyncio.wait_for(process.wait(), timeout=max(0.1, grace_seconds))
|
|
73
|
+
except TimeoutError:
|
|
74
|
+
if process.returncode is None:
|
|
75
|
+
try:
|
|
76
|
+
process.kill()
|
|
77
|
+
except ProcessLookupError:
|
|
78
|
+
return
|
|
79
|
+
await process.wait()
|