Parcourir la source

Track requested and effective model execution metadata

samien il y a 2 mois
Parent
commit
6a89e094e7

+ 24 - 0
hermes-plugins/yaoyao/dashboard/group_orchestrator.py

@@ -265,6 +265,10 @@ class _RuntimeState:
     event_index: int = 0
     terminal_status: str | None = None
     terminal_error: str = ""
+    actual_model: str | None = None
+    actual_provider: str | None = None
+    actual_reasoning_effort: str | None = None
+    actual_fast_mode: bool | None = None
     settle_error: BaseException | None = None
     prompt_committed: bool = False
     overflowed: bool = False
@@ -2727,6 +2731,22 @@ class GroupOrchestrator:
                     interrupt=True,
                 )
                 return
+            actual_model = payload.get("model")
+            actual_provider = payload.get("provider")
+            if (
+                isinstance(actual_model, str)
+                and actual_model.strip()
+                and isinstance(actual_provider, str)
+                and actual_provider.strip()
+            ):
+                state.actual_model = actual_model.strip()
+                state.actual_provider = actual_provider.strip()
+            actual_reasoning = payload.get("reasoning_effort")
+            if isinstance(actual_reasoning, str) and actual_reasoning.strip():
+                state.actual_reasoning_effort = actual_reasoning.strip()
+            actual_fast = payload.get("fast")
+            if isinstance(actual_fast, bool):
+                state.actual_fast_mode = actual_fast
             state.terminal_info_received.set()
             await self._finalize_state_locked(
                 state,
@@ -3441,6 +3461,10 @@ class GroupOrchestrator:
                         expected_stored_session_id=expected_stored,
                         stored_session_id=rotated_stored,
                         outcome=outcome,
+                        actual_model=state.actual_model,
+                        actual_provider=state.actual_provider,
+                        actual_reasoning_effort=state.actual_reasoning_effort,
+                        actual_fast_mode=state.actual_fast_mode,
                         error=self._bounded_error(error),
                     )
                     settled = True

+ 1 - 1
hermes-plugins/yaoyao/dashboard/group_protocol.py

@@ -18,7 +18,7 @@ from pydantic import (
 )
 
 
-PROTOCOL_VERSION = 5
+PROTOCOL_VERSION = 7
 MAX_AGENTS_PER_ROOM = 8
 MAX_MESSAGE_BYTES = 64 * 1024
 MAX_TOOL_STATE_BYTES = 256 * 1024

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

@@ -41,7 +41,7 @@ from .group_protocol import (
 )
 
 
-SCHEMA_VERSION = 5
+SCHEMA_VERSION = 7
 _WAL_RETRY_DELAYS = (0.01, 0.02, 0.04, 0.08)
 EVENT_RETENTION_SECONDS = 30 * 24 * 60 * 60
 MIN_RETAINED_EVENTS = 100_000
@@ -189,6 +189,14 @@ _SCHEMA_STATEMENTS = (
         required_reply INTEGER NOT NULL DEFAULT 0,
         status TEXT NOT NULL,
         runtime_session_id TEXT,
+        requested_model TEXT,
+        requested_provider TEXT,
+        requested_reasoning_effort TEXT,
+        requested_fast_mode INTEGER,
+        actual_model TEXT,
+        actual_provider TEXT,
+        actual_reasoning_effort TEXT,
+        actual_fast_mode INTEGER,
         error TEXT NOT NULL DEFAULT '',
         created_at REAL NOT NULL,
         updated_at REAL NOT NULL,
@@ -461,14 +469,20 @@ class GroupStore:
         if raw_version == "4":
             self._repair_early_v4(connection)
             self._migrate_v4_to_v5(connection)
+            raw_version = "5"
+        if raw_version == "5":
+            self._migrate_v5_to_v6(connection)
+            raw_version = "6"
+        if raw_version == "6":
+            self._migrate_v6_to_v7(connection)
         self._validated_schema_version(
             self._metadata_value_from_connection(connection, "schema_version")
         )
         self._validated_epoch(
             self._metadata_value_from_connection(connection, "journal_epoch")
         )
-        self._validate_v5_columns(connection)
-        self._validate_v5_values(connection)
+        self._validate_v7_columns(connection)
+        self._validate_v7_values(connection)
 
     @staticmethod
     def _migrate_v1_to_v2(connection: sqlite3.Connection) -> None:
@@ -1082,7 +1096,39 @@ class GroupStore:
         )
 
     @staticmethod
-    def _validate_v5_columns(connection: sqlite3.Connection) -> None:
+    def _migrate_v5_to_v6(connection: sqlite3.Connection) -> None:
+        """Add immutable configured and terminal effective model attribution."""
+        for column in (
+            "requested_model",
+            "requested_provider",
+            "actual_model",
+            "actual_provider",
+        ):
+            connection.execute(
+                f"ALTER TABLE group_agent_runs ADD COLUMN {column} TEXT"
+            )
+        connection.execute(
+            "UPDATE group_meta SET value = '6' WHERE key = 'schema_version'"
+        )
+
+    @staticmethod
+    def _migrate_v6_to_v7(connection: sqlite3.Connection) -> None:
+        """Add requested and effective reasoning/fast execution attribution."""
+        for definition in (
+            "requested_reasoning_effort TEXT",
+            "requested_fast_mode INTEGER",
+            "actual_reasoning_effort TEXT",
+            "actual_fast_mode INTEGER",
+        ):
+            connection.execute(
+                f"ALTER TABLE group_agent_runs ADD COLUMN {definition}"
+            )
+        connection.execute(
+            "UPDATE group_meta SET value = '7' WHERE key = 'schema_version'"
+        )
+
+    @staticmethod
+    def _validate_v7_columns(connection: sqlite3.Connection) -> None:
         expected_columns = {
             "group_meta": {"key", "value"},
             "group_rooms": {"id", "name", "cwd", "max_reply_rounds", "created_at", "updated_at", "archived"},
@@ -1090,7 +1136,7 @@ class GroupStore:
             "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_messages": {"seq", "id", "room_id", "topic_id", "sender_kind", "sender_id", "sender_name", "root_message_id", "reply_to_message_id", "client_message_id", "content", "reasoning", "tool_state_json", "status", "error", "visible", "created_at", "updated_at"},
-            "group_agent_runs": {"id", "room_id", "topic_id", "agent_id", "trigger_message_id", "response_message_id", "root_message_id", "depth", "reply_mode", "required_reply", "status", "runtime_session_id", "error", "created_at", "updated_at"},
+            "group_agent_runs": {"id", "room_id", "topic_id", "agent_id", "trigger_message_id", "response_message_id", "root_message_id", "depth", "reply_mode", "required_reply", "status", "runtime_session_id", "requested_model", "requested_provider", "requested_reasoning_effort", "requested_fast_mode", "actual_model", "actual_provider", "actual_reasoning_effort", "actual_fast_mode", "error", "created_at", "updated_at"},
             "group_interactions": {"id", "room_id", "topic_id", "agent_id", "run_id", "kind", "payload_json", "status", "created_at", "resolved_at"},
             "group_events": {"cursor", "epoch", "room_id", "event_type", "payload_json", "created_at"},
             "group_idempotency": {"request_id", "operation", "request_hash", "response_json", "created_at"},
@@ -1124,7 +1170,7 @@ class GroupStore:
                 raise GroupStoreError("GroupStore schema is partial or corrupt")
 
     @staticmethod
-    def _validate_v5_values(connection: sqlite3.Connection) -> None:
+    def _validate_v7_values(connection: sqlite3.Connection) -> None:
         invalid_value_queries = (
             f"""SELECT 1 FROM group_rooms
                 WHERE typeof(max_reply_rounds) != 'integer'
@@ -1175,6 +1221,28 @@ class GroupStore:
                    OR required_reply NOT IN (0, 1)
                    OR (required_reply = 1 AND reply_mode != 'automatic')
                 LIMIT 1""",
+            """SELECT 1 FROM group_agent_runs
+                WHERE (requested_model IS NULL) != (requested_provider IS NULL)
+                   OR (actual_model IS NULL) != (actual_provider IS NULL)
+                   OR length(COALESCE(requested_model, '')) > 4096
+                   OR length(COALESCE(requested_provider, '')) > 4096
+                   OR length(COALESCE(actual_model, '')) > 4096
+                   OR length(COALESCE(actual_provider, '')) > 4096
+                LIMIT 1""",
+            """SELECT 1 FROM group_agent_runs
+                WHERE (requested_reasoning_effort IS NOT NULL
+                       AND requested_reasoning_effort NOT IN
+                         ('none','minimal','low','medium','high','xhigh','max','ultra'))
+                   OR (actual_reasoning_effort IS NOT NULL
+                       AND actual_reasoning_effort NOT IN
+                         ('none','minimal','low','medium','high','xhigh','max','ultra'))
+                   OR (requested_fast_mode IS NOT NULL
+                       AND (typeof(requested_fast_mode) != 'integer'
+                            OR requested_fast_mode NOT IN (0, 1)))
+                   OR (actual_fast_mode IS NOT NULL
+                       AND (typeof(actual_fast_mode) != 'integer'
+                            OR actual_fast_mode NOT IN (0, 1)))
+                LIMIT 1""",
             """SELECT 1 FROM group_agent_runs AS run
                 JOIN group_messages AS response
                   ON response.id = run.response_message_id
@@ -2305,8 +2373,12 @@ class GroupStore:
                     """INSERT INTO group_agent_runs
                     (id, room_id, topic_id, agent_id, trigger_message_id, response_message_id, root_message_id,
                      depth, reply_mode, required_reply, status, runtime_session_id,
-                     error, created_at, updated_at)
-                    VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 'queued', NULL, '', ?, ?)""",
+                     requested_model, requested_provider,
+                     requested_reasoning_effort, requested_fast_mode,
+                     actual_model, actual_provider, actual_reasoning_effort,
+                     actual_fast_mode, error, created_at, updated_at)
+                    VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 'queued', NULL,
+                            ?, ?, ?, ?, NULL, NULL, NULL, NULL, '', ?, ?)""",
                     (
                         run_id,
                         canonical_room_id,
@@ -2317,6 +2389,10 @@ class GroupStore:
                         human_id,
                         reply_mode,
                         int(required_reply),
+                        agent["model_override"],
+                        agent["provider_override"],
+                        agent["reasoning_effort_override"],
+                        agent["fast_mode_override"],
                         now,
                         now,
                     ),
@@ -2350,7 +2426,7 @@ class GroupStore:
         canonical_message_id = self._canonical_uuid(message_id, "messageId")
         with self.read_transaction() as connection:
             return self._message_wire(
-                self._message_row(connection, canonical_message_id)
+                self._message_with_execution_row(connection, canonical_message_id)
             )
 
     def get_run(self, run_id: str) -> dict[str, object]:
@@ -2397,25 +2473,37 @@ class GroupStore:
             self._room_detail(connection, canonical_room_id, include_archived=True)
             if canonical_topic_id is not None:
                 self._topic_row(connection, canonical_room_id, canonical_topic_id)
-            scope = "room_id = ?"
+            scope = "message.room_id = ?"
             scope_params: tuple[object, ...] = (canonical_room_id,)
             if canonical_topic_id is not None:
-                scope += " AND topic_id = ?"
+                scope += " AND message.topic_id = ?"
                 scope_params = (canonical_room_id, canonical_topic_id)
+            select = """SELECT message.*,
+                run.requested_model AS execution_requested_model,
+                run.requested_provider AS execution_requested_provider,
+                run.requested_reasoning_effort AS execution_requested_reasoning_effort,
+                run.requested_fast_mode AS execution_requested_fast_mode,
+                run.actual_model AS execution_actual_model,
+                run.actual_provider AS execution_actual_provider,
+                run.actual_reasoning_effort AS execution_actual_reasoning_effort,
+                run.actual_fast_mode AS execution_actual_fast_mode
+                FROM group_messages AS message
+                LEFT JOIN group_agent_runs AS run
+                  ON run.response_message_id = message.id"""
             if before is not None:
                 rows = connection.execute(
-                    f"SELECT * FROM group_messages WHERE {scope} AND visible = 1 AND seq < ? ORDER BY seq DESC LIMIT ?",
+                    f"{select} WHERE {scope} AND message.visible = 1 AND message.seq < ? ORDER BY message.seq DESC LIMIT ?",
                     (*scope_params, before, limit),
                 ).fetchall()
                 rows = list(reversed(rows))
             elif after is not None:
                 rows = connection.execute(
-                    f"SELECT * FROM group_messages WHERE {scope} AND visible = 1 AND seq > ? ORDER BY seq ASC LIMIT ?",
+                    f"{select} WHERE {scope} AND message.visible = 1 AND message.seq > ? ORDER BY message.seq ASC LIMIT ?",
                     (*scope_params, after, limit),
                 ).fetchall()
             else:
                 rows = connection.execute(
-                    f"SELECT * FROM group_messages WHERE {scope} AND visible = 1 ORDER BY seq DESC LIMIT ?",
+                    f"{select} WHERE {scope} AND message.visible = 1 ORDER BY message.seq DESC LIMIT ?",
                     (*scope_params, limit),
                 ).fetchall()
                 rows = list(reversed(rows))
@@ -2832,6 +2920,10 @@ class GroupStore:
         expected_stored_session_id: str | None,
         stored_session_id: str | None,
         outcome: str,
+        actual_model: str | None = None,
+        actual_provider: str | None = None,
+        actual_reasoning_effort: str | None = None,
+        actual_fast_mode: bool | None = None,
         error: str = "",
     ) -> dict[str, object]:
         """Settle message, run, session rotation, and status in one transaction."""
@@ -2839,6 +2931,17 @@ class GroupStore:
         runtime = self._runtime_session_id(runtime_session_id)
         expected_stored = self._runtime_session_id(expected_stored_session_id)
         stored = self._runtime_session_id(stored_session_id)
+        effective_model = self._agent_configuration_value(actual_model, "model")
+        effective_provider = self._agent_configuration_value(
+            actual_provider, "provider"
+        )
+        if (effective_model is None) != (effective_provider is None):
+            raise ValueError("actual model and provider must both be set or both be null")
+        effective_reasoning = self._agent_configuration_value(
+            actual_reasoning_effort, "reasoningEffort"
+        )
+        if actual_fast_mode is not None and not isinstance(actual_fast_mode, bool):
+            raise ValueError("actual fast mode must be a boolean or null")
         run_error = self._message_text(error, "error", max_bytes=4096)
         if outcome not in {"completed", "failed", "interrupted"}:
             raise ValueError("outcome is invalid")
@@ -2873,6 +2976,20 @@ class GroupStore:
                     self._agent_wire(agent),
                     created_at=now,
                 )
+            connection.execute(
+                """UPDATE group_agent_runs
+                SET actual_model = ?, actual_provider = ?,
+                    actual_reasoning_effort = ?, actual_fast_mode = ?
+                WHERE id = ?""",
+                (
+                    effective_model,
+                    effective_provider,
+                    effective_reasoning,
+                    None if actual_fast_mode is None else int(actual_fast_mode),
+                    run["id"],
+                ),
+            )
+            run = self._run_row(connection, run["id"])
             self._cancel_pending_interactions(connection, run, now)
             changed = self._update_run(connection, run, outcome, now, None, run_error)
             self._fail_claimed_interaction_ledgers(
@@ -2904,9 +3021,27 @@ class GroupStore:
                         connection,
                         run["room_id"],
                         "message.upsert",
-                        self._message_wire(response),
+                        self._message_wire(
+                            self._message_with_execution_row(
+                                connection, response["id"]
+                            )
+                        ),
                         created_at=now,
                     )
+            elif (
+                effective_model is not None
+                or effective_reasoning is not None
+                or actual_fast_mode is not None
+            ) and response["visible"]:
+                self._append_event(
+                    connection,
+                    run["room_id"],
+                    "message.upsert",
+                    self._message_wire(
+                        self._message_with_execution_row(connection, response["id"])
+                    ),
+                    created_at=now,
+                )
             if outcome == "completed":
                 self._record_cascade_plan(
                     connection,
@@ -2917,7 +3052,9 @@ class GroupStore:
             self._append_agent_status(connection, run["room_id"], run["agent_id"], now)
             return {
                 "run": changed,
-                "message": self._message_wire(response),
+                "message": self._message_wire(
+                    self._message_with_execution_row(connection, response["id"])
+                ),
                 "agent": self._agent_wire(agent),
             }
 
@@ -3668,9 +3805,13 @@ class GroupStore:
                 """INSERT INTO group_agent_runs
                 (id, room_id, topic_id, agent_id, trigger_message_id,
                  response_message_id, root_message_id, depth, reply_mode,
-                 required_reply, status, runtime_session_id, error, created_at,
+                 required_reply, status, runtime_session_id, requested_model,
+                 requested_provider, requested_reasoning_effort,
+                 requested_fast_mode, actual_model, actual_provider,
+                 actual_reasoning_effort, actual_fast_mode, error, created_at,
                  updated_at)
-                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'queued', NULL, '', ?, ?)""",
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'queued', NULL,
+                        ?, ?, ?, ?, NULL, NULL, NULL, NULL, '', ?, ?)""",
                 (
                     run_id,
                     source["room_id"],
@@ -3681,6 +3822,10 @@ class GroupStore:
                     source["root_message_id"],
                     next_depth,
                     reply_mode,
+                    agent["model_override"],
+                    agent["provider_override"],
+                    agent["reasoning_effort_override"],
+                    agent["fast_mode_override"],
                     now,
                     now,
                 ),
@@ -5252,6 +5397,30 @@ class GroupStore:
             raise GroupNotFoundError("Message not found")
         return row
 
+    @staticmethod
+    def _message_with_execution_row(
+        connection: sqlite3.Connection, message_id: str
+    ) -> sqlite3.Row:
+        row = connection.execute(
+            """SELECT message.*,
+                run.requested_model AS execution_requested_model,
+                run.requested_provider AS execution_requested_provider,
+                run.requested_reasoning_effort AS execution_requested_reasoning_effort,
+                run.requested_fast_mode AS execution_requested_fast_mode,
+                run.actual_model AS execution_actual_model,
+                run.actual_provider AS execution_actual_provider,
+                run.actual_reasoning_effort AS execution_actual_reasoning_effort,
+                run.actual_fast_mode AS execution_actual_fast_mode
+            FROM group_messages AS message
+            LEFT JOIN group_agent_runs AS run
+              ON run.response_message_id = message.id
+            WHERE message.id = ?""",
+            (message_id,),
+        ).fetchone()
+        if row is None:
+            raise GroupNotFoundError("Message not found")
+        return row
+
     @staticmethod
     def _run_row(connection: sqlite3.Connection, run_id: str) -> sqlite3.Row:
         row = connection.execute(
@@ -5331,7 +5500,7 @@ class GroupStore:
             isinstance(item, dict) for item in tool_state
         ):
             raise GroupStoreError("Stored message tool state is corrupt")
-        return {
+        result = {
             "seq": row["seq"],
             "id": row["id"],
             "roomId": row["room_id"],
@@ -5351,6 +5520,31 @@ class GroupStore:
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
         }
+        keys = set(row.keys())
+        if "execution_actual_model" in keys:
+            values = {
+                "requestedModel": row["execution_requested_model"],
+                "requestedProvider": row["execution_requested_provider"],
+                "requestedReasoningEffort": row[
+                    "execution_requested_reasoning_effort"
+                ],
+                "requestedFastMode": (
+                    None
+                    if row["execution_requested_fast_mode"] is None
+                    else bool(row["execution_requested_fast_mode"])
+                ),
+                "actualModel": row["execution_actual_model"],
+                "actualProvider": row["execution_actual_provider"],
+                "actualReasoningEffort": row["execution_actual_reasoning_effort"],
+                "actualFastMode": (
+                    None
+                    if row["execution_actual_fast_mode"] is None
+                    else bool(row["execution_actual_fast_mode"])
+                ),
+            }
+            if any(value is not None for value in values.values()):
+                result["execution"] = values
+        return result
 
     @classmethod
     def _projection_message(cls, row: sqlite3.Row) -> dict[str, object]:
@@ -5514,6 +5708,22 @@ class GroupStore:
             "replyMode": row["reply_mode"],
             "status": row["status"],
             "runtimeSessionId": row["runtime_session_id"],
+            "requestedModel": row["requested_model"],
+            "requestedProvider": row["requested_provider"],
+            "requestedReasoningEffort": row["requested_reasoning_effort"],
+            "requestedFastMode": (
+                None
+                if row["requested_fast_mode"] is None
+                else bool(row["requested_fast_mode"])
+            ),
+            "actualModel": row["actual_model"],
+            "actualProvider": row["actual_provider"],
+            "actualReasoningEffort": row["actual_reasoning_effort"],
+            "actualFastMode": (
+                None
+                if row["actual_fast_mode"] is None
+                else bool(row["actual_fast_mode"])
+            ),
             "error": row["error"],
             "createdAt": row["created_at"],
             "updatedAt": row["updated_at"],
@@ -5526,6 +5736,17 @@ class GroupStore:
         """Keep the pre-v4 strict HTTP replay shape for failed interactions."""
         result = dict(run)
         result.pop("topicId", None)
+        for field in (
+            "requestedModel",
+            "requestedProvider",
+            "requestedReasoningEffort",
+            "requestedFastMode",
+            "actualModel",
+            "actualProvider",
+            "actualReasoningEffort",
+            "actualFastMode",
+        ):
+            result.pop(field, None)
         return result
 
     @staticmethod

+ 42 - 0
hermes-plugins/yaoyao/dashboard/plugin_api.py

@@ -132,6 +132,18 @@ class UploadBody(BaseModel):
     display_name: Optional[str] = None
 
 
+class SessionContextSnapshotBody(BaseModel):
+    used_tokens: int = Field(alias="usedTokens", gt=0)
+    limit_tokens: Optional[int] = Field(default=None, alias="limitTokens", gt=0)
+    percent: Optional[float] = Field(default=None, ge=0, le=100)
+    compressions: Optional[int] = Field(default=None, ge=0)
+    model: Optional[str] = Field(default=None, max_length=4096)
+    provider: Optional[str] = Field(default=None, max_length=4096)
+    observed_at: float = Field(alias="observedAt", gt=0)
+
+    model_config = {"populate_by_name": True}
+
+
 # ---------------------------------------------------------------------------
 # Endpoints
 # ---------------------------------------------------------------------------
@@ -273,6 +285,36 @@ def get_stats(
     return _store_for(profile).stats()
 
 
+@router.get("/session-context/{session_id}")
+def get_session_context(
+    session_id: str,
+    profile: Optional[str] = Query(default=None),
+):
+    """Return the last confirmed context-window reading for one chat."""
+    snapshot = _store_for(profile).get_session_context(session_id)
+    return {"snapshot": snapshot}
+
+
+@router.put("/session-context/{session_id}")
+def put_session_context(
+    session_id: str,
+    body: SessionContextSnapshotBody,
+    profile: Optional[str] = Query(default=None),
+):
+    """Persist a monotonic, profile-scoped context-window reading."""
+    snapshot = _store_for(profile).upsert_session_context(
+        session_id,
+        context_used=body.used_tokens,
+        context_limit=body.limit_tokens,
+        context_percent=body.percent,
+        compressions=body.compressions,
+        model=body.model,
+        provider=body.provider,
+        observed_at=body.observed_at,
+    )
+    return {"snapshot": snapshot}
+
+
 @router.get("/{item_id}")
 def get_item(
     item_id: int,

+ 175 - 0
hermes-plugins/yaoyao/dashboard/store.py

@@ -22,6 +22,7 @@ import copy
 import hashlib
 import json
 import logging
+import math
 import mimetypes
 import os
 import sqlite3
@@ -96,6 +97,24 @@ CREATE TABLE IF NOT EXISTS scanner_metadata (
     key    TEXT PRIMARY KEY,
     value  TEXT NOT NULL
 );
+
+-- Last authoritative context-window snapshot for an ordinary Hermes chat.
+-- The database itself is already profile-scoped, so session_id is sufficient
+-- as the primary key and no profile value can leak across stores.
+CREATE TABLE IF NOT EXISTS session_context_snapshots (
+    session_id       TEXT PRIMARY KEY,
+    context_used     INTEGER NOT NULL,
+    context_limit    INTEGER,
+    context_percent  REAL,
+    compressions     INTEGER,
+    model            TEXT,
+    provider         TEXT,
+    observed_at      REAL NOT NULL,
+    updated_at       REAL NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_session_context_updated
+    ON session_context_snapshots(updated_at DESC);
 """
 
 # Additive migrations run once per data_root at init time. Each is idempotent:
@@ -382,6 +401,162 @@ class Store:
             conn.commit()
         return True
 
+    # -- ordinary-session context snapshots -------------------------------
+
+    def upsert_session_context(
+        self,
+        session_id: str,
+        *,
+        context_used: int,
+        context_limit: Optional[int],
+        context_percent: Optional[float],
+        compressions: Optional[int],
+        model: Optional[str],
+        provider: Optional[str],
+        observed_at: float,
+    ) -> dict[str, Any]:
+        """Persist the newest confirmed context-window reading for a session.
+
+        ``observed_at`` makes writes monotonic across reconnects and devices:
+        a delayed request can never replace a newer model turn.
+        """
+        canonical_id = self._session_context_id(session_id)
+        if isinstance(context_used, bool) or not isinstance(context_used, int):
+            raise ValueError("contextUsed must be an integer")
+        if context_used <= 0:
+            raise ValueError("contextUsed must be positive")
+        if context_limit is not None and (
+            isinstance(context_limit, bool)
+            or not isinstance(context_limit, int)
+            or context_limit <= 0
+        ):
+            raise ValueError("contextLimit must be positive or null")
+        if context_percent is not None and (
+            isinstance(context_percent, bool)
+            or not isinstance(context_percent, (int, float))
+            or not math.isfinite(float(context_percent))
+            or not 0 <= float(context_percent) <= 100
+        ):
+            raise ValueError("contextPercent must be between 0 and 100")
+        if compressions is not None and (
+            isinstance(compressions, bool)
+            or not isinstance(compressions, int)
+            or compressions < 0
+        ):
+            raise ValueError("compressions must be non-negative or null")
+        if (
+            isinstance(observed_at, bool)
+            or not isinstance(observed_at, (int, float))
+            or not math.isfinite(float(observed_at))
+            or observed_at <= 0
+        ):
+            raise ValueError("observedAt must be a positive timestamp")
+        normalized_model = self._optional_context_label(model, "model")
+        normalized_provider = self._optional_context_label(provider, "provider")
+        percentage = (
+            float(context_percent)
+            if context_percent is not None
+            else (
+                min(100.0, max(0.0, context_used / context_limit * 100.0))
+                if context_limit is not None
+                else None
+            )
+        )
+        now = time.time()
+        conn = self.init()
+        with self._lock:
+            conn.execute(
+                """INSERT INTO session_context_snapshots
+                (session_id, context_used, context_limit, context_percent,
+                 compressions, model, provider, observed_at, updated_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+                ON CONFLICT(session_id) DO UPDATE SET
+                    context_used = excluded.context_used,
+                    context_limit = excluded.context_limit,
+                    context_percent = excluded.context_percent,
+                    compressions = excluded.compressions,
+                    model = excluded.model,
+                    provider = excluded.provider,
+                    observed_at = excluded.observed_at,
+                    updated_at = excluded.updated_at
+                WHERE excluded.observed_at >= session_context_snapshots.observed_at""",
+                (
+                    canonical_id,
+                    context_used,
+                    context_limit,
+                    percentage,
+                    compressions,
+                    normalized_model,
+                    normalized_provider,
+                    float(observed_at),
+                    now,
+                ),
+            )
+            conn.commit()
+            row = conn.execute(
+                "SELECT * FROM session_context_snapshots WHERE session_id = ?",
+                (canonical_id,),
+            ).fetchone()
+        if row is None:
+            raise RuntimeError("session context snapshot was not persisted")
+        return self._session_context_payload(row)
+
+    def get_session_context(self, session_id: str) -> Optional[dict[str, Any]]:
+        canonical_id = self._session_context_id(session_id)
+        conn = self.init()
+        with self._lock:
+            row = conn.execute(
+                "SELECT * FROM session_context_snapshots WHERE session_id = ?",
+                (canonical_id,),
+            ).fetchone()
+        return None if row is None else self._session_context_payload(row)
+
+    @staticmethod
+    def _session_context_id(value: str) -> str:
+        if not isinstance(value, str):
+            raise ValueError("sessionId must be a string")
+        normalized = value.strip()
+        if not normalized or len(normalized.encode("utf-8")) > 4096:
+            raise ValueError("sessionId must be between 1 and 4096 bytes")
+        if any(ord(character) < 32 for character in normalized):
+            raise ValueError("sessionId contains control characters")
+        return normalized
+
+    @staticmethod
+    def _optional_context_label(value: Optional[str], field: str) -> Optional[str]:
+        if value is None:
+            return None
+        if not isinstance(value, str):
+            raise ValueError(f"{field} must be a string or null")
+        normalized = value.strip()
+        if not normalized:
+            return None
+        if len(normalized.encode("utf-8")) > 4096:
+            raise ValueError(f"{field} exceeds 4096 bytes")
+        return normalized
+
+    @staticmethod
+    def _session_context_payload(row: sqlite3.Row) -> dict[str, Any]:
+        return {
+            "sessionId": row["session_id"],
+            "usedTokens": int(row["context_used"]),
+            "limitTokens": (
+                None if row["context_limit"] is None else int(row["context_limit"])
+            ),
+            "percent": (
+                None
+                if row["context_percent"] is None
+                else float(row["context_percent"])
+            ),
+            "compressions": (
+                None if row["compressions"] is None else int(row["compressions"])
+            ),
+            "model": row["model"],
+            "provider": row["provider"],
+            "observedAt": float(row["observed_at"]),
+            "updatedAt": float(row["updated_at"]),
+        }
+
     # -- queries -----------------------------------------------------------
 
     def get_attachment(self, attachment_id: int) -> Optional[dict[str, Any]]:

+ 71 - 0
hermes-plugins/yaoyao/dashboard/tests/test_file_library_contract.py

@@ -19,6 +19,77 @@ with mock.patch.object(poller, "start", return_value=None):
 
 
 class FileLibraryContractTests(unittest.TestCase):
+    def test_session_context_snapshot_is_profile_scoped_and_monotonic(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            first = store.Store(Path(directory) / "first")
+            second = store.Store(Path(directory) / "second")
+
+            saved = first.upsert_session_context(
+                "session-history-1",
+                context_used=41_920,
+                context_limit=256_000,
+                context_percent=None,
+                compressions=2,
+                model="gpt-5.6",
+                provider="openai-codex",
+                observed_at=200,
+            )
+            stale = first.upsert_session_context(
+                "session-history-1",
+                context_used=12_000,
+                context_limit=128_000,
+                context_percent=9,
+                compressions=0,
+                model="old-model",
+                provider="old-provider",
+                observed_at=100,
+            )
+
+            self.assertEqual(saved["usedTokens"], 41_920)
+            self.assertAlmostEqual(saved["percent"], 16.375)
+            self.assertEqual(stale["usedTokens"], 41_920)
+            self.assertEqual(stale["model"], "gpt-5.6")
+            self.assertEqual(
+                first.get_session_context("session-history-1"), stale
+            )
+            self.assertIsNone(second.get_session_context("session-history-1"))
+
+    def test_session_context_routes_use_selected_profile_store(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            archive = store.Store(Path(directory) / "profile")
+            requested_profiles: list[str | None] = []
+
+            def resolve(profile: str | None):
+                requested_profiles.append(profile)
+                return archive
+
+            body = plugin_api.SessionContextSnapshotBody.model_validate(
+                {
+                    "usedTokens": 33_600,
+                    "limitTokens": 256_000,
+                    "percent": 13.125,
+                    "compressions": 1,
+                    "model": "claude-sonnet-4-6",
+                    "provider": "anthropic",
+                    "observedAt": 300,
+                }
+            )
+            with mock.patch.object(plugin_api, "_store_for", side_effect=resolve):
+                written = plugin_api.put_session_context(
+                    "session-2", body, profile="planner"
+                )
+                loaded = plugin_api.get_session_context(
+                    "session-2", profile="planner"
+                )
+
+            self.assertEqual(requested_profiles, ["planner", "planner"])
+            self.assertEqual(
+                written["snapshot"]["model"], "claude-sonnet-4-6"
+            )
+            self.assertEqual(loaded, written)
+            paths = {getattr(route, "path", "") for route in plugin_api.router.routes}
+            self.assertIn("/session-context/{session_id}", paths)
+
     def test_search_and_kind_filter_before_cursor_pagination(self) -> None:
         with tempfile.TemporaryDirectory() as directory:
             root = Path(directory)

+ 15 - 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, 5)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 7)
         legacy = PROTOCOL.CreateRoomRequest.model_validate({
             "requestId": new_id(),
             "name": "兼容群",
@@ -472,13 +472,26 @@ class GroupHostContractTests(unittest.TestCase):
                 connection.execute(
                     "ALTER TABLE group_agent_runs DROP COLUMN required_reply"
                 )
+                for column in (
+                    "requested_model",
+                    "requested_provider",
+                    "requested_reasoning_effort",
+                    "requested_fast_mode",
+                    "actual_model",
+                    "actual_provider",
+                    "actual_reasoning_effort",
+                    "actual_fast_mode",
+                ):
+                    connection.execute(
+                        f"ALTER TABLE group_agent_runs DROP COLUMN {column}"
+                    )
                 connection.execute(
                     "UPDATE group_meta SET value = '4' WHERE key = 'schema_version'"
                 )
 
             migrated = GroupStore(path)
             migrated.initialize()
-            self.assertEqual(migrated.schema_version(), 5)
+            self.assertEqual(migrated.schema_version(), 7)
             with migrated.connection() as connection:
                 after_counts = {
                     table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]

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

@@ -36,6 +36,138 @@ def request_id() -> str:
 
 
 class GroupSettingsContractTests(unittest.TestCase):
+    def test_terminal_session_info_captures_effective_model_for_settlement(self) -> None:
+        async def exercise() -> tuple[object, dict[str, object]]:
+            orchestrator = ORCHESTRATOR.GroupOrchestrator(object())
+            state = ORCHESTRATOR._RuntimeState(
+                run_id=request_id(),
+                room_id=request_id(),
+                agent_id=request_id(),
+                runtime_id="runtime-effective-model",
+                generation=1,
+                expected_stored_id=None,
+                session_stored_id="stored-before",
+                expected_context_seq=0,
+                through_seq=0,
+                profile="default",
+                complete_seen=True,
+                complete_index=1,
+                event_index=2,
+                terminal_status="completed",
+            )
+            captured: dict[str, object] = {}
+
+            async def capture(_state: object, **kwargs: object) -> None:
+                captured.update(kwargs)
+
+            orchestrator._finalize_state_locked = capture  # type: ignore[method-assign]
+            await orchestrator._apply_runtime_event(
+                state,
+                ORCHESTRATOR._GatewayEvent(
+                    runtime_id=state.runtime_id,
+                    generation=state.generation,
+                    event_type="session.info",
+                    payload={
+                        "running": False,
+                        "stored_session_id": "stored-after",
+                        "profile_name": "default",
+                        "model": "claude-sonnet-4-6",
+                        "provider": "anthropic",
+                        "reasoning_effort": "xhigh",
+                        "fast": True,
+                    },
+                ),
+            )
+            return state, captured
+
+        state, captured = asyncio.run(exercise())
+        self.assertEqual(state.actual_model, "claude-sonnet-4-6")
+        self.assertEqual(state.actual_provider, "anthropic")
+        self.assertEqual(state.actual_reasoning_effort, "xhigh")
+        self.assertTrue(state.actual_fast_mode)
+        self.assertEqual(captured["outcome"], "completed")
+        self.assertEqual(captured["stored_session_id"], "stored-after")
+
+    def test_run_records_configured_and_effective_model_on_topic_message(self) -> None:
+        with tempfile.TemporaryDirectory() as directory:
+            store = GroupStore(Path(directory) / "group.db")
+            store.initialize()
+            room = store.create_room(
+                {
+                    "requestId": request_id(),
+                    "name": "模型记录群",
+                    "cwd": "",
+                    "agents": [
+                        {
+                            "profile": "default",
+                            "model": "gpt-5.6",
+                            "provider": "openai-codex",
+                            "reasoningEffort": "high",
+                            "fastMode": False,
+                        }
+                    ],
+                }
+            )
+            created = store.create_human_message(
+                room["id"],
+                request_id=request_id(),
+                client_message_id=request_id(),
+                content="请回答",
+                mention_agent_ids=[room["agents"][0]["id"]],
+            )
+            run = created["runs"][0]
+            self.assertEqual(run["requestedModel"], "gpt-5.6")
+            self.assertEqual(run["requestedProvider"], "openai-codex")
+            self.assertEqual(run["requestedReasoningEffort"], "high")
+            self.assertFalse(run["requestedFastMode"])
+            self.assertIsNone(run["actualModel"])
+
+            claimed = store.claim_next_runnable_run()
+            self.assertEqual(claimed["id"], run["id"])
+            store.bind_run_runtime(run["id"], "runtime-model-attribution")
+            store.upsert_agent_message(
+                run["id"],
+                content="已使用备用模型完成",
+                reasoning="",
+                tool_state=[],
+                status="completed",
+            )
+            settled = store.settle_run(
+                run["id"],
+                runtime_session_id="runtime-model-attribution",
+                expected_stored_session_id=None,
+                stored_session_id="stored-model-attribution",
+                outcome="completed",
+                actual_model="claude-sonnet-4-6",
+                actual_provider="anthropic",
+                actual_reasoning_effort="xhigh",
+                actual_fast_mode=True,
+            )
+            self.assertEqual(
+                settled["run"]["actualModel"], "claude-sonnet-4-6"
+            )
+            self.assertEqual(settled["run"]["actualProvider"], "anthropic")
+            self.assertEqual(settled["run"]["actualReasoningEffort"], "xhigh")
+            self.assertTrue(settled["run"]["actualFastMode"])
+
+            messages = store.list_messages(
+                room["id"], topic_id=created["message"]["topicId"], limit=100
+            )
+            response = next(item for item in messages if item["senderKind"] == "agent")
+            self.assertEqual(
+                response["execution"],
+                {
+                    "requestedModel": "gpt-5.6",
+                    "requestedProvider": "openai-codex",
+                    "requestedReasoningEffort": "high",
+                    "requestedFastMode": False,
+                    "actualModel": "claude-sonnet-4-6",
+                    "actualProvider": "anthropic",
+                    "actualReasoningEffort": "xhigh",
+                    "actualFastMode": True,
+                },
+            )
+
     def test_gateway_session_create_receives_agent_configuration(self) -> None:
         captured: dict[str, object] = {}
         adapter = GATEWAY.GroupGatewayAdapter(dispatcher=lambda *_: None)
@@ -205,7 +337,7 @@ class GroupSettingsContractTests(unittest.TestCase):
                 )
 
     def test_protocol_v5_advertises_reply_round_contract(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 5)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 7)
         self.assertEqual(
             PROTOCOL.limits_payload()["defaultMaxReplyRounds"], 3
         )
@@ -579,7 +711,7 @@ class GroupSettingsContractTests(unittest.TestCase):
             self._create_v1_database(path)
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 5)
+            self.assertEqual(store.schema_version(), 7)
             self.assertEqual(store.journal_epoch(), "11111111-1111-4111-8111-111111111111")
             with store.connection() as connection:
                 room = connection.execute("SELECT * FROM group_rooms").fetchone()

+ 2 - 2
hermes-plugins/yaoyao/dashboard/tests/test_group_topics_contract.py

@@ -33,7 +33,7 @@ def new_id() -> str:
 
 class GroupTopicsContractTests(unittest.TestCase):
     def test_protocol_and_router_advertise_topics(self) -> None:
-        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 5)
+        self.assertEqual(PROTOCOL.PROTOCOL_VERSION, 7)
         self.assertIn("topic.updated", PROTOCOL.EVENT_TYPES)
         request = PROTOCOL.SendMessageRequest.model_validate({
             "requestId": new_id(),
@@ -1162,7 +1162,7 @@ class GroupTopicsContractTests(unittest.TestCase):
 
             store = GroupStore(path)
             store.initialize()
-            self.assertEqual(store.schema_version(), 5)
+            self.assertEqual(store.schema_version(), 7)
             with store.connection() as migrated:
                 room = migrated.execute("SELECT * FROM group_rooms").fetchone()
                 agent = migrated.execute("SELECT * FROM group_agents").fetchone()