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