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,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)
|