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.
- toolplane/__init__.py +106 -0
- toolplane/common/__init__.py +93 -0
- toolplane/common/base_config.py +129 -0
- toolplane/common/base_connection_manager.py +171 -0
- toolplane/common/base_session_manager.py +321 -0
- toolplane/common/base_tool_manager.py +347 -0
- toolplane/common/constants.py +47 -0
- toolplane/common/utils.py +310 -0
- toolplane/core/__init__.py +67 -0
- toolplane/core/config.py +107 -0
- toolplane/core/connection.py +285 -0
- toolplane/core/errors.py +298 -0
- toolplane/core/machine.py +480 -0
- toolplane/core/request.py +775 -0
- toolplane/core/session.py +332 -0
- toolplane/core/session_context.py +514 -0
- toolplane/core/task.py +130 -0
- toolplane/core/tool.py +329 -0
- toolplane/http_core/__init__.py +37 -0
- toolplane/http_core/http_config.py +97 -0
- toolplane/http_core/http_connection.py +409 -0
- toolplane/http_core/http_machine.py +298 -0
- toolplane/http_core/http_request.py +748 -0
- toolplane/http_core/http_session.py +348 -0
- toolplane/http_core/http_session_context.py +491 -0
- toolplane/http_core/http_task.py +101 -0
- toolplane/http_core/http_tool.py +400 -0
- toolplane/interfaces/__init__.py +27 -0
- toolplane/interfaces/client_interface.py +122 -0
- toolplane/interfaces/connection_interface.py +193 -0
- toolplane/interfaces/event_interface.py +290 -0
- toolplane/interfaces/request_interface.py +439 -0
- toolplane/interfaces/session_interface.py +288 -0
- toolplane/interfaces/tool_interface.py +441 -0
- toolplane/proto/__init__.py +0 -0
- toolplane/proto/service_pb2.py +315 -0
- toolplane/proto/service_pb2_grpc.py +2240 -0
- toolplane/provider_cli.py +268 -0
- toolplane/provider_registry.py +77 -0
- toolplane/provider_runtime.py +302 -0
- toolplane/toolkits/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/__init__.py +0 -0
- toolplane/toolkits/standalone_tools/create_directory.py +94 -0
- toolplane/toolkits/standalone_tools/create_file.py +124 -0
- toolplane/toolkits/standalone_tools/file_search.py +229 -0
- toolplane/toolkits/standalone_tools/grep_search.py +372 -0
- toolplane/toolkits/standalone_tools/launcher.py +146 -0
- toolplane/toolkits/standalone_tools/list_dir.py +395 -0
- toolplane/toolkits/standalone_tools/read_file.py +346 -0
- toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
- toolplane/toolkits/standalone_tools/run_tests.py +66 -0
- toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
- toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
- toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
- toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
- toolplane/toolkits/swe/__init__.py +35 -0
- toolplane/toolkits/swe/create_directory.py +15 -0
- toolplane/toolkits/swe/create_file.py +15 -0
- toolplane/toolkits/swe/descriptions.py +273 -0
- toolplane/toolkits/swe/execute_bash.py +93 -0
- toolplane/toolkits/swe/file_editor.py +775 -0
- toolplane/toolkits/swe/file_search.py +16 -0
- toolplane/toolkits/swe/finish.py +50 -0
- toolplane/toolkits/swe/grep_search.py +19 -0
- toolplane/toolkits/swe/list_dir.py +407 -0
- toolplane/toolkits/swe/read_file.py +18 -0
- toolplane/toolkits/swe/replace_string_in_file.py +17 -0
- toolplane/toolkits/swe/search.py +260 -0
- toolplane/toolkits/swe/semantic_search.py +20 -0
- toolplane/toolkits/swe/str_replace_editor.py +647 -0
- toolplane/toolkits/swe/submit.py +29 -0
- toolplane/toolkits/swe/swe_toolkit.py +1296 -0
- toolplane/toolplane_client.py +686 -0
- toolplane/toolplane_http_client.py +681 -0
- toolplane/utils/__init__.py +3 -0
- toolplane/utils/schema.py +146 -0
- toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
- toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
- toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
- toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|