aip-agents-binary 0.5.25__py3-none-macosx_13_0_arm64.whl → 0.6.8__py3-none-macosx_13_0_arm64.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 (109) hide show
  1. aip_agents/agent/__init__.py +44 -4
  2. aip_agents/agent/base_langgraph_agent.py +163 -74
  3. aip_agents/agent/base_langgraph_agent.pyi +3 -2
  4. aip_agents/agent/langgraph_memory_enhancer_agent.py +368 -34
  5. aip_agents/agent/langgraph_memory_enhancer_agent.pyi +3 -2
  6. aip_agents/agent/langgraph_react_agent.py +329 -22
  7. aip_agents/agent/langgraph_react_agent.pyi +41 -2
  8. aip_agents/examples/hello_world_ptc.py +49 -0
  9. aip_agents/examples/hello_world_ptc.pyi +5 -0
  10. aip_agents/examples/hello_world_ptc_custom_tools.py +83 -0
  11. aip_agents/examples/hello_world_ptc_custom_tools.pyi +7 -0
  12. aip_agents/examples/hello_world_tool_output_client.py +9 -0
  13. aip_agents/examples/tools/multiply_tool.py +43 -0
  14. aip_agents/examples/tools/multiply_tool.pyi +18 -0
  15. aip_agents/guardrails/engines/base.py +6 -6
  16. aip_agents/mcp/client/__init__.py +38 -2
  17. aip_agents/mcp/client/connection_manager.py +36 -1
  18. aip_agents/mcp/client/connection_manager.pyi +3 -0
  19. aip_agents/mcp/client/persistent_session.py +318 -68
  20. aip_agents/mcp/client/persistent_session.pyi +9 -0
  21. aip_agents/mcp/client/transports.py +37 -2
  22. aip_agents/mcp/client/transports.pyi +9 -0
  23. aip_agents/memory/adapters/base_adapter.py +98 -0
  24. aip_agents/memory/adapters/base_adapter.pyi +25 -0
  25. aip_agents/ptc/__init__.py +87 -0
  26. aip_agents/ptc/__init__.pyi +14 -0
  27. aip_agents/ptc/custom_tools.py +473 -0
  28. aip_agents/ptc/custom_tools.pyi +184 -0
  29. aip_agents/ptc/custom_tools_payload.py +400 -0
  30. aip_agents/ptc/custom_tools_payload.pyi +31 -0
  31. aip_agents/ptc/custom_tools_templates/__init__.py +1 -0
  32. aip_agents/ptc/custom_tools_templates/__init__.pyi +0 -0
  33. aip_agents/ptc/custom_tools_templates/custom_build_function.py.template +23 -0
  34. aip_agents/ptc/custom_tools_templates/custom_init.py.template +15 -0
  35. aip_agents/ptc/custom_tools_templates/custom_invoke.py.template +60 -0
  36. aip_agents/ptc/custom_tools_templates/custom_registry.py.template +87 -0
  37. aip_agents/ptc/custom_tools_templates/custom_sources_init.py.template +7 -0
  38. aip_agents/ptc/custom_tools_templates/custom_wrapper.py.template +19 -0
  39. aip_agents/ptc/doc_gen.py +122 -0
  40. aip_agents/ptc/doc_gen.pyi +40 -0
  41. aip_agents/ptc/exceptions.py +57 -0
  42. aip_agents/ptc/exceptions.pyi +37 -0
  43. aip_agents/ptc/executor.py +261 -0
  44. aip_agents/ptc/executor.pyi +99 -0
  45. aip_agents/ptc/mcp/__init__.py +45 -0
  46. aip_agents/ptc/mcp/__init__.pyi +7 -0
  47. aip_agents/ptc/mcp/sandbox_bridge.py +668 -0
  48. aip_agents/ptc/mcp/sandbox_bridge.pyi +47 -0
  49. aip_agents/ptc/mcp/templates/__init__.py +1 -0
  50. aip_agents/ptc/mcp/templates/__init__.pyi +0 -0
  51. aip_agents/ptc/mcp/templates/mcp_client.py.template +239 -0
  52. aip_agents/ptc/naming.py +196 -0
  53. aip_agents/ptc/naming.pyi +85 -0
  54. aip_agents/ptc/payload.py +26 -0
  55. aip_agents/ptc/payload.pyi +15 -0
  56. aip_agents/ptc/prompt_builder.py +673 -0
  57. aip_agents/ptc/prompt_builder.pyi +59 -0
  58. aip_agents/ptc/ptc_helper.py +16 -0
  59. aip_agents/ptc/ptc_helper.pyi +1 -0
  60. aip_agents/ptc/sandbox_bridge.py +256 -0
  61. aip_agents/ptc/sandbox_bridge.pyi +38 -0
  62. aip_agents/ptc/template_utils.py +33 -0
  63. aip_agents/ptc/template_utils.pyi +13 -0
  64. aip_agents/ptc/templates/__init__.py +1 -0
  65. aip_agents/ptc/templates/__init__.pyi +0 -0
  66. aip_agents/ptc/templates/ptc_helper.py.template +134 -0
  67. aip_agents/ptc/tool_def_helpers.py +101 -0
  68. aip_agents/ptc/tool_def_helpers.pyi +38 -0
  69. aip_agents/ptc/tool_enrichment.py +163 -0
  70. aip_agents/ptc/tool_enrichment.pyi +60 -0
  71. aip_agents/sandbox/__init__.py +43 -0
  72. aip_agents/sandbox/__init__.pyi +5 -0
  73. aip_agents/sandbox/defaults.py +205 -0
  74. aip_agents/sandbox/defaults.pyi +30 -0
  75. aip_agents/sandbox/e2b_runtime.py +295 -0
  76. aip_agents/sandbox/e2b_runtime.pyi +57 -0
  77. aip_agents/sandbox/template_builder.py +131 -0
  78. aip_agents/sandbox/template_builder.pyi +36 -0
  79. aip_agents/sandbox/types.py +24 -0
  80. aip_agents/sandbox/types.pyi +14 -0
  81. aip_agents/sandbox/validation.py +50 -0
  82. aip_agents/sandbox/validation.pyi +20 -0
  83. aip_agents/sentry/sentry.py +29 -8
  84. aip_agents/sentry/sentry.pyi +3 -2
  85. aip_agents/tools/__init__.py +13 -2
  86. aip_agents/tools/__init__.pyi +3 -1
  87. aip_agents/tools/browser_use/browser_use_tool.py +8 -0
  88. aip_agents/tools/browser_use/streaming.py +2 -0
  89. aip_agents/tools/date_range_tool.py +554 -0
  90. aip_agents/tools/date_range_tool.pyi +21 -0
  91. aip_agents/tools/execute_ptc_code.py +357 -0
  92. aip_agents/tools/execute_ptc_code.pyi +90 -0
  93. aip_agents/tools/memory_search/__init__.py +8 -1
  94. aip_agents/tools/memory_search/__init__.pyi +3 -3
  95. aip_agents/tools/memory_search/mem0.py +114 -1
  96. aip_agents/tools/memory_search/mem0.pyi +11 -1
  97. aip_agents/tools/memory_search/schema.py +33 -0
  98. aip_agents/tools/memory_search/schema.pyi +10 -0
  99. aip_agents/tools/memory_search_tool.py +8 -0
  100. aip_agents/tools/memory_search_tool.pyi +2 -2
  101. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +26 -1
  102. aip_agents/utils/langgraph/tool_output_management.py +80 -0
  103. aip_agents/utils/langgraph/tool_output_management.pyi +37 -0
  104. {aip_agents_binary-0.5.25.dist-info → aip_agents_binary-0.6.8.dist-info}/METADATA +9 -19
  105. {aip_agents_binary-0.5.25.dist-info → aip_agents_binary-0.6.8.dist-info}/RECORD +107 -41
  106. {aip_agents_binary-0.5.25.dist-info → aip_agents_binary-0.6.8.dist-info}/WHEEL +1 -1
  107. aip_agents/examples/demo_memory_recall.py +0 -401
  108. aip_agents/examples/demo_memory_recall.pyi +0 -58
  109. {aip_agents_binary-0.5.25.dist-info → aip_agents_binary-0.6.8.dist-info}/top_level.txt +0 -0
@@ -106,6 +106,31 @@ class BaseMemoryAdapter(BaseMemory):
106
106
  Returns:
107
107
  List of memory hits matching the criteria.
108
108
  """
109
+ def delete_by_query(self, *, query: str, user_id: str, top_k: int | None = None, threshold: float | None = 0.3, filters: dict[str, Any] | None = None) -> list[dict[str, Any]]:
110
+ """Delete memories matching a query.
111
+
112
+ Args:
113
+ query: The search query string used to find memories to delete.
114
+ user_id: User identifier for the deletion scope.
115
+ top_k: Maximum number of memories to delete.
116
+ threshold: Minimum similarity threshold for deletion.
117
+ filters: Optional filters to apply to the deletion scope.
118
+
119
+ Returns:
120
+ List of deleted memory hits.
121
+ """
122
+ def delete(self, *, memory_ids: list[str] | None, user_id: str, metadata: dict[str, Any] | None = None, categories: list[str] | None = None) -> Any:
123
+ """Delete memories by IDs or by user scope when IDs are None.
124
+
125
+ Args:
126
+ memory_ids: Optional list of memory IDs to delete.
127
+ user_id: User identifier for the deletion scope.
128
+ metadata: Optional metadata filters to constrain deletion.
129
+ categories: Optional categories to filter by (best-effort).
130
+
131
+ Returns:
132
+ Backend-specific delete result or None on failure.
133
+ """
109
134
  def save_interaction(self, *, user_text: str, ai_text: str, user_id: str) -> None:
110
135
  """Save a user-AI interaction as memories.
111
136
 
@@ -0,0 +1,87 @@
1
+ """PTC (Programmatic Tool Calling) core module.
2
+
3
+ This module provides core PTC functionality, including executor, prompt builder,
4
+ sandbox bridge, and custom tool configuration validation.
5
+
6
+ Authors:
7
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
8
+ """
9
+
10
+ from aip_agents.ptc.custom_tools import (
11
+ PTCCustomToolConfig,
12
+ PTCCustomToolValidationError,
13
+ PTCFileToolDef,
14
+ PTCPackageToolDef,
15
+ PTCToolDef,
16
+ enrich_tool_def_with_metadata,
17
+ extract_tool_metadata,
18
+ validate_custom_tool_config,
19
+ )
20
+ from aip_agents.ptc.custom_tools_payload import (
21
+ CustomToolPayloadResult,
22
+ build_custom_tools_payload,
23
+ )
24
+ from aip_agents.ptc.exceptions import PTCError, PTCToolError
25
+ from aip_agents.ptc.prompt_builder import PromptConfig, build_ptc_prompt, compute_ptc_prompt_hash
26
+ from aip_agents.ptc.tool_def_helpers import file_tool, package_tool
27
+ from aip_agents.ptc.tool_enrichment import (
28
+ build_tool_lookup,
29
+ enrich_custom_tools_from_agent,
30
+ match_tool_by_name,
31
+ )
32
+
33
+ __all__ = [
34
+ # Exceptions
35
+ "PTCError",
36
+ "PTCToolError",
37
+ "PTCCustomToolValidationError",
38
+ # Executor
39
+ "PTCSandboxConfig",
40
+ "PTCSandboxExecutor",
41
+ # Custom tools
42
+ "PTCCustomToolConfig",
43
+ "PTCToolDef",
44
+ "PTCPackageToolDef",
45
+ "PTCFileToolDef",
46
+ "validate_custom_tool_config",
47
+ "extract_tool_metadata",
48
+ "enrich_tool_def_with_metadata",
49
+ # Tool enrichment
50
+ "build_tool_lookup",
51
+ "match_tool_by_name",
52
+ "enrich_custom_tools_from_agent",
53
+ # Tool definition helpers
54
+ "package_tool",
55
+ "file_tool",
56
+ # Custom tools payload
57
+ "CustomToolPayloadResult",
58
+ "build_custom_tools_payload",
59
+ # Prompt builder
60
+ "PromptConfig",
61
+ "build_ptc_prompt",
62
+ "compute_ptc_prompt_hash",
63
+ # Sandbox bridge
64
+ "build_sandbox_payload",
65
+ "wrap_ptc_code",
66
+ ]
67
+
68
+
69
+ def __getattr__(name: str):
70
+ """Lazy import to avoid circular dependencies."""
71
+ if name == "PTCSandboxConfig":
72
+ from aip_agents.ptc.executor import PTCSandboxConfig
73
+
74
+ return PTCSandboxConfig
75
+ elif name == "PTCSandboxExecutor":
76
+ from aip_agents.ptc.executor import PTCSandboxExecutor
77
+
78
+ return PTCSandboxExecutor
79
+ elif name == "build_sandbox_payload":
80
+ from aip_agents.ptc.sandbox_bridge import build_sandbox_payload
81
+
82
+ return build_sandbox_payload
83
+ elif name == "wrap_ptc_code":
84
+ from aip_agents.ptc.sandbox_bridge import wrap_ptc_code
85
+
86
+ return wrap_ptc_code
87
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -0,0 +1,14 @@
1
+ from aip_agents.ptc.custom_tools import PTCCustomToolConfig as PTCCustomToolConfig, PTCCustomToolValidationError as PTCCustomToolValidationError, PTCFileToolDef as PTCFileToolDef, PTCPackageToolDef as PTCPackageToolDef, PTCToolDef as PTCToolDef, enrich_tool_def_with_metadata as enrich_tool_def_with_metadata, extract_tool_metadata as extract_tool_metadata, validate_custom_tool_config as validate_custom_tool_config
2
+ from aip_agents.ptc.custom_tools_payload import CustomToolPayloadResult as CustomToolPayloadResult, build_custom_tools_payload as build_custom_tools_payload
3
+ from aip_agents.ptc.exceptions import PTCError as PTCError, PTCToolError as PTCToolError
4
+ from aip_agents.ptc.prompt_builder import PromptConfig as PromptConfig, build_ptc_prompt as build_ptc_prompt, compute_ptc_prompt_hash as compute_ptc_prompt_hash
5
+ from aip_agents.ptc.tool_def_helpers import file_tool as file_tool, package_tool as package_tool
6
+ from aip_agents.ptc.tool_enrichment import build_tool_lookup as build_tool_lookup, enrich_custom_tools_from_agent as enrich_custom_tools_from_agent, match_tool_by_name as match_tool_by_name
7
+
8
+ __all__ = ['PTCError', 'PTCToolError', 'PTCCustomToolValidationError', 'PTCSandboxConfig', 'PTCSandboxExecutor', 'PTCCustomToolConfig', 'PTCToolDef', 'PTCPackageToolDef', 'PTCFileToolDef', 'validate_custom_tool_config', 'extract_tool_metadata', 'enrich_tool_def_with_metadata', 'build_tool_lookup', 'match_tool_by_name', 'enrich_custom_tools_from_agent', 'package_tool', 'file_tool', 'CustomToolPayloadResult', 'build_custom_tools_payload', 'PromptConfig', 'build_ptc_prompt', 'compute_ptc_prompt_hash', 'build_sandbox_payload', 'wrap_ptc_code']
9
+
10
+ # Names in __all__ with no definition:
11
+ # PTCSandboxConfig
12
+ # PTCSandboxExecutor
13
+ # build_sandbox_payload
14
+ # wrap_ptc_code
@@ -0,0 +1,473 @@
1
+ """Custom LangChain tools configuration for PTC sandbox.
2
+
3
+ This module provides configuration classes for defining custom LangChain tools
4
+ that can be used inside the PTC sandbox alongside MCP tools.
5
+
6
+ Authors:
7
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Literal, TypedDict
16
+
17
+ from aip_agents.ptc.exceptions import PTCError
18
+ from aip_agents.ptc.naming import is_valid_identifier, sanitize_function_name
19
+ from aip_agents.utils.logger import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class PTCPackageToolDef(TypedDict, total=False):
25
+ """Definition for a package-based tool.
26
+
27
+ Required fields:
28
+ name: Tool name (used for imports and config lookup).
29
+ kind: Must be "package".
30
+ import_path: Dotted import path (e.g., "aip_agents.tools.time_tool").
31
+ class_name: Class name to import (e.g., "TimeTool").
32
+
33
+ Optional fields:
34
+ package_path: Path to source directory to bundle.
35
+ If omitted, the tool must already be available in sandbox (e.g., from site-packages).
36
+ description: Tool description (derived from tool object at construction time).
37
+ input_schema: JSON schema for tool input (derived from tool object at construction time).
38
+ """
39
+
40
+ name: str
41
+ kind: Literal["package"]
42
+ import_path: str
43
+ class_name: str
44
+ package_path: str # Optional
45
+ description: str # Optional - derived from tool object
46
+ input_schema: dict # Optional - derived from tool object
47
+
48
+
49
+ class PTCFileToolDef(TypedDict, total=False):
50
+ """Definition for a single-file tool.
51
+
52
+ Required fields:
53
+ name: Tool name (used for imports and config lookup).
54
+ kind: Must be "file".
55
+ file_path: Path to the Python file containing the tool.
56
+ class_name: Class name to import from the file.
57
+
58
+ Optional fields:
59
+ description: Tool description (derived from tool object at construction time).
60
+ input_schema: JSON schema for tool input (derived from tool object at construction time).
61
+ """
62
+
63
+ name: str
64
+ kind: Literal["file"]
65
+ file_path: str
66
+ class_name: str
67
+ description: str # Optional - derived from tool object
68
+ input_schema: dict # Optional - derived from tool object
69
+
70
+
71
+ # Union type for tool definitions
72
+ PTCToolDef = PTCPackageToolDef | PTCFileToolDef
73
+
74
+
75
+ class PTCCustomToolValidationError(PTCError):
76
+ """Error raised when custom tool configuration is invalid."""
77
+
78
+ pass
79
+
80
+
81
+ @dataclass
82
+ class PTCCustomToolConfig:
83
+ """Configuration for custom LangChain tools in PTC sandbox.
84
+
85
+ This config controls which user-defined LangChain tools are available
86
+ in the sandbox for use by PTC code.
87
+
88
+ Attributes:
89
+ enabled: Whether custom tools are enabled.
90
+ bundle_roots: List of allowed root directories for bundling tool sources.
91
+ Paths outside these roots will cause validation errors.
92
+ requirements: List of pip requirements to install in sandbox.
93
+ tools: List of tool definitions (package or file tools).
94
+
95
+ Example:
96
+ >>> config = PTCCustomToolConfig(
97
+ ... enabled=True,
98
+ ... bundle_roots=["/app/tools"],
99
+ ... requirements=["pydantic>=2.0"],
100
+ ... tools=[
101
+ ... {
102
+ ... "name": "time_tool",
103
+ ... "kind": "package",
104
+ ... "import_path": "aip_agents.tools.time_tool",
105
+ ... "class_name": "TimeTool",
106
+ ... },
107
+ ... ],
108
+ ... )
109
+ """
110
+
111
+ enabled: bool = False
112
+ bundle_roots: list[str] = field(default_factory=list)
113
+ requirements: list[str] = field(default_factory=list)
114
+ tools: list[PTCToolDef] = field(default_factory=list)
115
+
116
+
117
+ def _validate_class_name(class_name: str, tool_name: str) -> None:
118
+ """Validate a class name used in generated code.
119
+
120
+ Args:
121
+ class_name: Class name to validate.
122
+ tool_name: Tool name for error messages.
123
+
124
+ Raises:
125
+ PTCCustomToolValidationError: If class name is invalid.
126
+ """
127
+ if not is_valid_identifier(class_name):
128
+ raise PTCCustomToolValidationError(
129
+ f"Tool '{tool_name}' has invalid class_name '{class_name}'. Must be a valid Python identifier."
130
+ )
131
+
132
+
133
+ def _validate_package_tool(tool: PTCToolDef, name: str) -> None:
134
+ """Validate a package tool definition.
135
+
136
+ Args:
137
+ tool: Package tool definition to validate.
138
+ name: Tool name for error messages.
139
+
140
+ Raises:
141
+ PTCCustomToolValidationError: If package tool definition is invalid.
142
+ """
143
+ import_path = tool.get("import_path", "")
144
+ if not import_path:
145
+ raise PTCCustomToolValidationError(f"Package tool '{name}' missing required 'import_path' field")
146
+ if import_path.startswith("."):
147
+ raise PTCCustomToolValidationError(f"Package tool '{name}' import_path must be absolute: '{import_path}'")
148
+ if any(not part.isidentifier() for part in import_path.split(".")):
149
+ raise PTCCustomToolValidationError(f"Package tool '{name}' has invalid import_path: '{import_path}'")
150
+ class_name = tool.get("class_name", "")
151
+ if not class_name:
152
+ raise PTCCustomToolValidationError(f"Package tool '{name}' missing required 'class_name' field")
153
+ _validate_class_name(class_name, name)
154
+
155
+
156
+ def _validate_file_tool(tool: PTCToolDef, name: str) -> None:
157
+ """Validate a file tool definition.
158
+
159
+ Args:
160
+ tool: File tool definition to validate.
161
+ name: Tool name for error messages.
162
+
163
+ Raises:
164
+ PTCCustomToolValidationError: If file tool definition is invalid.
165
+ """
166
+ if not tool.get("file_path"):
167
+ raise PTCCustomToolValidationError(f"File tool '{name}' missing required 'file_path' field")
168
+ class_name = tool.get("class_name", "")
169
+ if not class_name:
170
+ raise PTCCustomToolValidationError(f"File tool '{name}' missing required 'class_name' field")
171
+ _validate_class_name(class_name, name)
172
+
173
+
174
+ def validate_tool_def(tool: PTCToolDef) -> None:
175
+ """Validate a tool definition has required fields.
176
+
177
+ Args:
178
+ tool: Tool definition to validate.
179
+
180
+ Raises:
181
+ PTCCustomToolValidationError: If tool definition is invalid.
182
+ """
183
+ name = tool.get("name")
184
+ kind = tool.get("kind")
185
+
186
+ if not name:
187
+ raise PTCCustomToolValidationError("Tool definition missing required 'name' field")
188
+
189
+ if kind not in ("package", "file"):
190
+ raise PTCCustomToolValidationError(f"Tool '{name}' has invalid kind '{kind}'. Must be 'package' or 'file'.")
191
+
192
+ if kind == "package":
193
+ _validate_package_tool(tool, name)
194
+ elif kind == "file":
195
+ _validate_file_tool(tool, name)
196
+
197
+
198
+ def validate_path_within_bundle_roots(
199
+ path: str | Path,
200
+ bundle_roots: list[str],
201
+ tool_name: str,
202
+ ) -> None:
203
+ """Validate that a path is within one of the bundle roots.
204
+
205
+ Args:
206
+ path: Path to validate.
207
+ bundle_roots: List of allowed root directories.
208
+ tool_name: Tool name for error messages.
209
+
210
+ Raises:
211
+ PTCCustomToolValidationError: If path is outside all bundle roots.
212
+ """
213
+ if not bundle_roots:
214
+ raise PTCCustomToolValidationError(
215
+ f"Tool '{tool_name}' requires bundle_roots to be set when using package_path or file_path"
216
+ )
217
+
218
+ # Resolve real path to prevent symlink escapes
219
+ real_path = Path(path).resolve()
220
+
221
+ for root in bundle_roots:
222
+ real_root = Path(root).resolve()
223
+ try:
224
+ real_path.relative_to(real_root)
225
+ return # Path is within this root
226
+ except ValueError:
227
+ continue
228
+
229
+ raise PTCCustomToolValidationError(
230
+ f"Tool '{tool_name}' path '{path}' is outside all bundle_roots. Allowed roots: {bundle_roots}"
231
+ )
232
+
233
+
234
+ def detect_relative_imports(file_path: str | Path) -> list[str]:
235
+ """Detect relative imports in a Python file using AST.
236
+
237
+ Args:
238
+ file_path: Path to the Python file to scan.
239
+
240
+ Returns:
241
+ List of relative import statements found.
242
+
243
+ Raises:
244
+ PTCCustomToolValidationError: If file cannot be read or parsed.
245
+ """
246
+ path = Path(file_path)
247
+ if not path.exists():
248
+ raise PTCCustomToolValidationError(f"File not found: {file_path}")
249
+
250
+ try:
251
+ source = path.read_text(encoding="utf-8")
252
+ except Exception as exc:
253
+ raise PTCCustomToolValidationError(f"Cannot read file '{file_path}': {exc}") from exc
254
+
255
+ try:
256
+ tree = ast.parse(source, filename=str(path))
257
+ except SyntaxError as exc:
258
+ raise PTCCustomToolValidationError(f"Syntax error in file '{file_path}': {exc}") from exc
259
+
260
+ relative_imports: list[str] = []
261
+ for node in ast.walk(tree):
262
+ if isinstance(node, ast.ImportFrom):
263
+ # level > 0 indicates relative import (e.g., from . import X, from ..foo import Y)
264
+ if node.level > 0:
265
+ module = node.module or ""
266
+ dots = "." * node.level
267
+ import_str = f"from {dots}{module} import ..."
268
+ relative_imports.append(import_str)
269
+
270
+ return relative_imports
271
+
272
+
273
+ def check_name_collisions(tools: list[PTCToolDef]) -> None:
274
+ """Check for tool name collisions after sanitization.
275
+
276
+ Args:
277
+ tools: List of tool definitions.
278
+
279
+ Raises:
280
+ PTCCustomToolValidationError: If two tools sanitize to the same name.
281
+ """
282
+ sanitized_to_original: dict[str, list[str]] = {}
283
+
284
+ for tool in tools:
285
+ name = tool.get("name", "")
286
+ sanitized = sanitize_function_name(name)
287
+
288
+ if sanitized not in sanitized_to_original:
289
+ sanitized_to_original[sanitized] = []
290
+ sanitized_to_original[sanitized].append(name)
291
+
292
+ # Check for collisions
293
+ for sanitized, originals in sanitized_to_original.items():
294
+ if len(originals) > 1:
295
+ raise PTCCustomToolValidationError(
296
+ f"Tool name collision: tools {originals} all sanitize to '{sanitized}'. Please use unique tool names."
297
+ )
298
+
299
+
300
+ def _validate_tool_paths(tool: dict, bundle_roots: list[str]) -> None:
301
+ """Validate paths for a single tool and check for relative imports.
302
+
303
+ Args:
304
+ tool: Tool definition dict with kind, name, and path fields.
305
+ bundle_roots: List of allowed bundle root directories.
306
+
307
+ Raises:
308
+ PTCCustomToolValidationError: If paths are invalid or file uses relative imports.
309
+ """
310
+ kind = tool.get("kind")
311
+ name = tool.get("name", "")
312
+
313
+ if kind == "package":
314
+ package_path = tool.get("package_path")
315
+ if package_path:
316
+ path = Path(package_path)
317
+ if not path.exists() or not path.is_dir():
318
+ raise PTCCustomToolValidationError(
319
+ f"Package tool '{name}' package_path does not exist or is not a directory: '{package_path}'"
320
+ )
321
+ validate_path_within_bundle_roots(package_path, bundle_roots, name)
322
+
323
+ elif kind == "file":
324
+ file_path = tool.get("file_path")
325
+ if file_path:
326
+ validate_path_within_bundle_roots(file_path, bundle_roots, name)
327
+
328
+ relative_imports = detect_relative_imports(file_path)
329
+ if relative_imports:
330
+ raise PTCCustomToolValidationError(
331
+ f"File tool '{name}' uses relative imports which are not supported: "
332
+ f"{relative_imports}. Use a package tool with package_path instead."
333
+ )
334
+
335
+
336
+ def validate_custom_tool_config(config: PTCCustomToolConfig) -> None:
337
+ """Validate the complete custom tool configuration.
338
+
339
+ This runs all validation checks:
340
+ - Tool definitions have required fields
341
+ - Paths are within bundle roots
342
+ - File tools don't use relative imports
343
+ - No name collisions
344
+
345
+ Args:
346
+ config: Custom tool configuration to validate.
347
+
348
+ Raises:
349
+ PTCCustomToolValidationError: If configuration is invalid.
350
+ """
351
+ if not config.enabled or not config.tools:
352
+ return
353
+
354
+ # Validate each tool definition
355
+ for tool in config.tools:
356
+ validate_tool_def(tool)
357
+
358
+ # Check for name collisions
359
+ check_name_collisions(config.tools)
360
+
361
+ # Validate paths and check for relative imports
362
+ for tool in config.tools:
363
+ _validate_tool_paths(tool, config.bundle_roots)
364
+
365
+
366
+ def extract_tool_metadata(tool: object) -> dict:
367
+ """Extract metadata from a LangChain BaseTool instance.
368
+
369
+ Extracts name, description, and input_schema from the tool object.
370
+ This is called at agent construction time to populate tool definitions.
371
+
372
+ Args:
373
+ tool: A LangChain BaseTool instance (or compatible object).
374
+
375
+ Returns:
376
+ Dict with keys:
377
+ - name: str (tool name)
378
+ - description: str (tool description, empty if not available)
379
+ - input_schema: dict (JSON schema, empty object schema if not available)
380
+
381
+ Note:
382
+ If schema inference fails, returns an empty object schema
383
+ ({"type": "object", "properties": {}}) so prompt/index output
384
+ falls back to tool(**kwargs).
385
+ """
386
+ result: dict = {
387
+ "name": "",
388
+ "description": "",
389
+ "input_schema": {"type": "object", "properties": {}},
390
+ }
391
+
392
+ # Extract name
393
+ if hasattr(tool, "name"):
394
+ result["name"] = str(tool.name)
395
+
396
+ # Extract description
397
+ if hasattr(tool, "description"):
398
+ desc = tool.description
399
+ if desc:
400
+ # Clean up multiline description - normalize whitespace
401
+ result["description"] = " ".join(str(desc).split())
402
+
403
+ # Extract input_schema from args_schema (Pydantic model)
404
+ if hasattr(tool, "args_schema") and tool.args_schema is not None:
405
+ try:
406
+ args_schema = tool.args_schema
407
+ # Pydantic v2 uses model_json_schema()
408
+ if hasattr(args_schema, "model_json_schema"):
409
+ schema = args_schema.model_json_schema()
410
+ # Remove Pydantic-specific keys that aren't needed
411
+ schema.pop("title", None)
412
+ schema.pop("$defs", None)
413
+ schema.pop("definitions", None)
414
+ result["input_schema"] = schema
415
+ # Pydantic v1 fallback uses schema()
416
+ elif hasattr(args_schema, "schema"):
417
+ schema = args_schema.schema()
418
+ schema.pop("title", None)
419
+ schema.pop("definitions", None)
420
+ result["input_schema"] = schema
421
+ except Exception:
422
+ # If schema extraction fails, keep the empty object schema
423
+ pass
424
+
425
+ return result
426
+
427
+
428
+ def enrich_tool_def_with_metadata(tool_def: PTCToolDef, tool: object) -> PTCToolDef:
429
+ """Enrich a tool definition with metadata extracted from the tool object.
430
+
431
+ Always derives description and input_schema from the tool object, overwriting
432
+ any user-supplied values. This ensures the tool object is the source of truth.
433
+ Called at agent construction time.
434
+
435
+ Args:
436
+ tool_def: The tool definition to enrich.
437
+ tool: The LangChain BaseTool instance.
438
+
439
+ Returns:
440
+ The tool definition with description and input_schema populated from the tool object.
441
+
442
+ Raises:
443
+ PTCCustomToolValidationError: If tool name in metadata does not match tool_def name.
444
+ """
445
+ metadata = extract_tool_metadata(tool)
446
+
447
+ # Create a mutable copy
448
+ enriched: dict = dict(tool_def)
449
+
450
+ # Validate name matches if metadata provides one
451
+ if metadata["name"] and metadata["name"] != enriched.get("name"):
452
+ raise PTCCustomToolValidationError(
453
+ f"Tool name mismatch: tool_def name '{enriched.get('name')}' does not match "
454
+ f"tool object name '{metadata['name']}'"
455
+ )
456
+
457
+ # Log if we're overwriting existing values
458
+ if "description" in enriched and enriched["description"]:
459
+ logger.debug(
460
+ f"Overwriting user-supplied description for tool '{enriched.get('name', 'unknown')}' "
461
+ f"with derived metadata from tool object"
462
+ )
463
+ if "input_schema" in enriched and enriched["input_schema"]:
464
+ logger.debug(
465
+ f"Overwriting user-supplied input_schema for tool '{enriched.get('name', 'unknown')}' "
466
+ f"with derived metadata from tool object"
467
+ )
468
+
469
+ # Always overwrite with derived metadata (tool object is source of truth)
470
+ enriched["description"] = metadata["description"]
471
+ enriched["input_schema"] = metadata["input_schema"]
472
+
473
+ return enriched # type: ignore[return-value]