glaip-sdk 0.0.1b5__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.
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env python3
2
+ """Base client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from typing import Any, Union
11
+
12
+ import httpx
13
+ from dotenv import load_dotenv
14
+
15
+ from glaip_sdk.exceptions import (
16
+ AuthenticationError,
17
+ ConflictError,
18
+ ForbiddenError,
19
+ NotFoundError,
20
+ RateLimitError,
21
+ ServerError,
22
+ TimeoutError,
23
+ ValidationError,
24
+ )
25
+
26
+ # Set up logging without basicConfig (library best practice)
27
+ logger = logging.getLogger("glaip_sdk")
28
+ logger.addHandler(logging.NullHandler())
29
+
30
+ client_log = logging.getLogger("glaip_sdk.client")
31
+ client_log.addHandler(logging.NullHandler())
32
+
33
+
34
+ class BaseClient:
35
+ """Base client with HTTP operations and authentication."""
36
+
37
+ def __init__(
38
+ self,
39
+ api_url: str | None = None,
40
+ api_key: str | None = None,
41
+ timeout: float = 30.0,
42
+ *,
43
+ parent_client: Union["BaseClient", None] = None,
44
+ load_env: bool = True,
45
+ ):
46
+ """Initialize the base client.
47
+
48
+ Args:
49
+ api_url: API base URL
50
+ api_key: API authentication key
51
+ timeout: Request timeout in seconds
52
+ parent_client: Parent client to adopt session/config from
53
+ load_env: Whether to load environment variables
54
+ """
55
+ self._parent_client = parent_client
56
+
57
+ if parent_client is not None:
58
+ # Adopt parent's session/config; DO NOT call super().__init__
59
+ client_log.debug("Adopting parent client configuration")
60
+ self.api_url = parent_client.api_url
61
+ self.api_key = parent_client.api_key
62
+ self._timeout = parent_client._timeout
63
+ self.http_client = parent_client.http_client
64
+ else:
65
+ # Initialize as standalone client
66
+ if load_env:
67
+ load_dotenv()
68
+
69
+ self.api_url = api_url or os.getenv("AIP_API_URL")
70
+ self.api_key = api_key or os.getenv("AIP_API_KEY")
71
+ self._timeout = timeout
72
+
73
+ if not self.api_url:
74
+ client_log.error("AIP_API_URL not found in environment or parameters")
75
+ raise ValueError("AIP_API_URL not found")
76
+ if not self.api_key:
77
+ client_log.error("AIP_API_KEY not found in environment or parameters")
78
+ raise ValueError("AIP_API_KEY not found")
79
+
80
+ client_log.info(f"Initializing client with API URL: {self.api_url}")
81
+ self.http_client = self._build_client(timeout)
82
+
83
+ def _build_client(self, timeout: float) -> httpx.Client:
84
+ """Build HTTP client with configuration."""
85
+ from glaip_sdk.config.constants import SDK_NAME, SDK_VERSION
86
+
87
+ return httpx.Client(
88
+ base_url=self.api_url,
89
+ headers={
90
+ "X-API-Key": self.api_key,
91
+ "User-Agent": f"{SDK_NAME}/{SDK_VERSION}",
92
+ },
93
+ timeout=httpx.Timeout(timeout),
94
+ follow_redirects=True,
95
+ http2=False,
96
+ limits=httpx.Limits(max_keepalive_connections=10, max_connections=100),
97
+ )
98
+
99
+ @property
100
+ def timeout(self) -> float:
101
+ """Get current timeout value."""
102
+ return self._timeout
103
+
104
+ @timeout.setter
105
+ def timeout(self, value: float):
106
+ """Set timeout and rebuild client."""
107
+ self._timeout = value
108
+ if (
109
+ hasattr(self, "http_client")
110
+ and self.http_client
111
+ and not self._parent_client
112
+ ):
113
+ self.http_client.close()
114
+ self.http_client = self._build_client(value)
115
+
116
+ def _request(self, method: str, endpoint: str, **kwargs) -> Any:
117
+ """Make HTTP request with error handling."""
118
+ client_log.debug(f"Making {method} request to {endpoint}")
119
+ try:
120
+ response = self.http_client.request(method, endpoint, **kwargs)
121
+ client_log.debug(f"Response status: {response.status_code}")
122
+ return self._handle_response(response)
123
+ except httpx.ConnectError as e:
124
+ client_log.warning(
125
+ f"Connection error on {method} {endpoint}, retrying once: {e}"
126
+ )
127
+ try:
128
+ response = self.http_client.request(method, endpoint, **kwargs)
129
+ client_log.debug(
130
+ f"Retry successful, response status: {response.status_code}"
131
+ )
132
+ return self._handle_response(response)
133
+ except httpx.ConnectError:
134
+ client_log.error(f"Retry failed for {method} {endpoint}: {e}")
135
+ raise e
136
+
137
+ def _handle_response(self, response: httpx.Response) -> Any:
138
+ """Handle HTTP response with proper error handling."""
139
+ if response.status_code == 204:
140
+ return None
141
+
142
+ parsed = None
143
+ content_type = response.headers.get("content-type", "").lower()
144
+ if "json" in content_type:
145
+ try:
146
+ parsed = response.json()
147
+ except ValueError:
148
+ pass
149
+
150
+ if parsed is None:
151
+ if 200 <= response.status_code < 300:
152
+ return response.text
153
+ else:
154
+ self._raise_api_error(response.status_code, response.text)
155
+
156
+ if isinstance(parsed, dict) and "success" in parsed:
157
+ if parsed.get("success"):
158
+ return parsed.get("data", parsed)
159
+ else:
160
+ error_type = parsed.get("error", "UnknownError")
161
+ message = parsed.get("message", "Unknown error")
162
+ self._raise_api_error(
163
+ response.status_code, message, error_type, payload=parsed
164
+ )
165
+
166
+ if 200 <= response.status_code < 300:
167
+ return parsed
168
+
169
+ message = parsed.get("message") if isinstance(parsed, dict) else str(parsed)
170
+ self._raise_api_error(response.status_code, message, payload=parsed)
171
+
172
+ def _raise_api_error(
173
+ self, status: int, message: str, error_type: str | None = None, *, payload=None
174
+ ):
175
+ """Raise appropriate exception with rich context."""
176
+ request_id = None
177
+ try:
178
+ request_id = self.http_client.headers.get("X-Request-Id")
179
+ except Exception:
180
+ pass
181
+
182
+ mapping = {
183
+ 400: ValidationError,
184
+ 401: AuthenticationError,
185
+ 403: ForbiddenError,
186
+ 404: NotFoundError,
187
+ 408: TimeoutError,
188
+ 409: ConflictError,
189
+ 429: RateLimitError,
190
+ 500: ServerError,
191
+ 503: ServerError,
192
+ 504: TimeoutError,
193
+ }
194
+
195
+ exception_class = mapping.get(status, ValidationError)
196
+ error_msg = f"HTTP {status}: {message}"
197
+ if request_id:
198
+ error_msg += f" (Request ID: {request_id})"
199
+
200
+ raise exception_class(
201
+ error_msg,
202
+ status_code=status,
203
+ error_type=error_type,
204
+ payload=payload,
205
+ request_id=request_id,
206
+ )
207
+
208
+ def close(self):
209
+ """Close the HTTP client."""
210
+ if (
211
+ hasattr(self, "http_client")
212
+ and self.http_client
213
+ and not self._parent_client
214
+ ):
215
+ self.http_client.close()
216
+
217
+ def __enter__(self):
218
+ """Context manager entry."""
219
+ return self
220
+
221
+ def __exit__(self, exc_type, exc_val, exc_tb):
222
+ """Context manager exit."""
223
+ self.close()
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """MCP client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ import logging
9
+ from typing import Any
10
+
11
+ from glaip_sdk.client.base import BaseClient
12
+ from glaip_sdk.models import MCP
13
+
14
+ # Set up module-level logger
15
+ logger = logging.getLogger("glaip_sdk.mcps")
16
+
17
+
18
+ class MCPClient(BaseClient):
19
+ """Client for MCP operations."""
20
+
21
+ def __init__(self, *, parent_client: BaseClient | None = None, **kwargs):
22
+ """Initialize the MCP client.
23
+
24
+ Args:
25
+ parent_client: Parent client to adopt session/config from
26
+ **kwargs: Additional arguments for standalone initialization
27
+ """
28
+ super().__init__(parent_client=parent_client, **kwargs)
29
+
30
+ def list_mcps(self) -> list[MCP]:
31
+ """List all MCPs."""
32
+ data = self._request("GET", "/mcps/")
33
+ return [MCP(**mcp_data)._set_client(self) for mcp_data in (data or [])]
34
+
35
+ def get_mcp_by_id(self, mcp_id: str) -> MCP:
36
+ """Get MCP by ID."""
37
+ data = self._request("GET", f"/mcps/{mcp_id}")
38
+ return MCP(**data)._set_client(self)
39
+
40
+ def find_mcps(self, name: str | None = None) -> list[MCP]:
41
+ """Find MCPs by name."""
42
+ # Backend doesn't support name query parameter, so we fetch all and filter client-side
43
+ data = self._request("GET", "/mcps/")
44
+ mcps = [MCP(**mcp_data)._set_client(self) for mcp_data in (data or [])]
45
+
46
+ if name:
47
+ # Client-side filtering by name (case-insensitive)
48
+ mcps = [mcp for mcp in mcps if name.lower() in mcp.name.lower()]
49
+
50
+ return mcps
51
+
52
+ def create_mcp(
53
+ self,
54
+ name: str,
55
+ description: str,
56
+ config: dict[str, Any] | None = None,
57
+ **kwargs,
58
+ ) -> MCP:
59
+ """Create a new MCP."""
60
+ payload = {
61
+ "name": name,
62
+ "description": description,
63
+ **kwargs,
64
+ }
65
+
66
+ if config:
67
+ payload["config"] = config
68
+
69
+ # Create the MCP (backend returns only the ID)
70
+ response_data = self._request("POST", "/mcps/", json=payload)
71
+
72
+ # Extract the ID from the response
73
+ if isinstance(response_data, dict) and "id" in response_data:
74
+ mcp_id = response_data["id"]
75
+ else:
76
+ # Fallback: assume response_data is the ID directly
77
+ mcp_id = str(response_data)
78
+
79
+ # Fetch the full MCP details
80
+ return self.get_mcp_by_id(mcp_id)
81
+
82
+ def update_mcp(self, mcp_id: str, **kwargs) -> MCP:
83
+ """Update an existing MCP."""
84
+ data = self._request("PUT", f"/mcps/{mcp_id}", json=kwargs)
85
+ return MCP(**data)._set_client(self)
86
+
87
+ def delete_mcp(self, mcp_id: str) -> None:
88
+ """Delete an MCP."""
89
+ self._request("DELETE", f"/mcps/{mcp_id}")
90
+
91
+ def get_mcp_tools(self, mcp_id: str) -> list[dict[str, Any]]:
92
+ """Get tools available from an MCP."""
93
+ data = self._request("GET", f"/mcps/{mcp_id}/tools")
94
+ return data or []
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """Tool client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ import logging
9
+
10
+ from glaip_sdk.client.base import BaseClient
11
+ from glaip_sdk.models import Tool
12
+
13
+ # Set up module-level logger
14
+ logger = logging.getLogger("glaip_sdk.tools")
15
+
16
+
17
+ class ToolClient(BaseClient):
18
+ """Client for tool operations."""
19
+
20
+ def __init__(self, *, parent_client: BaseClient | None = None, **kwargs):
21
+ """Initialize the tool client.
22
+
23
+ Args:
24
+ parent_client: Parent client to adopt session/config from
25
+ **kwargs: Additional arguments for standalone initialization
26
+ """
27
+ super().__init__(parent_client=parent_client, **kwargs)
28
+
29
+ def list_tools(self) -> list[Tool]:
30
+ """List all tools."""
31
+ data = self._request("GET", "/tools/")
32
+ return [Tool(**tool_data)._set_client(self) for tool_data in (data or [])]
33
+
34
+ def get_tool_by_id(self, tool_id: str) -> Tool:
35
+ """Get tool by ID."""
36
+ data = self._request("GET", f"/tools/{tool_id}")
37
+ return Tool(**data)._set_client(self)
38
+
39
+ def find_tools(self, name: str | None = None) -> list[Tool]:
40
+ """Find tools by name."""
41
+ # Backend doesn't support name query parameter, so we fetch all and filter client-side
42
+ data = self._request("GET", "/tools/")
43
+ tools = [Tool(**tool_data)._set_client(self) for tool_data in (data or [])]
44
+
45
+ if name:
46
+ # Client-side filtering by name (case-insensitive)
47
+ tools = [tool for tool in tools if name.lower() in tool.name.lower()]
48
+
49
+ return tools
50
+
51
+ def create_tool(
52
+ self,
53
+ name: str | None = None,
54
+ tool_type: str = "custom",
55
+ description: str | None = None,
56
+ tool_script: str | None = None,
57
+ tool_file: str | None = None,
58
+ file_path: str | None = None,
59
+ code: str | None = None,
60
+ framework: str = "langchain",
61
+ **kwargs,
62
+ ) -> Tool:
63
+ """Create a new tool.
64
+
65
+ Args:
66
+ name: Tool name (required if not provided via file)
67
+ tool_type: Tool type (defaults to "custom")
68
+ description: Tool description (optional)
69
+ tool_script: Tool script content (optional)
70
+ tool_file: Tool file path (optional)
71
+ file_path: Alternative to tool_file (for compatibility)
72
+ code: Alternative to tool_script (for compatibility)
73
+ framework: Tool framework (defaults to "langchain")
74
+ **kwargs: Additional tool parameters
75
+ """
76
+ # Handle compatibility parameters
77
+ if file_path and not tool_file:
78
+ tool_file = file_path
79
+ if code and not tool_script:
80
+ tool_script = code
81
+
82
+ # Auto-detect name from file if not provided
83
+ if not name and tool_file:
84
+ import os
85
+
86
+ name = os.path.splitext(os.path.basename(tool_file))[0]
87
+
88
+ if not name:
89
+ raise ValueError(
90
+ "Tool name is required (either explicitly or via file path)"
91
+ )
92
+
93
+ # Auto-detect description if not provided
94
+ if not description:
95
+ description = f"A {tool_type} tool"
96
+
97
+ payload = {
98
+ "name": name,
99
+ "tool_type": tool_type,
100
+ "description": description,
101
+ "framework": framework,
102
+ **kwargs,
103
+ }
104
+
105
+ if tool_script:
106
+ payload["tool_script"] = tool_script
107
+ if tool_file:
108
+ payload["tool_file"] = tool_file
109
+
110
+ data = self._request("POST", "/tools/", json=payload)
111
+ return Tool(**data)._set_client(self)
112
+
113
+ def create_tool_from_code(
114
+ self,
115
+ name: str,
116
+ code: str,
117
+ framework: str = "langchain",
118
+ ) -> Tool:
119
+ """Create a new tool plugin from code string.
120
+
121
+ This method uses the /tools/upload endpoint which properly processes
122
+ and registers tool plugins, unlike the regular create_tool method
123
+ which only creates metadata.
124
+
125
+ Args:
126
+ name: Name for the tool (used for temporary file naming)
127
+ code: Python code containing the tool plugin
128
+ framework: Tool framework (defaults to "langchain")
129
+
130
+ Returns:
131
+ Tool: The created tool object
132
+ """
133
+ import os
134
+ import tempfile
135
+
136
+ # Create a temporary file with the tool code
137
+ with tempfile.NamedTemporaryFile(
138
+ mode="w", suffix=".py", prefix=f"{name}_", delete=False, encoding="utf-8"
139
+ ) as temp_file:
140
+ temp_file.write(code)
141
+ temp_file_path = temp_file.name
142
+
143
+ try:
144
+ # Read the file content and upload it
145
+ with open(temp_file_path, encoding="utf-8") as f:
146
+ script_content = f.read()
147
+
148
+ # Create a file-like object for upload
149
+ import io
150
+
151
+ file_obj = io.BytesIO(script_content.encode("utf-8"))
152
+ file_obj.name = os.path.basename(temp_file_path)
153
+
154
+ # Use multipart form data for file upload
155
+ files = {"file": (file_obj.name, file_obj, "text/plain")}
156
+ data = {"framework": framework}
157
+
158
+ # Make the upload request
159
+ response = self._request("POST", "/tools/upload", files=files, data=data)
160
+ return Tool(**response)._set_client(self)
161
+ finally:
162
+ # Clean up the temporary file
163
+ try:
164
+ os.unlink(temp_file_path)
165
+ except OSError:
166
+ pass # Ignore cleanup errors
167
+
168
+ def update_tool(self, tool_id: str, **kwargs) -> Tool:
169
+ """Update an existing tool."""
170
+ data = self._request("PUT", f"/tools/{tool_id}", json=kwargs)
171
+ return Tool(**data)._set_client(self)
172
+
173
+ def delete_tool(self, tool_id: str) -> None:
174
+ """Delete a tool."""
175
+ self._request("DELETE", f"/tools/{tool_id}")
176
+
177
+ def install_tool(self, tool_id: str) -> bool:
178
+ """Install a tool."""
179
+ try:
180
+ self._request("POST", f"/tools/{tool_id}/install")
181
+ return True
182
+ except Exception as e:
183
+ logger.error(f"Failed to install tool {tool_id}: {e}")
184
+ return False
185
+
186
+ def uninstall_tool(self, tool_id: str) -> bool:
187
+ """Uninstall a tool."""
188
+ try:
189
+ self._request("POST", f"/tools/{tool_id}/uninstall")
190
+ return True
191
+ except Exception as e:
192
+ logger.error(f"Failed to uninstall tool {tool_id}: {e}")
193
+ return False
@@ -0,0 +1,166 @@
1
+ """Validation utilities for AIP SDK.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from glaip_sdk.exceptions import AmbiguousResourceError, NotFoundError, ValidationError
11
+ from glaip_sdk.models import Tool
12
+
13
+
14
+ class ResourceValidator:
15
+ """Validates and resolves resource references."""
16
+
17
+ RESERVED_NAMES = {
18
+ "research-agent",
19
+ "github-agent",
20
+ "aws-pricing-filter-generator-agent",
21
+ }
22
+
23
+ @classmethod
24
+ def is_reserved_name(cls, name: str) -> bool:
25
+ """Check if a name is reserved."""
26
+ return name in cls.RESERVED_NAMES
27
+
28
+ @classmethod
29
+ def extract_tool_ids(cls, tools: list[str | Tool], client) -> list[str]:
30
+ """Extract tool IDs from a list of tool names, IDs, or Tool objects.
31
+
32
+ For agent creation, the backend expects tool IDs (UUIDs).
33
+ This method handles:
34
+ - Tool objects (extracts their ID)
35
+ - UUID strings (passes through)
36
+ - Tool names (finds tool and extracts ID)
37
+ """
38
+ tool_ids = []
39
+ for tool in tools:
40
+ if isinstance(tool, str):
41
+ # Check if it's already a UUID
42
+ try:
43
+ UUID(tool)
44
+ tool_ids.append(tool) # Already a UUID string
45
+ except ValueError:
46
+ # It's a name, try to find the tool and get its ID
47
+ try:
48
+ found_tools = client.find_tools(name=tool)
49
+ if len(found_tools) == 1:
50
+ tool_ids.append(str(found_tools[0].id))
51
+ elif len(found_tools) > 1:
52
+ raise AmbiguousResourceError(
53
+ f"Multiple tools found with name '{tool}': {[t.id for t in found_tools]}"
54
+ )
55
+ else:
56
+ raise NotFoundError(f"Tool not found: {tool}")
57
+ except Exception as e:
58
+ raise ValidationError(
59
+ f"Failed to resolve tool name '{tool}' to ID: {e}"
60
+ )
61
+ elif hasattr(tool, "id") and tool.id is not None: # Tool object with ID
62
+ tool_ids.append(str(tool.id))
63
+ elif isinstance(tool, UUID): # UUID object
64
+ tool_ids.append(str(tool))
65
+ elif (
66
+ hasattr(tool, "name") and tool.name is not None
67
+ ): # Tool object with name but no ID
68
+ # Try to find the tool by name and get its ID
69
+ try:
70
+ found_tools = client.find_tools(name=tool.name)
71
+ if len(found_tools) == 1:
72
+ tool_ids.append(str(found_tools[0].id))
73
+ elif len(found_tools) > 1:
74
+ raise AmbiguousResourceError(
75
+ f"Multiple tools found with name '{tool.name}': {[t.id for t in found_tools]}"
76
+ )
77
+ else:
78
+ raise NotFoundError(f"Tool not found: {tool.name}")
79
+ except Exception as e:
80
+ raise ValidationError(
81
+ f"Failed to resolve tool name '{tool.name}' to ID: {e}"
82
+ )
83
+ else:
84
+ raise ValidationError(
85
+ f"Invalid tool reference: {tool} - must have 'id' or 'name' attribute"
86
+ )
87
+ return tool_ids
88
+
89
+ @classmethod
90
+ def extract_agent_ids(cls, agents: list[str | Any], client) -> list[str]:
91
+ """Extract agent IDs from a list of agent names, IDs, or agent objects.
92
+
93
+ For agent creation, the backend expects agent IDs (UUIDs).
94
+ This method handles:
95
+ - Agent objects (extracts their ID)
96
+ - UUID strings (passes through)
97
+ - Agent names (finds agent and extracts ID)
98
+ """
99
+ agent_ids = []
100
+ for agent in agents:
101
+ if isinstance(agent, str):
102
+ # Check if it's already a UUID
103
+ try:
104
+ UUID(agent)
105
+ agent_ids.append(agent) # Already a UUID string
106
+ except ValueError:
107
+ # It's a name, try to find the agent and get its ID
108
+ try:
109
+ found_agents = client.find_agents(name=agent)
110
+ if len(found_agents) == 1:
111
+ agent_ids.append(str(found_agents[0].id))
112
+ elif len(found_agents) > 1:
113
+ raise AmbiguousResourceError(
114
+ f"Multiple agents found with name '{agent}': {[a.id for a in found_agents]}"
115
+ )
116
+ else:
117
+ raise NotFoundError(f"Agent not found: {agent}")
118
+ except Exception as e:
119
+ raise ValidationError(
120
+ f"Failed to resolve agent name '{agent}' to ID: {e}"
121
+ )
122
+ elif hasattr(agent, "id") and agent.id is not None: # Agent object with ID
123
+ agent_ids.append(str(agent.id))
124
+ elif isinstance(agent, UUID): # UUID object
125
+ agent_ids.append(str(agent))
126
+ elif (
127
+ hasattr(agent, "name") and agent.name is not None
128
+ ): # Agent object with name but no ID
129
+ # Try to find the agent by name and get its ID
130
+ try:
131
+ found_agents = client.find_agents(name=agent.name)
132
+ if len(found_agents) == 1:
133
+ agent_ids.append(str(found_agents[0].id))
134
+ elif len(found_agents) > 1:
135
+ raise AmbiguousResourceError(
136
+ f"Multiple agents found with name '{agent.name}': {[a.id for a in found_agents]}"
137
+ )
138
+ else:
139
+ raise NotFoundError(f"Agent not found: {agent.name}")
140
+ except Exception as e:
141
+ raise ValidationError(
142
+ f"Failed to resolve agent name '{agent.name}' to ID: {e}"
143
+ )
144
+ else:
145
+ raise ValidationError(
146
+ f"Invalid agent reference: {agent} - must have 'id' or 'name' attribute"
147
+ )
148
+ return agent_ids
149
+
150
+ @classmethod
151
+ def validate_tools_exist(cls, tool_ids: list[str], client) -> None:
152
+ """Validate that all tool IDs exist."""
153
+ for tool_id in tool_ids:
154
+ try:
155
+ client.get_tool_by_id(tool_id)
156
+ except NotFoundError:
157
+ raise ValidationError(f"Tool not found: {tool_id}")
158
+
159
+ @classmethod
160
+ def validate_agents_exist(cls, agent_ids: list[str], client) -> None:
161
+ """Validate that all agent IDs exist."""
162
+ for agent_id in agent_ids:
163
+ try:
164
+ client.get_agent_by_id(agent_id)
165
+ except NotFoundError:
166
+ raise ValidationError(f"Agent not found: {agent_id}")