Przeglądaj źródła

新增群聊主持流程模式

samien 2 miesięcy temu
rodzic
commit
30253fe349

+ 27 - 2
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -374,6 +374,9 @@ def build_run_prompt(
             "description": _required_string_allowing_empty(
             "description": _required_string_allowing_empty(
                 agent.get("description", ""), "projection.agent.description"
                 agent.get("description", ""), "projection.agent.description"
             ),
             ),
+            "isHost": _required_bool(
+                agent.get("isHost", False), "projection.agent.isHost"
+            ),
         },
         },
         "messages": _strict_json_copy(messages, "projection.messages"),
         "messages": _strict_json_copy(messages, "projection.messages"),
         "omittedSummary": _strict_json_copy(
         "omittedSummary": _strict_json_copy(
@@ -383,6 +386,10 @@ def build_run_prompt(
             "id": _required_string(room.get("id"), "projection.room.id"),
             "id": _required_string(room.get("id"), "projection.room.id"),
             "name": _required_string(room.get("name"), "projection.room.name"),
             "name": _required_string(room.get("name"), "projection.room.name"),
             "maxReplyRounds": room.get("maxReplyRounds", 3),
             "maxReplyRounds": room.get("maxReplyRounds", 3),
+            "orchestrationMode": _required_string(
+                room.get("orchestrationMode", "free"),
+                "projection.room.orchestrationMode",
+            ),
         },
         },
         "run": {
         "run": {
             "id": _required_string(run.get("id"), "projection.run.id"),
             "id": _required_string(run.get("id"), "projection.run.id"),
@@ -429,7 +436,15 @@ def build_run_prompt(
         allow_nan=False,
         allow_nan=False,
     )
     )
     automatic_rule = ""
     automatic_rule = ""
-    if envelope["run"]["requiredReply"]:
+    host_flow = envelope["room"]["orchestrationMode"] == "host"
+    if envelope["run"]["requiredReply"] and host_flow:
+        automatic_rule = (
+            "你持有本话题当前唯一发言令牌,必须公开处理。请根据最新上下文动态决定下一步:"
+            "若要委派,只能使用成员列表中的一个精确 @显示名;禁止 @all,禁止一次 @ 多人;"
+            "若任务已经完成,直接给出最终答复且不要 @任何成员;也可以发起澄清请求。"
+            "禁止保持静默,禁止输出任何 YAOYAO_NO_REPLY 标记。"
+        )
+    elif envelope["run"]["requiredReply"]:
         automatic_rule = (
         automatic_rule = (
             "你是本房间唯一主持人。该用户消息没有有效 @,你必须公开处理;"
             "你是本房间唯一主持人。该用户消息没有有效 @,你必须公开处理;"
             "禁止保持静默,禁止输出任何 YAOYAO_NO_REPLY 标记。必须三选一:"
             "禁止保持静默,禁止输出任何 YAOYAO_NO_REPLY 标记。必须三选一:"
@@ -440,9 +455,19 @@ def build_run_prompt(
             f"若触发内容与自己的职责无关,禁止调用工具、禁止@成员,且完整答复只能是"
             f"若触发内容与自己的职责无关,禁止调用工具、禁止@成员,且完整答复只能是"
             f"{_NO_REPLY_TOKEN};正常答复绝不能包含该标记。"
             f"{_NO_REPLY_TOKEN};正常答复绝不能包含该标记。"
         )
         )
+    elif host_flow and not envelope["agent"]["isHost"]:
+        automatic_rule = (
+            "你正在执行主持人委派的当前步骤。只回答本步骤,不要 @任何成员;"
+            "完成后系统会把结果交还主持人复核并决定下一步。"
+        )
+    collaboration_rule = (
+        "主持流程中只有主持人可以调度下一位成员。"
+        if host_flow
+        else "仅在确实需要成员协作时,使用成员列表中的精确 @显示名 或 @all 发起下一轮。"
+    )
     return (
     return (
         "你正在 Hermes 多设备群聊中回复。只输出要发送到公共房间的答复;"
         "你正在 Hermes 多设备群聊中回复。只输出要发送到公共房间的答复;"
-        "仅在确实需要成员协作时,使用成员列表中的精确 @显示名 或 @all 发起下一轮。\n"
+        f"{collaboration_rule}\n"
         f"{automatic_rule}\n"
         f"{automatic_rule}\n"
         f"runId={envelope['run']['id']} roomId={envelope['room']['id']} "
         f"runId={envelope['run']['id']} roomId={envelope['room']['id']} "
         f"agentId={envelope['agent']['id']}\n"
         f"agentId={envelope['agent']['id']}\n"

+ 3 - 0
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -993,6 +993,8 @@ def _create_room_command(request: CreateRoomRequest) -> dict[str, object]:
     command = _command(request)
     command = _command(request)
     if "max_reply_rounds" not in request.model_fields_set:
     if "max_reply_rounds" not in request.model_fields_set:
         command.pop("maxReplyRounds", None)
         command.pop("maxReplyRounds", None)
+    if "orchestration_mode" not in request.model_fields_set:
+        command.pop("orchestrationMode", None)
     agents = command.get("agents")
     agents = command.get("agents")
     if isinstance(agents, list):
     if isinstance(agents, list):
         for seed, item in zip(request.agents, agents):
         for seed, item in zip(request.agents, agents):
@@ -1048,6 +1050,7 @@ async def capabilities() -> dict[str, object]:
         "latestCursor": latest_cursor,
         "latestCursor": latest_cursor,
         "limits": limits_payload(),
         "limits": limits_payload(),
         "eventTypes": sorted(EVENT_TYPES),
         "eventTypes": sorted(EVENT_TYPES),
+        "features": ["hostFlow"],
     }
     }
 
 
 
 

+ 17 - 3
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -38,6 +38,7 @@ CONTEXT_CHARACTER_BUDGET = 32_000
 REASONING_EFFORTS = frozenset({
 REASONING_EFFORTS = frozenset({
     "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
     "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"
 })
 })
+ORCHESTRATION_MODES = frozenset({"free", "host"})
 
 
 # These literals address the room as a whole and must never become a member
 # These literals address the room as a whole and must never become a member
 # display-name or alias target.  Keep the stable wire spelling for existing
 # display-name or alias target.  Keep the stable wire spelling for existing
@@ -137,6 +138,9 @@ class CreateRoomRequest(GroupModel):
     max_reply_rounds: StrictInt = Field(
     max_reply_rounds: StrictInt = Field(
         default=DEFAULT_MAX_REPLY_ROUNDS, alias="maxReplyRounds"
         default=DEFAULT_MAX_REPLY_ROUNDS, alias="maxReplyRounds"
     )
     )
+    orchestration_mode: Literal["free", "host"] = Field(
+        default="free", alias="orchestrationMode"
+    )
     agents: list[AgentSeed] = Field(min_length=1, max_length=MAX_AGENTS_PER_ROOM)
     agents: list[AgentSeed] = Field(min_length=1, max_length=MAX_AGENTS_PER_ROOM)
 
 
     @field_validator("max_reply_rounds")
     @field_validator("max_reply_rounds")
@@ -156,8 +160,13 @@ class UpdateRoomRequest(GroupModel):
     name: str | None = Field(default=None, min_length=1, max_length=100)
     name: str | None = Field(default=None, min_length=1, max_length=100)
     cwd: str | None = Field(default=None, max_length=4096)
     cwd: str | None = Field(default=None, max_length=4096)
     max_reply_rounds: StrictInt | None = Field(default=None, alias="maxReplyRounds")
     max_reply_rounds: StrictInt | None = Field(default=None, alias="maxReplyRounds")
+    orchestration_mode: Literal["free", "host"] | None = Field(
+        default=None, alias="orchestrationMode"
+    )
 
 
-    @field_validator("name", "cwd", "max_reply_rounds", mode="before")
+    @field_validator(
+        "name", "cwd", "max_reply_rounds", "orchestration_mode", mode="before"
+    )
     @classmethod
     @classmethod
     def reject_explicit_null(cls, value: Any) -> Any:
     def reject_explicit_null(cls, value: Any) -> Any:
         if value is None:
         if value is None:
@@ -166,8 +175,13 @@ class UpdateRoomRequest(GroupModel):
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
     def require_change(self) -> "UpdateRoomRequest":
     def require_change(self) -> "UpdateRoomRequest":
-        if not ({"name", "cwd", "max_reply_rounds"} & self.model_fields_set):
-            raise ValueError("Room update requires name, cwd, or maxReplyRounds")
+        if not (
+            {"name", "cwd", "max_reply_rounds", "orchestration_mode"}
+            & self.model_fields_set
+        ):
+            raise ValueError(
+                "Room update requires name, cwd, maxReplyRounds, or orchestrationMode"
+            )
         return self
         return self
 
 
     @field_validator("max_reply_rounds")
     @field_validator("max_reply_rounds")

+ 141 - 19
hermes-plugins/yaoyao/dashboard/group_store.py

@@ -32,6 +32,7 @@ from .group_protocol import (
     MAX_PLUGIN_CONCURRENCY,
     MAX_PLUGIN_CONCURRENCY,
     MAX_ROOM_CONCURRENCY,
     MAX_ROOM_CONCURRENCY,
     MAX_TOOL_STATE_BYTES,
     MAX_TOOL_STATE_BYTES,
+    ORCHESTRATION_MODES,
     is_reserved_mention_alias,
     is_reserved_mention_alias,
     normalize_display_name,
     normalize_display_name,
     normalize_interaction_id,
     normalize_interaction_id,
@@ -41,7 +42,7 @@ from .group_protocol import (
 )
 )
 
 
 
 
-SCHEMA_VERSION = 10
+SCHEMA_VERSION = 11
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
 MIN_RETAINED_EVENTS = 100_000
@@ -112,6 +113,7 @@ _SCHEMA_STATEMENTS = (
         name TEXT NOT NULL,
         name TEXT NOT NULL,
         cwd TEXT NOT NULL DEFAULT '',
         cwd TEXT NOT NULL DEFAULT '',
         max_reply_rounds INTEGER NOT NULL DEFAULT 3,
         max_reply_rounds INTEGER NOT NULL DEFAULT 3,
+        orchestration_mode TEXT NOT NULL DEFAULT 'free',
         created_at REAL NOT NULL,
         created_at REAL NOT NULL,
         updated_at REAL NOT NULL,
         updated_at REAL NOT NULL,
         archived INTEGER NOT NULL DEFAULT 0
         archived INTEGER NOT NULL DEFAULT 0
@@ -487,6 +489,9 @@ class GroupStore:
             raw_version = "9"
             raw_version = "9"
         if raw_version == "9":
         if raw_version == "9":
             self._migrate_v9_to_v10(connection)
             self._migrate_v9_to_v10(connection)
+            raw_version = "10"
+        if raw_version == "10":
+            self._migrate_v10_to_v11(connection)
         self._validated_schema_version(
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "schema_version")
             self._metadata_value_from_connection(connection, "schema_version")
         )
         )
@@ -1202,11 +1207,26 @@ class GroupStore:
             "UPDATE group_meta SET value = '10' WHERE key = 'schema_version'"
             "UPDATE group_meta SET value = '10' WHERE key = 'schema_version'"
         )
         )
 
 
+    @staticmethod
+    def _migrate_v10_to_v11(connection: sqlite3.Connection) -> None:
+        columns = {
+            str(row["name"])
+            for row in connection.execute("PRAGMA table_info(group_rooms)")
+        }
+        if "orchestration_mode" not in columns:
+            connection.execute(
+                "ALTER TABLE group_rooms ADD COLUMN orchestration_mode "
+                "TEXT NOT NULL DEFAULT 'free'"
+            )
+        connection.execute(
+            "UPDATE group_meta SET value = '11' WHERE key = 'schema_version'"
+        )
+
     @staticmethod
     @staticmethod
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_meta": {"key", "value"},
-            "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "created_at", "updated_at", "archived"},
+            "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "orchestration_mode", "created_at", "updated_at", "archived"},
             "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived", "pinned"},
             "group_topics": {"id", "room_id", "title", "last_read_message_seq", "created_at", "updated_at", "archived", "pinned"},
             "group_agents": {"id", "room_id", "profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
             "group_agents": {"id", "room_id", "profile", "display_name", "display_name_key", "description", "stored_session_id", "last_context_message_seq", "enabled", "reply_without_mention", "is_host", "model_override", "provider_override", "reasoning_effort_override", "fast_mode_override", "session_config_json", "created_at", "updated_at"},
             "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
             "group_agent_topic_state": {"agent_id", "topic_id", "last_context_message_seq", "created_at", "updated_at"},
@@ -1218,6 +1238,7 @@ class GroupStore:
         }
         }
         exact_specs = {
         exact_specs = {
             ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
             ("group_rooms", "max_reply_rounds"): ("INTEGER", 1, "3"),
+            ("group_rooms", "orchestration_mode"): ("TEXT", 1, "'free'"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "reply_without_mention"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("INTEGER", 1, "0"),
             ("group_agents", "is_host"): ("INTEGER", 1, "0"),
             ("group_messages", "visible"): ("INTEGER", 1, "1"),
             ("group_messages", "visible"): ("INTEGER", 1, "1"),
@@ -1256,6 +1277,10 @@ class GroupStore:
                        (max_reply_rounds < 1 OR
                        (max_reply_rounds < 1 OR
                         max_reply_rounds > {MAX_FINITE_REPLY_ROUNDS}))
                         max_reply_rounds > {MAX_FINITE_REPLY_ROUNDS}))
                 LIMIT 1""",
                 LIMIT 1""",
+            """SELECT 1 FROM group_rooms
+                WHERE typeof(orchestration_mode) != 'text'
+                   OR orchestration_mode NOT IN ('free', 'host')
+                LIMIT 1""",
             """SELECT 1 FROM group_agents
             """SELECT 1 FROM group_agents
                 WHERE typeof(reply_without_mention) != 'integer'
                 WHERE typeof(reply_without_mention) != 'integer'
                    OR reply_without_mention NOT IN (0, 1)
                    OR reply_without_mention NOT IN (0, 1)
@@ -1649,7 +1674,15 @@ class GroupStore:
 
 
     def create_room(self, command: Mapping[str, object]) -> dict[str, object]:
     def create_room(self, command: Mapping[str, object]) -> dict[str, object]:
         command = self._command(
         command = self._command(
-            command, {"requestId", "name", "cwd", "maxReplyRounds", "agents"}
+            command,
+            {
+                "requestId",
+                "name",
+                "cwd",
+                "maxReplyRounds",
+                "orchestrationMode",
+                "agents",
+            },
         )
         )
         request_id = self._command_request_id(command)
         request_id = self._command_request_id(command)
 
 
@@ -1659,14 +1692,18 @@ class GroupStore:
             max_reply_rounds = normalize_max_reply_rounds(
             max_reply_rounds = normalize_max_reply_rounds(
                 command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
                 command.get("maxReplyRounds", DEFAULT_MAX_REPLY_ROUNDS)
             )
             )
+            orchestration_mode = self._orchestration_mode(
+                command.get("orchestrationMode", "free")
+            )
             agents = self._new_agents(command.get("agents"))
             agents = self._new_agents(command.get("agents"))
             now = self._now()
             now = self._now()
             room_id = str(uuid.uuid4())
             room_id = str(uuid.uuid4())
             connection.execute(
             connection.execute(
                 """INSERT INTO group_rooms
                 """INSERT INTO group_rooms
-                (id, name, cwd, max_reply_rounds, created_at, updated_at, archived)
-                VALUES (?, ?, ?, ?, ?, ?, 0)""",
-                (room_id, name, cwd, max_reply_rounds, now, now),
+                (id, name, cwd, max_reply_rounds, orchestration_mode,
+                 created_at, updated_at, archived)
+                VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",
+                (room_id, name, cwd, max_reply_rounds, orchestration_mode, now, now),
             )
             )
             for agent in agents:
             for agent in agents:
                 self._insert_agent(connection, room_id, agent, now)
                 self._insert_agent(connection, room_id, agent, now)
@@ -1740,7 +1777,8 @@ class GroupStore:
         ):
         ):
             raise ValueError("limit must be an integer from 1 to 100")
             raise ValueError("limit must be an integer from 1 to 100")
         cursor_values = self._decode_cursor(cursor) if cursor is not None else None
         cursor_values = self._decode_cursor(cursor) if cursor is not None else None
-        query = """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
+        query = """SELECT id, name, cwd, max_reply_rounds, orchestration_mode,
+            created_at, updated_at, archived,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id) AS agent_count,
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_count
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id) AS topic_count
             FROM group_rooms WHERE archived = ?"""
             FROM group_rooms WHERE archived = ?"""
@@ -1889,17 +1927,22 @@ class GroupStore:
     ) -> dict[str, object]:
     ) -> dict[str, object]:
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         command = self._command(
         command = self._command(
-            command, {"requestId", "name", "cwd", "maxReplyRounds"}
+            command,
+            {"requestId", "name", "cwd", "maxReplyRounds", "orchestrationMode"},
         )
         )
         request_id = self._command_request_id(command)
         request_id = self._command_request_id(command)
         changes = {
         changes = {
-            key for key in ("name", "cwd", "maxReplyRounds") if key in command
+            key
+            for key in ("name", "cwd", "maxReplyRounds", "orchestrationMode")
+            if key in command
         }
         }
         if not changes:
         if not changes:
-            raise ValueError("Room update requires name, cwd, or maxReplyRounds")
+            raise ValueError(
+                "Room update requires name, cwd, maxReplyRounds, or orchestrationMode"
+            )
 
 
         def action(connection: sqlite3.Connection) -> dict[str, object]:
         def action(connection: sqlite3.Connection) -> dict[str, object]:
-            self._active_room(connection, canonical_room_id)
+            current_room = self._active_room(connection, canonical_room_id)
             values: list[object] = []
             values: list[object] = []
             assignments: list[str] = []
             assignments: list[str] = []
             if "name" in changes:
             if "name" in changes:
@@ -1911,6 +1954,22 @@ class GroupStore:
             if "maxReplyRounds" in changes:
             if "maxReplyRounds" in changes:
                 assignments.append("max_reply_rounds = ?")
                 assignments.append("max_reply_rounds = ?")
                 values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
                 values.append(normalize_max_reply_rounds(command["maxReplyRounds"]))
+            if "orchestrationMode" in changes:
+                next_mode = self._orchestration_mode(command["orchestrationMode"])
+                if next_mode != current_room["orchestration_mode"]:
+                    active = connection.execute(
+                        """SELECT 1 FROM group_agent_runs
+                        WHERE room_id = ?
+                          AND status IN ('queued', 'running', 'awaiting_input')
+                        LIMIT 1""",
+                        (canonical_room_id,),
+                    ).fetchone()
+                    if active is not None:
+                        raise GroupConflictError(
+                            "Cannot change orchestration mode while runs are active"
+                        )
+                assignments.append("orchestration_mode = ?")
+                values.append(next_mode)
             assignments.append("updated_at = ?")
             assignments.append("updated_at = ?")
             values.extend((self._now(), canonical_room_id))
             values.extend((self._now(), canonical_room_id))
             connection.execute(
             connection.execute(
@@ -2648,7 +2707,7 @@ class GroupStore:
             payload["topicId"] = canonical_topic_id
             payload["topicId"] = canonical_topic_id
 
 
         def action(connection: sqlite3.Connection) -> dict[str, object]:
         def action(connection: sqlite3.Connection) -> dict[str, object]:
-            self._active_room(connection, canonical_room_id)
+            room = self._active_room(connection, canonical_room_id)
             agents: list[tuple[sqlite3.Row, str, bool]] = []
             agents: list[tuple[sqlite3.Row, str, bool]] = []
             room_agents = connection.execute(
             room_agents = connection.execute(
                 """SELECT * FROM group_agents WHERE room_id = ?
                 """SELECT * FROM group_agents WHERE room_id = ?
@@ -2667,7 +2726,10 @@ class GroupStore:
                 for agent_id in dict.fromkeys([*agent_ids, *parsed_ids])
                 for agent_id in dict.fromkeys([*agent_ids, *parsed_ids])
                 if agent_id in agents_by_id and agents_by_id[agent_id]["enabled"]
                 if agent_id in agents_by_id and agents_by_id[agent_id]["enabled"]
             ]
             ]
-            if explicit_agent_ids:
+            if room["orchestration_mode"] == "host":
+                host = self._room_host(connection, canonical_room_id)
+                agents.append((host, "automatic", True))
+            elif explicit_agent_ids:
                 agents.extend(
                 agents.extend(
                     (agents_by_id[agent_id], "mentioned", False)
                     (agents_by_id[agent_id], "mentioned", False)
                     for agent_id in explicit_agent_ids
                     for agent_id in explicit_agent_ids
@@ -2897,6 +2959,8 @@ class GroupStore:
                  AND agent.room_id = candidate.room_id
                  AND agent.room_id = candidate.room_id
                 JOIN group_messages AS trigger_message
                 JOIN group_messages AS trigger_message
                   ON trigger_message.id = candidate.trigger_message_id
                   ON trigger_message.id = candidate.trigger_message_id
+                JOIN group_messages AS root_message
+                  ON root_message.id = candidate.root_message_id
                 WHERE candidate.status = 'queued'
                 WHERE candidate.status = 'queued'
                   AND room.archived = 0
                   AND room.archived = 0
                   AND agent.enabled = 1
                   AND agent.enabled = 1
@@ -2910,7 +2974,17 @@ class GroupStore:
                     WHERE occupied_room.room_id = candidate.room_id
                     WHERE occupied_room.room_id = candidate.room_id
                       AND occupied_room.status IN ('running', 'awaiting_input')
                       AND occupied_room.status IN ('running', 'awaiting_input')
                   ) < ?
                   ) < ?
-                ORDER BY trigger_message.seq ASC,
+                  AND (
+                    room.orchestration_mode != 'host'
+                    OR NOT EXISTS (
+                      SELECT 1 FROM group_agent_runs AS occupied_topic
+                      WHERE occupied_topic.topic_id = candidate.topic_id
+                        AND occupied_topic.status IN ('running', 'awaiting_input')
+                    )
+                  )
+                ORDER BY CASE WHEN room.orchestration_mode = 'host'
+                              THEN root_message.seq ELSE trigger_message.seq END ASC,
+                         trigger_message.seq ASC,
                          candidate.created_at ASC,
                          candidate.created_at ASC,
                          candidate.id ASC
                          candidate.id ASC
                 LIMIT 1""",
                 LIMIT 1""",
@@ -3462,7 +3536,38 @@ class GroupStore:
             "ORDER BY created_at ASC, id ASC",
             "ORDER BY created_at ASC, id ASC",
             (source["room_id"],),
             (source["room_id"],),
         ).fetchall()
         ).fetchall()
-        if source_message["visible"]:
+        room = self._active_room(connection, source["room_id"])
+        source_agent = next(
+            (agent for agent in agents if agent["id"] == source["agent_id"]), None
+        )
+        if source_agent is None:
+            raise GroupStoreError("Cascade source Agent is missing")
+        if room["orchestration_mode"] == "host" and source_message["visible"]:
+            if source_agent["is_host"]:
+                target_ids, warnings = self._plan_cascade_mentions(
+                    source_message["content"],
+                    agents,
+                    source_agent_id=source["agent_id"],
+                    include_automatic=False,
+                )
+                explicit_ids = list(target_ids)
+                if len(target_ids) > 1:
+                    warnings.append({
+                        "code": "mention",
+                        "message": "主持流程每轮只调度一位成员,已采用第一个 @ 目标",
+                    })
+                    target_ids = target_ids[:1]
+                    explicit_ids = explicit_ids[:1]
+            else:
+                host = next(
+                    (agent for agent in agents if agent["is_host"] and agent["enabled"]),
+                    None,
+                )
+                if host is None:
+                    raise GroupStoreError("Host flow has no enabled host Agent")
+                target_ids, warnings = [str(host["id"])], []
+                explicit_ids = []
+        elif source_message["visible"]:
             target_ids, warnings = self._plan_cascade_mentions(
             target_ids, warnings = self._plan_cascade_mentions(
                 source_message["content"],
                 source_message["content"],
                 agents,
                 agents,
@@ -4103,7 +4208,13 @@ class GroupStore:
         ).fetchall()
         ).fetchall()
         agents_by_id = {agent["id"]: agent for agent in room_agents}
         agents_by_id = {agent["id"]: agent for agent in room_agents}
         warning_texts = list(requested_warnings)
         warning_texts = list(requested_warnings)
-        next_depth = int(source["depth"]) + 1
+        source_agent = agents_by_id.get(source["agent_id"])
+        if source_agent is None:
+            raise GroupStoreError("Cascade source Agent is missing")
+        host_review = (
+            room["orchestration_mode"] == "host" and not source_agent["is_host"]
+        )
+        next_depth = int(source["depth"]) + (0 if host_review else 1)
         max_reply_rounds = int(room["max_reply_rounds"])
         max_reply_rounds = int(room["max_reply_rounds"])
         if max_reply_rounds != -1 and next_depth >= max_reply_rounds:
         if max_reply_rounds != -1 and next_depth >= max_reply_rounds:
             warning_texts.append("Cascade depth limit reached")
             warning_texts.append("Cascade depth limit reached")
@@ -4146,7 +4257,8 @@ class GroupStore:
             )
             )
             if reply_mode not in {"mentioned", "automatic"}:
             if reply_mode not in {"mentioned", "automatic"}:
                 raise GroupStoreError("Stored cascade plan is corrupt")
                 raise GroupStoreError("Stored cascade plan is corrupt")
-            visible = reply_mode == "mentioned"
+            required_reply = bool(host_review and agent["is_host"])
+            visible = reply_mode == "mentioned" or required_reply
             response_id = str(uuid.uuid4())
             response_id = str(uuid.uuid4())
             run_id = str(uuid.uuid4())
             run_id = str(uuid.uuid4())
             connection.execute(
             connection.execute(
@@ -4179,7 +4291,7 @@ class GroupStore:
                  requested_fast_mode, actual_model, actual_provider,
                  requested_fast_mode, actual_model, actual_provider,
                  actual_reasoning_effort, actual_fast_mode, error, created_at,
                  actual_reasoning_effort, actual_fast_mode, error, created_at,
                  updated_at)
                  updated_at)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'queued', NULL,
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', NULL,
                         ?, ?, ?, ?, NULL, NULL, NULL, NULL, '', ?, ?)""",
                         ?, ?, ?, ?, NULL, NULL, NULL, NULL, '', ?, ?)""",
                 (
                 (
                     run_id,
                     run_id,
@@ -4191,6 +4303,7 @@ class GroupStore:
                     source["root_message_id"],
                     source["root_message_id"],
                     next_depth,
                     next_depth,
                     reply_mode,
                     reply_mode,
+                    int(required_reply),
                     agent["model_override"],
                     agent["model_override"],
                     agent["provider_override"],
                     agent["provider_override"],
                     agent["reasoning_effort_override"],
                     agent["reasoning_effort_override"],
@@ -5497,7 +5610,8 @@ class GroupStore:
         created_at: float,
         created_at: float,
     ) -> None:
     ) -> None:
         room = connection.execute(
         room = connection.execute(
-            """SELECT id, name, cwd, max_reply_rounds, created_at, updated_at, archived,
+            """SELECT id, name, cwd, max_reply_rounds, orchestration_mode,
+            created_at, updated_at, archived,
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
             (SELECT COUNT(*) FROM group_agents WHERE room_id = group_rooms.id)
                 AS agent_count,
                 AS agent_count,
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id)
             (SELECT COUNT(*) FROM group_topics WHERE room_id = group_rooms.id)
@@ -6217,6 +6331,12 @@ class GroupStore:
             raise ValueError(f"{field} must not be blank")
             raise ValueError(f"{field} must not be blank")
         return value
         return value
 
 
+    @staticmethod
+    def _orchestration_mode(value: object) -> str:
+        if not isinstance(value, str) or value not in ORCHESTRATION_MODES:
+            raise ValueError("orchestrationMode must be free or host")
+        return value
+
     @classmethod
     @classmethod
     def _mention_agent_ids(cls, value: object) -> list[str]:
     def _mention_agent_ids(cls, value: object) -> list[str]:
         if not isinstance(value, list):
         if not isinstance(value, list):
@@ -6498,6 +6618,7 @@ class GroupStore:
             "name": row["name"],
             "name": row["name"],
             "cwd": row["cwd"],
             "cwd": row["cwd"],
             "maxReplyRounds": row["max_reply_rounds"],
             "maxReplyRounds": row["max_reply_rounds"],
+            "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
             "updatedAt": row["updated_at"],
             "archived": bool(row["archived"]),
             "archived": bool(row["archived"]),
@@ -6510,6 +6631,7 @@ class GroupStore:
             "name": row["name"],
             "name": row["name"],
             "cwd": row["cwd"],
             "cwd": row["cwd"],
             "maxReplyRounds": row["max_reply_rounds"],
             "maxReplyRounds": row["max_reply_rounds"],
+            "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
             "updatedAt": row["updated_at"],
             "archived": False,
             "archived": False,

+ 166 - 2
hermes-plugins/yaoyao/dashboard/tests/test_group_host_contract.py

@@ -34,7 +34,7 @@ def new_id() -> str:
 
 
 class GroupHostContractTests(unittest.TestCase):
 class GroupHostContractTests(unittest.TestCase):
     def test_protocol_v5_host_fields_are_independent_and_compatible(self) -> None:
     def test_protocol_v5_host_fields_are_independent_and_compatible(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 8)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
         legacy = PROTOCOL.CreateRoomRequest.model_validate({
         legacy = PROTOCOL.CreateRoomRequest.model_validate({
             "requestId": new_id(),
             "requestId": new_id(),
             "name": "兼容群",
             "name": "兼容群",
@@ -61,6 +61,11 @@ class GroupHostContractTests(unittest.TestCase):
             "isHost": True,
             "isHost": True,
         })
         })
         self.assertIs(update.is_host, True)
         self.assertIs(update.is_host, True)
+        flow = PROTOCOL.UpdateRoomRequest.model_validate({
+            "requestId": new_id(),
+            "orchestrationMode": "host",
+        })
+        self.assertEqual(flow.orchestration_mode, "host")
 
 
         with self.assertRaisesRegex(ValueError, "only one host"):
         with self.assertRaisesRegex(ValueError, "only one host"):
             PROTOCOL.CreateRoomRequest.model_validate({
             PROTOCOL.CreateRoomRequest.model_validate({
@@ -491,7 +496,16 @@ class GroupHostContractTests(unittest.TestCase):
 
 
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 8)
+            self.assertEqual(migrated.schema_version(), 11)
+            self.assertTrue(
+                all(
+                    room["orchestrationMode"] == "free"
+                    for room in (
+                        migrated.get_room(preferred_room["id"]),
+                        migrated.get_room(disabled_room["id"]),
+                    )
+                )
+            )
             with migrated.connection() as connection:
             with migrated.connection() as connection:
                 after_counts = {
                 after_counts = {
                     table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
                     table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
@@ -624,6 +638,7 @@ class GroupHostContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=new_id(),
                 run_id=new_id(),
                 room_id=new_id(),
                 room_id=new_id(),
+                topic_id=new_id(),
                 agent_id=new_id(),
                 agent_id=new_id(),
                 runtime_id="runtime",
                 runtime_id="runtime",
                 generation=1,
                 generation=1,
@@ -664,6 +679,155 @@ class GroupHostContractTests(unittest.TestCase):
 
 
         return await complete(clarify=False), await complete(clarify=True)
         return await complete(clarify=False), await complete(clarify=True)
 
 
+    def test_host_flow_uses_one_turn_token_and_returns_members_to_host(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "主持流程群",
+                "cwd": "",
+                "orchestrationMode": "host",
+                "agents": [
+                    {"profile": "host", "displayName": "主持人", "isHost": True},
+                    {"profile": "one", "displayName": "成员一"},
+                    {"profile": "two", "displayName": "成员二"},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+
+            first = self._send(
+                store,
+                room["id"],
+                "@所有人 按依赖推进",
+                [agents["one"]["id"], agents["two"]["id"]],
+            )
+            second = self._send(store, room["id"], "第二个请求", [])
+            self.assertEqual(
+                self._run_modes(first), {agents["host"]["id"]: "automatic"}
+            )
+            self.assertEqual(
+                self._run_modes(second), {agents["host"]["id"]: "automatic"}
+            )
+            claimed = store.claim_next_runnable_run()
+            self.assertEqual(claimed["rootMessageId"], first["message"]["id"])
+            self.assertIs(
+                store.read_run_projection(claimed["id"])["run"]["requiredReply"],
+                True,
+            )
+            self.assertIsNone(store.claim_next_runnable_run())
+            with self.assertRaisesRegex(
+                STORE_MODULE.GroupConflictError, "runs are active"
+            ):
+                store.update_room(
+                    room["id"],
+                    {"requestId": new_id(), "orchestrationMode": "free"},
+                )
+            store.transition_run(claimed["id"], "failed", error="测试释放令牌")
+            self.assertEqual(
+                store.claim_next_runnable_run()["rootMessageId"],
+                second["message"]["id"],
+            )
+
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "逐步委派群",
+                "cwd": "",
+                "orchestrationMode": "host",
+                "agents": [
+                    {"profile": "host", "displayName": "主持人", "isHost": True},
+                    {"profile": "one", "displayName": "成员一"},
+                    {"profile": "two", "displayName": "成员二"},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            source = self._insert_completed_source(
+                store,
+                room["id"],
+                agents["host"]["id"],
+                content="@成员一 先处理,@成员二 等待",
+            )
+            delegated = store.complete_cascade(source)
+            self.assertEqual(delegated["runCount"], 1)
+            delegated_run = store.get_run(delegated["runIds"][0])
+            self.assertEqual(delegated_run["agentId"], agents["one"]["id"])
+            with store.connection() as connection:
+                required_reply = connection.execute(
+                    "SELECT required_reply FROM group_agent_runs WHERE id = ?",
+                    (delegated_run["id"],),
+                ).fetchone()[0]
+            self.assertEqual(required_reply, 0)
+            self.assertEqual(delegated_run["depth"], 1)
+            self.assertEqual(delegated["systemMessageCount"], 1)
+
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "主持复核群",
+                "cwd": "",
+                "orchestrationMode": "host",
+                "agents": [
+                    {"profile": "host", "displayName": "主持人", "isHost": True},
+                    {"profile": "worker", "displayName": "执行者"},
+                    {"profile": "other", "displayName": "其他成员"},
+                ],
+            })
+            agents = {agent["profile"]: agent for agent in room["agents"]}
+            source = self._insert_completed_source(
+                store,
+                room["id"],
+                agents["worker"]["id"],
+                content="结果完成,@其他成员 可以继续",
+            )
+            reviewed = store.complete_cascade(source)
+            self.assertEqual(reviewed["runCount"], 1)
+            review_run = store.get_run(reviewed["runIds"][0])
+            self.assertEqual(review_run["agentId"], agents["host"]["id"])
+            self.assertEqual(review_run["replyMode"], "automatic")
+            self.assertEqual(review_run["depth"], 0)
+            self.assertIs(
+                store.get_message(review_run["responseMessageId"])["visible"], True
+            )
+            claimed = store.claim_next_runnable_run()
+            projection = store.read_run_projection(claimed["id"])
+            self.assertIs(projection["run"]["requiredReply"], True)
+            prompt = ORCHESTRATOR.build_run_prompt(
+                projection, room["agents"]
+            )
+            self.assertIn("当前唯一发言令牌", prompt)
+            self.assertIn("禁止 @all", prompt)
+
+    def test_v10_migration_adds_free_orchestration_mode(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            path = Path(directory) / "group.db"
+            store = GroupStore(path)
+            store.initialize()
+            room = store.create_room({
+                "requestId": new_id(),
+                "name": "旧版房间",
+                "cwd": "",
+                "agents": [{"profile": "host", "isHost": True}],
+            })
+            with store.connection() as connection:
+                connection.execute(
+                    "ALTER TABLE group_rooms DROP COLUMN orchestration_mode"
+                )
+                connection.execute(
+                    "UPDATE group_meta SET value = '10' WHERE key = 'schema_version'"
+                )
+
+            migrated = GroupStore(path)
+            migrated.initialize()
+            self.assertEqual(migrated.schema_version(), 11)
+            self.assertEqual(
+                migrated.get_room(room["id"])["orchestrationMode"], "free"
+            )
+
     @staticmethod
     @staticmethod
     def _send(
     def _send(
         store: GroupStore, room_id: str, content: str, mention_ids: list[str]
         store: GroupStore, room_id: str, content: str, mention_ids: list[str]

+ 9 - 2
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -42,6 +42,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=request_id(),
                 run_id=request_id(),
                 room_id=request_id(),
                 room_id=request_id(),
+                topic_id=request_id(),
                 agent_id=request_id(),
                 agent_id=request_id(),
                 runtime_id="runtime-effective-model",
                 runtime_id="runtime-effective-model",
                 generation=1,
                 generation=1,
@@ -337,7 +338,7 @@ class GroupSettingsContractTests(unittest.TestCase):
                 )
                 )
 
 
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 8)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
         self.assertEqual(
         self.assertEqual(
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
         )
         )
@@ -711,7 +712,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             self._create_v1_database(path)
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 8)
+            self.assertEqual(store.schema_version(), 11)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
@@ -1035,6 +1036,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 runtime_id="runtime",
                 generation=1,
                 generation=1,
@@ -1102,6 +1104,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 runtime_id="runtime",
                 generation=1,
                 generation=1,
@@ -1191,6 +1194,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 runtime_id="runtime",
                 generation=1,
                 generation=1,
@@ -1260,6 +1264,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 runtime_id="runtime",
                 generation=1,
                 generation=1,
@@ -1309,6 +1314,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 runtime_id="runtime",
                 generation=1,
                 generation=1,
@@ -1556,6 +1562,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 runtime_id="runtime",
                 generation=1,
                 generation=1,

+ 5 - 3
hermes-plugins/yaoyao/dashboard/tests/test_group_topics_contract.py

@@ -268,7 +268,7 @@ class GroupTopicsContractTests(unittest.TestCase):
             migrated = GroupStore(path)
             migrated = GroupStore(path)
             migrated.initialize()
             migrated.initialize()
 
 
-            self.assertEqual(migrated.schema_version(), 10)
+            self.assertEqual(migrated.schema_version(), 11)
             with migrated.connection() as connection:
             with migrated.connection() as connection:
                 topic = connection.execute(
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -1504,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
 
             store = GroupStore(path)
             store = GroupStore(path)
             store.initialize()
             store.initialize()
-            self.assertEqual(store.schema_version(), 10)
+            self.assertEqual(store.schema_version(), 11)
             with store.connection() as migrated:
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1529,7 +1529,9 @@ class GroupTopicsContractTests(unittest.TestCase):
                         (failed_interaction_request,),
                         (failed_interaction_request,),
                     ).fetchone()[0]
                     ).fetchone()[0]
                 )
                 )
-            self.assertEqual(dict(room), expected_room)
+            self.assertEqual(
+                dict(room), {**expected_room, "orchestration_mode": "free"}
+            )
             self.assertEqual(dict(agent), {**expected_agent, "is_host": 1})
             self.assertEqual(dict(agent), {**expected_agent, "is_host": 1})
             self.assertEqual(
             self.assertEqual(
                 (room["name"], room["max_reply_rounds"], room["archived"]),
                 (room["name"], room["max_reply_rounds"], room["archived"]),

+ 2 - 2
src/client/api/groups.ts

@@ -82,13 +82,13 @@ export interface AgentSeed {
   fastMode?: boolean | null
   fastMode?: boolean | null
 }
 }
 
 
-export async function createGroupRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+export async function createGroupRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms`, {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms`, {
     method: 'POST', body: { requestId: requestId(), cwd: '', maxReplyRounds: 3, ...input } as unknown as JsonValue,
     method: 'POST', body: { requestId: requestId(), cwd: '', maxReplyRounds: 3, ...input } as unknown as JsonValue,
   })))
   })))
 }
 }
 
 
-export async function updateGroupRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+export async function updateGroupRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`, {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`, {
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
   })))
   })))

+ 14 - 2
src/client/components/groups/CreateGroupDialog.vue

@@ -8,10 +8,12 @@ interface CreateGroupPayload {
   autoReply: boolean
   autoReply: boolean
   replyRounds: number
   replyRounds: number
   hostProfile?: string
   hostProfile?: string
+  orchestrationMode?: 'free' | 'host'
 }
 }
 
 
-const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; hostEnabled?: boolean; busy?: boolean }>(), {
+const props = withDefaults(defineProps<{ open: boolean; profiles: string[]; hostEnabled?: boolean; hostFlowEnabled?: boolean; busy?: boolean }>(), {
   hostEnabled: false,
   hostEnabled: false,
+  hostFlowEnabled: false,
   busy: false,
   busy: false,
 })
 })
 const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
 const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
@@ -21,6 +23,7 @@ const selected = ref<string[]>([])
 const hostProfile = ref('')
 const hostProfile = ref('')
 const autoReply = ref(true)
 const autoReply = ref(true)
 const replyRounds = ref(3)
 const replyRounds = ref(3)
+const orchestrationMode = ref<'free' | 'host'>('free')
 const valid = computed(() => name.value.trim().length > 0
 const valid = computed(() => name.value.trim().length > 0
   && selected.value.length >= 1
   && selected.value.length >= 1
   && selected.value.length <= 8
   && selected.value.length <= 8
@@ -33,6 +36,7 @@ watch(() => props.open, open => {
   hostProfile.value = selected.value[0] ?? ''
   hostProfile.value = selected.value[0] ?? ''
   autoReply.value = true
   autoReply.value = true
   replyRounds.value = 3
   replyRounds.value = 3
+  orchestrationMode.value = 'free'
 }, { immediate: true })
 }, { immediate: true })
 
 
 function toggle(profile: string) {
 function toggle(profile: string) {
@@ -50,6 +54,7 @@ function create() {
     replyRounds: replyRounds.value,
     replyRounds: replyRounds.value,
   }
   }
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
+  if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
   emit('create', payload)
   emit('create', payload)
 }
 }
 </script>
 </script>
@@ -74,8 +79,15 @@ function create() {
               <option v-for="profile in selected" :key="profile" :value="profile">{{ profile }}</option>
               <option v-for="profile in selected" :key="profile" :value="profile">{{ profile }}</option>
             </select>
             </select>
           </label>
           </label>
+          <label v-if="hostFlowEnabled" class="host-picker">
+            <span>协作模式<small>主持流程由主持人逐步选择下一位成员,避免多人并发造成上下文分叉。</small></span>
+            <select v-model="orchestrationMode" aria-label="协作模式">
+              <option value="free">自由讨论</option>
+              <option value="host">主持流程</option>
+            </select>
+          </label>
           <div class="dialog-settings">
           <div class="dialog-settings">
-            <label><input v-model="autoReply" type="checkbox" :aria-label="hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复'" />{{ hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复' }}</label>
+            <label><input v-model="autoReply" type="checkbox" :aria-label="hostEnabled ? '所有成员无需 @ 也回复' : '启用自动回复'" />{{ hostEnabled ? (orchestrationMode === 'host' ? '自由讨论时无需 @ 也回复' : '所有成员无需 @ 也回复') : '启用自动回复' }}</label>
             <label>最多轮数 <input v-model.number="replyRounds" type="number" min="1" max="12" /></label>
             <label>最多轮数 <input v-model.number="replyRounds" type="number" min="1" max="12" /></label>
           </div>
           </div>
           <footer><button class="quiet-button" type="button" @click="emit('close')">取消</button><button class="solid-button" type="button" :disabled="!valid || busy" @click="create">{{ busy ? '创建中…' : '创建群聊' }}</button></footer>
           <footer><button class="quiet-button" type="button" @click="emit('close')">取消</button><button class="solid-button" type="button" :disabled="!valid || busy" @click="create">{{ busy ? '创建中…' : '创建群聊' }}</button></footer>

+ 19 - 3
src/client/components/groups/GroupManager.vue

@@ -35,6 +35,7 @@ const props = withDefaults(defineProps<{
   room: UiRoom
   room: UiRoom
   agents: GroupAgent[]
   agents: GroupAgent[]
   hostEnabled?: boolean
   hostEnabled?: boolean
+  hostFlowEnabled?: boolean
   availableProfiles?: string[]
   availableProfiles?: string[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsLoading?: Record<string, boolean>
@@ -42,7 +43,7 @@ const props = withDefaults(defineProps<{
   agentUpdateError?: Record<string, string>
   agentUpdateError?: Record<string, string>
   managerError?: string
   managerError?: string
   busy?: boolean
   busy?: boolean
-}>(), { hostEnabled: false, busy: false })
+}>(), { hostEnabled: false, hostFlowEnabled: false, busy: false })
 
 
 const emit = defineEmits<{
 const emit = defineEmits<{
   updateRoom: [patch: Partial<UiRoom>]
   updateRoom: [patch: Partial<UiRoom>]
@@ -55,12 +56,17 @@ const emit = defineEmits<{
   archiveRoom: []
   archiveRoom: []
 }>()
 }>()
 
 
-const form = reactive({ name: props.room.name, replyRounds: props.room.replyRounds ?? 3 })
+const form = reactive({
+  name: props.room.name,
+  replyRounds: props.room.replyRounds ?? 3,
+  orchestrationMode: props.room.orchestrationMode ?? 'free' as 'free' | 'host',
+})
 const expandedAgentId = ref('')
 const expandedAgentId = ref('')
 const agentDrafts = reactive<Record<string, AgentDraft>>({})
 const agentDrafts = reactive<Record<string, AgentDraft>>({})
 const dirtyAgents = reactive(new Set<string>())
 const dirtyAgents = reactive(new Set<string>())
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
 const hostAgent = computed(() => props.agents.find(agent => agent.isHost))
 const hostAgent = computed(() => props.agents.find(agent => agent.isHost))
+const hasActiveAgents = computed(() => props.agents.some(agent => ['queued', 'running', 'awaiting_input'].includes(agent.status)))
 
 
 function modelKey(provider?: string | null, model?: string | null): string {
 function modelKey(provider?: string | null, model?: string | null): string {
   return provider && model ? JSON.stringify([provider, model]) : ''
   return provider && model ? JSON.stringify([provider, model]) : ''
@@ -92,6 +98,7 @@ function draftFrom(agent: GroupAgent): AgentDraft {
 watch(() => props.room, room => {
 watch(() => props.room, room => {
   form.name = room.name
   form.name = room.name
   form.replyRounds = room.replyRounds ?? 3
   form.replyRounds = room.replyRounds ?? 3
+  form.orchestrationMode = room.orchestrationMode ?? 'free'
 }, { deep: true })
 }, { deep: true })
 
 
 watch(() => props.agents, agents => {
 watch(() => props.agents, agents => {
@@ -113,6 +120,7 @@ function saveRoom() {
   emit('updateRoom', {
   emit('updateRoom', {
     name: form.name.trim() || props.room.name,
     name: form.name.trim() || props.room.name,
     replyRounds: replyRounds === -1 ? -1 : Math.min(100, Math.max(1, replyRounds || 1)),
     replyRounds: replyRounds === -1 ? -1 : Math.min(100, Math.max(1, replyRounds || 1)),
+    ...(props.hostFlowEnabled ? { orchestrationMode: form.orchestrationMode } : {}),
   })
   })
 }
 }
 
 
@@ -207,6 +215,13 @@ function statusLabel(status: GroupAgent['status']): string {
       <h3>房间</h3>
       <h3>房间</h3>
       <label><span>名称</span><input v-model="form.name" maxlength="80" @change="saveRoom" /></label>
       <label><span>名称</span><input v-model="form.name" maxlength="80" @change="saveRoom" /></label>
       <label class="rounds"><span>最多回复轮数<small>-1 表示无限</small></span><input v-model.number="form.replyRounds" type="number" min="-1" max="100" aria-label="最多回复轮数" @change="saveRoom" /></label>
       <label class="rounds"><span>最多回复轮数<small>-1 表示无限</small></span><input v-model.number="form.replyRounds" type="number" min="-1" max="100" aria-label="最多回复轮数" @change="saveRoom" /></label>
+      <label v-if="hostFlowEnabled" class="flow-mode">
+        <span>协作模式<small>主持流程会逐步调度,每轮只允许一位成员执行。</small></span>
+        <select v-model="form.orchestrationMode" aria-label="协作模式" :disabled="busy || hasActiveAgents" :title="hasActiveAgents ? '请等待当前回复完成或先中断 Agent' : ''" @change="saveRoom">
+          <option value="free">自由讨论</option>
+          <option value="host">主持流程</option>
+        </select>
+      </label>
     </section>
     </section>
 
 
     <section>
     <section>
@@ -285,7 +300,7 @@ function statusLabel(status: GroupAgent['status']): string {
             <label><input v-model="agentDrafts[selectedAgent.id].enabled" type="checkbox" aria-label="启用" @change="markDirty(selectedAgent.id)" />启用</label>
             <label><input v-model="agentDrafts[selectedAgent.id].enabled" type="checkbox" aria-label="启用" @change="markDirty(selectedAgent.id)" />启用</label>
             <label><input v-model="agentDrafts[selectedAgent.id].replyWithoutMention" type="checkbox" :aria-label="hostEnabled ? '无需 @ 也回复' : '自动回复'" @change="markDirty(selectedAgent.id)" />{{ hostEnabled ? '无需 @ 也回复' : '自动回复' }}</label>
             <label><input v-model="agentDrafts[selectedAgent.id].replyWithoutMention" type="checkbox" :aria-label="hostEnabled ? '无需 @ 也回复' : '自动回复'" @change="markDirty(selectedAgent.id)" />{{ hostEnabled ? '无需 @ 也回复' : '自动回复' }}</label>
           </div>
           </div>
-          <p v-if="hostEnabled" class="host-explanation">{{ selectedAgent.isHost ? '主持人始终处理用户无 @ 消息;此开关仅决定是否自动参与 Agent 发出的无 @ 消息。' : '开启后,该成员会自动参与未明确 @ 的消息;用户无 @ 消息仍由主持人兜底。' }}</p>
+          <p v-if="hostEnabled" class="host-explanation">{{ form.orchestrationMode === 'host' ? '主持流程下此开关暂不触发自动并发;切回自由讨论后继续按原设置生效。' : (selectedAgent.isHost ? '主持人始终处理用户无 @ 消息;此开关仅决定是否自动参与 Agent 发出的无 @ 消息。' : '开启后,该成员会自动参与未明确 @ 的消息;用户无 @ 消息仍由主持人兜底。') }}</p>
           <p v-if="agentUpdateError?.[selectedAgent.id]" class="agent-save-error" role="alert">{{ agentUpdateError[selectedAgent.id] }}</p>
           <p v-if="agentUpdateError?.[selectedAgent.id]" class="agent-save-error" role="alert">{{ agentUpdateError[selectedAgent.id] }}</p>
           <div class="editor-actions">
           <div class="editor-actions">
             <button class="quiet-button" type="button" :disabled="busy || !hasAgentChanges(selectedAgent)" @click="resetAgent(selectedAgent)">取消更改</button>
             <button class="quiet-button" type="button" :disabled="busy || !hasAgentChanges(selectedAgent)" @click="resetAgent(selectedAgent)">取消更改</button>
@@ -306,6 +321,7 @@ h3 { margin: 0 0 13px; color: var(--text-secondary); font-size: 10px; font-weigh
 label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
 label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
 input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
 .rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds > span { display: flex; flex-direction: column; gap: 2px; }.rounds small { color: var(--text-muted); font-size: 9px; }.rounds input { width: 62px; text-align: center; }
 .rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds > span { display: flex; flex-direction: column; gap: 2px; }.rounds small { color: var(--text-muted); font-size: 9px; }.rounds input { width: 62px; text-align: center; }
+.flow-mode { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; }.flow-mode > span { display: flex; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.flow-mode small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.flow-mode select { margin: 0; padding: 7px 8px; background: var(--surface); }
 .host-selector { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; margin-bottom: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); }.host-selector > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.host-selector small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.host-selector select { padding: 7px 8px; background: var(--surface); }
 .host-selector { display: grid; grid-template-columns: minmax(0, 1fr) 128px; align-items: center; gap: 10px; margin-bottom: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-soft); }.host-selector > span { display: flex; min-width: 0; flex-direction: column; gap: 2px; color: var(--text-secondary); font-weight: 650; }.host-selector small { color: var(--text-muted); font-size: 9px; font-weight: 400; line-height: 1.4; }.host-selector select { padding: 7px 8px; background: var(--surface); }
 .agent-list { display: flex; flex-direction: column; gap: 7px; }.agent-list article { display: grid; grid-template-columns: 32px minmax(0,1fr) repeat(3, 28px); align-items: center; gap: 7px; padding: 8px; border: 1px solid transparent; border-radius: 10px; background: var(--surface-soft); }
 .agent-list { display: flex; flex-direction: column; gap: 7px; }.agent-list article { display: grid; grid-template-columns: 32px minmax(0,1fr) repeat(3, 28px); align-items: center; gap: 7px; padding: 8px; border: 1px solid transparent; border-radius: 10px; background: var(--surface-soft); }
 .agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-running, .agent-avatar .status-queued { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-awaiting_input { background: var(--warning); }
 .agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-running, .agent-avatar .status-queued { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-awaiting_input { background: var(--warning); }

+ 1 - 0
src/client/components/groups/types.ts

@@ -17,4 +17,5 @@ export type UiRoom = {
   memberIds?: string[]
   memberIds?: string[]
   autoReply?: boolean
   autoReply?: boolean
   replyRounds?: number
   replyRounds?: number
+  orchestrationMode?: 'free' | 'host'
 }
 }

+ 8 - 1
src/client/components/workspace/viewModels.ts

@@ -248,7 +248,14 @@ export function agentToUi(agent: GroupAgent): UiAgent {
 }
 }
 
 
 export function roomToUi(room: GroupRoomDetail): UiRoom {
 export function roomToUi(room: GroupRoomDetail): UiRoom {
-  return { id: room.id, name: room.name, archived: room.archived, memberIds: room.agents.map(agent => agent.id), replyRounds: room.maxReplyRounds }
+  return {
+    id: room.id,
+    name: room.name,
+    archived: room.archived,
+    memberIds: room.agents.map(agent => agent.id),
+    replyRounds: room.maxReplyRounds,
+    orchestrationMode: room.orchestrationMode,
+  }
 }
 }
 
 
 export function fileToUi(item: FileLibraryItem): UiLibraryItem {
 export function fileToUi(item: FileLibraryItem): UiLibraryItem {

+ 4 - 3
src/client/stores/groups.ts

@@ -62,6 +62,7 @@ export const useGroupsStore = defineStore('groups', () => {
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
   const selectedRoom = computed(() => selectedRoomId.value ? protocol.value.roomDetails[selectedRoomId.value] : undefined)
   const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
   const topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
   const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
   const hostProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 5)
+  const hostFlowProtocol = computed(() => capabilities.value?.features?.includes('hostFlow') ?? false)
   const activityProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 8)
   const activityProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 8)
   const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   const topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   const selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
   const selectedTopic = computed(() => selectedTopicId.value ? topics.value.find(topic => topic.id === selectedTopicId.value) : undefined)
@@ -435,7 +436,7 @@ export const useGroupsStore = defineStore('groups', () => {
 
 
   async function refresh(): Promise<void> { await rebuildSnapshot() }
   async function refresh(): Promise<void> { await rebuildSnapshot() }
 
 
-  async function createRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+  async function createRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
     const detail = await createApi(input)
     const detail = await createApi(input)
     protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     selectedRoomId.value = detail.id
     selectedRoomId.value = detail.id
@@ -445,7 +446,7 @@ export const useGroupsStore = defineStore('groups', () => {
     return detail
     return detail
   }
   }
 
 
-  async function updateRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
+  async function updateRoom(roomId: string, input: { name?: string; cwd?: string; maxReplyRounds?: number; orchestrationMode?: 'free' | 'host' }): Promise<GroupRoomDetail> {
     const detail = await updateRoomApi(roomId, input)
     const detail = await updateRoomApi(roomId, input)
     const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
     const topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
     const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
     const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
@@ -740,7 +741,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
   }
 
 
   return {
   return {
-    availability, capabilities, topicProtocol, hostProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
+    availability, capabilities, topicProtocol, hostProtocol, hostFlowProtocol, activityProtocol, rooms, selectedRoomId, selectedRoom, topics, topicsForRoom, loadRoomTopics, selectedTopicId, selectedTopic,
     messages, agents, pendingInteractions,
     messages, agents, pendingInteractions,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     connectionState, isLoading, isSending, error, hasMoreBefore,
     pinnedTopics, start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,
     pinnedTopics, start, stop, refresh, selectRoom, selectTopic, startNewTopic, createRoom, updateRoom, archiveRoom, restoreRoom, archiveTopic, restoreTopic, setTopicPinned, renameTopic, loadPinnedTopics, archivedRooms, archivedTopics, addAgent, updateAgent,

+ 4 - 1
src/client/utils/normalize.ts

@@ -340,6 +340,7 @@ export function normalizeGroupCapabilities(value: unknown): GroupCapabilities {
       maxAgentDisplayNameLength: number(pick(limits, 'maxAgentDisplayNameLength', 'max_agent_display_name_length'), 100),
       maxAgentDisplayNameLength: number(pick(limits, 'maxAgentDisplayNameLength', 'max_agent_display_name_length'), 100),
     },
     },
     eventTypes: values(pick(source, 'eventTypes', 'event_types')).map(value => string(value)).filter(Boolean),
     eventTypes: values(pick(source, 'eventTypes', 'event_types')).map(value => string(value)).filter(Boolean),
+    features: values(source.features).map(value => string(value)).filter(Boolean),
   }
   }
 }
 }
 
 
@@ -429,6 +430,7 @@ export function normalizeGroupTopic(value: unknown): GroupTopicSummary {
 export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
 export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
   const source = record(value)
   const source = record(value)
   const rawAgents = source.agents
   const rawAgents = source.agents
+  const orchestrationMode = string(pick(source, 'orchestrationMode', 'orchestration_mode'))
   return {
   return {
     id: string(source.id), name: string(source.name, '未命名群聊'), cwd: string(source.cwd),
     id: string(source.id), name: string(source.name, '未命名群聊'), cwd: string(source.cwd),
     createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
@@ -437,6 +439,7 @@ export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
     unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
     unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
     activeRunCount: number(pick(source, 'activeRunCount', 'active_run_count')),
     activeRunCount: number(pick(source, 'activeRunCount', 'active_run_count')),
     maxReplyRounds: number(pick(source, 'maxReplyRounds', 'max_reply_rounds'), 3),
     maxReplyRounds: number(pick(source, 'maxReplyRounds', 'max_reply_rounds'), 3),
+    orchestrationMode: orchestrationMode === 'host' ? 'host' : 'free',
   }
   }
 }
 }
 
 
@@ -456,7 +459,7 @@ export function normalizeGroupRoomDetail(value: unknown): GroupRoomDetail {
   const summary = normalizeGroupRoom(source)
   const summary = normalizeGroupRoom(source)
   return {
   return {
     id: summary.id, name: summary.name, cwd: summary.cwd, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
     id: summary.id, name: summary.name, cwd: summary.cwd, createdAt: summary.createdAt, updatedAt: summary.updatedAt,
-    archived: summary.archived, maxReplyRounds: summary.maxReplyRounds,
+    archived: summary.archived, maxReplyRounds: summary.maxReplyRounds, orchestrationMode: summary.orchestrationMode,
     agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(normalizeGroupRun),
     agents: values(source.agents).map(normalizeGroupAgent), runs: values(source.runs).map(normalizeGroupRun),
     pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
     pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
     latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),
     latestCursor: number(pick(source, 'latestCursor', 'latest_cursor')),

+ 12 - 5
src/client/views/GroupsView.vue

@@ -152,7 +152,8 @@ const mentionOptions = computed<ComposerOption[]>(() => [
 const roomSubtitle = computed(() => {
 const roomSubtitle = computed(() => {
   if (!groups.selectedRoom) return '选择或新建一个群聊'
   if (!groups.selectedRoom) return '选择或新建一个群聊'
   const host = hostAgent.value ? `主持人 ${hostAgent.value.displayName || hostAgent.value.profile} · ` : ''
   const host = hostAgent.value ? `主持人 ${hostAgent.value.displayName || hostAgent.value.profile} · ` : ''
-  return `${groups.selectedRoom.name} · ${host}${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复`
+  const mode = groups.selectedRoom.orchestrationMode === 'host' ? '主持流程 · ' : ''
+  return `${groups.selectedRoom.name} · ${mode}${host}${groups.agents.length} 个 Agent · 最多 ${groups.selectedRoom.maxReplyRounds} 轮回复`
 })
 })
 const activeAgentIds = computed(() => {
 const activeAgentIds = computed(() => {
   if (!groups.topicProtocol) {
   if (!groups.topicProtocol) {
@@ -313,7 +314,7 @@ async function startTopicFromRoomAction() {
   } catch { /* store publishes the error */ }
   } catch { /* store publishes the error */ }
 }
 }
 
 
-async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number; hostProfile?: string }) {
+async function createRoom(payload: { name: string; profiles: string[]; autoReply: boolean; replyRounds: number; hostProfile?: string; orchestrationMode?: 'free' | 'host' }) {
   const hostProfile = payload.hostProfile || payload.profiles[0]
   const hostProfile = payload.hostProfile || payload.profiles[0]
   const detail = await groups.createRoom({
   const detail = await groups.createRoom({
     name: payload.name,
     name: payload.name,
@@ -324,6 +325,7 @@ async function createRoom(payload: { name: string; profiles: string[]; autoReply
       ...(groups.hostProtocol ? { isHost: profile === hostProfile } : {}),
       ...(groups.hostProtocol ? { isHost: profile === hostProfile } : {}),
     })),
     })),
     maxReplyRounds: payload.replyRounds,
     maxReplyRounds: payload.replyRounds,
+    ...(groups.hostFlowProtocol ? { orchestrationMode: payload.orchestrationMode ?? 'free' } : {}),
   })
   })
   createOpen.value = false
   createOpen.value = false
   await router.push(groupRoute(detail.id, groups.selectedTopicId))
   await router.push(groupRoute(detail.id, groups.selectedTopicId))
@@ -338,9 +340,13 @@ async function send(payload: ComposerSubmit) {
   } catch { /* store publishes the error */ }
   } catch { /* store publishes the error */ }
 }
 }
 
 
-async function updateRoom(patch: { name?: string; replyRounds?: number }) {
+async function updateRoom(patch: { name?: string; replyRounds?: number; orchestrationMode?: 'free' | 'host' }) {
   if (!groups.selectedRoom) return
   if (!groups.selectedRoom) return
-  await groups.updateRoom(groups.selectedRoom.id, { name: patch.name, maxReplyRounds: patch.replyRounds })
+  await groups.updateRoom(groups.selectedRoom.id, {
+    name: patch.name,
+    maxReplyRounds: patch.replyRounds,
+    ...(groups.hostFlowProtocol ? { orchestrationMode: patch.orchestrationMode } : {}),
+  })
 }
 }
 
 
 async function addAgent(profile: string) {
 async function addAgent(profile: string) {
@@ -628,6 +634,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :room="room"
         :room="room"
         :agents="groups.agents"
         :agents="groups.agents"
         :host-enabled="groups.hostProtocol"
         :host-enabled="groups.hostProtocol"
+        :host-flow-enabled="groups.hostFlowProtocol"
         :available-profiles="availableProfiles"
         :available-profiles="availableProfiles"
         :busy="managerBusy"
         :busy="managerBusy"
         :model-options-by-profile="modelOptionsByProfile"
         :model-options-by-profile="modelOptionsByProfile"
@@ -648,7 +655,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   </WorkspaceView>
   </WorkspaceView>
 
 
   <FloatingResourceSearch section="groups" label="搜索群聊" :items="roomSidebarItems" @select="selectRoom" />
   <FloatingResourceSearch section="groups" label="搜索群聊" :items="roomSidebarItems" @select="selectRoom" />
-  <CreateGroupDialog :open="createOpen" :profiles="profiles" :host-enabled="groups.hostProtocol" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
+  <CreateGroupDialog :open="createOpen" :profiles="profiles" :host-enabled="groups.hostProtocol" :host-flow-enabled="groups.hostFlowProtocol" :busy="groups.isLoading" @close="createOpen = false" @create="createRoom" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <PreviewModal v-if="preview" :item="preview" :items="conversationMediaItems" @close="preview = null" @add-to-composer="addPreviewToComposer" @source="preview = null" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
   <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="uploadsEnabled" @add="addMediaToComposer" />
 
 

+ 2 - 0
src/shared/types.ts

@@ -256,6 +256,7 @@ export interface GroupCapabilities {
   latestCursor: number
   latestCursor: number
   limits: GroupLimits
   limits: GroupLimits
   eventTypes: string[]
   eventTypes: string[]
+  features?: string[]
 }
 }
 
 
 export type GroupAgentStatus = 'idle' | 'queued' | 'running' | 'awaiting_input' | 'unknown'
 export type GroupAgentStatus = 'idle' | 'queued' | 'running' | 'awaiting_input' | 'unknown'
@@ -351,6 +352,7 @@ export interface GroupRoomSummary {
   unreadCount: number
   unreadCount: number
   activeRunCount?: number
   activeRunCount?: number
   maxReplyRounds: number
   maxReplyRounds: number
+  orchestrationMode?: 'free' | 'host'
 }
 }
 
 
 export interface GroupRoomDetail extends Omit<GroupRoomSummary, 'agentCount' | 'lastMessage' | 'unreadCount'> {
 export interface GroupRoomDetail extends Omit<GroupRoomSummary, 'agentCount' | 'lastMessage' | 'unreadCount'> {

+ 40 - 0
tests/client/groupHost.test.ts

@@ -52,6 +52,28 @@ describe('group host controls', () => {
     await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间')
     await wrapper.get('input[placeholder="例如:产品评审"]').setValue('v4 房间')
     await wrapper.get('.solid-button').trigger('click')
     await wrapper.get('.solid-button').trigger('click')
     expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile')
     expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('hostProfile')
+    expect(wrapper.emitted('create')?.[0]?.[0]).not.toHaveProperty('orchestrationMode')
+    wrapper.unmount()
+  })
+
+  it('offers host flow only when the server advertises the capability', async () => {
+    const wrapper = mount(CreateGroupDialog, {
+      attachTo: document.body,
+      props: {
+        open: true,
+        profiles: ['yaoyao', 'yaoer'],
+        hostEnabled: true,
+        hostFlowEnabled: true,
+      },
+      global: { stubs: { teleport: true } },
+    })
+    await wrapper.get('input[placeholder="例如:产品评审"]').setValue('顺序协作')
+    await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
+    await wrapper.get('.solid-button').trigger('click')
+    expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
+      name: '顺序协作',
+      orchestrationMode: 'host',
+    })
     wrapper.unmount()
     wrapper.unmount()
   })
   })
 
 
@@ -98,4 +120,22 @@ describe('group host controls', () => {
     expect(removeHost.attributes('title')).toContain('已启用成员')
     expect(removeHost.attributes('title')).toContain('已启用成员')
     expect(wrapper.get('[role="alert"]').text()).toBe('需要另一位已启用成员')
     expect(wrapper.get('[role="alert"]').text()).toBe('需要另一位已启用成员')
   })
   })
+
+  it('emits a room update when switching collaboration mode', async () => {
+    const first = agent('agent-1', 'yaoyao', '夭夭', true, false)
+    const wrapper = mount(GroupManager, {
+      props: {
+        room: { id: 'room-1', name: '群聊', memberIds: [first.id], replyRounds: 3, orchestrationMode: 'free' },
+        agents: [first],
+        hostEnabled: true,
+        hostFlowEnabled: true,
+      },
+    })
+    await wrapper.get<HTMLSelectElement>('select[aria-label="协作模式"]').setValue('host')
+    expect(wrapper.emitted('updateRoom')?.[0]?.[0]).toEqual({
+      name: '群聊',
+      replyRounds: 3,
+      orchestrationMode: 'host',
+    })
+  })
 })
 })

+ 3 - 2
tests/client/groupProtocol.test.ts

@@ -166,7 +166,8 @@ describe('group protocol continuity', () => {
     expect(normalizeGroupAgent({ id: 'legacy', room_id: 'room-1', profile: 'legacy' }).isHost).toBe(false)
     expect(normalizeGroupAgent({ id: 'legacy', room_id: 'room-1', profile: 'legacy' }).isHost).toBe(false)
     expect(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
     expect(normalizeGroupAgent({ id: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).toBe(true)
     expect(isSupportedGroupProtocolVersion(8)).toBe(true)
     expect(isSupportedGroupProtocolVersion(8)).toBe(true)
-    expect(isSupportedGroupProtocolVersion(9)).toBe(false)
-    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v8')
+    expect(isSupportedGroupProtocolVersion(10)).toBe(true)
+    expect(isSupportedGroupProtocolVersion(11)).toBe(false)
+    expect(SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL).toBe('v2–v10')
   })
   })
 })
 })