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.
- massgen/__init__.py +142 -8
- massgen/adapters/__init__.py +29 -0
- massgen/adapters/ag2_adapter.py +483 -0
- massgen/adapters/base.py +183 -0
- massgen/adapters/tests/__init__.py +0 -0
- massgen/adapters/tests/test_ag2_adapter.py +439 -0
- massgen/adapters/tests/test_agent_adapter.py +128 -0
- massgen/adapters/utils/__init__.py +2 -0
- massgen/adapters/utils/ag2_utils.py +236 -0
- massgen/adapters/utils/tests/__init__.py +0 -0
- massgen/adapters/utils/tests/test_ag2_utils.py +138 -0
- massgen/agent_config.py +329 -55
- massgen/api_params_handler/__init__.py +10 -0
- massgen/api_params_handler/_api_params_handler_base.py +99 -0
- massgen/api_params_handler/_chat_completions_api_params_handler.py +176 -0
- massgen/api_params_handler/_claude_api_params_handler.py +113 -0
- massgen/api_params_handler/_response_api_params_handler.py +130 -0
- massgen/backend/__init__.py +39 -4
- massgen/backend/azure_openai.py +385 -0
- massgen/backend/base.py +341 -69
- massgen/backend/base_with_mcp.py +1102 -0
- massgen/backend/capabilities.py +386 -0
- massgen/backend/chat_completions.py +577 -130
- massgen/backend/claude.py +1033 -537
- massgen/backend/claude_code.py +1203 -0
- massgen/backend/cli_base.py +209 -0
- massgen/backend/docs/BACKEND_ARCHITECTURE.md +126 -0
- massgen/backend/{CLAUDE_API_RESEARCH.md → docs/CLAUDE_API_RESEARCH.md} +18 -18
- massgen/backend/{GEMINI_API_DOCUMENTATION.md → docs/GEMINI_API_DOCUMENTATION.md} +9 -9
- massgen/backend/docs/Gemini MCP Integration Analysis.md +1050 -0
- massgen/backend/docs/MCP_IMPLEMENTATION_CLAUDE_BACKEND.md +177 -0
- massgen/backend/docs/MCP_INTEGRATION_RESPONSE_BACKEND.md +352 -0
- massgen/backend/docs/OPENAI_GPT5_MODELS.md +211 -0
- massgen/backend/{OPENAI_RESPONSES_API_FORMAT.md → docs/OPENAI_RESPONSE_API_TOOL_CALLS.md} +3 -3
- massgen/backend/docs/OPENAI_response_streaming.md +20654 -0
- massgen/backend/docs/inference_backend.md +257 -0
- massgen/backend/docs/permissions_and_context_files.md +1085 -0
- massgen/backend/external.py +126 -0
- massgen/backend/gemini.py +1850 -241
- massgen/backend/grok.py +40 -156
- massgen/backend/inference.py +156 -0
- massgen/backend/lmstudio.py +171 -0
- massgen/backend/response.py +1095 -322
- massgen/chat_agent.py +131 -113
- massgen/cli.py +1560 -275
- massgen/config_builder.py +2396 -0
- massgen/configs/BACKEND_CONFIGURATION.md +458 -0
- massgen/configs/README.md +559 -216
- massgen/configs/ag2/ag2_case_study.yaml +27 -0
- massgen/configs/ag2/ag2_coder.yaml +34 -0
- massgen/configs/ag2/ag2_coder_case_study.yaml +36 -0
- massgen/configs/ag2/ag2_gemini.yaml +27 -0
- massgen/configs/ag2/ag2_groupchat.yaml +108 -0
- massgen/configs/ag2/ag2_groupchat_gpt.yaml +118 -0
- massgen/configs/ag2/ag2_single_agent.yaml +21 -0
- massgen/configs/basic/multi/fast_timeout_example.yaml +37 -0
- massgen/configs/basic/multi/gemini_4o_claude.yaml +31 -0
- massgen/configs/basic/multi/gemini_gpt5nano_claude.yaml +36 -0
- massgen/configs/{gemini_4o_claude.yaml → basic/multi/geminicode_4o_claude.yaml} +3 -3
- massgen/configs/basic/multi/geminicode_gpt5nano_claude.yaml +36 -0
- massgen/configs/basic/multi/glm_gemini_claude.yaml +25 -0
- massgen/configs/basic/multi/gpt4o_audio_generation.yaml +30 -0
- massgen/configs/basic/multi/gpt4o_image_generation.yaml +31 -0
- massgen/configs/basic/multi/gpt5nano_glm_qwen.yaml +26 -0
- massgen/configs/basic/multi/gpt5nano_image_understanding.yaml +26 -0
- massgen/configs/{three_agents_default.yaml → basic/multi/three_agents_default.yaml} +8 -4
- massgen/configs/basic/multi/three_agents_opensource.yaml +27 -0
- massgen/configs/basic/multi/three_agents_vllm.yaml +20 -0
- massgen/configs/basic/multi/two_agents_gemini.yaml +19 -0
- massgen/configs/{two_agents.yaml → basic/multi/two_agents_gpt5.yaml} +14 -6
- massgen/configs/basic/multi/two_agents_opensource_lmstudio.yaml +31 -0
- massgen/configs/basic/multi/two_qwen_vllm_sglang.yaml +28 -0
- massgen/configs/{single_agent.yaml → basic/single/single_agent.yaml} +1 -1
- massgen/configs/{single_flash2.5.yaml → basic/single/single_flash2.5.yaml} +1 -2
- massgen/configs/basic/single/single_gemini2.5pro.yaml +16 -0
- massgen/configs/basic/single/single_gpt4o_audio_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_image_generation.yaml +22 -0
- massgen/configs/basic/single/single_gpt4o_video_generation.yaml +24 -0
- massgen/configs/basic/single/single_gpt5nano.yaml +20 -0
- massgen/configs/basic/single/single_gpt5nano_file_search.yaml +18 -0
- massgen/configs/basic/single/single_gpt5nano_image_understanding.yaml +17 -0
- massgen/configs/basic/single/single_gptoss120b.yaml +15 -0
- massgen/configs/basic/single/single_openrouter_audio_understanding.yaml +15 -0
- massgen/configs/basic/single/single_qwen_video_understanding.yaml +15 -0
- massgen/configs/debug/code_execution/command_filtering_blacklist.yaml +29 -0
- massgen/configs/debug/code_execution/command_filtering_whitelist.yaml +28 -0
- massgen/configs/debug/code_execution/docker_verification.yaml +29 -0
- massgen/configs/debug/skip_coordination_test.yaml +27 -0
- massgen/configs/debug/test_sdk_migration.yaml +17 -0
- massgen/configs/docs/DISCORD_MCP_SETUP.md +208 -0
- massgen/configs/docs/TWITTER_MCP_ENESCINAR_SETUP.md +82 -0
- massgen/configs/providers/azure/azure_openai_multi.yaml +21 -0
- massgen/configs/providers/azure/azure_openai_single.yaml +19 -0
- massgen/configs/providers/claude/claude.yaml +14 -0
- massgen/configs/providers/gemini/gemini_gpt5nano.yaml +28 -0
- massgen/configs/providers/local/lmstudio.yaml +11 -0
- massgen/configs/providers/openai/gpt5.yaml +46 -0
- massgen/configs/providers/openai/gpt5_nano.yaml +46 -0
- massgen/configs/providers/others/grok_single_agent.yaml +19 -0
- massgen/configs/providers/others/zai_coding_team.yaml +108 -0
- massgen/configs/providers/others/zai_glm45.yaml +12 -0
- massgen/configs/{creative_team.yaml → teams/creative/creative_team.yaml} +16 -6
- massgen/configs/{travel_planning.yaml → teams/creative/travel_planning.yaml} +16 -6
- massgen/configs/{news_analysis.yaml → teams/research/news_analysis.yaml} +16 -6
- massgen/configs/{research_team.yaml → teams/research/research_team.yaml} +15 -7
- massgen/configs/{technical_analysis.yaml → teams/research/technical_analysis.yaml} +16 -6
- massgen/configs/tools/code-execution/basic_command_execution.yaml +25 -0
- massgen/configs/tools/code-execution/code_execution_use_case_simple.yaml +41 -0
- massgen/configs/tools/code-execution/docker_claude_code.yaml +32 -0
- massgen/configs/tools/code-execution/docker_multi_agent.yaml +32 -0
- massgen/configs/tools/code-execution/docker_simple.yaml +29 -0
- massgen/configs/tools/code-execution/docker_with_resource_limits.yaml +32 -0
- massgen/configs/tools/code-execution/multi_agent_playwright_automation.yaml +57 -0
- massgen/configs/tools/filesystem/cc_gpt5_gemini_filesystem.yaml +34 -0
- massgen/configs/tools/filesystem/claude_code_context_sharing.yaml +68 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5.yaml +43 -0
- massgen/configs/tools/filesystem/claude_code_flash2.5_gptoss.yaml +49 -0
- massgen/configs/tools/filesystem/claude_code_gpt5nano.yaml +31 -0
- massgen/configs/tools/filesystem/claude_code_single.yaml +40 -0
- massgen/configs/tools/filesystem/fs_permissions_test.yaml +87 -0
- massgen/configs/tools/filesystem/gemini_gemini_workspace_cleanup.yaml +54 -0
- massgen/configs/tools/filesystem/gemini_gpt5_filesystem_casestudy.yaml +30 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_file_context_path.yaml +43 -0
- massgen/configs/tools/filesystem/gemini_gpt5nano_protected_paths.yaml +45 -0
- massgen/configs/tools/filesystem/gpt5mini_cc_fs_context_path.yaml +31 -0
- massgen/configs/tools/filesystem/grok4_gpt5_gemini_filesystem.yaml +32 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_claude_code_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/grok4_gpt5_gemini_filesystem_multiturn.yaml +58 -0
- massgen/configs/tools/filesystem/multiturn/two_claude_code_filesystem_multiturn.yaml +47 -0
- massgen/configs/tools/filesystem/multiturn/two_gemini_flash_filesystem_multiturn.yaml +48 -0
- massgen/configs/tools/mcp/claude_code_discord_mcp_example.yaml +27 -0
- massgen/configs/tools/mcp/claude_code_simple_mcp.yaml +35 -0
- massgen/configs/tools/mcp/claude_code_twitter_mcp_example.yaml +32 -0
- massgen/configs/tools/mcp/claude_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/claude_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/five_agents_travel_mcp_test.yaml +157 -0
- massgen/configs/tools/mcp/five_agents_weather_mcp_test.yaml +103 -0
- massgen/configs/tools/mcp/gemini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_sharing.yaml +23 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_single_agent.yaml +17 -0
- massgen/configs/tools/mcp/gemini_mcp_filesystem_test_with_claude_code.yaml +24 -0
- massgen/configs/tools/mcp/gemini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gemini_notion_mcp.yaml +52 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/gpt5_nano_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/gpt5mini_claude_code_discord_mcp_example.yaml +38 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/gpt_oss_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/grok3_mini_mcp_test.yaml +27 -0
- massgen/configs/tools/mcp/multimcp_gemini.yaml +111 -0
- massgen/configs/tools/mcp/qwen_api_mcp_example.yaml +25 -0
- massgen/configs/tools/mcp/qwen_api_mcp_test.yaml +28 -0
- massgen/configs/tools/mcp/qwen_local_mcp_example.yaml +24 -0
- massgen/configs/tools/mcp/qwen_local_mcp_test.yaml +27 -0
- massgen/configs/tools/planning/five_agents_discord_mcp_planning_mode.yaml +140 -0
- massgen/configs/tools/planning/five_agents_filesystem_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_notion_mcp_planning_mode.yaml +151 -0
- massgen/configs/tools/planning/five_agents_twitter_mcp_planning_mode.yaml +155 -0
- massgen/configs/tools/planning/gpt5_mini_case_study_mcp_planning_mode.yaml +73 -0
- massgen/configs/tools/web-search/claude_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gemini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt5_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/gpt_oss_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/grok3_mini_streamable_http_test.yaml +43 -0
- massgen/configs/tools/web-search/qwen_api_streamable_http_test.yaml +44 -0
- massgen/configs/tools/web-search/qwen_local_streamable_http_test.yaml +43 -0
- massgen/coordination_tracker.py +708 -0
- massgen/docker/README.md +462 -0
- massgen/filesystem_manager/__init__.py +21 -0
- massgen/filesystem_manager/_base.py +9 -0
- massgen/filesystem_manager/_code_execution_server.py +545 -0
- massgen/filesystem_manager/_docker_manager.py +477 -0
- massgen/filesystem_manager/_file_operation_tracker.py +248 -0
- massgen/filesystem_manager/_filesystem_manager.py +813 -0
- massgen/filesystem_manager/_path_permission_manager.py +1261 -0
- massgen/filesystem_manager/_workspace_tools_server.py +1815 -0
- massgen/formatter/__init__.py +10 -0
- massgen/formatter/_chat_completions_formatter.py +284 -0
- massgen/formatter/_claude_formatter.py +235 -0
- massgen/formatter/_formatter_base.py +156 -0
- massgen/formatter/_response_formatter.py +263 -0
- massgen/frontend/__init__.py +1 -2
- massgen/frontend/coordination_ui.py +471 -286
- massgen/frontend/displays/base_display.py +56 -11
- massgen/frontend/displays/create_coordination_table.py +1956 -0
- massgen/frontend/displays/rich_terminal_display.py +1259 -619
- massgen/frontend/displays/simple_display.py +9 -4
- massgen/frontend/displays/terminal_display.py +27 -68
- massgen/logger_config.py +681 -0
- massgen/mcp_tools/README.md +232 -0
- massgen/mcp_tools/__init__.py +105 -0
- massgen/mcp_tools/backend_utils.py +1035 -0
- massgen/mcp_tools/circuit_breaker.py +195 -0
- massgen/mcp_tools/client.py +894 -0
- massgen/mcp_tools/config_validator.py +138 -0
- massgen/mcp_tools/docs/circuit_breaker.md +646 -0
- massgen/mcp_tools/docs/client.md +950 -0
- massgen/mcp_tools/docs/config_validator.md +478 -0
- massgen/mcp_tools/docs/exceptions.md +1165 -0
- massgen/mcp_tools/docs/security.md +854 -0
- massgen/mcp_tools/exceptions.py +338 -0
- massgen/mcp_tools/hooks.py +212 -0
- massgen/mcp_tools/security.py +780 -0
- massgen/message_templates.py +342 -64
- massgen/orchestrator.py +1515 -241
- massgen/stream_chunk/__init__.py +35 -0
- massgen/stream_chunk/base.py +92 -0
- massgen/stream_chunk/multimodal.py +237 -0
- massgen/stream_chunk/text.py +162 -0
- massgen/tests/mcp_test_server.py +150 -0
- massgen/tests/multi_turn_conversation_design.md +0 -8
- massgen/tests/test_azure_openai_backend.py +156 -0
- massgen/tests/test_backend_capabilities.py +262 -0
- massgen/tests/test_backend_event_loop_all.py +179 -0
- massgen/tests/test_chat_completions_refactor.py +142 -0
- massgen/tests/test_claude_backend.py +15 -28
- massgen/tests/test_claude_code.py +268 -0
- massgen/tests/test_claude_code_context_sharing.py +233 -0
- massgen/tests/test_claude_code_orchestrator.py +175 -0
- massgen/tests/test_cli_backends.py +180 -0
- massgen/tests/test_code_execution.py +679 -0
- massgen/tests/test_external_agent_backend.py +134 -0
- massgen/tests/test_final_presentation_fallback.py +237 -0
- massgen/tests/test_gemini_planning_mode.py +351 -0
- massgen/tests/test_grok_backend.py +7 -10
- massgen/tests/test_http_mcp_server.py +42 -0
- massgen/tests/test_integration_simple.py +198 -0
- massgen/tests/test_mcp_blocking.py +125 -0
- massgen/tests/test_message_context_building.py +29 -47
- massgen/tests/test_orchestrator_final_presentation.py +48 -0
- massgen/tests/test_path_permission_manager.py +2087 -0
- massgen/tests/test_rich_terminal_display.py +14 -13
- massgen/tests/test_timeout.py +133 -0
- massgen/tests/test_v3_3agents.py +11 -12
- massgen/tests/test_v3_simple.py +8 -13
- massgen/tests/test_v3_three_agents.py +11 -18
- massgen/tests/test_v3_two_agents.py +8 -13
- massgen/token_manager/__init__.py +7 -0
- massgen/token_manager/token_manager.py +400 -0
- massgen/utils.py +52 -16
- massgen/v1/agent.py +45 -91
- massgen/v1/agents.py +18 -53
- massgen/v1/backends/gemini.py +50 -153
- massgen/v1/backends/grok.py +21 -54
- massgen/v1/backends/oai.py +39 -111
- massgen/v1/cli.py +36 -93
- massgen/v1/config.py +8 -12
- massgen/v1/logging.py +43 -127
- massgen/v1/main.py +18 -32
- massgen/v1/orchestrator.py +68 -209
- massgen/v1/streaming_display.py +62 -163
- massgen/v1/tools.py +8 -12
- massgen/v1/types.py +9 -23
- massgen/v1/utils.py +5 -23
- massgen-0.1.0.dist-info/METADATA +1245 -0
- massgen-0.1.0.dist-info/RECORD +273 -0
- massgen-0.1.0.dist-info/entry_points.txt +2 -0
- massgen/frontend/logging/__init__.py +0 -9
- massgen/frontend/logging/realtime_logger.py +0 -197
- massgen-0.0.3.dist-info/METADATA +0 -568
- massgen-0.0.3.dist-info/RECORD +0 -76
- massgen-0.0.3.dist-info/entry_points.txt +0 -2
- /massgen/backend/{Function calling openai responses.md → docs/Function calling openai responses.md} +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/WHEEL +0 -0
- {massgen-0.0.3.dist-info → massgen-0.1.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import time
|
|
26
|
+
import traceback
|
|
10
27
|
from dataclasses import dataclass, field
|
|
11
|
-
from
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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=
|
|
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.
|
|
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
|
|
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"
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
)
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
),
|
|
525
|
-
|
|
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=
|
|
533
|
-
valid_agent_ids=list(
|
|
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"🔁
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"🔁
|
|
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"🔁
|
|
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
|
-
|
|
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.
|
|
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"🔄
|
|
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"🔁
|
|
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"🔁
|
|
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
|
-
|
|
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
|
-
|
|
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"🔁
|
|
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"🔁
|
|
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",
|
|
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
|
-
|
|
910
|
-
|
|
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
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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=
|
|
997
|
-
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
|
|
1002
|
-
agent_system_message =
|
|
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":
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
)
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
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
|
|
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
|
|
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
|
)
|