fix: 补全 vendor 快照(model_tools/utils/toolsets/gateway/cron/hermes_time)+ python-dotenv 依赖

- vendor_hermes.sh: 补全遗漏的单文件模块和目录
- pyproject.toml: 添加 python-dotenv 隐性依赖
- 全量 import 测试通过(216 .py, 180K 行)
- firecrawl/fal_client 为可选工具,缺失时优雅跳过
This commit is contained in:
2026-04-29 02:24:04 +08:00
parent 9fe8a43e70
commit 3a1ecd7adc
67 changed files with 53228 additions and 64 deletions
+150 -38
View File
@@ -31,7 +31,7 @@ T = TypeVar("T")
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
SCHEMA_VERSION = 8
SCHEMA_VERSION = 9
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_version (
@@ -81,7 +81,8 @@ CREATE TABLE IF NOT EXISTS messages (
finish_reason TEXT,
reasoning TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT
codex_reasoning_items TEXT,
archived INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);
@@ -366,6 +367,13 @@ class SessionDB:
except Exception:
pass
cursor.execute("UPDATE schema_version SET version = 8")
if current_version < 9:
# v9: messages 表增加 archived 列(Context Compaction 消息归档)
try:
cursor.execute("ALTER TABLE messages ADD COLUMN archived INTEGER DEFAULT 0")
except sqlite3.OperationalError:
pass # Column already exists
cursor.execute("UPDATE schema_version SET version = 9")
# Unique title index — always ensure it exists (safe to run after migrations
# since the title column is guaranteed to exist at this point)
@@ -920,19 +928,125 @@ class SessionDB:
result.append(msg)
return result
def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
def archive_messages(self, session_id: str) -> int:
"""将指定 session 的所有活跃消息标记为 archived。
用于 Context Compaction:原始消息保留在 DB 中供前端历史查看,
但不再被 get_messages_as_conversation() 返回给 LLM。
Returns:
归档的消息数量。
"""
Load messages in the OpenAI conversation format (role + content dicts).
Used by the gateway to restore conversation history.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"reasoning, reasoning_details, codex_reasoning_items "
"FROM messages WHERE session_id = ? ORDER BY timestamp, id",
def _do(conn):
cursor = conn.execute(
"UPDATE messages SET archived = 1 "
"WHERE session_id = ? AND archived = 0",
(session_id,),
)
rows = cursor.fetchall()
return cursor.rowcount
return self._execute_write(_do)
def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
"""
Load active (non-archived) messages in the OpenAI conversation format.
Used by the gateway to restore conversation history for LLM context.
Archived messages (from Context Compaction) are excluded.
"""
with self._lock:
rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"reasoning, reasoning_details, codex_reasoning_items "
"FROM messages WHERE session_id = ? AND archived = 0 ORDER BY timestamp, id",
(session_id,),
).fetchall()
return self._rows_to_conversation(rows)
# ── Compaction 标记前缀(与 context_compressor.py 保持一致) ──
_COMPACTION_MARKER = "[CONTEXT COMPACTION"
def get_all_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
"""Load ALL messages (including archived) for frontend history display.
智能合并策略:
1. 如果没有归档消息 → 直接返回活跃消息(未发生过 compaction)。
2. 如果有归档消息 → 以归档消息为"基础历史",然后从活跃消息中
**去除 compaction summary + tail 重复副本**,只追加真正的新消息。
这确保前端看到完整、无重复的对话历史,同时 LLM 侧的
get_messages_as_conversation() 仍然只返回活跃(精简)上下文。
"""
with self._lock:
archived_rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"reasoning, reasoning_details, codex_reasoning_items "
"FROM messages WHERE session_id = ? AND archived = 1 "
"ORDER BY timestamp, id",
(session_id,),
).fetchall()
active_rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"reasoning, reasoning_details, codex_reasoning_items "
"FROM messages WHERE session_id = ? AND archived = 0 "
"ORDER BY timestamp, id",
(session_id,),
).fetchall()
# 无归档 → 从未压缩过,直接返回全部活跃消息
if not archived_rows:
return self._rows_to_conversation(active_rows)
archived_msgs = self._rows_to_conversation(archived_rows)
# 过滤归档中的 compaction summary(多次压缩可能产生多条)
archived_msgs = [
m for m in archived_msgs
if not (m.get("content") or "").lstrip().startswith(self._COMPACTION_MARKER)
]
# 构建归档消息的签名集(role + content 前 300 字),用于去重
# tail 消息的 timestamp 在 flush 时被重写,可能出现在 archived
# 的任意位置,因此必须对全量 archived 构建签名
all_sigs: set = set()
for msg in archived_msgs:
sig = self._msg_dedup_sig(msg)
all_sigs.add(sig)
# 从活跃消息中筛选真正的新消息
active_msgs = self._rows_to_conversation(active_rows)
new_msgs = []
for msg in active_msgs:
content = msg.get("content") or ""
# 跳过 compaction summaryLLM 内部参考,不应展示给用户)
if content.lstrip().startswith(self._COMPACTION_MARKER):
continue
# 跳过 tail 重复副本
sig = self._msg_dedup_sig(msg)
if sig in all_sigs:
continue
new_msgs.append(msg)
return archived_msgs + new_msgs
@staticmethod
def _msg_dedup_sig(msg: dict) -> tuple:
"""生成消息去重签名。
签名组成:(role, content_prefix, tool_call_id, tc_fingerprint)
- tool_call_id: tool 角色消息的关联 ID
- tc_fingerprint: assistant 消息携带的 tool_calls 首个 call ID
(防止空 content 但不同 tool_calls 的 assistant 消息误判重复)
"""
content = (msg.get("content") or "")[:300]
tc_fp = ""
tool_calls = msg.get("tool_calls")
if tool_calls and isinstance(tool_calls, list) and len(tool_calls) > 0:
first_tc = tool_calls[0] if isinstance(tool_calls[0], dict) else {}
tc_fp = first_tc.get("id", "") or first_tc.get("call_id", "")
return (msg.get("role", ""), content, msg.get("tool_call_id") or "", tc_fp)
def _rows_to_conversation(self, rows) -> List[Dict[str, Any]]:
"""将 DB rows 转换为 OpenAI conversation 格式(共享解析逻辑)。"""
messages = []
for row in rows:
msg = {"role": row["role"], "content": row["content"]}
@@ -944,11 +1058,8 @@ class SessionDB:
try:
msg["tool_calls"] = json.loads(row["tool_calls"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize tool_calls in conversation replay, falling back to []")
logger.warning("Failed to deserialize tool_calls, falling back to []")
msg["tool_calls"] = []
# Restore reasoning fields on assistant messages so providers
# that replay reasoning (OpenRouter, OpenAI, Nous) receive
# coherent multi-turn reasoning context.
if row["role"] == "assistant":
if row["reasoning"]:
msg["reasoning"] = row["reasoning"]
@@ -956,13 +1067,11 @@ class SessionDB:
try:
msg["reasoning_details"] = json.loads(row["reasoning_details"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize reasoning_details, falling back to None")
msg["reasoning_details"] = None
if row["codex_reasoning_items"]:
try:
msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize codex_reasoning_items, falling back to None")
msg["codex_reasoning_items"] = None
messages.append(msg)
return messages
@@ -1162,13 +1271,24 @@ class SessionDB:
cursor = self._conn.execute("SELECT COUNT(*) FROM sessions")
return cursor.fetchone()[0]
def message_count(self, session_id: str = None) -> int:
"""Count messages, optionally for a specific session."""
def message_count(self, session_id: str = None, active_only: bool = False) -> int:
"""Count messages, optionally for a specific session.
Args:
session_id: If provided, count only messages for this session.
active_only: If True, exclude archived messages (from Context Compaction).
"""
with self._lock:
if session_id:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
)
if active_only:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM messages WHERE session_id = ? AND archived = 0",
(session_id,),
)
else:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
)
else:
cursor = self._conn.execute("SELECT COUNT(*) FROM messages")
return cursor.fetchone()[0]
@@ -1305,21 +1425,13 @@ class SessionDB:
s.started_at
) AS last_active,
(
COALESCE(
(SELECT SUM(
(LENGTH(m3.tool_calls) - LENGTH(REPLACE(m3.tool_calls, '"write_file"', ''))) / LENGTH('"write_file"')
) FROM messages m3
WHERE m3.session_id = s.id
AND m3.tool_calls LIKE '%"write_file"%'),
0)
+
(SELECT COUNT(*) FROM messages m4
WHERE m4.session_id = s.id
AND m4.tool_calls LIKE '%"patch"%')
+
(SELECT COUNT(*) FROM messages m5
WHERE m5.session_id = s.id
AND m5.role = 'assistant' AND m5.content LIKE '%"type"%audio"%')
SELECT COUNT(*) FROM messages m3
WHERE m3.session_id = s.id
AND (
m3.tool_calls LIKE '%"write_file"%'
OR m3.tool_calls LIKE '%"patch"%'
OR (m3.role = 'assistant' AND m3.content LIKE '%"type"%audio"%')
)
) AS work_product_count
FROM sessions s
WHERE s.user_id = ?