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