toolplane-python-client 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 (81) hide show
  1. toolplane/__init__.py +106 -0
  2. toolplane/common/__init__.py +93 -0
  3. toolplane/common/base_config.py +129 -0
  4. toolplane/common/base_connection_manager.py +171 -0
  5. toolplane/common/base_session_manager.py +321 -0
  6. toolplane/common/base_tool_manager.py +347 -0
  7. toolplane/common/constants.py +47 -0
  8. toolplane/common/utils.py +310 -0
  9. toolplane/core/__init__.py +67 -0
  10. toolplane/core/config.py +107 -0
  11. toolplane/core/connection.py +285 -0
  12. toolplane/core/errors.py +298 -0
  13. toolplane/core/machine.py +480 -0
  14. toolplane/core/request.py +775 -0
  15. toolplane/core/session.py +332 -0
  16. toolplane/core/session_context.py +514 -0
  17. toolplane/core/task.py +130 -0
  18. toolplane/core/tool.py +329 -0
  19. toolplane/http_core/__init__.py +37 -0
  20. toolplane/http_core/http_config.py +97 -0
  21. toolplane/http_core/http_connection.py +409 -0
  22. toolplane/http_core/http_machine.py +298 -0
  23. toolplane/http_core/http_request.py +748 -0
  24. toolplane/http_core/http_session.py +348 -0
  25. toolplane/http_core/http_session_context.py +491 -0
  26. toolplane/http_core/http_task.py +101 -0
  27. toolplane/http_core/http_tool.py +400 -0
  28. toolplane/interfaces/__init__.py +27 -0
  29. toolplane/interfaces/client_interface.py +122 -0
  30. toolplane/interfaces/connection_interface.py +193 -0
  31. toolplane/interfaces/event_interface.py +290 -0
  32. toolplane/interfaces/request_interface.py +439 -0
  33. toolplane/interfaces/session_interface.py +288 -0
  34. toolplane/interfaces/tool_interface.py +441 -0
  35. toolplane/proto/__init__.py +0 -0
  36. toolplane/proto/service_pb2.py +315 -0
  37. toolplane/proto/service_pb2_grpc.py +2240 -0
  38. toolplane/provider_cli.py +268 -0
  39. toolplane/provider_registry.py +77 -0
  40. toolplane/provider_runtime.py +302 -0
  41. toolplane/toolkits/__init__.py +0 -0
  42. toolplane/toolkits/standalone_tools/__init__.py +0 -0
  43. toolplane/toolkits/standalone_tools/create_directory.py +94 -0
  44. toolplane/toolkits/standalone_tools/create_file.py +124 -0
  45. toolplane/toolkits/standalone_tools/file_search.py +229 -0
  46. toolplane/toolkits/standalone_tools/grep_search.py +372 -0
  47. toolplane/toolkits/standalone_tools/launcher.py +146 -0
  48. toolplane/toolkits/standalone_tools/list_dir.py +395 -0
  49. toolplane/toolkits/standalone_tools/read_file.py +346 -0
  50. toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
  51. toolplane/toolkits/standalone_tools/run_tests.py +66 -0
  52. toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
  53. toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
  54. toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
  55. toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
  56. toolplane/toolkits/swe/__init__.py +35 -0
  57. toolplane/toolkits/swe/create_directory.py +15 -0
  58. toolplane/toolkits/swe/create_file.py +15 -0
  59. toolplane/toolkits/swe/descriptions.py +273 -0
  60. toolplane/toolkits/swe/execute_bash.py +93 -0
  61. toolplane/toolkits/swe/file_editor.py +775 -0
  62. toolplane/toolkits/swe/file_search.py +16 -0
  63. toolplane/toolkits/swe/finish.py +50 -0
  64. toolplane/toolkits/swe/grep_search.py +19 -0
  65. toolplane/toolkits/swe/list_dir.py +407 -0
  66. toolplane/toolkits/swe/read_file.py +18 -0
  67. toolplane/toolkits/swe/replace_string_in_file.py +17 -0
  68. toolplane/toolkits/swe/search.py +260 -0
  69. toolplane/toolkits/swe/semantic_search.py +20 -0
  70. toolplane/toolkits/swe/str_replace_editor.py +647 -0
  71. toolplane/toolkits/swe/submit.py +29 -0
  72. toolplane/toolkits/swe/swe_toolkit.py +1296 -0
  73. toolplane/toolplane_client.py +686 -0
  74. toolplane/toolplane_http_client.py +681 -0
  75. toolplane/utils/__init__.py +3 -0
  76. toolplane/utils/schema.py +146 -0
  77. toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
  78. toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
  79. toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
  80. toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
  81. toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,480 @@
1
+ """Machine management for Toolplane client."""
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import grpc
9
+
10
+ from toolplane.interfaces.event_interface import Event, EventType
11
+ from toolplane.proto.service_pb2 import (
12
+ DrainMachineRequest,
13
+ GetMachineRequest,
14
+ ListMachinesRequest,
15
+ RegisterMachineRequest,
16
+ UnregisterMachineRequest,
17
+ UpdateMachinePingRequest,
18
+ )
19
+
20
+ from ..common.utils import timestamp_to_iso
21
+ from .connection import ConnectionManager
22
+ from .errors import MachineError, api_error_from_rpc_error
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class MachineManager:
28
+ """Manages machine registration and heartbeats."""
29
+
30
+ def __init__(
31
+ self,
32
+ connection_manager: ConnectionManager,
33
+ event_emitter: Optional[Any] = None,
34
+ tool_manager=None,
35
+ session_manager=None,
36
+ ):
37
+ """Initialize machine manager."""
38
+ self.connection_manager = connection_manager
39
+ self.machines: Dict[str, str] = {} # session_id -> machine_id
40
+ self.machines_lock = threading.RLock()
41
+ self._last_heartbeat: Dict[str, float] = {}
42
+ self._heartbeat_thread: Optional[threading.Thread] = None
43
+ self._running = False
44
+ self._tool_manager = tool_manager
45
+ self._session_manager = session_manager
46
+ self._last_recovery_attempt: Dict[str, float] = {}
47
+ self._event_emitter = event_emitter
48
+
49
+ def _emit_event(self, event_type: EventType, data: Dict[str, Any]) -> None:
50
+ if not self._event_emitter:
51
+ return
52
+ try:
53
+ self._event_emitter.emit(
54
+ Event(type=event_type, source="machine_manager", data=data)
55
+ )
56
+ except Exception:
57
+ # Event emission should not break core flows
58
+ pass
59
+
60
+ def attach_tool_manager(self, tool_manager):
61
+ """Attach tool manager to support re-registration after recovery."""
62
+ self._tool_manager = tool_manager
63
+
64
+ def attach_session_manager(self, session_manager):
65
+ """Attach session manager to update session context on recovery."""
66
+ self._session_manager = session_manager
67
+
68
+ def _normalize_machine(self, machine: Any) -> Dict[str, Any]:
69
+ return {
70
+ "id": machine.id,
71
+ "session_id": machine.session_id,
72
+ "sdk_version": machine.sdk_version,
73
+ "sdk_language": machine.sdk_language,
74
+ "ip": machine.ip,
75
+ "created_at": timestamp_to_iso(machine.created_at),
76
+ "last_ping_at": timestamp_to_iso(getattr(machine, "last_ping_at", None)),
77
+ }
78
+
79
+ def _clear_local_machine(self, session_id: str, machine_id: str) -> None:
80
+ with self.machines_lock:
81
+ if self.machines.get(session_id) == machine_id:
82
+ self.machines.pop(session_id, None)
83
+ self._last_heartbeat.pop(session_id, None)
84
+
85
+ def list_machines(self, session_id: str) -> List[Dict[str, Any]]:
86
+ """List machines for a session."""
87
+ self.connection_manager.ensure_connected()
88
+
89
+ try:
90
+ request = ListMachinesRequest(session_id=session_id)
91
+ response = self.connection_manager.machine_stub.ListMachines(
92
+ request, metadata=self.connection_manager.get_metadata()
93
+ )
94
+ return [self._normalize_machine(machine) for machine in response.machines]
95
+ except grpc.RpcError as rpc_error:
96
+ self._handle_rpc_error(rpc_error)
97
+ raise api_error_from_rpc_error(
98
+ rpc_error, context=f"Failed to list machines for session {session_id}"
99
+ ) from rpc_error
100
+ except Exception as e:
101
+ raise MachineError(f"Failed to list machines for session {session_id}: {e}")
102
+
103
+ def get_machine(self, session_id: str, machine_id: str) -> Dict[str, Any]:
104
+ """Get a specific machine by ID."""
105
+ self.connection_manager.ensure_connected()
106
+
107
+ try:
108
+ request = GetMachineRequest(session_id=session_id, machine_id=machine_id)
109
+ response = self.connection_manager.machine_stub.GetMachine(
110
+ request, metadata=self.connection_manager.get_metadata()
111
+ )
112
+ return self._normalize_machine(response)
113
+ except grpc.RpcError as rpc_error:
114
+ self._handle_rpc_error(rpc_error)
115
+ raise api_error_from_rpc_error(
116
+ rpc_error,
117
+ context=f"Failed to get machine {machine_id} for session {session_id}",
118
+ ) from rpc_error
119
+ except Exception as e:
120
+ raise MachineError(
121
+ f"Failed to get machine {machine_id} for session {session_id}: {e}"
122
+ )
123
+
124
+ def register_machine(self, session_id: str) -> str:
125
+ """Register a machine for a session."""
126
+ self.connection_manager.ensure_connected()
127
+
128
+ self._emit_event(
129
+ EventType.MACHINE_REGISTERED,
130
+ {
131
+ "session_id": session_id,
132
+ "phase": "start",
133
+ },
134
+ )
135
+
136
+ try:
137
+ request = RegisterMachineRequest(
138
+ session_id=session_id,
139
+ machine_id="", # Server generates ID
140
+ sdk_version="1.0.0",
141
+ sdk_language="python",
142
+ )
143
+
144
+ response = self.connection_manager.machine_stub.RegisterMachine(
145
+ request, metadata=self.connection_manager.get_metadata()
146
+ )
147
+
148
+ machine_id = response.id
149
+
150
+ # The registration response carries the per-machine credential
151
+ # exactly once. Store it so every subsequent provide-scoped call
152
+ # presents it (see ConnectionManager.get_metadata).
153
+ machine_token = getattr(response, "machine_token", "")
154
+ if machine_token:
155
+ self.connection_manager.set_machine_token(machine_token)
156
+
157
+ with self.machines_lock:
158
+ self.machines[session_id] = machine_id
159
+ self._last_heartbeat[session_id] = time.time()
160
+
161
+ self._emit_event(
162
+ EventType.MACHINE_REGISTERED,
163
+ {
164
+ "session_id": session_id,
165
+ "machine_id": machine_id,
166
+ "phase": "success",
167
+ },
168
+ )
169
+
170
+ return machine_id
171
+
172
+ except grpc.RpcError as rpc_error:
173
+ self._emit_event(
174
+ EventType.MACHINE_REGISTERED,
175
+ {
176
+ "session_id": session_id,
177
+ "phase": "error",
178
+ "error": str(rpc_error),
179
+ },
180
+ )
181
+ self._handle_rpc_error(rpc_error)
182
+ raise api_error_from_rpc_error(
183
+ rpc_error,
184
+ context=f"Failed to register machine for session {session_id}",
185
+ ) from rpc_error
186
+ except Exception as e:
187
+ self._emit_event(
188
+ EventType.MACHINE_REGISTERED,
189
+ {
190
+ "session_id": session_id,
191
+ "phase": "error",
192
+ "error": str(e),
193
+ },
194
+ )
195
+ raise MachineError(
196
+ f"Failed to register machine for session {session_id}: {e}"
197
+ )
198
+
199
+ def unregister_machine(
200
+ self,
201
+ session_id: str,
202
+ machine_id: Optional[str] = None,
203
+ reason: str = "explicit",
204
+ ) -> bool:
205
+ """Unregister a machine for a session."""
206
+ machine_id = machine_id or self.get_machine_id(session_id)
207
+ if not machine_id:
208
+ return True
209
+
210
+ try:
211
+ self._emit_event(
212
+ EventType.MACHINE_UNREGISTERED,
213
+ {
214
+ "session_id": session_id,
215
+ "machine_id": machine_id,
216
+ "phase": "start",
217
+ "reason": reason,
218
+ },
219
+ )
220
+
221
+ # Drain machine first
222
+ self.drain_machine(session_id, machine_id)
223
+
224
+ # Unregister machine
225
+ request = UnregisterMachineRequest(
226
+ session_id=session_id,
227
+ machine_id=machine_id,
228
+ )
229
+
230
+ self.connection_manager.machine_stub.UnregisterMachine(
231
+ request, metadata=self.connection_manager.get_metadata()
232
+ )
233
+
234
+ self._clear_local_machine(session_id, machine_id)
235
+
236
+ self._emit_event(
237
+ EventType.MACHINE_UNREGISTERED,
238
+ {
239
+ "session_id": session_id,
240
+ "machine_id": machine_id,
241
+ "phase": "success",
242
+ "reason": reason,
243
+ },
244
+ )
245
+
246
+ return True
247
+
248
+ except grpc.RpcError as rpc_error:
249
+ self._emit_event(
250
+ EventType.MACHINE_UNREGISTERED,
251
+ {
252
+ "session_id": session_id,
253
+ "machine_id": machine_id,
254
+ "phase": "error",
255
+ "reason": reason,
256
+ "error": str(rpc_error),
257
+ },
258
+ )
259
+ self._handle_rpc_error(rpc_error)
260
+ raise api_error_from_rpc_error(
261
+ rpc_error,
262
+ context=f"Failed to unregister machine for session {session_id}",
263
+ ) from rpc_error
264
+ except Exception as e:
265
+ self._emit_event(
266
+ EventType.MACHINE_UNREGISTERED,
267
+ {
268
+ "session_id": session_id,
269
+ "machine_id": machine_id,
270
+ "phase": "error",
271
+ "reason": reason,
272
+ "error": str(e),
273
+ },
274
+ )
275
+ raise MachineError(
276
+ f"Failed to unregister machine for session {session_id}: {e}"
277
+ )
278
+
279
+ def drain_machine(self, session_id: str, machine_id: Optional[str] = None) -> bool:
280
+ """Drain a machine for a session."""
281
+ machine_id = machine_id or self.get_machine_id(session_id)
282
+ if not machine_id:
283
+ return True
284
+
285
+ try:
286
+ request = DrainMachineRequest(
287
+ session_id=session_id,
288
+ machine_id=machine_id,
289
+ )
290
+
291
+ self.connection_manager.machine_stub.DrainMachine(
292
+ request, metadata=self.connection_manager.get_metadata()
293
+ )
294
+
295
+ self._clear_local_machine(session_id, machine_id)
296
+
297
+ return True
298
+
299
+ except grpc.RpcError as rpc_error:
300
+ self._handle_rpc_error(rpc_error)
301
+ raise api_error_from_rpc_error(
302
+ rpc_error, context=f"Failed to drain machine for session {session_id}"
303
+ ) from rpc_error
304
+ except Exception as e:
305
+ raise MachineError(f"Failed to drain machine for session {session_id}: {e}")
306
+
307
+ def get_machine_id(self, session_id: str) -> Optional[str]:
308
+ """Get machine ID for a session."""
309
+ with self.machines_lock:
310
+ return self.machines.get(session_id)
311
+
312
+ def start_heartbeat(self, heartbeat_interval: int = 60):
313
+ """Start heartbeat thread."""
314
+ if self._running:
315
+ return
316
+
317
+ self._running = True
318
+ self._heartbeat_thread = threading.Thread(
319
+ target=self._heartbeat_loop, args=(heartbeat_interval,), daemon=True
320
+ )
321
+ self._heartbeat_thread.start()
322
+
323
+ def stop_heartbeat(self):
324
+ """Stop heartbeat thread."""
325
+ self._running = False
326
+ if self._heartbeat_thread:
327
+ self._heartbeat_thread.join(timeout=1)
328
+
329
+ def _heartbeat_loop(self, interval: int):
330
+ """Heartbeat loop for all machines."""
331
+ while self._running:
332
+ sessions_to_recover: List[str] = []
333
+
334
+ try:
335
+ self.connection_manager.ensure_connected()
336
+ except Exception:
337
+ time.sleep(min(interval, 5))
338
+ continue
339
+
340
+ now = time.time()
341
+
342
+ with self.machines_lock:
343
+ due_machines = [
344
+ (session_id, machine_id)
345
+ for session_id, machine_id in self.machines.items()
346
+ if now - self._last_heartbeat.get(session_id, 0) >= interval
347
+ ]
348
+
349
+ for session_id, machine_id in due_machines:
350
+ try:
351
+ request = UpdateMachinePingRequest(
352
+ session_id=session_id, machine_id=machine_id
353
+ )
354
+
355
+ self.connection_manager.machine_stub.UpdateMachinePing(
356
+ request, metadata=self.connection_manager.get_metadata()
357
+ )
358
+
359
+ with self.machines_lock:
360
+ self._last_heartbeat[session_id] = now
361
+
362
+ except grpc.RpcError as rpc_error:
363
+ code = rpc_error.code()
364
+ if code in (
365
+ grpc.StatusCode.NOT_FOUND,
366
+ grpc.StatusCode.FAILED_PRECONDITION,
367
+ ):
368
+ self._emit_event(
369
+ EventType.MACHINE_HEARTBEAT_FAILED,
370
+ {
371
+ "session_id": session_id,
372
+ "machine_id": machine_id,
373
+ "status_code": code.name,
374
+ "error": str(rpc_error),
375
+ },
376
+ )
377
+ last_attempt = self._last_recovery_attempt.get(session_id, 0)
378
+ if now - last_attempt >= interval:
379
+ self._last_recovery_attempt[session_id] = now
380
+ sessions_to_recover.append(session_id)
381
+ with self.machines_lock:
382
+ self._last_heartbeat[session_id] = now
383
+ elif code == grpc.StatusCode.UNAVAILABLE:
384
+ self.connection_manager.mark_unhealthy()
385
+ with self.machines_lock:
386
+ self._last_heartbeat[session_id] = now
387
+ else:
388
+ logger.warning(
389
+ "Heartbeat error for session %s: %s", session_id, rpc_error
390
+ )
391
+ with self.machines_lock:
392
+ self._last_heartbeat[session_id] = now
393
+
394
+ except Exception as exc:
395
+ logger.warning(
396
+ "Unexpected heartbeat error for session %s: %s",
397
+ session_id,
398
+ exc,
399
+ )
400
+ with self.machines_lock:
401
+ self._last_heartbeat[session_id] = now
402
+
403
+ for session_id in sessions_to_recover:
404
+ self._recover_session(session_id)
405
+
406
+ time.sleep(5) # Check frequently, but send only per interval
407
+
408
+ def cleanup_all(self):
409
+ """Cleanup all machines."""
410
+ self.stop_heartbeat()
411
+
412
+ with self.machines_lock:
413
+ self._last_recovery_attempt.clear()
414
+ for session_id in list(self.machines.keys()):
415
+ try:
416
+ self.unregister_machine(session_id, reason="cleanup_all")
417
+ except Exception:
418
+ # Ignore cleanup errors
419
+ pass
420
+
421
+ def _handle_rpc_error(self, rpc_error: grpc.RpcError):
422
+ """Handle channel-level failures by resetting the connection.
423
+
424
+ Only UNAVAILABLE indicates the channel itself is broken. Credential
425
+ and state errors are deterministic per-call outcomes; recycling the
426
+ channel (which triggers machine re-registration) cannot fix them.
427
+ """
428
+ if rpc_error.code() == grpc.StatusCode.UNAVAILABLE:
429
+ self.connection_manager.mark_unhealthy()
430
+
431
+ def _recover_session(self, session_id: str):
432
+ """Attempt to recover machine and tool registrations for a session."""
433
+ try:
434
+ previous_machine_id = self.get_machine_id(session_id)
435
+ self._emit_event(
436
+ EventType.MACHINE_RECOVERY_STARTED,
437
+ {
438
+ "session_id": session_id,
439
+ "previous_machine_id": previous_machine_id,
440
+ },
441
+ )
442
+
443
+ new_machine_id = self.register_machine(session_id)
444
+
445
+ if self._tool_manager:
446
+ self._tool_manager.reregister_session_tools(session_id, new_machine_id)
447
+
448
+ if self._session_manager:
449
+ context = self._session_manager.get_session_context(session_id)
450
+ if context:
451
+ context.machine_id = new_machine_id
452
+
453
+ with self.machines_lock:
454
+ self._last_recovery_attempt.pop(session_id, None)
455
+
456
+ logger.info(
457
+ "Recovered machine for session %s with new machine ID %s",
458
+ session_id,
459
+ new_machine_id,
460
+ )
461
+
462
+ self._emit_event(
463
+ EventType.MACHINE_RECOVERY_SUCCEEDED,
464
+ {
465
+ "session_id": session_id,
466
+ "machine_id": new_machine_id,
467
+ },
468
+ )
469
+
470
+ except Exception as exc:
471
+ self._emit_event(
472
+ EventType.MACHINE_RECOVERY_FAILED,
473
+ {
474
+ "session_id": session_id,
475
+ "error": str(exc),
476
+ },
477
+ )
478
+ logger.warning(
479
+ "Failed to recover machine for session %s: %s", session_id, exc
480
+ )