Skip to content

Commit c4d9513

Browse files
author
赵明俊
committed
feat(对齐): 上下文组装与SSE协议对齐,并统一错误映射
- 新增 ContextAssembler(会话+SQLite+向量三层上下文),并接入 SessionManager - SSE 补齐 step_key/scope/context_debug,适配并行子任务时间线 - 引入统一错误码/脱敏与上游 LLM 错误分类(HTTP+SSE) - 适配 TaskExecutor 结果结构与穿插规划(Adaptive Replan) - API Key 规范化(去 Bearer 前缀)并在鉴权失败时自动清理无效 Key Made-with: Cursor
1 parent 3e8ab3b commit c4d9513

8 files changed

Lines changed: 586 additions & 43 deletions

File tree

hackbot_config/__init__.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,16 +133,23 @@ def delete_provider_api_key(provider: str) -> bool:
133133
_get_api_key_from_sqlite = _get_config_from_sqlite
134134

135135

136+
def normalize_bearer_api_key(key: str) -> str:
137+
"""去掉用户误写的 'Bearer ' 前缀,与 npm normalizeBearerApiKey 对齐。"""
138+
if key and key.strip().lower().startswith("bearer "):
139+
return key.strip()[7:].strip()
140+
return key.strip() if key else ""
141+
142+
136143
def get_provider_api_key(provider: str) -> Optional[str]:
137-
"""获取任意厂商的 API Key(优先 SQLite,其次环境变量)"""
144+
"""获取任意厂商的 API Key(优先 SQLite,其次环境变量),自动规范化 Bearer 前缀。"""
138145
# SQLite
139146
key = _get_config_from_sqlite(f"{provider}_api_key")
140147
if key and key.strip():
141-
return key.strip()
148+
return normalize_bearer_api_key(key)
142149
# 环境变量 (如 OPENAI_API_KEY / DEEPSEEK_API_KEY / ...)
143150
env_val = os.getenv(f"{provider.upper()}_API_KEY")
144151
if env_val and env_val.strip():
145-
return env_val.strip()
152+
return normalize_bearer_api_key(env_val)
146153
return None
147154

148155

router/chat.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
from router.dependencies import (
1616
get_agent,
1717
get_agents,
18+
get_context_assembler,
1819
get_planner_agent,
1920
get_qa_agent,
2021
get_summary_agent,
2122
)
2223
from router.schemas import ChatRequest, ChatResponse, RootResponseRequest
2324
from secbot_agent.core.session import SessionManager
25+
from utils.error_mapper import map_exception_to_client, redact_sensitive_text
2426
from utils.event_bus import EventBus, EventType, Event
2527
from utils.logger import logger
2628
from utils.log_context import log_context
@@ -32,6 +34,14 @@
3234
_root_pending: dict[str, asyncio.Future[dict[str, Any] | None]] = {}
3335

3436

37+
def _sse_step_key(data: dict, iteration: int) -> str:
38+
"""与 npm 端 sseStepKey 对齐:并行子任务以 todo_id 区分,避免前端时间线串台。"""
39+
todo_id = data.get("todo_id") or data.get("todoId")
40+
if todo_id is not None and str(todo_id).strip():
41+
return f"todo-{todo_id}"
42+
return f"iter-{iteration}"
43+
44+
3545
def _event_to_sse(event: Event) -> tuple[str, dict] | None:
3646
"""将 EventBus 事件映射为前端 SSE 的 (event_name, data)。"""
3747
t, d = event.type, event.data
@@ -40,55 +50,67 @@ def _event_to_sse(event: Event) -> tuple[str, dict] | None:
4050
"planning",
4151
{
4252
"content": d.get("summary", ""),
53+
"summary": d.get("summary", ""),
54+
"scope": d.get("scope", "master"),
4355
"todos": d.get("todos", []),
4456
"agent": d.get("agent"),
4557
},
4658
)
4759
if t == EventType.THINK_START:
60+
iteration = d.get("iteration", 1)
4861
return (
4962
"thought_start",
5063
{
51-
"iteration": d.get("iteration", 1),
64+
"iteration": iteration,
65+
"step_key": _sse_step_key(d, iteration),
5266
"agent": d.get("agent"),
5367
},
5468
)
5569
if t == EventType.THINK_CHUNK:
70+
iteration = d.get("iteration", 1)
5671
return (
5772
"thought_chunk",
5873
{
5974
"chunk": d.get("chunk", ""),
60-
"iteration": d.get("iteration", 1),
75+
"iteration": iteration,
76+
"step_key": _sse_step_key(d, iteration),
6177
"agent": d.get("agent"),
6278
},
6379
)
6480
if t == EventType.THINK_END:
81+
iteration = d.get("iteration", 1)
6582
return (
6683
"thought",
6784
{
6885
"content": d.get("thought", ""),
69-
"iteration": d.get("iteration", 1),
86+
"iteration": iteration,
87+
"step_key": _sse_step_key(d, iteration),
7088
"agent": d.get("agent"),
7189
},
7290
)
7391
if t == EventType.EXEC_START:
92+
iteration = d.get("iteration", 1)
7493
return (
7594
"action_start",
7695
{
7796
"tool": d.get("tool", ""),
7897
"params": d.get("params", {}),
79-
"iteration": d.get("iteration", 1),
98+
"iteration": iteration,
99+
"step_key": _sse_step_key(d, iteration),
80100
"agent": d.get("agent"),
81101
},
82102
)
83103
if t == EventType.EXEC_RESULT:
104+
iteration = d.get("iteration", 1)
84105
return (
85106
"action_result",
86107
{
87108
"tool": d.get("tool", ""),
88109
"success": d.get("success", True),
89110
"result": d.get("result"),
90111
"error": d.get("error", ""),
91-
"iteration": d.get("iteration", 1),
112+
"iteration": iteration,
113+
"step_key": _sse_step_key(d, iteration),
92114
"view_type": d.get("view_type", "raw"),
93115
"agent": d.get("agent"),
94116
},
@@ -201,6 +223,7 @@ async def get_root_password(command: str) -> dict[str, Any] | None:
201223
planner=get_planner_agent(),
202224
qa_agent=get_qa_agent(),
203225
summary_agent=get_summary_agent(),
226+
context_assembler=get_context_assembler(),
204227
get_root_password=get_root_password,
205228
)
206229

@@ -229,10 +252,15 @@ async def _run_interaction():
229252
)
230253
final_response.append(response)
231254
except Exception as e:
255+
mapped = map_exception_to_client(e)
232256
queue.put_nowait(
233257
{
234258
"event": "error",
235-
"data": {"error": str(e), "traceback": traceback.format_exc()},
259+
"data": {
260+
"error": mapped.message,
261+
"code": mapped.code.value,
262+
"statusCode": mapped.status_code,
263+
},
236264
}
237265
)
238266
finally:

router/dependencies.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
from secbot_agent.core.agents.qa_agent import QAAgent
1313
from secbot_agent.core.agents.planner_agent import PlannerAgent
1414
from secbot_agent.core.agents.summary_agent import SummaryAgent
15+
from secbot_agent.core.context_assembler import ContextAssembler
1516
from secbot_agent.database.manager import DatabaseManager
1617
from secbot_agent.core.memory.database_memory import DatabaseMemory
18+
from secbot_agent.core.memory.manager import MemoryManager
19+
from secbot_agent.core.memory.vector_store import VectorStoreManager
1720
from secbot_agent.defense.defense_manager import DefenseManager
1821
from secbot_agent.controller.controller import MainController
1922
from secbot_agent.system.controller import OSController
@@ -41,6 +44,9 @@ class _Singletons:
4144
_qa_agent: QAAgent | None = None
4245
_planner_agent: PlannerAgent | None = None
4346
_summary_agent: SummaryAgent | None = None
47+
_context_assembler: ContextAssembler | None = None
48+
_memory_manager: MemoryManager | None = None
49+
_vector_store_manager: VectorStoreManager | None = None
4450
_defense_manager: DefenseManager | None = None
4551
_main_controller: MainController | None = None
4652
_os_controller: OSController | None = None
@@ -110,6 +116,29 @@ def summary_agent(cls) -> SummaryAgent:
110116
cls._summary_agent = SummaryAgent()
111117
return cls._summary_agent
112118

119+
# -- 记忆与上下文 --
120+
@classmethod
121+
def memory_manager(cls) -> MemoryManager:
122+
if cls._memory_manager is None:
123+
cls._memory_manager = MemoryManager()
124+
return cls._memory_manager
125+
126+
@classmethod
127+
def vector_store_manager(cls) -> VectorStoreManager:
128+
if cls._vector_store_manager is None:
129+
cls._vector_store_manager = VectorStoreManager()
130+
return cls._vector_store_manager
131+
132+
@classmethod
133+
def context_assembler(cls) -> ContextAssembler:
134+
if cls._context_assembler is None:
135+
cls._context_assembler = ContextAssembler(
136+
db_manager=cls.db_manager(),
137+
memory_manager=cls.memory_manager(),
138+
vector_store_manager=cls.vector_store_manager(),
139+
)
140+
return cls._context_assembler
141+
113142
# -- 防御管理器 --
114143
@classmethod
115144
def defense_manager(cls) -> DefenseManager:
@@ -193,5 +222,17 @@ def get_os_detector() -> OSDetector:
193222
return _Singletons.os_detector()
194223

195224

225+
def get_context_assembler() -> ContextAssembler:
226+
return _Singletons.context_assembler()
227+
228+
229+
def get_memory_manager() -> MemoryManager:
230+
return _Singletons.memory_manager()
231+
232+
233+
def get_vector_store_manager() -> VectorStoreManager:
234+
return _Singletons.vector_store_manager()
235+
236+
196237
def get_session_id() -> str:
197238
return _session_id
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""
2+
ContextAssembler:三层上下文组装器
3+
与 npm-release 的 ContextAssemblerService 对齐,组合:
4+
1. 当前会话最近消息(RecentSession)
5+
2. SQLite 历史轮次(SQLiteHistory)
6+
3. 向量 episodic 记忆检索(VectorMemory)
7+
"""
8+
9+
import math
10+
import unicodedata
11+
from dataclasses import dataclass
12+
from typing import Dict, List, Optional, TYPE_CHECKING
13+
14+
from utils.logger import logger
15+
16+
if TYPE_CHECKING:
17+
from secbot_agent.core.memory.manager import MemoryManager
18+
from secbot_agent.core.memory.vector_store import VectorStoreManager
19+
from secbot_agent.core.models import Session
20+
from secbot_agent.database.manager import DatabaseManager
21+
22+
VECTOR_DIMENSION = 128
23+
24+
25+
@dataclass
26+
class ContextDebugMeta:
27+
session_messages: int = 0
28+
sqlite_turns: int = 0
29+
vector_hits: int = 0
30+
31+
32+
@dataclass
33+
class AssembledContext:
34+
context_block: str = ""
35+
debug: ContextDebugMeta = None
36+
37+
def __post_init__(self):
38+
if self.debug is None:
39+
self.debug = ContextDebugMeta()
40+
41+
42+
class ContextAssembler:
43+
"""
44+
三层上下文组装器 —— 合并会话消息、SQLite 对话历史和向量 episodic 记忆,
45+
为 QA / Agent 提供统一的 contextBlock。
46+
"""
47+
48+
def __init__(
49+
self,
50+
db_manager: "DatabaseManager",
51+
memory_manager: Optional["MemoryManager"] = None,
52+
vector_store_manager: Optional["VectorStoreManager"] = None,
53+
):
54+
self.db_manager = db_manager
55+
self.memory_manager = memory_manager
56+
self.vector_store_manager = vector_store_manager
57+
58+
async def build(
59+
self,
60+
query: str,
61+
session: "Session",
62+
session_id: str,
63+
agent_type: str = "hackbot",
64+
) -> AssembledContext:
65+
recent_session = [
66+
f"{m.role.value}: {m.content}" for m in session.messages[-24:]
67+
]
68+
69+
sqlite_history = self.db_manager.get_conversations(
70+
session_id=session_id, limit=8
71+
)
72+
73+
dedupe = set()
74+
sqlite_lines: List[str] = []
75+
for turn in reversed(sqlite_history):
76+
pair = f"用户: {turn.user_message}\n助手: {turn.assistant_message}"
77+
if pair in dedupe:
78+
continue
79+
dedupe.add(pair)
80+
sqlite_lines.append(pair)
81+
82+
vector_lines: List[str] = []
83+
if self.vector_store_manager is not None:
84+
try:
85+
query_vec = text_to_vector(query)
86+
store = self.vector_store_manager.get_store("episodic", VECTOR_DIMENSION)
87+
hits = store.search(query_vec, limit=6, collection="episodic", threshold=0.3)
88+
for item, similarity in hits:
89+
content = item.content.strip()
90+
if not content or content in dedupe:
91+
continue
92+
dedupe.add(content)
93+
sid = (item.metadata or {}).get("sessionId", "unknown")
94+
vector_lines.append(
95+
f"{content}\n来源: {sid} / 相似度: {similarity:.3f}"
96+
)
97+
except Exception as e:
98+
logger.warning(f"ContextAssembler 向量检索失败: {e}")
99+
100+
parts: List[str] = []
101+
if recent_session:
102+
parts.append(f"【RecentSession】\n" + "\n".join(recent_session))
103+
if sqlite_lines:
104+
parts.append(f"【SQLiteHistory】\n" + "\n\n".join(sqlite_lines))
105+
if vector_lines:
106+
parts.append(f"【VectorMemory】\n" + "\n\n".join(vector_lines))
107+
parts.append(f"【RequestMeta】\nsession_id: {session_id}\nagent: {agent_type}")
108+
109+
return AssembledContext(
110+
context_block="\n\n".join(parts),
111+
debug=ContextDebugMeta(
112+
session_messages=len(recent_session),
113+
sqlite_turns=len(sqlite_lines),
114+
vector_hits=len(vector_lines),
115+
),
116+
)
117+
118+
async def remember_turn(
119+
self,
120+
session_id: str,
121+
agent_type: str,
122+
user_message: str,
123+
assistant_message: str,
124+
) -> None:
125+
merged = f"用户: {user_message}\n助手: {assistant_message}"
126+
try:
127+
if self.memory_manager is not None:
128+
await self.memory_manager.remember(
129+
merged, "short_term", 0.6, sessionId=session_id, agentType=agent_type
130+
)
131+
await self.memory_manager.remember(
132+
merged, "episodic", 0.75, sessionId=session_id, agentType=agent_type
133+
)
134+
if self.vector_store_manager is not None:
135+
from datetime import datetime, timezone
136+
137+
vec = text_to_vector(merged)
138+
await self.vector_store_manager.add_memory(
139+
content=merged,
140+
vector=vec,
141+
memory_type="episodic",
142+
metadata={
143+
"sessionId": session_id,
144+
"agentType": agent_type,
145+
"createdAt": datetime.now(timezone.utc).isoformat(),
146+
},
147+
)
148+
except Exception as e:
149+
logger.warning(f"ContextAssembler.remember_turn 失败: {e}")
150+
151+
152+
def text_to_vector(text: str) -> List[float]:
153+
"""确定性字符哈希式向量(128 维),与 npm 端 textToVector 对齐。"""
154+
vector = [0.0] * VECTOR_DIMENSION
155+
normalized = unicodedata.normalize("NFKC", text).lower()
156+
if not normalized:
157+
return vector
158+
for i, ch in enumerate(normalized):
159+
code = ord(ch)
160+
index = (code + i * 31) % VECTOR_DIMENSION
161+
vector[index] += 1 + (code % 7) * 0.05
162+
norm = math.sqrt(sum(v * v for v in vector))
163+
if norm <= 1e-8:
164+
return vector
165+
return [v / norm for v in vector]

0 commit comments

Comments
 (0)