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,775 @@
1
+ """Request management for Toolplane client."""
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from typing import Any, Callable, Dict, List, Optional
9
+
10
+ import grpc
11
+
12
+ from toolplane.proto.service_pb2 import (
13
+ AppendRequestChunksRequest,
14
+ CancelRequestRequest,
15
+ ClaimNextRequestRequest,
16
+ CreateRequestRequest,
17
+ GetRequestChunksRequest,
18
+ GetRequestRequest,
19
+ ListRequestsRequest,
20
+ RenewRequestLeaseRequest,
21
+ RequestStatus,
22
+ ResumeStreamRequest,
23
+ SubmitRequestResultRequest,
24
+ UpdateRequestRequest,
25
+ )
26
+
27
+ from ..common.utils import proto_enum_name, timestamp_to_iso
28
+ from .connection import ConnectionManager
29
+ from .errors import (
30
+ RequestError,
31
+ api_error_from_rpc_error,
32
+ normalize_status_name,
33
+ status_for_wire,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # The server grants a 30s lease TTL by default; renewing at one third of that
39
+ # keeps a healthy executor comfortably ahead of the reaper.
40
+ LEASE_RENEWAL_INTERVAL_SECONDS = 10.0
41
+
42
+
43
+ class RequestManager:
44
+ """Manages request processing and polling."""
45
+
46
+ def __init__(self, connection_manager: ConnectionManager, max_workers: int = 10):
47
+ """Initialize request manager."""
48
+ self.connection_manager = connection_manager
49
+ self.executor = ThreadPoolExecutor(max_workers=max_workers)
50
+ self._running = False
51
+ self._poll_thread: Optional[threading.Thread] = None
52
+ self._poll_interval = 1.0
53
+ # In-flight lease registry: request_id -> lease grant metadata used by
54
+ # the renewal loop and by fenced provider writes.
55
+ self._active_leases: Dict[str, Dict[str, Any]] = {}
56
+ self._leases_lock = threading.Lock()
57
+ self._renewal_running = False
58
+ self._renewal_thread: Optional[threading.Thread] = None
59
+ self._renewal_interval = LEASE_RENEWAL_INTERVAL_SECONDS
60
+
61
+ def _normalize_request(self, request: Any) -> Dict[str, Any]:
62
+ normalized = {
63
+ "id": request.id,
64
+ "sessionId": request.session_id,
65
+ "toolName": request.tool_name,
66
+ "status": normalize_status_name(
67
+ proto_enum_name(request.status, RequestStatus)
68
+ ),
69
+ "input": request.input,
70
+ "createdAt": timestamp_to_iso(request.created_at),
71
+ "updatedAt": timestamp_to_iso(request.updated_at),
72
+ "executingMachineId": request.executing_machine_id,
73
+ "leasedBy": request.leased_by,
74
+ "leaseEpoch": request.lease_epoch,
75
+ "leaseExpiresAt": timestamp_to_iso(request.lease_expires_at),
76
+ "timeoutSeconds": request.timeout_seconds,
77
+ }
78
+
79
+ if request.result:
80
+ try:
81
+ normalized["result"] = json.loads(request.result)
82
+ except Exception:
83
+ normalized["result"] = request.result
84
+
85
+ if request.result_type:
86
+ normalized["resultType"] = request.result_type
87
+
88
+ if request.error:
89
+ normalized["error"] = request.error
90
+
91
+ # stream_results moved to the request_chunks table in v1; chunk
92
+ # windows are fetched via GetRequestChunks/getRequestChunksWindow.
93
+
94
+ return normalized
95
+
96
+ def start_polling(self, poll_interval: float = 1.0):
97
+ """Start request polling."""
98
+ if self._running:
99
+ return
100
+
101
+ self._running = True
102
+ self._poll_interval = poll_interval
103
+ self._poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
104
+ self._poll_thread.start()
105
+
106
+ def stop_polling(self):
107
+ """Stop request polling."""
108
+ self._running = False
109
+ if self._poll_thread:
110
+ self._poll_thread.join(timeout=1)
111
+
112
+ def _poll_loop(self):
113
+ """Main polling loop."""
114
+ while self._running:
115
+ try:
116
+ # This will be called by the main client with session info
117
+ time.sleep(self._poll_interval)
118
+ except Exception:
119
+ # Ignore polling errors
120
+ pass
121
+
122
+ # ---------------- Lease bookkeeping ----------------
123
+
124
+ def register_active_lease(
125
+ self, session_id: str, request_id: str, machine_id: str, lease_epoch: int
126
+ ):
127
+ """Track an in-flight lease so the renewal loop can keep it alive."""
128
+ with self._leases_lock:
129
+ self._active_leases[request_id] = {
130
+ "session_id": session_id,
131
+ "machine_id": machine_id,
132
+ "lease_epoch": lease_epoch,
133
+ }
134
+
135
+ def release_active_lease(self, request_id: str):
136
+ """Stop tracking a lease once its execution finished or the lease was lost."""
137
+ with self._leases_lock:
138
+ self._active_leases.pop(request_id, None)
139
+
140
+ def start_lease_renewal(self, interval: float = LEASE_RENEWAL_INTERVAL_SECONDS):
141
+ """Start the background lease renewal loop."""
142
+ if self._renewal_running:
143
+ return
144
+ self._renewal_running = True
145
+ self._renewal_interval = interval
146
+ self._renewal_thread = threading.Thread(
147
+ target=self._lease_renewal_loop, daemon=True
148
+ )
149
+ self._renewal_thread.start()
150
+
151
+ def stop_lease_renewal(self):
152
+ """Stop the background lease renewal loop."""
153
+ self._renewal_running = False
154
+ if self._renewal_thread:
155
+ self._renewal_thread.join(timeout=1)
156
+
157
+ def _lease_renewal_loop(self):
158
+ """Renew every tracked lease that is due."""
159
+ while self._renewal_running:
160
+ time.sleep(self._renewal_interval)
161
+ if not self._renewal_running:
162
+ return
163
+ with self._leases_lock:
164
+ leases = dict(self._active_leases)
165
+ for request_id, lease in leases.items():
166
+ if not self._renewal_running:
167
+ return
168
+ try:
169
+ self.renew_request_lease(
170
+ lease["session_id"],
171
+ request_id,
172
+ lease["machine_id"],
173
+ lease["lease_epoch"],
174
+ )
175
+ except grpc.RpcError as rpc_error:
176
+ if rpc_error.code() == grpc.StatusCode.FAILED_PRECONDITION:
177
+ # The lease was reclaimed or expired: stop renewing and
178
+ # let the fenced writes surface the loss.
179
+ logger.warning(
180
+ "Lease for request %s was lost (%s); stopping renewal.",
181
+ request_id,
182
+ rpc_error.code().name,
183
+ )
184
+ self.release_active_lease(request_id)
185
+ else:
186
+ # Transient transport error: keep the lease tracked and
187
+ # retry on the next pass.
188
+ logger.warning(
189
+ "Lease renewal failed for request %s: %s",
190
+ request_id,
191
+ rpc_error,
192
+ )
193
+ except Exception as e:
194
+ logger.warning(
195
+ "Lease renewal failed for request %s: %s", request_id, e
196
+ )
197
+
198
+ def renew_request_lease(
199
+ self, session_id: str, request_id: str, machine_id: str, lease_epoch: int
200
+ ) -> Dict[str, Any]:
201
+ """Renew the execution lease for a claimed/running request.
202
+
203
+ Only the current lease holder may renew; the server rejects stale or
204
+ mismatched grants with FAILED_PRECONDITION.
205
+ """
206
+ request = RenewRequestLeaseRequest(
207
+ session_id=session_id,
208
+ request_id=request_id,
209
+ machine_id=machine_id,
210
+ lease_epoch=lease_epoch,
211
+ )
212
+
213
+ response = self.connection_manager.requests_stub.RenewRequestLease(
214
+ request, metadata=self.connection_manager.get_metadata()
215
+ )
216
+ return self._normalize_request(response)
217
+
218
+ # ---------------- Provider poll/execution ----------------
219
+
220
+ def poll_session_requests(
221
+ self,
222
+ session_id: str,
223
+ machine_id: str,
224
+ tools: Dict[str, Callable],
225
+ streaming_tools: set,
226
+ limit: int = 5,
227
+ ):
228
+ """Poll for requests in a specific session.
229
+
230
+ Uses the atomic ClaimNextRequest primitive: one round-trip leases the
231
+ oldest claimable request for this machine, eliminating the
232
+ list-then-claim race entirely. Claims up to `limit` requests per tick
233
+ while the queue keeps serving; an idle queue returns immediately.
234
+ """
235
+ try:
236
+ self.connection_manager.ensure_connected()
237
+
238
+ # No registered tools: with an empty tool filter the server would
239
+ # match every session tool and we would claim work we cannot
240
+ # execute.
241
+ if not tools:
242
+ return
243
+
244
+ claimed_count = 0
245
+ while claimed_count < limit:
246
+ claim_next = ClaimNextRequestRequest(
247
+ session_id=session_id,
248
+ machine_id=machine_id,
249
+ tool_names=list(tools.keys()),
250
+ )
251
+
252
+ try:
253
+ response = self.connection_manager.requests_stub.ClaimNextRequest(
254
+ claim_next, metadata=self.connection_manager.get_metadata()
255
+ )
256
+ except grpc.RpcError as rpc_error:
257
+ if rpc_error.code() == grpc.StatusCode.UNAVAILABLE:
258
+ self.connection_manager.mark_unhealthy()
259
+ raise api_error_from_rpc_error(
260
+ rpc_error,
261
+ context=f"Failed to claim next request for session {session_id}",
262
+ ) from rpc_error
263
+
264
+ # claimed=false: the queue has nothing claimable right now.
265
+ if not response.claimed or not response.request.id:
266
+ break
267
+
268
+ req = response.request
269
+ # Track the lease grant so the renewal loop keeps it alive.
270
+ self.register_active_lease(
271
+ session_id,
272
+ req.id,
273
+ req.leased_by or machine_id,
274
+ req.lease_epoch,
275
+ )
276
+
277
+ # Execute in thread pool
278
+ self.executor.submit(self._execute_request, req, tools, streaming_tools)
279
+ claimed_count += 1
280
+
281
+ except grpc.RpcError as rpc_error:
282
+ if rpc_error.code() == grpc.StatusCode.UNAVAILABLE:
283
+ self.connection_manager.mark_unhealthy()
284
+ raise api_error_from_rpc_error(
285
+ rpc_error, context=f"Failed to poll requests for session {session_id}"
286
+ ) from rpc_error
287
+ except Exception as e:
288
+ raise RequestError(f"Failed to poll requests for session {session_id}: {e}")
289
+
290
+ def _execute_request(
291
+ self, request, tools: Dict[str, Callable], streaming_tools: set
292
+ ):
293
+ """Execute a claimed request."""
294
+ tool_name = request.tool_name
295
+ request_id = request.id
296
+ session_id = request.session_id
297
+ machine_id = request.leased_by
298
+ lease_epoch = request.lease_epoch
299
+
300
+ if tool_name not in tools:
301
+ self._submit_error_result(
302
+ session_id,
303
+ request_id,
304
+ f"Tool '{tool_name}' not found",
305
+ machine_id=machine_id,
306
+ lease_epoch=lease_epoch,
307
+ )
308
+ self.release_active_lease(request_id)
309
+ return
310
+
311
+ try:
312
+ # Parse input parameters
313
+ try:
314
+ params = json.loads(request.input)
315
+ except json.JSONDecodeError:
316
+ self._submit_error_result(
317
+ session_id,
318
+ request_id,
319
+ "Invalid JSON input",
320
+ machine_id=machine_id,
321
+ lease_epoch=lease_epoch,
322
+ )
323
+ self.release_active_lease(request_id)
324
+ return
325
+
326
+ # Execute the tool
327
+ tool_func = tools[tool_name]
328
+ is_streaming = tool_name in streaming_tools
329
+
330
+ self._handle_tool_execution(
331
+ session_id,
332
+ request_id,
333
+ tool_func,
334
+ params,
335
+ is_streaming,
336
+ machine_id=machine_id,
337
+ lease_epoch=lease_epoch,
338
+ )
339
+
340
+ except Exception as e:
341
+ self._submit_error_result(
342
+ session_id,
343
+ request_id,
344
+ str(e),
345
+ machine_id=machine_id,
346
+ lease_epoch=lease_epoch,
347
+ )
348
+ finally:
349
+ self.release_active_lease(request_id)
350
+
351
+ def _handle_tool_execution(
352
+ self,
353
+ session_id: str,
354
+ request_id: str,
355
+ tool_func: Callable,
356
+ params: Dict,
357
+ is_streaming: bool,
358
+ machine_id: str = "",
359
+ lease_epoch: int = 0,
360
+ ):
361
+ """Handle tool execution (streaming or non-streaming)."""
362
+ try:
363
+ # Mark as running
364
+ self._update_request_status(
365
+ session_id,
366
+ request_id,
367
+ "running",
368
+ machine_id=machine_id,
369
+ lease_epoch=lease_epoch,
370
+ )
371
+
372
+ if is_streaming:
373
+ self._handle_streaming_execution(
374
+ session_id,
375
+ request_id,
376
+ tool_func,
377
+ params,
378
+ machine_id=machine_id,
379
+ lease_epoch=lease_epoch,
380
+ )
381
+ else:
382
+ self._handle_normal_execution(
383
+ session_id,
384
+ request_id,
385
+ tool_func,
386
+ params,
387
+ machine_id=machine_id,
388
+ lease_epoch=lease_epoch,
389
+ )
390
+
391
+ except Exception as e:
392
+ self._submit_error_result(
393
+ session_id,
394
+ request_id,
395
+ str(e),
396
+ machine_id=machine_id,
397
+ lease_epoch=lease_epoch,
398
+ )
399
+
400
+ def _handle_streaming_execution(
401
+ self,
402
+ session_id: str,
403
+ request_id: str,
404
+ tool_func: Callable,
405
+ params: Dict,
406
+ machine_id: str = "",
407
+ lease_epoch: int = 0,
408
+ ):
409
+ """Handle streaming tool execution."""
410
+ # Set streaming mode
411
+ self._update_request(
412
+ session_id,
413
+ request_id,
414
+ result_type="streaming",
415
+ machine_id=machine_id,
416
+ lease_epoch=lease_epoch,
417
+ )
418
+
419
+ chunks = []
420
+ for chunk in tool_func(**params):
421
+ data = chunk if isinstance(chunk, str) else json.dumps(chunk)
422
+
423
+ # Append chunk
424
+ self._append_request_chunk(
425
+ session_id,
426
+ request_id,
427
+ data,
428
+ machine_id=machine_id,
429
+ lease_epoch=lease_epoch,
430
+ )
431
+ chunks.append(data)
432
+
433
+ # Submit final result
434
+ self._submit_result(
435
+ session_id,
436
+ request_id,
437
+ json.dumps(chunks),
438
+ "resolution",
439
+ machine_id=machine_id,
440
+ lease_epoch=lease_epoch,
441
+ )
442
+
443
+ def _handle_normal_execution(
444
+ self,
445
+ session_id: str,
446
+ request_id: str,
447
+ tool_func: Callable,
448
+ params: Dict,
449
+ machine_id: str = "",
450
+ lease_epoch: int = 0,
451
+ ):
452
+ """Handle normal tool execution."""
453
+ result = tool_func(**params)
454
+ self._submit_result(
455
+ session_id,
456
+ request_id,
457
+ json.dumps(result),
458
+ "resolution",
459
+ machine_id=machine_id,
460
+ lease_epoch=lease_epoch,
461
+ )
462
+
463
+ def _update_request_status(
464
+ self,
465
+ session_id: str,
466
+ request_id: str,
467
+ status: str,
468
+ machine_id: str = "",
469
+ lease_epoch: int = 0,
470
+ ):
471
+ """Update request status (fenced provider write)."""
472
+ request = UpdateRequestRequest(
473
+ session_id=session_id,
474
+ request_id=request_id,
475
+ status=status_for_wire(status),
476
+ machine_id=machine_id,
477
+ lease_epoch=lease_epoch,
478
+ )
479
+
480
+ self.connection_manager.requests_stub.UpdateRequest(
481
+ request, metadata=self.connection_manager.get_metadata()
482
+ )
483
+
484
+ def _update_request(
485
+ self,
486
+ session_id: str,
487
+ request_id: str,
488
+ result_type: str,
489
+ machine_id: str = "",
490
+ lease_epoch: int = 0,
491
+ ):
492
+ """Update request with result type (fenced provider write)."""
493
+ request = UpdateRequestRequest(
494
+ session_id=session_id,
495
+ request_id=request_id,
496
+ result_type=result_type,
497
+ machine_id=machine_id,
498
+ lease_epoch=lease_epoch,
499
+ )
500
+
501
+ self.connection_manager.requests_stub.UpdateRequest(
502
+ request, metadata=self.connection_manager.get_metadata()
503
+ )
504
+
505
+ def _append_request_chunk(
506
+ self,
507
+ session_id: str,
508
+ request_id: str,
509
+ chunk: str,
510
+ machine_id: str = "",
511
+ lease_epoch: int = 0,
512
+ ):
513
+ """Append chunk to request (fenced provider write)."""
514
+ request = AppendRequestChunksRequest(
515
+ session_id=session_id,
516
+ request_id=request_id,
517
+ chunks=[chunk],
518
+ result_type="streaming",
519
+ machine_id=machine_id,
520
+ lease_epoch=lease_epoch,
521
+ )
522
+
523
+ self.connection_manager.requests_stub.AppendRequestChunks(
524
+ request, metadata=self.connection_manager.get_metadata()
525
+ )
526
+
527
+ def _submit_result(
528
+ self,
529
+ session_id: str,
530
+ request_id: str,
531
+ result: str,
532
+ result_type: str,
533
+ machine_id: str = "",
534
+ lease_epoch: int = 0,
535
+ ):
536
+ """Submit request result (fenced provider write)."""
537
+ request = SubmitRequestResultRequest(
538
+ session_id=session_id,
539
+ request_id=request_id,
540
+ result=result,
541
+ result_type=result_type,
542
+ machine_id=machine_id,
543
+ lease_epoch=lease_epoch,
544
+ )
545
+
546
+ self.connection_manager.requests_stub.SubmitRequestResult(
547
+ request, metadata=self.connection_manager.get_metadata()
548
+ )
549
+
550
+ def _submit_error_result(
551
+ self,
552
+ session_id: str,
553
+ request_id: str,
554
+ error: str,
555
+ machine_id: str = "",
556
+ lease_epoch: int = 0,
557
+ ):
558
+ """Submit error result.
559
+
560
+ A rejection that itself fails fencing (the lease was already lost) is
561
+ logged rather than propagated, so error handling cannot loop.
562
+ """
563
+ try:
564
+ self._submit_result(
565
+ session_id,
566
+ request_id,
567
+ json.dumps({"error": error}),
568
+ "rejection",
569
+ machine_id=machine_id,
570
+ lease_epoch=lease_epoch,
571
+ )
572
+ except grpc.RpcError as rpc_error:
573
+ if rpc_error.code() == grpc.StatusCode.FAILED_PRECONDITION:
574
+ logger.warning(
575
+ "Could not submit rejection for request %s: lease was lost (%s)",
576
+ request_id,
577
+ rpc_error.code().name,
578
+ )
579
+ else:
580
+ raise
581
+
582
+ # ---------------- Consumer API ----------------
583
+
584
+ def create_request(
585
+ self,
586
+ session_id: str,
587
+ tool_name: str,
588
+ input_data: str,
589
+ timeout_seconds: int = 0,
590
+ idempotency_key: str = "",
591
+ ) -> str:
592
+ """Create a new request.
593
+
594
+ timeout_seconds optionally overrides the absolute execution timeout;
595
+ zero keeps the server default. idempotency_key, when set, dedups
596
+ creates within the session: retrying with the same key returns the
597
+ original request instead of enqueueing duplicate work.
598
+ """
599
+ try:
600
+ self.connection_manager.ensure_connected()
601
+
602
+ request = CreateRequestRequest(
603
+ session_id=session_id,
604
+ tool_name=tool_name,
605
+ input=input_data,
606
+ timeout_seconds=timeout_seconds,
607
+ idempotency_key=idempotency_key,
608
+ )
609
+
610
+ response = self.connection_manager.requests_stub.CreateRequest(
611
+ request, metadata=self.connection_manager.get_metadata()
612
+ )
613
+ return response.id
614
+
615
+ except Exception as e:
616
+ raise RequestError(f"Failed to create request: {e}")
617
+
618
+ def resume_stream(self, session_id: str, request_id: str, last_seq: int = 0):
619
+ """Resume a request chunk stream after the given absolute sequence.
620
+
621
+ Yields chunk dicts (seq, request_id, chunk, is_final, error) covering
622
+ everything the server still retains after last_seq. Raises
623
+ ToolplaneInvalidArgumentError (OUT_OF_RANGE) when the retained window
624
+ has moved past last_seq and replay is no longer possible.
625
+ """
626
+ try:
627
+ self.connection_manager.ensure_connected()
628
+
629
+ request = ResumeStreamRequest(
630
+ session_id=session_id, request_id=request_id, last_seq=last_seq
631
+ )
632
+ for chunk in self.connection_manager.requests_stub.ResumeStream(
633
+ request, metadata=self.connection_manager.get_metadata()
634
+ ):
635
+ yield {
636
+ "seq": chunk.seq,
637
+ "request_id": chunk.request_id,
638
+ "chunk": chunk.chunk,
639
+ "is_final": chunk.is_final,
640
+ "error": chunk.error,
641
+ }
642
+
643
+ except grpc.RpcError as rpc_error:
644
+ raise api_error_from_rpc_error(
645
+ rpc_error, context=f"Failed to resume stream for request {request_id}"
646
+ ) from rpc_error
647
+
648
+ def list_requests(
649
+ self,
650
+ session_id: str,
651
+ status: str = "",
652
+ tool_name: str = "",
653
+ limit: int = 10,
654
+ page_token: str = "",
655
+ ) -> List[Dict[str, Any]]:
656
+ """List requests in a session.
657
+
658
+ page_token is the opaque cursor from a previous page's response;
659
+ an empty string starts from the first page. Use list_requests_page
660
+ when you need the continuation cursor.
661
+ """
662
+ return self.list_requests_page(
663
+ session_id, status, tool_name, limit, page_token
664
+ )["requests"]
665
+
666
+ def list_requests_page(
667
+ self,
668
+ session_id: str,
669
+ status: str = "",
670
+ tool_name: str = "",
671
+ limit: int = 10,
672
+ page_token: str = "",
673
+ ) -> Dict[str, Any]:
674
+ """List one page of requests in a session.
675
+
676
+ Returns the page alongside the requests: next_page_token is the
677
+ opaque cursor for the next call (empty on the last page) and
678
+ total_size is the filtered total across all pages.
679
+ """
680
+ try:
681
+ self.connection_manager.ensure_connected()
682
+
683
+ request = ListRequestsRequest(
684
+ session_id=session_id,
685
+ status=status_for_wire(status),
686
+ tool_name=tool_name,
687
+ page_size=limit,
688
+ page_token=page_token or "",
689
+ )
690
+
691
+ response = self.connection_manager.requests_stub.ListRequests(
692
+ request, metadata=self.connection_manager.get_metadata()
693
+ )
694
+ return {
695
+ "requests": [
696
+ self._normalize_request(entry) for entry in response.requests
697
+ ],
698
+ "next_page_token": response.page.next_page_token,
699
+ "total_size": response.page.total_size,
700
+ }
701
+
702
+ except Exception as e:
703
+ raise RequestError(f"Failed to list requests: {e}")
704
+
705
+ def get_request_status(self, session_id: str, request_id: str) -> Dict[str, Any]:
706
+ """Get request status, with the retained chunk window attached.
707
+
708
+ The request message no longer carries chunk payloads; the window is
709
+ fetched from the chunks endpoint so poll-based consumers see the
710
+ same data as resume-stream consumers.
711
+ """
712
+ try:
713
+ self.connection_manager.ensure_connected()
714
+
715
+ request = GetRequestRequest(session_id=session_id, request_id=request_id)
716
+
717
+ response = self.connection_manager.requests_stub.GetRequest(
718
+ request, metadata=self.connection_manager.get_metadata()
719
+ )
720
+
721
+ result = self._normalize_request(response)
722
+
723
+ try:
724
+ chunks_request = GetRequestChunksRequest(
725
+ session_id=session_id, request_id=request_id
726
+ )
727
+ chunks_response = (
728
+ self.connection_manager.requests_stub.GetRequestChunks(
729
+ chunks_request, metadata=self.connection_manager.get_metadata()
730
+ )
731
+ )
732
+ if chunks_response.chunks:
733
+ result["streamResults"] = list(chunks_response.chunks)
734
+ except Exception as exc:
735
+ # Chunk enrichment is best-effort for the status envelope,
736
+ # but never silent: a chunk-read failure (size limits,
737
+ # window state) is logged and surfaced on the envelope so
738
+ # stream consumers see why streamResults is absent instead
739
+ # of quietly missing data.
740
+ logger.warning(
741
+ "stream chunk read failed for request %s: %s",
742
+ request_id,
743
+ exc,
744
+ )
745
+ result["streamResultsError"] = str(exc)
746
+
747
+ return result
748
+
749
+ except Exception as e:
750
+ raise RequestError(f"Failed to get request status: {e}")
751
+
752
+ def cancel_request(self, session_id: str, request_id: str) -> bool:
753
+ """Cancel a request."""
754
+ try:
755
+ self.connection_manager.ensure_connected()
756
+
757
+ request = CancelRequestRequest(
758
+ session_id=session_id,
759
+ request_id=request_id,
760
+ )
761
+
762
+ response = self.connection_manager.requests_stub.CancelRequest(
763
+ request, metadata=self.connection_manager.get_metadata()
764
+ )
765
+
766
+ return response.success
767
+
768
+ except Exception as e:
769
+ raise RequestError(f"Failed to cancel request: {e}")
770
+
771
+ def shutdown(self):
772
+ """Shutdown request manager."""
773
+ self.stop_polling()
774
+ self.stop_lease_renewal()
775
+ self.executor.shutdown(wait=False)