massgen 0.0.3__py3-none-any.whl → 0.1.0__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 (268) hide show
  1. massgen/__init__.py +142 -8
  2. massgen/adapters/__init__.py +29 -0
  3. massgen/adapters/ag2_adapter.py +483 -0
  4. massgen/adapters/base.py +183 -0
  5. massgen/adapters/tests/__init__.py +0 -0
  6. massgen/adapters/tests/test_ag2_adapter.py +439 -0
  7. massgen/adapters/tests/test_agent_adapter.py +128 -0
  8. massgen/adapters/utils/__init__.py +2 -0
  9. massgen/adapters/utils/ag2_utils.py +236 -0
  10. massgen/adapters/utils/tests/__init__.py +0 -0
  11. massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
  12. massgen/agent_config.py +329 -55
  13. massgen/api_params_handler/__init__.py +10 -0
  14. massgen/api_params_handler/_api_params_handler_base.py +99 -0
  15. massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
  16. massgen/api_params_handler/_claude_api_params_handler.py +113 -0
  17. massgen/api_params_handler/_response_api_params_handler.py +130 -0
  18. massgen/backend/__init__.py +39 -4
  19. massgen/backend/azure_openai.py +385 -0
  20. massgen/backend/base.py +341 -69
  21. massgen/backend/base_with_mcp.py +1102 -0
  22. massgen/backend/capabilities.py +386 -0
  23. massgen/backend/chat_completions.py +577 -130
  24. massgen/backend/claude.py +1033 -537
  25. massgen/backend/claude_code.py +1203 -0
  26. massgen/backend/cli_base.py +209 -0
  27. massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
  28. massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
  29. massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
  30. massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
  31. massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
  32. massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
  33. massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
  34. massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
  35. massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
  36. massgen/backend/docs/inference_backend.md +257 -0
  37. massgen/backend/docs/permissions_and_context_files.md +1085 -0
  38. massgen/backend/external.py +126 -0
  39. massgen/backend/gemini.py +1850 -241
  40. massgen/backend/grok.py +40 -156
  41. massgen/backend/inference.py +156 -0
  42. massgen/backend/lmstudio.py +171 -0
  43. massgen/backend/response.py +1095 -322
  44. massgen/chat_agent.py +131 -113
  45. massgen/cli.py +1560 -275
  46. massgen/config_builder.py +2396 -0
  47. massgen/configs/BACKEND_CONFIGURATION.md +458 -0
  48. massgen/configs/README.md +559 -216
  49. massgen/configs/ag2/ag2_case_study.yaml +27 -0
  50. massgen/configs/ag2/ag2_coder.yaml +34 -0
  51. massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
  52. massgen/configs/ag2/ag2_gemini.yaml +27 -0
  53. massgen/configs/ag2/ag2_groupchat.yaml +108 -0
  54. massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
  55. massgen/configs/ag2/ag2_single_agent.yaml +21 -0
  56. massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
  57. massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
  58. massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
  59. massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
  60. massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
  61. massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
  62. massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
  63. massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
  64. massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
  65. massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
  66. massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
  67. massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
  68. massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
  69. massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
  70. massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
  71. massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
  72. massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
  73. massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
  74. massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
  75. massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
  76. massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
  77. massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
  78. massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
  79. massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
  80. massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
  81. massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
  82. massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
  83. massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
  84. massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
  85. massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
  86. massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
  87. massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
  88. massgen/configs/debug/skip_coordination_test.yaml +27 -0
  89. massgen/configs/debug/test_sdk_migration.yaml +17 -0
  90. massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
  91. massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
  92. massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
  93. massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
  94. massgen/configs/providers/claude/claude.yaml +14 -0
  95. massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
  96. massgen/configs/providers/local/lmstudio.yaml +11 -0
  97. massgen/configs/providers/openai/gpt5.yaml +46 -0
  98. massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
  99. massgen/configs/providers/others/grok_single_agent.yaml +19 -0
  100. massgen/configs/providers/others/zai_coding_team.yaml +108 -0
  101. massgen/configs/providers/others/zai_glm45.yaml +12 -0
  102. massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
  103. massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
  104. massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
  105. massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
  106. massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
  107. massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
  108. massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
  109. massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
  110. massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
  111. massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
  112. massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
  113. massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
  114. massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
  115. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
  116. massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
  117. massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
  118. massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
  119. massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
  120. massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
  121. massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
  122. massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
  123. massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
  124. massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
  125. massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
  126. massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
  127. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
  128. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
  129. massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
  130. massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
  131. massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
  132. massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
  133. massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
  134. massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
  135. massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
  136. massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
  137. massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
  138. massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
  139. massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
  140. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
  141. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
  142. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
  143. massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
  144. massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
  145. massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
  146. massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
  147. massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
  148. massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
  149. massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
  150. massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
  151. massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
  152. massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
  153. massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
  154. massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
  155. massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
  156. massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
  157. massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
  158. massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
  159. massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
  160. massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
  161. massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
  162. massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
  163. massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
  164. massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
  165. massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
  166. massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
  167. massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
  168. massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
  169. massgen/coordination_tracker.py +708 -0
  170. massgen/docker/README.md +462 -0
  171. massgen/filesystem_manager/__init__.py +21 -0
  172. massgen/filesystem_manager/_base.py +9 -0
  173. massgen/filesystem_manager/_code_execution_server.py +545 -0
  174. massgen/filesystem_manager/_docker_manager.py +477 -0
  175. massgen/filesystem_manager/_file_operation_tracker.py +248 -0
  176. massgen/filesystem_manager/_filesystem_manager.py +813 -0
  177. massgen/filesystem_manager/_path_permission_manager.py +1261 -0
  178. massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
  179. massgen/formatter/__init__.py +10 -0
  180. massgen/formatter/_chat_completions_formatter.py +284 -0
  181. massgen/formatter/_claude_formatter.py +235 -0
  182. massgen/formatter/_formatter_base.py +156 -0
  183. massgen/formatter/_response_formatter.py +263 -0
  184. massgen/frontend/__init__.py +1 -2
  185. massgen/frontend/coordination_ui.py +471 -286
  186. massgen/frontend/displays/base_display.py +56 -11
  187. massgen/frontend/displays/create_coordination_table.py +1956 -0
  188. massgen/frontend/displays/rich_terminal_display.py +1259 -619
  189. massgen/frontend/displays/simple_display.py +9 -4
  190. massgen/frontend/displays/terminal_display.py +27 -68
  191. massgen/logger_config.py +681 -0
  192. massgen/mcp_tools/README.md +232 -0
  193. massgen/mcp_tools/__init__.py +105 -0
  194. massgen/mcp_tools/backend_utils.py +1035 -0
  195. massgen/mcp_tools/circuit_breaker.py +195 -0
  196. massgen/mcp_tools/client.py +894 -0
  197. massgen/mcp_tools/config_validator.py +138 -0
  198. massgen/mcp_tools/docs/circuit_breaker.md +646 -0
  199. massgen/mcp_tools/docs/client.md +950 -0
  200. massgen/mcp_tools/docs/config_validator.md +478 -0
  201. massgen/mcp_tools/docs/exceptions.md +1165 -0
  202. massgen/mcp_tools/docs/security.md +854 -0
  203. massgen/mcp_tools/exceptions.py +338 -0
  204. massgen/mcp_tools/hooks.py +212 -0
  205. massgen/mcp_tools/security.py +780 -0
  206. massgen/message_templates.py +342 -64
  207. massgen/orchestrator.py +1515 -241
  208. massgen/stream_chunk/__init__.py +35 -0
  209. massgen/stream_chunk/base.py +92 -0
  210. massgen/stream_chunk/multimodal.py +237 -0
  211. massgen/stream_chunk/text.py +162 -0
  212. massgen/tests/mcp_test_server.py +150 -0
  213. massgen/tests/multi_turn_conversation_design.md +0 -8
  214. massgen/tests/test_azure_openai_backend.py +156 -0
  215. massgen/tests/test_backend_capabilities.py +262 -0
  216. massgen/tests/test_backend_event_loop_all.py +179 -0
  217. massgen/tests/test_chat_completions_refactor.py +142 -0
  218. massgen/tests/test_claude_backend.py +15 -28
  219. massgen/tests/test_claude_code.py +268 -0
  220. massgen/tests/test_claude_code_context_sharing.py +233 -0
  221. massgen/tests/test_claude_code_orchestrator.py +175 -0
  222. massgen/tests/test_cli_backends.py +180 -0
  223. massgen/tests/test_code_execution.py +679 -0
  224. massgen/tests/test_external_agent_backend.py +134 -0
  225. massgen/tests/test_final_presentation_fallback.py +237 -0
  226. massgen/tests/test_gemini_planning_mode.py +351 -0
  227. massgen/tests/test_grok_backend.py +7 -10
  228. massgen/tests/test_http_mcp_server.py +42 -0
  229. massgen/tests/test_integration_simple.py +198 -0
  230. massgen/tests/test_mcp_blocking.py +125 -0
  231. massgen/tests/test_message_context_building.py +29 -47
  232. massgen/tests/test_orchestrator_final_presentation.py +48 -0
  233. massgen/tests/test_path_permission_manager.py +2087 -0
  234. massgen/tests/test_rich_terminal_display.py +14 -13
  235. massgen/tests/test_timeout.py +133 -0
  236. massgen/tests/test_v3_3agents.py +11 -12
  237. massgen/tests/test_v3_simple.py +8 -13
  238. massgen/tests/test_v3_three_agents.py +11 -18
  239. massgen/tests/test_v3_two_agents.py +8 -13
  240. massgen/token_manager/__init__.py +7 -0
  241. massgen/token_manager/token_manager.py +400 -0
  242. massgen/utils.py +52 -16
  243. massgen/v1/agent.py +45 -91
  244. massgen/v1/agents.py +18 -53
  245. massgen/v1/backends/gemini.py +50 -153
  246. massgen/v1/backends/grok.py +21 -54
  247. massgen/v1/backends/oai.py +39 -111
  248. massgen/v1/cli.py +36 -93
  249. massgen/v1/config.py +8 -12
  250. massgen/v1/logging.py +43 -127
  251. massgen/v1/main.py +18 -32
  252. massgen/v1/orchestrator.py +68 -209
  253. massgen/v1/streaming_display.py +62 -163
  254. massgen/v1/tools.py +8 -12
  255. massgen/v1/types.py +9 -23
  256. massgen/v1/utils.py +5 -23
  257. massgen-0.1.0.dist-info/METADATA +1245 -0
  258. massgen-0.1.0.dist-info/RECORD +273 -0
  259. massgen-0.1.0.dist-info/entry_points.txt +2 -0
  260. massgen/frontend/logging/__init__.py +0 -9
  261. massgen/frontend/logging/realtime_logger.py +0 -197
  262. massgen-0.0.3.dist-info/METADATA +0 -568
  263. massgen-0.0.3.dist-info/RECORD +0 -76
  264. massgen-0.0.3.dist-info/entry_points.txt +0 -2
  265. /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
  266. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
massgen/cli.py CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
2
3
  """
3
4
  MassGen Command Line Interface
4
5
 
@@ -16,34 +17,55 @@ Usage examples:
16
17
  python -m massgen.cli --config config.yaml
17
18
 
18
19
  # Multiple agents from config
19
- python -m massgen.cli --config multi_agent.yaml "Compare different approaches to renewable energy"
20
+ python -m massgen.cli --config multi_agent.yaml "Compare different approaches to renewable energy" # noqa
20
21
  """
21
22
 
22
23
  import argparse
23
24
  import asyncio
24
25
  import json
25
26
  import os
27
+ import shutil
26
28
  import sys
27
- import yaml
29
+ from datetime import datetime
28
30
  from pathlib import Path
29
- from typing import Dict, Any, Optional, List
30
-
31
- from .utils import MODEL_MAPPINGS, get_backend_type_from_model
32
-
31
+ from typing import Any, Dict, List, Optional
33
32
 
34
- # Load environment variables from .env file
33
+ import yaml
34
+ from dotenv import load_dotenv
35
+ from rich.console import Console
36
+ from rich.panel import Panel
37
+ from rich.table import Table
38
+
39
+ from .agent_config import AgentConfig, TimeoutConfig
40
+ from .backend.azure_openai import AzureOpenAIBackend
41
+ from .backend.chat_completions import ChatCompletionsBackend
42
+ from .backend.claude import ClaudeBackend
43
+ from .backend.claude_code import ClaudeCodeBackend
44
+ from .backend.gemini import GeminiBackend
45
+ from .backend.grok import GrokBackend
46
+ from .backend.inference import InferenceBackend
47
+ from .backend.lmstudio import LMStudioBackend
48
+ from .backend.response import ResponseBackend
49
+ from .chat_agent import ConfigurableAgent, SingleAgent
50
+ from .frontend.coordination_ui import CoordinationUI
51
+ from .logger_config import _DEBUG_MODE, logger, setup_logging
52
+ from .orchestrator import Orchestrator
53
+ from .utils import get_backend_type_from_model
54
+
55
+
56
+ # Load environment variables from .env files
35
57
  def load_env_file():
36
- """Load environment variables from .env file if it exists."""
37
- env_file = Path(".env")
38
- if env_file.exists():
39
- with open(env_file, "r") as f:
40
- for line in f:
41
- line = line.strip()
42
- if line and not line.startswith("#") and "=" in line:
43
- key, value = line.split("=", 1)
44
- # Remove quotes if present
45
- value = value.strip("\"'")
46
- os.environ[key] = value
58
+ """Load environment variables from .env files.
59
+
60
+ Search order (later files override earlier ones):
61
+ 1. MassGen package .env (development fallback)
62
+ 2. User home ~/.massgen/.env (global user config)
63
+ 3. Current directory .env (project-specific, highest priority)
64
+ """
65
+ # Load in priority order (later overrides earlier)
66
+ load_dotenv(Path(__file__).parent / ".env") # Package fallback
67
+ load_dotenv(Path.home() / ".massgen" / ".env") # User global
68
+ load_dotenv() # Current directory (highest priority)
47
69
 
48
70
 
49
71
  # Load .env file at module import
@@ -53,15 +75,6 @@ load_env_file()
53
75
  project_root = Path(__file__).parent.parent.parent.parent
54
76
  sys.path.insert(0, str(project_root))
55
77
 
56
- from massgen.backend.response import ResponseBackend
57
- from massgen.backend.grok import GrokBackend
58
- from massgen.backend.claude import ClaudeBackend
59
- from massgen.backend.gemini import GeminiBackend
60
- from massgen.chat_agent import SingleAgent, ConfigurableAgent
61
- from massgen.agent_config import AgentConfig
62
- from massgen.orchestrator import Orchestrator
63
- from massgen.frontend.coordination_ui import CoordinationUI
64
-
65
78
  # Color constants for terminal output
66
79
  BRIGHT_CYAN = "\033[96m"
67
80
  BRIGHT_BLUE = "\033[94m"
@@ -77,22 +90,153 @@ BOLD = "\033[1m"
77
90
  class ConfigurationError(Exception):
78
91
  """Configuration error for CLI."""
79
92
 
80
- pass
93
+
94
+ def _substitute_variables(obj: Any, variables: Dict[str, str]) -> Any:
95
+ """Recursively substitute ${var} references in config with actual values.
96
+
97
+ Args:
98
+ obj: Config object (dict, list, str, or other)
99
+ variables: Dict of variable names to values
100
+
101
+ Returns:
102
+ Config object with variables substituted
103
+ """
104
+ if isinstance(obj, dict):
105
+ return {k: _substitute_variables(v, variables) for k, v in obj.items()}
106
+ elif isinstance(obj, list):
107
+ return [_substitute_variables(item, variables) for item in obj]
108
+ elif isinstance(obj, str):
109
+ # Replace ${var} with value
110
+ result = obj
111
+ for var_name, var_value in variables.items():
112
+ result = result.replace(f"${{{var_name}}}", var_value)
113
+ return result
114
+ else:
115
+ return obj
116
+
117
+
118
+ def resolve_config_path(config_arg: Optional[str]) -> Optional[Path]:
119
+ """Resolve config file with flexible syntax.
120
+
121
+ Priority order:
122
+
123
+ **If --config flag provided (highest priority):**
124
+ 1. @examples/NAME → Package examples (search configs directory)
125
+ 2. Absolute/relative paths (exact path as specified)
126
+ 3. Named configs in ~/.config/massgen/agents/
127
+
128
+ **If NO --config flag (auto-discovery):**
129
+ 1. .massgen/config.yaml (project-level config in current directory)
130
+ 2. ~/.config/massgen/config.yaml (global default config)
131
+ 3. None → trigger config builder
132
+
133
+ Args:
134
+ config_arg: Config argument from --config flag (can be @examples/NAME, path, or None)
135
+
136
+ Returns:
137
+ Path to config file, or None if config builder should run
138
+
139
+ Raises:
140
+ ConfigurationError: If config file not found
141
+ """
142
+ # Check for default configs if no config_arg provided
143
+ if not config_arg:
144
+ # Priority 1: Project-level config (.massgen/config.yaml in current directory)
145
+ project_config = Path.cwd() / ".massgen" / "config.yaml"
146
+ if project_config.exists():
147
+ return project_config
148
+
149
+ # Priority 2: Global default config
150
+ global_config = Path.home() / ".config/massgen/config.yaml"
151
+ if global_config.exists():
152
+ return global_config
153
+
154
+ return None # Trigger builder
155
+
156
+ # Handle @examples/ prefix - search in package configs
157
+ if config_arg.startswith("@examples/"):
158
+ name = config_arg[10:] # Remove '@examples/' prefix
159
+ try:
160
+ from importlib.resources import files
161
+
162
+ configs_root = files("massgen") / "configs"
163
+
164
+ # Search recursively for matching name
165
+ # Try to find by filename stem match
166
+ for config_file in configs_root.rglob("*.yaml"):
167
+ # Check if name matches the file stem or is contained in the path
168
+ if name in config_file.name or name in str(config_file):
169
+ return Path(str(config_file))
170
+
171
+ raise ConfigurationError(
172
+ f"Config '{config_arg}' not found in package.\n" f"Use --list-examples to see available configs.",
173
+ )
174
+ except Exception as e:
175
+ if isinstance(e, ConfigurationError):
176
+ raise
177
+ raise ConfigurationError(f"Error loading package config: {e}")
178
+
179
+ # Try as regular path (absolute or relative)
180
+ path = Path(config_arg).expanduser()
181
+ if path.exists():
182
+ return path
183
+
184
+ # Try in user config directory (~/.config/massgen/agents/)
185
+ user_agents_dir = Path.home() / ".config/massgen/agents"
186
+ user_config = user_agents_dir / f"{config_arg}.yaml"
187
+ if user_config.exists():
188
+ return user_config
189
+
190
+ # Also try with .yaml extension if not provided
191
+ if not config_arg.endswith((".yaml", ".yml")):
192
+ user_config_with_ext = user_agents_dir / f"{config_arg}.yaml"
193
+ if user_config_with_ext.exists():
194
+ return user_config_with_ext
195
+
196
+ # Config not found anywhere
197
+ raise ConfigurationError(
198
+ f"Configuration file not found: {config_arg}\n"
199
+ f"Searched in:\n"
200
+ f" - Current directory: {Path.cwd() / config_arg}\n"
201
+ f" - User configs: {user_agents_dir / config_arg}.yaml\n"
202
+ f"Use --list-examples to see available package configs.",
203
+ )
81
204
 
82
205
 
83
206
  def load_config_file(config_path: str) -> Dict[str, Any]:
84
- """Load configuration from YAML or JSON file."""
207
+ """Load configuration from YAML or JSON file.
208
+
209
+ Search order:
210
+ 1. Exact path as provided (absolute or relative to CWD)
211
+ 2. If just a filename, search in package's configs/ directory
212
+ 3. If a relative path, also try within package's configs/ directory
213
+
214
+ Supports variable substitution: ${cwd} in any string will be replaced with the agent's cwd value.
215
+ """
85
216
  path = Path(config_path)
86
217
 
87
- # If file doesn't exist in current path, try massgen/configs/ directory
88
- if not path.exists():
89
- # Try in massgen/configs/ directory
90
- configs_path = Path(__file__).parent / "configs" / path.name
91
- if configs_path.exists():
92
- path = configs_path
218
+ # Try the path as-is first (handles absolute paths and relative to CWD)
219
+ if path.exists():
220
+ pass # Use this path
221
+ elif path.is_absolute():
222
+ # Absolute path that doesn't exist
223
+ raise ConfigurationError(f"Configuration file not found: {config_path}")
224
+ else:
225
+ # Relative path or just filename - search in package configs
226
+ package_configs_dir = Path(__file__).parent / "configs"
227
+
228
+ # Try 1: Just the filename in package configs root
229
+ candidate1 = package_configs_dir / path.name
230
+ # Try 2: The full relative path within package configs
231
+ candidate2 = package_configs_dir / path
232
+
233
+ if candidate1.exists():
234
+ path = candidate1
235
+ elif candidate2.exists():
236
+ path = candidate2
93
237
  else:
94
238
  raise ConfigurationError(
95
- f"Configuration file not found: {config_path} (also checked {configs_path})"
239
+ f"Configuration file not found: {config_path}\n" f"Searched in:\n" f" - {Path.cwd() / config_path}\n" f" - {candidate1}\n" f" - {candidate2}",
96
240
  )
97
241
 
98
242
  try:
@@ -102,186 +246,564 @@ def load_config_file(config_path: str) -> Dict[str, Any]:
102
246
  elif path.suffix.lower() == ".json":
103
247
  return json.load(f)
104
248
  else:
105
- raise ConfigurationError(
106
- f"Unsupported config file format: {path.suffix}"
107
- )
249
+ raise ConfigurationError(f"Unsupported config file format: {path.suffix}")
108
250
  except Exception as e:
109
251
  raise ConfigurationError(f"Error reading config file: {e}")
110
252
 
111
253
 
112
254
  def create_backend(backend_type: str, **kwargs) -> Any:
113
- """Create backend instance from type and parameters."""
255
+ """Create backend instance from type and parameters.
256
+
257
+ Supported backend types:
258
+ - openai: OpenAI API (requires OPENAI_API_KEY)
259
+ - grok: xAI Grok (requires XAI_API_KEY)
260
+ - sglang: SGLang inference server (local)
261
+ - claude: Anthropic Claude (requires ANTHROPIC_API_KEY)
262
+ - gemini: Google Gemini (requires GOOGLE_API_KEY or GEMINI_API_KEY)
263
+ - chatcompletion: OpenAI-compatible providers (auto-detects API key based on base_url)
264
+
265
+ Supported backend with external dependencies:
266
+ - ag2/autogen: AG2 (AutoGen) framework agents
267
+
268
+ For chatcompletion backend, the following providers are auto-detected:
269
+ - Cerebras AI (cerebras.ai) -> CEREBRAS_API_KEY
270
+ - Together AI (together.ai/together.xyz) -> TOGETHER_API_KEY
271
+ - Fireworks AI (fireworks.ai) -> FIREWORKS_API_KEY
272
+ - Groq (groq.com) -> GROQ_API_KEY
273
+ - Nebius AI Studio (studio.nebius.ai) -> NEBIUS_API_KEY
274
+ - OpenRouter (openrouter.ai) -> OPENROUTER_API_KEY
275
+ - POE (poe.com) -> POE_API_KEY
276
+ - Qwen (dashscope.aliyuncs.com) -> QWEN_API_KEY
277
+
278
+ External agent frameworks are supported via the adapter registry.
279
+ """
114
280
  backend_type = backend_type.lower()
115
281
 
282
+ # Check if this is a framework/adapter type
283
+ from massgen.adapters import adapter_registry
284
+
285
+ if backend_type in adapter_registry:
286
+ # Use ExternalAgentBackend for all registered adapter types
287
+ from massgen.backend.external import ExternalAgentBackend
288
+
289
+ return ExternalAgentBackend(adapter_type=backend_type, **kwargs)
290
+
116
291
  if backend_type == "openai":
117
292
  api_key = kwargs.get("api_key") or os.getenv("OPENAI_API_KEY")
118
293
  if not api_key:
119
294
  raise ConfigurationError(
120
- "OpenAI API key not found. Set OPENAI_API_KEY or provide in config."
295
+ "OpenAI API key not found. Set OPENAI_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
121
296
  )
122
- return ResponseBackend(api_key=api_key)
297
+ return ResponseBackend(api_key=api_key, **kwargs)
123
298
 
124
299
  elif backend_type == "grok":
125
300
  api_key = kwargs.get("api_key") or os.getenv("XAI_API_KEY")
126
301
  if not api_key:
127
302
  raise ConfigurationError(
128
- "Grok API key not found. Set XAI_API_KEY or provide in config."
303
+ "Grok API key not found. Set XAI_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
129
304
  )
130
- return GrokBackend(api_key=api_key)
305
+ return GrokBackend(api_key=api_key, **kwargs)
131
306
 
132
307
  elif backend_type == "claude":
133
308
  api_key = kwargs.get("api_key") or os.getenv("ANTHROPIC_API_KEY")
134
309
  if not api_key:
135
310
  raise ConfigurationError(
136
- "Claude API key not found. Set ANTHROPIC_API_KEY or provide in config."
311
+ "Claude API key not found. Set ANTHROPIC_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
137
312
  )
138
- return ClaudeBackend(api_key=api_key)
313
+ return ClaudeBackend(api_key=api_key, **kwargs)
139
314
 
140
315
  elif backend_type == "gemini":
141
- api_key = (
142
- kwargs.get("api_key")
143
- or os.getenv("GOOGLE_API_KEY")
144
- or os.getenv("GEMINI_API_KEY")
145
- )
316
+ api_key = kwargs.get("api_key") or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
146
317
  if not api_key:
147
318
  raise ConfigurationError(
148
- "Gemini API key not found. Set GOOGLE_API_KEY or provide in config."
319
+ "Gemini API key not found. Set GOOGLE_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
149
320
  )
150
- return GeminiBackend(api_key=api_key)
321
+ return GeminiBackend(api_key=api_key, **kwargs)
322
+
323
+ elif backend_type == "chatcompletion":
324
+ api_key = kwargs.get("api_key")
325
+ base_url = kwargs.get("base_url")
326
+
327
+ # Determine API key based on base URL if not explicitly provided
328
+ if not api_key:
329
+ if base_url and "cerebras.ai" in base_url:
330
+ api_key = os.getenv("CEREBRAS_API_KEY")
331
+ if not api_key:
332
+ raise ConfigurationError(
333
+ "Cerebras AI API key not found. Set CEREBRAS_API_KEY environment variable.\n"
334
+ "You can add it to a .env file in:\n"
335
+ " - Current directory: .env\n"
336
+ " - Global config: ~/.massgen/.env",
337
+ )
338
+ elif base_url and "together.xyz" in base_url:
339
+ api_key = os.getenv("TOGETHER_API_KEY")
340
+ if not api_key:
341
+ raise ConfigurationError(
342
+ "Together AI API key not found. Set TOGETHER_API_KEY environment variable.\n"
343
+ "You can add it to a .env file in:\n"
344
+ " - Current directory: .env\n"
345
+ " - Global config: ~/.massgen/.env",
346
+ )
347
+ elif base_url and "fireworks.ai" in base_url:
348
+ api_key = os.getenv("FIREWORKS_API_KEY")
349
+ if not api_key:
350
+ raise ConfigurationError(
351
+ "Fireworks AI API key not found. Set FIREWORKS_API_KEY environment variable.\n"
352
+ "You can add it to a .env file in:\n"
353
+ " - Current directory: .env\n"
354
+ " - Global config: ~/.massgen/.env",
355
+ )
356
+ elif base_url and "groq.com" in base_url:
357
+ api_key = os.getenv("GROQ_API_KEY")
358
+ if not api_key:
359
+ raise ConfigurationError(
360
+ "Groq API key not found. Set GROQ_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
361
+ )
362
+ elif base_url and "nebius.com" in base_url:
363
+ api_key = os.getenv("NEBIUS_API_KEY")
364
+ if not api_key:
365
+ raise ConfigurationError(
366
+ "Nebius AI Studio API key not found. Set NEBIUS_API_KEY environment variable.\n"
367
+ "You can add it to a .env file in:\n"
368
+ " - Current directory: .env\n"
369
+ " - Global config: ~/.massgen/.env",
370
+ )
371
+ elif base_url and "openrouter.ai" in base_url:
372
+ api_key = os.getenv("OPENROUTER_API_KEY")
373
+ if not api_key:
374
+ raise ConfigurationError(
375
+ "OpenRouter API key not found. Set OPENROUTER_API_KEY environment variable.\n"
376
+ "You can add it to a .env file in:\n"
377
+ " - Current directory: .env\n"
378
+ " - Global config: ~/.massgen/.env",
379
+ )
380
+ elif base_url and ("z.ai" in base_url or "bigmodel.cn" in base_url):
381
+ api_key = os.getenv("ZAI_API_KEY")
382
+ if not api_key:
383
+ raise ConfigurationError(
384
+ "ZAI API key not found. Set ZAI_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
385
+ )
386
+ elif base_url and ("moonshot.ai" in base_url or "moonshot.cn" in base_url):
387
+ api_key = os.getenv("MOONSHOT_API_KEY") or os.getenv("KIMI_API_KEY")
388
+ if not api_key:
389
+ raise ConfigurationError(
390
+ "Kimi/Moonshot API key not found. Set MOONSHOT_API_KEY or KIMI_API_KEY environment variable.\n"
391
+ "You can add it to a .env file in:\n"
392
+ " - Current directory: .env\n"
393
+ " - Global config: ~/.massgen/.env",
394
+ )
395
+ elif base_url and "poe.com" in base_url:
396
+ api_key = os.getenv("POE_API_KEY")
397
+ if not api_key:
398
+ raise ConfigurationError(
399
+ "POE API key not found. Set POE_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
400
+ )
401
+ elif base_url and "aliyuncs.com" in base_url:
402
+ api_key = os.getenv("QWEN_API_KEY")
403
+ if not api_key:
404
+ raise ConfigurationError(
405
+ "Qwen API key not found. Set QWEN_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
406
+ )
407
+
408
+ return ChatCompletionsBackend(api_key=api_key, **kwargs)
409
+
410
+ elif backend_type == "zai":
411
+ # ZAI (Zhipu.ai) uses OpenAI-compatible Chat Completions at a custom base_url
412
+ # Supports both global (z.ai) and China (bigmodel.cn) endpoints
413
+ api_key = kwargs.get("api_key") or os.getenv("ZAI_API_KEY")
414
+ if not api_key:
415
+ raise ConfigurationError(
416
+ "ZAI API key not found. Set ZAI_API_KEY environment variable.\n" "You can add it to a .env file in:\n" " - Current directory: .env\n" " - Global config: ~/.massgen/.env",
417
+ )
418
+ return ChatCompletionsBackend(api_key=api_key, **kwargs)
419
+
420
+ elif backend_type == "lmstudio":
421
+ # LM Studio local server (OpenAI-compatible). Defaults handled by backend.
422
+ return LMStudioBackend(**kwargs)
423
+
424
+ elif backend_type == "vllm":
425
+ # vLLM local server (OpenAI-compatible). Defaults handled by backend.
426
+ return InferenceBackend(backend_type="vllm", **kwargs)
427
+
428
+ elif backend_type == "sglang":
429
+ # SGLang local server (OpenAI-compatible). Defaults handled by backend.
430
+ return InferenceBackend(backend_type="sglang", **kwargs)
431
+
432
+ elif backend_type == "claude_code":
433
+ # ClaudeCodeBackend using claude-code-sdk-python
434
+ # Authentication handled by backend (API key or subscription)
435
+
436
+ # Validate claude-code-sdk availability
437
+ try:
438
+ pass
439
+ except ImportError:
440
+ raise ConfigurationError("claude-code-sdk not found. Install with: pip install claude-code-sdk")
441
+
442
+ return ClaudeCodeBackend(**kwargs)
443
+
444
+ elif backend_type == "azure_openai":
445
+ api_key = kwargs.get("api_key") or os.getenv("AZURE_OPENAI_API_KEY")
446
+ endpoint = kwargs.get("base_url") or os.getenv("AZURE_OPENAI_ENDPOINT")
447
+ if not api_key:
448
+ raise ConfigurationError("Azure OpenAI API key not found. Set AZURE_OPENAI_API_KEY or provide in config.")
449
+ if not endpoint:
450
+ raise ConfigurationError("Azure OpenAI endpoint not found. Set AZURE_OPENAI_ENDPOINT or provide base_url in config.")
451
+ return AzureOpenAIBackend(**kwargs)
151
452
 
152
453
  else:
153
454
  raise ConfigurationError(f"Unsupported backend type: {backend_type}")
154
455
 
155
456
 
156
- def create_agents_from_config(config: Dict[str, Any]) -> Dict[str, ConfigurableAgent]:
457
+ def create_agents_from_config(config: Dict[str, Any], orchestrator_config: Optional[Dict[str, Any]] = None) -> Dict[str, ConfigurableAgent]:
157
458
  """Create agents from configuration."""
158
459
  agents = {}
159
460
 
160
- # Handle single agent configuration
161
- if "agent" in config:
162
- agent_config_data = config["agent"]
163
- backend_config = agent_config_data.get("backend", {})
461
+ agent_entries = [config["agent"]] if "agent" in config else config.get("agents", None)
462
+
463
+ if not agent_entries:
464
+ raise ConfigurationError("Configuration must contain either 'agent' or 'agents' section")
465
+
466
+ for i, agent_data in enumerate(agent_entries, start=1):
467
+ backend_config = agent_data.get("backend", {})
468
+
469
+ # Substitute variables like ${cwd} in backend config
470
+ if "cwd" in backend_config:
471
+ variables = {"cwd": backend_config["cwd"]}
472
+ backend_config = _substitute_variables(backend_config, variables)
164
473
 
165
474
  # Infer backend type from model if not explicitly provided
166
- if "type" not in backend_config and "model" in backend_config:
167
- backend_type = get_backend_type_from_model(backend_config["model"])
168
- else:
169
- backend_type = backend_config.get("type")
170
- if not backend_type:
171
- raise ConfigurationError(
172
- "Backend type must be specified or inferrable from model"
173
- )
475
+ backend_type = backend_config.get("type") or (get_backend_type_from_model(backend_config["model"]) if "model" in backend_config else None)
476
+ if not backend_type:
477
+ raise ConfigurationError("Backend type must be specified or inferrable from model")
478
+
479
+ # Add orchestrator context for filesystem setup if available
480
+ if orchestrator_config:
481
+ if "agent_temporary_workspace" in orchestrator_config:
482
+ backend_config["agent_temporary_workspace"] = orchestrator_config["agent_temporary_workspace"]
483
+ # Add orchestrator-level context_paths to all agents
484
+ if "context_paths" in orchestrator_config:
485
+ # Merge orchestrator context_paths with agent-specific ones
486
+ agent_context_paths = backend_config.get("context_paths", [])
487
+ orchestrator_context_paths = orchestrator_config["context_paths"]
488
+
489
+ # Deduplicate paths - orchestrator paths take precedence
490
+ merged_paths = orchestrator_context_paths.copy()
491
+ orchestrator_paths_set = {path.get("path") for path in orchestrator_context_paths}
492
+
493
+ for agent_path in agent_context_paths:
494
+ if agent_path.get("path") not in orchestrator_paths_set:
495
+ merged_paths.append(agent_path)
496
+
497
+ backend_config["context_paths"] = merged_paths
174
498
 
175
499
  backend = create_backend(backend_type, **backend_config)
176
-
177
- # Create proper AgentConfig with backend_params
178
- if backend_type.lower() == "openai":
179
- agent_config = AgentConfig.create_openai_config(
180
- **{k: v for k, v in backend_config.items() if k != "type"}
181
- )
182
- elif backend_type.lower() == "claude":
183
- agent_config = AgentConfig.create_claude_config(
184
- **{k: v for k, v in backend_config.items() if k != "type"}
185
- )
186
- elif backend_type.lower() == "grok":
187
- agent_config = AgentConfig.create_grok_config(
188
- **{k: v for k, v in backend_config.items() if k != "type"}
189
- )
190
- elif backend_type.lower() == "gemini":
191
- agent_config = AgentConfig.create_gemini_config(
192
- **{k: v for k, v in backend_config.items() if k != "type"}
193
- )
500
+ backend_params = {k: v for k, v in backend_config.items() if k != "type"}
501
+
502
+ backend_type_lower = backend_type.lower()
503
+ if backend_type_lower == "openai":
504
+ agent_config = AgentConfig.create_openai_config(**backend_params)
505
+ elif backend_type_lower == "claude":
506
+ agent_config = AgentConfig.create_claude_config(**backend_params)
507
+ elif backend_type_lower == "grok":
508
+ agent_config = AgentConfig.create_grok_config(**backend_params)
509
+ elif backend_type_lower == "gemini":
510
+ agent_config = AgentConfig.create_gemini_config(**backend_params)
511
+ elif backend_type_lower == "zai":
512
+ agent_config = AgentConfig.create_zai_config(**backend_params)
513
+ elif backend_type_lower == "chatcompletion":
514
+ agent_config = AgentConfig.create_chatcompletion_config(**backend_params)
515
+ elif backend_type_lower == "lmstudio":
516
+ agent_config = AgentConfig.create_lmstudio_config(**backend_params)
517
+ elif backend_type_lower == "vllm":
518
+ agent_config = AgentConfig.create_vllm_config(**backend_params)
519
+ elif backend_type_lower == "sglang":
520
+ agent_config = AgentConfig.create_sglang_config(**backend_params)
194
521
  else:
195
- # Fallback to basic config
196
522
  agent_config = AgentConfig(backend_params=backend_config)
197
523
 
198
- # Set agent ID and system message
199
- agent_config.agent_id = agent_config_data.get("id", "agent1")
200
- agent_config.custom_system_instruction = agent_config_data.get("system_message")
201
-
202
- agent = ConfigurableAgent(config=agent_config, backend=backend)
203
- agents[agent.agent_id] = agent
204
-
205
- # Handle multiple agents configuration
206
- elif "agents" in config:
207
- for agent_config_data in config["agents"]:
208
- backend_config = agent_config_data.get("backend", {})
209
-
210
- # Infer backend type from model if not explicitly provided
211
- if "type" not in backend_config and "model" in backend_config:
212
- backend_type = get_backend_type_from_model(backend_config["model"])
213
- else:
214
- backend_type = backend_config.get("type")
215
- if not backend_type:
216
- raise ConfigurationError(
217
- "Backend type must be specified or inferrable from model"
218
- )
524
+ agent_config.agent_id = agent_data.get("id", f"agent{i}")
219
525
 
220
- backend = create_backend(backend_type, **backend_config)
221
-
222
- # Create proper AgentConfig with backend_params
223
- if backend_type.lower() == "openai":
224
- agent_config = AgentConfig.create_openai_config(
225
- **{k: v for k, v in backend_config.items() if k != "type"}
226
- )
227
- elif backend_type.lower() == "claude":
228
- agent_config = AgentConfig.create_claude_config(
229
- **{k: v for k, v in backend_config.items() if k != "type"}
230
- )
231
- elif backend_type.lower() == "grok":
232
- agent_config = AgentConfig.create_grok_config(
233
- **{k: v for k, v in backend_config.items() if k != "type"}
234
- )
526
+ # Route system_message to backend-specific system prompt parameter
527
+ system_msg = agent_data.get("system_message")
528
+ if system_msg:
529
+ if backend_type_lower == "claude_code":
530
+ # For Claude Code, use append_system_prompt to preserve Claude Code capabilities
531
+ agent_config.backend_params["append_system_prompt"] = system_msg
235
532
  else:
236
- # Fallback to basic config
237
- agent_config = AgentConfig(backend_params=backend_config)
238
-
239
- # Set agent ID and system message
240
- agent_config.agent_id = agent_config_data.get("id", f"agent{len(agents)+1}")
241
- agent_config.custom_system_instruction = agent_config_data.get(
242
- "system_message"
243
- )
533
+ # For other backends, fall back to deprecated custom_system_instruction
534
+ # TODO: Add backend-specific routing for other backends
535
+ agent_config.custom_system_instruction = system_msg
244
536
 
245
- agent = ConfigurableAgent(config=agent_config, backend=backend)
246
- agents[agent.agent_id] = agent
537
+ # Timeout configuration will be applied to orchestrator instead of individual agents
247
538
 
248
- else:
249
- raise ConfigurationError(
250
- "Configuration must contain either 'agent' or 'agents' section"
251
- )
539
+ agent = ConfigurableAgent(config=agent_config, backend=backend)
540
+ agents[agent.config.agent_id] = agent
252
541
 
253
542
  return agents
254
543
 
255
544
 
256
545
  def create_simple_config(
257
- backend_type: str, model: str, system_message: Optional[str] = None
546
+ backend_type: str,
547
+ model: str,
548
+ system_message: Optional[str] = None,
549
+ base_url: Optional[str] = None,
550
+ ui_config: Optional[Dict[str, Any]] = None,
258
551
  ) -> Dict[str, Any]:
259
552
  """Create a simple single-agent configuration."""
260
- return {
553
+ backend_config = {"type": backend_type, "model": model}
554
+ if base_url:
555
+ backend_config["base_url"] = base_url
556
+
557
+ # Add required workspace configuration for Claude Code backend
558
+ if backend_type == "claude_code":
559
+ backend_config["cwd"] = "workspace1"
560
+
561
+ # Use provided UI config or default to rich_terminal for CLI usage
562
+ if ui_config is None:
563
+ ui_config = {"display_type": "rich_terminal", "logging_enabled": True}
564
+
565
+ config = {
261
566
  "agent": {
262
567
  "id": "agent1",
263
- "backend": {"type": backend_type, "model": model},
568
+ "backend": backend_config,
264
569
  "system_message": system_message or "You are a helpful AI assistant.",
265
570
  },
266
- "ui": {"display_type": "rich_terminal", "logging_enabled": True},
571
+ "ui": ui_config,
267
572
  }
268
573
 
574
+ # Add orchestrator config with .massgen/ structure for Claude Code
575
+ if backend_type == "claude_code":
576
+ config["orchestrator"] = {
577
+ "snapshot_storage": ".massgen/snapshots",
578
+ "agent_temporary_workspace": ".massgen/temp_workspaces",
579
+ "session_storage": ".massgen/sessions",
580
+ }
581
+
582
+ return config
583
+
584
+
585
+ def validate_context_paths(config: Dict[str, Any]) -> None:
586
+ """Validate that all context paths in the config exist.
587
+
588
+ Context paths can be either files or directories.
589
+ File-level context paths allow access to specific files without exposing sibling files.
590
+ Raises ConfigurationError with clear message if any paths don't exist.
591
+ """
592
+ orchestrator_cfg = config.get("orchestrator", {})
593
+ context_paths = orchestrator_cfg.get("context_paths", [])
594
+
595
+ missing_paths = []
596
+
597
+ for context_path_config in context_paths:
598
+ if isinstance(context_path_config, dict):
599
+ path = context_path_config.get("path")
600
+ else:
601
+ # Handle string format for backwards compatibility
602
+ path = context_path_config
603
+
604
+ if path:
605
+ path_obj = Path(path)
606
+ if not path_obj.exists():
607
+ missing_paths.append(path)
608
+
609
+ if missing_paths:
610
+ errors = ["Context paths not found:"]
611
+ for path in missing_paths:
612
+ errors.append(f" - {path}")
613
+ errors.append("\nPlease update your configuration with valid paths.")
614
+ raise ConfigurationError("\n".join(errors))
615
+
616
+
617
+ def relocate_filesystem_paths(config: Dict[str, Any]) -> None:
618
+ """Relocate filesystem paths (orchestrator paths and agent workspaces) to be under .massgen/ directory.
619
+
620
+ Modifies the config in-place to ensure all MassGen state is organized
621
+ under .massgen/ for clean project structure.
622
+ """
623
+ massgen_dir = Path(".massgen")
624
+
625
+ # Relocate orchestrator paths
626
+ orchestrator_cfg = config.get("orchestrator", {})
627
+ if orchestrator_cfg:
628
+ path_fields = [
629
+ "snapshot_storage",
630
+ "agent_temporary_workspace",
631
+ "session_storage",
632
+ ]
633
+
634
+ for field in path_fields:
635
+ if field in orchestrator_cfg:
636
+ user_path = orchestrator_cfg[field]
637
+ # If user provided an absolute path or already starts with .massgen/, keep as-is
638
+ if Path(user_path).is_absolute() or user_path.startswith(".massgen/"):
639
+ continue
640
+ # Otherwise, relocate under .massgen/
641
+ orchestrator_cfg[field] = str(massgen_dir / user_path)
642
+
643
+ # Relocate agent workspaces (cwd fields)
644
+ agent_entries = [config["agent"]] if "agent" in config else config.get("agents", [])
645
+ for agent_data in agent_entries:
646
+ backend_config = agent_data.get("backend", {})
647
+ if "cwd" in backend_config:
648
+ user_cwd = backend_config["cwd"]
649
+ # If user provided an absolute path or already starts with .massgen/, keep as-is
650
+ if Path(user_cwd).is_absolute() or user_cwd.startswith(".massgen/"):
651
+ continue
652
+ # Otherwise, relocate under .massgen/workspaces/
653
+ backend_config["cwd"] = str(massgen_dir / "workspaces" / user_cwd)
654
+
655
+
656
+ def load_previous_turns(session_info: Dict[str, Any], session_storage: str) -> List[Dict[str, Any]]:
657
+ """
658
+ Load previous turns from session storage.
659
+
660
+ Returns:
661
+ List of previous turn metadata dicts
662
+ """
663
+ session_id = session_info.get("session_id")
664
+ if not session_id:
665
+ return []
666
+
667
+ session_dir = Path(session_storage) / session_id
668
+ if not session_dir.exists():
669
+ return []
670
+
671
+ previous_turns = []
672
+ turn_num = 1
673
+
674
+ while True:
675
+ turn_dir = session_dir / f"turn_{turn_num}"
676
+ if not turn_dir.exists():
677
+ break
678
+
679
+ metadata_file = turn_dir / "metadata.json"
680
+ if metadata_file.exists():
681
+ metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
682
+ # Use absolute path for workspace
683
+ workspace_path = (turn_dir / "workspace").resolve()
684
+ previous_turns.append(
685
+ {
686
+ "turn": turn_num,
687
+ "path": str(workspace_path),
688
+ "task": metadata.get("task", ""),
689
+ "winning_agent": metadata.get("winning_agent", ""),
690
+ },
691
+ )
692
+
693
+ turn_num += 1
694
+
695
+ return previous_turns
696
+
697
+
698
+ async def handle_session_persistence(
699
+ orchestrator,
700
+ question: str,
701
+ session_info: Dict[str, Any],
702
+ session_storage: str,
703
+ ) -> tuple[Optional[str], int, Optional[str]]:
704
+ """
705
+ Handle session persistence after orchestrator completes.
706
+
707
+ Returns:
708
+ tuple: (session_id, updated_turn_number, normalized_answer)
709
+ """
710
+ # Get final result from orchestrator
711
+ final_result = orchestrator.get_final_result()
712
+ if not final_result:
713
+ # No filesystem work to persist
714
+ return (session_info.get("session_id"), session_info.get("current_turn", 0), None)
715
+
716
+ # Initialize or reuse session ID
717
+ session_id = session_info.get("session_id")
718
+ if not session_id:
719
+ session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
720
+
721
+ # Increment turn
722
+ current_turn = session_info.get("current_turn", 0) + 1
723
+
724
+ # Create turn directory
725
+ session_dir = Path(session_storage) / session_id
726
+ turn_dir = session_dir / f"turn_{current_turn}"
727
+ turn_dir.mkdir(parents=True, exist_ok=True)
728
+
729
+ # Normalize answer paths
730
+ final_answer = final_result["final_answer"]
731
+ workspace_path = final_result.get("workspace_path")
732
+ turn_workspace_path = (turn_dir / "workspace").resolve() # Make absolute
733
+
734
+ if workspace_path:
735
+ # Replace workspace paths in answer with absolute path
736
+ normalized_answer = final_answer.replace(workspace_path, str(turn_workspace_path))
737
+ else:
738
+ normalized_answer = final_answer
739
+
740
+ # Save normalized answer
741
+ answer_file = turn_dir / "answer.txt"
742
+ answer_file.write_text(normalized_answer, encoding="utf-8")
743
+
744
+ # Save metadata
745
+ metadata = {
746
+ "turn": current_turn,
747
+ "timestamp": datetime.now().isoformat(),
748
+ "winning_agent": final_result["winning_agent_id"],
749
+ "task": question,
750
+ "session_id": session_id,
751
+ }
752
+ metadata_file = turn_dir / "metadata.json"
753
+ metadata_file.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
754
+
755
+ # Create/update session summary for easy viewing
756
+ session_summary_file = session_dir / "SESSION_SUMMARY.txt"
757
+ summary_lines = []
758
+
759
+ if session_summary_file.exists():
760
+ summary_lines = session_summary_file.read_text(encoding="utf-8").splitlines()
761
+ else:
762
+ summary_lines.append("=" * 80)
763
+ summary_lines.append(f"Multi-Turn Session: {session_id}")
764
+ summary_lines.append("=" * 80)
765
+ summary_lines.append("")
766
+
767
+ # Add turn separator and info
768
+ summary_lines.append("")
769
+ summary_lines.append("=" * 80)
770
+ summary_lines.append(f"TURN {current_turn}")
771
+ summary_lines.append("=" * 80)
772
+ summary_lines.append(f"Timestamp: {metadata['timestamp']}")
773
+ summary_lines.append(f"Winning Agent: {metadata['winning_agent']}")
774
+ summary_lines.append(f"Task: {question}")
775
+ summary_lines.append(f"Workspace: {turn_workspace_path}")
776
+ summary_lines.append(f"Answer: See {(turn_dir / 'answer.txt').resolve()}")
777
+ summary_lines.append("")
778
+
779
+ session_summary_file.write_text("\n".join(summary_lines), encoding="utf-8")
780
+
781
+ # Copy workspace if it exists
782
+ if workspace_path and Path(workspace_path).exists():
783
+ shutil.copytree(workspace_path, turn_workspace_path, dirs_exist_ok=True)
784
+
785
+ return (session_id, current_turn, normalized_answer)
786
+
269
787
 
270
788
  async def run_question_with_history(
271
789
  question: str,
272
790
  agents: Dict[str, SingleAgent],
273
791
  ui_config: Dict[str, Any],
274
792
  history: List[Dict[str, Any]],
275
- ) -> str:
276
- """Run MassGen with a question and conversation history."""
793
+ session_info: Dict[str, Any],
794
+ **kwargs,
795
+ ) -> tuple[str, Optional[str], int]:
796
+ """Run MassGen with a question and conversation history.
797
+
798
+ Returns:
799
+ tuple: (response_text, session_id, turn_number)
800
+ """
277
801
  # Build messages including history
278
802
  messages = history.copy()
279
803
  messages.append({"role": "user", "content": question})
280
804
 
281
805
  # Check if we should use orchestrator for single agents (default: False for backward compatibility)
282
- use_orchestrator_for_single = ui_config.get(
283
- "use_orchestrator_for_single_agent", True
284
- )
806
+ use_orchestrator_for_single = ui_config.get("use_orchestrator_for_single_agent", True)
285
807
 
286
808
  if len(agents) == 1 and not use_orchestrator_for_single:
287
809
  # Single agent mode with history
@@ -290,7 +812,7 @@ async def run_question_with_history(
290
812
  print(f"Agent: {agent.agent_id}", flush=True)
291
813
  if history:
292
814
  print(f"History: {len(history)//2} previous exchanges", flush=True)
293
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
815
+ print(f"Question: {question}", flush=True)
294
816
  print("\n" + "=" * 60, flush=True)
295
817
 
296
818
  response_content = ""
@@ -305,25 +827,54 @@ async def run_question_with_history(
305
827
  continue
306
828
  elif chunk.type == "error":
307
829
  print(f"\n❌ Error: {chunk.error}", flush=True)
308
- return ""
830
+ return ("", session_info.get("session_id"), session_info.get("current_turn", 0))
309
831
 
310
832
  print("\n" + "=" * 60, flush=True)
311
- return response_content
833
+ # Single agent mode doesn't use session storage
834
+ return (response_content, session_info.get("session_id"), session_info.get("current_turn", 0))
312
835
 
313
836
  else:
314
837
  # Multi-agent mode with history
315
- orchestrator = Orchestrator(agents=agents)
838
+ # Create orchestrator config with timeout settings
839
+ timeout_config = kwargs.get("timeout_config")
840
+ orchestrator_config = AgentConfig()
841
+ if timeout_config:
842
+ orchestrator_config.timeout_config = timeout_config
843
+
844
+ # Get orchestrator parameters from config
845
+ orchestrator_cfg = kwargs.get("orchestrator", {})
846
+
847
+ # Get context sharing parameters
848
+ snapshot_storage = orchestrator_cfg.get("snapshot_storage")
849
+ agent_temporary_workspace = orchestrator_cfg.get("agent_temporary_workspace")
850
+ session_storage = orchestrator_cfg.get("session_storage", "sessions") # Default to "sessions"
851
+
852
+ # Get debug/test parameters
853
+ if orchestrator_cfg.get("skip_coordination_rounds", False):
854
+ orchestrator_config.skip_coordination_rounds = True
855
+
856
+ # Load previous turns from session storage for multi-turn conversations
857
+ previous_turns = load_previous_turns(session_info, session_storage)
858
+
859
+ orchestrator = Orchestrator(
860
+ agents=agents,
861
+ config=orchestrator_config,
862
+ snapshot_storage=snapshot_storage,
863
+ agent_temporary_workspace=agent_temporary_workspace,
864
+ previous_turns=previous_turns,
865
+ )
316
866
  # Create a fresh UI instance for each question to ensure clean state
317
867
  ui = CoordinationUI(
318
868
  display_type=ui_config.get("display_type", "rich_terminal"),
319
869
  logging_enabled=ui_config.get("logging_enabled", True),
870
+ enable_final_presentation=True, # Required for multi-turn: ensures final answer is saved
320
871
  )
321
872
 
322
873
  print(f"\n🤖 {BRIGHT_CYAN}Multi-Agent Mode{RESET}", flush=True)
323
874
  print(f"Agents: {', '.join(agents.keys())}", flush=True)
324
875
  if history:
325
876
  print(f"History: {len(history)//2} previous exchanges", flush=True)
326
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
877
+ print(f"Question: {question}", flush=True)
327
878
  print("\n" + "=" * 60, flush=True)
328
879
 
329
880
  # For multi-agent with history, we need to use a different approach
@@ -332,29 +883,30 @@ async def run_question_with_history(
332
883
  if history and len(history) > 0:
333
884
  # Use coordination UI with conversation context
334
885
  # Extract current question from messages
335
- current_question = (
336
- messages[-1].get("content", question) if messages else question
337
- )
886
+ current_question = messages[-1].get("content", question) if messages else question
338
887
 
339
888
  # Pass the full message context to the UI coordination
340
- response_content = await ui.coordinate_with_context(
341
- orchestrator, current_question, messages
342
- )
889
+ response_content = await ui.coordinate_with_context(orchestrator, current_question, messages)
343
890
  else:
344
891
  # Standard coordination for new conversations
345
892
  response_content = await ui.coordinate(orchestrator, question)
346
893
 
347
- return response_content
894
+ # Handle session persistence if applicable
895
+ session_id_to_use, updated_turn, normalized_response = await handle_session_persistence(
896
+ orchestrator,
897
+ question,
898
+ session_info,
899
+ session_storage,
900
+ )
901
+
902
+ # Return normalized response so conversation history has correct paths
903
+ return (normalized_response or response_content, session_id_to_use, updated_turn)
348
904
 
349
905
 
350
- async def run_single_question(
351
- question: str, agents: Dict[str, SingleAgent], ui_config: Dict[str, Any]
352
- ) -> str:
906
+ async def run_single_question(question: str, agents: Dict[str, SingleAgent], ui_config: Dict[str, Any], **kwargs) -> str:
353
907
  """Run MassGen with a single question."""
354
908
  # Check if we should use orchestrator for single agents (default: False for backward compatibility)
355
- use_orchestrator_for_single = ui_config.get(
356
- "use_orchestrator_for_single_agent", True
357
- )
909
+ use_orchestrator_for_single = ui_config.get("use_orchestrator_for_single_agent", True)
358
910
 
359
911
  if len(agents) == 1 and not use_orchestrator_for_single:
360
912
  # Single agent mode with existing SimpleDisplay frontend
@@ -362,7 +914,7 @@ async def run_single_question(
362
914
 
363
915
  print(f"\n🤖 {BRIGHT_CYAN}Single Agent Mode{RESET}", flush=True)
364
916
  print(f"Agent: {agent.agent_id}", flush=True)
365
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
917
+ print(f"Question: {question}", flush=True)
366
918
  print("\n" + "=" * 60, flush=True)
367
919
 
368
920
  messages = [{"role": "user", "content": question}]
@@ -384,67 +936,472 @@ async def run_single_question(
384
936
 
385
937
  else:
386
938
  # Multi-agent mode
387
- orchestrator = Orchestrator(agents=agents)
939
+ # Create orchestrator config with timeout settings
940
+ timeout_config = kwargs.get("timeout_config")
941
+ orchestrator_config = AgentConfig()
942
+ if timeout_config:
943
+ orchestrator_config.timeout_config = timeout_config
944
+
945
+ # Get orchestrator parameters from config
946
+ orchestrator_cfg = kwargs.get("orchestrator", {})
947
+
948
+ # Get context sharing parameters
949
+ snapshot_storage = orchestrator_cfg.get("snapshot_storage")
950
+ agent_temporary_workspace = orchestrator_cfg.get("agent_temporary_workspace")
951
+
952
+ # Get debug/test parameters
953
+ if orchestrator_cfg.get("skip_coordination_rounds", False):
954
+ orchestrator_config.skip_coordination_rounds = True
955
+
956
+ orchestrator = Orchestrator(
957
+ agents=agents,
958
+ config=orchestrator_config,
959
+ snapshot_storage=snapshot_storage,
960
+ agent_temporary_workspace=agent_temporary_workspace,
961
+ )
388
962
  # Create a fresh UI instance for each question to ensure clean state
389
963
  ui = CoordinationUI(
390
964
  display_type=ui_config.get("display_type", "rich_terminal"),
391
965
  logging_enabled=ui_config.get("logging_enabled", True),
966
+ enable_final_presentation=True, # Ensures final presentation is generated
392
967
  )
393
968
 
394
969
  print(f"\n🤖 {BRIGHT_CYAN}Multi-Agent Mode{RESET}", flush=True)
395
970
  print(f"Agents: {', '.join(agents.keys())}", flush=True)
396
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
971
+ print(f"Question: {question}", flush=True)
397
972
  print("\n" + "=" * 60, flush=True)
398
973
 
399
974
  final_response = await ui.coordinate(orchestrator, question)
400
975
  return final_response
401
976
 
402
977
 
978
+ def prompt_for_context_paths(original_config: Dict[str, Any], orchestrator_cfg: Dict[str, Any]) -> bool:
979
+ """Prompt user to add context paths in interactive mode.
980
+
981
+ Returns True if config was modified, False otherwise.
982
+ """
983
+ # Check if filesystem is enabled (at least one agent has cwd)
984
+ agent_entries = [original_config["agent"]] if "agent" in original_config else original_config.get("agents", [])
985
+ has_filesystem = any("cwd" in agent.get("backend", {}) for agent in agent_entries)
986
+
987
+ if not has_filesystem:
988
+ return False
989
+
990
+ # Show current context paths
991
+ existing_paths = orchestrator_cfg.get("context_paths", [])
992
+ cwd = Path.cwd()
993
+
994
+ # Use Rich for better display
995
+ from rich.console import Console as RichConsole
996
+ from rich.panel import Panel as RichPanel
997
+
998
+ rich_console = RichConsole()
999
+
1000
+ # Build context paths display
1001
+ context_content = []
1002
+ if existing_paths:
1003
+ for path_config in existing_paths:
1004
+ path = path_config.get("path") if isinstance(path_config, dict) else path_config
1005
+ permission = path_config.get("permission", "read") if isinstance(path_config, dict) else "read"
1006
+ context_content.append(f" [green]✓[/green] {path} [dim]({permission})[/dim]")
1007
+ else:
1008
+ context_content.append(" [yellow]No context paths configured[/yellow]")
1009
+
1010
+ context_panel = RichPanel(
1011
+ "\n".join(context_content),
1012
+ title="[bold bright_cyan]📂 Context Paths[/bold bright_cyan]",
1013
+ border_style="cyan",
1014
+ padding=(0, 2),
1015
+ width=80,
1016
+ )
1017
+ rich_console.print(context_panel)
1018
+ print()
1019
+
1020
+ # Check if CWD is already in context paths
1021
+ cwd_str = str(cwd)
1022
+ cwd_already_added = any((path_config.get("path") if isinstance(path_config, dict) else path_config) == cwd_str for path_config in existing_paths)
1023
+
1024
+ if not cwd_already_added:
1025
+ # Create prompt panel
1026
+ prompt_content = [
1027
+ "[bold cyan]Add current directory as context path?[/bold cyan]",
1028
+ f" [yellow]{cwd}[/yellow]",
1029
+ "",
1030
+ " [dim]Context paths give agents access to your project files.[/dim]",
1031
+ " [dim]• Read-only during coordination (prevents conflicts)[/dim]",
1032
+ " [dim]• Write permission for final agent to save results[/dim]",
1033
+ "",
1034
+ " [dim]Options:[/dim]",
1035
+ " [green]Y[/green] → Add with write permission (default)",
1036
+ " [cyan]P[/cyan] → Add with protected paths (e.g., .env, secrets)",
1037
+ " [yellow]N[/yellow] → Skip",
1038
+ " [blue]C[/blue] → Add custom path",
1039
+ ]
1040
+ prompt_panel = RichPanel(
1041
+ "\n".join(prompt_content),
1042
+ border_style="cyan",
1043
+ padding=(1, 2),
1044
+ width=80,
1045
+ )
1046
+ rich_console.print(prompt_panel)
1047
+ print()
1048
+ try:
1049
+ response = input(f" {BRIGHT_CYAN}Your choice [Y/P/N/C]:{RESET} ").strip().lower()
1050
+
1051
+ if response in ["y", "yes", ""]:
1052
+ # Add CWD with write permission
1053
+ if "context_paths" not in orchestrator_cfg:
1054
+ orchestrator_cfg["context_paths"] = []
1055
+ orchestrator_cfg["context_paths"].append({"path": cwd_str, "permission": "write"})
1056
+ print(f" {BRIGHT_GREEN}✅ Added: {cwd} (write){RESET}", flush=True)
1057
+ return True
1058
+ elif response in ["p", "protected"]:
1059
+ # Add CWD with write permission and protected paths
1060
+ protected_paths = []
1061
+ print(f"\n {BRIGHT_CYAN}Enter protected paths (one per line, empty to finish):{RESET}", flush=True)
1062
+ print(f" {BRIGHT_YELLOW}Tip: Protected paths are relative to {cwd}{RESET}", flush=True)
1063
+ while True:
1064
+ protected_input = input(f" {BRIGHT_CYAN}→{RESET} ").strip()
1065
+ if not protected_input:
1066
+ break
1067
+ protected_paths.append(protected_input)
1068
+ print(f" {BRIGHT_GREEN}✓ Added: {protected_input}{RESET}", flush=True)
1069
+
1070
+ if "context_paths" not in orchestrator_cfg:
1071
+ orchestrator_cfg["context_paths"] = []
1072
+
1073
+ context_config = {"path": cwd_str, "permission": "write"}
1074
+ if protected_paths:
1075
+ context_config["protected_paths"] = protected_paths
1076
+
1077
+ orchestrator_cfg["context_paths"].append(context_config)
1078
+ print(f"\n {BRIGHT_GREEN}✅ Added: {cwd} (write) with {len(protected_paths)} protected path(s){RESET}", flush=True)
1079
+ return True
1080
+ elif response in ["n", "no"]:
1081
+ # User explicitly declined
1082
+ return False
1083
+ elif response in ["c", "custom"]:
1084
+ # Loop until valid path or user cancels
1085
+ print()
1086
+ while True:
1087
+ custom_path = input(f" {BRIGHT_CYAN}Enter path (absolute or relative):{RESET} ").strip()
1088
+ if not custom_path:
1089
+ print(f" {BRIGHT_YELLOW}⚠️ Cancelled{RESET}", flush=True)
1090
+ return False
1091
+
1092
+ # Resolve to absolute path
1093
+ abs_path = str(Path(custom_path).resolve())
1094
+
1095
+ # Check if path exists
1096
+ if not Path(abs_path).exists():
1097
+ print(f" {BRIGHT_RED}✗ Path does not exist: {abs_path}{RESET}", flush=True)
1098
+ retry = input(f" {BRIGHT_CYAN}Try again? [Y/n]:{RESET} ").strip().lower()
1099
+ if retry in ["n", "no"]:
1100
+ return False
1101
+ continue
1102
+
1103
+ # Valid path (file or directory), ask for permission
1104
+ permission = input(f" {BRIGHT_CYAN}Permission [read/write] (default: write):{RESET} ").strip().lower() or "write"
1105
+ if permission not in ["read", "write"]:
1106
+ permission = "write"
1107
+
1108
+ # Ask about protected paths if write permission
1109
+ protected_paths = []
1110
+ if permission == "write":
1111
+ add_protected = input(f" {BRIGHT_CYAN}Add protected paths? [y/N]:{RESET} ").strip().lower()
1112
+ if add_protected in ["y", "yes"]:
1113
+ print(f" {BRIGHT_CYAN}Enter protected paths (one per line, empty to finish):{RESET}", flush=True)
1114
+ while True:
1115
+ protected_input = input(f" {BRIGHT_CYAN}→{RESET} ").strip()
1116
+ if not protected_input:
1117
+ break
1118
+ protected_paths.append(protected_input)
1119
+ print(f" {BRIGHT_GREEN}✓ Added: {protected_input}{RESET}", flush=True)
1120
+
1121
+ if "context_paths" not in orchestrator_cfg:
1122
+ orchestrator_cfg["context_paths"] = []
1123
+
1124
+ context_config = {"path": abs_path, "permission": permission}
1125
+ if protected_paths:
1126
+ context_config["protected_paths"] = protected_paths
1127
+
1128
+ orchestrator_cfg["context_paths"].append(context_config)
1129
+ if protected_paths:
1130
+ print(f" {BRIGHT_GREEN}✅ Added: {abs_path} ({permission}) with {len(protected_paths)} protected path(s){RESET}", flush=True)
1131
+ else:
1132
+ print(f" {BRIGHT_GREEN}✅ Added: {abs_path} ({permission}){RESET}", flush=True)
1133
+ return True
1134
+ else:
1135
+ # Invalid response - clarify options
1136
+ print(f"\n {BRIGHT_RED}✗ Invalid option: '{response}'{RESET}", flush=True)
1137
+ print(f" {BRIGHT_YELLOW}Please choose: Y (yes), P (protected), N (no), or C (custom){RESET}", flush=True)
1138
+ return False
1139
+ except (KeyboardInterrupt, EOFError):
1140
+ print() # New line after Ctrl+C
1141
+ return False
1142
+
1143
+ return False
1144
+
1145
+
1146
+ def show_available_examples():
1147
+ """Display available example configurations from package."""
1148
+ try:
1149
+ from importlib.resources import files
1150
+
1151
+ configs_root = files("massgen") / "configs"
1152
+
1153
+ print(f"\n{BRIGHT_CYAN}Available Example Configurations{RESET}")
1154
+ print("=" * 60)
1155
+
1156
+ # Organize by category
1157
+ categories = {}
1158
+ for config_file in sorted(configs_root.rglob("*.yaml")):
1159
+ # Get relative path from configs root
1160
+ rel_path = str(config_file).replace(str(configs_root) + "/", "")
1161
+ # Extract category (first directory)
1162
+ parts = rel_path.split("/")
1163
+ category = parts[0] if len(parts) > 1 else "root"
1164
+
1165
+ if category not in categories:
1166
+ categories[category] = []
1167
+
1168
+ # Create a short name for @examples/
1169
+ # Use the path without .yaml extension
1170
+ short_name = rel_path.replace(".yaml", "").replace("/", "_")
1171
+
1172
+ categories[category].append((short_name, rel_path))
1173
+
1174
+ # Display categories
1175
+ for category, configs in sorted(categories.items()):
1176
+ print(f"\n{BRIGHT_YELLOW}{category.title()}:{RESET}")
1177
+ for short_name, rel_path in configs[:10]: # Limit to avoid overwhelming
1178
+ print(f" {BRIGHT_GREEN}@examples/{short_name:<40}{RESET} {rel_path}")
1179
+
1180
+ if len(configs) > 10:
1181
+ print(f" ... and {len(configs) - 10} more")
1182
+
1183
+ print(f"\n{BRIGHT_BLUE}Usage:{RESET}")
1184
+ print(' massgen --config @examples/SHORTNAME "Your question"')
1185
+ print(" massgen --example SHORTNAME > my-config.yaml")
1186
+ print()
1187
+
1188
+ except Exception as e:
1189
+ print(f"Error listing examples: {e}")
1190
+ print("Examples may not be available (development mode?)")
1191
+
1192
+
1193
+ def print_example_config(name: str):
1194
+ """Print an example config to stdout.
1195
+
1196
+ Args:
1197
+ name: Name of the example (can include or exclude @examples/ prefix)
1198
+ """
1199
+ try:
1200
+ # Remove @examples/ prefix if present
1201
+ if name.startswith("@examples/"):
1202
+ name = name[10:]
1203
+
1204
+ # Try to resolve the config
1205
+ resolved = resolve_config_path(f"@examples/{name}")
1206
+ if resolved:
1207
+ with open(resolved, "r") as f:
1208
+ print(f.read())
1209
+ else:
1210
+ print(f"Error: Could not find example '{name}'", file=sys.stderr)
1211
+ print("Use --list-examples to see available configs", file=sys.stderr)
1212
+ sys.exit(1)
1213
+
1214
+ except ConfigurationError as e:
1215
+ print(f"Error: {e}", file=sys.stderr)
1216
+ sys.exit(1)
1217
+ except Exception as e:
1218
+ print(f"Error printing example config: {e}", file=sys.stderr)
1219
+ sys.exit(1)
1220
+
1221
+
1222
+ def should_run_builder() -> bool:
1223
+ """Check if config builder should run automatically.
1224
+
1225
+ Returns True if:
1226
+ - No default config exists at ~/.config/massgen/config.yaml
1227
+ """
1228
+ default_config = Path.home() / ".config/massgen/config.yaml"
1229
+ return not default_config.exists()
1230
+
1231
+
403
1232
  def print_help_messages():
404
- print(
405
- "\n💬 Type your questions below. Use slash commands or press Ctrl+C to stop.",
406
- flush=True,
1233
+ """Display help messages using Rich for better formatting."""
1234
+ rich_console = Console()
1235
+
1236
+ help_content = """[dim]💬 Type your questions below
1237
+ 💡 Use slash commands: [cyan]/help[/cyan], [cyan]/quit[/cyan], [cyan]/reset[/cyan], [cyan]/status[/cyan], [cyan]/config[/cyan]
1238
+ ⌨️ Press [cyan]Ctrl+C[/cyan] to exit[/dim]"""
1239
+
1240
+ help_panel = Panel(
1241
+ help_content,
1242
+ border_style="dim",
1243
+ padding=(0, 2),
1244
+ width=80,
407
1245
  )
408
- print("💡 Commands: /quit, /exit, /reset, /help", flush=True)
409
- print("=" * 60, flush=True)
1246
+ rich_console.print(help_panel)
410
1247
 
411
1248
 
412
1249
  async def run_interactive_mode(
413
- agents: Dict[str, SingleAgent], ui_config: Dict[str, Any]
1250
+ agents: Dict[str, SingleAgent],
1251
+ ui_config: Dict[str, Any],
1252
+ original_config: Dict[str, Any] = None,
1253
+ orchestrator_cfg: Dict[str, Any] = None,
1254
+ config_path: Optional[str] = None,
1255
+ **kwargs,
414
1256
  ):
415
1257
  """Run MassGen in interactive mode with conversation history."""
416
- print(f"\n{BRIGHT_CYAN}🤖 MassGen Interactive Mode{RESET}", flush=True)
417
- print("="*60, flush=True)
418
-
419
- # Display configuration
420
- print(f"📋 {BRIGHT_YELLOW}Configuration:{RESET}", flush=True)
421
- print(f" Agents: {len(agents)}", flush=True)
422
- for agent_id, agent in agents.items():
423
- backend_name = agent.backend.__class__.__name__.replace("Backend", "")
424
- print(f" • {agent_id}: {backend_name}", flush=True)
425
-
426
- use_orchestrator_for_single = ui_config.get(
427
- "use_orchestrator_for_single_agent", True
1258
+
1259
+ # Use Rich console for better display
1260
+ rich_console = Console()
1261
+
1262
+ # Clear screen
1263
+ rich_console.clear()
1264
+
1265
+ # ASCII art for interactive multi-agent mode
1266
+ ascii_art = """[bold #4A90E2]
1267
+ ███╗ ███╗ █████╗ ███████╗███████╗ ██████╗ ███████╗███╗ ██╗
1268
+ ████╗ ████║██╔══██╗██╔════╝██╔════╝██╔════╝ ██╔════╝████╗ ██║
1269
+ ██╔████╔██║███████║███████╗███████╗██║ ███╗█████╗ ██╔██╗ ██║
1270
+ ██║╚██╔╝██║██╔══██║╚════██║╚════██║██║ ██║██╔══╝ ██║╚██╗██║
1271
+ ██║ ╚═╝ ██║██║ ██║███████║███████║╚██████╔╝███████╗██║ ╚████║
1272
+ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝[/bold #4A90E2]
1273
+
1274
+ [dim] 🤖 🤖 🤖 → 💬 collaborate → 🎯 winner → 📢 final[/dim]
1275
+ """
1276
+
1277
+ # Wrap ASCII art in a panel
1278
+ ascii_panel = Panel(
1279
+ ascii_art,
1280
+ border_style="bold #4A90E2",
1281
+ padding=(0, 2),
1282
+ width=80,
1283
+ )
1284
+ rich_console.print(ascii_panel)
1285
+ print()
1286
+
1287
+ # Create configuration table
1288
+ config_table = Table(
1289
+ show_header=False,
1290
+ box=None,
1291
+ padding=(0, 2),
1292
+ show_edge=False,
428
1293
  )
1294
+ config_table.add_column("Label", style="bold cyan", no_wrap=True)
1295
+ config_table.add_column("Value", style="white")
1296
+
1297
+ # Determine mode
1298
+ ui_config.get("use_orchestrator_for_single_agent", True)
429
1299
  if len(agents) == 1:
430
- mode = (
431
- "Single Agent (Orchestrator)"
432
- if use_orchestrator_for_single
433
- else "Single Agent (Direct)"
434
- )
1300
+ mode = "Single Agent"
1301
+ mode_icon = "🤖"
1302
+ else:
1303
+ mode = f"Multi-Agent ({len(agents)} agents)"
1304
+ mode_icon = "🤝"
1305
+
1306
+ config_table.add_row(f"{mode_icon} Mode:", f"[bold]{mode}[/bold]")
1307
+
1308
+ # Add agents info
1309
+ if len(agents) <= 3:
1310
+ # Show all agents if 3 or fewer
1311
+ for agent_id, agent in agents.items():
1312
+ # Get model name from config
1313
+ model = agent.config.backend_params.get("model", "unknown")
1314
+ backend_name = agent.backend.__class__.__name__.replace("Backend", "")
1315
+ # Show model with backend in parentheses
1316
+ display = f"{model} [dim]({backend_name})[/dim]"
1317
+ config_table.add_row(f" ├─ {agent_id}:", display)
435
1318
  else:
436
- mode = "Multi-Agent Coordination"
437
- print(f" Mode: {mode}", flush=True)
438
- print(f" UI: {ui_config.get('display_type', 'rich_terminal')}", flush=True)
1319
+ # Show count and first 2 agents
1320
+ agent_list = list(agents.items())
1321
+ for i, (agent_id, agent) in enumerate(agent_list[:2]):
1322
+ model = agent.config.backend_params.get("model", "unknown")
1323
+ backend_name = agent.backend.__class__.__name__.replace("Backend", "")
1324
+ display = f"{model} [dim]({backend_name})[/dim]"
1325
+ config_table.add_row(f" ├─ {agent_id}:", display)
1326
+ config_table.add_row(" └─ ...", f"[dim]and {len(agents) - 2} more[/dim]")
1327
+
1328
+ # Create main panel with configuration
1329
+ config_panel = Panel(
1330
+ config_table,
1331
+ title="[bold bright_yellow]⚙️ Session Configuration[/bold bright_yellow]",
1332
+ border_style="yellow",
1333
+ padding=(0, 2),
1334
+ width=80,
1335
+ )
1336
+ rich_console.print(config_panel)
1337
+ print()
1338
+
1339
+ # Prompt for context paths if filesystem is enabled
1340
+ if original_config and orchestrator_cfg:
1341
+ config_modified = prompt_for_context_paths(original_config, orchestrator_cfg)
1342
+ if config_modified:
1343
+ # Recreate agents with updated context paths
1344
+ agents = create_agents_from_config(original_config, orchestrator_cfg)
1345
+ print(f" {BRIGHT_GREEN}✓ Agents reloaded with updated context paths{RESET}", flush=True)
1346
+ print()
439
1347
 
440
1348
  print_help_messages()
441
1349
 
442
1350
  # Maintain conversation history
443
1351
  conversation_history = []
444
1352
 
1353
+ # Session management for multi-turn filesystem support
1354
+ session_id = None
1355
+ current_turn = 0
1356
+ session_storage = kwargs.get("orchestrator", {}).get("session_storage", "sessions")
1357
+
445
1358
  try:
446
1359
  while True:
447
1360
  try:
1361
+ # Recreate agents with previous turn as read-only context path.
1362
+ # This provides agents with BOTH:
1363
+ # 1. Read-only context path (original turn n-1 results) - for reference/comparison
1364
+ # 2. Writable workspace (copy of turn n-1 results, pre-populated by orchestrator) - for modification
1365
+ # This allows agents to compare "what I changed" vs "what was originally there".
1366
+ # TODO: We may want to avoid full recreation if possible in the future, conditioned on being able to easily reset MCPs.
1367
+ if current_turn > 0 and original_config and orchestrator_cfg:
1368
+ # Get the most recent turn path (the one just completed)
1369
+ session_dir = Path(session_storage) / session_id
1370
+ latest_turn_dir = session_dir / f"turn_{current_turn}"
1371
+ latest_turn_workspace = latest_turn_dir / "workspace"
1372
+
1373
+ if latest_turn_workspace.exists():
1374
+ logger.info(f"[CLI] Recreating agents with turn {current_turn} workspace as read-only context path")
1375
+
1376
+ # Clean up existing agents' backends and filesystem managers
1377
+ for agent_id, agent in agents.items():
1378
+ # Cleanup filesystem manager (Docker containers, etc.)
1379
+ if hasattr(agent, "backend") and hasattr(agent.backend, "filesystem_manager"):
1380
+ if agent.backend.filesystem_manager:
1381
+ try:
1382
+ agent.backend.filesystem_manager.cleanup()
1383
+ except Exception as e:
1384
+ logger.warning(f"[CLI] Cleanup failed for agent {agent_id}: {e}")
1385
+
1386
+ # Cleanup backend itself
1387
+ if hasattr(agent.backend, "__aexit__"):
1388
+ await agent.backend.__aexit__(None, None, None)
1389
+
1390
+ # Inject previous turn path as read-only context
1391
+ modified_config = original_config.copy()
1392
+ agent_entries = [modified_config["agent"]] if "agent" in modified_config else modified_config.get("agents", [])
1393
+
1394
+ for agent_data in agent_entries:
1395
+ backend_config = agent_data.get("backend", {})
1396
+ if "cwd" in backend_config: # Only inject if agent has filesystem support
1397
+ existing_context_paths = backend_config.get("context_paths", [])
1398
+ new_turn_config = {"path": str(latest_turn_workspace.resolve()), "permission": "read"}
1399
+ backend_config["context_paths"] = existing_context_paths + [new_turn_config]
1400
+
1401
+ # Recreate agents from modified config
1402
+ agents = create_agents_from_config(modified_config, orchestrator_cfg)
1403
+ logger.info(f"[CLI] Successfully recreated {len(agents)} agents with turn {current_turn} path as read-only context")
1404
+
448
1405
  question = input(f"\n{BRIGHT_BLUE}👤 User:{RESET} ").strip()
449
1406
 
450
1407
  # Handle slash commands
@@ -465,9 +1422,7 @@ async def run_interactive_mode(
465
1422
  )
466
1423
  continue
467
1424
  elif command in ["/help", "/h"]:
468
- print(
469
- f"\n{BRIGHT_CYAN}📚 Available Commands:{RESET}", flush=True
470
- )
1425
+ print(f"\n{BRIGHT_CYAN}📚 Available Commands:{RESET}", flush=True)
471
1426
  print(" /quit, /exit, /q - Exit the program", flush=True)
472
1427
  print(
473
1428
  " /reset, /clear - Clear conversation history",
@@ -477,9 +1432,8 @@ async def run_interactive_mode(
477
1432
  " /help, /h - Show this help message",
478
1433
  flush=True,
479
1434
  )
480
- print(
481
- " /status - Show current status", flush=True
482
- )
1435
+ print(" /status - Show current status", flush=True)
1436
+ print(" /config - Open config file in editor", flush=True)
483
1437
  continue
484
1438
  elif command == "/status":
485
1439
  print(f"\n{BRIGHT_CYAN}📊 Current Status:{RESET}", flush=True)
@@ -487,15 +1441,9 @@ async def run_interactive_mode(
487
1441
  f" Agents: {len(agents)} ({', '.join(agents.keys())})",
488
1442
  flush=True,
489
1443
  )
490
- use_orch_single = ui_config.get(
491
- "use_orchestrator_for_single_agent", True
492
- )
1444
+ use_orch_single = ui_config.get("use_orchestrator_for_single_agent", True)
493
1445
  if len(agents) == 1:
494
- mode_display = (
495
- "Single Agent (Orchestrator)"
496
- if use_orch_single
497
- else "Single Agent (Direct)"
498
- )
1446
+ mode_display = "Single Agent (Orchestrator)" if use_orch_single else "Single Agent (Direct)"
499
1447
  else:
500
1448
  mode_display = "Multi-Agent"
501
1449
  print(f" Mode: {mode_display}", flush=True)
@@ -503,6 +1451,28 @@ async def run_interactive_mode(
503
1451
  f" History: {len(conversation_history)//2} exchanges",
504
1452
  flush=True,
505
1453
  )
1454
+ if config_path:
1455
+ print(f" Config: {config_path}", flush=True)
1456
+ continue
1457
+ elif command == "/config":
1458
+ if config_path:
1459
+ import platform
1460
+ import subprocess
1461
+
1462
+ try:
1463
+ system = platform.system()
1464
+ if system == "Darwin": # macOS
1465
+ subprocess.run(["open", config_path])
1466
+ elif system == "Windows":
1467
+ subprocess.run(["start", config_path], shell=True)
1468
+ else: # Linux and others
1469
+ subprocess.run(["xdg-open", config_path])
1470
+ print(f"\n📝 Opening config file: {config_path}", flush=True)
1471
+ except Exception as e:
1472
+ print(f"\n❌ Error opening config file: {e}", flush=True)
1473
+ print(f" Config location: {config_path}", flush=True)
1474
+ else:
1475
+ print("\n❌ No config file available (using CLI arguments)", flush=True)
506
1476
  continue
507
1477
  else:
508
1478
  print(f"❓ Unknown command: {command}", flush=True)
@@ -530,16 +1500,40 @@ async def run_interactive_mode(
530
1500
 
531
1501
  print(f"\n🔄 {BRIGHT_YELLOW}Processing...{RESET}", flush=True)
532
1502
 
533
- response = await run_question_with_history(
534
- question, agents, ui_config, conversation_history
1503
+ # Increment turn counter BEFORE processing so logs go to correct turn_N directory
1504
+ next_turn = current_turn + 1
1505
+
1506
+ # Initialize session ID on first turn
1507
+ if session_id is None:
1508
+ session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
1509
+
1510
+ # Reconfigure logging for the turn we're about to process
1511
+ setup_logging(debug=_DEBUG_MODE, turn=next_turn)
1512
+ logger.info(f"Starting turn {next_turn}")
1513
+
1514
+ # Pass session state for multi-turn filesystem support
1515
+ session_info = {
1516
+ "session_id": session_id,
1517
+ "current_turn": current_turn, # Pass CURRENT turn (for looking up previous turns)
1518
+ "session_storage": session_storage,
1519
+ }
1520
+ response, updated_session_id, updated_turn = await run_question_with_history(
1521
+ question,
1522
+ agents,
1523
+ ui_config,
1524
+ conversation_history,
1525
+ session_info,
1526
+ **kwargs,
535
1527
  )
536
1528
 
1529
+ # Update session state after completion
1530
+ session_id = updated_session_id
1531
+ current_turn = updated_turn
1532
+
537
1533
  if response:
538
1534
  # Add to conversation history
539
1535
  conversation_history.append({"role": "user", "content": question})
540
- conversation_history.append(
541
- {"role": "assistant", "content": response}
542
- )
1536
+ conversation_history.append({"role": "assistant", "content": response})
543
1537
  print(f"\n{BRIGHT_GREEN}✅ Complete!{RESET}", flush=True)
544
1538
  print(
545
1539
  f"{BRIGHT_CYAN}💭 History: {len(conversation_history)//2} exchanges{RESET}",
@@ -560,8 +1554,180 @@ async def run_interactive_mode(
560
1554
  except KeyboardInterrupt:
561
1555
  print("\n👋 Goodbye!")
562
1556
 
563
- async def main():
564
- """Main CLI entry point."""
1557
+
1558
+ async def main(args):
1559
+ """Main CLI entry point (async operations only)."""
1560
+ # Check if bare `massgen` with no args - use default config if it exists
1561
+ if not args.backend and not args.model and not args.config:
1562
+ # Use resolve_config_path to check project-level then global config
1563
+ resolved_default = resolve_config_path(None)
1564
+ if resolved_default:
1565
+ # Use discovered config for interactive mode (no question) or single query (with question)
1566
+ args.config = str(resolved_default)
1567
+ else:
1568
+ # No default config - this will be handled by wizard trigger in cli_main()
1569
+ if args.question:
1570
+ # User provided a question but no config exists - this is an error
1571
+ print("❌ Configuration error: No default configuration found.", flush=True)
1572
+ print("Run 'massgen --init' to create one, or use 'massgen --model MODEL \"question\"'", flush=True)
1573
+ sys.exit(1)
1574
+ # No question and no config - wizard will be triggered in cli_main()
1575
+ return
1576
+
1577
+ # Validate arguments (only if we didn't auto-set config above)
1578
+ if not args.backend:
1579
+ if not args.model and not args.config:
1580
+ print("❌ Configuration error: Either --config, --model, or --backend must be specified", flush=True)
1581
+ sys.exit(1)
1582
+
1583
+ try:
1584
+ # Load or create configuration
1585
+ if args.config:
1586
+ # Resolve config path (handles @examples/, paths, ~/.config/massgen/agents/)
1587
+ resolved_path = resolve_config_path(args.config)
1588
+ if resolved_path is None:
1589
+ # This shouldn't happen if we reached here, but handle it
1590
+ raise ConfigurationError("Could not resolve config path")
1591
+ config = load_config_file(str(resolved_path))
1592
+ if args.debug:
1593
+ logger.debug(f"Resolved config path: {resolved_path}")
1594
+ logger.debug(f"Config content: {json.dumps(config, indent=2)}")
1595
+ else:
1596
+ model = args.model
1597
+ if args.backend:
1598
+ backend = args.backend
1599
+ else:
1600
+ backend = get_backend_type_from_model(model=model)
1601
+ if args.system_message:
1602
+ system_message = args.system_message
1603
+ else:
1604
+ system_message = None
1605
+ config = create_simple_config(
1606
+ backend_type=backend,
1607
+ model=model,
1608
+ system_message=system_message,
1609
+ base_url=args.base_url,
1610
+ )
1611
+ if args.debug:
1612
+ logger.debug(f"Created simple config with backend: {backend}, model: {model}")
1613
+ logger.debug(f"Config content: {json.dumps(config, indent=2)}")
1614
+
1615
+ # Validate that all context paths exist before proceeding
1616
+ validate_context_paths(config)
1617
+
1618
+ # Relocate all filesystem paths to .massgen/ directory
1619
+ relocate_filesystem_paths(config)
1620
+
1621
+ # Apply command-line overrides
1622
+ ui_config = config.get("ui", {})
1623
+ if args.no_display:
1624
+ ui_config["display_type"] = "simple"
1625
+ if args.no_logs:
1626
+ ui_config["logging_enabled"] = False
1627
+ if args.debug:
1628
+ ui_config["debug"] = True
1629
+ # Enable logging if debug is on
1630
+ ui_config["logging_enabled"] = True
1631
+ # # Force simple UI in debug mode
1632
+ # ui_config["display_type"] = "simple"
1633
+
1634
+ # Apply timeout overrides from CLI arguments
1635
+ timeout_settings = config.get("timeout_settings", {})
1636
+ if args.orchestrator_timeout is not None:
1637
+ timeout_settings["orchestrator_timeout_seconds"] = args.orchestrator_timeout
1638
+
1639
+ # Update config with timeout settings
1640
+ config["timeout_settings"] = timeout_settings
1641
+
1642
+ # Create agents
1643
+ if args.debug:
1644
+ logger.debug("Creating agents from config...")
1645
+ # Extract orchestrator config for agent setup
1646
+ orchestrator_cfg = config.get("orchestrator", {})
1647
+
1648
+ # Check if any agent has cwd (filesystem support) and validate orchestrator config
1649
+ agent_entries = [config["agent"]] if "agent" in config else config.get("agents", [])
1650
+ has_cwd = any("cwd" in agent.get("backend", {}) for agent in agent_entries)
1651
+
1652
+ if has_cwd:
1653
+ if not orchestrator_cfg:
1654
+ raise ConfigurationError(
1655
+ "Agents with 'cwd' (filesystem support) require orchestrator configuration.\n"
1656
+ "Please add an 'orchestrator' section to your config file.\n\n"
1657
+ "Example (customize paths as needed):\n"
1658
+ "orchestrator:\n"
1659
+ ' snapshot_storage: "your_snapshot_dir"\n'
1660
+ ' agent_temporary_workspace: "your_temp_dir"',
1661
+ )
1662
+
1663
+ # Check for required fields in orchestrator config
1664
+ if "snapshot_storage" not in orchestrator_cfg:
1665
+ raise ConfigurationError(
1666
+ "Missing 'snapshot_storage' in orchestrator configuration.\n"
1667
+ "This is required for agents with filesystem support (cwd).\n\n"
1668
+ "Add to your orchestrator section:\n"
1669
+ ' snapshot_storage: "your_snapshot_dir" # Directory for workspace snapshots',
1670
+ )
1671
+
1672
+ if "agent_temporary_workspace" not in orchestrator_cfg:
1673
+ raise ConfigurationError(
1674
+ "Missing 'agent_temporary_workspace' in orchestrator configuration.\n"
1675
+ "This is required for agents with filesystem support (cwd).\n\n"
1676
+ "Add to your orchestrator section:\n"
1677
+ ' agent_temporary_workspace: "your_temp_dir" # Directory for temporary agent workspaces',
1678
+ )
1679
+
1680
+ agents = create_agents_from_config(config, orchestrator_cfg)
1681
+
1682
+ if not agents:
1683
+ raise ConfigurationError("No agents configured")
1684
+
1685
+ if args.debug:
1686
+ logger.debug(f"Created {len(agents)} agent(s): {list(agents.keys())}")
1687
+
1688
+ # Create timeout config from settings and put it in kwargs
1689
+ timeout_settings = config.get("timeout_settings", {})
1690
+ timeout_config = TimeoutConfig(**timeout_settings) if timeout_settings else TimeoutConfig()
1691
+
1692
+ kwargs = {"timeout_config": timeout_config}
1693
+
1694
+ # Add orchestrator configuration if present
1695
+ if "orchestrator" in config:
1696
+ kwargs["orchestrator"] = config["orchestrator"]
1697
+
1698
+ # Run mode based on whether question was provided
1699
+ try:
1700
+ if args.question:
1701
+ await run_single_question(args.question, agents, ui_config, **kwargs)
1702
+ # if response:
1703
+ # print(f"\n{BRIGHT_GREEN}Final Response:{RESET}", flush=True)
1704
+ # print(f"{response}", flush=True)
1705
+ else:
1706
+ # Pass the config path to interactive mode
1707
+ config_file_path = str(resolved_path) if args.config and resolved_path else None
1708
+ await run_interactive_mode(agents, ui_config, original_config=config, orchestrator_cfg=orchestrator_cfg, config_path=config_file_path, **kwargs)
1709
+ finally:
1710
+ # Cleanup all agents' filesystem managers (including Docker containers)
1711
+ for agent_id, agent in agents.items():
1712
+ if hasattr(agent, "backend") and hasattr(agent.backend, "filesystem_manager"):
1713
+ if agent.backend.filesystem_manager:
1714
+ try:
1715
+ agent.backend.filesystem_manager.cleanup()
1716
+ except Exception as e:
1717
+ logger.warning(f"[CLI] Cleanup failed for agent {agent_id}: {e}")
1718
+
1719
+ except ConfigurationError as e:
1720
+ print(f"❌ Configuration error: {e}", flush=True)
1721
+ sys.exit(1)
1722
+ except KeyboardInterrupt:
1723
+ print("\n👋 Goodbye!", flush=True)
1724
+ except Exception as e:
1725
+ print(f"❌ Error: {e}", flush=True)
1726
+ sys.exit(1)
1727
+
1728
+
1729
+ def cli_main():
1730
+ """Synchronous wrapper for CLI entry point."""
565
1731
  parser = argparse.ArgumentParser(
566
1732
  description="MassGen - Multi-Agent Coordination CLI",
567
1733
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -569,21 +1735,40 @@ async def main():
569
1735
  Examples:
570
1736
  # Use configuration file
571
1737
  python -m massgen.cli --config config.yaml "What is machine learning?"
572
-
1738
+
573
1739
  # Quick single agent setup
574
1740
  python -m massgen.cli --backend openai --model gpt-4o-mini "Explain quantum computing"
575
1741
  python -m massgen.cli --backend claude --model claude-sonnet-4-20250514 "Analyze this data"
576
-
1742
+
1743
+ # Use ChatCompletion backend with custom base URL
1744
+ python -m massgen.cli --backend chatcompletion --model gpt-oss-120b --base-url https://api.cerebras.ai/v1/chat/completions "What is 2+2?"
1745
+
577
1746
  # Interactive mode
578
1747
  python -m massgen.cli --config config.yaml
579
-
1748
+
1749
+ # Timeout control examples
1750
+ python -m massgen.cli --config config.yaml --orchestrator-timeout 600 "Complex task"
1751
+
580
1752
  # Create sample configurations
581
1753
  python -m massgen.cli --create-samples
582
1754
 
583
1755
  Environment Variables:
584
- OPENAI_API_KEY - Required for OpenAI backend
585
- XAI_API_KEY - Required for Grok backend
586
- ANTHROPIC_API_KEY - Required for Claude backend
1756
+ OPENAI_API_KEY - Required for OpenAI backend
1757
+ XAI_API_KEY - Required for Grok backend
1758
+ ANTHROPIC_API_KEY - Required for Claude backend
1759
+ GOOGLE_API_KEY - Required for Gemini backend (or GEMINI_API_KEY)
1760
+ ZAI_API_KEY - Required for ZAI backend
1761
+
1762
+ CEREBRAS_API_KEY - For Cerebras AI (cerebras.ai)
1763
+ TOGETHER_API_KEY - For Together AI (together.ai, together.xyz)
1764
+ FIREWORKS_API_KEY - For Fireworks AI (fireworks.ai)
1765
+ GROQ_API_KEY - For Groq (groq.com)
1766
+ NEBIUS_API_KEY - For Nebius AI Studio (studio.nebius.ai)
1767
+ OPENROUTER_API_KEY - For OpenRouter (openrouter.ai)
1768
+ POE_API_KEY - For POE (poe.com)
1769
+
1770
+ Note: The chatcompletion backend auto-detects the provider from the base_url
1771
+ and uses the appropriate environment variable for API key.
587
1772
  """,
588
1773
  )
589
1774
 
@@ -596,13 +1781,23 @@ Environment Variables:
596
1781
 
597
1782
  # Configuration options
598
1783
  config_group = parser.add_mutually_exclusive_group()
599
- config_group.add_argument(
600
- "--config", type=str, help="Path to YAML/JSON configuration file"
601
- )
1784
+ config_group.add_argument("--config", type=str, help="Path to YAML/JSON configuration file or @examples/NAME")
602
1785
  config_group.add_argument(
603
1786
  "--backend",
604
1787
  type=str,
605
- choices=["openai", "grok", "claude", "gemini"],
1788
+ choices=[
1789
+ "chatcompletion",
1790
+ "claude",
1791
+ "gemini",
1792
+ "grok",
1793
+ "openai",
1794
+ "azure_openai",
1795
+ "claude_code",
1796
+ "zai",
1797
+ "lmstudio",
1798
+ "vllm",
1799
+ "sglang",
1800
+ ],
606
1801
  help="Backend type for quick setup",
607
1802
  )
608
1803
 
@@ -610,77 +1805,167 @@ Environment Variables:
610
1805
  parser.add_argument(
611
1806
  "--model",
612
1807
  type=str,
613
- default="gpt-4o-mini",
614
- help="Model name for quick setup (default: gpt-4o-mini)",
1808
+ default=None,
1809
+ help="Model name for quick setup",
615
1810
  )
1811
+ parser.add_argument("--system-message", type=str, help="System message for quick setup")
616
1812
  parser.add_argument(
617
- "--system-message", type=str, help="System message for quick setup"
1813
+ "--base-url",
1814
+ type=str,
1815
+ help="Base URL for API endpoint (e.g., https://api.cerebras.ai/v1/chat/completions)",
618
1816
  )
619
1817
 
620
1818
  # UI options
1819
+ parser.add_argument("--no-display", action="store_true", help="Disable visual coordination display")
1820
+ parser.add_argument("--no-logs", action="store_true", help="Disable logging")
1821
+ parser.add_argument("--debug", action="store_true", help="Enable debug mode with verbose logging")
621
1822
  parser.add_argument(
622
- "--no-display", action="store_true", help="Disable visual coordination display"
1823
+ "--init",
1824
+ action="store_true",
1825
+ help="Launch interactive configuration builder to create config file",
1826
+ )
1827
+ parser.add_argument(
1828
+ "--setup-keys",
1829
+ action="store_true",
1830
+ help="Launch interactive API key setup wizard to configure credentials",
1831
+ )
1832
+ parser.add_argument(
1833
+ "--list-examples",
1834
+ action="store_true",
1835
+ help="List available example configurations from package",
1836
+ )
1837
+ parser.add_argument(
1838
+ "--example",
1839
+ type=str,
1840
+ help="Print example config to stdout (e.g., --example basic_multi)",
1841
+ )
1842
+ parser.add_argument(
1843
+ "--show-schema",
1844
+ action="store_true",
1845
+ help="Display configuration schema and available parameters",
1846
+ )
1847
+ parser.add_argument(
1848
+ "--schema-backend",
1849
+ type=str,
1850
+ help="Show schema for specific backend (use with --show-schema)",
1851
+ )
1852
+ parser.add_argument(
1853
+ "--with-examples",
1854
+ action="store_true",
1855
+ help="Include example configurations in schema display",
1856
+ )
1857
+
1858
+ # Timeout options
1859
+ timeout_group = parser.add_argument_group("timeout settings", "Override timeout settings from config")
1860
+ timeout_group.add_argument(
1861
+ "--orchestrator-timeout",
1862
+ type=int,
1863
+ help="Maximum time for orchestrator coordination in seconds (default: 1800)",
623
1864
  )
624
- parser.add_argument("--no-logs", action="store_true", help="Disable logging")
625
1865
 
626
1866
  args = parser.parse_args()
627
1867
 
628
- # Validate arguments
629
- if not args.backend:
630
- if not args.model and not args.config:
631
- parser.error(
632
- "If there is not --backend, either --config or --model must be specified"
633
- )
1868
+ # Always setup logging (will save INFO to file, console output depends on debug flag)
1869
+ setup_logging(debug=args.debug)
634
1870
 
635
- try:
636
- # Load or create configuration
637
- if args.config:
638
- config = load_config_file(args.config)
639
- else:
640
- model = args.model
641
- if args.backend:
642
- backend = args.backend
643
- else:
644
- backend = get_backend_type_from_model(model=model)
645
- if args.system_message:
646
- system_message = args.system_message
647
- else:
648
- system_message = None
649
- config = create_simple_config(
650
- backend_type=backend, model=model, system_message=system_message
651
- )
1871
+ if args.debug:
1872
+ logger.info("Debug mode enabled")
1873
+ logger.debug(f"Command line arguments: {vars(args)}")
652
1874
 
653
- # Apply command-line overrides
654
- ui_config = config.get("ui", {})
655
- if args.no_display:
656
- ui_config["display_type"] = "simple"
657
- if args.no_logs:
658
- ui_config["logging_enabled"] = False
1875
+ # Handle special commands first
1876
+ if args.list_examples:
1877
+ show_available_examples()
1878
+ return
659
1879
 
660
- # Create agents
661
- agents = create_agents_from_config(config)
1880
+ if args.example:
1881
+ print_example_config(args.example)
1882
+ return
662
1883
 
663
- if not agents:
664
- raise ConfigurationError("No agents configured")
1884
+ if args.show_schema:
1885
+ from .schema_display import show_schema
665
1886
 
666
- # Run mode based on whether question was provided
667
- if args.question:
668
- response = await run_single_question(args.question, agents, ui_config)
669
- # if response:
670
- # print(f"\n{BRIGHT_GREEN}Final Response:{RESET}", flush=True)
671
- # print(f"{response}", flush=True)
1887
+ show_schema(backend=args.schema_backend, show_examples=args.with_examples)
1888
+ return
1889
+
1890
+ # Launch interactive API key setup if requested
1891
+ if args.setup_keys:
1892
+ from .config_builder import ConfigBuilder
1893
+
1894
+ builder = ConfigBuilder()
1895
+ api_keys = builder.interactive_api_key_setup()
1896
+
1897
+ if any(api_keys.values()):
1898
+ print(f"\n{BRIGHT_GREEN}✅ API key setup complete!{RESET}")
1899
+ print(f"{BRIGHT_CYAN}💡 You can now use MassGen with these providers{RESET}\n")
672
1900
  else:
673
- await run_interactive_mode(agents, ui_config)
1901
+ print(f"\n{BRIGHT_YELLOW}⚠️ No API keys configured{RESET}")
1902
+ print(f"{BRIGHT_CYAN}💡 You can run 'massgen --setup-keys' anytime to set them up{RESET}\n")
1903
+ return
1904
+
1905
+ # Launch interactive config builder if requested
1906
+ if args.init:
1907
+ from .config_builder import ConfigBuilder
1908
+
1909
+ builder = ConfigBuilder()
1910
+ result = builder.run()
1911
+
1912
+ if result and len(result) == 2:
1913
+ filepath, question = result
1914
+ if filepath and question:
1915
+ # Update args to use the newly created config
1916
+ args.config = filepath
1917
+ args.question = question
1918
+ elif filepath:
1919
+ # Config created but user chose not to run
1920
+ print(f"\n✅ Configuration saved to: {filepath}")
1921
+ print(f'Run with: python -m massgen.cli --config {filepath} "Your question"')
1922
+ return
1923
+ else:
1924
+ # User cancelled
1925
+ return
1926
+ else:
1927
+ # Builder returned None (cancelled or error)
1928
+ return
1929
+
1930
+ # First-run detection: auto-trigger builder if no config specified and first run
1931
+ if not args.question and not args.config and not args.model and not args.backend:
1932
+ if should_run_builder():
1933
+ print()
1934
+ print()
1935
+ print(f"{BRIGHT_CYAN}{'=' * 60}{RESET}")
1936
+ print(f"{BRIGHT_CYAN} 👋 Welcome to MassGen!{RESET}")
1937
+ print(f"{BRIGHT_CYAN}{'=' * 60}{RESET}")
1938
+ print()
1939
+ print(" Let's set up your default configuration...")
1940
+ print()
1941
+
1942
+ from .config_builder import ConfigBuilder
1943
+
1944
+ builder = ConfigBuilder(default_mode=True)
1945
+ result = builder.run()
1946
+
1947
+ if result and len(result) == 2:
1948
+ filepath, question = result
1949
+ if filepath:
1950
+ args.config = filepath
1951
+ if question:
1952
+ args.question = question
1953
+ else:
1954
+ print("\n✅ Configuration saved! You can now run queries.")
1955
+ print('Example: massgen "Your question here"')
1956
+ return
1957
+ else:
1958
+ return
1959
+ else:
1960
+ return
674
1961
 
675
- except ConfigurationError as e:
676
- print(f"❌ Configuration error: {e}", flush=True)
677
- sys.exit(1)
1962
+ # Now call the async main with the parsed arguments
1963
+ try:
1964
+ asyncio.run(main(args))
678
1965
  except KeyboardInterrupt:
679
- print("\n👋 Goodbye!", flush=True)
680
- except Exception as e:
681
- print(f"❌ Error: {e}", flush=True)
682
- sys.exit(1)
1966
+ # User pressed Ctrl+C - exit gracefully without traceback
1967
+ pass
683
1968
 
684
1969
 
685
1970
  if __name__ == "__main__":
686
- asyncio.run(main())
1971
+ cli_main()