evolvingmachines-evolve 0.0.55.dev1355__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.
evolve/bridge.py ADDED
@@ -0,0 +1,509 @@
1
+ """Node.js bridge subprocess manager for JSON-RPC communication."""
2
+
3
+ import asyncio
4
+ import atexit
5
+ import json
6
+ import logging
7
+ import os
8
+ import signal
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Global registry of bridge process PIDs for atexit cleanup
16
+ # We store PIDs instead of process objects because asyncio.subprocess.Process
17
+ # doesn't have sync wait() - we use os.kill/os.waitpid for cleanup
18
+ _bridge_pids: List[int] = []
19
+
20
+
21
+ def _atexit_cleanup():
22
+ """Kill all bridge processes on Python exit."""
23
+ for pid in _bridge_pids:
24
+ try:
25
+ os.kill(pid, signal.SIGTERM)
26
+ except (OSError, ProcessLookupError):
27
+ pass # Process already dead
28
+ try:
29
+ # Non-blocking wait to reap zombie
30
+ os.waitpid(pid, os.WNOHANG)
31
+ except (OSError, ChildProcessError):
32
+ pass
33
+ _bridge_pids.clear()
34
+
35
+
36
+ atexit.register(_atexit_cleanup)
37
+
38
+
39
+ class SandboxNotFoundError(Exception):
40
+ """Raised when sandbox is not found (expired or killed)."""
41
+ pass
42
+
43
+
44
+ class BridgeConnectionError(Exception):
45
+ """Raised when bridge process fails to start or dies unexpectedly."""
46
+ pass
47
+
48
+
49
+ class BridgeBuildError(Exception):
50
+ """Raised when bridge build (npm install/build) fails."""
51
+ pass
52
+
53
+
54
+ class BridgeManager:
55
+ """Manages Node.js subprocess running the JSON-RPC bridge.
56
+
57
+ Uses asyncio.create_subprocess_exec for native async I/O.
58
+ This allows proper cancellation and clean shutdown without blocking threads.
59
+ """
60
+
61
+ def __init__(self):
62
+ self.process: Optional[asyncio.subprocess.Process] = None
63
+ self.request_id = 0
64
+ self.pending_requests: Dict[int, asyncio.Future] = {}
65
+ # Serialize writes to stdin to avoid interleaved JSON-RPC requests
66
+ self._write_lock = asyncio.Lock()
67
+ # Default RPC timeout to prevent hangs (overridden per-call for long runs)
68
+ self.default_call_timeout_s: float = 120.0
69
+ self.stderr_task: Optional[asyncio.Task] = None
70
+ # Buffers for chunked stdout/stderr events from the bridge.
71
+ # The Node bridge may emit {seq, done} chunks for oversized streams.
72
+ self._stream_buffers: Dict[str, List[str]] = {"stdout": [], "stderr": []}
73
+ self.event_callbacks: Dict[str, List[Callable]] = {
74
+ 'stdout': [], # str data
75
+ 'stderr': [], # str data
76
+ 'content': [], # dict params
77
+ 'lifecycle': [], # dict params
78
+ }
79
+ self.reader_task: Optional[asyncio.Task] = None
80
+ self._pid: Optional[int] = None
81
+
82
+ async def start(self):
83
+ """Start the Node.js bridge process."""
84
+ if self.process is not None:
85
+ return
86
+
87
+ # Find bridge script (bundled version for distribution)
88
+ bridge_dir = Path(__file__).parent.parent / 'bridge'
89
+ bridge_script = bridge_dir / 'dist' / 'bridge.bundle.cjs'
90
+
91
+ # Fallback to unbundled version for development
92
+ if not bridge_script.exists():
93
+ bridge_script = bridge_dir / 'dist' / 'bridge.js'
94
+
95
+ # Auto-build bridge if missing (turnkey experience)
96
+ if not bridge_script.exists():
97
+ await self._build_bridge(bridge_dir)
98
+
99
+ # Start Node.js process with native asyncio subprocess
100
+ self.process = await asyncio.create_subprocess_exec(
101
+ 'node', str(bridge_script),
102
+ stdin=asyncio.subprocess.PIPE,
103
+ stdout=asyncio.subprocess.PIPE,
104
+ stderr=asyncio.subprocess.PIPE,
105
+ )
106
+ self._pid = self.process.pid
107
+
108
+ # Register PID for atexit cleanup
109
+ _bridge_pids.append(self._pid)
110
+
111
+ # Start reading responses (native async - no blocking threads)
112
+ self.reader_task = asyncio.create_task(self._read_responses())
113
+ # Drain stderr to avoid pipe backpressure
114
+ if self.process.stderr is not None:
115
+ self.stderr_task = asyncio.create_task(self._drain_stderr())
116
+
117
+ async def _build_bridge(self, bridge_dir: Path):
118
+ """Build the bridge if missing (first run experience)."""
119
+ import shutil
120
+ import subprocess
121
+
122
+ # Check if Node.js and npm are installed
123
+ if not shutil.which('node'):
124
+ raise BridgeBuildError(
125
+ "Bridge build failed: Node.js not found in PATH.\n"
126
+ "Evolve requires Node.js 18+ to run the TypeScript bridge.\n"
127
+ "Install from https://nodejs.org/ or run 'make build' manually from packages/sdk-py/."
128
+ )
129
+
130
+ if not shutil.which('npm'):
131
+ raise BridgeBuildError(
132
+ "Bridge build failed: npm not found in PATH.\n"
133
+ "npm is usually installed with Node.js - check your Node.js installation.\n"
134
+ "Alternatively, run 'make build' manually from packages/sdk-py/."
135
+ )
136
+
137
+ logger.info("First run: building Node.js bridge...")
138
+ try:
139
+ # Run npm install/build in executor to avoid blocking event loop
140
+ loop = asyncio.get_running_loop()
141
+
142
+ # Install dependencies
143
+ await loop.run_in_executor(
144
+ None,
145
+ lambda: subprocess.run(
146
+ ['npm', 'install'],
147
+ cwd=bridge_dir,
148
+ check=True,
149
+ capture_output=True,
150
+ text=True
151
+ )
152
+ )
153
+
154
+ # Build bridge
155
+ await loop.run_in_executor(
156
+ None,
157
+ lambda: subprocess.run(
158
+ ['npm', 'run', 'build'],
159
+ cwd=bridge_dir,
160
+ check=True,
161
+ capture_output=True,
162
+ text=True
163
+ )
164
+ )
165
+ logger.info("Bridge built successfully")
166
+ except subprocess.CalledProcessError as e:
167
+ raise BridgeBuildError(
168
+ f"Bridge build failed during npm execution.\n"
169
+ f"Error: {e.stderr}\n"
170
+ f"Try running 'make build' manually from packages/sdk-py/ to see the full error."
171
+ ) from e
172
+
173
+ async def stop(self):
174
+ """Stop the Node.js bridge process."""
175
+ if self.process is None:
176
+ return
177
+
178
+ # Best-effort flush of observability events before shutdown.
179
+ # Covers edge cases where kill() RPC failed but bridge is still alive.
180
+ try:
181
+ await self.call('flush_observability', timeout_s=5.0)
182
+ except Exception:
183
+ pass
184
+
185
+ # Note: We intentionally do NOT clear event_callbacks here
186
+ # User-registered callbacks should persist across bridge restarts
187
+
188
+ # Remove from atexit registry
189
+ if self._pid and self._pid in _bridge_pids:
190
+ _bridge_pids.remove(self._pid)
191
+
192
+ # Cancel reader tasks first (they will exit cleanly now that process is terminating)
193
+ if self.reader_task:
194
+ self.reader_task.cancel()
195
+ try:
196
+ await self.reader_task
197
+ except asyncio.CancelledError:
198
+ pass
199
+
200
+ if self.stderr_task:
201
+ self.stderr_task.cancel()
202
+ try:
203
+ await self.stderr_task
204
+ except asyncio.CancelledError:
205
+ pass
206
+ self.stderr_task = None
207
+
208
+ # Terminate process
209
+ try:
210
+ self.process.terminate()
211
+ await asyncio.wait_for(self.process.wait(), timeout=5)
212
+ except asyncio.TimeoutError:
213
+ self.process.kill()
214
+ await self.process.wait()
215
+
216
+ self.process = None
217
+ self.reader_task = None
218
+ self._pid = None
219
+
220
+ def on(self, event_type: str, callback: Callable):
221
+ """Register event callback.
222
+
223
+ Raises:
224
+ ValueError: If event_type is not supported.
225
+ """
226
+ if event_type not in self.event_callbacks:
227
+ supported = ", ".join(sorted(self.event_callbacks))
228
+ raise ValueError(
229
+ f"Unsupported event type '{event_type}'. Supported event types: {supported}"
230
+ )
231
+ self.event_callbacks[event_type].append(callback)
232
+
233
+ async def call(
234
+ self,
235
+ method: str,
236
+ params: Optional[Dict[str, Any]] = None,
237
+ timeout_s: Optional[float] = None,
238
+ ) -> Any:
239
+ """Call a JSON-RPC method and wait for response.
240
+
241
+ Args:
242
+ method: JSON-RPC method name
243
+ params: JSON-RPC params dict
244
+ timeout_s: Optional timeout in seconds. If None, uses default_call_timeout_s.
245
+ """
246
+ if self.process is None or self.process.stdin is None:
247
+ raise BridgeConnectionError("Bridge not started. Call start() first.")
248
+
249
+ async with self._write_lock:
250
+ self.request_id += 1
251
+ request_id = self.request_id
252
+
253
+ request = {
254
+ 'jsonrpc': '2.0',
255
+ 'method': method,
256
+ 'params': params or {},
257
+ 'id': request_id,
258
+ }
259
+
260
+ # Create future for response
261
+ future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
262
+ self.pending_requests[request_id] = future
263
+
264
+ # Send request (native async write)
265
+ payload = json.dumps(request).encode('utf-8')
266
+ frame = len(payload).to_bytes(4, byteorder='big') + payload
267
+ self.process.stdin.write(frame)
268
+ await self.process.stdin.drain()
269
+
270
+ # Wait for response (error handling done in _handle_response)
271
+ timeout = timeout_s if timeout_s is not None else self.default_call_timeout_s
272
+ try:
273
+ return await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
274
+ except asyncio.CancelledError:
275
+ removed = self.pending_requests.pop(request_id, None)
276
+ if removed is not None and not future.done():
277
+ future.cancel()
278
+ raise
279
+ except asyncio.TimeoutError as e:
280
+ # Drop pending request to avoid leaks; late response will be ignored.
281
+ removed = self.pending_requests.pop(request_id, None)
282
+ if removed is not None and not future.done():
283
+ future.cancel()
284
+ raise BridgeConnectionError(
285
+ f"Bridge call timed out after {timeout:.1f}s: {method}"
286
+ ) from e
287
+
288
+ async def _read_responses(self):
289
+ """Read framed JSON-RPC messages from bridge stdout.
290
+
291
+ Uses native asyncio reads - fully cancellable, no blocking threads.
292
+ """
293
+ if self.process is None or self.process.stdout is None:
294
+ return
295
+
296
+ # 50MB cap on incoming frames. This applies to ALL bridge responses including
297
+ # RPC results (run().stdout, get_output_files(), etc.). If a response exceeds
298
+ # 50MB, the bridge connection fails. This is stricter than the TS SDK which
299
+ # has no response size limit. For very large outputs, consider streaming via
300
+ # stdout/stderr events or fetching files individually.
301
+ max_frame_bytes = 50 * 1024 * 1024
302
+
303
+ try:
304
+ async def read_exact(n: int) -> Optional[bytes]:
305
+ """Read exactly n bytes from stdout."""
306
+ chunks: List[bytes] = []
307
+ remaining = n
308
+ while remaining > 0:
309
+ chunk = await self.process.stdout.read(remaining)
310
+ if not chunk:
311
+ return None
312
+ chunks.append(chunk)
313
+ remaining -= len(chunk)
314
+ return b"".join(chunks)
315
+
316
+ while True:
317
+ header = await read_exact(4)
318
+ if not header:
319
+ break
320
+ length = int.from_bytes(header, byteorder='big')
321
+ if length <= 0 or length > max_frame_bytes:
322
+ logger.error(f"Invalid frame length from bridge: {length}")
323
+ break
324
+
325
+ payload = await read_exact(length)
326
+ if payload is None:
327
+ break
328
+
329
+ try:
330
+ text = payload.decode('utf-8')
331
+ message = json.loads(text)
332
+ except Exception:
333
+ logger.exception("Failed to parse bridge frame")
334
+ continue
335
+
336
+ if isinstance(message, dict) and message.get('method') == 'event':
337
+ self._handle_event(message.get('params') or {})
338
+ elif isinstance(message, dict) and 'id' in message:
339
+ self._handle_response(message)
340
+
341
+ except asyncio.CancelledError:
342
+ # Clean cancellation - expected during stop()
343
+ raise
344
+ except Exception as e:
345
+ logger.error(f"Bridge reader died: {e}")
346
+ finally:
347
+ # Fail all pending requests so callers don't hang
348
+ error = BridgeConnectionError("Bridge process terminated unexpectedly")
349
+ for request_id, future in list(self.pending_requests.items()):
350
+ if not future.done():
351
+ future.set_exception(error)
352
+ self.pending_requests.clear()
353
+
354
+ async def _drain_stderr(self):
355
+ """Drain bridge stderr to prevent blocking.
356
+
357
+ Uses native asyncio reads - fully cancellable.
358
+ """
359
+ if self.process is None or self.process.stderr is None:
360
+ return
361
+ try:
362
+ while True:
363
+ line = await self.process.stderr.readline()
364
+ if not line:
365
+ break
366
+ try:
367
+ text = line.decode("utf-8", errors="ignore").rstrip()
368
+ except Exception:
369
+ text = str(line).rstrip()
370
+ if text:
371
+ logger.debug(f"[bridge stderr] {text}")
372
+ except asyncio.CancelledError:
373
+ pass
374
+ except Exception as e:
375
+ logger.debug(f"Bridge stderr drain died: {e}")
376
+
377
+ def _handle_event(self, params: Dict[str, Any]):
378
+ """Handle event notification from bridge."""
379
+ event_type = params.get('type')
380
+ callbacks = self.event_callbacks.get(event_type, [])
381
+
382
+ if event_type in ('stdout', 'stderr'):
383
+ data = params.get('data', '')
384
+ seq = params.get('seq')
385
+ done = params.get('done')
386
+
387
+ # If chunk metadata is present, reassemble to preserve "NDJSON line" semantics.
388
+ if seq is not None or done is not None:
389
+ buf = self._stream_buffers.setdefault(event_type, [])
390
+ if seq == 0 and buf:
391
+ # Best-effort flush of previous incomplete sequence.
392
+ prev = "".join(buf)
393
+ buf.clear()
394
+ for callback in callbacks:
395
+ try:
396
+ callback(prev)
397
+ except Exception:
398
+ logger.exception("Error in %s callback", event_type)
399
+
400
+ buf.append(data)
401
+ if done:
402
+ full = "".join(buf)
403
+ buf.clear()
404
+ for callback in callbacks:
405
+ try:
406
+ callback(full)
407
+ except Exception:
408
+ logger.exception("Error in %s callback", event_type)
409
+ return
410
+
411
+ # No chunk metadata → emit directly.
412
+ for callback in callbacks:
413
+ try:
414
+ callback(data)
415
+ except Exception:
416
+ logger.exception("Error in %s callback", event_type)
417
+ elif event_type in ('content', 'lifecycle'):
418
+ for callback in callbacks:
419
+ try:
420
+ callback(params)
421
+ except Exception:
422
+ logger.exception("Error in %s callback", event_type)
423
+
424
+ def _handle_response(self, message: Dict[str, Any]):
425
+ """Handle JSON-RPC response."""
426
+ request_id = message.get('id')
427
+ if request_id is None or request_id not in self.pending_requests:
428
+ return
429
+
430
+ future = self.pending_requests.pop(request_id)
431
+ if future.done():
432
+ return
433
+
434
+ if 'error' in message:
435
+ error = message['error']
436
+ error_code = error.get('code', -32603)
437
+ error_message = error.get('message', 'Unknown error')
438
+
439
+ # Check for NotFoundError (code -32001 or message pattern)
440
+ if error_code == -32001 or 'not found' in error_message.lower():
441
+ try:
442
+ future.set_exception(SandboxNotFoundError(error_message))
443
+ except asyncio.InvalidStateError:
444
+ pass
445
+ else:
446
+ try:
447
+ future.set_exception(Exception(error_message))
448
+ except asyncio.InvalidStateError:
449
+ pass
450
+ else:
451
+ try:
452
+ future.set_result(message.get('result'))
453
+ except asyncio.InvalidStateError:
454
+ pass
455
+
456
+ # =========================================================================
457
+ # MULTI-INSTANCE METHODS (for Swarm)
458
+ # =========================================================================
459
+
460
+ async def create_instance(
461
+ self,
462
+ instance_id: str,
463
+ params: Dict[str, Any],
464
+ timeout_s: Optional[float] = None,
465
+ ) -> Any:
466
+ """Create a new Evolve instance in the bridge."""
467
+ return await self.call(
468
+ 'create_instance',
469
+ {'instance_id': instance_id, **params},
470
+ timeout_s=timeout_s,
471
+ )
472
+
473
+ async def run_on_instance(
474
+ self,
475
+ instance_id: str,
476
+ prompt: str,
477
+ timeout_ms: Optional[int] = None,
478
+ call_timeout_s: Optional[float] = None,
479
+ ) -> Any:
480
+ """Run prompt on a specific Evolve instance."""
481
+ params = {'instance_id': instance_id, 'prompt': prompt}
482
+ if timeout_ms is not None:
483
+ params['timeout_ms'] = timeout_ms
484
+ return await self.call('run_on_instance', params, timeout_s=call_timeout_s)
485
+
486
+ async def get_output_on_instance(
487
+ self,
488
+ instance_id: str,
489
+ recursive: bool = False,
490
+ timeout_s: Optional[float] = None,
491
+ ) -> Any:
492
+ """Get output files from a specific Evolve instance."""
493
+ return await self.call(
494
+ 'get_output_on_instance',
495
+ {'instance_id': instance_id, 'recursive': recursive},
496
+ timeout_s=timeout_s,
497
+ )
498
+
499
+ async def kill_instance(
500
+ self,
501
+ instance_id: str,
502
+ timeout_s: Optional[float] = None,
503
+ ) -> Any:
504
+ """Kill and remove a specific Evolve instance."""
505
+ return await self.call(
506
+ 'kill_instance',
507
+ {'instance_id': instance_id},
508
+ timeout_s=timeout_s,
509
+ )