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,321 @@
1
+ """Base session manager class shared between gRPC and HTTP clients."""
2
+
3
+ import threading
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, Dict, List, Optional, Set
6
+
7
+ try:
8
+ from ..core.errors import SessionError
9
+ except ImportError:
10
+ # Fallback for standalone testing
11
+ class SessionError(Exception):
12
+ """Session error for standalone testing."""
13
+
14
+ pass
15
+
16
+
17
+ from .utils import (
18
+ generate_session_id,
19
+ sanitize_input,
20
+ validate_description,
21
+ validate_session_name,
22
+ )
23
+
24
+
25
+ class BaseSessionManager(ABC):
26
+ """Base class for session management."""
27
+
28
+ def __init__(self, connection_manager):
29
+ """Initialize base session manager."""
30
+ self.connection_manager = connection_manager
31
+ self.sessions: Dict[str, Any] = {}
32
+ self.sessions_lock = threading.RLock()
33
+ self._owned_sessions: Set[str] = set()
34
+
35
+ def create_session(
36
+ self,
37
+ session_id: Optional[str] = None,
38
+ user_id: Optional[str] = None,
39
+ name: Optional[str] = None,
40
+ description: Optional[str] = None,
41
+ namespace: Optional[str] = None,
42
+ api_key: Optional[str] = None,
43
+ ) -> str:
44
+ """Create a new session."""
45
+ # Validate inputs
46
+ if name and not validate_session_name(name):
47
+ raise SessionError(f"Invalid session name: {name}")
48
+
49
+ if description and not validate_description(description):
50
+ raise SessionError(
51
+ f"Session description too long: {len(description)} characters"
52
+ )
53
+
54
+ # Sanitize inputs
55
+ if name:
56
+ name = sanitize_input(name, max_length=100)
57
+ if description:
58
+ description = sanitize_input(description, max_length=500)
59
+ if namespace:
60
+ namespace = sanitize_input(namespace, max_length=100)
61
+
62
+ # Generate session ID if not provided
63
+ if not session_id:
64
+ session_id = generate_session_id()
65
+
66
+ try:
67
+ self.connection_manager.ensure_connected()
68
+ created_session_id = self._create_session_on_server(
69
+ session_id=session_id,
70
+ user_id=user_id,
71
+ name=name,
72
+ description=description,
73
+ namespace=namespace,
74
+ api_key=api_key,
75
+ )
76
+
77
+ # Store session info locally
78
+ with self.sessions_lock:
79
+ self.sessions[created_session_id] = {
80
+ "id": created_session_id,
81
+ "user_id": user_id,
82
+ "name": name,
83
+ "description": description,
84
+ "namespace": namespace,
85
+ "api_key": "",
86
+ "created_at": threading.current_thread().ident, # Basic tracking
87
+ }
88
+ self._owned_sessions.add(created_session_id)
89
+
90
+ return created_session_id
91
+
92
+ except Exception as e:
93
+ raise SessionError(f"Failed to create session: {e}")
94
+
95
+ @abstractmethod
96
+ def _create_session_on_server(
97
+ self,
98
+ session_id: str,
99
+ user_id: Optional[str],
100
+ name: Optional[str],
101
+ description: Optional[str],
102
+ namespace: Optional[str],
103
+ api_key: Optional[str],
104
+ ) -> str:
105
+ """Create session on server (protocol-specific)."""
106
+ pass
107
+
108
+ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
109
+ """Get session information."""
110
+ try:
111
+ self.connection_manager.ensure_connected()
112
+
113
+ # Try to get from local cache first
114
+ with self.sessions_lock:
115
+ local_session = self.sessions.get(session_id)
116
+ if local_session:
117
+ return local_session
118
+
119
+ # Get from server
120
+ server_session = self._get_session_from_server(session_id)
121
+
122
+ # Cache locally
123
+ if server_session:
124
+ with self.sessions_lock:
125
+ self.sessions[session_id] = server_session
126
+
127
+ return server_session
128
+
129
+ except Exception as e:
130
+ raise SessionError(f"Failed to get session {session_id}: {e}")
131
+
132
+ @abstractmethod
133
+ def _get_session_from_server(self, session_id: str) -> Optional[Dict[str, Any]]:
134
+ """Get session from server (protocol-specific)."""
135
+ pass
136
+
137
+ def delete_session(self, session_id: str):
138
+ """Delete a session."""
139
+ try:
140
+ self.connection_manager.ensure_connected()
141
+
142
+ # Delete from server
143
+ self._delete_session_on_server(session_id)
144
+
145
+ # Remove from local cache
146
+ with self.sessions_lock:
147
+ self.sessions.pop(session_id, None)
148
+ self._owned_sessions.discard(session_id)
149
+
150
+ except Exception as e:
151
+ raise SessionError(f"Failed to delete session {session_id}: {e}")
152
+
153
+ @abstractmethod
154
+ def _delete_session_on_server(self, session_id: str):
155
+ """Delete session on server (protocol-specific)."""
156
+ pass
157
+
158
+ def list_sessions(self) -> List[Dict[str, Any]]:
159
+ """List all sessions."""
160
+ try:
161
+ self.connection_manager.ensure_connected()
162
+ sessions = self._list_sessions_on_server()
163
+
164
+ # Update local cache
165
+ with self.sessions_lock:
166
+ for session in sessions:
167
+ session_id = session.get("id")
168
+ if session_id:
169
+ self.sessions[session_id] = session
170
+
171
+ return sessions
172
+
173
+ except Exception as e:
174
+ raise SessionError(f"Failed to list sessions: {e}")
175
+
176
+ @abstractmethod
177
+ def _list_sessions_on_server(self) -> List[Dict[str, Any]]:
178
+ """List sessions on server (protocol-specific)."""
179
+ pass
180
+
181
+ def session_exists(self, session_id: str) -> bool:
182
+ """Check if a session exists."""
183
+ try:
184
+ session = self.get_session(session_id)
185
+ return session is not None
186
+ except Exception:
187
+ return False
188
+
189
+ def get_session_info(self, session_id: str) -> Dict[str, Any]:
190
+ """Get detailed session information."""
191
+ session = self.get_session(session_id)
192
+ if not session:
193
+ raise SessionError(f"Session {session_id} not found")
194
+
195
+ return {
196
+ "id": session.get("id"),
197
+ "name": session.get("name"),
198
+ "description": session.get("description"),
199
+ "namespace": session.get("namespace"),
200
+ "user_id": session.get("user_id"),
201
+ "created_at": session.get("created_at"),
202
+ "status": session.get("status", "active"),
203
+ }
204
+
205
+ def update_session(
206
+ self,
207
+ session_id: str,
208
+ name: Optional[str] = None,
209
+ description: Optional[str] = None,
210
+ namespace: Optional[str] = None,
211
+ ):
212
+ """Update session information."""
213
+ # Validate inputs
214
+ if name and not validate_session_name(name):
215
+ raise SessionError(f"Invalid session name: {name}")
216
+
217
+ if description and not validate_description(description):
218
+ raise SessionError(
219
+ f"Session description too long: {len(description)} characters"
220
+ )
221
+
222
+ # Sanitize inputs
223
+ if name:
224
+ name = sanitize_input(name, max_length=100)
225
+ if description:
226
+ description = sanitize_input(description, max_length=500)
227
+ if namespace:
228
+ namespace = sanitize_input(namespace, max_length=100)
229
+
230
+ try:
231
+ self.connection_manager.ensure_connected()
232
+
233
+ # Update on server
234
+ updated_session = self._update_session_on_server(
235
+ session_id, name, description, namespace
236
+ )
237
+
238
+ # Update local cache
239
+ with self.sessions_lock:
240
+ if updated_session:
241
+ self.sessions[session_id] = updated_session
242
+ elif session_id in self.sessions:
243
+ if name:
244
+ self.sessions[session_id]["name"] = name
245
+ if description:
246
+ self.sessions[session_id]["description"] = description
247
+ if namespace:
248
+ self.sessions[session_id]["namespace"] = namespace
249
+
250
+ return updated_session
251
+
252
+ except Exception as e:
253
+ raise SessionError(f"Failed to update session {session_id}: {e}")
254
+
255
+ @abstractmethod
256
+ def _update_session_on_server(
257
+ self,
258
+ session_id: str,
259
+ name: Optional[str],
260
+ description: Optional[str],
261
+ namespace: Optional[str],
262
+ ) -> Dict[str, Any]:
263
+ """Update session on server (protocol-specific)."""
264
+ pass
265
+
266
+ def cleanup_session(self, session_id: str):
267
+ """Cleanup a session and its resources."""
268
+ try:
269
+ # Delete session only if this client created it
270
+ owned = False
271
+ with self.sessions_lock:
272
+ owned = session_id in self._owned_sessions
273
+
274
+ if owned:
275
+ self.delete_session(session_id)
276
+
277
+ # Remove from local cache
278
+ with self.sessions_lock:
279
+ self.sessions.pop(session_id, None)
280
+ self._owned_sessions.discard(session_id)
281
+
282
+ except Exception:
283
+ # Log error but don't raise for cleanup
284
+ pass
285
+
286
+ def cleanup_all(self):
287
+ """Cleanup all sessions."""
288
+ with self.sessions_lock:
289
+ session_ids = list(self.sessions.keys())
290
+ for session_id in session_ids:
291
+ self.cleanup_session(session_id)
292
+
293
+ def get_session_count(self) -> int:
294
+ """Get the number of active sessions."""
295
+ with self.sessions_lock:
296
+ return len(self.sessions)
297
+
298
+ def get_session_stats(self) -> Dict[str, Any]:
299
+ """Get session statistics."""
300
+ with self.sessions_lock:
301
+ sessions = list(self.sessions.values())
302
+
303
+ stats = {
304
+ "total_sessions": len(sessions),
305
+ "session_ids": list(self.sessions.keys()),
306
+ "sessions_by_namespace": {},
307
+ "sessions_by_user": {},
308
+ }
309
+
310
+ for session in sessions:
311
+ namespace = session.get("namespace", "default")
312
+ user_id = session.get("user_id", "unknown")
313
+
314
+ stats["sessions_by_namespace"][namespace] = (
315
+ stats["sessions_by_namespace"].get(namespace, 0) + 1
316
+ )
317
+ stats["sessions_by_user"][user_id] = (
318
+ stats["sessions_by_user"].get(user_id, 0) + 1
319
+ )
320
+
321
+ return stats
@@ -0,0 +1,347 @@
1
+ """Base tool manager class shared between gRPC and HTTP clients."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from threading import RLock
6
+ from typing import Any, Callable, Dict, List, Optional, Set
7
+
8
+ try:
9
+ from toolplane.utils.schema import generate_schema_from_function
10
+ except ImportError:
11
+ # Fallback for standalone testing
12
+ def generate_schema_from_function(func):
13
+ """Fallback schema generator for standalone testing."""
14
+ return {"type": "object", "properties": {}}
15
+
16
+
17
+ try:
18
+ from ..core.errors import ToolError, ToolplaneAPIError
19
+ except ImportError:
20
+ # Fallback for standalone testing
21
+ class ToolError(Exception):
22
+ """Tool error for standalone testing."""
23
+
24
+ pass
25
+
26
+ class ToolplaneAPIError(Exception):
27
+ """API error for standalone testing."""
28
+
29
+ pass
30
+
31
+
32
+ from .constants import CACHE_TTL_SECONDS
33
+ from .utils import (
34
+ cache_with_ttl,
35
+ sanitize_input,
36
+ validate_tool_name,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class BaseToolManager(ABC):
43
+ """Base class for tool management."""
44
+
45
+ def __init__(self, connection_manager):
46
+ """Initialize base tool manager."""
47
+ self.connection_manager = connection_manager
48
+ self.tools: Dict[str, Dict[str, Callable]] = (
49
+ {}
50
+ ) # session_id -> {tool_name: func}
51
+ self.tool_schemas: Dict[str, Dict[str, Dict]] = (
52
+ {}
53
+ ) # session_id -> {tool_name: schema}
54
+ self.streaming_tools: Dict[str, Set[str]] = {} # session_id -> {tool_names}
55
+ self._tool_cache: Dict[str, tuple] = {} # session_id -> (timestamp, tools)
56
+ self._lock = RLock()
57
+
58
+ def register_tool(
59
+ self,
60
+ session_id: str,
61
+ machine_id: str,
62
+ name: str,
63
+ func: Callable,
64
+ schema: Optional[Dict] = None,
65
+ description: Optional[str] = None,
66
+ stream: bool = False,
67
+ tags: Optional[List[str]] = None,
68
+ ):
69
+ """Register a tool for a session."""
70
+ # Validate inputs
71
+ if not validate_tool_name(name):
72
+ raise ToolError(f"Invalid tool name: {name}")
73
+
74
+ if tags is None:
75
+ tags = []
76
+
77
+ # Sanitize description
78
+ if description:
79
+ description = sanitize_input(description, max_length=500)
80
+
81
+ # Generate schema if not provided
82
+ if schema is None:
83
+ try:
84
+ schema = generate_schema_from_function(func)
85
+ except Exception as e:
86
+ raise ToolError(f"Failed to generate schema for tool {name}: {e}")
87
+
88
+ # Add description and tags to schema
89
+ if description:
90
+ schema["description"] = description
91
+ schema["tags"] = tags
92
+
93
+ # Store tool locally
94
+ with self._lock:
95
+ if session_id not in self.tools:
96
+ self.tools[session_id] = {}
97
+ self.tool_schemas[session_id] = {}
98
+ self.streaming_tools[session_id] = set()
99
+
100
+ self.tools[session_id][name] = func
101
+ self.tool_schemas[session_id][name] = schema
102
+
103
+ if stream:
104
+ self.streaming_tools[session_id].add(name)
105
+
106
+ # Register with server
107
+ self._register_tool_with_server(session_id, machine_id, name, schema)
108
+
109
+ @abstractmethod
110
+ def _register_tool_with_server(
111
+ self, session_id: str, machine_id: str, name: str, schema: Dict
112
+ ):
113
+ """Register tool with server (protocol-specific)."""
114
+ pass
115
+
116
+ def unregister_tool(self, session_id: str, name: str):
117
+ """Unregister a tool from a session."""
118
+ try:
119
+ # Remove from local storage
120
+ with self._lock:
121
+ if session_id in self.tools:
122
+ self.tools[session_id].pop(name, None)
123
+ self.tool_schemas[session_id].pop(name, None)
124
+ self.streaming_tools[session_id].discard(name)
125
+
126
+ # Remove from server
127
+ self._unregister_tool_from_server(session_id, name)
128
+
129
+ except Exception as e:
130
+ raise ToolError(f"Failed to unregister tool {name}: {e}")
131
+
132
+ @abstractmethod
133
+ def _unregister_tool_from_server(self, session_id: str, name: str):
134
+ """Unregister tool from server (protocol-specific)."""
135
+ pass
136
+
137
+ def get_tool(self, session_id: str, name: str) -> Optional[Callable]:
138
+ """Get a tool function by name."""
139
+ with self._lock:
140
+ return self.tools.get(session_id, {}).get(name)
141
+
142
+ def is_streaming_tool(self, session_id: str, name: str) -> bool:
143
+ """Check if a tool is a streaming tool."""
144
+ with self._lock:
145
+ return name in self.streaming_tools.get(session_id, set())
146
+
147
+ def get_session_tools(self, session_id: str) -> Dict[str, Callable]:
148
+ """Get all tools for a session."""
149
+ with self._lock:
150
+ return self.tools.get(session_id, {}).copy()
151
+
152
+ def get_tool_schema(self, session_id: str, name: str) -> Optional[Dict]:
153
+ """Get tool schema by name."""
154
+ with self._lock:
155
+ return self.tool_schemas.get(session_id, {}).get(name)
156
+
157
+ def list_session_tools(self, session_id: str) -> List[str]:
158
+ """List all tool names for a session."""
159
+ with self._lock:
160
+ return list(self.tools.get(session_id, {}).keys())
161
+
162
+ @cache_with_ttl(CACHE_TTL_SECONDS)
163
+ def get_available_tools(self, session_id: str) -> Dict[str, Any]:
164
+ """Get available tools from server (cached)."""
165
+ try:
166
+ self.connection_manager.ensure_connected()
167
+ return self._get_available_tools_from_server(session_id)
168
+
169
+ except Exception as e:
170
+ raise ToolError(f"Failed to get available tools: {e}")
171
+
172
+ @abstractmethod
173
+ def _get_available_tools_from_server(self, session_id: str) -> Dict[str, Any]:
174
+ """Get available tools from server (protocol-specific)."""
175
+ pass
176
+
177
+ def execute_tool(
178
+ self,
179
+ session_id: str,
180
+ tool_name: str,
181
+ params: Dict,
182
+ idempotency_key: str = "",
183
+ timeout_seconds: int = 0,
184
+ wait_timeout_seconds: int = 0,
185
+ ):
186
+ """Execute a tool and return ``(request_id, terminal_status, result)``.
187
+
188
+ timeout_seconds overrides the request's absolute per-attempt timeout
189
+ on the wire (0 keeps the server default). wait_timeout_seconds asks
190
+ the server to long-poll until the request is terminal; then
191
+ terminal_status names the final state and result carries the parsed
192
+ result value for a successful run, and both are ``(None, None)``
193
+ when the call returned while the request is still in flight.
194
+ """
195
+ try:
196
+ # Validate tool name
197
+ if not validate_tool_name(tool_name):
198
+ raise ToolError(f"Invalid tool name: {tool_name}")
199
+
200
+ self.connection_manager.ensure_connected()
201
+ return self._execute_tool_on_server(
202
+ session_id,
203
+ tool_name,
204
+ params,
205
+ idempotency_key,
206
+ timeout_seconds,
207
+ wait_timeout_seconds,
208
+ )
209
+
210
+ except ToolplaneAPIError:
211
+ # Typed server errors keep their identity for the caller.
212
+ raise
213
+ except Exception as e:
214
+ raise ToolError(f"Failed to execute tool {tool_name}: {e}")
215
+
216
+ @abstractmethod
217
+ def _execute_tool_on_server(
218
+ self,
219
+ session_id: str,
220
+ tool_name: str,
221
+ params: Dict,
222
+ idempotency_key: str = "",
223
+ timeout_seconds: int = 0,
224
+ ) -> str:
225
+ """Execute tool on server (protocol-specific)."""
226
+ pass
227
+
228
+ def stream_tool(
229
+ self,
230
+ session_id: str,
231
+ tool_name: str,
232
+ params: Dict,
233
+ idempotency_key: str = "",
234
+ timeout_seconds: int = 0,
235
+ ):
236
+ """Stream tool execution."""
237
+ try:
238
+ # Validate tool name
239
+ if not validate_tool_name(tool_name):
240
+ raise ToolError(f"Invalid tool name: {tool_name}")
241
+
242
+ self.connection_manager.ensure_connected()
243
+ yield from self._stream_tool_on_server(
244
+ session_id, tool_name, params, idempotency_key, timeout_seconds
245
+ )
246
+
247
+ except ToolplaneAPIError:
248
+ raise
249
+ except Exception as e:
250
+ raise ToolError(f"Failed to stream tool {tool_name}: {e}")
251
+
252
+ @abstractmethod
253
+ def _stream_tool_on_server(
254
+ self,
255
+ session_id: str,
256
+ tool_name: str,
257
+ params: Dict,
258
+ idempotency_key: str = "",
259
+ timeout_seconds: int = 0,
260
+ ):
261
+ """Stream tool execution on server (protocol-specific)."""
262
+ pass
263
+
264
+ def cleanup_session_tools(self, session_id: str):
265
+ """Cleanup all tools for a session."""
266
+ with self._lock:
267
+ if session_id in self.tools:
268
+ tool_names = list(self.tools[session_id].keys())
269
+ for tool_name in tool_names:
270
+ try:
271
+ self.unregister_tool(session_id, tool_name)
272
+ except Exception:
273
+ # Ignore cleanup errors
274
+ pass
275
+
276
+ # Clear local storage
277
+ self.tools.pop(session_id, None)
278
+ self.tool_schemas.pop(session_id, None)
279
+ self.streaming_tools.pop(session_id, None)
280
+
281
+ def cleanup_all(self):
282
+ """Cleanup all tools."""
283
+ with self._lock:
284
+ session_ids = list(self.tools.keys())
285
+ for session_id in session_ids:
286
+ self.cleanup_session_tools(session_id)
287
+
288
+ def get_tool_stats(self, session_id: str) -> Dict[str, Any]:
289
+ """Get tool statistics for a session."""
290
+ with self._lock:
291
+ session_tools = self.tools.get(session_id, {})
292
+ streaming_tools = self.streaming_tools.get(session_id, set())
293
+
294
+ return {
295
+ "total_tools": len(session_tools),
296
+ "streaming_tools": len(streaming_tools),
297
+ "regular_tools": len(session_tools) - len(streaming_tools),
298
+ "tool_names": list(session_tools.keys()),
299
+ "streaming_tool_names": list(streaming_tools),
300
+ }
301
+
302
+ def reregister_session_tools(self, session_id: str, machine_id: Optional[str]):
303
+ """Re-register all tools for a session after reconnecting."""
304
+ if not machine_id:
305
+ return
306
+
307
+ with self._lock:
308
+ schemas = list(self.tool_schemas.get(session_id, {}).items())
309
+
310
+ for tool_name, schema in schemas:
311
+ try:
312
+ self._register_tool_with_server(
313
+ session_id, machine_id, tool_name, schema
314
+ )
315
+ except Exception as exc:
316
+ # Registration is an upsert: same-machine re-register never
317
+ # conflicts, so every failure here is real (including a name
318
+ # owned by another live machine, which the server reports as
319
+ # FAILED_PRECONDITION) and surfaces as a warning.
320
+ logger.warning(
321
+ "Failed to re-register tool %s for session %s: %s",
322
+ tool_name,
323
+ session_id,
324
+ exc,
325
+ )
326
+
327
+ def validate_tool_params(
328
+ self, session_id: str, tool_name: str, params: Dict
329
+ ) -> bool:
330
+ """Validate tool parameters against schema."""
331
+ schema = self.get_tool_schema(session_id, tool_name)
332
+ if not schema:
333
+ return True # No schema to validate against
334
+
335
+ try:
336
+ # Basic validation - can be extended with jsonschema
337
+ tool_schema = schema.get("schema", {})
338
+ if "properties" in tool_schema:
339
+ required_fields = tool_schema.get("required", [])
340
+ for field in required_fields:
341
+ if field not in params:
342
+ return False
343
+
344
+ return True
345
+
346
+ except Exception:
347
+ return False