toolplane-python-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. toolplane/__init__.py +106 -0
  2. toolplane/common/__init__.py +93 -0
  3. toolplane/common/base_config.py +129 -0
  4. toolplane/common/base_connection_manager.py +171 -0
  5. toolplane/common/base_session_manager.py +321 -0
  6. toolplane/common/base_tool_manager.py +347 -0
  7. toolplane/common/constants.py +47 -0
  8. toolplane/common/utils.py +310 -0
  9. toolplane/core/__init__.py +67 -0
  10. toolplane/core/config.py +107 -0
  11. toolplane/core/connection.py +285 -0
  12. toolplane/core/errors.py +298 -0
  13. toolplane/core/machine.py +480 -0
  14. toolplane/core/request.py +775 -0
  15. toolplane/core/session.py +332 -0
  16. toolplane/core/session_context.py +514 -0
  17. toolplane/core/task.py +130 -0
  18. toolplane/core/tool.py +329 -0
  19. toolplane/http_core/__init__.py +37 -0
  20. toolplane/http_core/http_config.py +97 -0
  21. toolplane/http_core/http_connection.py +409 -0
  22. toolplane/http_core/http_machine.py +298 -0
  23. toolplane/http_core/http_request.py +748 -0
  24. toolplane/http_core/http_session.py +348 -0
  25. toolplane/http_core/http_session_context.py +491 -0
  26. toolplane/http_core/http_task.py +101 -0
  27. toolplane/http_core/http_tool.py +400 -0
  28. toolplane/interfaces/__init__.py +27 -0
  29. toolplane/interfaces/client_interface.py +122 -0
  30. toolplane/interfaces/connection_interface.py +193 -0
  31. toolplane/interfaces/event_interface.py +290 -0
  32. toolplane/interfaces/request_interface.py +439 -0
  33. toolplane/interfaces/session_interface.py +288 -0
  34. toolplane/interfaces/tool_interface.py +441 -0
  35. toolplane/proto/__init__.py +0 -0
  36. toolplane/proto/service_pb2.py +315 -0
  37. toolplane/proto/service_pb2_grpc.py +2240 -0
  38. toolplane/provider_cli.py +268 -0
  39. toolplane/provider_registry.py +77 -0
  40. toolplane/provider_runtime.py +302 -0
  41. toolplane/toolkits/__init__.py +0 -0
  42. toolplane/toolkits/standalone_tools/__init__.py +0 -0
  43. toolplane/toolkits/standalone_tools/create_directory.py +94 -0
  44. toolplane/toolkits/standalone_tools/create_file.py +124 -0
  45. toolplane/toolkits/standalone_tools/file_search.py +229 -0
  46. toolplane/toolkits/standalone_tools/grep_search.py +372 -0
  47. toolplane/toolkits/standalone_tools/launcher.py +146 -0
  48. toolplane/toolkits/standalone_tools/list_dir.py +395 -0
  49. toolplane/toolkits/standalone_tools/read_file.py +346 -0
  50. toolplane/toolkits/standalone_tools/replace_string_in_file.py +407 -0
  51. toolplane/toolkits/standalone_tools/run_tests.py +66 -0
  52. toolplane/toolkits/standalone_tools/semantic_search.py +485 -0
  53. toolplane/toolkits/standalone_tools/standalone_toolkit.py +979 -0
  54. toolplane/toolkits/standalone_tools/test_failure_analysis.py +618 -0
  55. toolplane/toolkits/standalone_tools/test_standalone_toolkit.py +517 -0
  56. toolplane/toolkits/swe/__init__.py +35 -0
  57. toolplane/toolkits/swe/create_directory.py +15 -0
  58. toolplane/toolkits/swe/create_file.py +15 -0
  59. toolplane/toolkits/swe/descriptions.py +273 -0
  60. toolplane/toolkits/swe/execute_bash.py +93 -0
  61. toolplane/toolkits/swe/file_editor.py +775 -0
  62. toolplane/toolkits/swe/file_search.py +16 -0
  63. toolplane/toolkits/swe/finish.py +50 -0
  64. toolplane/toolkits/swe/grep_search.py +19 -0
  65. toolplane/toolkits/swe/list_dir.py +407 -0
  66. toolplane/toolkits/swe/read_file.py +18 -0
  67. toolplane/toolkits/swe/replace_string_in_file.py +17 -0
  68. toolplane/toolkits/swe/search.py +260 -0
  69. toolplane/toolkits/swe/semantic_search.py +20 -0
  70. toolplane/toolkits/swe/str_replace_editor.py +647 -0
  71. toolplane/toolkits/swe/submit.py +29 -0
  72. toolplane/toolkits/swe/swe_toolkit.py +1296 -0
  73. toolplane/toolplane_client.py +686 -0
  74. toolplane/toolplane_http_client.py +681 -0
  75. toolplane/utils/__init__.py +3 -0
  76. toolplane/utils/schema.py +146 -0
  77. toolplane_python_client-0.1.0.dist-info/METADATA +543 -0
  78. toolplane_python_client-0.1.0.dist-info/RECORD +81 -0
  79. toolplane_python_client-0.1.0.dist-info/WHEEL +5 -0
  80. toolplane_python_client-0.1.0.dist-info/entry_points.txt +2 -0
  81. toolplane_python_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )