massgen 0.1.0a2__py3-none-any.whl → 0.1.1__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.

Potentially problematic release.


This version of massgen might be problematic. Click here for more details.

Files changed (111) hide show
  1. massgen/__init__.py +1 -1
  2. massgen/agent_config.py +17 -0
  3. massgen/api_params_handler/_api_params_handler_base.py +1 -0
  4. massgen/api_params_handler/_chat_completions_api_params_handler.py +8 -1
  5. massgen/api_params_handler/_claude_api_params_handler.py +8 -1
  6. massgen/api_params_handler/_gemini_api_params_handler.py +73 -0
  7. massgen/api_params_handler/_response_api_params_handler.py +8 -1
  8. massgen/backend/base.py +31 -0
  9. massgen/backend/{base_with_mcp.py → base_with_custom_tool_and_mcp.py} +282 -11
  10. massgen/backend/chat_completions.py +182 -92
  11. massgen/backend/claude.py +115 -18
  12. massgen/backend/claude_code.py +378 -14
  13. massgen/backend/docs/CLAUDE_API_RESEARCH.md +3 -3
  14. massgen/backend/gemini.py +1275 -1607
  15. massgen/backend/gemini_mcp_manager.py +545 -0
  16. massgen/backend/gemini_trackers.py +344 -0
  17. massgen/backend/gemini_utils.py +43 -0
  18. massgen/backend/response.py +129 -70
  19. massgen/cli.py +643 -132
  20. massgen/config_builder.py +381 -32
  21. massgen/configs/README.md +111 -80
  22. massgen/configs/basic/multi/three_agents_default.yaml +1 -1
  23. massgen/configs/basic/single/single_agent.yaml +1 -1
  24. massgen/configs/providers/openai/gpt5_nano.yaml +3 -3
  25. massgen/configs/tools/custom_tools/claude_code_custom_tool_example.yaml +32 -0
  26. massgen/configs/tools/custom_tools/claude_code_custom_tool_example_no_path.yaml +28 -0
  27. massgen/configs/tools/custom_tools/claude_code_custom_tool_with_mcp_example.yaml +40 -0
  28. massgen/configs/tools/custom_tools/claude_code_custom_tool_with_wrong_mcp_example.yaml +38 -0
  29. massgen/configs/tools/custom_tools/claude_code_wrong_custom_tool_with_mcp_example.yaml +38 -0
  30. massgen/configs/tools/custom_tools/claude_custom_tool_example.yaml +24 -0
  31. massgen/configs/tools/custom_tools/claude_custom_tool_example_no_path.yaml +22 -0
  32. massgen/configs/tools/custom_tools/claude_custom_tool_with_mcp_example.yaml +35 -0
  33. massgen/configs/tools/custom_tools/claude_custom_tool_with_wrong_mcp_example.yaml +33 -0
  34. massgen/configs/tools/custom_tools/claude_wrong_custom_tool_with_mcp_example.yaml +33 -0
  35. massgen/configs/tools/custom_tools/gemini_custom_tool_example.yaml +24 -0
  36. massgen/configs/tools/custom_tools/gemini_custom_tool_example_no_path.yaml +22 -0
  37. massgen/configs/tools/custom_tools/gemini_custom_tool_with_mcp_example.yaml +35 -0
  38. massgen/configs/tools/custom_tools/gemini_custom_tool_with_wrong_mcp_example.yaml +33 -0
  39. massgen/configs/tools/custom_tools/gemini_wrong_custom_tool_with_mcp_example.yaml +33 -0
  40. massgen/configs/tools/custom_tools/github_issue_market_analysis.yaml +94 -0
  41. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_example.yaml +24 -0
  42. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_example_no_path.yaml +22 -0
  43. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_with_mcp_example.yaml +35 -0
  44. massgen/configs/tools/custom_tools/gpt5_nano_custom_tool_with_wrong_mcp_example.yaml +33 -0
  45. massgen/configs/tools/custom_tools/gpt5_nano_wrong_custom_tool_with_mcp_example.yaml +33 -0
  46. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_example.yaml +25 -0
  47. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_example_no_path.yaml +23 -0
  48. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_with_mcp_example.yaml +34 -0
  49. massgen/configs/tools/custom_tools/gpt_oss_custom_tool_with_wrong_mcp_example.yaml +34 -0
  50. massgen/configs/tools/custom_tools/gpt_oss_wrong_custom_tool_with_mcp_example.yaml +34 -0
  51. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_example.yaml +24 -0
  52. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_example_no_path.yaml +22 -0
  53. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_with_mcp_example.yaml +35 -0
  54. massgen/configs/tools/custom_tools/grok3_mini_custom_tool_with_wrong_mcp_example.yaml +33 -0
  55. massgen/configs/tools/custom_tools/grok3_mini_wrong_custom_tool_with_mcp_example.yaml +33 -0
  56. massgen/configs/tools/custom_tools/qwen_api_custom_tool_example.yaml +25 -0
  57. massgen/configs/tools/custom_tools/qwen_api_custom_tool_example_no_path.yaml +23 -0
  58. massgen/configs/tools/custom_tools/qwen_api_custom_tool_with_mcp_example.yaml +36 -0
  59. massgen/configs/tools/custom_tools/qwen_api_custom_tool_with_wrong_mcp_example.yaml +34 -0
  60. massgen/configs/tools/custom_tools/qwen_api_wrong_custom_tool_with_mcp_example.yaml +34 -0
  61. massgen/configs/tools/custom_tools/qwen_local_custom_tool_example.yaml +24 -0
  62. massgen/configs/tools/custom_tools/qwen_local_custom_tool_example_no_path.yaml +22 -0
  63. massgen/configs/tools/custom_tools/qwen_local_custom_tool_with_mcp_example.yaml +35 -0
  64. massgen/configs/tools/custom_tools/qwen_local_custom_tool_with_wrong_mcp_example.yaml +33 -0
  65. massgen/configs/tools/custom_tools/qwen_local_wrong_custom_tool_with_mcp_example.yaml +33 -0
  66. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +1 -1
  67. massgen/configs/voting/gemini_gpt_voting_sensitivity.yaml +67 -0
  68. massgen/formatter/_chat_completions_formatter.py +104 -0
  69. massgen/formatter/_claude_formatter.py +120 -0
  70. massgen/formatter/_gemini_formatter.py +448 -0
  71. massgen/formatter/_response_formatter.py +88 -0
  72. massgen/frontend/coordination_ui.py +4 -2
  73. massgen/logger_config.py +35 -3
  74. massgen/message_templates.py +56 -6
  75. massgen/orchestrator.py +179 -10
  76. massgen/stream_chunk/base.py +3 -0
  77. massgen/tests/custom_tools_example.py +392 -0
  78. massgen/tests/mcp_test_server.py +17 -7
  79. massgen/tests/test_config_builder.py +423 -0
  80. massgen/tests/test_custom_tools.py +401 -0
  81. massgen/tests/test_tools.py +127 -0
  82. massgen/tool/README.md +935 -0
  83. massgen/tool/__init__.py +39 -0
  84. massgen/tool/_async_helpers.py +70 -0
  85. massgen/tool/_basic/__init__.py +8 -0
  86. massgen/tool/_basic/_two_num_tool.py +24 -0
  87. massgen/tool/_code_executors/__init__.py +10 -0
  88. massgen/tool/_code_executors/_python_executor.py +74 -0
  89. massgen/tool/_code_executors/_shell_executor.py +61 -0
  90. massgen/tool/_exceptions.py +39 -0
  91. massgen/tool/_file_handlers/__init__.py +10 -0
  92. massgen/tool/_file_handlers/_file_operations.py +218 -0
  93. massgen/tool/_manager.py +634 -0
  94. massgen/tool/_registered_tool.py +88 -0
  95. massgen/tool/_result.py +66 -0
  96. massgen/tool/_self_evolution/_github_issue_analyzer.py +369 -0
  97. massgen/tool/docs/builtin_tools.md +681 -0
  98. massgen/tool/docs/exceptions.md +794 -0
  99. massgen/tool/docs/execution_results.md +691 -0
  100. massgen/tool/docs/manager.md +887 -0
  101. massgen/tool/docs/workflow_toolkits.md +529 -0
  102. massgen/tool/workflow_toolkits/__init__.py +57 -0
  103. massgen/tool/workflow_toolkits/base.py +55 -0
  104. massgen/tool/workflow_toolkits/new_answer.py +126 -0
  105. massgen/tool/workflow_toolkits/vote.py +167 -0
  106. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/METADATA +89 -131
  107. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/RECORD +111 -36
  108. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/WHEEL +0 -0
  109. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/entry_points.txt +0 -0
  110. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/licenses/LICENSE +0 -0
  111. {massgen-0.1.0a2.dist-info → massgen-0.1.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,634 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Tool management system for MassGen."""
3
+
4
+ import asyncio
5
+ import importlib
6
+ import importlib.util
7
+ import inspect
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from functools import partial
11
+ from pathlib import Path
12
+ from typing import Any, AsyncGenerator, Callable, Dict, Generator, List, Optional, Type
13
+
14
+ from docstring_parser import parse
15
+ from pydantic import BaseModel, ConfigDict, Field, create_model
16
+
17
+ from ._async_helpers import (
18
+ wrap_as_async_generator,
19
+ wrap_object_async,
20
+ wrap_sync_gen_async,
21
+ )
22
+ from ._registered_tool import RegisteredToolEntry
23
+ from ._result import ExecutionResult, TextContent
24
+
25
+
26
+ @dataclass
27
+ class ToolCategory:
28
+ """Tool category configuration."""
29
+
30
+ category_name: str
31
+ """Category identifier for grouping tools."""
32
+
33
+ is_enabled: bool
34
+ """Whether tools in this category are active."""
35
+
36
+ category_desc: str
37
+ """Description of the tool category."""
38
+
39
+ usage_hints: Optional[str] = None
40
+ """Usage guidelines for tools in this category."""
41
+
42
+
43
+ class ToolManager:
44
+ """Manager class for tool registration and execution.
45
+
46
+ Provides methods for:
47
+ - Tool registration: `add_tool_function`
48
+ - Tool removal: `delete_tool_function`
49
+ - Category management: `setup_category`, `modify_categories`, `delete_categories`
50
+ - Schema retrieval: `fetch_tool_schemas`
51
+ - Tool execution: `execute_tool`
52
+ """
53
+
54
+ def __init__(self) -> None:
55
+ """Initialize the tool manager."""
56
+ self.registered_tools: Dict[str, RegisteredToolEntry] = {}
57
+ self.tool_categories: Dict[str, ToolCategory] = {}
58
+
59
+ def setup_category(
60
+ self,
61
+ category_name: str,
62
+ description: str,
63
+ enabled: bool = False,
64
+ usage_hints: Optional[str] = None,
65
+ ) -> None:
66
+ """Create a new tool category.
67
+
68
+ Args:
69
+ category_name: Name of the category
70
+ description: Category description
71
+ enabled: Whether category is initially active
72
+ usage_hints: Optional usage guidelines
73
+ """
74
+ if category_name in self.tool_categories or category_name == "default":
75
+ raise ValueError(
76
+ f"Category '{category_name}' already exists or is reserved.",
77
+ )
78
+
79
+ self.tool_categories[category_name] = ToolCategory(
80
+ category_name=category_name,
81
+ category_desc=description,
82
+ usage_hints=usage_hints,
83
+ is_enabled=enabled,
84
+ )
85
+
86
+ def modify_categories(self, category_list: List[str], enabled: bool) -> None:
87
+ """Update the activation status of categories.
88
+
89
+ Args:
90
+ category_list: List of category names
91
+ enabled: New activation status
92
+ """
93
+ for cat_name in category_list:
94
+ if cat_name == "default":
95
+ continue # Default category is always active
96
+
97
+ if cat_name in self.tool_categories:
98
+ self.tool_categories[cat_name].is_enabled = enabled
99
+
100
+ def delete_categories(self, category_list: List[str]) -> None:
101
+ """Remove categories and their associated tools.
102
+
103
+ Args:
104
+ category_list: Categories to remove
105
+ """
106
+ if isinstance(category_list, str):
107
+ category_list = [category_list]
108
+
109
+ if "default" in category_list:
110
+ raise ValueError("Cannot remove the default category.")
111
+
112
+ for cat_name in category_list:
113
+ self.tool_categories.pop(cat_name, None)
114
+
115
+ # Remove tools in deleted categories
116
+ tool_list = list(self.registered_tools.keys())
117
+ for tool_name in tool_list:
118
+ if self.registered_tools[tool_name].category in category_list:
119
+ self.registered_tools.pop(tool_name)
120
+
121
+ def add_tool_function(
122
+ self,
123
+ path: Optional[str] = None,
124
+ func: Optional[Callable] = None,
125
+ category: str = "default",
126
+ preset_args: Optional[Dict[str, Any]] = None,
127
+ description: Optional[str] = None,
128
+ tool_schema: Optional[dict] = None,
129
+ include_full_desc: bool = True,
130
+ allow_var_args: bool = False,
131
+ allow_var_kwargs: bool = False,
132
+ post_processor: Optional[Callable] = None,
133
+ ) -> None:
134
+ """Register a tool function.
135
+
136
+ Args:
137
+ path: Optional path to Python file or module. If None, use func parameter
138
+ func: The tool function to register (required if path is None)
139
+ category: Category for the tool
140
+ preset_args: Arguments to preset (hidden from schema)
141
+ description: Optional function description
142
+ tool_schema: Optional manual JSON schema
143
+ include_full_desc: Include long description from docstring
144
+ allow_var_args: Include *args in schema
145
+ allow_var_kwargs: Include **kwargs in schema
146
+ post_processor: Optional post-processing function
147
+ """
148
+ if category not in self.tool_categories and category != "default":
149
+ raise ValueError(f"Category '{category}' not found.")
150
+
151
+ # Handle function loading
152
+ if isinstance(func, str):
153
+ # func is a string - treat it as function name to load
154
+ func_name = func
155
+ if path is not None:
156
+ # Load from specified path
157
+ func = self._load_function_from_path(path, func_name)
158
+ if func is None:
159
+ raise ValueError(f"Could not load function '{func_name}' from path: {path}")
160
+ else:
161
+ # No path specified - try to find in tool folder
162
+ func = self._load_builtin_function(func_name)
163
+ if func is None:
164
+ raise ValueError(f"Could not find built-in function: {func_name}")
165
+ elif func is None:
166
+ # No func provided at all
167
+ if path is not None:
168
+ # Try to load from path (will auto-detect function)
169
+ func = self._load_function_from_path(path, None)
170
+ if func is None:
171
+ raise ValueError(f"Could not load function from path: {path}")
172
+ else:
173
+ raise ValueError("Either 'path' or 'func' must be provided")
174
+ elif not callable(func):
175
+ raise ValueError("'func' must be a callable or a string (function name)")
176
+
177
+ # Validate schema if provided
178
+ if tool_schema:
179
+ assert isinstance(tool_schema, dict) and "type" in tool_schema and tool_schema["type"] == "function", "Invalid tool schema format."
180
+
181
+ # Handle partial functions
182
+ if isinstance(func, partial):
183
+ func_kwargs = func.keywords.copy()
184
+ if func.args:
185
+ param_list = list(inspect.signature(func.func).parameters.keys())
186
+ for idx, arg_val in enumerate(func.args):
187
+ if idx < len(param_list):
188
+ func_kwargs[param_list[idx]] = arg_val
189
+
190
+ preset_args = {**func_kwargs, **(preset_args or {})}
191
+ tool_name = func.func.__name__
192
+ base_func = func.func
193
+ tool_schema = tool_schema or self._extract_tool_schema(
194
+ func.func,
195
+ include_full_desc,
196
+ allow_var_args,
197
+ allow_var_kwargs,
198
+ )
199
+ else:
200
+ tool_name = func.__name__
201
+ base_func = func
202
+ tool_schema = tool_schema or self._extract_tool_schema(
203
+ func,
204
+ include_full_desc,
205
+ allow_var_args,
206
+ allow_var_kwargs,
207
+ )
208
+
209
+ # Add prefix for custom tools (not built-in ones)
210
+ if category != "builtin" and not tool_name.startswith("custom_tool__"):
211
+ tool_name = f"custom_tool__{tool_name}"
212
+ # Update the schema to reflect the new name
213
+ tool_schema["function"]["name"] = tool_name
214
+
215
+ # Check for duplicate names
216
+ if tool_name in self.registered_tools:
217
+ raise ValueError(f"Tool '{tool_name}' is already registered.")
218
+
219
+ # Override description if provided
220
+ if description:
221
+ tool_schema["function"]["description"] = description
222
+
223
+ # Remove preset args from schema
224
+ for arg in preset_args or {}:
225
+ if arg in tool_schema["function"]["parameters"]["properties"]:
226
+ tool_schema["function"]["parameters"]["properties"].pop(arg)
227
+
228
+ if "required" in tool_schema["function"]["parameters"]:
229
+ if arg in tool_schema["function"]["parameters"]["required"]:
230
+ tool_schema["function"]["parameters"]["required"].remove(arg)
231
+
232
+ if not tool_schema["function"]["parameters"]["required"]:
233
+ tool_schema["function"]["parameters"].pop("required", None)
234
+
235
+ tool_entry = RegisteredToolEntry(
236
+ tool_name=tool_name,
237
+ category=category,
238
+ origin="function",
239
+ base_function=base_func,
240
+ schema_def=tool_schema,
241
+ preset_params=preset_args or {},
242
+ extension_model=None,
243
+ post_processor=post_processor,
244
+ )
245
+
246
+ self.registered_tools[tool_name] = tool_entry
247
+
248
+ def delete_tool_function(self, tool_name: str) -> None:
249
+ """Remove a tool function by name.
250
+
251
+ Args:
252
+ tool_name: Name of tool to remove
253
+ """
254
+ self.registered_tools.pop(tool_name, None)
255
+
256
+ def fetch_tool_schemas(self) -> List[dict]:
257
+ """Get JSON schemas for all active tools.
258
+
259
+ Returns:
260
+ List of tool JSON schemas
261
+ """
262
+ schemas = []
263
+ for tool in self.registered_tools.values():
264
+ if tool.category == "default":
265
+ schemas.append(tool.get_extended_schema)
266
+ elif tool.category in self.tool_categories:
267
+ if self.tool_categories[tool.category].is_enabled:
268
+ schemas.append(tool.get_extended_schema)
269
+ return schemas
270
+
271
+ def apply_extension_model(
272
+ self,
273
+ tool_name: str,
274
+ model_class: Optional[Type[BaseModel]],
275
+ ) -> None:
276
+ """Apply an extension model to a tool's schema.
277
+
278
+ Args:
279
+ tool_name: Name of the tool
280
+ model_class: Pydantic model to extend schema with
281
+ """
282
+ if model_class and not issubclass(model_class, BaseModel):
283
+ raise TypeError("Extension model must be a Pydantic BaseModel.")
284
+
285
+ if tool_name in self.registered_tools:
286
+ self.registered_tools[tool_name].extension_model = model_class
287
+ else:
288
+ raise ValueError(f"Tool '{tool_name}' not found.")
289
+
290
+ async def execute_tool(
291
+ self,
292
+ tool_request: dict,
293
+ ) -> AsyncGenerator[ExecutionResult, None]:
294
+ """Execute a tool and return results as async generator.
295
+
296
+ Args:
297
+ tool_request: Tool execution request with name and input
298
+
299
+ Yields:
300
+ ExecutionResult objects (accumulated)
301
+ """
302
+ tool_name = tool_request.get("name")
303
+
304
+ if tool_name not in self.registered_tools:
305
+ yield ExecutionResult(
306
+ output_blocks=[
307
+ TextContent(
308
+ data=f"ToolNotFound: No tool named '{tool_name}' exists",
309
+ ),
310
+ ],
311
+ )
312
+ return
313
+
314
+ tool_entry = self.registered_tools[tool_name]
315
+ exec_kwargs = {
316
+ **tool_entry.preset_params,
317
+ **(tool_request.get("input", {}) or {}),
318
+ }
319
+
320
+ # Prepare post-processor if exists
321
+ if tool_entry.post_processor:
322
+ post_proc_partial = partial(
323
+ tool_entry.post_processor,
324
+ tool_request,
325
+ )
326
+ else:
327
+ post_proc_partial = None
328
+
329
+ try:
330
+ # Execute based on function type
331
+ if inspect.iscoroutinefunction(tool_entry.base_function):
332
+ try:
333
+ result = await tool_entry.base_function(**exec_kwargs)
334
+ except asyncio.CancelledError:
335
+ result = ExecutionResult(
336
+ output_blocks=[
337
+ TextContent(
338
+ data="<system>Tool execution was interrupted</system>",
339
+ ),
340
+ ],
341
+ is_streaming=True,
342
+ is_final=True,
343
+ was_interrupted=True,
344
+ )
345
+ else:
346
+ result = tool_entry.base_function(**exec_kwargs)
347
+
348
+ except Exception as err:
349
+ result = ExecutionResult(
350
+ output_blocks=[
351
+ TextContent(data=f"Error: {err}"),
352
+ ],
353
+ )
354
+
355
+ # Handle different return types
356
+ if isinstance(result, AsyncGenerator):
357
+ async for item in wrap_as_async_generator(result, post_proc_partial):
358
+ yield item
359
+ elif isinstance(result, Generator):
360
+ async for item in wrap_sync_gen_async(result, post_proc_partial):
361
+ yield item
362
+ elif isinstance(result, ExecutionResult):
363
+ async for item in wrap_object_async(result, post_proc_partial):
364
+ yield item
365
+ else:
366
+ raise TypeError(
367
+ f"Tool must return ExecutionResult or Generator, got {type(result)}",
368
+ )
369
+
370
+ def fetch_category_hints(self) -> str:
371
+ """Get usage hints from active categories.
372
+
373
+ Returns:
374
+ Combined usage hints string
375
+ """
376
+ hints_list = []
377
+ for cat_name, category in self.tool_categories.items():
378
+ if category.is_enabled and category.usage_hints:
379
+ hints_list.append(
380
+ f"## {cat_name} Tools\n{category.usage_hints}",
381
+ )
382
+ return "\n".join(hints_list)
383
+
384
+ def reset_state(self) -> None:
385
+ """Clear all registered tools and categories."""
386
+ self.registered_tools.clear()
387
+ self.tool_categories.clear()
388
+
389
+ def _load_builtin_function(self, func_name: str) -> Optional[Callable]:
390
+ """Load a built-in function from the tool folder.
391
+
392
+ Args:
393
+ func_name: Name of the function to load
394
+
395
+ Returns:
396
+ The loaded function or None if not found
397
+ """
398
+ # Try to import from tool module submodules
399
+ tool_modules = [
400
+ "_basic",
401
+ "_code_executors",
402
+ "_file_handlers",
403
+ "_multimedia_processors",
404
+ "workflow_toolkits",
405
+ ]
406
+
407
+ for module_name in tool_modules:
408
+ try:
409
+ full_module_name = f"massgen.tool.{module_name}"
410
+ module = importlib.import_module(full_module_name)
411
+ if hasattr(module, func_name):
412
+ return getattr(module, func_name)
413
+ except ImportError:
414
+ continue
415
+
416
+ # Try to find in __init__.py exports
417
+ try:
418
+ tool_module = importlib.import_module("massgen.tool")
419
+ if hasattr(tool_module, func_name):
420
+ return getattr(tool_module, func_name)
421
+ except ImportError:
422
+ pass
423
+
424
+ # Search in all Python files in tool folder
425
+ tool_folder = Path(__file__).parent
426
+ for py_file in tool_folder.glob("*.py"):
427
+ if py_file.stem == "__init__" or py_file.stem == "_manager":
428
+ continue
429
+
430
+ try:
431
+ spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
432
+ if spec and spec.loader:
433
+ module = importlib.util.module_from_spec(spec)
434
+ spec.loader.exec_module(module)
435
+ if hasattr(module, func_name):
436
+ return getattr(module, func_name)
437
+ except Exception:
438
+ continue
439
+
440
+ return None
441
+
442
+ def _load_function_from_path(
443
+ self,
444
+ path: str,
445
+ func_name: Optional[str] = None,
446
+ ) -> Optional[Callable]:
447
+ """Load a function from a given path.
448
+
449
+ Args:
450
+ path: Path to Python file or module name
451
+ func_name: Optional specific function name to load
452
+
453
+ Returns:
454
+ The loaded function or None if not found
455
+ """
456
+ # If path doesn't contain file extension, try to find it in tool folder
457
+ if not path.endswith(".py"):
458
+ # Try to import from tool module
459
+ try:
460
+ # First try as a submodule of tool
461
+ module_name = f"massgen.tool.{path}"
462
+ module = importlib.import_module(module_name)
463
+ except ImportError:
464
+ # Try to find in tool folder's Python files
465
+ tool_folder = Path(__file__).parent
466
+ possible_files = [
467
+ tool_folder / f"{path}.py",
468
+ tool_folder / f"_{path}.py",
469
+ ]
470
+
471
+ for file_path in possible_files:
472
+ if file_path.exists():
473
+ path = str(file_path)
474
+ break
475
+ else:
476
+ # If still not found, try as a direct module import
477
+ try:
478
+ module = importlib.import_module(path)
479
+ except ImportError:
480
+ return None
481
+
482
+ # If path is a file path, load it dynamically
483
+ if path.endswith(".py"):
484
+ path_obj = Path(path)
485
+
486
+ # If path is not absolute, try multiple resolution strategies
487
+ if not path_obj.is_absolute():
488
+ # First try as relative to current working directory
489
+ cwd_path = Path.cwd() / path
490
+ if cwd_path.exists():
491
+ path_obj = cwd_path
492
+ else:
493
+ # Then try relative to tool folder
494
+ tool_folder = Path(__file__).parent
495
+ tool_path = tool_folder / path
496
+ if tool_path.exists():
497
+ path_obj = tool_path
498
+ else:
499
+ # Finally try resolving from tool folder's parent (massgen/)
500
+ # in case path starts with "massgen/"
501
+ if path.startswith("massgen/"):
502
+ # Get the massgen package root
503
+ massgen_root = tool_folder.parent.parent
504
+ full_path = massgen_root / path
505
+ if full_path.exists():
506
+ path_obj = full_path
507
+
508
+ if not path_obj.exists():
509
+ return None
510
+
511
+ # Load module from file
512
+ module_name = path_obj.stem
513
+ spec = importlib.util.spec_from_file_location(module_name, path_obj)
514
+ if spec is None or spec.loader is None:
515
+ return None
516
+
517
+ module = importlib.util.module_from_spec(spec)
518
+ sys.modules[module_name] = module
519
+ spec.loader.exec_module(module)
520
+
521
+ # Extract function from module
522
+ if func_name:
523
+ # If specific function name provided
524
+ if hasattr(module, func_name):
525
+ return getattr(module, func_name)
526
+ else:
527
+ # Try to find a main function or the first callable
528
+ # Priority order: main -> first public function -> first function
529
+ if hasattr(module, "main"):
530
+ return getattr(module, "main")
531
+
532
+ # Find all callables in the module
533
+ callables = []
534
+ for name in dir(module):
535
+ attr = getattr(module, name)
536
+ if callable(attr) and not name.startswith("_"):
537
+ # Skip imported functions
538
+ if hasattr(attr, "__module__") and attr.__module__ != module.__name__:
539
+ continue
540
+ callables.append((name, attr))
541
+
542
+ # Return the first public function found
543
+ if callables:
544
+ return callables[0][1]
545
+
546
+ return None
547
+
548
+ @staticmethod
549
+ def _extract_tool_schema(
550
+ func: Callable,
551
+ include_full: bool,
552
+ include_varargs: bool,
553
+ include_varkwargs: bool,
554
+ ) -> dict:
555
+ """Extract JSON schema from function signature and docstring."""
556
+ doc_parsed = parse(func.__doc__)
557
+ param_docs = {p.arg_name: p.description for p in doc_parsed.params}
558
+
559
+ # Build description
560
+ desc_parts = []
561
+ if doc_parsed.short_description:
562
+ desc_parts.append(doc_parsed.short_description)
563
+ if include_full and doc_parsed.long_description:
564
+ desc_parts.append(doc_parsed.long_description)
565
+
566
+ func_desc = "\n\n".join(desc_parts)
567
+
568
+ # Build parameter fields
569
+ param_fields = {}
570
+ for param_name, param_info in inspect.signature(func).parameters.items():
571
+ if param_name in ["self", "cls"]:
572
+ continue
573
+
574
+ if param_info.kind == inspect.Parameter.VAR_KEYWORD:
575
+ if not include_varkwargs:
576
+ continue
577
+ param_fields[param_name] = (
578
+ Dict[str, Any] if param_info.annotation == inspect.Parameter.empty else Dict[str, param_info.annotation],
579
+ Field(
580
+ description=param_docs.get(param_name),
581
+ default={} if param_info.default is param_info.empty else param_info.default,
582
+ ),
583
+ )
584
+ elif param_info.kind == inspect.Parameter.VAR_POSITIONAL:
585
+ if not include_varargs:
586
+ continue
587
+ param_fields[param_name] = (
588
+ list[Any] if param_info.annotation == inspect.Parameter.empty else list[param_info.annotation],
589
+ Field(
590
+ description=param_docs.get(param_name),
591
+ default=[] if param_info.default is param_info.empty else param_info.default,
592
+ ),
593
+ )
594
+ else:
595
+ param_fields[param_name] = (
596
+ Any if param_info.annotation == inspect.Parameter.empty else param_info.annotation,
597
+ Field(
598
+ description=param_docs.get(param_name),
599
+ default=... if param_info.default is param_info.empty else param_info.default,
600
+ ),
601
+ )
602
+
603
+ dynamic_model = create_model(
604
+ "_DynamicToolModel",
605
+ __config__=ConfigDict(arbitrary_types_allowed=True),
606
+ **param_fields,
607
+ )
608
+
609
+ params_schema = dynamic_model.model_json_schema()
610
+
611
+ # Remove title fields
612
+ def remove_titles(obj):
613
+ if isinstance(obj, dict):
614
+ obj.pop("title", None)
615
+ for v in obj.values():
616
+ remove_titles(v)
617
+ elif isinstance(obj, list):
618
+ for item in obj:
619
+ remove_titles(item)
620
+
621
+ remove_titles(params_schema)
622
+
623
+ schema = {
624
+ "type": "function",
625
+ "function": {
626
+ "name": func.__name__,
627
+ "parameters": params_schema,
628
+ },
629
+ }
630
+
631
+ if func_desc:
632
+ schema["function"]["description"] = func_desc
633
+
634
+ return schema