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