massgen 0.0.3__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (268) hide show
  1. massgen/__init__.py +142 -8
  2. massgen/adapters/__init__.py +29 -0
  3. massgen/adapters/ag2_adapter.py +483 -0
  4. massgen/adapters/base.py +183 -0
  5. massgen/adapters/tests/__init__.py +0 -0
  6. massgen/adapters/tests/test_ag2_adapter.py +439 -0
  7. massgen/adapters/tests/test_agent_adapter.py +128 -0
  8. massgen/adapters/utils/__init__.py +2 -0
  9. massgen/adapters/utils/ag2_utils.py +236 -0
  10. massgen/adapters/utils/tests/__init__.py +0 -0
  11. massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
  12. massgen/agent_config.py +329 -55
  13. massgen/api_params_handler/__init__.py +10 -0
  14. massgen/api_params_handler/_api_params_handler_base.py +99 -0
  15. massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
  16. massgen/api_params_handler/_claude_api_params_handler.py +113 -0
  17. massgen/api_params_handler/_response_api_params_handler.py +130 -0
  18. massgen/backend/__init__.py +39 -4
  19. massgen/backend/azure_openai.py +385 -0
  20. massgen/backend/base.py +341 -69
  21. massgen/backend/base_with_mcp.py +1102 -0
  22. massgen/backend/capabilities.py +386 -0
  23. massgen/backend/chat_completions.py +577 -130
  24. massgen/backend/claude.py +1033 -537
  25. massgen/backend/claude_code.py +1203 -0
  26. massgen/backend/cli_base.py +209 -0
  27. massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
  28. massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
  29. massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
  30. massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
  31. massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
  32. massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
  33. massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
  34. massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
  35. massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
  36. massgen/backend/docs/inference_backend.md +257 -0
  37. massgen/backend/docs/permissions_and_context_files.md +1085 -0
  38. massgen/backend/external.py +126 -0
  39. massgen/backend/gemini.py +1850 -241
  40. massgen/backend/grok.py +40 -156
  41. massgen/backend/inference.py +156 -0
  42. massgen/backend/lmstudio.py +171 -0
  43. massgen/backend/response.py +1095 -322
  44. massgen/chat_agent.py +131 -113
  45. massgen/cli.py +1560 -275
  46. massgen/config_builder.py +2396 -0
  47. massgen/configs/BACKEND_CONFIGURATION.md +458 -0
  48. massgen/configs/README.md +559 -216
  49. massgen/configs/ag2/ag2_case_study.yaml +27 -0
  50. massgen/configs/ag2/ag2_coder.yaml +34 -0
  51. massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
  52. massgen/configs/ag2/ag2_gemini.yaml +27 -0
  53. massgen/configs/ag2/ag2_groupchat.yaml +108 -0
  54. massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
  55. massgen/configs/ag2/ag2_single_agent.yaml +21 -0
  56. massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
  57. massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
  58. massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
  59. massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
  60. massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
  61. massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
  62. massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
  63. massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
  64. massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
  65. massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
  66. massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
  67. massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
  68. massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
  69. massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
  70. massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
  71. massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
  72. massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
  73. massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
  74. massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
  75. massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
  76. massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
  77. massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
  78. massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
  79. massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
  80. massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
  81. massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
  82. massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
  83. massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
  84. massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
  85. massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
  86. massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
  87. massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
  88. massgen/configs/debug/skip_coordination_test.yaml +27 -0
  89. massgen/configs/debug/test_sdk_migration.yaml +17 -0
  90. massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
  91. massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
  92. massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
  93. massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
  94. massgen/configs/providers/claude/claude.yaml +14 -0
  95. massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
  96. massgen/configs/providers/local/lmstudio.yaml +11 -0
  97. massgen/configs/providers/openai/gpt5.yaml +46 -0
  98. massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
  99. massgen/configs/providers/others/grok_single_agent.yaml +19 -0
  100. massgen/configs/providers/others/zai_coding_team.yaml +108 -0
  101. massgen/configs/providers/others/zai_glm45.yaml +12 -0
  102. massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
  103. massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
  104. massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
  105. massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
  106. massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
  107. massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
  108. massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
  109. massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
  110. massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
  111. massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
  112. massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
  113. massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
  114. massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
  115. massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
  116. massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
  117. massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
  118. massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
  119. massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
  120. massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
  121. massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
  122. massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
  123. massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
  124. massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
  125. massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
  126. massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
  127. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
  128. massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
  129. massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
  130. massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
  131. massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
  132. massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
  133. massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
  134. massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
  135. massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
  136. massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
  137. massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
  138. massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
  139. massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
  140. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
  141. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
  142. massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
  143. massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
  144. massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
  145. massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
  146. massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
  147. massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
  148. massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
  149. massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
  150. massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
  151. massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
  152. massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
  153. massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
  154. massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
  155. massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
  156. massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
  157. massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
  158. massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
  159. massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
  160. massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
  161. massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
  162. massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
  163. massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
  164. massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
  165. massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
  166. massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
  167. massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
  168. massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
  169. massgen/coordination_tracker.py +708 -0
  170. massgen/docker/README.md +462 -0
  171. massgen/filesystem_manager/__init__.py +21 -0
  172. massgen/filesystem_manager/_base.py +9 -0
  173. massgen/filesystem_manager/_code_execution_server.py +545 -0
  174. massgen/filesystem_manager/_docker_manager.py +477 -0
  175. massgen/filesystem_manager/_file_operation_tracker.py +248 -0
  176. massgen/filesystem_manager/_filesystem_manager.py +813 -0
  177. massgen/filesystem_manager/_path_permission_manager.py +1261 -0
  178. massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
  179. massgen/formatter/__init__.py +10 -0
  180. massgen/formatter/_chat_completions_formatter.py +284 -0
  181. massgen/formatter/_claude_formatter.py +235 -0
  182. massgen/formatter/_formatter_base.py +156 -0
  183. massgen/formatter/_response_formatter.py +263 -0
  184. massgen/frontend/__init__.py +1 -2
  185. massgen/frontend/coordination_ui.py +471 -286
  186. massgen/frontend/displays/base_display.py +56 -11
  187. massgen/frontend/displays/create_coordination_table.py +1956 -0
  188. massgen/frontend/displays/rich_terminal_display.py +1259 -619
  189. massgen/frontend/displays/simple_display.py +9 -4
  190. massgen/frontend/displays/terminal_display.py +27 -68
  191. massgen/logger_config.py +681 -0
  192. massgen/mcp_tools/README.md +232 -0
  193. massgen/mcp_tools/__init__.py +105 -0
  194. massgen/mcp_tools/backend_utils.py +1035 -0
  195. massgen/mcp_tools/circuit_breaker.py +195 -0
  196. massgen/mcp_tools/client.py +894 -0
  197. massgen/mcp_tools/config_validator.py +138 -0
  198. massgen/mcp_tools/docs/circuit_breaker.md +646 -0
  199. massgen/mcp_tools/docs/client.md +950 -0
  200. massgen/mcp_tools/docs/config_validator.md +478 -0
  201. massgen/mcp_tools/docs/exceptions.md +1165 -0
  202. massgen/mcp_tools/docs/security.md +854 -0
  203. massgen/mcp_tools/exceptions.py +338 -0
  204. massgen/mcp_tools/hooks.py +212 -0
  205. massgen/mcp_tools/security.py +780 -0
  206. massgen/message_templates.py +342 -64
  207. massgen/orchestrator.py +1515 -241
  208. massgen/stream_chunk/__init__.py +35 -0
  209. massgen/stream_chunk/base.py +92 -0
  210. massgen/stream_chunk/multimodal.py +237 -0
  211. massgen/stream_chunk/text.py +162 -0
  212. massgen/tests/mcp_test_server.py +150 -0
  213. massgen/tests/multi_turn_conversation_design.md +0 -8
  214. massgen/tests/test_azure_openai_backend.py +156 -0
  215. massgen/tests/test_backend_capabilities.py +262 -0
  216. massgen/tests/test_backend_event_loop_all.py +179 -0
  217. massgen/tests/test_chat_completions_refactor.py +142 -0
  218. massgen/tests/test_claude_backend.py +15 -28
  219. massgen/tests/test_claude_code.py +268 -0
  220. massgen/tests/test_claude_code_context_sharing.py +233 -0
  221. massgen/tests/test_claude_code_orchestrator.py +175 -0
  222. massgen/tests/test_cli_backends.py +180 -0
  223. massgen/tests/test_code_execution.py +679 -0
  224. massgen/tests/test_external_agent_backend.py +134 -0
  225. massgen/tests/test_final_presentation_fallback.py +237 -0
  226. massgen/tests/test_gemini_planning_mode.py +351 -0
  227. massgen/tests/test_grok_backend.py +7 -10
  228. massgen/tests/test_http_mcp_server.py +42 -0
  229. massgen/tests/test_integration_simple.py +198 -0
  230. massgen/tests/test_mcp_blocking.py +125 -0
  231. massgen/tests/test_message_context_building.py +29 -47
  232. massgen/tests/test_orchestrator_final_presentation.py +48 -0
  233. massgen/tests/test_path_permission_manager.py +2087 -0
  234. massgen/tests/test_rich_terminal_display.py +14 -13
  235. massgen/tests/test_timeout.py +133 -0
  236. massgen/tests/test_v3_3agents.py +11 -12
  237. massgen/tests/test_v3_simple.py +8 -13
  238. massgen/tests/test_v3_three_agents.py +11 -18
  239. massgen/tests/test_v3_two_agents.py +8 -13
  240. massgen/token_manager/__init__.py +7 -0
  241. massgen/token_manager/token_manager.py +400 -0
  242. massgen/utils.py +52 -16
  243. massgen/v1/agent.py +45 -91
  244. massgen/v1/agents.py +18 -53
  245. massgen/v1/backends/gemini.py +50 -153
  246. massgen/v1/backends/grok.py +21 -54
  247. massgen/v1/backends/oai.py +39 -111
  248. massgen/v1/cli.py +36 -93
  249. massgen/v1/config.py +8 -12
  250. massgen/v1/logging.py +43 -127
  251. massgen/v1/main.py +18 -32
  252. massgen/v1/orchestrator.py +68 -209
  253. massgen/v1/streaming_display.py +62 -163
  254. massgen/v1/tools.py +8 -12
  255. massgen/v1/types.py +9 -23
  256. massgen/v1/utils.py +5 -23
  257. massgen-0.1.0.dist-info/METADATA +1245 -0
  258. massgen-0.1.0.dist-info/RECORD +273 -0
  259. massgen-0.1.0.dist-info/entry_points.txt +2 -0
  260. massgen/frontend/logging/__init__.py +0 -9
  261. massgen/frontend/logging/realtime_logger.py +0 -197
  262. massgen-0.0.3.dist-info/METADATA +0 -568
  263. massgen-0.0.3.dist-info/RECORD +0 -76
  264. massgen-0.0.3.dist-info/entry_points.txt +0 -2
  265. /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
  266. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
  267. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
  268. {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/top_level.txt +0 -0
massgen/orchestrator.py CHANGED
@@ -1,17 +1,50 @@
1
+ # -*- coding: utf-8 -*-
1
2
  """
2
3
  MassGen Orchestrator Agent - Chat interface that manages sub-agents internally.
3
4
 
4
5
  The orchestrator presents a unified chat interface to users while coordinating
5
6
  multiple sub-agents using the proven binary decision framework behind the scenes.
7
+
8
+ TODOs:
9
+
10
+ - Move CLI's coordinate_with_context logic to orchestrator and simplify CLI to just use orchestrator
11
+ - Implement orchestrator system message functionality to customize coordination behavior:
12
+
13
+ * Custom voting strategies (consensus, expertise-weighted, domain-specific)
14
+ * Message construction templates for sub-agent instructions
15
+ * Conflict resolution approaches (evidence-based, democratic, expert-priority)
16
+ * Workflow preferences (thorough vs fast, iterative vs single-pass)
17
+ * Domain-specific coordination (research teams, technical reviews, creative brainstorming)
18
+ * Dynamic agent selection based on task requirements and orchestrator instructions
6
19
  """
7
20
 
8
21
  import asyncio
9
- from typing import Dict, List, Optional, Any, AsyncGenerator
22
+ import json
23
+ import os
24
+ import shutil
25
+ import time
26
+ import traceback
10
27
  from dataclasses import dataclass, field
11
- from .message_templates import MessageTemplates
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Any, AsyncGenerator, Dict, List, Optional
31
+
12
32
  from .agent_config import AgentConfig
13
33
  from .backend.base import StreamChunk
14
34
  from .chat_agent import ChatAgent
35
+ from .coordination_tracker import CoordinationTracker
36
+ from .logger_config import get_log_session_dir # Import to get log directory
37
+ from .logger_config import logger # Import logger directly for INFO logging
38
+ from .logger_config import (
39
+ log_coordination_step,
40
+ log_orchestrator_activity,
41
+ log_orchestrator_agent_message,
42
+ log_stream_chunk,
43
+ log_tool_call,
44
+ )
45
+ from .message_templates import MessageTemplates
46
+ from .stream_chunk import ChunkType
47
+ from .utils import ActionType, AgentStatus, CoordinationStage
15
48
 
16
49
 
17
50
  @dataclass
@@ -23,12 +56,17 @@ class AgentState:
23
56
  has_voted: Whether the agent has voted in the current round
24
57
  votes: Dictionary storing vote data for this agent
25
58
  restart_pending: Whether the agent should gracefully restart due to new answers
59
+ is_killed: Whether this agent has been killed due to timeout/limits
60
+ timeout_reason: Reason for timeout (if applicable)
26
61
  """
27
62
 
28
63
  answer: Optional[str] = None
29
64
  has_voted: bool = False
30
65
  votes: Dict[str, Any] = field(default_factory=dict)
31
66
  restart_pending: bool = False
67
+ is_killed: bool = False
68
+ timeout_reason: Optional[str] = None
69
+ last_context: Optional[Dict[str, Any]] = None # Store the context sent to this agent
32
70
 
33
71
 
34
72
  class Orchestrator(ChatAgent):
@@ -54,6 +92,16 @@ class Orchestrator(ChatAgent):
54
92
  - Configurable presentation formats for final answers
55
93
  - Advanced coordination workflows (hierarchical, weighted voting, etc.)
56
94
 
95
+ TODO (v0.0.14 Context Sharing Enhancement - See docs/dev_notes/v0.0.14-context.md):
96
+ - Add permission validation logic for agent workspace access
97
+ - Implement validate_agent_access() method to check if agent has required permission for resource
98
+ - Replace current prompt-based access control with explicit system-level enforcement
99
+ - Add PermissionManager integration for managing agent access rules
100
+ - Implement audit logging for all access attempts to workspace resources
101
+ - Support dynamic permission negotiation during runtime
102
+ - Add configurable policy framework for permission management
103
+ - Integrate with workspace snapshot mechanism for controlled context sharing
104
+
57
105
  Restart Behavior:
58
106
  When an agent provides new_answer, all agents gracefully restart to ensure
59
107
  consistent coordination state. This allows all agents to transition to Case 2
@@ -66,6 +114,9 @@ class Orchestrator(ChatAgent):
66
114
  orchestrator_id: str = "orchestrator",
67
115
  session_id: Optional[str] = None,
68
116
  config: Optional[AgentConfig] = None,
117
+ snapshot_storage: Optional[str] = None,
118
+ agent_temporary_workspace: Optional[str] = None,
119
+ previous_turns: Optional[List[Dict[str, Any]]] = None,
69
120
  ):
70
121
  """
71
122
  Initialize MassGen orchestrator.
@@ -75,6 +126,9 @@ class Orchestrator(ChatAgent):
75
126
  orchestrator_id: Unique identifier for this orchestrator (default: "orchestrator")
76
127
  session_id: Optional session identifier
77
128
  config: Optional AgentConfig for customizing orchestrator behavior
129
+ snapshot_storage: Optional path to store agent workspace snapshots
130
+ agent_temporary_workspace: Optional path for agent temporary workspaces
131
+ previous_turns: List of previous turn metadata for multi-turn conversations (loaded by CLI)
78
132
  """
79
133
  super().__init__(session_id)
80
134
  self.orchestrator_id = orchestrator_id
@@ -85,9 +139,7 @@ class Orchestrator(ChatAgent):
85
139
  # Get message templates from config
86
140
  self.message_templates = self.config.message_templates or MessageTemplates()
87
141
  # Create workflow tools for agents (vote and new_answer)
88
- self.workflow_tools = self.message_templates.get_standard_tools(
89
- list(agents.keys())
90
- )
142
+ self.workflow_tools = self.message_templates.get_standard_tools(list(agents.keys()))
91
143
 
92
144
  # MassGen-specific state
93
145
  self.current_task: Optional[str] = None
@@ -96,6 +148,66 @@ class Orchestrator(ChatAgent):
96
148
  # Internal coordination state
97
149
  self._coordination_messages: List[Dict[str, str]] = []
98
150
  self._selected_agent: Optional[str] = None
151
+ self._final_presentation_content: Optional[str] = None
152
+
153
+ # Timeout and resource tracking
154
+ self.total_tokens: int = 0
155
+ self.coordination_start_time: float = 0
156
+ self.is_orchestrator_timeout: bool = False
157
+ self.timeout_reason: Optional[str] = None
158
+
159
+ # Coordination state tracking for cleanup
160
+ self._active_streams: Dict = {}
161
+ self._active_tasks: Dict = {}
162
+
163
+ # Context sharing for agents with filesystem support
164
+ self._snapshot_storage: Optional[str] = snapshot_storage
165
+ self._agent_temporary_workspace: Optional[str] = agent_temporary_workspace
166
+
167
+ # Multi-turn session tracking (loaded by CLI, not managed by orchestrator)
168
+ self._previous_turns: List[Dict[str, Any]] = previous_turns or []
169
+
170
+ # Coordination tracking - always enabled for analysis/debugging
171
+ self.coordination_tracker = CoordinationTracker()
172
+ self.coordination_tracker.initialize_session(list(agents.keys()))
173
+
174
+ # Create snapshot storage and workspace directories if specified
175
+ if snapshot_storage:
176
+ self._snapshot_storage = snapshot_storage
177
+ snapshot_path = Path(self._snapshot_storage)
178
+ # Clean existing directory if it exists and has contents
179
+ if snapshot_path.exists() and any(snapshot_path.iterdir()):
180
+ shutil.rmtree(snapshot_path)
181
+ snapshot_path.mkdir(parents=True, exist_ok=True)
182
+
183
+ # Configure orchestration paths for each agent with filesystem support
184
+ for agent_id, agent in self.agents.items():
185
+ if agent.backend.filesystem_manager:
186
+ agent.backend.filesystem_manager.setup_orchestration_paths(
187
+ agent_id=agent_id,
188
+ snapshot_storage=self._snapshot_storage,
189
+ agent_temporary_workspace=self._agent_temporary_workspace,
190
+ )
191
+ # Update MCP config with agent_id for Docker mode (must be after setup_orchestration_paths)
192
+ agent.backend.filesystem_manager.update_backend_mcp_config(agent.backend.config)
193
+
194
+ @staticmethod
195
+ def _get_chunk_type_value(chunk) -> str:
196
+ """
197
+ Extract chunk type as string, handling both legacy and typed chunks.
198
+
199
+ Args:
200
+ chunk: StreamChunk, TextStreamChunk, or MultimodalStreamChunk
201
+
202
+ Returns:
203
+ String representation of chunk type (e.g., "content", "tool_calls")
204
+ """
205
+ chunk_type = chunk.type
206
+
207
+ if isinstance(chunk_type, ChunkType):
208
+ return chunk_type.value
209
+
210
+ return str(chunk_type)
99
211
 
100
212
  async def chat(
101
213
  self,
@@ -129,9 +241,8 @@ class Orchestrator(ChatAgent):
129
241
  user_message = conversation_context.get("current_message")
130
242
 
131
243
  if not user_message:
132
- yield StreamChunk(
133
- type="error", error="No user message found in conversation"
134
- )
244
+ log_stream_chunk("orchestrator", "error", "No user message found in conversation")
245
+ yield StreamChunk(type="error", error="No user message found in conversation")
135
246
  return
136
247
 
137
248
  # Add user message to history
@@ -141,22 +252,25 @@ class Orchestrator(ChatAgent):
141
252
  if self.workflow_phase == "idle":
142
253
  # New task - start MassGen coordination with full context
143
254
  self.current_task = user_message
255
+ # Reinitialize session with user prompt now that we have it
256
+ self.coordination_tracker.initialize_session(list(self.agents.keys()), self.current_task)
144
257
  self.workflow_phase = "coordinating"
145
258
 
146
- async for chunk in self._coordinate_agents(conversation_context):
259
+ # Clear agent workspaces for new turn (if this is a multi-turn conversation with history)
260
+ if conversation_context and conversation_context.get("conversation_history"):
261
+ self._clear_agent_workspaces()
262
+
263
+ async for chunk in self._coordinate_agents_with_timeout(conversation_context):
147
264
  yield chunk
148
265
 
149
266
  elif self.workflow_phase == "presenting":
150
267
  # Handle follow-up question with full conversation context
151
- async for chunk in self._handle_followup(
152
- user_message, conversation_context
153
- ):
268
+ async for chunk in self._handle_followup(user_message, conversation_context):
154
269
  yield chunk
155
270
  else:
156
271
  # Already coordinating - provide status update
157
- yield StreamChunk(
158
- type="content", content="🔄 Coordinating agents, please wait..."
159
- )
272
+ log_stream_chunk("orchestrator", "content", "🔄 Coordinating agents, please wait...")
273
+ yield StreamChunk(type="content", content="🔄 Coordinating agents, please wait...")
160
274
  # Note: In production, you might want to queue follow-up questions
161
275
 
162
276
  async def chat_simple(self, user_message: str) -> AsyncGenerator[StreamChunk, None]:
@@ -173,9 +287,7 @@ class Orchestrator(ChatAgent):
173
287
  async for chunk in self.chat(messages):
174
288
  yield chunk
175
289
 
176
- def _build_conversation_context(
177
- self, messages: List[Dict[str, Any]]
178
- ) -> Dict[str, Any]:
290
+ def _build_conversation_context(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
179
291
  """Build conversation context from message list."""
180
292
  conversation_history = []
181
293
  current_message = None
@@ -206,10 +318,107 @@ class Orchestrator(ChatAgent):
206
318
  "full_messages": messages,
207
319
  }
208
320
 
209
- async def _coordinate_agents(
210
- self, conversation_context: Optional[Dict[str, Any]] = None
211
- ) -> AsyncGenerator[StreamChunk, None]:
321
+ def save_coordination_logs(self):
322
+ """Public method to save coordination logs after final presentation is complete."""
323
+ # End the coordination session
324
+ self.coordination_tracker._end_session()
325
+
326
+ # Save coordination logs using the coordination tracker
327
+ log_session_dir = get_log_session_dir()
328
+ if log_session_dir:
329
+ self.coordination_tracker.save_coordination_logs(log_session_dir)
330
+
331
+ async def _coordinate_agents_with_timeout(self, conversation_context: Optional[Dict[str, Any]] = None) -> AsyncGenerator[StreamChunk, None]:
332
+ """Execute coordination with orchestrator-level timeout protection."""
333
+ self.coordination_start_time = time.time()
334
+ self.total_tokens = 0
335
+ self.is_orchestrator_timeout = False
336
+ self.timeout_reason = None
337
+
338
+ log_orchestrator_activity(
339
+ self.orchestrator_id,
340
+ "Starting coordination with timeout",
341
+ {
342
+ "timeout_seconds": self.config.timeout_config.orchestrator_timeout_seconds,
343
+ "agents": list(self.agents.keys()),
344
+ },
345
+ )
346
+
347
+ # Track active coordination state for cleanup
348
+ self._active_streams = {}
349
+ self._active_tasks = {}
350
+
351
+ timeout_seconds = self.config.timeout_config.orchestrator_timeout_seconds
352
+
353
+ try:
354
+ # Use asyncio.timeout for timeout protection
355
+ async with asyncio.timeout(timeout_seconds):
356
+ async for chunk in self._coordinate_agents(conversation_context):
357
+ # Track tokens if this is a content chunk
358
+ if hasattr(chunk, "content") and chunk.content:
359
+ self.total_tokens += len(chunk.content.split()) # Rough token estimation
360
+
361
+ yield chunk
362
+
363
+ except asyncio.TimeoutError:
364
+ self.is_orchestrator_timeout = True
365
+ elapsed = time.time() - self.coordination_start_time
366
+ self.timeout_reason = f"Time limit exceeded ({elapsed:.1f}s/{timeout_seconds}s)"
367
+ # Track timeout for all agents that were still working
368
+ for agent_id in self.agent_states.keys():
369
+ if not self.agent_states[agent_id].has_voted:
370
+ self.coordination_tracker.track_agent_action(agent_id, ActionType.TIMEOUT, self.timeout_reason)
371
+
372
+ # Force cleanup of any active agent streams and tasks
373
+ await self._cleanup_active_coordination()
374
+
375
+ # Handle timeout by jumping to final presentation
376
+ if self.is_orchestrator_timeout:
377
+ async for chunk in self._handle_orchestrator_timeout():
378
+ yield chunk
379
+
380
+ async def _coordinate_agents(self, conversation_context: Optional[Dict[str, Any]] = None) -> AsyncGenerator[StreamChunk, None]:
212
381
  """Execute unified MassGen coordination workflow with real-time streaming."""
382
+ log_coordination_step(
383
+ "Starting multi-agent coordination",
384
+ {
385
+ "agents": list(self.agents.keys()),
386
+ "has_context": conversation_context is not None,
387
+ },
388
+ )
389
+
390
+ # Check if we should skip coordination rounds (debug/test mode)
391
+ if self.config.skip_coordination_rounds:
392
+ log_stream_chunk(
393
+ "orchestrator",
394
+ "content",
395
+ "⚡ [DEBUG MODE] Skipping coordination rounds, going straight to final presentation...\n\n",
396
+ self.orchestrator_id,
397
+ )
398
+ yield StreamChunk(
399
+ type="content",
400
+ content="⚡ [DEBUG MODE] Skipping coordination rounds, going straight to final presentation...\n\n",
401
+ source=self.orchestrator_id,
402
+ )
403
+
404
+ # Select first agent as winner (or random if needed)
405
+ self._selected_agent = list(self.agents.keys())[0]
406
+ log_coordination_step(
407
+ "Skipped coordination, selected first agent",
408
+ {"selected_agent": self._selected_agent},
409
+ )
410
+
411
+ # Present final answer immediately
412
+ async for chunk in self._present_final_answer():
413
+ yield chunk
414
+ return
415
+
416
+ log_stream_chunk(
417
+ "orchestrator",
418
+ "content",
419
+ "🚀 Starting multi-agent coordination...\n\n",
420
+ self.orchestrator_id,
421
+ )
213
422
  yield StreamChunk(
214
423
  type="content",
215
424
  content="🚀 Starting multi-agent coordination...\n\n",
@@ -223,6 +432,12 @@ class Orchestrator(ChatAgent):
223
432
  self.agent_states[agent_id].has_voted = False
224
433
  self.agent_states[agent_id].restart_pending = True
225
434
 
435
+ log_stream_chunk(
436
+ "orchestrator",
437
+ "content",
438
+ "## 📋 Agents Coordinating\n",
439
+ self.orchestrator_id,
440
+ )
226
441
  yield StreamChunk(
227
442
  type="content",
228
443
  content="## 📋 Agents Coordinating\n",
@@ -230,19 +445,16 @@ class Orchestrator(ChatAgent):
230
445
  )
231
446
 
232
447
  # Start streaming coordination with real-time agent output
233
- async for chunk in self._stream_coordination_with_agents(
234
- votes, conversation_context
235
- ):
448
+ async for chunk in self._stream_coordination_with_agents(votes, conversation_context):
236
449
  yield chunk
237
450
 
238
451
  # Determine final agent based on votes
239
- current_answers = {
240
- aid: state.answer
241
- for aid, state in self.agent_states.items()
242
- if state.answer
243
- }
244
- self._selected_agent = self._determine_final_agent_from_votes(
245
- votes, current_answers
452
+ current_answers = {aid: state.answer for aid, state in self.agent_states.items() if state.answer}
453
+ self._selected_agent = self._determine_final_agent_from_votes(votes, current_answers)
454
+
455
+ log_coordination_step(
456
+ "Final agent selected",
457
+ {"selected_agent": self._selected_agent, "votes": votes},
246
458
  )
247
459
 
248
460
  # Present final answer
@@ -270,19 +482,22 @@ class Orchestrator(ChatAgent):
270
482
  active_streams = {}
271
483
  active_tasks = {} # Track active tasks to prevent duplicate task creation
272
484
 
485
+ # Store references for timeout cleanup
486
+ self._active_streams = active_streams
487
+ self._active_tasks = active_tasks
488
+
273
489
  # Stream agent outputs in real-time until all have voted
274
490
  while not all(state.has_voted for state in self.agent_states.values()):
491
+ # Start new coordination iteration
492
+ self.coordination_tracker.start_new_iteration()
493
+
494
+ # Check for orchestrator timeout - stop spawning new agents
495
+ if self.is_orchestrator_timeout:
496
+ break
275
497
  # Start any agents that aren't running and haven't voted yet
276
- current_answers = {
277
- aid: state.answer
278
- for aid, state in self.agent_states.items()
279
- if state.answer
280
- }
498
+ current_answers = {aid: state.answer for aid, state in self.agent_states.items() if state.answer}
281
499
  for agent_id in self.agents.keys():
282
- if (
283
- agent_id not in active_streams
284
- and not self.agent_states[agent_id].has_voted
285
- ):
500
+ if agent_id not in active_streams and not self.agent_states[agent_id].has_voted and not self.agent_states[agent_id].is_killed:
286
501
  active_streams[agent_id] = self._stream_agent_execution(
287
502
  agent_id,
288
503
  self.current_task,
@@ -296,21 +511,18 @@ class Orchestrator(ChatAgent):
296
511
  # Create tasks only for streams that don't already have active tasks
297
512
  for agent_id, stream in active_streams.items():
298
513
  if agent_id not in active_tasks:
299
- active_tasks[agent_id] = asyncio.create_task(
300
- self._get_next_chunk(stream)
301
- )
514
+ active_tasks[agent_id] = asyncio.create_task(self._get_next_chunk(stream))
302
515
 
303
516
  if not active_tasks:
304
517
  break
305
518
 
306
- done, _ = await asyncio.wait(
307
- active_tasks.values(), return_when=asyncio.FIRST_COMPLETED
308
- )
519
+ done, _ = await asyncio.wait(active_tasks.values(), return_when=asyncio.FIRST_COMPLETED)
309
520
 
310
521
  # Collect results from completed agents
311
522
  reset_signal = False
312
523
  voted_agents = {}
313
524
  answered_agents = {}
525
+ completed_agent_ids = set() # Track all agents whose tasks completed, i.e., done, error, result.
314
526
 
315
527
  # Process completed stream chunks
316
528
  for task in done:
@@ -323,13 +535,25 @@ class Orchestrator(ChatAgent):
323
535
 
324
536
  if chunk_type == "content":
325
537
  # Stream agent content in real-time with source info
326
- yield StreamChunk(
327
- type="content", content=chunk_data, source=agent_id
328
- )
538
+ log_stream_chunk("orchestrator", "content", chunk_data, agent_id)
539
+ yield StreamChunk(type="content", content=chunk_data, source=agent_id)
540
+
541
+ elif chunk_type == "reasoning":
542
+ # Stream reasoning content with proper attribution
543
+ log_stream_chunk("orchestrator", "reasoning", chunk_data, agent_id)
544
+ yield chunk_data # chunk_data is already a StreamChunk with source
329
545
 
330
546
  elif chunk_type == "result":
331
547
  # Agent completed with result
332
548
  result_type, result_data = chunk_data
549
+ # Result ends the agent's current stream
550
+ completed_agent_ids.add(agent_id)
551
+ log_stream_chunk(
552
+ "orchestrator",
553
+ f"result.{result_type}",
554
+ result_data,
555
+ agent_id,
556
+ )
333
557
 
334
558
  # Emit agent completion status immediately upon result
335
559
  yield StreamChunk(
@@ -342,41 +566,115 @@ class Orchestrator(ChatAgent):
342
566
 
343
567
  if result_type == "answer":
344
568
  # Agent provided an answer (initial or improved)
569
+ agent = self.agents.get(agent_id)
570
+ # Get the context that was sent to this agent
571
+ agent_context = self.get_last_context(agent_id)
572
+ # Save snapshot (of workspace and answer) when agent provides new answer
573
+ answer_timestamp = await self._save_agent_snapshot(
574
+ agent_id,
575
+ answer_content=result_data,
576
+ context_data=agent_context,
577
+ )
578
+ if agent and agent.backend.filesystem_manager:
579
+ agent.backend.filesystem_manager.log_current_state("after providing answer")
345
580
  # Always record answers, even from restarting agents (orchestrator accepts them)
581
+
346
582
  answered_agents[agent_id] = result_data
583
+ # Pass timestamp to coordination_tracker for mapping
584
+ self.coordination_tracker.add_agent_answer(
585
+ agent_id,
586
+ result_data,
587
+ snapshot_timestamp=answer_timestamp,
588
+ )
589
+ restart_triggered_id = agent_id # Last agent to provide new answer
347
590
  reset_signal = True
591
+ log_stream_chunk(
592
+ "orchestrator",
593
+ "content",
594
+ "✅ Answer provided\n",
595
+ agent_id,
596
+ )
597
+
598
+ # Track new answer event
599
+ log_stream_chunk(
600
+ "orchestrator",
601
+ "content",
602
+ "✅ Answer provided\n",
603
+ agent_id,
604
+ )
348
605
  yield StreamChunk(
349
606
  type="content",
350
- content=f"[{agent_id}] ✅ Answer provided",
607
+ content="✅ Answer provided\n",
351
608
  source=agent_id,
352
609
  )
353
610
 
354
611
  elif result_type == "vote":
355
612
  # Agent voted for existing answer
356
613
  # Ignore votes from agents with restart pending (votes are about current state)
357
- if self.agent_states[agent_id].restart_pending:
614
+ if self._check_restart_pending(agent_id):
358
615
  voted_for = result_data.get("agent_id", "<unknown>")
359
616
  reason = result_data.get("reason", "No reason provided")
617
+ # Track the ignored vote action
618
+ self.coordination_tracker.track_agent_action(
619
+ agent_id,
620
+ ActionType.VOTE_IGNORED,
621
+ f"Voted for {voted_for} but ignored due to restart",
622
+ )
623
+ # Save in coordination tracker that we waste a vote due to restart
624
+ log_stream_chunk(
625
+ "orchestrator",
626
+ "content",
627
+ f"🔄 Vote for [{voted_for}] ignored (reason: {reason}) - restarting due to new answers",
628
+ agent_id,
629
+ )
360
630
  yield StreamChunk(
361
631
  type="content",
362
- content=f"🔄 Vote by [{agent_id}] for [{voted_for}] ignored (reason: {reason}) - restarting due to new answers",
632
+ content=f"🔄 Vote for [{voted_for}] ignored (reason: {reason}) - restarting due to new answers",
363
633
  source=agent_id,
364
634
  )
365
635
  # yield StreamChunk(type="content", content="🔄 Vote ignored - restarting due to new answers", source=agent_id)
366
636
  else:
637
+ # Save vote snapshot (includes workspace)
638
+ vote_timestamp = await self._save_agent_snapshot(
639
+ agent_id=agent_id,
640
+ vote_data=result_data,
641
+ context_data=self.get_last_context(agent_id),
642
+ )
643
+ # Log workspaces for current agent
644
+ agent = self.agents.get(agent_id)
645
+ if agent and agent.backend.filesystem_manager:
646
+ self.agents.get(agent_id).backend.filesystem_manager.log_current_state("after voting")
367
647
  voted_agents[agent_id] = result_data
648
+ # Pass timestamp to coordination_tracker for mapping
649
+ self.coordination_tracker.add_agent_vote(
650
+ agent_id,
651
+ result_data,
652
+ snapshot_timestamp=vote_timestamp,
653
+ )
654
+
655
+ # Track new vote event
656
+ voted_for = result_data.get("agent_id", "<unknown>")
657
+ reason = result_data.get("reason", "No reason provided")
658
+ log_stream_chunk(
659
+ "orchestrator",
660
+ "content",
661
+ f"✅ Vote recorded for [{result_data['agent_id']}]",
662
+ agent_id,
663
+ )
368
664
  yield StreamChunk(
369
665
  type="content",
370
- content=f"[{agent_id}] ✅ Vote recorded for {result_data['agent_id']}",
666
+ content=f"✅ Vote recorded for [{result_data['agent_id']}]",
371
667
  source=agent_id,
372
668
  )
373
669
 
374
670
  elif chunk_type == "error":
375
671
  # Agent error
376
- yield StreamChunk(
377
- type="content", content=f"❌ {chunk_data}", source=agent_id
378
- )
379
- # Emit agent completion status for errors too
672
+ self.coordination_tracker.track_agent_action(agent_id, ActionType.ERROR, chunk_data)
673
+ # Error ends the agent's current stream
674
+ completed_agent_ids.add(agent_id)
675
+ log_stream_chunk("orchestrator", "error", chunk_data, agent_id)
676
+ yield StreamChunk(type="content", content=f"❌ {chunk_data}", source=agent_id)
677
+ log_stream_chunk("orchestrator", "agent_status", "completed", agent_id)
380
678
  yield StreamChunk(
381
679
  type="agent_status",
382
680
  source=agent_id,
@@ -385,8 +683,21 @@ class Orchestrator(ChatAgent):
385
683
  )
386
684
  await self._close_agent_stream(agent_id, active_streams)
387
685
 
686
+ elif chunk_type == "debug":
687
+ # Debug information - forward as StreamChunk for logging
688
+ log_stream_chunk("orchestrator", "debug", chunk_data, agent_id)
689
+ yield StreamChunk(type="debug", content=chunk_data, source=agent_id)
690
+
691
+ elif chunk_type == "mcp_status":
692
+ # MCP status messages - forward with proper formatting
693
+ mcp_message = f"🔧 MCP: {chunk_data}"
694
+ log_stream_chunk("orchestrator", "mcp_status", chunk_data, agent_id)
695
+ yield StreamChunk(type="content", content=mcp_message, source=agent_id)
696
+
388
697
  elif chunk_type == "done":
389
698
  # Stream completed - emit completion status for frontend
699
+ completed_agent_ids.add(agent_id)
700
+ log_stream_chunk("orchestrator", "done", None, agent_id)
390
701
  yield StreamChunk(
391
702
  type="agent_status",
392
703
  source=agent_id,
@@ -396,6 +707,9 @@ class Orchestrator(ChatAgent):
396
707
  await self._close_agent_stream(agent_id, active_streams)
397
708
 
398
709
  except Exception as e:
710
+ self.coordination_tracker.track_agent_action(agent_id, ActionType.ERROR, f"Stream error - {e}")
711
+ completed_agent_ids.add(agent_id)
712
+ log_stream_chunk("orchestrator", "error", f"❌ Stream error - {e}", agent_id)
399
713
  yield StreamChunk(
400
714
  type="content",
401
715
  content=f"❌ Stream error - {e}",
@@ -409,9 +723,14 @@ class Orchestrator(ChatAgent):
409
723
  for state in self.agent_states.values():
410
724
  state.has_voted = False
411
725
  votes.clear()
412
- # Signal ALL agents to gracefully restart
726
+
413
727
  for agent_id in self.agent_states.keys():
414
728
  self.agent_states[agent_id].restart_pending = True
729
+
730
+ # Track restart signals
731
+ self.coordination_tracker.track_restart_signal(restart_triggered_id, list(self.agent_states.keys()))
732
+ # Note that the agent that sent the restart signal had its stream end so we should mark as completed. NOTE the below breaks it.
733
+ self.coordination_tracker.complete_agent_restart(restart_triggered_id)
415
734
  # Set has_voted = True for agents that voted (only if no reset signal)
416
735
  else:
417
736
  for agent_id, vote_data in voted_agents.items():
@@ -422,26 +741,386 @@ class Orchestrator(ChatAgent):
422
741
  for agent_id, answer in answered_agents.items():
423
742
  self.agent_states[agent_id].answer = answer
424
743
 
425
- # Cancel any remaining tasks and close streams
426
- for task in active_tasks.values():
744
+ # Update status based on what actions agents took
745
+ for agent_id in completed_agent_ids:
746
+ if agent_id in answered_agents:
747
+ self.coordination_tracker.change_status(agent_id, AgentStatus.ANSWERED)
748
+ elif agent_id in voted_agents:
749
+ self.coordination_tracker.change_status(agent_id, AgentStatus.VOTED)
750
+ # Errors and timeouts are already tracked via track_agent_action
751
+
752
+ # Cancel any remaining tasks and close streams, as all agents have voted (no more new answers)
753
+ for agent_id, task in active_tasks.items():
754
+ if not task.done():
755
+ self.coordination_tracker.track_agent_action(
756
+ agent_id,
757
+ ActionType.CANCELLED,
758
+ "All agents voted - coordination complete",
759
+ )
427
760
  task.cancel()
428
761
  for agent_id in list(active_streams.keys()):
429
762
  await self._close_agent_stream(agent_id, active_streams)
430
763
 
431
- async def _close_agent_stream(
432
- self, agent_id: str, active_streams: Dict[str, AsyncGenerator]
433
- ) -> None:
764
+ async def _copy_all_snapshots_to_temp_workspace(self, agent_id: str) -> Optional[str]:
765
+ """Copy all agents' latest workspace snapshots to a temporary workspace for context sharing.
766
+
767
+ TODO (v0.0.14 Context Sharing Enhancement - See docs/dev_notes/v0.0.14-context.md):
768
+ - Validate agent permissions before restoring snapshots
769
+ - Check if agent has read access to other agents' workspaces
770
+ - Implement fine-grained control over which snapshots can be accessed
771
+ - Add audit logging for snapshot access attempts
772
+
773
+ Args:
774
+ agent_id: ID of the Claude Code agent receiving the context
775
+
776
+ Returns:
777
+ Path to the agent's workspace directory if successful, None otherwise
778
+ """
779
+ agent = self.agents.get(agent_id)
780
+ if not agent:
781
+ return None
782
+
783
+ # Check if agent has filesystem support
784
+ if not agent.backend.filesystem_manager:
785
+ return None
786
+
787
+ # Create anonymous mapping for agent IDs (same logic as in message_templates.py)
788
+ # This ensures consistency with the anonymous IDs shown to agents
789
+ agent_mapping = {}
790
+ sorted_agent_ids = sorted(self.agents.keys())
791
+ for i, real_agent_id in enumerate(sorted_agent_ids, 1):
792
+ agent_mapping[real_agent_id] = f"agent{i}"
793
+
794
+ # Collect snapshots from snapshot_storage directory
795
+ all_snapshots = {}
796
+ if self._snapshot_storage:
797
+ snapshot_base = Path(self._snapshot_storage)
798
+ for source_agent_id in self.agents.keys():
799
+ source_snapshot = snapshot_base / source_agent_id
800
+ if source_snapshot.exists() and source_snapshot.is_dir():
801
+ all_snapshots[source_agent_id] = source_snapshot
802
+
803
+ # Use the filesystem manager to copy snapshots to temp workspace
804
+ workspace_path = await agent.backend.filesystem_manager.copy_snapshots_to_temp_workspace(all_snapshots, agent_mapping)
805
+ return str(workspace_path) if workspace_path else None
806
+
807
+ async def _save_agent_snapshot(
808
+ self,
809
+ agent_id: str,
810
+ answer_content: str = None,
811
+ vote_data: Dict[str, Any] = None,
812
+ is_final: bool = False,
813
+ context_data: Any = None,
814
+ ) -> str:
815
+ """
816
+ Save a snapshot of an agent's working directory and answer/vote with the same timestamp.
817
+
818
+ Creates a timestamped directory structure:
819
+ - agent_id/timestamp/workspace/ - Contains the workspace files
820
+ - agent_id/timestamp/answer.txt - Contains the answer text (if provided)
821
+ - agent_id/timestamp/vote.json - Contains the vote data (if provided)
822
+ - agent_id/timestamp/context.txt - Contains the context used (if provided)
823
+
824
+ Args:
825
+ agent_id: ID of the agent
826
+ answer_content: The answer content to save (if provided)
827
+ vote_data: The vote data to save (if provided)
828
+ is_final: If True, save as final snapshot for presentation
829
+ context_data: The context data to save (conversation, answers, etc.)
830
+
831
+ Returns:
832
+ The timestamp used for this snapshot
833
+ """
834
+ logger.info(f"[Orchestrator._save_agent_snapshot] Called for agent_id={agent_id}, has_answer={bool(answer_content)}, has_vote={bool(vote_data)}, is_final={is_final}")
835
+
836
+ agent = self.agents.get(agent_id)
837
+ if not agent:
838
+ logger.warning(f"[Orchestrator._save_agent_snapshot] Agent {agent_id} not found in agents dict")
839
+ return None
840
+
841
+ # Generate single timestamp for answer/vote and workspace
842
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
843
+
844
+ # Save answer if provided
845
+ if answer_content:
846
+ try:
847
+ log_session_dir = get_log_session_dir()
848
+ if log_session_dir:
849
+ if is_final:
850
+ # For final, save to final directory
851
+ timestamped_dir = log_session_dir / "final" / agent_id
852
+ else:
853
+ # For regular snapshots, create timestamped directory
854
+ timestamped_dir = log_session_dir / agent_id / timestamp
855
+ timestamped_dir.mkdir(parents=True, exist_ok=True)
856
+ answer_file = timestamped_dir / "answer.txt"
857
+
858
+ # Write the answer content
859
+ answer_file.write_text(answer_content)
860
+ logger.info(f"[Orchestrator._save_agent_snapshot] Saved answer to {answer_file}")
861
+
862
+ except Exception as e:
863
+ logger.warning(f"[Orchestrator._save_agent_snapshot] Failed to save answer for {agent_id}: {e}")
864
+
865
+ # Save vote if provided
866
+ if vote_data:
867
+ try:
868
+ log_session_dir = get_log_session_dir()
869
+ if log_session_dir:
870
+ # Create timestamped directory for vote
871
+ timestamped_dir = log_session_dir / agent_id / timestamp
872
+ timestamped_dir.mkdir(parents=True, exist_ok=True)
873
+ vote_file = timestamped_dir / "vote.json"
874
+
875
+ # Get current state for context
876
+ current_answers = {aid: state.answer for aid, state in self.agent_states.items() if state.answer}
877
+
878
+ # Create anonymous agent mapping
879
+ agent_mapping = {}
880
+ for i, real_id in enumerate(sorted(self.agents.keys()), 1):
881
+ agent_mapping[f"agent{i}"] = real_id
882
+
883
+ # Build comprehensive vote data
884
+ comprehensive_vote_data = {
885
+ "voter_id": agent_id,
886
+ "voter_anon_id": next(
887
+ (anon for anon, real in agent_mapping.items() if real == agent_id),
888
+ agent_id,
889
+ ),
890
+ "voted_for": vote_data.get("agent_id", "unknown"),
891
+ "voted_for_anon": next(
892
+ (anon for anon, real in agent_mapping.items() if real == vote_data.get("agent_id")),
893
+ "unknown",
894
+ ),
895
+ "reason": vote_data.get("reason", ""),
896
+ "timestamp": timestamp,
897
+ "unix_timestamp": time.time(),
898
+ "iteration": self.coordination_tracker.current_iteration if self.coordination_tracker else None,
899
+ "coordination_round": self.coordination_tracker.max_round if self.coordination_tracker else None,
900
+ "available_options": list(current_answers.keys()),
901
+ "available_options_anon": [
902
+ next(
903
+ (anon for anon, real in agent_mapping.items() if real == aid),
904
+ aid,
905
+ )
906
+ for aid in sorted(current_answers.keys())
907
+ ],
908
+ "agent_mapping": agent_mapping,
909
+ "vote_context": {
910
+ "total_agents": len(self.agents),
911
+ "agents_with_answers": len(current_answers),
912
+ "current_task": self.current_task,
913
+ },
914
+ }
915
+
916
+ # Write the comprehensive vote data
917
+ with open(vote_file, "w", encoding="utf-8") as f:
918
+ json.dump(comprehensive_vote_data, f, indent=2)
919
+ logger.info(f"[Orchestrator._save_agent_snapshot] Saved comprehensive vote to {vote_file}")
920
+
921
+ except Exception as e:
922
+ logger.error(f"[Orchestrator._save_agent_snapshot] Failed to save vote for {agent_id}: {e}")
923
+ logger.error(f"[Orchestrator._save_agent_snapshot] Traceback: {traceback.format_exc()}")
924
+
925
+ # Save workspace snapshot with the same timestamp
926
+ if agent.backend.filesystem_manager:
927
+ logger.info(f"[Orchestrator._save_agent_snapshot] Agent {agent_id} has filesystem_manager, calling save_snapshot with timestamp={timestamp if not is_final else None}")
928
+ await agent.backend.filesystem_manager.save_snapshot(timestamp=timestamp if not is_final else None, is_final=is_final)
929
+
930
+ # Clear workspace after saving snapshot (but not for final snapshots)
931
+ if not is_final:
932
+ agent.backend.filesystem_manager.clear_workspace()
933
+ logger.info(f"[Orchestrator._save_agent_snapshot] Cleared workspace for {agent_id} after saving snapshot")
934
+ else:
935
+ logger.info(f"[Orchestrator._save_agent_snapshot] Agent {agent_id} does not have filesystem_manager")
936
+
937
+ # Save context if provided (unified context saving)
938
+ if context_data and (answer_content or vote_data):
939
+ try:
940
+ log_session_dir = get_log_session_dir()
941
+ if log_session_dir:
942
+ if is_final:
943
+ timestamped_dir = log_session_dir / "final" / agent_id
944
+ else:
945
+ timestamped_dir = log_session_dir / agent_id / timestamp
946
+
947
+ context_file = timestamped_dir / "context.txt"
948
+
949
+ # Handle different types of context data
950
+ if isinstance(context_data, dict):
951
+ # Pretty print dict/JSON data
952
+ context_file.write_text(json.dumps(context_data, indent=2, default=str))
953
+ else:
954
+ # Save as string
955
+ context_file.write_text(str(context_data))
956
+
957
+ logger.info(f"[Orchestrator._save_agent_snapshot] Saved context to {context_file}")
958
+ except Exception as ce:
959
+ logger.warning(f"[Orchestrator._save_agent_snapshot] Failed to save context for {agent_id}: {ce}")
960
+
961
+ # Return the timestamp for tracking
962
+ return timestamp if not is_final else "final"
963
+
964
+ def get_last_context(self, agent_id: str) -> Any:
965
+ """Get the last context for an agent, or None if not available."""
966
+ return self.agent_states[agent_id].last_context if agent_id in self.agent_states else None
967
+
968
+ async def _close_agent_stream(self, agent_id: str, active_streams: Dict[str, AsyncGenerator]) -> None:
434
969
  """Close and remove an agent stream safely."""
435
970
  if agent_id in active_streams:
436
971
  try:
437
972
  await active_streams[agent_id].aclose()
438
- except:
973
+ except Exception:
439
974
  pass # Ignore cleanup errors
440
975
  del active_streams[agent_id]
441
976
 
442
977
  def _check_restart_pending(self, agent_id: str) -> bool:
443
- """Check if agent should restart and yield restart message if needed."""
444
- return self.agent_states[agent_id].restart_pending
978
+ """Check if agent should restart and yield restart message if needed. This will always be called when exiting out of _stream_agent_execution()."""
979
+ restart_pending = self.agent_states[agent_id].restart_pending
980
+ return restart_pending
981
+
982
+ async def _save_partial_work_on_restart(self, agent_id: str) -> None:
983
+ """
984
+ Save partial work snapshot when agent is restarting due to new answers from others.
985
+ This ensures that any work done before the restart is preserved and shared with other agents.
986
+
987
+ Args:
988
+ agent_id: ID of the agent being restarted
989
+ """
990
+ agent = self.agents.get(agent_id)
991
+ if not agent or not agent.backend.filesystem_manager:
992
+ return
993
+
994
+ logger.info(f"[Orchestrator._save_partial_work_on_restart] Saving partial work for {agent_id} before restart")
995
+
996
+ # Save the partial work snapshot with context
997
+ await self._save_agent_snapshot(
998
+ agent_id,
999
+ answer_content=None, # No complete answer yet
1000
+ context_data=self.get_last_context(agent_id),
1001
+ is_final=False,
1002
+ )
1003
+
1004
+ agent.backend.filesystem_manager.log_current_state("after saving partial work on restart")
1005
+
1006
+ def _normalize_workspace_paths_in_answers(self, answers: Dict[str, str], viewing_agent_id: Optional[str] = None) -> Dict[str, str]:
1007
+ """Normalize absolute workspace paths in agent answers to accessible temporary workspace paths.
1008
+
1009
+ This addresses the issue where agents working in separate workspace directories
1010
+ reference the same logical files using different absolute paths, causing them
1011
+ to think they're working on different tasks when voting.
1012
+
1013
+ Converts workspace paths to temporary workspace paths where the viewing agent can actually
1014
+ access other agents' files for verification during context sharing.
1015
+
1016
+ TODO: Replace with Docker volume mounts to ensure consistent paths across agents.
1017
+
1018
+ Args:
1019
+ answers: Dict mapping agent_id to their answer content
1020
+ viewing_agent_id: The agent who will be reading these answers.
1021
+ If None, normalizes to generic "workspace/" prefix.
1022
+
1023
+ Returns:
1024
+ Dict with same keys but normalized answer content with accessible paths
1025
+ """
1026
+ normalized_answers = {}
1027
+
1028
+ # Get viewing agent's temporary workspace path for context sharing (full absolute path)
1029
+ temp_workspace_base = None
1030
+ if viewing_agent_id:
1031
+ viewing_agent = self.agents.get(viewing_agent_id)
1032
+ if viewing_agent and viewing_agent.backend.filesystem_manager:
1033
+ temp_workspace_base = str(viewing_agent.backend.filesystem_manager.agent_temporary_workspace)
1034
+ # Create anonymous agent mapping for consistent directory names
1035
+ agent_mapping = {}
1036
+ sorted_agent_ids = sorted(self.agents.keys())
1037
+ for i, real_agent_id in enumerate(sorted_agent_ids, 1):
1038
+ agent_mapping[real_agent_id] = f"agent{i}"
1039
+
1040
+ for agent_id, answer in answers.items():
1041
+ normalized_answer = answer
1042
+
1043
+ # Replace all workspace paths found in the answer with accessible paths
1044
+ for other_agent_id, other_agent in self.agents.items():
1045
+ if not other_agent.backend.filesystem_manager:
1046
+ continue
1047
+
1048
+ anon_agent_id = agent_mapping.get(other_agent_id, f"agent_{other_agent_id}")
1049
+ replace_path = os.path.join(temp_workspace_base, anon_agent_id) if temp_workspace_base else anon_agent_id
1050
+ other_workspace = str(other_agent.backend.filesystem_manager.get_current_workspace())
1051
+ logger.debug(
1052
+ f"[Orchestrator._normalize_workspace_paths_in_answers] Replacing {other_workspace} in answer from {agent_id} with path {replace_path}. original answer: {normalized_answer}",
1053
+ )
1054
+ normalized_answer = normalized_answer.replace(other_workspace, replace_path)
1055
+ logger.debug(f"[Orchestrator._normalize_workspace_paths_in_answers] Intermediate normalized answer: {normalized_answer}")
1056
+
1057
+ normalized_answers[agent_id] = normalized_answer
1058
+
1059
+ return normalized_answers
1060
+
1061
+ def _normalize_workspace_paths_for_comparison(self, content: str, replacement_path: str = "/workspace") -> str:
1062
+ """
1063
+ Normalize all workspace paths in content to a canonical form for equality comparison.
1064
+
1065
+ Unlike _normalize_workspace_paths_in_answers which normalizes paths for specific agents,
1066
+ this method normalizes ALL workspace paths to a neutral canonical form (like '/workspace')
1067
+ so that content can be compared for equality regardless of which agent workspace it came from.
1068
+
1069
+ Args:
1070
+ content: Content that may contain workspace paths
1071
+
1072
+ Returns:
1073
+ Content with all workspace paths normalized to canonical form
1074
+ """
1075
+ normalized_content = content
1076
+
1077
+ # Replace all agent workspace paths with canonical '/workspace/'
1078
+ for agent_id, agent in self.agents.items():
1079
+ if not agent.backend.filesystem_manager:
1080
+ continue
1081
+
1082
+ # Get this agent's workspace path
1083
+ workspace_path = str(agent.backend.filesystem_manager.get_current_workspace())
1084
+ normalized_content = normalized_content.replace(workspace_path, replacement_path)
1085
+
1086
+ return normalized_content
1087
+
1088
+ async def _cleanup_active_coordination(self) -> None:
1089
+ """Force cleanup of active coordination streams and tasks on timeout."""
1090
+ # Cancel and cleanup active tasks
1091
+ if hasattr(self, "_active_tasks") and self._active_tasks:
1092
+ for agent_id, task in self._active_tasks.items():
1093
+ if not task.done():
1094
+ # Only track if not already tracked by timeout above
1095
+ if not self.is_orchestrator_timeout:
1096
+ self.coordination_tracker.track_agent_action(agent_id, ActionType.CANCELLED, "Coordination cleanup")
1097
+ task.cancel()
1098
+ try:
1099
+ await task
1100
+ except (asyncio.CancelledError, Exception):
1101
+ pass # Ignore cleanup errors
1102
+ self._active_tasks.clear()
1103
+
1104
+ # Close active streams
1105
+ if hasattr(self, "_active_streams") and self._active_streams:
1106
+ for agent_id in list(self._active_streams.keys()):
1107
+ await self._close_agent_stream(agent_id, self._active_streams)
1108
+
1109
+ # TODO (v0.0.14 Context Sharing Enhancement - See docs/dev_notes/v0.0.14-context.md):
1110
+ # Add the following permission validation methods:
1111
+ # async def validate_agent_access(self, agent_id: str, resource_path: str, access_type: str) -> bool:
1112
+ # """Check if agent has required permission for resource.
1113
+ #
1114
+ # Args:
1115
+ # agent_id: ID of the agent requesting access
1116
+ # resource_path: Path to the resource being accessed
1117
+ # access_type: Type of access (read, write, read-write, execute)
1118
+ #
1119
+ # Returns:
1120
+ # bool: True if access is allowed, False otherwise
1121
+ # """
1122
+ # # Implementation will check against PermissionManager
1123
+ # pass
445
1124
 
446
1125
  def _create_tool_error_messages(
447
1126
  self,
@@ -472,16 +1151,12 @@ class Orchestrator(ChatAgent):
472
1151
 
473
1152
  # Send primary error for the first tool call
474
1153
  first_tool_call = tool_calls[0]
475
- error_result_msg = agent.backend.create_tool_result_message(
476
- first_tool_call, primary_error_msg
477
- )
1154
+ error_result_msg = agent.backend.create_tool_result_message(first_tool_call, primary_error_msg)
478
1155
  enforcement_msgs.append(error_result_msg)
479
1156
 
480
1157
  # Send secondary error messages for any additional tool calls (API requires response to ALL calls)
481
1158
  for additional_tool_call in tool_calls[1:]:
482
- neutral_msg = agent.backend.create_tool_result_message(
483
- additional_tool_call, secondary_error_msg
484
- )
1159
+ neutral_msg = agent.backend.create_tool_result_message(additional_tool_call, secondary_error_msg)
485
1160
  enforcement_msgs.append(neutral_msg)
486
1161
 
487
1162
  return enforcement_msgs
@@ -508,32 +1183,171 @@ class Orchestrator(ChatAgent):
508
1183
  """
509
1184
  agent = self.agents[agent_id]
510
1185
 
1186
+ # Get backend name for logging
1187
+ backend_name = None
1188
+ if hasattr(agent, "backend") and hasattr(agent.backend, "get_provider_name"):
1189
+ backend_name = agent.backend.get_provider_name()
1190
+
1191
+ log_orchestrator_activity(
1192
+ self.orchestrator_id,
1193
+ f"Starting agent execution: {agent_id}",
1194
+ {
1195
+ "agent_id": agent_id,
1196
+ "backend": backend_name,
1197
+ "task": task if task else None, # Full task for debug logging
1198
+ "has_answers": bool(answers),
1199
+ "num_answers": len(answers) if answers else 0,
1200
+ },
1201
+ )
1202
+
1203
+ # Add periodic heartbeat logging for stuck agents
1204
+ logger.info(f"[Orchestrator] Agent {agent_id} starting execution loop...")
1205
+
1206
+ # Initialize agent state
1207
+ self.agent_states[agent_id].is_killed = False
1208
+ self.agent_states[agent_id].timeout_reason = None
1209
+
511
1210
  # Clear restart pending flag at the beginning of agent execution
1211
+ if self.agent_states[agent_id].restart_pending:
1212
+ # Track restart_pending transition (True → False) - restart processed
1213
+ self.coordination_tracker.complete_agent_restart(agent_id)
1214
+
512
1215
  self.agent_states[agent_id].restart_pending = False
513
1216
 
1217
+ # Copy all agents' snapshots to temp workspace for context sharing
1218
+ await self._copy_all_snapshots_to_temp_workspace(agent_id)
1219
+
1220
+ # Clear the agent's workspace to prepare for new execution
1221
+ # This preserves the previous agent's output for logging while giving a clean slate
1222
+ if agent.backend.filesystem_manager:
1223
+ # agent.backend.filesystem_manager.clear_workspace() # Don't clear for now.
1224
+ agent.backend.filesystem_manager.log_current_state("before execution")
1225
+
514
1226
  try:
1227
+ # Get agent's custom system message if available
1228
+ agent_system_message = agent.get_configurable_system_message()
1229
+
1230
+ # Append filesystem system message, if applicable
1231
+ if agent.backend.filesystem_manager:
1232
+ main_workspace = str(agent.backend.filesystem_manager.get_current_workspace())
1233
+ temp_workspace = str(agent.backend.filesystem_manager.agent_temporary_workspace) if agent.backend.filesystem_manager.agent_temporary_workspace else None
1234
+ # Get context paths if available
1235
+ context_paths = agent.backend.filesystem_manager.path_permission_manager.get_context_paths() if agent.backend.filesystem_manager.path_permission_manager else []
1236
+
1237
+ # Add previous turns as read-only context paths (only n-2 and earlier)
1238
+ previous_turns_context = self._get_previous_turns_context_paths()
1239
+
1240
+ # Filter to only show turn n-2 and earlier (agents start with n-1 in their workspace)
1241
+ # Get current turn from previous_turns list
1242
+ current_turn_num = len(previous_turns_context) + 1 if previous_turns_context else 1
1243
+ turns_to_show = [t for t in previous_turns_context if t["turn"] < current_turn_num - 1]
1244
+
1245
+ # Previous turn paths already registered in orchestrator constructor
1246
+
1247
+ # Check if workspace was pre-populated (has any previous turns)
1248
+ workspace_prepopulated = len(previous_turns_context) > 0
1249
+
1250
+ # Check if image generation is enabled for this agent
1251
+ enable_image_generation = False
1252
+ if hasattr(agent, "config") and agent.config:
1253
+ enable_image_generation = agent.config.backend_params.get("enable_image_generation", False)
1254
+ elif hasattr(agent, "backend") and hasattr(agent.backend, "backend_params"):
1255
+ enable_image_generation = agent.backend.backend_params.get("enable_image_generation", False)
1256
+
1257
+ # Extract command execution parameters
1258
+ enable_command_execution = False
1259
+ if hasattr(agent, "config") and agent.config:
1260
+ enable_command_execution = agent.config.backend_params.get("enable_mcp_command_line", False)
1261
+ elif hasattr(agent, "backend") and hasattr(agent.backend, "backend_params"):
1262
+ enable_command_execution = agent.backend.backend_params.get("enable_mcp_command_line", False)
1263
+
1264
+ filesystem_system_message = self.message_templates.filesystem_system_message(
1265
+ main_workspace=main_workspace,
1266
+ temp_workspace=temp_workspace,
1267
+ context_paths=context_paths,
1268
+ previous_turns=turns_to_show,
1269
+ workspace_prepopulated=workspace_prepopulated,
1270
+ enable_image_generation=enable_image_generation,
1271
+ agent_answers=answers,
1272
+ enable_command_execution=enable_command_execution,
1273
+ )
1274
+ agent_system_message = f"{agent_system_message}\n\n{filesystem_system_message}" if agent_system_message else filesystem_system_message
1275
+
1276
+ # Normalize workspace paths in agent answers for better comparison from this agent's perspective
1277
+ normalized_answers = self._normalize_workspace_paths_in_answers(answers, agent_id) if answers else answers
1278
+
1279
+ # Log the normalized answers this agent will see
1280
+ if normalized_answers:
1281
+ logger.info(f"[Orchestrator] Agent {agent_id} sees normalized answers: {normalized_answers}")
1282
+ else:
1283
+ logger.info(f"[Orchestrator] Agent {agent_id} sees no existing answers")
1284
+
1285
+ # Check if planning mode is enabled for coordination phase
1286
+ is_coordination_phase = self.workflow_phase == "coordinating"
1287
+ planning_mode_enabled = (
1288
+ self.config.coordination_config and self.config.coordination_config.enable_planning_mode and is_coordination_phase
1289
+ if self.config and hasattr(self.config, "coordination_config")
1290
+ else False
1291
+ )
1292
+
1293
+ # Add planning mode instructions to system message if enabled
1294
+ if planning_mode_enabled and self.config.coordination_config.planning_mode_instruction:
1295
+ planning_instructions = f"\n\n{self.config.coordination_config.planning_mode_instruction}"
1296
+ agent_system_message = f"{agent_system_message}{planning_instructions}" if agent_system_message else planning_instructions.strip()
1297
+
515
1298
  # Build conversation with context support
516
- if conversation_context and conversation_context.get(
517
- "conversation_history"
518
- ):
1299
+ if conversation_context and conversation_context.get("conversation_history"):
519
1300
  # Use conversation context-aware building
520
1301
  conversation = self.message_templates.build_conversation_with_context(
521
1302
  current_task=task,
522
- conversation_history=conversation_context.get(
523
- "conversation_history", []
524
- ),
525
- agent_summaries=answers,
526
- valid_agent_ids=list(answers.keys()) if answers else None,
1303
+ conversation_history=conversation_context.get("conversation_history", []),
1304
+ agent_summaries=normalized_answers,
1305
+ valid_agent_ids=list(normalized_answers.keys()) if normalized_answers else None,
1306
+ base_system_message=agent_system_message,
527
1307
  )
528
1308
  else:
529
1309
  # Fallback to standard conversation building
530
1310
  conversation = self.message_templates.build_initial_conversation(
531
1311
  task=task,
532
- agent_summaries=answers,
533
- valid_agent_ids=list(answers.keys()) if answers else None,
1312
+ agent_summaries=normalized_answers,
1313
+ valid_agent_ids=list(normalized_answers.keys()) if normalized_answers else None,
1314
+ base_system_message=agent_system_message,
534
1315
  )
535
1316
 
1317
+ # Track all the context used for this agent execution
1318
+ self.coordination_tracker.track_agent_context(
1319
+ agent_id,
1320
+ answers,
1321
+ conversation.get("conversation_history", []),
1322
+ conversation,
1323
+ )
1324
+
1325
+ # Store the context in agent state for later use when saving snapshots
1326
+ self.agent_states[agent_id].last_context = conversation
1327
+
1328
+ # Log the messages being sent to the agent with backend info
1329
+ backend_name = None
1330
+ if hasattr(agent, "backend") and hasattr(agent.backend, "get_provider_name"):
1331
+ backend_name = agent.backend.get_provider_name()
1332
+
1333
+ log_orchestrator_agent_message(
1334
+ agent_id,
1335
+ "SEND",
1336
+ {
1337
+ "system": conversation["system_message"],
1338
+ "user": conversation["user_message"],
1339
+ },
1340
+ backend_name=backend_name,
1341
+ )
1342
+
536
1343
  # Clean startup without redundant messages
1344
+ # Set planning mode on the agent's backend to control MCP tool execution
1345
+ if hasattr(agent.backend, "set_planning_mode"):
1346
+ agent.backend.set_planning_mode(planning_mode_enabled)
1347
+ if planning_mode_enabled:
1348
+ logger.info(f"[Orchestrator] Backend planning mode ENABLED for {agent_id} - MCP tools blocked")
1349
+ else:
1350
+ logger.info(f"[Orchestrator] Backend planning mode DISABLED for {agent_id} - MCP tools allowed")
537
1351
 
538
1352
  # Build proper conversation messages with system + user messages
539
1353
  max_attempts = 3
@@ -543,54 +1357,105 @@ class Orchestrator(ChatAgent):
543
1357
  ]
544
1358
  enforcement_msg = self.message_templates.enforcement_message()
545
1359
 
1360
+ # Update agent status to STREAMING
1361
+ self.coordination_tracker.change_status(agent_id, AgentStatus.STREAMING)
1362
+
546
1363
  for attempt in range(max_attempts):
1364
+ logger.info(f"[Orchestrator] Agent {agent_id} attempt {attempt + 1}/{max_attempts}")
1365
+
547
1366
  if self._check_restart_pending(agent_id):
1367
+ logger.info(f"[Orchestrator] Agent {agent_id} restarting due to restart_pending flag")
1368
+ # Save any partial work before restarting
1369
+ await self._save_partial_work_on_restart(agent_id)
548
1370
  # yield ("content", "🔄 Gracefully restarting due to new answers from other agents")
549
1371
  yield (
550
1372
  "content",
551
- f"🔁 Agent [{agent_id}] gracefully restarting due to new answer detected",
1373
+ f"🔁 [{agent_id}] gracefully restarting due to new answer detected\n",
552
1374
  )
553
1375
  yield ("done", None)
554
1376
  return
555
1377
 
556
1378
  # Stream agent response with workflow tools
1379
+ # TODO: Need to still log this redo enforcement msg in the context.txt, and this & others in the coordination tracker.
557
1380
  if attempt == 0:
558
1381
  # First attempt: orchestrator provides initial conversation
559
1382
  # But we need the agent to have this in its history for subsequent calls
560
1383
  # First attempt: provide complete conversation and reset agent's history
561
- chat_stream = agent.chat(
562
- conversation_messages, self.workflow_tools, reset_chat=True
563
- )
1384
+ chat_stream = agent.chat(conversation_messages, self.workflow_tools, reset_chat=True, current_stage=CoordinationStage.INITIAL_ANSWER)
564
1385
  else:
565
1386
  # Subsequent attempts: send enforcement message (set by error handling)
566
1387
 
567
1388
  if isinstance(enforcement_msg, list):
568
1389
  # Tool message array
569
- chat_stream = agent.chat(
570
- enforcement_msg, self.workflow_tools, reset_chat=False
571
- )
1390
+ chat_stream = agent.chat(enforcement_msg, self.workflow_tools, reset_chat=False, current_stage=CoordinationStage.ENFORCEMENT)
572
1391
  else:
573
1392
  # Single user message
574
1393
  enforcement_message = {
575
1394
  "role": "user",
576
1395
  "content": enforcement_msg,
577
1396
  }
578
- chat_stream = agent.chat(
579
- [enforcement_message], self.workflow_tools, reset_chat=False
580
- )
1397
+ chat_stream = agent.chat([enforcement_message], self.workflow_tools, reset_chat=False, current_stage=CoordinationStage.ENFORCEMENT)
581
1398
  response_text = ""
582
1399
  tool_calls = []
583
1400
  workflow_tool_found = False
1401
+
1402
+ logger.info(f"[Orchestrator] Agent {agent_id} starting to stream chat response...")
1403
+
584
1404
  async for chunk in chat_stream:
585
- if chunk.type == "content":
1405
+ chunk_type = self._get_chunk_type_value(chunk)
1406
+ if chunk_type == "content":
586
1407
  response_text += chunk.content
587
1408
  # Stream agent content directly - source field handles attribution
588
1409
  yield ("content", chunk.content)
589
- elif chunk.type == "tool_calls":
1410
+ # Log received content
1411
+ backend_name = None
1412
+ if hasattr(agent, "backend") and hasattr(agent.backend, "get_provider_name"):
1413
+ backend_name = agent.backend.get_provider_name()
1414
+ log_orchestrator_agent_message(
1415
+ agent_id,
1416
+ "RECV",
1417
+ {"content": chunk.content},
1418
+ backend_name=backend_name,
1419
+ )
1420
+ elif chunk_type in [
1421
+ "reasoning",
1422
+ "reasoning_done",
1423
+ "reasoning_summary",
1424
+ "reasoning_summary_done",
1425
+ ]:
1426
+ # Stream reasoning content as tuple format
1427
+ reasoning_chunk = StreamChunk(
1428
+ type=chunk.type,
1429
+ content=chunk.content,
1430
+ source=agent_id,
1431
+ reasoning_delta=getattr(chunk, "reasoning_delta", None),
1432
+ reasoning_text=getattr(chunk, "reasoning_text", None),
1433
+ reasoning_summary_delta=getattr(chunk, "reasoning_summary_delta", None),
1434
+ reasoning_summary_text=getattr(chunk, "reasoning_summary_text", None),
1435
+ item_id=getattr(chunk, "item_id", None),
1436
+ content_index=getattr(chunk, "content_index", None),
1437
+ summary_index=getattr(chunk, "summary_index", None),
1438
+ )
1439
+ yield ("reasoning", reasoning_chunk)
1440
+ elif chunk_type == "backend_status":
1441
+ pass
1442
+ elif chunk_type == "mcp_status":
1443
+ # Forward MCP status messages with proper formatting
1444
+ mcp_content = f"🔧 MCP: {chunk.content}"
1445
+ yield ("content", mcp_content)
1446
+ elif chunk_type == "debug":
1447
+ # Forward debug chunks
1448
+ yield ("debug", chunk.content)
1449
+ elif chunk_type == "tool_calls":
590
1450
  # Use the correct tool_calls field
591
1451
  chunk_tool_calls = getattr(chunk, "tool_calls", []) or []
592
1452
  tool_calls.extend(chunk_tool_calls)
593
1453
  # Stream tool calls to show agent actions
1454
+ # Get backend name for logging
1455
+ backend_name = None
1456
+ if hasattr(agent, "backend") and hasattr(agent.backend, "get_provider_name"):
1457
+ backend_name = agent.backend.get_provider_name()
1458
+
594
1459
  for tool_call in chunk_tool_calls:
595
1460
  tool_name = agent.backend.extract_tool_name(tool_call)
596
1461
  tool_args = agent.backend.extract_tool_arguments(tool_call)
@@ -598,49 +1463,53 @@ class Orchestrator(ChatAgent):
598
1463
  if tool_name == "new_answer":
599
1464
  content = tool_args.get("content", "")
600
1465
  yield ("content", f'💡 Providing answer: "{content}"')
1466
+ log_tool_call(
1467
+ agent_id,
1468
+ "new_answer",
1469
+ {"content": content},
1470
+ None,
1471
+ backend_name,
1472
+ ) # Full content for debug logging
601
1473
  elif tool_name == "vote":
602
1474
  agent_voted_for = tool_args.get("agent_id", "")
603
1475
  reason = tool_args.get("reason", "")
1476
+ log_tool_call(
1477
+ agent_id,
1478
+ "vote",
1479
+ {"agent_id": agent_voted_for, "reason": reason},
1480
+ None,
1481
+ backend_name,
1482
+ ) # Full reason for debug logging
604
1483
 
605
1484
  # Convert anonymous agent ID to real agent ID for display
606
1485
  real_agent_id = agent_voted_for
607
1486
  if answers: # Only do mapping if answers exist
608
1487
  agent_mapping = {}
609
- for i, real_id in enumerate(
610
- sorted(answers.keys()), 1
611
- ):
1488
+ for i, real_id in enumerate(sorted(answers.keys()), 1):
612
1489
  agent_mapping[f"agent{i}"] = real_id
613
- real_agent_id = agent_mapping.get(
614
- agent_voted_for, agent_voted_for
615
- )
1490
+ real_agent_id = agent_mapping.get(agent_voted_for, agent_voted_for)
616
1491
 
617
1492
  yield (
618
1493
  "content",
619
- f"🗳️ Voting for {real_agent_id}: {reason}",
1494
+ f"🗳️ Voting for [{real_agent_id}] (options: {', '.join(sorted(answers.keys()))}) : {reason}",
620
1495
  )
621
1496
  else:
622
1497
  yield ("content", f"🔧 Using {tool_name}")
623
- elif chunk.type == "error":
1498
+ log_tool_call(agent_id, tool_name, tool_args, None, backend_name)
1499
+ elif chunk_type == "error":
624
1500
  # Stream error information to user interface
625
- error_msg = (
626
- getattr(chunk, "error", str(chunk.content))
627
- if hasattr(chunk, "error")
628
- else str(chunk.content)
629
- )
630
- yield ("content", f"❌ Error: {error_msg}")
1501
+ error_msg = getattr(chunk, "error", str(chunk.content)) if hasattr(chunk, "error") else str(chunk.content)
1502
+ yield ("content", f"❌ Error: {error_msg}\n")
631
1503
 
632
1504
  # Check for multiple vote calls before processing
633
- vote_calls = [
634
- tc
635
- for tc in tool_calls
636
- if agent.backend.extract_tool_name(tc) == "vote"
637
- ]
1505
+ vote_calls = [tc for tc in tool_calls if agent.backend.extract_tool_name(tc) == "vote"]
638
1506
  if len(vote_calls) > 1:
639
1507
  if attempt < max_attempts - 1:
640
1508
  if self._check_restart_pending(agent_id):
1509
+ await self._save_partial_work_on_restart(agent_id)
641
1510
  yield (
642
1511
  "content",
643
- f"🔁 Agent [{agent_id}] gracefully restarting due to new answer detected",
1512
+ f"🔁 [{agent_id}] gracefully restarting due to new answer detected\n",
644
1513
  )
645
1514
  yield ("done", None)
646
1515
  return
@@ -664,17 +1533,14 @@ class Orchestrator(ChatAgent):
664
1533
  return
665
1534
 
666
1535
  # Check for mixed new_answer and vote calls - violates binary decision framework
667
- new_answer_calls = [
668
- tc
669
- for tc in tool_calls
670
- if agent.backend.extract_tool_name(tc) == "new_answer"
671
- ]
1536
+ new_answer_calls = [tc for tc in tool_calls if agent.backend.extract_tool_name(tc) == "new_answer"]
672
1537
  if len(vote_calls) > 0 and len(new_answer_calls) > 0:
673
1538
  if attempt < max_attempts - 1:
674
1539
  if self._check_restart_pending(agent_id):
1540
+ await self._save_partial_work_on_restart(agent_id)
675
1541
  yield (
676
1542
  "content",
677
- f"🔁 Agent [{agent_id}] gracefully restarting due to new answer detected",
1543
+ f"🔁 [{agent_id}] gracefully restarting due to new answer detected\n",
678
1544
  )
679
1545
  yield ("done", None)
680
1546
  return
@@ -682,14 +1548,12 @@ class Orchestrator(ChatAgent):
682
1548
  yield ("content", f"❌ {error_msg}")
683
1549
 
684
1550
  # Send tool error response for all tool calls that caused the violation
685
- enforcement_msg = self._create_tool_error_messages(
686
- agent, tool_calls, error_msg
687
- )
1551
+ enforcement_msg = self._create_tool_error_messages(agent, tool_calls, error_msg)
688
1552
  continue # Retry this attempt
689
1553
  else:
690
1554
  yield (
691
1555
  "error",
692
- f"Agent used both vote and new_answer tools in single response after max attempts",
1556
+ "Agent used both vote and new_answer tools in single response after max attempts",
693
1557
  )
694
1558
  yield ("done", None)
695
1559
  return
@@ -701,11 +1565,14 @@ class Orchestrator(ChatAgent):
701
1565
  tool_args = agent.backend.extract_tool_arguments(tool_call)
702
1566
 
703
1567
  if tool_name == "vote":
1568
+ # Log which agents we are choosing from
1569
+ logger.info(f"[Orchestrator] Agent {agent_id} voting from options: {list(answers.keys()) if answers else 'No answers available'}")
704
1570
  # Check if agent should restart - votes invalid during restart
705
- if self.agent_states[agent_id].restart_pending:
1571
+ if self._check_restart_pending(agent_id):
1572
+ await self._save_partial_work_on_restart(agent_id)
706
1573
  yield (
707
1574
  "content",
708
- f"🔄 Agent [{agent_id}] Vote invalid - restarting due to new answers",
1575
+ f"🔄 [{agent_id}] Vote invalid - restarting due to new answers",
709
1576
  )
710
1577
  yield ("done", None)
711
1578
  return
@@ -716,18 +1583,17 @@ class Orchestrator(ChatAgent):
716
1583
  # Invalid - can't vote when no answers exist
717
1584
  if attempt < max_attempts - 1:
718
1585
  if self._check_restart_pending(agent_id):
1586
+ await self._save_partial_work_on_restart(agent_id)
719
1587
  yield (
720
1588
  "content",
721
- f"🔁 Agent [{agent_id}] gracefully restarting due to new answer detected",
1589
+ f"🔁 [{agent_id}] gracefully restarting due to new answer detected\n",
722
1590
  )
723
1591
  yield ("done", None)
724
1592
  return
725
1593
  error_msg = "Cannot vote when no answers exist. Use new_answer tool."
726
1594
  yield ("content", f"❌ {error_msg}")
727
1595
  # Create proper tool error message for retry
728
- enforcement_msg = self._create_tool_error_messages(
729
- agent, [tool_call], error_msg
730
- )
1596
+ enforcement_msg = self._create_tool_error_messages(agent, [tool_call], error_msg)
731
1597
  continue
732
1598
  else:
733
1599
  yield (
@@ -742,43 +1608,30 @@ class Orchestrator(ChatAgent):
742
1608
 
743
1609
  # Convert anonymous agent ID back to real agent ID
744
1610
  agent_mapping = {}
745
- for i, real_agent_id in enumerate(
746
- sorted(answers.keys()), 1
747
- ):
1611
+ for i, real_agent_id in enumerate(sorted(answers.keys()), 1):
748
1612
  agent_mapping[f"agent{i}"] = real_agent_id
749
1613
 
750
- voted_agent = agent_mapping.get(
751
- voted_agent_anon, voted_agent_anon
752
- )
1614
+ voted_agent = agent_mapping.get(voted_agent_anon, voted_agent_anon)
753
1615
 
754
1616
  # Handle invalid agent_id
755
1617
  if voted_agent not in answers:
756
1618
  if attempt < max_attempts - 1:
757
1619
  if self._check_restart_pending(agent_id):
1620
+ await self._save_partial_work_on_restart(agent_id)
758
1621
  yield (
759
1622
  "content",
760
- f"🔁 Agent [{agent_id}] gracefully restarting due to new answer detected",
1623
+ f"🔁 [{agent_id}] gracefully restarting due to new answer detected\n",
761
1624
  )
762
1625
  yield ("done", None)
763
1626
  return
764
1627
  # Create reverse mapping for error message
765
- reverse_mapping = {
766
- real_id: f"agent{i}"
767
- for i, real_id in enumerate(
768
- sorted(answers.keys()), 1
769
- )
770
- }
771
- valid_anon_agents = [
772
- reverse_mapping[real_id]
773
- for real_id in answers.keys()
774
- ]
1628
+ reverse_mapping = {real_id: f"agent{i}" for i, real_id in enumerate(sorted(answers.keys()), 1)}
1629
+ valid_anon_agents = [reverse_mapping[real_id] for real_id in answers.keys()]
775
1630
  error_msg = f"Invalid agent_id '{voted_agent_anon}'. Valid agents: {', '.join(valid_anon_agents)}"
776
1631
  # Send tool error result back to agent
777
1632
  yield ("content", f"❌ {error_msg}")
778
1633
  # Create proper tool error message for retry
779
- enforcement_msg = self._create_tool_error_messages(
780
- agent, [tool_call], error_msg
781
- )
1634
+ enforcement_msg = self._create_tool_error_messages(agent, [tool_call], error_msg)
782
1635
  continue # Retry with updated conversation
783
1636
  else:
784
1637
  yield (
@@ -808,24 +1661,25 @@ class Orchestrator(ChatAgent):
808
1661
  content = tool_args.get("content", response_text.strip())
809
1662
 
810
1663
  # Check for duplicate answer
1664
+ # Normalize both new content and existing content to neutral paths for comparison
1665
+ normalized_new_content = self._normalize_workspace_paths_for_comparison(content)
1666
+
811
1667
  for existing_agent_id, existing_content in answers.items():
812
- if content.strip() == existing_content.strip():
1668
+ normalized_existing_content = self._normalize_workspace_paths_for_comparison(existing_content)
1669
+ if normalized_new_content.strip() == normalized_existing_content.strip():
813
1670
  if attempt < max_attempts - 1:
814
1671
  if self._check_restart_pending(agent_id):
1672
+ await self._save_partial_work_on_restart(agent_id)
815
1673
  yield (
816
1674
  "content",
817
- f"🔁 Agent [{agent_id}] gracefully restarting due to new answer detected",
1675
+ f"🔁 [{agent_id}] gracefully restarting due to new answer detected\n",
818
1676
  )
819
1677
  yield ("done", None)
820
1678
  return
821
1679
  error_msg = f"Answer already provided by {existing_agent_id}. Provide different answer or vote for existing one."
822
1680
  yield ("content", f"❌ {error_msg}")
823
1681
  # Create proper tool error message for retry
824
- enforcement_msg = (
825
- self._create_tool_error_messages(
826
- agent, [tool_call], error_msg
827
- )
828
- )
1682
+ enforcement_msg = self._create_tool_error_messages(agent, [tool_call], error_msg)
829
1683
  continue
830
1684
  else:
831
1685
  yield (
@@ -839,7 +1693,8 @@ class Orchestrator(ChatAgent):
839
1693
  yield ("result", ("answer", content))
840
1694
  yield ("done", None)
841
1695
  return
842
-
1696
+ elif tool_name.startswith("mcp"):
1697
+ pass
843
1698
  else:
844
1699
  # Non-workflow tools not yet implemented
845
1700
  yield (
@@ -850,14 +1705,15 @@ class Orchestrator(ChatAgent):
850
1705
  # Case 3: Non-workflow response, need enforcement (only if no workflow tool was found)
851
1706
  if not workflow_tool_found:
852
1707
  if self._check_restart_pending(agent_id):
1708
+ await self._save_partial_work_on_restart(agent_id)
853
1709
  yield (
854
1710
  "content",
855
- f"🔁 Agent [{agent_id}] gracefully restarting due to new answer detected",
1711
+ f"🔁 [{agent_id}] gracefully restarting due to new answer detected\n",
856
1712
  )
857
1713
  yield ("done", None)
858
1714
  return
859
1715
  if attempt < max_attempts - 1:
860
- yield ("content", f"🔄 needs to use workflow tools...")
1716
+ yield ("content", "🔄 needs to use workflow tools...\n")
861
1717
  # Reset to default enforcement message for this case
862
1718
  enforcement_msg = self.message_templates.enforcement_message()
863
1719
  continue # Retry with updated conversation
@@ -885,31 +1741,38 @@ class Orchestrator(ChatAgent):
885
1741
 
886
1742
  async def _present_final_answer(self) -> AsyncGenerator[StreamChunk, None]:
887
1743
  """Present the final coordinated answer."""
1744
+ log_stream_chunk("orchestrator", "content", "## 🎯 Final Coordinated Answer\n")
888
1745
  yield StreamChunk(type="content", content="## 🎯 Final Coordinated Answer\n")
889
1746
 
890
1747
  # Select the best agent based on current state
891
1748
  if not self._selected_agent:
892
1749
  self._selected_agent = self._determine_final_agent_from_states()
893
1750
  if self._selected_agent:
1751
+ log_stream_chunk(
1752
+ "orchestrator",
1753
+ "content",
1754
+ f"🏆 Selected Agent: {self._selected_agent}\n",
1755
+ )
894
1756
  yield StreamChunk(
895
1757
  type="content",
896
1758
  content=f"🏆 Selected Agent: {self._selected_agent}\n",
897
1759
  )
898
1760
 
899
- if (
900
- self._selected_agent
901
- and self._selected_agent in self.agent_states
902
- and self.agent_states[self._selected_agent].answer
903
- ):
904
- final_answer = self.agent_states[self._selected_agent].answer
1761
+ if self._selected_agent and self._selected_agent in self.agent_states and self.agent_states[self._selected_agent].answer:
1762
+ final_answer = self.agent_states[self._selected_agent].answer # NOTE: This is the raw answer from the winning agent, not the actual final answer.
905
1763
 
906
1764
  # Add to conversation history
907
1765
  self.add_to_history("assistant", final_answer)
908
1766
 
909
- yield StreamChunk(
910
- type="content", content=f"🏆 Selected Agent: {self._selected_agent}\n"
911
- )
1767
+ log_stream_chunk("orchestrator", "content", f"🏆 Selected Agent: {self._selected_agent}\n")
1768
+ yield StreamChunk(type="content", content=f"🏆 Selected Agent: {self._selected_agent}\n")
1769
+ log_stream_chunk("orchestrator", "content", final_answer)
912
1770
  yield StreamChunk(type="content", content=final_answer)
1771
+ log_stream_chunk(
1772
+ "orchestrator",
1773
+ "content",
1774
+ f"\n\n---\n*Coordinated by {len(self.agents)} agents via MassGen framework*",
1775
+ )
913
1776
  yield StreamChunk(
914
1777
  type="content",
915
1778
  content=f"\n\n---\n*Coordinated by {len(self.agents)} agents via MassGen framework*",
@@ -917,15 +1780,85 @@ class Orchestrator(ChatAgent):
917
1780
  else:
918
1781
  error_msg = "❌ Unable to provide coordinated answer - no successful agents"
919
1782
  self.add_to_history("assistant", error_msg)
1783
+ log_stream_chunk("orchestrator", "error", error_msg)
920
1784
  yield StreamChunk(type="content", content=error_msg)
921
1785
 
922
1786
  # Update workflow phase
923
1787
  self.workflow_phase = "presenting"
1788
+ log_stream_chunk("orchestrator", "done", None)
924
1789
  yield StreamChunk(type="done")
925
1790
 
926
- def _determine_final_agent_from_votes(
927
- self, votes: Dict[str, Dict], agent_answers: Dict[str, str]
928
- ) -> str:
1791
+ async def _handle_orchestrator_timeout(self) -> AsyncGenerator[StreamChunk, None]:
1792
+ """Handle orchestrator timeout by jumping directly to get_final_presentation."""
1793
+ # Output orchestrator timeout message first
1794
+ log_stream_chunk(
1795
+ "orchestrator",
1796
+ "content",
1797
+ f"\n⚠️ **Orchestrator Timeout**: {self.timeout_reason}\n",
1798
+ self.orchestrator_id,
1799
+ )
1800
+ yield StreamChunk(
1801
+ type="content",
1802
+ content=f"\n⚠️ **Orchestrator Timeout**: {self.timeout_reason}\n",
1803
+ source=self.orchestrator_id,
1804
+ )
1805
+
1806
+ # Count available answers
1807
+ available_answers = {aid: state.answer for aid, state in self.agent_states.items() if state.answer and not state.is_killed}
1808
+
1809
+ log_stream_chunk(
1810
+ "orchestrator",
1811
+ "content",
1812
+ f"📊 Current state: {len(available_answers)} answers available\n",
1813
+ self.orchestrator_id,
1814
+ )
1815
+ yield StreamChunk(
1816
+ type="content",
1817
+ content=f"📊 Current state: {len(available_answers)} answers available\n",
1818
+ source=self.orchestrator_id,
1819
+ )
1820
+
1821
+ # If no answers available, provide fallback with timeout explanation
1822
+ if len(available_answers) == 0:
1823
+ log_stream_chunk(
1824
+ "orchestrator",
1825
+ "error",
1826
+ "❌ No answers available from any agents due to timeout. No agents had enough time to provide responses.\n",
1827
+ self.orchestrator_id,
1828
+ )
1829
+ yield StreamChunk(
1830
+ type="content",
1831
+ content="❌ No answers available from any agents due to timeout. No agents had enough time to provide responses.\n",
1832
+ source=self.orchestrator_id,
1833
+ )
1834
+ self.workflow_phase = "presenting"
1835
+ log_stream_chunk("orchestrator", "done", None)
1836
+ yield StreamChunk(type="done")
1837
+ return
1838
+
1839
+ # Determine best available agent for presentation
1840
+ current_votes = {aid: state.votes for aid, state in self.agent_states.items() if state.votes and not state.is_killed}
1841
+
1842
+ self._selected_agent = self._determine_final_agent_from_votes(current_votes, available_answers)
1843
+
1844
+ # Jump directly to get_final_presentation
1845
+ vote_results = self._get_vote_results()
1846
+ log_stream_chunk(
1847
+ "orchestrator",
1848
+ "content",
1849
+ f"🎯 Jumping to final presentation with {self._selected_agent} (selected despite timeout)\n",
1850
+ self.orchestrator_id,
1851
+ )
1852
+ yield StreamChunk(
1853
+ type="content",
1854
+ content=f"🎯 Jumping to final presentation with {self._selected_agent} (selected despite timeout)\n",
1855
+ source=self.orchestrator_id,
1856
+ )
1857
+
1858
+ async for chunk in self.get_final_presentation(self._selected_agent, vote_results):
1859
+ yield chunk
1860
+
1861
+ def _determine_final_agent_from_votes(self, votes: Dict[str, Dict], agent_answers: Dict[str, str]) -> str:
929
1862
  """Determine which agent should present the final answer based on votes."""
930
1863
  if not votes:
931
1864
  # No votes yet, return first agent with an answer (earliest by generation time)
@@ -943,9 +1876,7 @@ class Orchestrator(ChatAgent):
943
1876
 
944
1877
  # Find agents with maximum votes
945
1878
  max_votes = max(vote_counts.values())
946
- tied_agents = [
947
- agent_id for agent_id, count in vote_counts.items() if count == max_votes
948
- ]
1879
+ tied_agents = [agent_id for agent_id, count in vote_counts.items() if count == max_votes]
949
1880
 
950
1881
  # Break ties by agent registration order (order in agent_states dict)
951
1882
  for agent_id in agent_answers.keys():
@@ -953,30 +1884,44 @@ class Orchestrator(ChatAgent):
953
1884
  return agent_id
954
1885
 
955
1886
  # Fallback to first tied agent
956
- return (
957
- tied_agents[0]
958
- if tied_agents
959
- else next(iter(agent_answers)) if agent_answers else None
960
- )
1887
+ return tied_agents[0] if tied_agents else next(iter(agent_answers)) if agent_answers else None
961
1888
 
962
- async def get_final_presentation(
963
- self, selected_agent_id: str, vote_results: Dict[str, Any]
964
- ) -> AsyncGenerator[StreamChunk, None]:
1889
+ async def get_final_presentation(self, selected_agent_id: str, vote_results: Dict[str, Any]) -> AsyncGenerator[StreamChunk, None]:
965
1890
  """Ask the winning agent to present their final answer with voting context."""
1891
+ # Start tracking the final round
1892
+ self.coordination_tracker.start_final_round(selected_agent_id)
1893
+
966
1894
  if selected_agent_id not in self.agents:
967
- yield StreamChunk(
968
- type="error", error=f"Selected agent {selected_agent_id} not found"
969
- )
1895
+ log_stream_chunk("orchestrator", "error", f"Selected agent {selected_agent_id} not found")
1896
+ yield StreamChunk(type="error", error=f"Selected agent {selected_agent_id} not found")
970
1897
  return
971
1898
 
972
1899
  agent = self.agents[selected_agent_id]
973
1900
 
1901
+ # Enable write access for final agent on context paths. This ensures that those paths marked `write` by the user are now writable (as all previous agents were read-only).
1902
+ if agent.backend.filesystem_manager:
1903
+ agent.backend.filesystem_manager.path_permission_manager.set_context_write_access_enabled(True)
1904
+
1905
+ # Reset backend planning mode to allow MCP tool execution during final presentation
1906
+ if hasattr(agent.backend, "set_planning_mode"):
1907
+ agent.backend.set_planning_mode(False)
1908
+ logger.info(f"[Orchestrator] Backend planning mode DISABLED for final presentation: {selected_agent_id} - MCP tools now allowed")
1909
+
1910
+ # Copy all agents' snapshots to temp workspace to preserve context from coordination phase
1911
+ # This allows the agent to reference and access previous work
1912
+ temp_workspace_path = await self._copy_all_snapshots_to_temp_workspace(selected_agent_id)
1913
+ yield StreamChunk(
1914
+ type="debug",
1915
+ content=f"Restored workspace context for final presentation: {temp_workspace_path}",
1916
+ source=selected_agent_id,
1917
+ )
1918
+
974
1919
  # Prepare context about the voting
975
1920
  vote_counts = vote_results.get("vote_counts", {})
976
1921
  voter_details = vote_results.get("voter_details", {})
977
1922
  is_tie = vote_results.get("is_tie", False)
978
1923
 
979
- # Build voting summary
1924
+ # Build voting summary -- note we only include the number of votes and reasons for the selected agent. There is no information about the distribution of votes beyond this.
980
1925
  voting_summary = f"You received {vote_counts.get(selected_agent_id, 0)} vote(s)"
981
1926
  if voter_details.get(selected_agent_id):
982
1927
  reasons = [v["reason"] for v in voter_details[selected_agent_id]]
@@ -986,64 +1931,270 @@ class Orchestrator(ChatAgent):
986
1931
  voting_summary += " (tie-broken by registration order)"
987
1932
 
988
1933
  # Get all answers for context
989
- all_answers = {
990
- aid: s.answer for aid, s in self.agent_states.items() if s.answer
991
- }
1934
+ all_answers = {aid: s.answer for aid, s in self.agent_states.items() if s.answer}
1935
+
1936
+ # Normalize workspace paths in both voting summary and all answers for final presentation. Use same function for consistency.
1937
+ normalized_voting_summary = self._normalize_workspace_paths_in_answers({selected_agent_id: voting_summary}, selected_agent_id)[selected_agent_id]
1938
+ normalized_all_answers = self._normalize_workspace_paths_in_answers(all_answers, selected_agent_id)
992
1939
 
993
1940
  # Use MessageTemplates to build the presentation message
994
1941
  presentation_content = self.message_templates.build_final_presentation_message(
995
1942
  original_task=self.current_task or "Task coordination",
996
- vote_summary=voting_summary,
997
- all_answers=all_answers,
1943
+ vote_summary=normalized_voting_summary,
1944
+ all_answers=normalized_all_answers,
998
1945
  selected_agent_id=selected_agent_id,
999
1946
  )
1000
1947
 
1001
- # Get agent's original system message if available
1002
- agent_system_message = getattr(agent, "system_message", None)
1948
+ # Get agent's configurable system message using the standard interface
1949
+ agent_system_message = agent.get_configurable_system_message()
1950
+
1951
+ # Check if image generation is enabled for this agent
1952
+ enable_image_generation = False
1953
+ if hasattr(agent, "config") and agent.config:
1954
+ enable_image_generation = agent.config.backend_params.get("enable_image_generation", False)
1955
+ elif hasattr(agent, "backend") and hasattr(agent.backend, "backend_params"):
1956
+ enable_image_generation = agent.backend.backend_params.get("enable_image_generation", False)
1957
+
1958
+ # Extract command execution parameters
1959
+ enable_command_execution = False
1960
+ if hasattr(agent, "config") and agent.config:
1961
+ enable_command_execution = agent.config.backend_params.get("enable_mcp_command_line", False)
1962
+ elif hasattr(agent, "backend") and hasattr(agent.backend, "backend_params"):
1963
+ enable_command_execution = agent.backend.backend_params.get("enable_mcp_command_line", False)
1964
+ # Check if audio generation is enabled for this agent
1965
+ enable_audio_generation = False
1966
+ if hasattr(agent, "config") and agent.config:
1967
+ enable_audio_generation = agent.config.backend_params.get("enable_audio_generation", False)
1968
+ elif hasattr(agent, "backend") and hasattr(agent.backend, "backend_params"):
1969
+ enable_audio_generation = agent.backend.backend_params.get("enable_audio_generation", False)
1970
+
1971
+ # Check if agent has write access to context paths (requires file delivery)
1972
+ has_irreversible_actions = False
1973
+ if agent.backend.filesystem_manager:
1974
+ context_paths = agent.backend.filesystem_manager.path_permission_manager.get_context_paths()
1975
+ # Check if any context path has write permission
1976
+ has_irreversible_actions = any(cp.get("permission") == "write" for cp in context_paths)
1977
+
1978
+ # Build system message with workspace context if available
1979
+ base_system_message = self.message_templates.final_presentation_system_message(
1980
+ agent_system_message,
1981
+ enable_image_generation,
1982
+ enable_audio_generation,
1983
+ has_irreversible_actions,
1984
+ enable_command_execution,
1985
+ )
1986
+
1987
+ # Change the status of all agents that were not selected to AgentStatus.COMPLETED
1988
+ for aid, state in self.agent_states.items():
1989
+ if aid != selected_agent_id:
1990
+ self.coordination_tracker.change_status(aid, AgentStatus.COMPLETED)
1991
+
1992
+ self.coordination_tracker.set_final_agent(selected_agent_id, voting_summary, all_answers)
1993
+
1994
+ # Add workspace context information to system message if workspace was restored
1995
+ if agent.backend.filesystem_manager and temp_workspace_path:
1996
+ main_workspace = str(agent.backend.filesystem_manager.get_current_workspace())
1997
+ temp_workspace = str(agent.backend.filesystem_manager.agent_temporary_workspace) if agent.backend.filesystem_manager.agent_temporary_workspace else None
1998
+ # Get context paths if available
1999
+ context_paths = agent.backend.filesystem_manager.path_permission_manager.get_context_paths() if agent.backend.filesystem_manager.path_permission_manager else []
2000
+
2001
+ # Add previous turns as read-only context paths (only n-2 and earlier)
2002
+ previous_turns_context = self._get_previous_turns_context_paths()
2003
+
2004
+ # Filter to only show turn n-2 and earlier
2005
+ current_turn_num = len(previous_turns_context) + 1 if previous_turns_context else 1
2006
+ turns_to_show = [t for t in previous_turns_context if t["turn"] < current_turn_num - 1]
2007
+
2008
+ # Check if workspace was pre-populated
2009
+ workspace_prepopulated = len(previous_turns_context) > 0
2010
+
2011
+ base_system_message = (
2012
+ self.message_templates.filesystem_system_message(
2013
+ main_workspace=main_workspace,
2014
+ temp_workspace=temp_workspace,
2015
+ context_paths=context_paths,
2016
+ previous_turns=turns_to_show,
2017
+ workspace_prepopulated=workspace_prepopulated,
2018
+ enable_image_generation=enable_image_generation,
2019
+ agent_answers=all_answers,
2020
+ enable_command_execution=enable_command_execution,
2021
+ )
2022
+ + "\n\n## Instructions\n"
2023
+ + base_system_message
2024
+ )
2025
+
1003
2026
  # Create conversation with system and user messages
1004
2027
  presentation_messages = [
1005
2028
  {
1006
2029
  "role": "system",
1007
- "content": self.message_templates.final_presentation_system_message(
1008
- agent_system_message
1009
- ),
2030
+ "content": base_system_message,
1010
2031
  },
1011
2032
  {"role": "user", "content": presentation_content},
1012
2033
  ]
2034
+
2035
+ # Store the final context in agent state for saving
2036
+ self.agent_states[selected_agent_id].last_context = {
2037
+ "messages": presentation_messages,
2038
+ "is_final": True,
2039
+ "vote_summary": voting_summary,
2040
+ "all_answers": all_answers,
2041
+ "complete_vote_results": vote_results, # Include ALL vote data
2042
+ "vote_counts": vote_counts,
2043
+ "voter_details": voter_details,
2044
+ "all_votes": {aid: state.votes for aid, state in self.agent_states.items() if state.votes}, # All individual votes
2045
+ }
2046
+
2047
+ log_stream_chunk(
2048
+ "orchestrator",
2049
+ "status",
2050
+ f"🎤 [{selected_agent_id}] presenting final answer\n",
2051
+ )
1013
2052
  yield StreamChunk(
1014
2053
  type="status",
1015
2054
  content=f"🎤 [{selected_agent_id}] presenting final answer\n",
1016
2055
  )
1017
2056
 
1018
2057
  # Use agent's chat method with proper system message (reset chat for clean presentation)
1019
- async for chunk in agent.chat(presentation_messages, reset_chat=True):
1020
- # Use the same streaming approach as regular coordination
1021
- if chunk.type == "content" and chunk.content:
1022
- yield StreamChunk(
1023
- type="content", content=chunk.content, source=selected_agent_id
1024
- )
1025
- elif chunk.type == "done":
1026
- yield StreamChunk(type="done", source=selected_agent_id)
1027
- elif chunk.type == "error":
1028
- yield StreamChunk(
1029
- type="error", error=chunk.error, source=selected_agent_id
1030
- )
1031
- # Pass through other chunk types as-is but with source
2058
+ presentation_content = ""
2059
+
2060
+ try:
2061
+ # Track final round iterations (each chunk is like an iteration)
2062
+ async for chunk in agent.chat(presentation_messages, reset_chat=True, current_stage=CoordinationStage.PRESENTATION):
2063
+ chunk_type = self._get_chunk_type_value(chunk)
2064
+ # Start new iteration for this chunk
2065
+ self.coordination_tracker.start_new_iteration()
2066
+ # Use the same streaming approach as regular coordination
2067
+ if chunk_type == "content" and chunk.content:
2068
+ presentation_content += chunk.content
2069
+ log_stream_chunk("orchestrator", "content", chunk.content, selected_agent_id)
2070
+ yield StreamChunk(type="content", content=chunk.content, source=selected_agent_id)
2071
+ elif chunk_type in [
2072
+ "reasoning",
2073
+ "reasoning_done",
2074
+ "reasoning_summary",
2075
+ "reasoning_summary_done",
2076
+ ]:
2077
+ # Stream reasoning content with proper attribution (same as main coordination)
2078
+ reasoning_chunk = StreamChunk(
2079
+ type=chunk_type,
2080
+ content=chunk.content,
2081
+ source=selected_agent_id,
2082
+ reasoning_delta=getattr(chunk, "reasoning_delta", None),
2083
+ reasoning_text=getattr(chunk, "reasoning_text", None),
2084
+ reasoning_summary_delta=getattr(chunk, "reasoning_summary_delta", None),
2085
+ reasoning_summary_text=getattr(chunk, "reasoning_summary_text", None),
2086
+ item_id=getattr(chunk, "item_id", None),
2087
+ content_index=getattr(chunk, "content_index", None),
2088
+ summary_index=getattr(chunk, "summary_index", None),
2089
+ )
2090
+ # Use the same format as main coordination for consistency
2091
+ log_stream_chunk("orchestrator", chunk.type, chunk.content, selected_agent_id)
2092
+ yield reasoning_chunk
2093
+ elif chunk_type == "backend_status":
2094
+ import json
2095
+
2096
+ status_json = json.loads(chunk.content)
2097
+ cwd = status_json["cwd"]
2098
+ session_id = status_json["session_id"]
2099
+ content = f"""Final Temp Working directory: {cwd}.
2100
+ Final Session ID: {session_id}.
2101
+ """
2102
+
2103
+ log_stream_chunk("orchestrator", "content", content, selected_agent_id)
2104
+ yield StreamChunk(type="content", content=content, source=selected_agent_id)
2105
+ elif chunk_type == "mcp_status":
2106
+ # Handle MCP status messages in final presentation
2107
+ mcp_content = f"🔧 MCP: {chunk.content}"
2108
+ log_stream_chunk("orchestrator", "content", mcp_content, selected_agent_id)
2109
+ yield StreamChunk(type="content", content=mcp_content, source=selected_agent_id)
2110
+ elif chunk_type == "done":
2111
+ # Save the final workspace snapshot (from final workspace directory)
2112
+ final_answer = presentation_content.strip() if presentation_content.strip() else self.agent_states[selected_agent_id].answer # fallback to stored answer if no content generated
2113
+ final_context = self.get_last_context(selected_agent_id)
2114
+ await self._save_agent_snapshot(
2115
+ self._selected_agent,
2116
+ answer_content=final_answer,
2117
+ is_final=True,
2118
+ context_data=final_context,
2119
+ )
2120
+
2121
+ # Track the final answer in coordination tracker
2122
+ self.coordination_tracker.set_final_answer(selected_agent_id, final_answer, snapshot_timestamp="final")
2123
+
2124
+ log_stream_chunk("orchestrator", "done", None, selected_agent_id)
2125
+ yield StreamChunk(type="done", source=selected_agent_id)
2126
+ elif chunk_type == "error":
2127
+ log_stream_chunk("orchestrator", "error", chunk.error, selected_agent_id)
2128
+ yield StreamChunk(type="error", error=chunk.error, source=selected_agent_id)
2129
+ # Pass through other chunk types as-is but with source
2130
+ else:
2131
+ if hasattr(chunk, "source"):
2132
+ log_stream_chunk(
2133
+ "orchestrator",
2134
+ chunk_type,
2135
+ getattr(chunk, "content", ""),
2136
+ selected_agent_id,
2137
+ )
2138
+ yield StreamChunk(
2139
+ type=chunk_type,
2140
+ content=getattr(chunk, "content", ""),
2141
+ source=selected_agent_id,
2142
+ **{k: v for k, v in chunk.__dict__.items() if k not in ["type", "content", "source"]},
2143
+ )
2144
+ else:
2145
+ log_stream_chunk(
2146
+ "orchestrator",
2147
+ chunk_type,
2148
+ getattr(chunk, "content", ""),
2149
+ selected_agent_id,
2150
+ )
2151
+ yield StreamChunk(
2152
+ type=chunk_type,
2153
+ content=getattr(chunk, "content", ""),
2154
+ source=selected_agent_id,
2155
+ **{k: v for k, v in chunk.__dict__.items() if k not in ["type", "content", "source"]},
2156
+ )
2157
+
2158
+ finally:
2159
+ # Store the final presentation content for logging
2160
+ if presentation_content.strip():
2161
+ # Store the synthesized final answer
2162
+ self._final_presentation_content = presentation_content.strip()
1032
2163
  else:
1033
- if hasattr(chunk, "source"):
1034
- chunk.source = selected_agent_id
1035
- yield chunk
2164
+ # If no content was generated, use the stored answer as fallback
2165
+ stored_answer = self.agent_states[selected_agent_id].answer
2166
+ if stored_answer:
2167
+ fallback_content = f"\n📋 Using stored answer as final presentation:\n\n{stored_answer}"
2168
+ log_stream_chunk("orchestrator", "content", fallback_content, selected_agent_id)
2169
+ yield StreamChunk(
2170
+ type="content",
2171
+ content=fallback_content,
2172
+ source=selected_agent_id,
2173
+ )
2174
+ self._final_presentation_content = stored_answer
2175
+ else:
2176
+ log_stream_chunk(
2177
+ "orchestrator",
2178
+ "error",
2179
+ "\n❌ No content generated for final presentation and no stored answer available.",
2180
+ selected_agent_id,
2181
+ )
2182
+ yield StreamChunk(
2183
+ type="content",
2184
+ content="\n❌ No content generated for final presentation and no stored answer available.",
2185
+ source=selected_agent_id,
2186
+ )
2187
+
2188
+ # Mark final round as completed
2189
+ self.coordination_tracker.change_status(selected_agent_id, AgentStatus.COMPLETED)
2190
+
2191
+ # Save logs
2192
+ self.save_coordination_logs()
1036
2193
 
1037
2194
  def _get_vote_results(self) -> Dict[str, Any]:
1038
2195
  """Get current vote results and statistics."""
1039
- agent_answers = {
1040
- aid: state.answer
1041
- for aid, state in self.agent_states.items()
1042
- if state.answer
1043
- }
1044
- votes = {
1045
- aid: state.votes for aid, state in self.agent_states.items() if state.votes
1046
- }
2196
+ agent_answers = {aid: state.answer for aid, state in self.agent_states.items() if state.answer}
2197
+ votes = {aid: state.votes for aid, state in self.agent_states.items() if state.votes}
1047
2198
 
1048
2199
  # Count votes for each agent
1049
2200
  vote_counts = {}
@@ -1059,7 +2210,7 @@ class Orchestrator(ChatAgent):
1059
2210
  {
1060
2211
  "voter": voter_id,
1061
2212
  "reason": vote_data.get("reason", "No reason provided"),
1062
- }
2213
+ },
1063
2214
  )
1064
2215
 
1065
2216
  # Determine winner
@@ -1067,11 +2218,7 @@ class Orchestrator(ChatAgent):
1067
2218
  is_tie = False
1068
2219
  if vote_counts:
1069
2220
  max_votes = max(vote_counts.values())
1070
- tied_agents = [
1071
- agent_id
1072
- for agent_id, count in vote_counts.items()
1073
- if count == max_votes
1074
- ]
2221
+ tied_agents = [agent_id for agent_id, count in vote_counts.items() if count == max_votes]
1075
2222
  is_tie = len(tied_agents) > 1
1076
2223
 
1077
2224
  # Break ties by agent registration order
@@ -1083,6 +2230,11 @@ class Orchestrator(ChatAgent):
1083
2230
  if not winner:
1084
2231
  winner = tied_agents[0] if tied_agents else None
1085
2232
 
2233
+ # Create agent mapping for anonymous display
2234
+ agent_mapping = {}
2235
+ for i, real_id in enumerate(sorted(agent_answers.keys()), 1):
2236
+ agent_mapping[f"agent{i}"] = real_id
2237
+
1086
2238
  return {
1087
2239
  "vote_counts": vote_counts,
1088
2240
  "voter_details": voter_details,
@@ -1091,16 +2243,13 @@ class Orchestrator(ChatAgent):
1091
2243
  "total_votes": len(votes),
1092
2244
  "agents_with_answers": len(agent_answers),
1093
2245
  "agents_voted": len([v for v in votes.values() if v.get("agent_id")]),
2246
+ "agent_mapping": agent_mapping,
1094
2247
  }
1095
2248
 
1096
2249
  def _determine_final_agent_from_states(self) -> Optional[str]:
1097
2250
  """Determine final agent based on current agent states."""
1098
2251
  # Find agents with answers
1099
- agents_with_answers = {
1100
- aid: state.answer
1101
- for aid, state in self.agent_states.items()
1102
- if state.answer
1103
- }
2252
+ agents_with_answers = {aid: state.answer for aid, state in self.agent_states.items() if state.answer}
1104
2253
 
1105
2254
  if not agents_with_answers:
1106
2255
  return None
@@ -1108,27 +2257,37 @@ class Orchestrator(ChatAgent):
1108
2257
  # Return the first agent with an answer (by order in agent_states)
1109
2258
  return next(iter(agents_with_answers))
1110
2259
 
1111
- async def _handle_followup(
1112
- self, user_message: str, conversation_context: Optional[Dict[str, Any]] = None
1113
- ) -> AsyncGenerator[StreamChunk, None]:
2260
+ async def _handle_followup(self, user_message: str, conversation_context: Optional[Dict[str, Any]] = None) -> AsyncGenerator[StreamChunk, None]:
1114
2261
  """Handle follow-up questions after presenting final answer with conversation context."""
1115
2262
  # For now, acknowledge with context awareness
1116
2263
  # Future: implement full re-coordination with follow-up context
1117
2264
 
1118
- if (
1119
- conversation_context
1120
- and len(conversation_context.get("conversation_history", [])) > 0
1121
- ):
2265
+ if conversation_context and len(conversation_context.get("conversation_history", [])) > 0:
2266
+ log_stream_chunk(
2267
+ "orchestrator",
2268
+ "content",
2269
+ f"🤔 Thank you for your follow-up question in our ongoing conversation. I understand you're asking: "
2270
+ f"'{user_message}'. Currently, the coordination is complete, but I can help clarify the answer or "
2271
+ f"coordinate a new task that takes our conversation history into account.",
2272
+ )
1122
2273
  yield StreamChunk(
1123
2274
  type="content",
1124
- content=f"🤔 Thank you for your follow-up question in our ongoing conversation. I understand you're asking: '{user_message}'. Currently, the coordination is complete, but I can help clarify the answer or coordinate a new task that takes our conversation history into account.",
2275
+ content=f"🤔 Thank you for your follow-up question in our ongoing conversation. I understand you're "
2276
+ f"asking: '{user_message}'. Currently, the coordination is complete, but I can help clarify the answer "
2277
+ f"or coordinate a new task that takes our conversation history into account.",
1125
2278
  )
1126
2279
  else:
2280
+ log_stream_chunk(
2281
+ "orchestrator",
2282
+ "content",
2283
+ f"🤔 Thank you for your follow-up: '{user_message}'. The coordination is complete, but I can help clarify the answer or coordinate a new task if needed.",
2284
+ )
1127
2285
  yield StreamChunk(
1128
2286
  type="content",
1129
2287
  content=f"🤔 Thank you for your follow-up: '{user_message}'. The coordination is complete, but I can help clarify the answer or coordinate a new task if needed.",
1130
2288
  )
1131
2289
 
2290
+ log_stream_chunk("orchestrator", "done", None)
1132
2291
  yield StreamChunk(type="done")
1133
2292
 
1134
2293
  # =============================================================================
@@ -1147,6 +2306,27 @@ class Orchestrator(ChatAgent):
1147
2306
  if agent_id in self.agent_states:
1148
2307
  del self.agent_states[agent_id]
1149
2308
 
2309
+ def get_final_result(self) -> Optional[Dict[str, Any]]:
2310
+ """
2311
+ Get final result for session persistence.
2312
+
2313
+ Returns:
2314
+ Dict with final_answer, winning_agent_id, and workspace_path, or None if not available
2315
+ """
2316
+ if not self._selected_agent or not self._final_presentation_content:
2317
+ return None
2318
+
2319
+ winning_agent = self.agents.get(self._selected_agent)
2320
+ workspace_path = None
2321
+ if winning_agent and winning_agent.backend.filesystem_manager:
2322
+ workspace_path = str(winning_agent.backend.filesystem_manager.get_current_workspace())
2323
+
2324
+ return {
2325
+ "final_answer": self._final_presentation_content,
2326
+ "winning_agent_id": self._selected_agent,
2327
+ "workspace_path": workspace_path,
2328
+ }
2329
+
1150
2330
  def get_status(self) -> Dict[str, Any]:
1151
2331
  """Get current orchestrator status."""
1152
2332
  # Calculate vote results
@@ -1157,6 +2337,7 @@ class Orchestrator(ChatAgent):
1157
2337
  "workflow_phase": self.workflow_phase,
1158
2338
  "current_task": self.current_task,
1159
2339
  "selected_agent": self._selected_agent,
2340
+ "final_presentation_content": self._final_presentation_content,
1160
2341
  "vote_results": vote_results,
1161
2342
  "agents": {
1162
2343
  aid: {
@@ -1174,19 +2355,106 @@ class Orchestrator(ChatAgent):
1174
2355
  "conversation_length": len(self.conversation_history),
1175
2356
  }
1176
2357
 
1177
- def reset(self) -> None:
2358
+ def get_configurable_system_message(self) -> Optional[str]:
2359
+ """
2360
+ Get the configurable system message for the orchestrator.
2361
+
2362
+ This can define how the orchestrator should coordinate agents, construct messages,
2363
+ handle conflicts, make decisions, etc. For example:
2364
+ - Custom voting strategies
2365
+ - Message construction templates
2366
+ - Conflict resolution approaches
2367
+ - Coordination workflow preferences
2368
+
2369
+ Returns:
2370
+ Orchestrator's configurable system message if available, None otherwise
2371
+ """
2372
+ if self.config and hasattr(self.config, "get_configurable_system_message"):
2373
+ return self.config.get_configurable_system_message()
2374
+ elif self.config and hasattr(self.config, "custom_system_instruction"):
2375
+ return self.config.custom_system_instruction
2376
+ elif self.config and self.config.backend_params:
2377
+ # Check for backend-specific system prompts
2378
+ backend_params = self.config.backend_params
2379
+ if "system_prompt" in backend_params:
2380
+ return backend_params["system_prompt"]
2381
+ elif "append_system_prompt" in backend_params:
2382
+ return backend_params["append_system_prompt"]
2383
+ return None
2384
+
2385
+ def _clear_agent_workspaces(self) -> None:
2386
+ """
2387
+ Clear all agent workspaces and pre-populate with previous turn's results.
2388
+
2389
+ This creates a WRITABLE copy of turn n-1 in each agent's workspace.
2390
+ Note: CLI separately provides turn n-1 as a READ-ONLY context path, allowing
2391
+ agents to both modify files (in workspace) and reference originals (via context path).
2392
+ """
2393
+ # Get previous turn (n-1) workspace for pre-population
2394
+ previous_turn_workspace = None
2395
+ if self._previous_turns:
2396
+ # Get the most recent turn (last in list)
2397
+ latest_turn = self._previous_turns[-1]
2398
+ previous_turn_workspace = Path(latest_turn["path"])
2399
+
2400
+ for agent_id, agent in self.agents.items():
2401
+ if agent.backend.filesystem_manager:
2402
+ workspace_path = agent.backend.filesystem_manager.get_current_workspace()
2403
+ if workspace_path and Path(workspace_path).exists():
2404
+ # Clear workspace contents but keep the directory
2405
+ for item in Path(workspace_path).iterdir():
2406
+ if item.is_file():
2407
+ item.unlink()
2408
+ elif item.is_dir():
2409
+ shutil.rmtree(item)
2410
+ logger.info(f"[Orchestrator] Cleared workspace for {agent_id}: {workspace_path}")
2411
+
2412
+ # Pre-populate with previous turn's results if available (creates writable copy)
2413
+ if previous_turn_workspace and previous_turn_workspace.exists():
2414
+ logger.info(f"[Orchestrator] Pre-populating {agent_id} workspace with writable copy of turn n-1 from {previous_turn_workspace}")
2415
+ for item in previous_turn_workspace.iterdir():
2416
+ dest = Path(workspace_path) / item.name
2417
+ if item.is_file():
2418
+ shutil.copy2(item, dest)
2419
+ elif item.is_dir():
2420
+ shutil.copytree(item, dest, dirs_exist_ok=True)
2421
+ logger.info(f"[Orchestrator] Pre-populated {agent_id} workspace with writable copy of turn n-1")
2422
+
2423
+ def _get_previous_turns_context_paths(self) -> List[Dict[str, Any]]:
2424
+ """
2425
+ Get previous turns as context paths for current turn's agents.
2426
+
2427
+ Returns:
2428
+ List of previous turn information with path, turn number, and task
2429
+ """
2430
+ return self._previous_turns
2431
+
2432
+ async def reset(self) -> None:
1178
2433
  """Reset orchestrator state for new task."""
1179
2434
  self.conversation_history.clear()
1180
2435
  self.current_task = None
1181
2436
  self.workflow_phase = "idle"
1182
2437
  self._coordination_messages.clear()
1183
2438
  self._selected_agent = None
2439
+ self._final_presentation_content = None
1184
2440
 
1185
2441
  # Reset agent states
1186
2442
  for state in self.agent_states.values():
1187
2443
  state.answer = None
1188
2444
  state.has_voted = False
1189
2445
  state.restart_pending = False
2446
+ state.is_killed = False
2447
+ state.timeout_reason = None
2448
+
2449
+ # Reset orchestrator timeout tracking
2450
+ self.total_tokens = 0
2451
+ self.coordination_start_time = 0
2452
+ self.is_orchestrator_timeout = False
2453
+ self.timeout_reason = None
2454
+
2455
+ # Clear coordination state
2456
+ self._active_streams = {}
2457
+ self._active_tasks = {}
1190
2458
 
1191
2459
 
1192
2460
  # =============================================================================
@@ -1199,6 +2467,8 @@ def create_orchestrator(
1199
2467
  orchestrator_id: str = "orchestrator",
1200
2468
  session_id: Optional[str] = None,
1201
2469
  config: Optional[AgentConfig] = None,
2470
+ snapshot_storage: Optional[str] = None,
2471
+ agent_temporary_workspace: Optional[str] = None,
1202
2472
  ) -> Orchestrator:
1203
2473
  """
1204
2474
  Create a MassGen orchestrator with sub-agents.
@@ -1208,6 +2478,8 @@ def create_orchestrator(
1208
2478
  orchestrator_id: Unique identifier for this orchestrator (default: "orchestrator")
1209
2479
  session_id: Optional session ID
1210
2480
  config: Optional AgentConfig for orchestrator customization
2481
+ snapshot_storage: Optional path to store agent workspace snapshots
2482
+ agent_temporary_workspace: Optional path for agent temporary workspaces (for Claude Code context sharing)
1211
2483
 
1212
2484
  Returns:
1213
2485
  Configured Orchestrator
@@ -1219,4 +2491,6 @@ def create_orchestrator(
1219
2491
  orchestrator_id=orchestrator_id,
1220
2492
  session_id=session_id,
1221
2493
  config=config,
2494
+ snapshot_storage=snapshot_storage,
2495
+ agent_temporary_workspace=agent_temporary_workspace,
1222
2496
  )