glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.16__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 (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/METADATA +4 -5
  150. glaip_sdk-0.6.16.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,21 @@
1
+ """Shared helpers for client configuration wiring.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from glaip_sdk.client.base import BaseClient
12
+
13
+
14
+ def build_shared_config(client: BaseClient) -> dict[str, Any]:
15
+ """Return the keyword arguments used to initialize sub-clients."""
16
+ return {
17
+ "parent_client": client,
18
+ "api_url": client.api_url,
19
+ "api_key": client.api_key,
20
+ "timeout": client._timeout,
21
+ }
@@ -0,0 +1,661 @@
1
+ #!/usr/bin/env python3
2
+ """Tool client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
7
+ """
8
+
9
+ import logging
10
+ import os
11
+ import tempfile
12
+ from typing import Any
13
+
14
+ from glaip_sdk.client.base import BaseClient
15
+ from glaip_sdk.config.constants import (
16
+ DEFAULT_TOOL_FRAMEWORK,
17
+ DEFAULT_TOOL_TYPE,
18
+ DEFAULT_TOOL_VERSION,
19
+ )
20
+ from glaip_sdk.models import ToolResponse
21
+ from glaip_sdk.tools import Tool
22
+ from glaip_sdk.utils.client_utils import (
23
+ add_kwargs_to_payload,
24
+ create_model_instances,
25
+ find_by_name,
26
+ )
27
+ from glaip_sdk.utils.resource_refs import is_uuid
28
+
29
+ # API endpoints
30
+ TOOLS_ENDPOINT = "/tools/"
31
+ TOOLS_UPLOAD_ENDPOINT = "/tools/upload"
32
+ TOOLS_UPLOAD_BY_ID_ENDPOINT_FMT = "/tools/{tool_id}/upload"
33
+
34
+ # Set up module-level logger
35
+ logger = logging.getLogger("glaip_sdk.tools")
36
+
37
+
38
+ class ToolClient(BaseClient):
39
+ """Client for tool operations."""
40
+
41
+ def __init__(self, *, parent_client: BaseClient | None = None, **kwargs):
42
+ """Initialize the tool client.
43
+
44
+ Args:
45
+ parent_client: Parent client to adopt session/config from
46
+ **kwargs: Additional arguments for standalone initialization
47
+ """
48
+ super().__init__(parent_client=parent_client, **kwargs)
49
+
50
+ def list_tools(self, tool_type: str | None = None) -> list[Tool]:
51
+ """List all tools, optionally filtered by type.
52
+
53
+ Args:
54
+ tool_type: Filter tools by type (e.g., "custom", "native")
55
+ """
56
+ endpoint = TOOLS_ENDPOINT
57
+ if tool_type:
58
+ endpoint += f"?type={tool_type}"
59
+ data = self._request("GET", endpoint)
60
+ return create_model_instances(data, Tool, self)
61
+
62
+ def get_tool_by_id(self, tool_id: str) -> Tool:
63
+ """Get tool by ID."""
64
+ data = self._request("GET", f"{TOOLS_ENDPOINT}{tool_id}")
65
+ response = ToolResponse(**data)
66
+ return Tool.from_response(response, client=self)
67
+
68
+ def find_tools(self, name: str | None = None) -> list[Tool]:
69
+ """Find tools by name."""
70
+ data = self._request("GET", TOOLS_ENDPOINT)
71
+ tools = create_model_instances(data, Tool, self)
72
+ return find_by_name(tools, name, case_sensitive=False)
73
+
74
+ def _validate_and_read_file(self, file_path: str) -> str:
75
+ """Validate file exists and read its content.
76
+
77
+ Args:
78
+ file_path: Path to the file to read
79
+
80
+ Returns:
81
+ str: File content
82
+
83
+ Raises:
84
+ FileNotFoundError: If file doesn't exist
85
+ """
86
+ if not os.path.exists(file_path):
87
+ raise FileNotFoundError(f"Tool file not found: {file_path}")
88
+
89
+ with open(file_path, encoding="utf-8") as f:
90
+ return f.read()
91
+
92
+ def _extract_name_from_file(self, file_path: str) -> str:
93
+ """Extract tool name from file path.
94
+
95
+ Args:
96
+ file_path: Path to the file
97
+
98
+ Returns:
99
+ str: Extracted name (filename without extension)
100
+ """
101
+ return os.path.splitext(os.path.basename(file_path))[0]
102
+
103
+ def _prepare_upload_data(self, name: str, framework: str, description: str | None = None, **kwargs) -> dict:
104
+ """Prepare upload data dictionary.
105
+
106
+ Args:
107
+ name: Tool name
108
+ framework: Tool framework
109
+ description: Optional description
110
+ **kwargs: Additional parameters
111
+
112
+ Returns:
113
+ dict: Upload data dictionary
114
+ """
115
+ data = {
116
+ "name": name,
117
+ "framework": framework,
118
+ "type": kwargs.pop("tool_type", DEFAULT_TOOL_TYPE), # Default to custom
119
+ }
120
+
121
+ if description:
122
+ data["description"] = description
123
+
124
+ # Handle tags if provided in kwargs
125
+ if kwargs.get("tags"):
126
+ if isinstance(kwargs["tags"], list):
127
+ data["tags"] = ",".join(kwargs["tags"])
128
+ else:
129
+ data["tags"] = kwargs["tags"]
130
+
131
+ # Include any other kwargs in the upload data
132
+ for key, value in kwargs.items():
133
+ if key not in ["tags"]: # tags already handled above
134
+ data[key] = value
135
+
136
+ return data
137
+
138
+ def _upload_tool_file(self, file_path: str, upload_data: dict) -> Tool:
139
+ """Upload tool file to server.
140
+
141
+ Args:
142
+ file_path: Path to temporary file to upload
143
+ upload_data: Dictionary with upload metadata
144
+
145
+ Returns:
146
+ Tool: Created tool object
147
+ """
148
+ with open(file_path, "rb") as fb:
149
+ files = {
150
+ "file": (os.path.basename(file_path), fb, "application/octet-stream"),
151
+ }
152
+
153
+ response = self._request(
154
+ "POST",
155
+ TOOLS_UPLOAD_ENDPOINT,
156
+ files=files,
157
+ data=upload_data,
158
+ )
159
+
160
+ tool_response = ToolResponse(**response)
161
+ return Tool.from_response(tool_response, client=self)
162
+
163
+ def _build_create_payload(
164
+ self,
165
+ name: str,
166
+ description: str | None = None,
167
+ framework: str = DEFAULT_TOOL_FRAMEWORK,
168
+ tool_type: str = DEFAULT_TOOL_TYPE,
169
+ **kwargs,
170
+ ) -> dict[str, Any]:
171
+ """Build payload for tool creation with proper metadata handling.
172
+
173
+ CENTRALIZED PAYLOAD BUILDING LOGIC:
174
+ - Handles file vs metadata-only tool creation
175
+ - Sets proper defaults and required fields
176
+ - Processes tags and other metadata consistently
177
+
178
+ Args:
179
+ name: Tool name
180
+ description: Tool description
181
+ framework: Tool framework (defaults to langchain)
182
+ tool_type: Tool type (defaults to custom)
183
+ **kwargs: Additional parameters (tags, version, etc.)
184
+
185
+ Returns:
186
+ Complete payload dictionary for tool creation
187
+ """
188
+ # Prepare the creation payload with required fields
189
+ payload: dict[str, any] = {
190
+ "name": name.strip(),
191
+ "type": tool_type,
192
+ "framework": framework,
193
+ "version": kwargs.get("version", DEFAULT_TOOL_VERSION),
194
+ }
195
+
196
+ # Add description if provided
197
+ if description:
198
+ payload["description"] = description.strip()
199
+
200
+ # Handle tags - convert list to comma-separated string for API
201
+ if kwargs.get("tags"):
202
+ if isinstance(kwargs["tags"], list):
203
+ payload["tags"] = ",".join(str(tag).strip() for tag in kwargs["tags"])
204
+ else:
205
+ payload["tags"] = str(kwargs["tags"])
206
+
207
+ # Add any other kwargs (excluding already handled ones)
208
+ excluded_keys = {"tags", "version"}
209
+ add_kwargs_to_payload(payload, kwargs, excluded_keys)
210
+
211
+ return payload
212
+
213
+ def _handle_description_update(
214
+ self, update_data: dict[str, Any], description: str | None, current_tool: Tool
215
+ ) -> None:
216
+ """Handle description field in update payload."""
217
+ if description is not None:
218
+ update_data["description"] = description.strip()
219
+ elif hasattr(current_tool, "description") and current_tool.description:
220
+ update_data["description"] = current_tool.description
221
+
222
+ def _handle_tags_update(self, update_data: dict[str, Any], kwargs: dict[str, Any], current_tool: Tool) -> None:
223
+ """Handle tags field in update payload."""
224
+ if kwargs.get("tags"):
225
+ if isinstance(kwargs["tags"], list):
226
+ update_data["tags"] = ",".join(str(tag).strip() for tag in kwargs["tags"])
227
+ else:
228
+ update_data["tags"] = str(kwargs["tags"])
229
+ elif hasattr(current_tool, "tags") and current_tool.tags:
230
+ # Preserve existing tags if present
231
+ if isinstance(current_tool.tags, list):
232
+ update_data["tags"] = ",".join(str(tag).strip() for tag in current_tool.tags)
233
+ else:
234
+ update_data["tags"] = str(current_tool.tags)
235
+
236
+ def _handle_additional_kwargs(self, update_data: dict[str, Any], kwargs: dict[str, Any]) -> None:
237
+ """Handle additional kwargs in update payload."""
238
+ excluded_keys = {
239
+ "tags",
240
+ "framework",
241
+ "version",
242
+ "type",
243
+ "tool_type",
244
+ "name",
245
+ "description",
246
+ }
247
+ for key, value in kwargs.items():
248
+ if key not in excluded_keys:
249
+ update_data[key] = value
250
+
251
+ def _build_update_payload(
252
+ self,
253
+ current_tool: Tool,
254
+ name: str | None = None,
255
+ description: str | None = None,
256
+ **kwargs,
257
+ ) -> dict[str, Any]:
258
+ """Build payload for tool update with proper current state preservation.
259
+
260
+ Args:
261
+ current_tool: Current tool object to update
262
+ name: New tool name (None to keep current)
263
+ description: New description (None to keep current)
264
+ **kwargs: Additional parameters (tags, framework, etc.)
265
+
266
+ Returns:
267
+ Complete payload dictionary for tool update
268
+
269
+ Notes:
270
+ - Preserves current values as defaults when new values not provided
271
+ - Handles metadata updates properly
272
+ """
273
+ # Prepare the update payload with current values as defaults
274
+ type_override = kwargs.pop("type", None)
275
+ if type_override is None:
276
+ type_override = kwargs.pop("tool_type", None)
277
+ current_type = (
278
+ type_override
279
+ or getattr(current_tool, "tool_type", None)
280
+ or getattr(current_tool, "type", None)
281
+ or DEFAULT_TOOL_TYPE
282
+ )
283
+ # Convert enum to string value for API payload
284
+ if hasattr(current_type, "value"):
285
+ current_type = current_type.value
286
+
287
+ update_data = {
288
+ "name": name if name is not None else current_tool.name,
289
+ "type": current_type,
290
+ "framework": kwargs.get("framework", getattr(current_tool, "framework", DEFAULT_TOOL_FRAMEWORK)),
291
+ "version": kwargs.get("version", getattr(current_tool, "version", DEFAULT_TOOL_VERSION)),
292
+ }
293
+
294
+ # Handle description update
295
+ self._handle_description_update(update_data, description, current_tool)
296
+
297
+ # Handle tags update
298
+ self._handle_tags_update(update_data, kwargs, current_tool)
299
+
300
+ # Handle additional kwargs
301
+ self._handle_additional_kwargs(update_data, kwargs)
302
+
303
+ return update_data
304
+
305
+ def _create_tool_from_file(
306
+ self,
307
+ file_path: str,
308
+ name: str | None = None,
309
+ description: str | None = None,
310
+ framework: str = "langchain",
311
+ **kwargs,
312
+ ) -> Tool:
313
+ """Create tool from file content using upload endpoint.
314
+
315
+ Args:
316
+ file_path: Path to tool file
317
+ name: Optional tool name (auto-detected if not provided)
318
+ description: Optional tool description
319
+ framework: Tool framework
320
+ **kwargs: Additional parameters
321
+
322
+ Returns:
323
+ Tool: Created tool object
324
+ """
325
+ # Read and validate file
326
+ file_content = self._validate_and_read_file(file_path)
327
+
328
+ # Auto-detect name if not provided
329
+ if not name:
330
+ name = self._extract_name_from_file(file_path)
331
+
332
+ # Handle description - generate default if not provided or empty
333
+ if description is None or description == "":
334
+ # Generate default description based on tool_type if available
335
+ tool_type = kwargs.get("tool_type", "custom")
336
+ description = f"A {tool_type} tool"
337
+
338
+ # Create temporary file for upload
339
+ with tempfile.NamedTemporaryFile(
340
+ mode="w",
341
+ suffix=".py",
342
+ prefix=f"{name}_",
343
+ delete=False,
344
+ encoding="utf-8",
345
+ ) as temp_file:
346
+ temp_file.write(file_content)
347
+ temp_file_path = temp_file.name
348
+
349
+ try:
350
+ # Prepare upload data
351
+ upload_data = self._prepare_upload_data(name=name, framework=framework, description=description, **kwargs)
352
+
353
+ # Upload file
354
+ return self._upload_tool_file(temp_file_path, upload_data)
355
+
356
+ finally:
357
+ # Clean up temporary file
358
+ try:
359
+ os.unlink(temp_file_path)
360
+ except OSError:
361
+ pass # Ignore cleanup errors
362
+
363
+ def create_tool(
364
+ self,
365
+ file_path: str,
366
+ name: str | None = None,
367
+ description: str | None = None,
368
+ framework: str = "langchain",
369
+ **kwargs,
370
+ ) -> Tool:
371
+ """Create a new tool from a file.
372
+
373
+ Args:
374
+ file_path: File path to tool script (required) - file content will be read and processed as plugin
375
+ name: Tool name (auto-detected from file if not provided)
376
+ description: Tool description (auto-generated if not provided)
377
+ framework: Tool framework (defaults to "langchain")
378
+ **kwargs: Additional tool parameters
379
+ """
380
+ return self._create_tool_from_file(
381
+ file_path=file_path,
382
+ name=name,
383
+ description=description,
384
+ framework=framework,
385
+ **kwargs,
386
+ )
387
+
388
+ def create_tool_from_code(
389
+ self,
390
+ name: str,
391
+ code: str,
392
+ framework: str = "langchain",
393
+ description: str | None = None,
394
+ tags: list[str] | None = None,
395
+ ) -> Tool:
396
+ """Create a new tool plugin from code string.
397
+
398
+ This method uses the /tools/upload endpoint which properly processes
399
+ and registers tool plugins, unlike the regular create_tool method
400
+ which only creates metadata.
401
+
402
+ Args:
403
+ name: Name for the tool (used for temporary file naming)
404
+ code: Python code containing the tool plugin
405
+ framework: Tool framework (defaults to "langchain")
406
+ description: Optional tool description
407
+ tags: Optional list of tags
408
+
409
+ Returns:
410
+ Tool: The created tool object
411
+ """
412
+ # Create a temporary file with the tool code
413
+ with tempfile.NamedTemporaryFile(
414
+ mode="w",
415
+ suffix=".py",
416
+ prefix=f"{name}_",
417
+ delete=False,
418
+ encoding="utf-8",
419
+ ) as temp_file:
420
+ temp_file.write(code)
421
+ temp_file_path = temp_file.name
422
+
423
+ try:
424
+ # Prepare upload data using shared helper
425
+ upload_data = self._prepare_upload_data(
426
+ name=name,
427
+ framework=framework,
428
+ description=description,
429
+ tags=tags if tags else None,
430
+ )
431
+
432
+ # Upload file using shared helper
433
+ return self._upload_tool_file(temp_file_path, upload_data)
434
+
435
+ finally:
436
+ # Clean up the temporary file
437
+ try:
438
+ os.unlink(temp_file_path)
439
+ except OSError:
440
+ pass # Ignore cleanup errors
441
+
442
+ def update_tool(self, tool_id: str, **kwargs) -> Tool:
443
+ """Update an existing tool."""
444
+ data = self._request("PUT", f"{TOOLS_ENDPOINT}{tool_id}", json=kwargs)
445
+ response = ToolResponse(**data)
446
+ return Tool.from_response(response, client=self)
447
+
448
+ def delete_tool(self, tool_id: str) -> None:
449
+ """Delete a tool."""
450
+ self._request("DELETE", f"{TOOLS_ENDPOINT}{tool_id}")
451
+
452
+ def upsert_tool(
453
+ self,
454
+ identifier: str | Tool,
455
+ code: str | None = None,
456
+ description: str | None = None,
457
+ framework: str = "langchain",
458
+ **kwargs,
459
+ ) -> Tool:
460
+ """Create or update a tool by instance, ID, or name.
461
+
462
+ Args:
463
+ identifier: Tool instance, ID (UUID string), or name
464
+ code: Python code containing the tool plugin (required for create)
465
+ description: Tool description
466
+ framework: Tool framework (defaults to "langchain")
467
+ **kwargs: Additional parameters (tags, version, etc.)
468
+
469
+ Returns:
470
+ The created or updated tool.
471
+
472
+ Example:
473
+ >>> # By name with code (creates if not exists)
474
+ >>> tool = client.tools.upsert_tool(
475
+ ... "greeting",
476
+ ... code=bundled_source,
477
+ ... description="A greeting tool",
478
+ ... )
479
+ >>> # By instance
480
+ >>> tool = client.tools.upsert_tool(existing_tool, code=new_code)
481
+ >>> # By ID
482
+ >>> tool = client.tools.upsert_tool("uuid-here", code=new_code)
483
+ """
484
+ # Handle Tool instance
485
+ if isinstance(identifier, Tool):
486
+ if identifier.id:
487
+ logger.info("Updating tool by instance: %s", identifier.name)
488
+ return self._do_tool_upsert_update(
489
+ identifier.id,
490
+ identifier.name,
491
+ code,
492
+ description,
493
+ framework,
494
+ **kwargs,
495
+ )
496
+ identifier = identifier.name
497
+
498
+ # Handle string (ID or name)
499
+ if isinstance(identifier, str):
500
+ if is_uuid(identifier):
501
+ logger.info("Updating tool by ID: %s", identifier)
502
+ existing = self.get_tool_by_id(identifier)
503
+ return self._do_tool_upsert_update(identifier, existing.name, code, description, framework, **kwargs)
504
+
505
+ # It's a name - find or create
506
+ return self._upsert_tool_by_name(identifier, code, description, framework, **kwargs)
507
+
508
+ raise ValueError(f"Invalid identifier type: {type(identifier)}")
509
+
510
+ def _do_tool_upsert_update(
511
+ self,
512
+ tool_id: str,
513
+ name: str | None,
514
+ code: str | None,
515
+ description: str | None,
516
+ framework: str,
517
+ **kwargs,
518
+ ) -> Tool:
519
+ """Perform the update part of tool upsert."""
520
+ if code:
521
+ # Update via file upload
522
+ with tempfile.NamedTemporaryFile(
523
+ mode="w",
524
+ suffix=".py",
525
+ prefix=f"{name or 'tool'}_",
526
+ delete=False,
527
+ encoding="utf-8",
528
+ ) as temp_file:
529
+ temp_file.write(code)
530
+ temp_file_path = temp_file.name
531
+
532
+ try:
533
+ return self.update_tool_via_file(
534
+ tool_id,
535
+ temp_file_path,
536
+ name=name,
537
+ description=description,
538
+ framework=framework,
539
+ **kwargs,
540
+ )
541
+ finally:
542
+ try:
543
+ os.unlink(temp_file_path)
544
+ except OSError:
545
+ pass
546
+ else:
547
+ # Metadata-only update
548
+ update_kwargs = {"framework": framework, **kwargs}
549
+ if name:
550
+ update_kwargs["name"] = name
551
+ if description:
552
+ update_kwargs["description"] = description
553
+ return self.update_tool(tool_id, **update_kwargs)
554
+
555
+ def _upsert_tool_by_name(
556
+ self,
557
+ name: str,
558
+ code: str | None,
559
+ description: str | None,
560
+ framework: str,
561
+ **kwargs,
562
+ ) -> Tool:
563
+ """Find tool by name and update, or create if not found."""
564
+ existing = self.find_tools(name)
565
+ name_lower = name.lower()
566
+ exact_matches = [tool for tool in existing if tool.name and tool.name.lower() == name_lower]
567
+
568
+ if len(exact_matches) == 1:
569
+ logger.info("Updating existing tool: %s", name)
570
+ return self._do_tool_upsert_update(exact_matches[0].id, name, code, description, framework, **kwargs)
571
+
572
+ if len(exact_matches) > 1:
573
+ raise ValueError(f"Multiple tools found with name '{name}'")
574
+
575
+ # Create new tool - code is required
576
+ if not code:
577
+ raise ValueError(f"Tool '{name}' not found and no code provided for creation")
578
+
579
+ logger.info("Creating new tool: %s", name)
580
+ return self.create_tool_from_code(
581
+ name=name,
582
+ code=code,
583
+ framework=framework,
584
+ description=description,
585
+ **kwargs,
586
+ )
587
+
588
+ def get_tool_script(self, tool_id: str) -> str:
589
+ """Get the tool script content.
590
+
591
+ Args:
592
+ tool_id: The ID of the tool
593
+
594
+ Returns:
595
+ str: The tool script content
596
+
597
+ Raises:
598
+ Exception: If the tool script cannot be retrieved
599
+ """
600
+ try:
601
+ response = self._request("GET", f"{TOOLS_ENDPOINT}{tool_id}/script")
602
+ return response.get("script", "") or response.get("content", "")
603
+ except Exception as e:
604
+ logger.error(f"Failed to get tool script for {tool_id}: {e}")
605
+ raise
606
+
607
+ def update_tool_via_file(self, tool_id: str, file_path: str, **kwargs) -> Tool:
608
+ """Update a tool plugin via file upload.
609
+
610
+ Args:
611
+ tool_id: The ID of the tool to update
612
+ file_path: Path to the new tool file
613
+ **kwargs: Additional metadata to update (name, description, tags, etc.)
614
+
615
+ Returns:
616
+ Tool: The updated tool object
617
+
618
+ Raises:
619
+ FileNotFoundError: If the file doesn't exist
620
+ Exception: If the update fails
621
+ """
622
+ # Validate file exists
623
+ self._validate_and_read_file(file_path)
624
+
625
+ # Fetch current metadata to ensure required fields are preserved
626
+ current_tool = self.get_tool_by_id(tool_id)
627
+
628
+ payload_kwargs = kwargs.copy()
629
+ name = payload_kwargs.pop("name", None)
630
+ description = payload_kwargs.pop("description", None)
631
+ update_payload = self._build_update_payload(
632
+ current_tool=current_tool,
633
+ name=name,
634
+ description=description,
635
+ **payload_kwargs,
636
+ )
637
+
638
+ try:
639
+ # Prepare multipart upload
640
+ with open(file_path, "rb") as fb:
641
+ files = {
642
+ "file": (
643
+ os.path.basename(file_path),
644
+ fb,
645
+ "application/octet-stream",
646
+ ),
647
+ }
648
+
649
+ response = self._request(
650
+ "PUT",
651
+ TOOLS_UPLOAD_BY_ID_ENDPOINT_FMT.format(tool_id=tool_id),
652
+ files=files,
653
+ data=update_payload,
654
+ )
655
+
656
+ tool_response = ToolResponse(**response)
657
+ return Tool.from_response(tool_response, client=self)
658
+
659
+ except Exception as e:
660
+ logger.error("Failed to update tool %s via file: %s", tool_id, e)
661
+ raise