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,491 @@
|
|
|
1
|
+
"""HTTP session context implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from toolplane.utils.schema import generate_schema_from_function
|
|
11
|
+
|
|
12
|
+
from ..core.errors import (
|
|
13
|
+
ToolplaneAPIError,
|
|
14
|
+
ToolplaneCancelledError,
|
|
15
|
+
ToolplaneError,
|
|
16
|
+
ToolplaneInvalidArgumentError,
|
|
17
|
+
ToolplaneTimeoutError,
|
|
18
|
+
normalize_status_name,
|
|
19
|
+
)
|
|
20
|
+
from ..core.session_context import derive_wait_for
|
|
21
|
+
from .http_connection import HTTPConnectionManager
|
|
22
|
+
from .http_machine import HTTPMachineManager
|
|
23
|
+
from .http_request import HTTPRequestManager
|
|
24
|
+
from .http_session import HTTPSessionManager
|
|
25
|
+
from .http_tool import HTTPToolManager
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HTTPSessionContext:
|
|
31
|
+
"""
|
|
32
|
+
Represents an HTTP session context with its own tools and machine registration.
|
|
33
|
+
Encapsulates all session-specific state and operations.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
session_id: str,
|
|
39
|
+
connection_manager: HTTPConnectionManager,
|
|
40
|
+
machine_manager: HTTPMachineManager,
|
|
41
|
+
tool_manager: HTTPToolManager,
|
|
42
|
+
request_manager: HTTPRequestManager,
|
|
43
|
+
session_manager: HTTPSessionManager,
|
|
44
|
+
):
|
|
45
|
+
"""Initialize HTTP session context."""
|
|
46
|
+
self.session_id = session_id
|
|
47
|
+
self.connection_manager = connection_manager
|
|
48
|
+
self.machine_manager = machine_manager
|
|
49
|
+
self.tool_manager = tool_manager
|
|
50
|
+
self.request_manager = request_manager
|
|
51
|
+
self.session_manager = session_manager
|
|
52
|
+
|
|
53
|
+
self.machine_id: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
def register_machine(self) -> bool:
|
|
56
|
+
"""Register a machine for this session."""
|
|
57
|
+
try:
|
|
58
|
+
self.machine_id = self.machine_manager.register_machine(self.session_id)
|
|
59
|
+
logger.debug(
|
|
60
|
+
"Registered machine %s for session %s", self.machine_id, self.session_id
|
|
61
|
+
)
|
|
62
|
+
return True
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"Error registering machine for session %s: %s", self.session_id, e
|
|
66
|
+
)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def register_tool(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
func: Callable,
|
|
73
|
+
schema: Optional[Dict] = None,
|
|
74
|
+
description: Optional[str] = None,
|
|
75
|
+
stream: bool = False,
|
|
76
|
+
tags: Optional[List[str]] = None,
|
|
77
|
+
):
|
|
78
|
+
"""Register a tool for this session."""
|
|
79
|
+
if not self.machine_id:
|
|
80
|
+
raise ToolplaneError(
|
|
81
|
+
f"Session {self.session_id} has no machine registration. Use ProviderRuntime.attach_session(...) or register_machine() before registering tools."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
self.tool_manager.register_tool(
|
|
86
|
+
self.session_id,
|
|
87
|
+
self.machine_id,
|
|
88
|
+
name,
|
|
89
|
+
func,
|
|
90
|
+
schema,
|
|
91
|
+
description,
|
|
92
|
+
stream,
|
|
93
|
+
tags,
|
|
94
|
+
)
|
|
95
|
+
logger.debug(
|
|
96
|
+
"Registered tool %s%s for session %s",
|
|
97
|
+
name,
|
|
98
|
+
" (streaming)" if stream else "",
|
|
99
|
+
self.session_id,
|
|
100
|
+
)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise ToolplaneError(
|
|
103
|
+
f"Failed to register tool {name} for session {self.session_id}: {e}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def invoke(
|
|
107
|
+
self,
|
|
108
|
+
tool_name: str,
|
|
109
|
+
timeout_seconds: int = 0,
|
|
110
|
+
wait_timeout: Optional[int] = None,
|
|
111
|
+
**params,
|
|
112
|
+
) -> Any:
|
|
113
|
+
"""Invoke a tool in this session and return the tool's result value.
|
|
114
|
+
|
|
115
|
+
timeout_seconds sets the request's absolute per-attempt execution
|
|
116
|
+
timeout on the wire (0 keeps the server default). The local wait ends
|
|
117
|
+
after wait_timeout seconds (default: timeout_seconds + 15, else 60).
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
# Wait budget: also drives the server-side long-poll. The POST
|
|
121
|
+
# must complete inside the transport deadline — a wait that
|
|
122
|
+
# outlives it times out, gets retried, and duplicates the
|
|
123
|
+
# invocation — so the long-poll is clamped to the transport
|
|
124
|
+
# budget and the local poller covers the remainder. The shared
|
|
125
|
+
# derivation clamps derived waits to the server's wait ceiling.
|
|
126
|
+
wait_for = derive_wait_for(timeout_seconds, wait_timeout)
|
|
127
|
+
|
|
128
|
+
deadline = time.monotonic() + wait_for
|
|
129
|
+
|
|
130
|
+
transport_budget = max(
|
|
131
|
+
5,
|
|
132
|
+
int(getattr(self.connection_manager.config, "request_timeout", 30)) - 5,
|
|
133
|
+
)
|
|
134
|
+
http_wait = min(wait_for, transport_budget)
|
|
135
|
+
|
|
136
|
+
request_id, terminal_status, result = self.tool_manager.execute_tool(
|
|
137
|
+
self.session_id,
|
|
138
|
+
tool_name,
|
|
139
|
+
params,
|
|
140
|
+
timeout_seconds=timeout_seconds,
|
|
141
|
+
wait_timeout_seconds=http_wait,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Poll for completion; the HTTP wait already returns the unwrapped
|
|
145
|
+
# tool result.
|
|
146
|
+
if terminal_status == "done":
|
|
147
|
+
return result
|
|
148
|
+
if terminal_status == "cancelled":
|
|
149
|
+
raise ToolplaneCancelledError(
|
|
150
|
+
f"Request was cancelled (request_id={request_id})"
|
|
151
|
+
)
|
|
152
|
+
if terminal_status == "failed":
|
|
153
|
+
raise ToolplaneError(f"Tool execution failed (request_id={request_id})")
|
|
154
|
+
remaining = max(1, int(deadline - time.monotonic()))
|
|
155
|
+
return self._wait_for_completion(request_id, timeout=remaining)
|
|
156
|
+
|
|
157
|
+
except ToolplaneTimeoutError:
|
|
158
|
+
raise
|
|
159
|
+
except ToolplaneAPIError:
|
|
160
|
+
# Typed server errors keep their identity for the caller.
|
|
161
|
+
raise
|
|
162
|
+
|
|
163
|
+
async def ainvoke(
|
|
164
|
+
self,
|
|
165
|
+
tool_name: str,
|
|
166
|
+
timeout_seconds: int = 0,
|
|
167
|
+
**params,
|
|
168
|
+
) -> str:
|
|
169
|
+
"""Submit a tool invocation without blocking the caller.
|
|
170
|
+
|
|
171
|
+
Returns the request ID once the gateway accepts the work; poll
|
|
172
|
+
get_request_status (or await astream) for the outcome. The blocking
|
|
173
|
+
submission runs in a worker thread, so this is safe to await from a
|
|
174
|
+
running event loop.
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
payload = json.dumps(params)
|
|
178
|
+
return await asyncio.to_thread(
|
|
179
|
+
self.request_manager.create_request,
|
|
180
|
+
self.session_id,
|
|
181
|
+
tool_name,
|
|
182
|
+
payload,
|
|
183
|
+
timeout_seconds=timeout_seconds,
|
|
184
|
+
)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
raise ToolplaneError(f"Failed to async invoke tool {tool_name}: {e}")
|
|
187
|
+
|
|
188
|
+
def stream(self, tool_name: str, callback: Callable[[Any, bool], None], **params):
|
|
189
|
+
"""Stream tool execution.
|
|
190
|
+
|
|
191
|
+
The stream is resumable: if the direct stream fails mid-flight, the
|
|
192
|
+
fallback resumes from the last received sequence number (or, when it
|
|
193
|
+
died before the first chunk, re-invokes under the same idempotency
|
|
194
|
+
key so the server returns the original request). A tool that
|
|
195
|
+
completed with an error is never re-executed.
|
|
196
|
+
"""
|
|
197
|
+
idempotency_key = uuid.uuid4().hex
|
|
198
|
+
all_chunks = []
|
|
199
|
+
request_id = None
|
|
200
|
+
last_seq = 0
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
try:
|
|
204
|
+
for chunk in self.tool_manager.stream_tool(
|
|
205
|
+
self.session_id, tool_name, params, idempotency_key
|
|
206
|
+
):
|
|
207
|
+
chunk_data = chunk.get("result", {})
|
|
208
|
+
is_final = chunk_data.get("isFinal", False)
|
|
209
|
+
chunk_content = chunk_data.get("chunk", "")
|
|
210
|
+
|
|
211
|
+
if chunk_data.get("requestId"):
|
|
212
|
+
request_id = chunk_data.get("requestId")
|
|
213
|
+
if chunk_data.get("seq"):
|
|
214
|
+
last_seq = max(last_seq, int(chunk_data.get("seq", 0)))
|
|
215
|
+
|
|
216
|
+
callback(chunk_content, is_final)
|
|
217
|
+
all_chunks.append(chunk_content)
|
|
218
|
+
|
|
219
|
+
stream_error = chunk_data.get("error") or chunk.get("error")
|
|
220
|
+
if stream_error:
|
|
221
|
+
raise ToolplaneError(f"Streaming error: {stream_error}")
|
|
222
|
+
|
|
223
|
+
if is_final:
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
return all_chunks
|
|
227
|
+
|
|
228
|
+
except ToolplaneError:
|
|
229
|
+
# The tool itself failed: re-executing it would duplicate the
|
|
230
|
+
# side effect, so surface the failure.
|
|
231
|
+
raise
|
|
232
|
+
except Exception:
|
|
233
|
+
# Transport failure mid-stream: resume from the last sequence
|
|
234
|
+
# number the server acknowledges.
|
|
235
|
+
if request_id:
|
|
236
|
+
return self._resume_stream(
|
|
237
|
+
request_id,
|
|
238
|
+
last_seq,
|
|
239
|
+
callback,
|
|
240
|
+
all_chunks,
|
|
241
|
+
tool_name,
|
|
242
|
+
params,
|
|
243
|
+
idempotency_key,
|
|
244
|
+
)
|
|
245
|
+
# The stream died before any chunk arrived. The server may
|
|
246
|
+
# already have created the request; re-invoking under the same
|
|
247
|
+
# idempotency key returns that request instead of executing
|
|
248
|
+
# the tool a second time.
|
|
249
|
+
return self._stream_via_polling(
|
|
250
|
+
tool_name, callback, params, idempotency_key
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
except ToolplaneError:
|
|
254
|
+
raise
|
|
255
|
+
except Exception as e:
|
|
256
|
+
raise ToolplaneError(f"Failed to stream tool {tool_name}: {e}")
|
|
257
|
+
|
|
258
|
+
def _resume_stream(
|
|
259
|
+
self,
|
|
260
|
+
request_id: str,
|
|
261
|
+
last_seq: int,
|
|
262
|
+
callback: Callable,
|
|
263
|
+
all_chunks: List,
|
|
264
|
+
tool_name: str,
|
|
265
|
+
params: Dict,
|
|
266
|
+
idempotency_key: str,
|
|
267
|
+
):
|
|
268
|
+
"""Continue a broken stream from the last acknowledged sequence."""
|
|
269
|
+
try:
|
|
270
|
+
for chunk in self.request_manager.resume_stream(
|
|
271
|
+
self.session_id, request_id, last_seq
|
|
272
|
+
):
|
|
273
|
+
value = chunk["chunk"]
|
|
274
|
+
if value not in ("", None):
|
|
275
|
+
callback(value, chunk["is_final"])
|
|
276
|
+
all_chunks.append(value)
|
|
277
|
+
if chunk["error"]:
|
|
278
|
+
raise ToolplaneError(f"Streaming error: {chunk['error']}")
|
|
279
|
+
if chunk["is_final"]:
|
|
280
|
+
return all_chunks
|
|
281
|
+
except ToolplaneInvalidArgumentError:
|
|
282
|
+
# The retained window moved past our position; the full result is
|
|
283
|
+
# still fetchable by polling the original request.
|
|
284
|
+
self._stream_via_polling(
|
|
285
|
+
tool_name,
|
|
286
|
+
callback,
|
|
287
|
+
params,
|
|
288
|
+
idempotency_key,
|
|
289
|
+
request_id=request_id,
|
|
290
|
+
skip=len(all_chunks),
|
|
291
|
+
accumulate=all_chunks,
|
|
292
|
+
)
|
|
293
|
+
return all_chunks
|
|
294
|
+
|
|
295
|
+
def _stream_via_polling(
|
|
296
|
+
self,
|
|
297
|
+
tool_name: str,
|
|
298
|
+
callback: Callable,
|
|
299
|
+
params: Dict,
|
|
300
|
+
idempotency_key: str = "",
|
|
301
|
+
request_id: Optional[str] = None,
|
|
302
|
+
skip: int = 0,
|
|
303
|
+
accumulate: Optional[List] = None,
|
|
304
|
+
):
|
|
305
|
+
"""Stream via polling fallback.
|
|
306
|
+
|
|
307
|
+
When request_id is given, polls that request; otherwise invokes the
|
|
308
|
+
tool (under idempotency_key when provided) and polls the result.
|
|
309
|
+
skip suppresses the first skip chunks, which the caller already
|
|
310
|
+
delivered. When accumulate is given, polled chunks append to it so
|
|
311
|
+
callers keep the chunks they already delivered.
|
|
312
|
+
"""
|
|
313
|
+
if request_id is None:
|
|
314
|
+
request_id = self.tool_manager.execute_tool(
|
|
315
|
+
self.session_id, tool_name, params, idempotency_key
|
|
316
|
+
)[0]
|
|
317
|
+
|
|
318
|
+
all_chunks = accumulate if accumulate is not None else []
|
|
319
|
+
last_chunk_count = skip
|
|
320
|
+
|
|
321
|
+
while True:
|
|
322
|
+
status = self.get_request_status(request_id)
|
|
323
|
+
|
|
324
|
+
if "streamResults" in status:
|
|
325
|
+
chunks = status["streamResults"]
|
|
326
|
+
|
|
327
|
+
# Process new chunks
|
|
328
|
+
for i in range(last_chunk_count, len(chunks)):
|
|
329
|
+
callback(chunks[i], False)
|
|
330
|
+
all_chunks.append(chunks[i])
|
|
331
|
+
|
|
332
|
+
last_chunk_count = len(chunks)
|
|
333
|
+
|
|
334
|
+
if normalize_status_name(status["status"]) == "done":
|
|
335
|
+
callback("", True)
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
if normalize_status_name(status["status"]) == "cancelled":
|
|
339
|
+
raise ToolplaneCancelledError(
|
|
340
|
+
"Request was cancelled "
|
|
341
|
+
f"(request_id={status.get('requestId', request_id)})"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if normalize_status_name(status["status"]) == "failure":
|
|
345
|
+
raise ToolplaneError(
|
|
346
|
+
f"Streaming failed: {status.get('error', 'Unknown error')}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
time.sleep(0.5)
|
|
350
|
+
|
|
351
|
+
return all_chunks
|
|
352
|
+
|
|
353
|
+
async def astream(
|
|
354
|
+
self, tool_name: str, callback: Callable[[Any, bool], None], **params
|
|
355
|
+
):
|
|
356
|
+
"""Awaitable stream: runs the blocking stream loop in a worker
|
|
357
|
+
thread and resolves with the collected chunks."""
|
|
358
|
+
return await asyncio.to_thread(self.stream, tool_name, callback, **params)
|
|
359
|
+
|
|
360
|
+
def get_request_status(self, request_id: str) -> Dict[str, Any]:
|
|
361
|
+
"""Get request status."""
|
|
362
|
+
return self.request_manager.get_request_status(self.session_id, request_id)
|
|
363
|
+
|
|
364
|
+
def get_available_tools(self) -> Dict[str, Any]:
|
|
365
|
+
"""Get available tools for this session."""
|
|
366
|
+
return self.tool_manager.get_available_tools(self.session_id)
|
|
367
|
+
|
|
368
|
+
def list_tools(self) -> List[Dict[str, Any]]:
|
|
369
|
+
"""List tools for this session."""
|
|
370
|
+
return self.tool_manager.list_tools(self.session_id)
|
|
371
|
+
|
|
372
|
+
def get_tool_by_id(self, tool_id: str) -> Dict[str, Any]:
|
|
373
|
+
"""Get a tool by ID for this session."""
|
|
374
|
+
return self.tool_manager.get_tool_by_id(self.session_id, tool_id)
|
|
375
|
+
|
|
376
|
+
def get_tool_by_name(self, tool_name: str) -> Dict[str, Any]:
|
|
377
|
+
"""Get a tool by name for this session."""
|
|
378
|
+
return self.tool_manager.get_tool_by_name(self.session_id, tool_name)
|
|
379
|
+
|
|
380
|
+
def delete_tool(self, tool_id: str) -> bool:
|
|
381
|
+
"""Delete a tool by ID for this session."""
|
|
382
|
+
return self.tool_manager.delete_tool(self.session_id, tool_id)
|
|
383
|
+
|
|
384
|
+
def tool(self, name=None, description=None, stream=False, tags=None):
|
|
385
|
+
"""Decorator for registering tools."""
|
|
386
|
+
if tags is None:
|
|
387
|
+
tags = []
|
|
388
|
+
|
|
389
|
+
def decorator(func):
|
|
390
|
+
tool_name = name or func.__name__
|
|
391
|
+
tool_schema = generate_schema_from_function(func)
|
|
392
|
+
|
|
393
|
+
if description:
|
|
394
|
+
tool_schema["description"] = description
|
|
395
|
+
|
|
396
|
+
self.register_tool(tool_name, func, tool_schema, description, stream, tags)
|
|
397
|
+
return func
|
|
398
|
+
|
|
399
|
+
return decorator
|
|
400
|
+
|
|
401
|
+
def _wait_for_completion(self, request_id: str, timeout: int = 60) -> Any:
|
|
402
|
+
"""Wait for request completion."""
|
|
403
|
+
start_time = time.time()
|
|
404
|
+
last_status: Dict[str, Any] = {}
|
|
405
|
+
|
|
406
|
+
while time.time() - start_time < timeout:
|
|
407
|
+
status = self.get_request_status(request_id)
|
|
408
|
+
last_status = status
|
|
409
|
+
|
|
410
|
+
if normalize_status_name(status["status"]) == "done":
|
|
411
|
+
try:
|
|
412
|
+
return json.loads(status["result"])
|
|
413
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
414
|
+
return status["result"]
|
|
415
|
+
|
|
416
|
+
if normalize_status_name(status["status"]) == "cancelled":
|
|
417
|
+
raise ToolplaneCancelledError(
|
|
418
|
+
f"Request was cancelled (request_id={request_id})"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if normalize_status_name(status["status"]) == "failure":
|
|
422
|
+
raise ToolplaneError(
|
|
423
|
+
f"Tool execution failed: {status.get('error', 'Unknown error')}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
time.sleep(0.5)
|
|
427
|
+
|
|
428
|
+
raise ToolplaneTimeoutError(
|
|
429
|
+
f"Tool execution timed out after {timeout}s (request_id={request_id}, "
|
|
430
|
+
f"status={last_status.get('status', 'unknown')})"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def cleanup(self):
|
|
434
|
+
"""Cleanup this session."""
|
|
435
|
+
try:
|
|
436
|
+
# Cleanup tools
|
|
437
|
+
self.tool_manager.cleanup_session_tools(self.session_id)
|
|
438
|
+
|
|
439
|
+
# Unregister machine
|
|
440
|
+
if self.machine_id:
|
|
441
|
+
self.machine_manager.unregister_machine(
|
|
442
|
+
self.session_id, reason="session_context_cleanup"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Remove from session manager
|
|
446
|
+
self.session_manager.remove_session_context(self.session_id)
|
|
447
|
+
|
|
448
|
+
except Exception as e:
|
|
449
|
+
logger.warning("Error cleaning up session %s: %s", self.session_id, e)
|
|
450
|
+
|
|
451
|
+
def poll_requests(self):
|
|
452
|
+
"""Poll for requests in this session."""
|
|
453
|
+
if not self.machine_id:
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
tools = self.tool_manager.get_session_tools(self.session_id)
|
|
458
|
+
streaming_tools = self.tool_manager.streaming_tools.get(
|
|
459
|
+
self.session_id, set()
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
self.request_manager.poll_session_requests(
|
|
463
|
+
self.session_id, self.machine_id, tools, streaming_tools
|
|
464
|
+
)
|
|
465
|
+
except Exception as e:
|
|
466
|
+
logger.warning(
|
|
467
|
+
"Error polling requests for session %s: %s", self.session_id, e
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# New methods for user session management
|
|
471
|
+
def list_user_sessions(
|
|
472
|
+
self, user_id: str, page_size: int = 10, page_token: int = 0, filter: str = ""
|
|
473
|
+
) -> Dict[str, Any]:
|
|
474
|
+
"""List user sessions with pagination and filtering."""
|
|
475
|
+
return self.session_manager.list_user_sessions(
|
|
476
|
+
user_id, page_size, page_token, filter
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def bulk_delete_sessions(
|
|
480
|
+
self, user_id: str, session_ids: Optional[List[str]] = None, filter: str = ""
|
|
481
|
+
) -> Dict[str, Any]:
|
|
482
|
+
"""Bulk delete sessions for a user."""
|
|
483
|
+
return self.session_manager.bulk_delete_sessions(user_id, session_ids, filter)
|
|
484
|
+
|
|
485
|
+
def get_session_stats(self, user_id: str) -> Dict[str, int]:
|
|
486
|
+
"""Get session statistics for a user."""
|
|
487
|
+
return self.session_manager.get_session_stats(user_id)
|
|
488
|
+
|
|
489
|
+
def invalidate_session(self, reason: str = "") -> bool:
|
|
490
|
+
"""Invalidate a session."""
|
|
491
|
+
return self.session_manager.invalidate_session(self.session_id, reason)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""HTTP task management for Toolplane client."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
|
+
from ..common.utils import format_error_message
|
|
6
|
+
from ..core.errors import TaskError
|
|
7
|
+
from .http_connection import HTTPConnectionManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HTTPTaskManager:
|
|
11
|
+
"""Manages task lifecycle for the HTTP client."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, connection_manager: HTTPConnectionManager):
|
|
14
|
+
"""Initialize HTTP task manager."""
|
|
15
|
+
self.connection_manager = connection_manager
|
|
16
|
+
|
|
17
|
+
def _normalize_task(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
|
18
|
+
return {
|
|
19
|
+
"id": task.get("id", ""),
|
|
20
|
+
"session_id": task.get("sessionId", task.get("session_id", "")),
|
|
21
|
+
"tool_name": task.get("toolName", task.get("tool_name", "")),
|
|
22
|
+
"status": task.get("status", ""),
|
|
23
|
+
"input": task.get("input", ""),
|
|
24
|
+
"result": task.get("result", ""),
|
|
25
|
+
"result_type": task.get("resultType", task.get("result_type", "")),
|
|
26
|
+
"error": task.get("error", ""),
|
|
27
|
+
"created_at": task.get("createdAt", task.get("created_at", "")),
|
|
28
|
+
"updated_at": task.get("updatedAt", task.get("updated_at", "")),
|
|
29
|
+
"completed_at": task.get("completedAt", task.get("completed_at", "")),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
def create_task(
|
|
33
|
+
self,
|
|
34
|
+
session_id: str,
|
|
35
|
+
tool_name: str,
|
|
36
|
+
input_data: str,
|
|
37
|
+
idempotency_key: str = "",
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
"""Create a task for a session.
|
|
40
|
+
|
|
41
|
+
idempotency_key, when set, dedups creates within the session:
|
|
42
|
+
retrying with the same key returns the original task without
|
|
43
|
+
re-executing it.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
self.connection_manager.ensure_connected()
|
|
47
|
+
payload = {
|
|
48
|
+
"sessionId": session_id,
|
|
49
|
+
"toolName": tool_name,
|
|
50
|
+
"input": input_data,
|
|
51
|
+
}
|
|
52
|
+
if idempotency_key:
|
|
53
|
+
payload["idempotencyKey"] = idempotency_key
|
|
54
|
+
response = self.connection_manager.create_task(payload)
|
|
55
|
+
task = response.get("task", response)
|
|
56
|
+
if not isinstance(task, dict):
|
|
57
|
+
raise TaskError(
|
|
58
|
+
f"Unexpected task payload for session {session_id}: {task}"
|
|
59
|
+
)
|
|
60
|
+
return self._normalize_task(task)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
raise TaskError(format_error_message(exc, "Failed to create task"))
|
|
63
|
+
|
|
64
|
+
def get_task(self, session_id: str, task_id: str) -> Dict[str, Any]:
|
|
65
|
+
"""Get a task for a session."""
|
|
66
|
+
try:
|
|
67
|
+
self.connection_manager.ensure_connected()
|
|
68
|
+
response = self.connection_manager.get_task(session_id, task_id)
|
|
69
|
+
task = response.get("task", response)
|
|
70
|
+
if not isinstance(task, dict):
|
|
71
|
+
raise TaskError(
|
|
72
|
+
f"Unexpected task payload for session {session_id}: {task}"
|
|
73
|
+
)
|
|
74
|
+
return self._normalize_task(task)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
raise TaskError(format_error_message(exc, f"Failed to get task {task_id}"))
|
|
77
|
+
|
|
78
|
+
def list_tasks(self, session_id: str) -> List[Dict[str, Any]]:
|
|
79
|
+
"""List tasks for a session."""
|
|
80
|
+
try:
|
|
81
|
+
self.connection_manager.ensure_connected()
|
|
82
|
+
response = self.connection_manager.list_tasks(session_id)
|
|
83
|
+
tasks = response.get("tasks", [])
|
|
84
|
+
return [self._normalize_task(task) for task in tasks]
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise TaskError(
|
|
87
|
+
format_error_message(
|
|
88
|
+
exc, f"Failed to list tasks for session {session_id}"
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def cancel_task(self, session_id: str, task_id: str) -> bool:
|
|
93
|
+
"""Cancel a task for a session."""
|
|
94
|
+
try:
|
|
95
|
+
self.connection_manager.ensure_connected()
|
|
96
|
+
response = self.connection_manager.cancel_task(session_id, task_id)
|
|
97
|
+
return response.get("success", False)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
raise TaskError(
|
|
100
|
+
format_error_message(exc, f"Failed to cancel task {task_id}")
|
|
101
|
+
)
|