massgen 0.0.3__py3-none-any.whl → 0.1.0a1__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 +1504 -287
  46. massgen/config_builder.py +2165 -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.0a1.dist-info/METADATA +1287 -0
  258. massgen-0.1.0a1.dist-info/RECORD +273 -0
  259. massgen-0.1.0a1.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.0a1.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0a1.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0a1.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,169 +246,495 @@ 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
- raise ConfigurationError(
120
- "OpenAI API key not found. Set OPENAI_API_KEY or provide in config."
121
- )
122
- return ResponseBackend(api_key=api_key)
294
+ raise ConfigurationError("OpenAI API key not found. Set OPENAI_API_KEY or provide " "in config.")
295
+ return ResponseBackend(api_key=api_key, **kwargs)
123
296
 
124
297
  elif backend_type == "grok":
125
298
  api_key = kwargs.get("api_key") or os.getenv("XAI_API_KEY")
126
299
  if not api_key:
127
- raise ConfigurationError(
128
- "Grok API key not found. Set XAI_API_KEY or provide in config."
129
- )
130
- return GrokBackend(api_key=api_key)
300
+ raise ConfigurationError("Grok API key not found. Set XAI_API_KEY or provide in config.")
301
+ return GrokBackend(api_key=api_key, **kwargs)
131
302
 
132
303
  elif backend_type == "claude":
133
304
  api_key = kwargs.get("api_key") or os.getenv("ANTHROPIC_API_KEY")
134
305
  if not api_key:
135
- raise ConfigurationError(
136
- "Claude API key not found. Set ANTHROPIC_API_KEY or provide in config."
137
- )
138
- return ClaudeBackend(api_key=api_key)
306
+ raise ConfigurationError("Claude API key not found. Set ANTHROPIC_API_KEY or provide in config.")
307
+ return ClaudeBackend(api_key=api_key, **kwargs)
139
308
 
140
309
  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
- )
310
+ api_key = kwargs.get("api_key") or os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
146
311
  if not api_key:
147
- raise ConfigurationError(
148
- "Gemini API key not found. Set GOOGLE_API_KEY or provide in config."
149
- )
150
- return GeminiBackend(api_key=api_key)
312
+ raise ConfigurationError("Gemini API key not found. Set GOOGLE_API_KEY or provide in config.")
313
+ return GeminiBackend(api_key=api_key, **kwargs)
314
+
315
+ elif backend_type == "chatcompletion":
316
+ api_key = kwargs.get("api_key")
317
+ base_url = kwargs.get("base_url")
318
+
319
+ # Determine API key based on base URL if not explicitly provided
320
+ if not api_key:
321
+ if base_url and "cerebras.ai" in base_url:
322
+ api_key = os.getenv("CEREBRAS_API_KEY")
323
+ if not api_key:
324
+ raise ConfigurationError("Cerebras AI API key not found. Set CEREBRAS_API_KEY or provide in config.")
325
+ elif base_url and "together.xyz" in base_url:
326
+ api_key = os.getenv("TOGETHER_API_KEY")
327
+ if not api_key:
328
+ raise ConfigurationError("Together AI API key not found. Set TOGETHER_API_KEY or provide in config.")
329
+ elif base_url and "fireworks.ai" in base_url:
330
+ api_key = os.getenv("FIREWORKS_API_KEY")
331
+ if not api_key:
332
+ raise ConfigurationError("Fireworks AI API key not found. Set FIREWORKS_API_KEY or provide in config.")
333
+ elif base_url and "groq.com" in base_url:
334
+ api_key = os.getenv("GROQ_API_KEY")
335
+ if not api_key:
336
+ raise ConfigurationError("Groq API key not found. Set GROQ_API_KEY or provide in config.")
337
+ elif base_url and "nebius.com" in base_url:
338
+ api_key = os.getenv("NEBIUS_API_KEY")
339
+ if not api_key:
340
+ raise ConfigurationError("Nebius AI Studio API key not found. Set NEBIUS_API_KEY or provide in config.")
341
+ elif base_url and "openrouter.ai" in base_url:
342
+ api_key = os.getenv("OPENROUTER_API_KEY")
343
+ if not api_key:
344
+ raise ConfigurationError("OpenRouter API key not found. Set OPENROUTER_API_KEY or provide in config.")
345
+ elif base_url and ("z.ai" in base_url or "bigmodel.cn" in base_url):
346
+ api_key = os.getenv("ZAI_API_KEY")
347
+ if not api_key:
348
+ raise ConfigurationError("ZAI API key not found. Set ZAI_API_KEY or provide in config.")
349
+ elif base_url and ("moonshot.ai" in base_url or "moonshot.cn" in base_url):
350
+ api_key = os.getenv("MOONSHOT_API_KEY") or os.getenv("KIMI_API_KEY")
351
+ if not api_key:
352
+ raise ConfigurationError("Kimi/Moonshot API key not found. Set MOONSHOT_API_KEY or KIMI_API_KEY or provide in config.")
353
+ elif base_url and "poe.com" in base_url:
354
+ api_key = os.getenv("POE_API_KEY")
355
+ if not api_key:
356
+ raise ConfigurationError("POE API key not found. Set POE_API_KEY or provide in config.")
357
+ elif base_url and "aliyuncs.com" in base_url:
358
+ api_key = os.getenv("QWEN_API_KEY")
359
+ if not api_key:
360
+ raise ConfigurationError("Qwen API key not found. Set QWEN_API_KEY or provide in config.")
361
+
362
+ return ChatCompletionsBackend(api_key=api_key, **kwargs)
363
+
364
+ elif backend_type == "zai":
365
+ # ZAI (Zhipu.ai) uses OpenAI-compatible Chat Completions at a custom base_url
366
+ # Supports both global (z.ai) and China (bigmodel.cn) endpoints
367
+ api_key = kwargs.get("api_key") or os.getenv("ZAI_API_KEY")
368
+ if not api_key:
369
+ raise ConfigurationError("ZAI API key not found. Set ZAI_API_KEY or provide in config.")
370
+ return ChatCompletionsBackend(api_key=api_key, **kwargs)
371
+
372
+ elif backend_type == "lmstudio":
373
+ # LM Studio local server (OpenAI-compatible). Defaults handled by backend.
374
+ return LMStudioBackend(**kwargs)
375
+
376
+ elif backend_type == "vllm":
377
+ # vLLM local server (OpenAI-compatible). Defaults handled by backend.
378
+ return InferenceBackend(backend_type="vllm", **kwargs)
379
+
380
+ elif backend_type == "sglang":
381
+ # SGLang local server (OpenAI-compatible). Defaults handled by backend.
382
+ return InferenceBackend(backend_type="sglang", **kwargs)
383
+
384
+ elif backend_type == "claude_code":
385
+ # ClaudeCodeBackend using claude-code-sdk-python
386
+ # Authentication handled by backend (API key or subscription)
387
+
388
+ # Validate claude-code-sdk availability
389
+ try:
390
+ pass
391
+ except ImportError:
392
+ raise ConfigurationError("claude-code-sdk not found. Install with: pip install claude-code-sdk")
393
+
394
+ return ClaudeCodeBackend(**kwargs)
395
+
396
+ elif backend_type == "azure_openai":
397
+ api_key = kwargs.get("api_key") or os.getenv("AZURE_OPENAI_API_KEY")
398
+ endpoint = kwargs.get("base_url") or os.getenv("AZURE_OPENAI_ENDPOINT")
399
+ if not api_key:
400
+ raise ConfigurationError("Azure OpenAI API key not found. Set AZURE_OPENAI_API_KEY or provide in config.")
401
+ if not endpoint:
402
+ raise ConfigurationError("Azure OpenAI endpoint not found. Set AZURE_OPENAI_ENDPOINT or provide base_url in config.")
403
+ return AzureOpenAIBackend(**kwargs)
151
404
 
152
405
  else:
153
406
  raise ConfigurationError(f"Unsupported backend type: {backend_type}")
154
407
 
155
408
 
156
- def create_agents_from_config(config: Dict[str, Any]) -> Dict[str, ConfigurableAgent]:
409
+ def create_agents_from_config(config: Dict[str, Any], orchestrator_config: Optional[Dict[str, Any]] = None) -> Dict[str, ConfigurableAgent]:
157
410
  """Create agents from configuration."""
158
411
  agents = {}
159
412
 
160
- # Handle single agent configuration
161
- if "agent" in config:
162
- agent_config_data = config["agent"]
163
- backend_config = agent_config_data.get("backend", {})
413
+ agent_entries = [config["agent"]] if "agent" in config else config.get("agents", None)
414
+
415
+ if not agent_entries:
416
+ raise ConfigurationError("Configuration must contain either 'agent' or 'agents' section")
417
+
418
+ for i, agent_data in enumerate(agent_entries, start=1):
419
+ backend_config = agent_data.get("backend", {})
420
+
421
+ # Substitute variables like ${cwd} in backend config
422
+ if "cwd" in backend_config:
423
+ variables = {"cwd": backend_config["cwd"]}
424
+ backend_config = _substitute_variables(backend_config, variables)
164
425
 
165
426
  # 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
- )
427
+ backend_type = backend_config.get("type") or (get_backend_type_from_model(backend_config["model"]) if "model" in backend_config else None)
428
+ if not backend_type:
429
+ raise ConfigurationError("Backend type must be specified or inferrable from model")
430
+
431
+ # Add orchestrator context for filesystem setup if available
432
+ if orchestrator_config:
433
+ if "agent_temporary_workspace" in orchestrator_config:
434
+ backend_config["agent_temporary_workspace"] = orchestrator_config["agent_temporary_workspace"]
435
+ # Add orchestrator-level context_paths to all agents
436
+ if "context_paths" in orchestrator_config:
437
+ # Merge orchestrator context_paths with agent-specific ones
438
+ agent_context_paths = backend_config.get("context_paths", [])
439
+ orchestrator_context_paths = orchestrator_config["context_paths"]
440
+
441
+ # Deduplicate paths - orchestrator paths take precedence
442
+ merged_paths = orchestrator_context_paths.copy()
443
+ orchestrator_paths_set = {path.get("path") for path in orchestrator_context_paths}
444
+
445
+ for agent_path in agent_context_paths:
446
+ if agent_path.get("path") not in orchestrator_paths_set:
447
+ merged_paths.append(agent_path)
448
+
449
+ backend_config["context_paths"] = merged_paths
174
450
 
175
451
  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
- )
452
+ backend_params = {k: v for k, v in backend_config.items() if k != "type"}
453
+
454
+ backend_type_lower = backend_type.lower()
455
+ if backend_type_lower == "openai":
456
+ agent_config = AgentConfig.create_openai_config(**backend_params)
457
+ elif backend_type_lower == "claude":
458
+ agent_config = AgentConfig.create_claude_config(**backend_params)
459
+ elif backend_type_lower == "grok":
460
+ agent_config = AgentConfig.create_grok_config(**backend_params)
461
+ elif backend_type_lower == "gemini":
462
+ agent_config = AgentConfig.create_gemini_config(**backend_params)
463
+ elif backend_type_lower == "zai":
464
+ agent_config = AgentConfig.create_zai_config(**backend_params)
465
+ elif backend_type_lower == "chatcompletion":
466
+ agent_config = AgentConfig.create_chatcompletion_config(**backend_params)
467
+ elif backend_type_lower == "lmstudio":
468
+ agent_config = AgentConfig.create_lmstudio_config(**backend_params)
469
+ elif backend_type_lower == "vllm":
470
+ agent_config = AgentConfig.create_vllm_config(**backend_params)
471
+ elif backend_type_lower == "sglang":
472
+ agent_config = AgentConfig.create_sglang_config(**backend_params)
194
473
  else:
195
- # Fallback to basic config
196
474
  agent_config = AgentConfig(backend_params=backend_config)
197
475
 
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
- )
219
-
220
- backend = create_backend(backend_type, **backend_config)
476
+ agent_config.agent_id = agent_data.get("id", f"agent{i}")
221
477
 
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
- )
478
+ # Route system_message to backend-specific system prompt parameter
479
+ system_msg = agent_data.get("system_message")
480
+ if system_msg:
481
+ if backend_type_lower == "claude_code":
482
+ # For Claude Code, use append_system_prompt to preserve Claude Code capabilities
483
+ agent_config.backend_params["append_system_prompt"] = system_msg
235
484
  else:
236
- # Fallback to basic config
237
- agent_config = AgentConfig(backend_params=backend_config)
485
+ # For other backends, fall back to deprecated custom_system_instruction
486
+ # TODO: Add backend-specific routing for other backends
487
+ agent_config.custom_system_instruction = system_msg
238
488
 
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
- )
244
-
245
- agent = ConfigurableAgent(config=agent_config, backend=backend)
246
- agents[agent.agent_id] = agent
489
+ # Timeout configuration will be applied to orchestrator instead of individual agents
247
490
 
248
- else:
249
- raise ConfigurationError(
250
- "Configuration must contain either 'agent' or 'agents' section"
251
- )
491
+ agent = ConfigurableAgent(config=agent_config, backend=backend)
492
+ agents[agent.config.agent_id] = agent
252
493
 
253
494
  return agents
254
495
 
255
496
 
256
497
  def create_simple_config(
257
- backend_type: str, model: str, system_message: Optional[str] = None
498
+ backend_type: str,
499
+ model: str,
500
+ system_message: Optional[str] = None,
501
+ base_url: Optional[str] = None,
502
+ ui_config: Optional[Dict[str, Any]] = None,
258
503
  ) -> Dict[str, Any]:
259
504
  """Create a simple single-agent configuration."""
260
- return {
505
+ backend_config = {"type": backend_type, "model": model}
506
+ if base_url:
507
+ backend_config["base_url"] = base_url
508
+
509
+ # Add required workspace configuration for Claude Code backend
510
+ if backend_type == "claude_code":
511
+ backend_config["cwd"] = "workspace1"
512
+
513
+ # Use provided UI config or default to rich_terminal for CLI usage
514
+ if ui_config is None:
515
+ ui_config = {"display_type": "rich_terminal", "logging_enabled": True}
516
+
517
+ config = {
261
518
  "agent": {
262
519
  "id": "agent1",
263
- "backend": {"type": backend_type, "model": model},
520
+ "backend": backend_config,
264
521
  "system_message": system_message or "You are a helpful AI assistant.",
265
522
  },
266
- "ui": {"display_type": "rich_terminal", "logging_enabled": True},
523
+ "ui": ui_config,
524
+ }
525
+
526
+ # Add orchestrator config with .massgen/ structure for Claude Code
527
+ if backend_type == "claude_code":
528
+ config["orchestrator"] = {
529
+ "snapshot_storage": ".massgen/snapshots",
530
+ "agent_temporary_workspace": ".massgen/temp_workspaces",
531
+ "session_storage": ".massgen/sessions",
532
+ }
533
+
534
+ return config
535
+
536
+
537
+ def validate_context_paths(config: Dict[str, Any]) -> None:
538
+ """Validate that all context paths in the config exist.
539
+
540
+ Context paths can be either files or directories.
541
+ File-level context paths allow access to specific files without exposing sibling files.
542
+ Raises ConfigurationError with clear message if any paths don't exist.
543
+ """
544
+ orchestrator_cfg = config.get("orchestrator", {})
545
+ context_paths = orchestrator_cfg.get("context_paths", [])
546
+
547
+ missing_paths = []
548
+
549
+ for context_path_config in context_paths:
550
+ if isinstance(context_path_config, dict):
551
+ path = context_path_config.get("path")
552
+ else:
553
+ # Handle string format for backwards compatibility
554
+ path = context_path_config
555
+
556
+ if path:
557
+ path_obj = Path(path)
558
+ if not path_obj.exists():
559
+ missing_paths.append(path)
560
+
561
+ if missing_paths:
562
+ errors = ["Context paths not found:"]
563
+ for path in missing_paths:
564
+ errors.append(f" - {path}")
565
+ errors.append("\nPlease update your configuration with valid paths.")
566
+ raise ConfigurationError("\n".join(errors))
567
+
568
+
569
+ def relocate_filesystem_paths(config: Dict[str, Any]) -> None:
570
+ """Relocate filesystem paths (orchestrator paths and agent workspaces) to be under .massgen/ directory.
571
+
572
+ Modifies the config in-place to ensure all MassGen state is organized
573
+ under .massgen/ for clean project structure.
574
+ """
575
+ massgen_dir = Path(".massgen")
576
+
577
+ # Relocate orchestrator paths
578
+ orchestrator_cfg = config.get("orchestrator", {})
579
+ if orchestrator_cfg:
580
+ path_fields = [
581
+ "snapshot_storage",
582
+ "agent_temporary_workspace",
583
+ "session_storage",
584
+ ]
585
+
586
+ for field in path_fields:
587
+ if field in orchestrator_cfg:
588
+ user_path = orchestrator_cfg[field]
589
+ # If user provided an absolute path or already starts with .massgen/, keep as-is
590
+ if Path(user_path).is_absolute() or user_path.startswith(".massgen/"):
591
+ continue
592
+ # Otherwise, relocate under .massgen/
593
+ orchestrator_cfg[field] = str(massgen_dir / user_path)
594
+
595
+ # Relocate agent workspaces (cwd fields)
596
+ agent_entries = [config["agent"]] if "agent" in config else config.get("agents", [])
597
+ for agent_data in agent_entries:
598
+ backend_config = agent_data.get("backend", {})
599
+ if "cwd" in backend_config:
600
+ user_cwd = backend_config["cwd"]
601
+ # If user provided an absolute path or already starts with .massgen/, keep as-is
602
+ if Path(user_cwd).is_absolute() or user_cwd.startswith(".massgen/"):
603
+ continue
604
+ # Otherwise, relocate under .massgen/workspaces/
605
+ backend_config["cwd"] = str(massgen_dir / "workspaces" / user_cwd)
606
+
607
+
608
+ def load_previous_turns(session_info: Dict[str, Any], session_storage: str) -> List[Dict[str, Any]]:
609
+ """
610
+ Load previous turns from session storage.
611
+
612
+ Returns:
613
+ List of previous turn metadata dicts
614
+ """
615
+ session_id = session_info.get("session_id")
616
+ if not session_id:
617
+ return []
618
+
619
+ session_dir = Path(session_storage) / session_id
620
+ if not session_dir.exists():
621
+ return []
622
+
623
+ previous_turns = []
624
+ turn_num = 1
625
+
626
+ while True:
627
+ turn_dir = session_dir / f"turn_{turn_num}"
628
+ if not turn_dir.exists():
629
+ break
630
+
631
+ metadata_file = turn_dir / "metadata.json"
632
+ if metadata_file.exists():
633
+ metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
634
+ # Use absolute path for workspace
635
+ workspace_path = (turn_dir / "workspace").resolve()
636
+ previous_turns.append(
637
+ {
638
+ "turn": turn_num,
639
+ "path": str(workspace_path),
640
+ "task": metadata.get("task", ""),
641
+ "winning_agent": metadata.get("winning_agent", ""),
642
+ },
643
+ )
644
+
645
+ turn_num += 1
646
+
647
+ return previous_turns
648
+
649
+
650
+ async def handle_session_persistence(
651
+ orchestrator,
652
+ question: str,
653
+ session_info: Dict[str, Any],
654
+ session_storage: str,
655
+ ) -> tuple[Optional[str], int, Optional[str]]:
656
+ """
657
+ Handle session persistence after orchestrator completes.
658
+
659
+ Returns:
660
+ tuple: (session_id, updated_turn_number, normalized_answer)
661
+ """
662
+ # Get final result from orchestrator
663
+ final_result = orchestrator.get_final_result()
664
+ if not final_result:
665
+ # No filesystem work to persist
666
+ return (session_info.get("session_id"), session_info.get("current_turn", 0), None)
667
+
668
+ # Initialize or reuse session ID
669
+ session_id = session_info.get("session_id")
670
+ if not session_id:
671
+ session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
672
+
673
+ # Increment turn
674
+ current_turn = session_info.get("current_turn", 0) + 1
675
+
676
+ # Create turn directory
677
+ session_dir = Path(session_storage) / session_id
678
+ turn_dir = session_dir / f"turn_{current_turn}"
679
+ turn_dir.mkdir(parents=True, exist_ok=True)
680
+
681
+ # Normalize answer paths
682
+ final_answer = final_result["final_answer"]
683
+ workspace_path = final_result.get("workspace_path")
684
+ turn_workspace_path = (turn_dir / "workspace").resolve() # Make absolute
685
+
686
+ if workspace_path:
687
+ # Replace workspace paths in answer with absolute path
688
+ normalized_answer = final_answer.replace(workspace_path, str(turn_workspace_path))
689
+ else:
690
+ normalized_answer = final_answer
691
+
692
+ # Save normalized answer
693
+ answer_file = turn_dir / "answer.txt"
694
+ answer_file.write_text(normalized_answer, encoding="utf-8")
695
+
696
+ # Save metadata
697
+ metadata = {
698
+ "turn": current_turn,
699
+ "timestamp": datetime.now().isoformat(),
700
+ "winning_agent": final_result["winning_agent_id"],
701
+ "task": question,
702
+ "session_id": session_id,
267
703
  }
704
+ metadata_file = turn_dir / "metadata.json"
705
+ metadata_file.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
706
+
707
+ # Create/update session summary for easy viewing
708
+ session_summary_file = session_dir / "SESSION_SUMMARY.txt"
709
+ summary_lines = []
710
+
711
+ if session_summary_file.exists():
712
+ summary_lines = session_summary_file.read_text(encoding="utf-8").splitlines()
713
+ else:
714
+ summary_lines.append("=" * 80)
715
+ summary_lines.append(f"Multi-Turn Session: {session_id}")
716
+ summary_lines.append("=" * 80)
717
+ summary_lines.append("")
718
+
719
+ # Add turn separator and info
720
+ summary_lines.append("")
721
+ summary_lines.append("=" * 80)
722
+ summary_lines.append(f"TURN {current_turn}")
723
+ summary_lines.append("=" * 80)
724
+ summary_lines.append(f"Timestamp: {metadata['timestamp']}")
725
+ summary_lines.append(f"Winning Agent: {metadata['winning_agent']}")
726
+ summary_lines.append(f"Task: {question}")
727
+ summary_lines.append(f"Workspace: {turn_workspace_path}")
728
+ summary_lines.append(f"Answer: See {(turn_dir / 'answer.txt').resolve()}")
729
+ summary_lines.append("")
730
+
731
+ session_summary_file.write_text("\n".join(summary_lines), encoding="utf-8")
732
+
733
+ # Copy workspace if it exists
734
+ if workspace_path and Path(workspace_path).exists():
735
+ shutil.copytree(workspace_path, turn_workspace_path, dirs_exist_ok=True)
736
+
737
+ return (session_id, current_turn, normalized_answer)
268
738
 
269
739
 
270
740
  async def run_question_with_history(
@@ -272,16 +742,20 @@ async def run_question_with_history(
272
742
  agents: Dict[str, SingleAgent],
273
743
  ui_config: Dict[str, Any],
274
744
  history: List[Dict[str, Any]],
275
- ) -> str:
276
- """Run MassGen with a question and conversation history."""
745
+ session_info: Dict[str, Any],
746
+ **kwargs,
747
+ ) -> tuple[str, Optional[str], int]:
748
+ """Run MassGen with a question and conversation history.
749
+
750
+ Returns:
751
+ tuple: (response_text, session_id, turn_number)
752
+ """
277
753
  # Build messages including history
278
754
  messages = history.copy()
279
755
  messages.append({"role": "user", "content": question})
280
756
 
281
757
  # 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
- )
758
+ use_orchestrator_for_single = ui_config.get("use_orchestrator_for_single_agent", True)
285
759
 
286
760
  if len(agents) == 1 and not use_orchestrator_for_single:
287
761
  # Single agent mode with history
@@ -290,7 +764,7 @@ async def run_question_with_history(
290
764
  print(f"Agent: {agent.agent_id}", flush=True)
291
765
  if history:
292
766
  print(f"History: {len(history)//2} previous exchanges", flush=True)
293
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
767
+ print(f"Question: {question}", flush=True)
294
768
  print("\n" + "=" * 60, flush=True)
295
769
 
296
770
  response_content = ""
@@ -305,25 +779,54 @@ async def run_question_with_history(
305
779
  continue
306
780
  elif chunk.type == "error":
307
781
  print(f"\n❌ Error: {chunk.error}", flush=True)
308
- return ""
782
+ return ("", session_info.get("session_id"), session_info.get("current_turn", 0))
309
783
 
310
784
  print("\n" + "=" * 60, flush=True)
311
- return response_content
785
+ # Single agent mode doesn't use session storage
786
+ return (response_content, session_info.get("session_id"), session_info.get("current_turn", 0))
312
787
 
313
788
  else:
314
789
  # Multi-agent mode with history
315
- orchestrator = Orchestrator(agents=agents)
790
+ # Create orchestrator config with timeout settings
791
+ timeout_config = kwargs.get("timeout_config")
792
+ orchestrator_config = AgentConfig()
793
+ if timeout_config:
794
+ orchestrator_config.timeout_config = timeout_config
795
+
796
+ # Get orchestrator parameters from config
797
+ orchestrator_cfg = kwargs.get("orchestrator", {})
798
+
799
+ # Get context sharing parameters
800
+ snapshot_storage = orchestrator_cfg.get("snapshot_storage")
801
+ agent_temporary_workspace = orchestrator_cfg.get("agent_temporary_workspace")
802
+ session_storage = orchestrator_cfg.get("session_storage", "sessions") # Default to "sessions"
803
+
804
+ # Get debug/test parameters
805
+ if orchestrator_cfg.get("skip_coordination_rounds", False):
806
+ orchestrator_config.skip_coordination_rounds = True
807
+
808
+ # Load previous turns from session storage for multi-turn conversations
809
+ previous_turns = load_previous_turns(session_info, session_storage)
810
+
811
+ orchestrator = Orchestrator(
812
+ agents=agents,
813
+ config=orchestrator_config,
814
+ snapshot_storage=snapshot_storage,
815
+ agent_temporary_workspace=agent_temporary_workspace,
816
+ previous_turns=previous_turns,
817
+ )
316
818
  # Create a fresh UI instance for each question to ensure clean state
317
819
  ui = CoordinationUI(
318
820
  display_type=ui_config.get("display_type", "rich_terminal"),
319
821
  logging_enabled=ui_config.get("logging_enabled", True),
822
+ enable_final_presentation=True, # Required for multi-turn: ensures final answer is saved
320
823
  )
321
824
 
322
825
  print(f"\n🤖 {BRIGHT_CYAN}Multi-Agent Mode{RESET}", flush=True)
323
826
  print(f"Agents: {', '.join(agents.keys())}", flush=True)
324
827
  if history:
325
828
  print(f"History: {len(history)//2} previous exchanges", flush=True)
326
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
829
+ print(f"Question: {question}", flush=True)
327
830
  print("\n" + "=" * 60, flush=True)
328
831
 
329
832
  # For multi-agent with history, we need to use a different approach
@@ -332,29 +835,30 @@ async def run_question_with_history(
332
835
  if history and len(history) > 0:
333
836
  # Use coordination UI with conversation context
334
837
  # Extract current question from messages
335
- current_question = (
336
- messages[-1].get("content", question) if messages else question
337
- )
838
+ current_question = messages[-1].get("content", question) if messages else question
338
839
 
339
840
  # Pass the full message context to the UI coordination
340
- response_content = await ui.coordinate_with_context(
341
- orchestrator, current_question, messages
342
- )
841
+ response_content = await ui.coordinate_with_context(orchestrator, current_question, messages)
343
842
  else:
344
843
  # Standard coordination for new conversations
345
844
  response_content = await ui.coordinate(orchestrator, question)
346
845
 
347
- return response_content
846
+ # Handle session persistence if applicable
847
+ session_id_to_use, updated_turn, normalized_response = await handle_session_persistence(
848
+ orchestrator,
849
+ question,
850
+ session_info,
851
+ session_storage,
852
+ )
853
+
854
+ # Return normalized response so conversation history has correct paths
855
+ return (normalized_response or response_content, session_id_to_use, updated_turn)
348
856
 
349
857
 
350
- async def run_single_question(
351
- question: str, agents: Dict[str, SingleAgent], ui_config: Dict[str, Any]
352
- ) -> str:
858
+ async def run_single_question(question: str, agents: Dict[str, SingleAgent], ui_config: Dict[str, Any], **kwargs) -> str:
353
859
  """Run MassGen with a single question."""
354
860
  # 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
- )
861
+ use_orchestrator_for_single = ui_config.get("use_orchestrator_for_single_agent", True)
358
862
 
359
863
  if len(agents) == 1 and not use_orchestrator_for_single:
360
864
  # Single agent mode with existing SimpleDisplay frontend
@@ -362,7 +866,7 @@ async def run_single_question(
362
866
 
363
867
  print(f"\n🤖 {BRIGHT_CYAN}Single Agent Mode{RESET}", flush=True)
364
868
  print(f"Agent: {agent.agent_id}", flush=True)
365
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
869
+ print(f"Question: {question}", flush=True)
366
870
  print("\n" + "=" * 60, flush=True)
367
871
 
368
872
  messages = [{"role": "user", "content": question}]
@@ -384,67 +888,472 @@ async def run_single_question(
384
888
 
385
889
  else:
386
890
  # Multi-agent mode
387
- orchestrator = Orchestrator(agents=agents)
891
+ # Create orchestrator config with timeout settings
892
+ timeout_config = kwargs.get("timeout_config")
893
+ orchestrator_config = AgentConfig()
894
+ if timeout_config:
895
+ orchestrator_config.timeout_config = timeout_config
896
+
897
+ # Get orchestrator parameters from config
898
+ orchestrator_cfg = kwargs.get("orchestrator", {})
899
+
900
+ # Get context sharing parameters
901
+ snapshot_storage = orchestrator_cfg.get("snapshot_storage")
902
+ agent_temporary_workspace = orchestrator_cfg.get("agent_temporary_workspace")
903
+
904
+ # Get debug/test parameters
905
+ if orchestrator_cfg.get("skip_coordination_rounds", False):
906
+ orchestrator_config.skip_coordination_rounds = True
907
+
908
+ orchestrator = Orchestrator(
909
+ agents=agents,
910
+ config=orchestrator_config,
911
+ snapshot_storage=snapshot_storage,
912
+ agent_temporary_workspace=agent_temporary_workspace,
913
+ )
388
914
  # Create a fresh UI instance for each question to ensure clean state
389
915
  ui = CoordinationUI(
390
916
  display_type=ui_config.get("display_type", "rich_terminal"),
391
917
  logging_enabled=ui_config.get("logging_enabled", True),
918
+ enable_final_presentation=True, # Ensures final presentation is generated
392
919
  )
393
920
 
394
921
  print(f"\n🤖 {BRIGHT_CYAN}Multi-Agent Mode{RESET}", flush=True)
395
922
  print(f"Agents: {', '.join(agents.keys())}", flush=True)
396
- print(f"Question: {BRIGHT_WHITE}{question}{RESET}", flush=True)
923
+ print(f"Question: {question}", flush=True)
397
924
  print("\n" + "=" * 60, flush=True)
398
925
 
399
926
  final_response = await ui.coordinate(orchestrator, question)
400
927
  return final_response
401
928
 
402
929
 
930
+ def prompt_for_context_paths(original_config: Dict[str, Any], orchestrator_cfg: Dict[str, Any]) -> bool:
931
+ """Prompt user to add context paths in interactive mode.
932
+
933
+ Returns True if config was modified, False otherwise.
934
+ """
935
+ # Check if filesystem is enabled (at least one agent has cwd)
936
+ agent_entries = [original_config["agent"]] if "agent" in original_config else original_config.get("agents", [])
937
+ has_filesystem = any("cwd" in agent.get("backend", {}) for agent in agent_entries)
938
+
939
+ if not has_filesystem:
940
+ return False
941
+
942
+ # Show current context paths
943
+ existing_paths = orchestrator_cfg.get("context_paths", [])
944
+ cwd = Path.cwd()
945
+
946
+ # Use Rich for better display
947
+ from rich.console import Console as RichConsole
948
+ from rich.panel import Panel as RichPanel
949
+
950
+ rich_console = RichConsole()
951
+
952
+ # Build context paths display
953
+ context_content = []
954
+ if existing_paths:
955
+ for path_config in existing_paths:
956
+ path = path_config.get("path") if isinstance(path_config, dict) else path_config
957
+ permission = path_config.get("permission", "read") if isinstance(path_config, dict) else "read"
958
+ context_content.append(f" [green]✓[/green] {path} [dim]({permission})[/dim]")
959
+ else:
960
+ context_content.append(" [yellow]No context paths configured[/yellow]")
961
+
962
+ context_panel = RichPanel(
963
+ "\n".join(context_content),
964
+ title="[bold bright_cyan]📂 Context Paths[/bold bright_cyan]",
965
+ border_style="cyan",
966
+ padding=(0, 2),
967
+ width=80,
968
+ )
969
+ rich_console.print(context_panel)
970
+ print()
971
+
972
+ # Check if CWD is already in context paths
973
+ cwd_str = str(cwd)
974
+ cwd_already_added = any((path_config.get("path") if isinstance(path_config, dict) else path_config) == cwd_str for path_config in existing_paths)
975
+
976
+ if not cwd_already_added:
977
+ # Create prompt panel
978
+ prompt_content = [
979
+ "[bold cyan]Add current directory as context path?[/bold cyan]",
980
+ f" [yellow]{cwd}[/yellow]",
981
+ "",
982
+ " [dim]Context paths give agents access to your project files.[/dim]",
983
+ " [dim]• Read-only during coordination (prevents conflicts)[/dim]",
984
+ " [dim]• Write permission for final agent to save results[/dim]",
985
+ "",
986
+ " [dim]Options:[/dim]",
987
+ " [green]Y[/green] → Add with write permission (default)",
988
+ " [cyan]P[/cyan] → Add with protected paths (e.g., .env, secrets)",
989
+ " [yellow]N[/yellow] → Skip",
990
+ " [blue]C[/blue] → Add custom path",
991
+ ]
992
+ prompt_panel = RichPanel(
993
+ "\n".join(prompt_content),
994
+ border_style="cyan",
995
+ padding=(1, 2),
996
+ width=80,
997
+ )
998
+ rich_console.print(prompt_panel)
999
+ print()
1000
+ try:
1001
+ response = input(f" {BRIGHT_CYAN}Your choice [Y/P/N/C]:{RESET} ").strip().lower()
1002
+
1003
+ if response in ["y", "yes", ""]:
1004
+ # Add CWD with write permission
1005
+ if "context_paths" not in orchestrator_cfg:
1006
+ orchestrator_cfg["context_paths"] = []
1007
+ orchestrator_cfg["context_paths"].append({"path": cwd_str, "permission": "write"})
1008
+ print(f" {BRIGHT_GREEN}✅ Added: {cwd} (write){RESET}", flush=True)
1009
+ return True
1010
+ elif response in ["p", "protected"]:
1011
+ # Add CWD with write permission and protected paths
1012
+ protected_paths = []
1013
+ print(f"\n {BRIGHT_CYAN}Enter protected paths (one per line, empty to finish):{RESET}", flush=True)
1014
+ print(f" {BRIGHT_YELLOW}Tip: Protected paths are relative to {cwd}{RESET}", flush=True)
1015
+ while True:
1016
+ protected_input = input(f" {BRIGHT_CYAN}→{RESET} ").strip()
1017
+ if not protected_input:
1018
+ break
1019
+ protected_paths.append(protected_input)
1020
+ print(f" {BRIGHT_GREEN}✓ Added: {protected_input}{RESET}", flush=True)
1021
+
1022
+ if "context_paths" not in orchestrator_cfg:
1023
+ orchestrator_cfg["context_paths"] = []
1024
+
1025
+ context_config = {"path": cwd_str, "permission": "write"}
1026
+ if protected_paths:
1027
+ context_config["protected_paths"] = protected_paths
1028
+
1029
+ orchestrator_cfg["context_paths"].append(context_config)
1030
+ print(f"\n {BRIGHT_GREEN}✅ Added: {cwd} (write) with {len(protected_paths)} protected path(s){RESET}", flush=True)
1031
+ return True
1032
+ elif response in ["n", "no"]:
1033
+ # User explicitly declined
1034
+ return False
1035
+ elif response in ["c", "custom"]:
1036
+ # Loop until valid path or user cancels
1037
+ print()
1038
+ while True:
1039
+ custom_path = input(f" {BRIGHT_CYAN}Enter path (absolute or relative):{RESET} ").strip()
1040
+ if not custom_path:
1041
+ print(f" {BRIGHT_YELLOW}⚠️ Cancelled{RESET}", flush=True)
1042
+ return False
1043
+
1044
+ # Resolve to absolute path
1045
+ abs_path = str(Path(custom_path).resolve())
1046
+
1047
+ # Check if path exists
1048
+ if not Path(abs_path).exists():
1049
+ print(f" {BRIGHT_RED}✗ Path does not exist: {abs_path}{RESET}", flush=True)
1050
+ retry = input(f" {BRIGHT_CYAN}Try again? [Y/n]:{RESET} ").strip().lower()
1051
+ if retry in ["n", "no"]:
1052
+ return False
1053
+ continue
1054
+
1055
+ # Valid path (file or directory), ask for permission
1056
+ permission = input(f" {BRIGHT_CYAN}Permission [read/write] (default: write):{RESET} ").strip().lower() or "write"
1057
+ if permission not in ["read", "write"]:
1058
+ permission = "write"
1059
+
1060
+ # Ask about protected paths if write permission
1061
+ protected_paths = []
1062
+ if permission == "write":
1063
+ add_protected = input(f" {BRIGHT_CYAN}Add protected paths? [y/N]:{RESET} ").strip().lower()
1064
+ if add_protected in ["y", "yes"]:
1065
+ print(f" {BRIGHT_CYAN}Enter protected paths (one per line, empty to finish):{RESET}", flush=True)
1066
+ while True:
1067
+ protected_input = input(f" {BRIGHT_CYAN}→{RESET} ").strip()
1068
+ if not protected_input:
1069
+ break
1070
+ protected_paths.append(protected_input)
1071
+ print(f" {BRIGHT_GREEN}✓ Added: {protected_input}{RESET}", flush=True)
1072
+
1073
+ if "context_paths" not in orchestrator_cfg:
1074
+ orchestrator_cfg["context_paths"] = []
1075
+
1076
+ context_config = {"path": abs_path, "permission": permission}
1077
+ if protected_paths:
1078
+ context_config["protected_paths"] = protected_paths
1079
+
1080
+ orchestrator_cfg["context_paths"].append(context_config)
1081
+ if protected_paths:
1082
+ print(f" {BRIGHT_GREEN}✅ Added: {abs_path} ({permission}) with {len(protected_paths)} protected path(s){RESET}", flush=True)
1083
+ else:
1084
+ print(f" {BRIGHT_GREEN}✅ Added: {abs_path} ({permission}){RESET}", flush=True)
1085
+ return True
1086
+ else:
1087
+ # Invalid response - clarify options
1088
+ print(f"\n {BRIGHT_RED}✗ Invalid option: '{response}'{RESET}", flush=True)
1089
+ print(f" {BRIGHT_YELLOW}Please choose: Y (yes), P (protected), N (no), or C (custom){RESET}", flush=True)
1090
+ return False
1091
+ except (KeyboardInterrupt, EOFError):
1092
+ print() # New line after Ctrl+C
1093
+ return False
1094
+
1095
+ return False
1096
+
1097
+
1098
+ def show_available_examples():
1099
+ """Display available example configurations from package."""
1100
+ try:
1101
+ from importlib.resources import files
1102
+
1103
+ configs_root = files("massgen") / "configs"
1104
+
1105
+ print(f"\n{BRIGHT_CYAN}Available Example Configurations{RESET}")
1106
+ print("=" * 60)
1107
+
1108
+ # Organize by category
1109
+ categories = {}
1110
+ for config_file in sorted(configs_root.rglob("*.yaml")):
1111
+ # Get relative path from configs root
1112
+ rel_path = str(config_file).replace(str(configs_root) + "/", "")
1113
+ # Extract category (first directory)
1114
+ parts = rel_path.split("/")
1115
+ category = parts[0] if len(parts) > 1 else "root"
1116
+
1117
+ if category not in categories:
1118
+ categories[category] = []
1119
+
1120
+ # Create a short name for @examples/
1121
+ # Use the path without .yaml extension
1122
+ short_name = rel_path.replace(".yaml", "").replace("/", "_")
1123
+
1124
+ categories[category].append((short_name, rel_path))
1125
+
1126
+ # Display categories
1127
+ for category, configs in sorted(categories.items()):
1128
+ print(f"\n{BRIGHT_YELLOW}{category.title()}:{RESET}")
1129
+ for short_name, rel_path in configs[:10]: # Limit to avoid overwhelming
1130
+ print(f" {BRIGHT_GREEN}@examples/{short_name:<40}{RESET} {rel_path}")
1131
+
1132
+ if len(configs) > 10:
1133
+ print(f" ... and {len(configs) - 10} more")
1134
+
1135
+ print(f"\n{BRIGHT_BLUE}Usage:{RESET}")
1136
+ print(' massgen --config @examples/SHORTNAME "Your question"')
1137
+ print(" massgen --example SHORTNAME > my-config.yaml")
1138
+ print()
1139
+
1140
+ except Exception as e:
1141
+ print(f"Error listing examples: {e}")
1142
+ print("Examples may not be available (development mode?)")
1143
+
1144
+
1145
+ def print_example_config(name: str):
1146
+ """Print an example config to stdout.
1147
+
1148
+ Args:
1149
+ name: Name of the example (can include or exclude @examples/ prefix)
1150
+ """
1151
+ try:
1152
+ # Remove @examples/ prefix if present
1153
+ if name.startswith("@examples/"):
1154
+ name = name[10:]
1155
+
1156
+ # Try to resolve the config
1157
+ resolved = resolve_config_path(f"@examples/{name}")
1158
+ if resolved:
1159
+ with open(resolved, "r") as f:
1160
+ print(f.read())
1161
+ else:
1162
+ print(f"Error: Could not find example '{name}'", file=sys.stderr)
1163
+ print("Use --list-examples to see available configs", file=sys.stderr)
1164
+ sys.exit(1)
1165
+
1166
+ except ConfigurationError as e:
1167
+ print(f"Error: {e}", file=sys.stderr)
1168
+ sys.exit(1)
1169
+ except Exception as e:
1170
+ print(f"Error printing example config: {e}", file=sys.stderr)
1171
+ sys.exit(1)
1172
+
1173
+
1174
+ def should_run_builder() -> bool:
1175
+ """Check if config builder should run automatically.
1176
+
1177
+ Returns True if:
1178
+ - No default config exists at ~/.config/massgen/config.yaml
1179
+ """
1180
+ default_config = Path.home() / ".config/massgen/config.yaml"
1181
+ return not default_config.exists()
1182
+
1183
+
403
1184
  def print_help_messages():
404
- print(
405
- "\n💬 Type your questions below. Use slash commands or press Ctrl+C to stop.",
406
- flush=True,
1185
+ """Display help messages using Rich for better formatting."""
1186
+ rich_console = Console()
1187
+
1188
+ help_content = """[dim]💬 Type your questions below
1189
+ 💡 Use slash commands: [cyan]/help[/cyan], [cyan]/quit[/cyan], [cyan]/reset[/cyan], [cyan]/status[/cyan], [cyan]/config[/cyan]
1190
+ ⌨️ Press [cyan]Ctrl+C[/cyan] to exit[/dim]"""
1191
+
1192
+ help_panel = Panel(
1193
+ help_content,
1194
+ border_style="dim",
1195
+ padding=(0, 2),
1196
+ width=80,
407
1197
  )
408
- print("💡 Commands: /quit, /exit, /reset, /help", flush=True)
409
- print("=" * 60, flush=True)
1198
+ rich_console.print(help_panel)
410
1199
 
411
1200
 
412
1201
  async def run_interactive_mode(
413
- agents: Dict[str, SingleAgent], ui_config: Dict[str, Any]
1202
+ agents: Dict[str, SingleAgent],
1203
+ ui_config: Dict[str, Any],
1204
+ original_config: Dict[str, Any] = None,
1205
+ orchestrator_cfg: Dict[str, Any] = None,
1206
+ config_path: Optional[str] = None,
1207
+ **kwargs,
414
1208
  ):
415
1209
  """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
1210
+
1211
+ # Use Rich console for better display
1212
+ rich_console = Console()
1213
+
1214
+ # Clear screen
1215
+ rich_console.clear()
1216
+
1217
+ # ASCII art for interactive multi-agent mode
1218
+ ascii_art = """[bold cyan]
1219
+ ███╗ ███╗ █████╗ ███████╗███████╗ ██████╗ ███████╗███╗ ██╗
1220
+ ████╗ ████║██╔══██╗██╔════╝██╔════╝██╔════╝ ██╔════╝████╗ ██║
1221
+ ██╔████╔██║███████║███████╗███████╗██║ ███╗█████╗ ██╔██╗ ██║
1222
+ ██║╚██╔╝██║██╔══██║╚════██║╚════██║██║ ██║██╔══╝ ██║╚██╗██║
1223
+ ██║ ╚═╝ ██║██║ ██║███████║███████║╚██████╔╝███████╗██║ ╚████║
1224
+ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝[/bold cyan]
1225
+
1226
+ [dim] 🤖 🤖 🤖 → 💬 collaborate → 🎯 winner → 📢 final[/dim]
1227
+ """
1228
+
1229
+ # Wrap ASCII art in a panel
1230
+ ascii_panel = Panel(
1231
+ ascii_art,
1232
+ border_style="bold cyan",
1233
+ padding=(0, 2),
1234
+ width=80,
1235
+ )
1236
+ rich_console.print(ascii_panel)
1237
+ print()
1238
+
1239
+ # Create configuration table
1240
+ config_table = Table(
1241
+ show_header=False,
1242
+ box=None,
1243
+ padding=(0, 2),
1244
+ show_edge=False,
428
1245
  )
1246
+ config_table.add_column("Label", style="bold cyan", no_wrap=True)
1247
+ config_table.add_column("Value", style="white")
1248
+
1249
+ # Determine mode
1250
+ ui_config.get("use_orchestrator_for_single_agent", True)
429
1251
  if len(agents) == 1:
430
- mode = (
431
- "Single Agent (Orchestrator)"
432
- if use_orchestrator_for_single
433
- else "Single Agent (Direct)"
434
- )
1252
+ mode = "Single Agent"
1253
+ mode_icon = "🤖"
1254
+ else:
1255
+ mode = f"Multi-Agent ({len(agents)} agents)"
1256
+ mode_icon = "🤝"
1257
+
1258
+ config_table.add_row(f"{mode_icon} Mode:", f"[bold]{mode}[/bold]")
1259
+
1260
+ # Add agents info
1261
+ if len(agents) <= 3:
1262
+ # Show all agents if 3 or fewer
1263
+ for agent_id, agent in agents.items():
1264
+ # Get model name from config
1265
+ model = agent.config.backend_params.get("model", "unknown")
1266
+ backend_name = agent.backend.__class__.__name__.replace("Backend", "")
1267
+ # Show model with backend in parentheses
1268
+ display = f"{model} [dim]({backend_name})[/dim]"
1269
+ config_table.add_row(f" ├─ {agent_id}:", display)
435
1270
  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)
1271
+ # Show count and first 2 agents
1272
+ agent_list = list(agents.items())
1273
+ for i, (agent_id, agent) in enumerate(agent_list[:2]):
1274
+ model = agent.config.backend_params.get("model", "unknown")
1275
+ backend_name = agent.backend.__class__.__name__.replace("Backend", "")
1276
+ display = f"{model} [dim]({backend_name})[/dim]"
1277
+ config_table.add_row(f" ├─ {agent_id}:", display)
1278
+ config_table.add_row(" └─ ...", f"[dim]and {len(agents) - 2} more[/dim]")
1279
+
1280
+ # Create main panel with configuration
1281
+ config_panel = Panel(
1282
+ config_table,
1283
+ title="[bold bright_yellow]⚙️ Session Configuration[/bold bright_yellow]",
1284
+ border_style="yellow",
1285
+ padding=(0, 2),
1286
+ width=80,
1287
+ )
1288
+ rich_console.print(config_panel)
1289
+ print()
1290
+
1291
+ # Prompt for context paths if filesystem is enabled
1292
+ if original_config and orchestrator_cfg:
1293
+ config_modified = prompt_for_context_paths(original_config, orchestrator_cfg)
1294
+ if config_modified:
1295
+ # Recreate agents with updated context paths
1296
+ agents = create_agents_from_config(original_config, orchestrator_cfg)
1297
+ print(f" {BRIGHT_GREEN}✓ Agents reloaded with updated context paths{RESET}", flush=True)
1298
+ print()
439
1299
 
440
1300
  print_help_messages()
441
1301
 
442
1302
  # Maintain conversation history
443
1303
  conversation_history = []
444
1304
 
1305
+ # Session management for multi-turn filesystem support
1306
+ session_id = None
1307
+ current_turn = 0
1308
+ session_storage = kwargs.get("orchestrator", {}).get("session_storage", "sessions")
1309
+
445
1310
  try:
446
1311
  while True:
447
1312
  try:
1313
+ # Recreate agents with previous turn as read-only context path.
1314
+ # This provides agents with BOTH:
1315
+ # 1. Read-only context path (original turn n-1 results) - for reference/comparison
1316
+ # 2. Writable workspace (copy of turn n-1 results, pre-populated by orchestrator) - for modification
1317
+ # This allows agents to compare "what I changed" vs "what was originally there".
1318
+ # TODO: We may want to avoid full recreation if possible in the future, conditioned on being able to easily reset MCPs.
1319
+ if current_turn > 0 and original_config and orchestrator_cfg:
1320
+ # Get the most recent turn path (the one just completed)
1321
+ session_dir = Path(session_storage) / session_id
1322
+ latest_turn_dir = session_dir / f"turn_{current_turn}"
1323
+ latest_turn_workspace = latest_turn_dir / "workspace"
1324
+
1325
+ if latest_turn_workspace.exists():
1326
+ logger.info(f"[CLI] Recreating agents with turn {current_turn} workspace as read-only context path")
1327
+
1328
+ # Clean up existing agents' backends and filesystem managers
1329
+ for agent_id, agent in agents.items():
1330
+ # Cleanup filesystem manager (Docker containers, etc.)
1331
+ if hasattr(agent, "backend") and hasattr(agent.backend, "filesystem_manager"):
1332
+ if agent.backend.filesystem_manager:
1333
+ try:
1334
+ agent.backend.filesystem_manager.cleanup()
1335
+ except Exception as e:
1336
+ logger.warning(f"[CLI] Cleanup failed for agent {agent_id}: {e}")
1337
+
1338
+ # Cleanup backend itself
1339
+ if hasattr(agent.backend, "__aexit__"):
1340
+ await agent.backend.__aexit__(None, None, None)
1341
+
1342
+ # Inject previous turn path as read-only context
1343
+ modified_config = original_config.copy()
1344
+ agent_entries = [modified_config["agent"]] if "agent" in modified_config else modified_config.get("agents", [])
1345
+
1346
+ for agent_data in agent_entries:
1347
+ backend_config = agent_data.get("backend", {})
1348
+ if "cwd" in backend_config: # Only inject if agent has filesystem support
1349
+ existing_context_paths = backend_config.get("context_paths", [])
1350
+ new_turn_config = {"path": str(latest_turn_workspace.resolve()), "permission": "read"}
1351
+ backend_config["context_paths"] = existing_context_paths + [new_turn_config]
1352
+
1353
+ # Recreate agents from modified config
1354
+ agents = create_agents_from_config(modified_config, orchestrator_cfg)
1355
+ logger.info(f"[CLI] Successfully recreated {len(agents)} agents with turn {current_turn} path as read-only context")
1356
+
448
1357
  question = input(f"\n{BRIGHT_BLUE}👤 User:{RESET} ").strip()
449
1358
 
450
1359
  # Handle slash commands
@@ -465,9 +1374,7 @@ async def run_interactive_mode(
465
1374
  )
466
1375
  continue
467
1376
  elif command in ["/help", "/h"]:
468
- print(
469
- f"\n{BRIGHT_CYAN}📚 Available Commands:{RESET}", flush=True
470
- )
1377
+ print(f"\n{BRIGHT_CYAN}📚 Available Commands:{RESET}", flush=True)
471
1378
  print(" /quit, /exit, /q - Exit the program", flush=True)
472
1379
  print(
473
1380
  " /reset, /clear - Clear conversation history",
@@ -477,9 +1384,8 @@ async def run_interactive_mode(
477
1384
  " /help, /h - Show this help message",
478
1385
  flush=True,
479
1386
  )
480
- print(
481
- " /status - Show current status", flush=True
482
- )
1387
+ print(" /status - Show current status", flush=True)
1388
+ print(" /config - Open config file in editor", flush=True)
483
1389
  continue
484
1390
  elif command == "/status":
485
1391
  print(f"\n{BRIGHT_CYAN}📊 Current Status:{RESET}", flush=True)
@@ -487,15 +1393,9 @@ async def run_interactive_mode(
487
1393
  f" Agents: {len(agents)} ({', '.join(agents.keys())})",
488
1394
  flush=True,
489
1395
  )
490
- use_orch_single = ui_config.get(
491
- "use_orchestrator_for_single_agent", True
492
- )
1396
+ use_orch_single = ui_config.get("use_orchestrator_for_single_agent", True)
493
1397
  if len(agents) == 1:
494
- mode_display = (
495
- "Single Agent (Orchestrator)"
496
- if use_orch_single
497
- else "Single Agent (Direct)"
498
- )
1398
+ mode_display = "Single Agent (Orchestrator)" if use_orch_single else "Single Agent (Direct)"
499
1399
  else:
500
1400
  mode_display = "Multi-Agent"
501
1401
  print(f" Mode: {mode_display}", flush=True)
@@ -503,6 +1403,28 @@ async def run_interactive_mode(
503
1403
  f" History: {len(conversation_history)//2} exchanges",
504
1404
  flush=True,
505
1405
  )
1406
+ if config_path:
1407
+ print(f" Config: {config_path}", flush=True)
1408
+ continue
1409
+ elif command == "/config":
1410
+ if config_path:
1411
+ import platform
1412
+ import subprocess
1413
+
1414
+ try:
1415
+ system = platform.system()
1416
+ if system == "Darwin": # macOS
1417
+ subprocess.run(["open", config_path])
1418
+ elif system == "Windows":
1419
+ subprocess.run(["start", config_path], shell=True)
1420
+ else: # Linux and others
1421
+ subprocess.run(["xdg-open", config_path])
1422
+ print(f"\n📝 Opening config file: {config_path}", flush=True)
1423
+ except Exception as e:
1424
+ print(f"\n❌ Error opening config file: {e}", flush=True)
1425
+ print(f" Config location: {config_path}", flush=True)
1426
+ else:
1427
+ print("\n❌ No config file available (using CLI arguments)", flush=True)
506
1428
  continue
507
1429
  else:
508
1430
  print(f"❓ Unknown command: {command}", flush=True)
@@ -530,16 +1452,40 @@ async def run_interactive_mode(
530
1452
 
531
1453
  print(f"\n🔄 {BRIGHT_YELLOW}Processing...{RESET}", flush=True)
532
1454
 
533
- response = await run_question_with_history(
534
- question, agents, ui_config, conversation_history
1455
+ # Increment turn counter BEFORE processing so logs go to correct turn_N directory
1456
+ next_turn = current_turn + 1
1457
+
1458
+ # Initialize session ID on first turn
1459
+ if session_id is None:
1460
+ session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
1461
+
1462
+ # Reconfigure logging for the turn we're about to process
1463
+ setup_logging(debug=_DEBUG_MODE, turn=next_turn)
1464
+ logger.info(f"Starting turn {next_turn}")
1465
+
1466
+ # Pass session state for multi-turn filesystem support
1467
+ session_info = {
1468
+ "session_id": session_id,
1469
+ "current_turn": current_turn, # Pass CURRENT turn (for looking up previous turns)
1470
+ "session_storage": session_storage,
1471
+ }
1472
+ response, updated_session_id, updated_turn = await run_question_with_history(
1473
+ question,
1474
+ agents,
1475
+ ui_config,
1476
+ conversation_history,
1477
+ session_info,
1478
+ **kwargs,
535
1479
  )
536
1480
 
1481
+ # Update session state after completion
1482
+ session_id = updated_session_id
1483
+ current_turn = updated_turn
1484
+
537
1485
  if response:
538
1486
  # Add to conversation history
539
1487
  conversation_history.append({"role": "user", "content": question})
540
- conversation_history.append(
541
- {"role": "assistant", "content": response}
542
- )
1488
+ conversation_history.append({"role": "assistant", "content": response})
543
1489
  print(f"\n{BRIGHT_GREEN}✅ Complete!{RESET}", flush=True)
544
1490
  print(
545
1491
  f"{BRIGHT_CYAN}💭 History: {len(conversation_history)//2} exchanges{RESET}",
@@ -560,8 +1506,180 @@ async def run_interactive_mode(
560
1506
  except KeyboardInterrupt:
561
1507
  print("\n👋 Goodbye!")
562
1508
 
563
- async def main():
564
- """Main CLI entry point."""
1509
+
1510
+ async def main(args):
1511
+ """Main CLI entry point (async operations only)."""
1512
+ # Check if bare `massgen` with no args - use default config if it exists
1513
+ if not args.backend and not args.model and not args.config:
1514
+ # Use resolve_config_path to check project-level then global config
1515
+ resolved_default = resolve_config_path(None)
1516
+ if resolved_default:
1517
+ # Use discovered config for interactive mode (no question) or single query (with question)
1518
+ args.config = str(resolved_default)
1519
+ else:
1520
+ # No default config - this will be handled by wizard trigger in cli_main()
1521
+ if args.question:
1522
+ # User provided a question but no config exists - this is an error
1523
+ print("❌ Configuration error: No default configuration found.", flush=True)
1524
+ print("Run 'massgen --init' to create one, or use 'massgen --model MODEL \"question\"'", flush=True)
1525
+ sys.exit(1)
1526
+ # No question and no config - wizard will be triggered in cli_main()
1527
+ return
1528
+
1529
+ # Validate arguments (only if we didn't auto-set config above)
1530
+ if not args.backend:
1531
+ if not args.model and not args.config:
1532
+ print("❌ Configuration error: Either --config, --model, or --backend must be specified", flush=True)
1533
+ sys.exit(1)
1534
+
1535
+ try:
1536
+ # Load or create configuration
1537
+ if args.config:
1538
+ # Resolve config path (handles @examples/, paths, ~/.config/massgen/agents/)
1539
+ resolved_path = resolve_config_path(args.config)
1540
+ if resolved_path is None:
1541
+ # This shouldn't happen if we reached here, but handle it
1542
+ raise ConfigurationError("Could not resolve config path")
1543
+ config = load_config_file(str(resolved_path))
1544
+ if args.debug:
1545
+ logger.debug(f"Resolved config path: {resolved_path}")
1546
+ logger.debug(f"Config content: {json.dumps(config, indent=2)}")
1547
+ else:
1548
+ model = args.model
1549
+ if args.backend:
1550
+ backend = args.backend
1551
+ else:
1552
+ backend = get_backend_type_from_model(model=model)
1553
+ if args.system_message:
1554
+ system_message = args.system_message
1555
+ else:
1556
+ system_message = None
1557
+ config = create_simple_config(
1558
+ backend_type=backend,
1559
+ model=model,
1560
+ system_message=system_message,
1561
+ base_url=args.base_url,
1562
+ )
1563
+ if args.debug:
1564
+ logger.debug(f"Created simple config with backend: {backend}, model: {model}")
1565
+ logger.debug(f"Config content: {json.dumps(config, indent=2)}")
1566
+
1567
+ # Validate that all context paths exist before proceeding
1568
+ validate_context_paths(config)
1569
+
1570
+ # Relocate all filesystem paths to .massgen/ directory
1571
+ relocate_filesystem_paths(config)
1572
+
1573
+ # Apply command-line overrides
1574
+ ui_config = config.get("ui", {})
1575
+ if args.no_display:
1576
+ ui_config["display_type"] = "simple"
1577
+ if args.no_logs:
1578
+ ui_config["logging_enabled"] = False
1579
+ if args.debug:
1580
+ ui_config["debug"] = True
1581
+ # Enable logging if debug is on
1582
+ ui_config["logging_enabled"] = True
1583
+ # # Force simple UI in debug mode
1584
+ # ui_config["display_type"] = "simple"
1585
+
1586
+ # Apply timeout overrides from CLI arguments
1587
+ timeout_settings = config.get("timeout_settings", {})
1588
+ if args.orchestrator_timeout is not None:
1589
+ timeout_settings["orchestrator_timeout_seconds"] = args.orchestrator_timeout
1590
+
1591
+ # Update config with timeout settings
1592
+ config["timeout_settings"] = timeout_settings
1593
+
1594
+ # Create agents
1595
+ if args.debug:
1596
+ logger.debug("Creating agents from config...")
1597
+ # Extract orchestrator config for agent setup
1598
+ orchestrator_cfg = config.get("orchestrator", {})
1599
+
1600
+ # Check if any agent has cwd (filesystem support) and validate orchestrator config
1601
+ agent_entries = [config["agent"]] if "agent" in config else config.get("agents", [])
1602
+ has_cwd = any("cwd" in agent.get("backend", {}) for agent in agent_entries)
1603
+
1604
+ if has_cwd:
1605
+ if not orchestrator_cfg:
1606
+ raise ConfigurationError(
1607
+ "Agents with 'cwd' (filesystem support) require orchestrator configuration.\n"
1608
+ "Please add an 'orchestrator' section to your config file.\n\n"
1609
+ "Example (customize paths as needed):\n"
1610
+ "orchestrator:\n"
1611
+ ' snapshot_storage: "your_snapshot_dir"\n'
1612
+ ' agent_temporary_workspace: "your_temp_dir"',
1613
+ )
1614
+
1615
+ # Check for required fields in orchestrator config
1616
+ if "snapshot_storage" not in orchestrator_cfg:
1617
+ raise ConfigurationError(
1618
+ "Missing 'snapshot_storage' in orchestrator configuration.\n"
1619
+ "This is required for agents with filesystem support (cwd).\n\n"
1620
+ "Add to your orchestrator section:\n"
1621
+ ' snapshot_storage: "your_snapshot_dir" # Directory for workspace snapshots',
1622
+ )
1623
+
1624
+ if "agent_temporary_workspace" not in orchestrator_cfg:
1625
+ raise ConfigurationError(
1626
+ "Missing 'agent_temporary_workspace' in orchestrator configuration.\n"
1627
+ "This is required for agents with filesystem support (cwd).\n\n"
1628
+ "Add to your orchestrator section:\n"
1629
+ ' agent_temporary_workspace: "your_temp_dir" # Directory for temporary agent workspaces',
1630
+ )
1631
+
1632
+ agents = create_agents_from_config(config, orchestrator_cfg)
1633
+
1634
+ if not agents:
1635
+ raise ConfigurationError("No agents configured")
1636
+
1637
+ if args.debug:
1638
+ logger.debug(f"Created {len(agents)} agent(s): {list(agents.keys())}")
1639
+
1640
+ # Create timeout config from settings and put it in kwargs
1641
+ timeout_settings = config.get("timeout_settings", {})
1642
+ timeout_config = TimeoutConfig(**timeout_settings) if timeout_settings else TimeoutConfig()
1643
+
1644
+ kwargs = {"timeout_config": timeout_config}
1645
+
1646
+ # Add orchestrator configuration if present
1647
+ if "orchestrator" in config:
1648
+ kwargs["orchestrator"] = config["orchestrator"]
1649
+
1650
+ # Run mode based on whether question was provided
1651
+ try:
1652
+ if args.question:
1653
+ await run_single_question(args.question, agents, ui_config, **kwargs)
1654
+ # if response:
1655
+ # print(f"\n{BRIGHT_GREEN}Final Response:{RESET}", flush=True)
1656
+ # print(f"{response}", flush=True)
1657
+ else:
1658
+ # Pass the config path to interactive mode
1659
+ config_file_path = str(resolved_path) if args.config and resolved_path else None
1660
+ await run_interactive_mode(agents, ui_config, original_config=config, orchestrator_cfg=orchestrator_cfg, config_path=config_file_path, **kwargs)
1661
+ finally:
1662
+ # Cleanup all agents' filesystem managers (including Docker containers)
1663
+ for agent_id, agent in agents.items():
1664
+ if hasattr(agent, "backend") and hasattr(agent.backend, "filesystem_manager"):
1665
+ if agent.backend.filesystem_manager:
1666
+ try:
1667
+ agent.backend.filesystem_manager.cleanup()
1668
+ except Exception as e:
1669
+ logger.warning(f"[CLI] Cleanup failed for agent {agent_id}: {e}")
1670
+
1671
+ except ConfigurationError as e:
1672
+ print(f"❌ Configuration error: {e}", flush=True)
1673
+ sys.exit(1)
1674
+ except KeyboardInterrupt:
1675
+ print("\n👋 Goodbye!", flush=True)
1676
+ except Exception as e:
1677
+ print(f"❌ Error: {e}", flush=True)
1678
+ sys.exit(1)
1679
+
1680
+
1681
+ def cli_main():
1682
+ """Synchronous wrapper for CLI entry point."""
565
1683
  parser = argparse.ArgumentParser(
566
1684
  description="MassGen - Multi-Agent Coordination CLI",
567
1685
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -569,21 +1687,40 @@ async def main():
569
1687
  Examples:
570
1688
  # Use configuration file
571
1689
  python -m massgen.cli --config config.yaml "What is machine learning?"
572
-
1690
+
573
1691
  # Quick single agent setup
574
1692
  python -m massgen.cli --backend openai --model gpt-4o-mini "Explain quantum computing"
575
1693
  python -m massgen.cli --backend claude --model claude-sonnet-4-20250514 "Analyze this data"
576
-
1694
+
1695
+ # Use ChatCompletion backend with custom base URL
1696
+ python -m massgen.cli --backend chatcompletion --model gpt-oss-120b --base-url https://api.cerebras.ai/v1/chat/completions "What is 2+2?"
1697
+
577
1698
  # Interactive mode
578
1699
  python -m massgen.cli --config config.yaml
579
-
1700
+
1701
+ # Timeout control examples
1702
+ python -m massgen.cli --config config.yaml --orchestrator-timeout 600 "Complex task"
1703
+
580
1704
  # Create sample configurations
581
1705
  python -m massgen.cli --create-samples
582
1706
 
583
1707
  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
1708
+ OPENAI_API_KEY - Required for OpenAI backend
1709
+ XAI_API_KEY - Required for Grok backend
1710
+ ANTHROPIC_API_KEY - Required for Claude backend
1711
+ GOOGLE_API_KEY - Required for Gemini backend (or GEMINI_API_KEY)
1712
+ ZAI_API_KEY - Required for ZAI backend
1713
+
1714
+ CEREBRAS_API_KEY - For Cerebras AI (cerebras.ai)
1715
+ TOGETHER_API_KEY - For Together AI (together.ai, together.xyz)
1716
+ FIREWORKS_API_KEY - For Fireworks AI (fireworks.ai)
1717
+ GROQ_API_KEY - For Groq (groq.com)
1718
+ NEBIUS_API_KEY - For Nebius AI Studio (studio.nebius.ai)
1719
+ OPENROUTER_API_KEY - For OpenRouter (openrouter.ai)
1720
+ POE_API_KEY - For POE (poe.com)
1721
+
1722
+ Note: The chatcompletion backend auto-detects the provider from the base_url
1723
+ and uses the appropriate environment variable for API key.
587
1724
  """,
588
1725
  )
589
1726
 
@@ -596,13 +1733,23 @@ Environment Variables:
596
1733
 
597
1734
  # Configuration options
598
1735
  config_group = parser.add_mutually_exclusive_group()
599
- config_group.add_argument(
600
- "--config", type=str, help="Path to YAML/JSON configuration file"
601
- )
1736
+ config_group.add_argument("--config", type=str, help="Path to YAML/JSON configuration file or @examples/NAME")
602
1737
  config_group.add_argument(
603
1738
  "--backend",
604
1739
  type=str,
605
- choices=["openai", "grok", "claude", "gemini"],
1740
+ choices=[
1741
+ "chatcompletion",
1742
+ "claude",
1743
+ "gemini",
1744
+ "grok",
1745
+ "openai",
1746
+ "azure_openai",
1747
+ "claude_code",
1748
+ "zai",
1749
+ "lmstudio",
1750
+ "vllm",
1751
+ "sglang",
1752
+ ],
606
1753
  help="Backend type for quick setup",
607
1754
  )
608
1755
 
@@ -610,77 +1757,147 @@ Environment Variables:
610
1757
  parser.add_argument(
611
1758
  "--model",
612
1759
  type=str,
613
- default="gpt-4o-mini",
614
- help="Model name for quick setup (default: gpt-4o-mini)",
1760
+ default=None,
1761
+ help="Model name for quick setup",
615
1762
  )
1763
+ parser.add_argument("--system-message", type=str, help="System message for quick setup")
616
1764
  parser.add_argument(
617
- "--system-message", type=str, help="System message for quick setup"
1765
+ "--base-url",
1766
+ type=str,
1767
+ help="Base URL for API endpoint (e.g., https://api.cerebras.ai/v1/chat/completions)",
618
1768
  )
619
1769
 
620
1770
  # UI options
1771
+ parser.add_argument("--no-display", action="store_true", help="Disable visual coordination display")
1772
+ parser.add_argument("--no-logs", action="store_true", help="Disable logging")
1773
+ parser.add_argument("--debug", action="store_true", help="Enable debug mode with verbose logging")
621
1774
  parser.add_argument(
622
- "--no-display", action="store_true", help="Disable visual coordination display"
1775
+ "--init",
1776
+ action="store_true",
1777
+ help="Launch interactive configuration builder to create config file",
1778
+ )
1779
+ parser.add_argument(
1780
+ "--list-examples",
1781
+ action="store_true",
1782
+ help="List available example configurations from package",
1783
+ )
1784
+ parser.add_argument(
1785
+ "--example",
1786
+ type=str,
1787
+ help="Print example config to stdout (e.g., --example basic_multi)",
1788
+ )
1789
+ parser.add_argument(
1790
+ "--show-schema",
1791
+ action="store_true",
1792
+ help="Display configuration schema and available parameters",
1793
+ )
1794
+ parser.add_argument(
1795
+ "--schema-backend",
1796
+ type=str,
1797
+ help="Show schema for specific backend (use with --show-schema)",
1798
+ )
1799
+ parser.add_argument(
1800
+ "--with-examples",
1801
+ action="store_true",
1802
+ help="Include example configurations in schema display",
623
1803
  )
624
- parser.add_argument("--no-logs", action="store_true", help="Disable logging")
625
1804
 
626
- args = parser.parse_args()
1805
+ # Timeout options
1806
+ timeout_group = parser.add_argument_group("timeout settings", "Override timeout settings from config")
1807
+ timeout_group.add_argument(
1808
+ "--orchestrator-timeout",
1809
+ type=int,
1810
+ help="Maximum time for orchestrator coordination in seconds (default: 1800)",
1811
+ )
627
1812
 
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
- )
1813
+ args = parser.parse_args()
634
1814
 
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
1815
+ # Always setup logging (will save INFO to file, console output depends on debug flag)
1816
+ setup_logging(debug=args.debug)
1817
+
1818
+ if args.debug:
1819
+ logger.info("Debug mode enabled")
1820
+ logger.debug(f"Command line arguments: {vars(args)}")
1821
+
1822
+ # Handle special commands first
1823
+ if args.list_examples:
1824
+ show_available_examples()
1825
+ return
1826
+
1827
+ if args.example:
1828
+ print_example_config(args.example)
1829
+ return
1830
+
1831
+ if args.show_schema:
1832
+ from .schema_display import show_schema
1833
+
1834
+ show_schema(backend=args.schema_backend, show_examples=args.with_examples)
1835
+ return
1836
+
1837
+ # Launch interactive config builder if requested
1838
+ if args.init:
1839
+ from .config_builder import ConfigBuilder
1840
+
1841
+ builder = ConfigBuilder()
1842
+ result = builder.run()
1843
+
1844
+ if result and len(result) == 2:
1845
+ filepath, question = result
1846
+ if filepath and question:
1847
+ # Update args to use the newly created config
1848
+ args.config = filepath
1849
+ args.question = question
1850
+ elif filepath:
1851
+ # Config created but user chose not to run
1852
+ print(f"\n✅ Configuration saved to: {filepath}")
1853
+ print(f'Run with: python -m massgen.cli --config {filepath} "Your question"')
1854
+ return
647
1855
  else:
648
- system_message = None
649
- config = create_simple_config(
650
- backend_type=backend, model=model, system_message=system_message
651
- )
652
-
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
659
-
660
- # Create agents
661
- agents = create_agents_from_config(config)
662
-
663
- if not agents:
664
- raise ConfigurationError("No agents configured")
665
-
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)
1856
+ # User cancelled
1857
+ return
672
1858
  else:
673
- await run_interactive_mode(agents, ui_config)
1859
+ # Builder returned None (cancelled or error)
1860
+ return
1861
+
1862
+ # First-run detection: auto-trigger builder if no config specified and first run
1863
+ if not args.question and not args.config and not args.model and not args.backend:
1864
+ if should_run_builder():
1865
+ print()
1866
+ print()
1867
+ print(f"{BRIGHT_CYAN}{'=' * 60}{RESET}")
1868
+ print(f"{BRIGHT_CYAN} 👋 Welcome to MassGen!{RESET}")
1869
+ print(f"{BRIGHT_CYAN}{'=' * 60}{RESET}")
1870
+ print()
1871
+ print(" Let's set up your default configuration...")
1872
+ print()
1873
+
1874
+ from .config_builder import ConfigBuilder
1875
+
1876
+ builder = ConfigBuilder(default_mode=True)
1877
+ result = builder.run()
1878
+
1879
+ if result and len(result) == 2:
1880
+ filepath, question = result
1881
+ if filepath:
1882
+ args.config = filepath
1883
+ if question:
1884
+ args.question = question
1885
+ else:
1886
+ print("\n✅ Configuration saved! You can now run queries.")
1887
+ print('Example: massgen "Your question here"')
1888
+ return
1889
+ else:
1890
+ return
1891
+ else:
1892
+ return
674
1893
 
675
- except ConfigurationError as e:
676
- print(f"❌ Configuration error: {e}", flush=True)
677
- sys.exit(1)
1894
+ # Now call the async main with the parsed arguments
1895
+ try:
1896
+ asyncio.run(main(args))
678
1897
  except KeyboardInterrupt:
679
- print("\n👋 Goodbye!", flush=True)
680
- except Exception as e:
681
- print(f"❌ Error: {e}", flush=True)
682
- sys.exit(1)
1898
+ # User pressed Ctrl+C - exit gracefully without traceback
1899
+ pass
683
1900
 
684
1901
 
685
1902
  if __name__ == "__main__":
686
- asyncio.run(main())
1903
+ cli_main()