Просмотр исходного кода

新增群聊主持流程模式

samien 2 месяцев назад
Родитель
Сommit
30253fe349

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

@@ -374,6 +374,9 @@ def build_run_prompt(
             "description": _required_string_allowing_empty(
                 agent.get("description", ""), "projection.agent.description"
             ),
+            "isHost": _required_bool(
+                agent.get("isHost", False), "projection.agent.isHost"
+            ),
         },
         "messages": _strict_json_copy(messages, "projection.messages"),
         "omittedSummary": _strict_json_copy(
@@ -383,6 +386,10 @@ def build_run_prompt(
             "id": _required_string(room.get("id"), "projection.room.id"),
             "name": _required_string(room.get("name"), "projection.room.name"),
             "maxReplyRounds": room.get("maxReplyRounds", 3),
+            "orchestrationMode": _required_string(
+                room.get("orchestrationMode", "free"),
+                "projection.room.orchestrationMode",
+            ),
         },
         "run": {
             "id": _required_string(run.get("id"), "projection.run.id"),
@@ -429,7 +436,15 @@ def build_run_prompt(
         allow_nan=False,
     )
     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 = (
             "你是本房间唯一主持人。该用户消息没有有效 @,你必须公开处理;"
             "禁止保持静默,禁止输出任何 YAOYAO_NO_REPLY 标记。必须三选一:"
@@ -440,9 +455,19 @@ def build_run_prompt(
             f"若触发内容与自己的职责无关,禁止调用工具、禁止@成员,且完整答复只能是"
             f"{_NO_REPLY_TOKEN};正常答复绝不能包含该标记。"
         )
+    elif host_flow and not envelope["agent"]["isHost"]:
+        automatic_rule = (
+            "你正在执行主持人委派的当前步骤。只回答本步骤,不要 @任何成员;"
+            "完成后系统会把结果交还主持人复核并决定下一步。"
+        )
+    collaboration_rule = (
+        "主持流程中只有主持人可以调度下一位成员。"
+        if host_flow
+        else "仅在确实需要成员协作时,使用成员列表中的精确 @显示名 或 @all 发起下一轮。"
+    )
     return (
         "你正在 Hermes 多设备群聊中回复。只输出要发送到公共房间的答复;"
-        "仅在确实需要成员协作时,使用成员列表中的精确 @显示名 或 @all 发起下一轮。\n"
+        f"{collaboration_rule}\n"
         f"{automatic_rule}\n"
         f"runId={envelope['run']['id']} roomId={envelope['room']['id']} "
         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)
     if "max_reply_rounds" not in request.model_fields_set:
         command.pop("maxReplyRounds", None)
+    if "orchestration_mode" not in request.model_fields_set:
+        command.pop("orchestrationMode", None)
     agents = command.get("agents")
     if isinstance(agents, list):
         for seed, item in zip(request.agents, agents):
@@ -1048,6 +1050,7 @@ async def capabilities() -> dict[str, object]:
         "latestCursor": latest_cursor,
         "limits": limits_payload(),
         "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({
     "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
 # display-name or alias target.  Keep the stable wire spelling for existing
@@ -137,6 +138,9 @@ class CreateRoomRequest(GroupModel):
     max_reply_rounds: StrictInt = Field(
         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)
 
     @field_validator("max_reply_rounds")
@@ -156,8 +160,13 @@ class UpdateRoomRequest(GroupModel):
     name: str | None = Field(default=None, min_length=1, max_length=100)
     cwd: str | None = Field(default=None, max_length=4096)
     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
     def reject_explicit_null(cls, value: Any) -> Any:
         if value is None:
@@ -166,8 +175,13 @@ class UpdateRoomRequest(GroupModel):
 
     @model_validator(mode="after")
     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
 
     @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_ROOM_CONCURRENCY,
     MAX_TOOL_STATE_BYTES,
+    ORCHESTRATION_MODES,
     is_reserved_mention_alias,
     normalize_display_name,
     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)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -112,6 +113,7 @@ _SCHEMA_STATEMENTS = (
         name TEXT NOT NULL,
         cwd TEXT NOT NULL DEFAULT '',
         max_reply_rounds INTEGER NOT NULL DEFAULT 3,
+        orchestration_mode TEXT NOT NULL DEFAULT 'free',
         created_at REAL NOT NULL,
         updated_at REAL NOT NULL,
         archived INTEGER NOT NULL DEFAULT 0
@@ -487,6 +489,9 @@ class GroupStore:
             raw_version = "9"
         if raw_version == "9":
             self._migrate_v9_to_v10(connection)
+            raw_version = "10"
+        if raw_version == "10":
+            self._migrate_v10_to_v11(connection)
         self._validated_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'"
         )
 
+    @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
     def _validate_v8_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "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_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"},
@@ -1218,6 +1238,7 @@ class GroupStore:
         }
         exact_specs = {
             ("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", "is_host"): ("INTEGER", 1, "0"),
             ("group_messages", "visible"): ("INTEGER", 1, "1"),
@@ -1256,6 +1277,10 @@ class GroupStore:
                        (max_reply_rounds < 1 OR
                         max_reply_rounds > {MAX_FINITE_REPLY_ROUNDS}))
                 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
                 WHERE typeof(reply_without_mention) != 'integer'
                    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]:
         command = self._command(
-            command, {"requestId", "name", "cwd", "maxReplyRounds", "agents"}
+            command,
+            {
+                "requestId",
+                "name",
+                "cwd",
+                "maxReplyRounds",
+                "orchestrationMode",
+                "agents",
+            },
         )
         request_id = self._command_request_id(command)
 
@@ -1659,14 +1692,18 @@ class GroupStore:
             max_reply_rounds = normalize_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"))
             now = self._now()
             room_id = str(uuid.uuid4())
             connection.execute(
                 """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:
                 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")
         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_topics WHERE room_id = group_rooms.id) AS topic_count
             FROM group_rooms WHERE archived = ?"""
@@ -1889,17 +1927,22 @@ class GroupStore:
     ) -> dict[str, object]:
         canonical_room_id = self._canonical_uuid(room_id, "roomId")
         command = self._command(
-            command, {"requestId", "name", "cwd", "maxReplyRounds"}
+            command,
+            {"requestId", "name", "cwd", "maxReplyRounds", "orchestrationMode"},
         )
         request_id = self._command_request_id(command)
         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:
-            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]:
-            self._active_room(connection, canonical_room_id)
+            current_room = self._active_room(connection, canonical_room_id)
             values: list[object] = []
             assignments: list[str] = []
             if "name" in changes:
@@ -1911,6 +1954,22 @@ class GroupStore:
             if "maxReplyRounds" in changes:
                 assignments.append("max_reply_rounds = ?")
                 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 = ?")
             values.extend((self._now(), canonical_room_id))
             connection.execute(
@@ -2648,7 +2707,7 @@ class GroupStore:
             payload["topicId"] = canonical_topic_id
 
         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]] = []
             room_agents = connection.execute(
                 """SELECT * FROM group_agents WHERE room_id = ?
@@ -2667,7 +2726,10 @@ class GroupStore:
                 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 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_by_id[agent_id], "mentioned", False)
                     for agent_id in explicit_agent_ids
@@ -2897,6 +2959,8 @@ class GroupStore:
                  AND agent.room_id = candidate.room_id
                 JOIN group_messages AS trigger_message
                   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'
                   AND room.archived = 0
                   AND agent.enabled = 1
@@ -2910,7 +2974,17 @@ class GroupStore:
                     WHERE occupied_room.room_id = candidate.room_id
                       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.id ASC
                 LIMIT 1""",
@@ -3462,7 +3536,38 @@ class GroupStore:
             "ORDER BY created_at ASC, id ASC",
             (source["room_id"],),
         ).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(
                 source_message["content"],
                 agents,
@@ -4103,7 +4208,13 @@ class GroupStore:
         ).fetchall()
         agents_by_id = {agent["id"]: agent for agent in room_agents}
         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"])
         if max_reply_rounds != -1 and next_depth >= max_reply_rounds:
             warning_texts.append("Cascade depth limit reached")
@@ -4146,7 +4257,8 @@ class GroupStore:
             )
             if reply_mode not in {"mentioned", "automatic"}:
                 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())
             run_id = str(uuid.uuid4())
             connection.execute(
@@ -4179,7 +4291,7 @@ class GroupStore:
                  requested_fast_mode, actual_model, actual_provider,
                  actual_reasoning_effort, actual_fast_mode, error, created_at,
                  updated_at)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'queued', NULL,
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', NULL,
                         ?, ?, ?, ?, NULL, NULL, NULL, NULL, '', ?, ?)""",
                 (
                     run_id,
@@ -4191,6 +4303,7 @@ class GroupStore:
                     source["root_message_id"],
                     next_depth,
                     reply_mode,
+                    int(required_reply),
                     agent["model_override"],
                     agent["provider_override"],
                     agent["reasoning_effort_override"],
@@ -5497,7 +5610,8 @@ class GroupStore:
         created_at: float,
     ) -> None:
         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)
                 AS agent_count,
             (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")
         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
     def _mention_agent_ids(cls, value: object) -> list[str]:
         if not isinstance(value, list):
@@ -6498,6 +6618,7 @@ class GroupStore:
             "name": row["name"],
             "cwd": row["cwd"],
             "maxReplyRounds": row["max_reply_rounds"],
+            "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
             "archived": bool(row["archived"]),
@@ -6510,6 +6631,7 @@ class GroupStore:
             "name": row["name"],
             "cwd": row["cwd"],
             "maxReplyRounds": row["max_reply_rounds"],
+            "orchestrationMode": row["orchestration_mode"],
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
             "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):
     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({
             "requestId": new_id(),
             "name": "兼容群",
@@ -61,6 +61,11 @@ class GroupHostContractTests(unittest.TestCase):
             "isHost": 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"):
             PROTOCOL.CreateRoomRequest.model_validate({
@@ -491,7 +496,16 @@ class GroupHostContractTests(unittest.TestCase):
 
             migrated = GroupStore(path)
             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:
                 after_counts = {
                     table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
@@ -624,6 +638,7 @@ class GroupHostContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
                 run_id=new_id(),
                 room_id=new_id(),
+                topic_id=new_id(),
                 agent_id=new_id(),
                 runtime_id="runtime",
                 generation=1,
@@ -664,6 +679,155 @@ class GroupHostContractTests(unittest.TestCase):
 
         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
     def _send(
         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(
                 run_id=request_id(),
                 room_id=request_id(),
+                topic_id=request_id(),
                 agent_id=request_id(),
                 runtime_id="runtime-effective-model",
                 generation=1,
@@ -337,7 +338,7 @@ class GroupSettingsContractTests(unittest.TestCase):
                 )
 
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 8)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 10)
         self.assertEqual(
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
         )
@@ -711,7 +712,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 8)
+            self.assertEqual(store.schema_version(), 11)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()
@@ -1035,6 +1036,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 generation=1,
@@ -1102,6 +1104,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 generation=1,
@@ -1191,6 +1194,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 generation=1,
@@ -1260,6 +1264,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 generation=1,
@@ -1309,6 +1314,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 generation=1,
@@ -1556,6 +1562,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             state = ORCHESTRATOR._RuntimeState(
                 run_id=str(uuid.uuid4()),
                 room_id=str(uuid.uuid4()),
+                topic_id=str(uuid.uuid4()),
                 agent_id=str(uuid.uuid4()),
                 runtime_id="runtime",
                 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.initialize()
 
-            self.assertEqual(migrated.schema_version(), 10)
+            self.assertEqual(migrated.schema_version(), 11)
             with migrated.connection() as connection:
                 topic = connection.execute(
                     "SELECT * FROM group_topics WHERE id = ?", (topic_id,)
@@ -1504,7 +1504,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 10)
+            self.assertEqual(store.schema_version(), 11)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()
@@ -1529,7 +1529,9 @@ class GroupTopicsContractTests(unittest.TestCase):
                         (failed_interaction_request,),
                     ).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(
                 (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
 }
 
-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`, {
     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)}`, {
     method: 'PATCH', body: { requestId: requestId(), ...input } as JsonValue,
   })))

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

@@ -8,10 +8,12 @@ interface CreateGroupPayload {
   autoReply: boolean
   replyRounds: number
   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,
+  hostFlowEnabled: false,
   busy: false,
 })
 const emit = defineEmits<{ close: []; create: [payload: CreateGroupPayload] }>()
@@ -21,6 +23,7 @@ const selected = ref<string[]>([])
 const hostProfile = ref('')
 const autoReply = ref(true)
 const replyRounds = ref(3)
+const orchestrationMode = ref<'free' | 'host'>('free')
 const valid = computed(() => name.value.trim().length > 0
   && selected.value.length >= 1
   && selected.value.length <= 8
@@ -33,6 +36,7 @@ watch(() => props.open, open => {
   hostProfile.value = selected.value[0] ?? ''
   autoReply.value = true
   replyRounds.value = 3
+  orchestrationMode.value = 'free'
 }, { immediate: true })
 
 function toggle(profile: string) {
@@ -50,6 +54,7 @@ function create() {
     replyRounds: replyRounds.value,
   }
   if (props.hostEnabled) payload.hostProfile = hostProfile.value
+  if (props.hostFlowEnabled) payload.orchestrationMode = orchestrationMode.value
   emit('create', payload)
 }
 </script>
@@ -74,8 +79,15 @@ function create() {
               <option v-for="profile in selected" :key="profile" :value="profile">{{ profile }}</option>
             </select>
           </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">
-            <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>
           </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>

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

@@ -35,6 +35,7 @@ const props = withDefaults(defineProps<{
   room: UiRoom
   agents: GroupAgent[]
   hostEnabled?: boolean
+  hostFlowEnabled?: boolean
   availableProfiles?: string[]
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
@@ -42,7 +43,7 @@ const props = withDefaults(defineProps<{
   agentUpdateError?: Record<string, string>
   managerError?: string
   busy?: boolean
-}>(), { hostEnabled: false, busy: false })
+}>(), { hostEnabled: false, hostFlowEnabled: false, busy: false })
 
 const emit = defineEmits<{
   updateRoom: [patch: Partial<UiRoom>]
@@ -55,12 +56,17 @@ const emit = defineEmits<{
   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 agentDrafts = reactive<Record<string, AgentDraft>>({})
 const dirtyAgents = reactive(new Set<string>())
 const selectedAgent = computed(() => props.agents.find(agent => agent.id === expandedAgentId.value))
 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 {
   return provider && model ? JSON.stringify([provider, model]) : ''
@@ -92,6 +98,7 @@ function draftFrom(agent: GroupAgent): AgentDraft {
 watch(() => props.room, room => {
   form.name = room.name
   form.replyRounds = room.replyRounds ?? 3
+  form.orchestrationMode = room.orchestrationMode ?? 'free'
 }, { deep: true })
 
 watch(() => props.agents, agents => {
@@ -113,6 +120,7 @@ function saveRoom() {
   emit('updateRoom', {
     name: form.name.trim() || props.room.name,
     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>
       <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 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>
@@ -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].replyWithoutMention" type="checkbox" :aria-label="hostEnabled ? '无需 @ 也回复' : '自动回复'" @change="markDirty(selectedAgent.id)" />{{ hostEnabled ? '无需 @ 也回复' : '自动回复' }}</label>
           </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>
           <div class="editor-actions">
             <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; }
 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; }
+.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); }
 .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); }

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

@@ -17,4 +17,5 @@ export type UiRoom = {
   memberIds?: string[]
   autoReply?: boolean
   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 {
-  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 {

+ 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 topicProtocol = computed(() => (capabilities.value?.protocolVersion ?? 0) >= 4)
   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 topics = computed(() => selectedRoomId.value ? protocol.value.topicsByRoom[selectedRoomId.value] ?? [] : [])
   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 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)
     protocol.value = snapshotGroupRoom(protocol.value, detail, [], topicProtocol.value ? { topics: [] } : {})
     selectedRoomId.value = detail.id
@@ -445,7 +446,7 @@ export const useGroupsStore = defineStore('groups', () => {
     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 topicId = topicProtocol.value && selectedRoomId.value === roomId ? selectedTopicId.value : undefined
     const roomMessages = topicId ? protocol.value.messagesByTopic[topicId] ?? [] : protocol.value.messagesByRoom[roomId] ?? []
@@ -740,7 +741,7 @@ export const useGroupsStore = defineStore('groups', () => {
   }
 
   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,
     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,

+ 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),
     },
     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 {
   const source = record(value)
   const rawAgents = source.agents
+  const orchestrationMode = string(pick(source, 'orchestrationMode', 'orchestration_mode'))
   return {
     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')),
@@ -437,6 +439,7 @@ export function normalizeGroupRoom(value: unknown): GroupRoomSummary {
     unreadCount: number(pick(source, 'unreadCount', 'unread_count')),
     activeRunCount: number(pick(source, 'activeRunCount', 'active_run_count')),
     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)
   return {
     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),
     pendingInteractions: values(pick(source, 'pendingInteractions', 'pending_interactions')).map(normalizeGroupInteraction),
     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(() => {
   if (!groups.selectedRoom) return '选择或新建一个群聊'
   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(() => {
   if (!groups.topicProtocol) {
@@ -313,7 +314,7 @@ async function startTopicFromRoomAction() {
   } 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 detail = await groups.createRoom({
     name: payload.name,
@@ -324,6 +325,7 @@ async function createRoom(payload: { name: string; profiles: string[]; autoReply
       ...(groups.hostProtocol ? { isHost: profile === hostProfile } : {}),
     })),
     maxReplyRounds: payload.replyRounds,
+    ...(groups.hostFlowProtocol ? { orchestrationMode: payload.orchestrationMode ?? 'free' } : {}),
   })
   createOpen.value = false
   await router.push(groupRoute(detail.id, groups.selectedTopicId))
@@ -338,9 +340,13 @@ async function send(payload: ComposerSubmit) {
   } 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
-  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) {
@@ -628,6 +634,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :room="room"
         :agents="groups.agents"
         :host-enabled="groups.hostProtocol"
+        :host-flow-enabled="groups.hostFlowProtocol"
         :available-profiles="availableProfiles"
         :busy="managerBusy"
         :model-options-by-profile="modelOptionsByProfile"
@@ -648,7 +655,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
   </WorkspaceView>
 
   <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" />
   <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
   limits: GroupLimits
   eventTypes: string[]
+  features?: string[]
 }
 
 export type GroupAgentStatus = 'idle' | 'queued' | 'running' | 'awaiting_input' | 'unknown'
@@ -351,6 +352,7 @@ export interface GroupRoomSummary {
   unreadCount: number
   activeRunCount?: number
   maxReplyRounds: number
+  orchestrationMode?: 'free' | 'host'
 }
 
 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('.solid-button').trigger('click')
     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()
   })
 
@@ -98,4 +120,22 @@ describe('group host controls', () => {
     expect(removeHost.attributes('title')).toContain('已启用成员')
     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: 'host', room_id: 'room-1', profile: 'host', is_host: true }).isHost).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')
   })
 })