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
@@ -0,0 +1,673 @@
1
+ """PTC Prompt Builder.
2
+
3
+ Generates usage guidance prompts for PTC that help the LLM correctly use
4
+ the execute_ptc_code tool with proper import patterns and parameter naming.
5
+
6
+ Authors:
7
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any, Literal
14
+
15
+ from aip_agents.ptc.custom_tools import PTCCustomToolConfig
16
+ from aip_agents.ptc.naming import (
17
+ example_value_from_schema,
18
+ sanitize_function_name,
19
+ sanitize_module_name_with_reserved,
20
+ sanitize_param_name,
21
+ schema_to_params,
22
+ )
23
+ from aip_agents.utils.logger import get_logger
24
+
25
+ if TYPE_CHECKING:
26
+ from aip_agents.mcp.client.base_mcp_client import BaseMCPClient
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ # Prompt mode type alias
31
+ PromptMode = Literal["minimal", "index", "full", "auto"]
32
+
33
+ # Markdown constants
34
+ PYTHON_BLOCK_START = "```python"
35
+
36
+
37
+ @dataclass
38
+ class PromptConfig:
39
+ """Configuration for PTC prompt generation.
40
+
41
+ Attributes:
42
+ mode: Prompt mode - minimal, index, full, or auto.
43
+ auto_threshold: Total tool count threshold for auto mode (default 10).
44
+ include_example: Whether to include example code in prompt.
45
+ """
46
+
47
+ mode: PromptMode = "auto"
48
+ auto_threshold: int = 10
49
+ include_example: bool = True
50
+
51
+
52
+ # Shared PTC usage rules block (DRY: used in both placeholder and full prompts)
53
+ PTC_USAGE_RULES = """## PTC (Programmatic Tool Calling) Usage
54
+
55
+ When using `execute_ptc_code`, follow these rules:
56
+
57
+ 1. **Import patterns**:
58
+ - MCP tools: `from tools.<server> import <tool_name>`
59
+ - Custom tools: `from tools.custom import <tool_name>`
60
+ 2. **Output**: Only `print()` output is returned to you. Always print results.
61
+ 3. **Parameter names**: All parameters are lowercase with underscores.
62
+ - Example: `userId` becomes `userid`, `user-id` becomes `user_id`
63
+ """
64
+
65
+
66
+ def build_ptc_prompt(
67
+ mcp_client: BaseMCPClient | None = None,
68
+ config: PromptConfig | None = None,
69
+ custom_tools_config: PTCCustomToolConfig | None = None,
70
+ ) -> str:
71
+ """Build PTC usage guidance prompt from MCP and custom tool configurations.
72
+
73
+ Generates a short usage block that includes:
74
+ - The import patterns: MCP (`from tools.<server> import <tool>`) and
75
+ custom (`from tools.custom import <tool>`)
76
+ - Rule: use `print()`; only printed output returns
77
+ - Rule: parameter names are sanitized to lowercase/underscored
78
+ - Prompt mode content (minimal/index/full)
79
+ - Examples based on the resolved prompt mode
80
+
81
+ Args:
82
+ mcp_client: The MCP client with configured servers. Can be None if only custom tools.
83
+ config: Prompt configuration. If None, uses default PromptConfig.
84
+ custom_tools_config: Optional custom LangChain tools configuration.
85
+
86
+ Returns:
87
+ PTC usage guidance prompt string.
88
+ """
89
+ if config is None:
90
+ config = PromptConfig()
91
+
92
+ # Collect MCP server info (sorted for deterministic output)
93
+ server_infos: list[dict[str, Any]] = []
94
+ if mcp_client and mcp_client.servers:
95
+ for server_name in sorted(mcp_client.servers.keys()):
96
+ tools = _get_server_tools(mcp_client, server_name)
97
+ server_infos.append({"name": server_name, "tools": tools})
98
+
99
+ # Collect custom tool info
100
+ custom_tool_infos: list[dict[str, Any]] = []
101
+ if custom_tools_config and custom_tools_config.enabled and custom_tools_config.tools:
102
+ custom_tool_infos = _get_custom_tool_infos(custom_tools_config)
103
+
104
+ # Check if we have any tools
105
+ if not server_infos and not custom_tool_infos:
106
+ return _build_placeholder_prompt()
107
+
108
+ # Resolve mode and build appropriate prompt
109
+ resolved_mode = _resolve_mode(config, server_infos, custom_tool_infos)
110
+
111
+ if resolved_mode == "minimal":
112
+ return _build_minimal_prompt(server_infos, config.include_example, custom_tool_infos)
113
+ elif resolved_mode == "index":
114
+ return _build_index_prompt(server_infos, config.include_example, custom_tool_infos)
115
+ else: # full
116
+ return _build_full_prompt(server_infos, config.include_example, custom_tool_infos)
117
+
118
+
119
+ def _get_server_tools(
120
+ mcp_client: BaseMCPClient,
121
+ server_name: str,
122
+ ) -> list[dict[str, Any]]:
123
+ """Get tool definitions for a server.
124
+
125
+ When tools are not loaded but allowed_tools exists, returns stub tool entries.
126
+
127
+ Args:
128
+ mcp_client: MCP client instance.
129
+ server_name: Name of the server.
130
+
131
+ Returns:
132
+ List of tool definitions with name, description, and input_schema.
133
+ Stubs have empty description and minimal schema when tools not loaded.
134
+ """
135
+ tools: list[dict[str, Any]] = []
136
+ allowed_tools: list[str] | None = None
137
+ raw_tools: list[Any] = []
138
+ try:
139
+ # Try to get cached tools from session pool
140
+ session = mcp_client.session_pool.get_session(server_name)
141
+ allowed_tools = session.allowed_tools if session.allowed_tools else None
142
+
143
+ # Get tools from session (public attribute on PersistentMCPSession)
144
+ raw_tools = list(getattr(session, "tools", []))
145
+ except (KeyError, AttributeError) as e:
146
+ logger.debug(f"Could not get tools for server '{server_name}': {e}")
147
+
148
+ if allowed_tools is None:
149
+ allowed_tools = _get_allowed_tools_from_config(mcp_client, server_name)
150
+
151
+ if not raw_tools and allowed_tools:
152
+ # Tools not loaded but allowlist exists - return stub entries
153
+ for tool_name in sorted(allowed_tools):
154
+ tools.append(
155
+ {
156
+ "name": tool_name,
157
+ "description": "",
158
+ "input_schema": {"type": "object", "properties": {}},
159
+ "stub": True,
160
+ }
161
+ )
162
+ elif raw_tools:
163
+ # Tools loaded - return actual tool definitions
164
+ for tool in raw_tools:
165
+ if allowed_tools and tool.name not in allowed_tools:
166
+ continue
167
+ tools.append(
168
+ {
169
+ "name": tool.name,
170
+ "description": tool.description or "",
171
+ "input_schema": tool.inputSchema,
172
+ "stub": False,
173
+ }
174
+ )
175
+ return tools
176
+
177
+
178
+ def _get_allowed_tools_from_config(mcp_client: BaseMCPClient, server_name: str) -> list[str] | None:
179
+ """Extract allowed_tools from MCP client server config.
180
+
181
+ Args:
182
+ mcp_client: MCP client instance.
183
+ server_name: Server name to look up.
184
+
185
+ Returns:
186
+ List of allowed tools or None.
187
+ """
188
+ if not mcp_client or not mcp_client.servers:
189
+ return None
190
+
191
+ config = mcp_client.servers.get(server_name)
192
+ if not config:
193
+ return None
194
+
195
+ raw_allowed = config.get("allowed_tools") if isinstance(config, dict) else getattr(config, "allowed_tools", None)
196
+ if raw_allowed and isinstance(raw_allowed, list):
197
+ return list(raw_allowed)
198
+ return None
199
+
200
+
201
+ def _get_custom_tool_infos(custom_tools_config: PTCCustomToolConfig) -> list[dict[str, Any]]:
202
+ """Get tool info dicts from custom tools configuration.
203
+
204
+ Args:
205
+ custom_tools_config: Custom tools configuration.
206
+
207
+ Returns:
208
+ List of tool info dicts with name, description, and input_schema.
209
+ """
210
+ tools: list[dict[str, Any]] = []
211
+ for tool_def in custom_tools_config.tools:
212
+ name = tool_def.get("name", "")
213
+ description = tool_def.get("description", "")
214
+ input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
215
+
216
+ tools.append(
217
+ {
218
+ "name": name,
219
+ "description": description,
220
+ "input_schema": input_schema,
221
+ "stub": not description and not input_schema.get("properties"),
222
+ }
223
+ )
224
+ return tools
225
+
226
+
227
+ def _count_total_tools(
228
+ server_infos: list[dict[str, Any]],
229
+ custom_tool_infos: list[dict[str, Any]] | None = None,
230
+ ) -> int:
231
+ """Count total tools across all servers and custom tools.
232
+
233
+ Args:
234
+ server_infos: List of server info dicts with name and tools.
235
+ custom_tool_infos: List of custom tool info dicts.
236
+
237
+ Returns:
238
+ Total tool count.
239
+ """
240
+ mcp_count = sum(len(info.get("tools", [])) for info in server_infos)
241
+ custom_count = len(custom_tool_infos) if custom_tool_infos else 0
242
+ return mcp_count + custom_count
243
+
244
+
245
+ def _resolve_mode(
246
+ config: PromptConfig,
247
+ server_infos: list[dict[str, Any]],
248
+ custom_tool_infos: list[dict[str, Any]] | None = None,
249
+ ) -> PromptMode:
250
+ """Resolve auto mode to concrete mode based on tool count.
251
+
252
+ Args:
253
+ config: Prompt configuration.
254
+ server_infos: List of server info dicts.
255
+ custom_tool_infos: List of custom tool info dicts.
256
+
257
+ Returns:
258
+ Resolved mode (minimal, index, or full).
259
+ """
260
+ if config.mode != "auto":
261
+ return config.mode
262
+
263
+ total_tools = _count_total_tools(server_infos, custom_tool_infos)
264
+ if total_tools == 0 or total_tools > config.auto_threshold:
265
+ return "minimal"
266
+ return "full"
267
+
268
+
269
+ def _build_discovery_example() -> str:
270
+ """Build discovery example using ptc_helper module.
271
+
272
+ Returns:
273
+ Discovery example code string.
274
+ """
275
+ return """from tools.ptc_helper import list_tools, describe_tool
276
+
277
+ # List available tools in a package
278
+ tools = list_tools("package_name")
279
+ print([tool["name"] for tool in tools])
280
+
281
+ # Get details for a specific tool
282
+ doc = describe_tool("package_name", tools[0]["name"])
283
+ print(doc["doc"])"""
284
+
285
+
286
+ def _build_minimal_prompt(
287
+ server_infos: list[dict[str, Any]],
288
+ include_example: bool,
289
+ custom_tool_infos: list[dict[str, Any]] | None = None,
290
+ ) -> str:
291
+ """Build minimal prompt with rules and package list only.
292
+
293
+ Args:
294
+ server_infos: List of server info dicts with name and tools.
295
+ include_example: Whether to include discovery example.
296
+ custom_tool_infos: List of custom tool info dicts.
297
+
298
+ Returns:
299
+ Minimal PTC usage prompt.
300
+ """
301
+ lines = [
302
+ PTC_USAGE_RULES.rstrip(),
303
+ "",
304
+ "### Available Packages",
305
+ "",
306
+ ]
307
+
308
+ # List MCP packages (sorted reserved-safe sanitized names)
309
+ package_names = sorted(sanitize_module_name_with_reserved(info["name"]) for info in server_infos)
310
+ for pkg in package_names:
311
+ lines.append(f"- `tools.{pkg}`")
312
+
313
+ # Add custom tools package if present
314
+ if custom_tool_infos:
315
+ lines.append("- `tools.custom`")
316
+
317
+ lines.append("")
318
+ lines.append("Use `tools.ptc_helper` to discover available tools and their signatures.")
319
+
320
+ if include_example:
321
+ lines.extend(
322
+ [
323
+ "",
324
+ "### Discovery Example",
325
+ "",
326
+ PYTHON_BLOCK_START,
327
+ _build_discovery_example(),
328
+ "```",
329
+ ]
330
+ )
331
+
332
+ return "\n".join(lines)
333
+
334
+
335
+ def _build_index_prompt(
336
+ server_infos: list[dict[str, Any]],
337
+ include_example: bool,
338
+ custom_tool_infos: list[dict[str, Any]] | None = None,
339
+ ) -> str:
340
+ """Build index prompt with rules, package list, and tool names.
341
+
342
+ Args:
343
+ server_infos: List of server info dicts with name and tools.
344
+ include_example: Whether to include discovery example.
345
+ custom_tool_infos: List of custom tool info dicts.
346
+
347
+ Returns:
348
+ Index PTC usage prompt.
349
+ """
350
+ lines = [
351
+ PTC_USAGE_RULES.rstrip(),
352
+ "",
353
+ "### Available Tools",
354
+ "",
355
+ ]
356
+
357
+ # Sort server infos by reserved-safe sanitized name for deterministic output
358
+ sorted_infos = sorted(server_infos, key=lambda x: sanitize_module_name_with_reserved(x["name"]))
359
+
360
+ for server_info in sorted_infos:
361
+ safe_server = sanitize_module_name_with_reserved(server_info["name"])
362
+ lines.append(f"**`tools.{safe_server}`**")
363
+
364
+ # Sort tools by sanitized name
365
+ sorted_tools = sorted(server_info["tools"], key=lambda t: sanitize_function_name(t["name"]))
366
+ tool_names = [sanitize_function_name(t["name"]) for t in sorted_tools]
367
+ lines.append(f" Tools: {', '.join(tool_names)}")
368
+ lines.append("")
369
+
370
+ # Add custom tools section if present
371
+ if custom_tool_infos:
372
+ lines.append("**`tools.custom`**")
373
+ sorted_custom = sorted(custom_tool_infos, key=lambda t: sanitize_function_name(t["name"]))
374
+ tool_names = [sanitize_function_name(t["name"]) for t in sorted_custom]
375
+ lines.append(f" Tools: {', '.join(tool_names)}")
376
+ lines.append("")
377
+
378
+ lines.append("Use `tools.ptc_helper` to get tool signatures and descriptions.")
379
+
380
+ if include_example:
381
+ lines.extend(
382
+ [
383
+ "",
384
+ "### Discovery Example",
385
+ "",
386
+ PYTHON_BLOCK_START,
387
+ _build_discovery_example(),
388
+ "```",
389
+ ]
390
+ )
391
+
392
+ return "\n".join(lines)
393
+
394
+
395
+ def _build_full_prompt(
396
+ server_infos: list[dict[str, Any]],
397
+ include_example: bool,
398
+ custom_tool_infos: list[dict[str, Any]] | None = None,
399
+ ) -> str:
400
+ """Build full prompt with rules, signatures, and descriptions.
401
+
402
+ Args:
403
+ server_infos: List of server info dicts with name and tools.
404
+ include_example: Whether to include real tool example.
405
+ custom_tool_infos: List of custom tool info dicts.
406
+
407
+ Returns:
408
+ Full PTC usage prompt.
409
+ """
410
+ lines = [
411
+ PTC_USAGE_RULES.rstrip(),
412
+ "",
413
+ "### Available Tools",
414
+ "",
415
+ ]
416
+
417
+ # Sort server infos by reserved-safe sanitized name for deterministic output
418
+ sorted_infos = sorted(server_infos, key=lambda x: sanitize_module_name_with_reserved(x["name"]))
419
+
420
+ for server_info in sorted_infos:
421
+ safe_server = sanitize_module_name_with_reserved(server_info["name"])
422
+ lines.append(f"**Server: `{safe_server}`** (from `tools.{safe_server}`)")
423
+ lines.append("")
424
+
425
+ # Sort tools by sanitized name
426
+ sorted_tools = sorted(server_info["tools"], key=lambda t: sanitize_function_name(t["name"]))
427
+
428
+ for tool in sorted_tools:
429
+ func_name = sanitize_function_name(tool["name"])
430
+ schema = tool.get("input_schema", {})
431
+ params = schema_to_params(schema)
432
+ raw_desc = tool.get("description", "")
433
+ desc = raw_desc[:120]
434
+ if raw_desc and len(raw_desc) > 120:
435
+ desc += "..."
436
+
437
+ lines.append(f"- `{func_name}({params})`: {desc}")
438
+
439
+ lines.append("")
440
+
441
+ # Add custom tools section if present
442
+ if custom_tool_infos:
443
+ lines.append("**Custom Tools** (from `tools.custom`)")
444
+ lines.append("")
445
+
446
+ sorted_custom = sorted(custom_tool_infos, key=lambda t: sanitize_function_name(t["name"]))
447
+
448
+ for tool in sorted_custom:
449
+ func_name = sanitize_function_name(tool["name"])
450
+ schema = tool.get("input_schema", {})
451
+ params = schema_to_params(schema)
452
+ raw_desc = tool.get("description", "")
453
+ desc = raw_desc[:120]
454
+ if raw_desc and len(raw_desc) > 120:
455
+ desc += "..."
456
+
457
+ lines.append(f"- `{func_name}({params})`: {desc}")
458
+
459
+ lines.append("")
460
+
461
+ if include_example:
462
+ example = _build_example(server_infos, custom_tool_infos)
463
+ lines.extend(
464
+ [
465
+ "### Example",
466
+ "",
467
+ PYTHON_BLOCK_START,
468
+ example,
469
+ "```",
470
+ ]
471
+ )
472
+
473
+ return "\n".join(lines)
474
+
475
+
476
+ def _build_prompt_from_servers(server_infos: list[dict[str, Any]]) -> str:
477
+ """Build prompt from collected server information (legacy, uses full mode).
478
+
479
+ Args:
480
+ server_infos: List of server info dicts with name and tools.
481
+
482
+ Returns:
483
+ Formatted PTC usage prompt.
484
+ """
485
+ return _build_full_prompt(server_infos, include_example=True)
486
+
487
+
488
+ def _build_example(
489
+ server_infos: list[dict[str, Any]],
490
+ custom_tool_infos: list[dict[str, Any]] | None = None,
491
+ ) -> str:
492
+ """Build an example code snippet using the first available tool.
493
+
494
+ Args:
495
+ server_infos: List of server info dicts.
496
+ custom_tool_infos: List of custom tool info dicts.
497
+
498
+ Returns:
499
+ Example code string.
500
+ """
501
+ # Try MCP tools first
502
+ if server_infos:
503
+ sorted_servers = sorted(server_infos, key=lambda info: sanitize_module_name_with_reserved(info["name"]))
504
+ for server in sorted_servers:
505
+ tools = server.get("tools", [])
506
+ if tools:
507
+ sorted_tools = sorted(tools, key=lambda t: sanitize_function_name(t["name"]))
508
+ tool = sorted_tools[0]
509
+ safe_server = sanitize_module_name_with_reserved(server["name"])
510
+ func_name = sanitize_function_name(tool["name"])
511
+ args_str = _build_example_args_from_schema(tool.get("input_schema", {}))
512
+ return f"""from tools.{safe_server} import {func_name}
513
+
514
+ result = {func_name}({args_str})
515
+ print(result)"""
516
+
517
+ # Fall back to custom tools
518
+ if custom_tool_infos:
519
+ sorted_custom = sorted(custom_tool_infos, key=lambda t: sanitize_function_name(t["name"]))
520
+ tool = sorted_custom[0]
521
+ func_name = sanitize_function_name(tool["name"])
522
+ args_str = _build_example_args_from_schema(tool.get("input_schema", {}))
523
+ return f"""from tools.custom import {func_name}
524
+
525
+ result = {func_name}({args_str})
526
+ print(result)"""
527
+
528
+ return _build_generic_example()
529
+
530
+
531
+ def _build_example_args_from_schema(schema: dict[str, Any]) -> str:
532
+ """Build example arguments string from a JSON schema.
533
+
534
+ Args:
535
+ schema: JSON schema for tool input.
536
+
537
+ Returns:
538
+ Example arguments string.
539
+ """
540
+ properties = schema.get("properties", {})
541
+ required = set(schema.get("required", []))
542
+
543
+ args: list[str] = []
544
+ for prop_name in sorted(required):
545
+ if prop_name not in properties:
546
+ continue
547
+ safe_name = sanitize_param_name(prop_name)
548
+ prop_schema = properties[prop_name]
549
+ example_value = _get_example_value(prop_schema, prop_name)
550
+ args.append(f"{safe_name}={example_value}")
551
+
552
+ for prop_name in sorted(properties.keys()):
553
+ if prop_name in required:
554
+ continue
555
+ if len(args) >= 2:
556
+ break
557
+ safe_name = sanitize_param_name(prop_name)
558
+ prop_schema = properties[prop_name]
559
+ example_value = _get_example_value(prop_schema, prop_name)
560
+ args.append(f"{safe_name}={example_value}")
561
+
562
+ return ", ".join(args) if args else ""
563
+
564
+
565
+ def _get_example_value(prop_schema: dict[str, Any], prop_name: str) -> str:
566
+ """Generate an example value for a parameter.
567
+
568
+ Prefers schema-provided examples, defaults, or enums.
569
+ Falls back to type-based placeholders.
570
+
571
+ Args:
572
+ prop_schema: Property schema from JSON schema.
573
+ prop_name: Original property name.
574
+
575
+ Returns:
576
+ Example value as a Python literal string.
577
+ """
578
+ return example_value_from_schema(prop_schema)
579
+
580
+
581
+ def _build_generic_example() -> str:
582
+ """Build a generic example when no tools are available.
583
+
584
+ Returns:
585
+ Generic example code string.
586
+ """
587
+ return """from tools.server_name import tool_name
588
+
589
+ result = tool_name(param="value")
590
+ print(result)"""
591
+
592
+
593
+ def _build_placeholder_prompt() -> str:
594
+ """Build a placeholder prompt when no MCP servers are configured.
595
+
596
+ Returns:
597
+ Placeholder PTC usage prompt.
598
+ """
599
+ return PTC_USAGE_RULES + "\n*No MCP servers configured yet. Tools will be available after MCP setup.*\n"
600
+
601
+
602
+ def _build_server_hash_part(mcp_client: BaseMCPClient, server_name: str) -> str:
603
+ """Build hash part for a single MCP server.
604
+
605
+ Args:
606
+ mcp_client: MCP client instance.
607
+ server_name: Name of the server.
608
+
609
+ Returns:
610
+ Hash part string for the server.
611
+ """
612
+ try:
613
+ session = mcp_client.session_pool.get_session(server_name)
614
+ tools = list(getattr(session, "tools", []))
615
+ tool_names = sorted(t.name for t in tools)
616
+
617
+ allowed = session.allowed_tools if hasattr(session, "allowed_tools") else None
618
+ if not allowed:
619
+ allowed = _get_allowed_tools_from_config(mcp_client, server_name)
620
+ allowed_str = ",".join(sorted(allowed)) if allowed else "*"
621
+
622
+ return f"{server_name}:{','.join(tool_names)}|allowed={allowed_str}"
623
+ except (KeyError, AttributeError):
624
+ allowed = _get_allowed_tools_from_config(mcp_client, server_name)
625
+ allowed_str = ",".join(sorted(allowed)) if allowed else "*"
626
+ return f"{server_name}:|allowed={allowed_str}"
627
+
628
+
629
+ def compute_ptc_prompt_hash(
630
+ mcp_client: BaseMCPClient | None = None,
631
+ config: PromptConfig | None = None,
632
+ custom_tools_config: PTCCustomToolConfig | None = None,
633
+ ) -> str:
634
+ """Compute a hash of the MCP and custom tool configuration for change detection.
635
+
636
+ Includes PromptConfig fields, allowed_tools, and custom tools in hash computation
637
+ so prompt updates re-sync correctly when configuration changes.
638
+
639
+ Args:
640
+ mcp_client: MCP client instance. Can be None if only custom tools.
641
+ config: Prompt configuration. If None, uses default PromptConfig.
642
+ custom_tools_config: Optional custom LangChain tools configuration.
643
+
644
+ Returns:
645
+ Hash string representing current configuration.
646
+ """
647
+ import hashlib
648
+
649
+ if config is None:
650
+ config = PromptConfig()
651
+
652
+ # Include config fields in hash
653
+ config_part = f"mode={config.mode}|threshold={config.auto_threshold}|example={config.include_example}"
654
+
655
+ # Create hash from server names, tool names, and allowed_tools
656
+ parts: list[str] = [config_part]
657
+
658
+ # Add MCP server parts
659
+ if mcp_client and mcp_client.servers:
660
+ for server_name in sorted(mcp_client.servers.keys()):
661
+ parts.append(_build_server_hash_part(mcp_client, server_name))
662
+
663
+ # Add custom tools parts
664
+ if custom_tools_config and custom_tools_config.enabled and custom_tools_config.tools:
665
+ custom_tool_names = sorted(sanitize_function_name(t.get("name", "")) for t in custom_tools_config.tools)
666
+ parts.append(f"custom:{','.join(custom_tool_names)}")
667
+
668
+ # Return empty hash if no tools configured
669
+ if len(parts) == 1:
670
+ return ""
671
+
672
+ content = "|".join(parts)
673
+ return hashlib.sha256(content.encode()).hexdigest()[:16]