fix: Feishu channel agents file_write permission blocked + memory system tests & docs

- Fix 8 Feishu agent handlers to use permission_level="acceptEdits" so file_write
  tool works without Web UI approval popup (lingxi/renshenguo/suyao/tiantian/orange/main/schedule)
- Add P5-P7 memory improvements: offline keyword fallback, team sharing, file-based memory
- Add auto_dream_service for daily memory consolidation
- Add 99 memory system test cases (basic 18 + advanced 43 + pytest 38)
- Add platform capability assessment report and unfinished project checklist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
renjianbo
2026-06-14 20:35:12 +08:00
parent a7512a5423
commit 7f4aeb021b
22 changed files with 6191 additions and 68 deletions

View File

@@ -25,6 +25,35 @@ from app.agent_runtime.context import AgentContext
from app.agent_runtime.memory import AgentMemory
from app.agent_runtime.tool_manager import AgentToolManager
from app.core.exceptions import WorkflowExecutionError
from app.core.hooks import HookManager, HookEvent, HookContext, HookResult
from app.agent_runtime.plan_mode import PlanMode, Plan, PlanStatus
from app.core.error_recovery import ErrorClassifier, ErrorType, ConversationRecovery
from app.core.memdir import MemoryDir, MemoryType as MemType, MemoryManifest, parse_frontmatter
from app.core.memory_selector import memory_selector
from app.core.compaction import CompactionEngine, CompactionResult, CompactionStrategy
from app.core.compaction_config import CompactionConfig
from app.core.token_counter import is_context_length_error
from app.core.streamlined_output import (
StreamlinedTransformer,
create_streamlined_transformer,
get_tool_summary_text,
ToolCounts,
categorize_tool,
)
from app.core.prompt_sections import (
PromptComposer,
PromptSection,
create_prompt_composer,
create_default_static_sections,
create_default_dynamic_sections,
section_environment,
section_language,
)
from app.core.token_budget import (
TokenBudget,
TokenBudgetConfig,
create_token_budget,
)
from app.services.agent_learning_service import (
extract_pattern_from_result,
format_pattern_hint,
@@ -54,18 +83,8 @@ class LLMCallMetrics(TypedDict, total=False):
status: str # success / error
error_message: Optional[str]
# 可重试的 API 异常
_RETRYABLE_ERRORS = (
"timed out",
"timeout",
"connection error",
"temporarily unavailable",
"server disconnected",
"rate limit",
"too many requests",
"internal server error",
"service unavailable",
)
# 全局错误分类器(可重试判定 + 退避策略)
_error_classifier = ErrorClassifier()
class AgentRuntime:
@@ -86,6 +105,8 @@ class AgentRuntime:
execution_logger: Optional[Any] = None,
on_tool_executed: Optional[Callable[[str], Any]] = None,
on_llm_call: Optional[Callable[[Dict[str, Any]], Any]] = None,
hook_manager: Optional[HookManager] = None,
streamlined: bool = False,
):
self.config = config or AgentConfig()
self.context = context or AgentContext(
@@ -97,6 +118,14 @@ class AgentRuntime:
scope_id=_mem_scope,
max_history=self.config.memory.max_history_messages,
persist=self.config.memory.persist_to_db,
vector_memory_enabled=self.config.memory.vector_memory_enabled,
vector_memory_top_k=self.config.memory.vector_memory_top_k,
vector_memory_rerank=self.config.memory.vector_memory_rerank,
memory_type_filter=self.config.memory.memory_type_filter,
team_id=self.config.memory.team_id,
team_share_enabled=self.config.memory.team_share_enabled,
memory_dir_enabled=self.config.memory.memory_dir_enabled,
memory_dir_path=self.config.memory.memory_dir_path,
)
self.tool_manager = tool_manager or AgentToolManager(
include_tools=self.config.tools.include_tools,
@@ -104,6 +133,9 @@ class AgentRuntime:
cache_enabled=self.config.tools.cache_enabled,
cache_tool_whitelist=self.config.tools.cache_tool_whitelist,
cache_ttl_ms=self.config.tools.cache_ttl_ms,
permission_level=self.config.tools.permission_level,
auto_approve_rules=self.config.tools.auto_approve_rules,
deny_tools=self.config.tools.deny_tools,
)
self.execution_logger = execution_logger
self.on_tool_executed = on_tool_executed
@@ -113,10 +145,119 @@ class AgentRuntime:
# 自主学习作用域bare 聊天用 "bare"Agent 用 "agent"
self._learning_scope_kind = "bare" if "bare" in str(_mem_scope) else "agent"
# Hook 管理器 (P1)
self.hook_manager = hook_manager or HookManager()
# 计划模式 (P2)
self.plan_mode = PlanMode(self.config.llm) if self.config.llm.plan_mode_enabled else None
# 对话自动压缩 (参考 Claude Code compact)
self.compaction_engine: Optional[CompactionEngine] = None
compaction_cfg = getattr(self.config.memory, 'compaction', None)
if compaction_cfg is None:
compaction_cfg = CompactionConfig()
if compaction_cfg.enabled:
self.compaction_engine = CompactionEngine(
config=compaction_cfg,
model=self.config.llm.model,
)
logger.info("对话压缩引擎已启用 (model=%s, window=%d)",
self.config.llm.model, self.config.llm.context_window)
# 工具结果流式美化 (参考 Claude Code streamlinedTransform)
self.streamlined = streamlined
self._streamlined_transformer: Optional[StreamlinedTransformer] = None
if streamlined:
self._streamlined_transformer = create_streamlined_transformer(enabled=True)
logger.info("工具结果流式美化已启用")
# 系统提示词分层装配 (P2 — 参考 Claude Code systemPromptSections.ts)
self._prompt_composer: Optional[PromptComposer] = None
self._prompt_sections_enabled = self.config.prompt_sections.enabled
if self._prompt_sections_enabled:
ps_config = self.config.prompt_sections
# 构建静态段(按开关过滤)
static_sections = []
s_switches = ps_config.static_sections
if s_switches.get("persona", True):
static_sections.append(PromptSection(
"persona",
lambda cfg=self.config: f"{cfg.system_prompt}\n\n"
))
if s_switches.get("capabilities", True):
from app.core.prompt_sections import section_capabilities
static_sections.append(PromptSection("capabilities", section_capabilities))
if s_switches.get("tool_instructions", True):
from app.core.prompt_sections import section_tool_instructions
static_sections.append(PromptSection("tool_instructions", section_tool_instructions))
if s_switches.get("safety_rules", True):
from app.core.prompt_sections import section_safety_rules
static_sections.append(PromptSection("safety_rules", section_safety_rules))
if s_switches.get("output_style", True):
from app.core.prompt_sections import section_output_style
static_sections.append(PromptSection("output_style", section_output_style))
self._prompt_composer = PromptComposer()
self._prompt_composer.add_static_sections(static_sections)
logger.info("系统提示词分层装配已启用 (%d 静态段)", len(static_sections))
# Token 预算管理 (P2 — 参考 Claude Code tokenBudget.ts)
self._token_budget: Optional[TokenBudget] = None
tb_config = self.config.token_budget
if tb_config.enabled:
self._token_budget = TokenBudget(
config=TokenBudgetConfig(
enabled=True,
context_window=tb_config.context_window or self.config.llm.context_window,
output_reserve=tb_config.output_reserve,
warning_threshold_pct=tb_config.warning_threshold_pct,
compact_threshold_pct=tb_config.compact_threshold_pct,
hard_limit_pct=tb_config.hard_limit_pct,
user_budget=tb_config.user_budget,
auto_continue=tb_config.auto_continue,
compaction_after_warning=tb_config.compaction_after_warning,
max_compaction_attempts=tb_config.max_compaction_attempts,
),
model=self.config.llm.model,
)
logger.info("Token 预算管理已启用 (window=%d, compact@%d%%)",
self._token_budget.config.context_window,
int(tb_config.compact_threshold_pct * 100))
# 崩溃恢复 (P4)
self.recovery = ConversationRecovery()
self._recovery_snapshot_counter = 0
# 文件式记忆 (MEMORY.md)
self._memdir: Optional[MemoryDir] = None
self._memdir_manifest: Optional[MemoryManifest] = None
if self.config.memory.memory_dir_enabled:
mem_path = self.config.memory.memory_dir_path
if not mem_path:
# 默认路径: 项目根目录下的 .claude/memory
import os as _os
mem_path = _os.path.join(
_os.path.dirname(_os.path.dirname(_os.path.dirname(__file__))),
".claude", "memory",
)
self._memdir = MemoryDir(mem_path)
# 启动时扫描一次
self._memdir_manifest = self._memdir.scan()
memory_selector.reset()
logger.info("文件式记忆已启用: %s (%d 条)", mem_path,
self._memdir_manifest.total_files)
# 预算回调:供 WorkflowEngine 注入,使 Agent 内部计数计入工作流预算
# 返回 True 表示预算充足;返回 False 或抛出异常表示超限
self.on_llm_invocation: Optional[Callable[[], Any]] = None
def _attach_token_usage(self, result: AgentResult) -> AgentResult:
"""将 TokenBudget 摘要附加到 AgentResult若启用"""
if self._token_budget:
from app.agent_runtime.schemas import TokenUsageInfo
result.token_usage = TokenUsageInfo(**self._token_budget.summary())
return result
def _build_execution_log_kwargs(self, user_input: str, result: AgentResult, latency_ms: int) -> dict:
"""从 AgentResult 构建 execution_logger 所需的参数字典。"""
tool_chain = []
@@ -151,6 +292,27 @@ class AgentRuntime:
provider=self.config.llm.provider,
)
def _fire_recovery_snapshot(self):
"""Fire-and-forget 保存崩溃恢复快照(每 5 次工具调用保存一次)。"""
self._recovery_snapshot_counter += 1
if self._recovery_snapshot_counter % 5 != 0:
return
try:
import asyncio
asyncio.ensure_future(
self.recovery.save_snapshot(
session_id=self.context.session_id,
messages=self.context.messages,
extra={
"agent_name": self.config.name,
"iteration": self.context.iteration,
"tool_calls_made": self.context.tool_calls_made,
},
)
)
except Exception:
pass
def _fire_execution_log(self, user_input: str, result: AgentResult, start_time: float):
"""Fire-and-forget 记录执行日志(非阻塞)。"""
try:
@@ -172,17 +334,49 @@ class AgentRuntime:
self._llm_invocations = 0 # 每次 run() 重置 LLM 调用计数
_run_start = time.time() # 执行开始时间,用于计算总延迟
# 1. 首次运行时加载长期记忆到 system prompt
if not self._memory_context_loaded:
# 1. 系统提示词分层装配(首次加载全部段,后续只刷新动态段)
if self._prompt_sections_enabled:
system_prompt = await self._compose_system_prompt(user_input)
self.context.set_system_prompt(system_prompt)
if not self._memory_context_loaded:
self._memory_context_loaded = True
logger.info("分层装配已完成(静态段 + 动态段)")
elif not self._memory_context_loaded:
await self._inject_memory_context(user_input)
self._memory_context_loaded = True
# 1.5 知识检索增强:从知识库注入相关经验到 system prompt
await self._inject_knowledge_context(user_input)
await self._inject_knowledge_context(user_input)
# 2. 追加用户消息
self.context.add_user_message(user_input)
# 2.5 计划模式 (P2) — 生成执行计划
plan: Optional[Plan] = None
if self.plan_mode and self.config.llm.plan_mode_enabled:
try:
plan = await self.plan_mode.generate_plan(
user_input=user_input,
available_tools=self.tool_manager.tool_names(),
messages_history=self.context.messages,
)
logger.info("计划模式: 已生成计划 (%d 步骤)", len(plan.steps))
if self.config.llm.plan_approval_required:
approved = await self.plan_mode.present_plan(plan)
if not approved:
logger.info("计划模式: 计划被拒绝")
result = AgentResult(
success=False,
content=f"计划已被拒绝。\n\n{plan.to_markdown()}",
iterations_used=0,
tool_calls_made=0,
error="plan_rejected",
)
self._fire_execution_log(user_input, result, _run_start)
self._attach_token_usage(result)
return result
except Exception as e:
logger.warning("计划生成失败,回退到直接执行: %s", e)
plan = None
# 3. ReAct 循环
llm = _LLMClient(self.config.llm)
tool_schemas = self.tool_manager.get_tool_schemas()
@@ -194,6 +388,18 @@ class AgentRuntime:
llm_callback_ctx = {"step_type": "think", "tool_name": None}
def _llm_callback(metrics: Dict[str, Any]):
# Token 预算追踪 (P2)
if self._token_budget:
prompt_tok = metrics.get("prompt_tokens", 0)
comp_tok = metrics.get("completion_tokens", 0)
if prompt_tok <= 0:
prompt_tok = self._token_budget.input_tokens # fallback estimate
self._token_budget.record_llm_call(
prompt_tokens=prompt_tok,
completion_tokens=comp_tok,
iteration=self.context.iteration,
step_type=llm_callback_ctx["step_type"],
)
if self.on_llm_call:
metrics.update({
"session_id": self.context.session_id,
@@ -206,6 +412,31 @@ class AgentRuntime:
while self.context.iteration < max_iter:
self.context.iteration += 1
# Token 预算检查:每次迭代前更新输入 token 估计
if self._token_budget:
self._token_budget.update_from_counter(self.context.messages)
self._token_budget.reset_compaction_attempts()
# 对话自动压缩 (参考 Claude Code autoCompact) + Token 预算驱动压缩
_should_compact = self.compaction_engine and self.context.iteration > 1
if _should_compact and self._token_budget and self._token_budget.needs_compaction:
self._token_budget.record_compaction_attempt()
logger.info("TokenBudget 触发自动压缩: %s", self._token_budget.status_line)
if self.compaction_engine and self.context.iteration > 1:
compact_result = await self.compaction_engine.maybe_compact(
self.context.messages,
self.config.llm.context_window,
)
if compact_result.strategy != CompactionStrategy.NONE:
self.context.replace_internal_messages(
[m for m in compact_result.messages
if m.get("role") != "system"] # 去掉 system由 context 管理)
)
logger.debug(
"压缩完成: strategy=%s saved=%d tokens",
compact_result.strategy.value, compact_result.tokens_saved,
)
# 裁剪过长历史
messages = self.memory.trim_messages(self.context.messages)
@@ -221,6 +452,7 @@ class AgentRuntime:
tool_calls_made=self.context.tool_calls_made,
steps=steps, error=err)
self._fire_execution_log(user_input, result, _run_start)
self._attach_token_usage(result)
return result
# 调用外部 LLM 预算回调WorkflowEngine 注入,将 Agent 的 LLM 计入工作流预算)
@@ -237,6 +469,7 @@ class AgentRuntime:
tool_calls_made=self.context.tool_calls_made,
steps=steps, error=str(e))
self._fire_execution_log(user_input, result, _run_start)
self._attach_token_usage(result)
return result
# 调用 LLM
@@ -267,6 +500,7 @@ class AgentRuntime:
error=err_str,
)
self._fire_execution_log(user_input, result, _run_start)
self._attach_token_usage(result)
return result
# 记录 LLM 调用次数(内部计数)
@@ -337,6 +571,7 @@ class AgentRuntime:
steps=steps,
)
self._fire_execution_log(user_input, result, _run_start)
self._attach_token_usage(result)
return result
# 有工具调用 → 先记录 assistant 消息(含 tool_calls
@@ -380,6 +615,26 @@ class AgentRuntime:
except (json.JSONDecodeError, TypeError):
targs = {}
# Hook: PreToolUse — 可拦截/修改工具调用
hook_ctx = HookContext(
event=HookEvent.PRE_TOOL_USE,
tool_name=tname,
tool_input=targs,
session_id=self.context.session_id,
agent_name=self.config.name,
user_id=self.config.user_id,
)
hook_res = await self.hook_manager.trigger(HookEvent.PRE_TOOL_USE, hook_ctx)
if not hook_res.allowed:
result = json.dumps({"error": hook_res.reason}, ensure_ascii=False)
self.context.add_tool_result(tcid, tname, result)
continue
if hook_res.modified_input:
targs = hook_res.modified_input
# 审批检查需要原始参数,所以审批在前;但如果 hook 改了参数,需要重新构建
if hook_res.modified_input and tname in self.config.tools.require_approval:
tfn["arguments"] = json.dumps(targs, ensure_ascii=False)
# 工具执行前审批检查
if tname in self.config.tools.require_approval:
from app.services.approval_manager import approval_manager as _am
@@ -420,6 +675,21 @@ class AgentRuntime:
self.context.add_tool_result(tcid, tname, result)
self.context.tool_calls_made += 1
# Hook: PostToolUse — 工具执行后处理
post_ctx = HookContext(
event=HookEvent.POST_TOOL_USE,
tool_name=tname,
tool_input=targs,
tool_output=result,
session_id=self.context.session_id,
agent_name=self.config.name,
user_id=self.config.user_id,
)
await self.hook_manager.trigger(HookEvent.POST_TOOL_USE, post_ctx)
# 崩溃恢复快照 (P4)
self._fire_recovery_snapshot()
# 预算检查:工具调用次数
if self.context.tool_calls_made > budget.max_tool_calls:
err = f"已超过工具调用预算({budget.max_tool_calls} 次)"
@@ -431,6 +701,7 @@ class AgentRuntime:
tool_calls_made=self.context.tool_calls_made,
steps=steps, error=err)
self._fire_execution_log(user_input, result, _run_start)
self._attach_token_usage(result)
return result
if self.on_tool_executed:
@@ -484,11 +755,32 @@ class AgentRuntime:
error=truncation_msg,
)
self._fire_execution_log(user_input, result, _run_start)
self._attach_token_usage(result)
return result
async def run_stream(self, user_input: str) -> AsyncGenerator[dict, None]:
"""
流式执行 Agent 单轮对话。
流式执行 Agent 单轮对话(支持 streamlined 模式)
与 run() 逻辑相同,但在每个关键步骤 yield SSE 事件。
当 streamlined=True 时,工具调用会被折叠为累计摘要。
"""
if self._streamlined_transformer:
self._streamlined_transformer.reset()
async for event in self._run_stream_impl(user_input):
transformed = self._streamlined_transformer.transform(event)
if transformed is not None:
yield transformed
flushed = self._streamlined_transformer.flush()
if flushed:
yield flushed
else:
async for event in self._run_stream_impl(user_input):
yield event
async def _run_stream_impl(self, user_input: str) -> AsyncGenerator[dict, None]:
"""
流式执行 Agent 单轮对话(内部实现)。
与 run() 逻辑相同,但在每个关键步骤 yield SSE 事件:
- think: LLM 思考中,准备调用工具
@@ -501,17 +793,63 @@ class AgentRuntime:
self.context.iteration = 0
self.context.tool_calls_made = 0
# 1. 首次运行时加载长期记忆到 system prompt
if not self._memory_context_loaded:
# 1. 系统提示词分层装配
if self._prompt_sections_enabled:
system_prompt = await self._compose_system_prompt(user_input)
self.context.set_system_prompt(system_prompt)
if not self._memory_context_loaded:
self._memory_context_loaded = True
logger.info("分层装配已完成(静态段 + 动态段)")
elif not self._memory_context_loaded:
await self._inject_memory_context(user_input)
self._memory_context_loaded = True
# 1.5 知识检索增强:从知识库注入相关经验到 system prompt
await self._inject_knowledge_context(user_input)
await self._inject_knowledge_context(user_input)
# 2. 追加用户消息
self.context.add_user_message(user_input)
# 2.5 计划模式 (P2) — 流式生成执行计划
plan: Optional[Plan] = None
if self.plan_mode and self.config.llm.plan_mode_enabled:
yield {"type": "plan_generating", "content": "正在生成执行计划…", "iteration": 0}
try:
plan = await self.plan_mode.generate_plan(
user_input=user_input,
available_tools=self.tool_manager.tool_names(),
messages_history=self.context.messages,
)
logger.info("计划模式: 已生成计划 (%d 步骤)", len(plan.steps))
yield {
"type": "plan",
"content": plan.to_markdown(),
"plan_data": plan.to_dict(),
"iteration": 0,
"session_id": self.context.session_id,
}
if self.config.llm.plan_approval_required:
# 等待外部审批(通过 on_approval_required 回调)
approved = await self.plan_mode.present_plan(plan)
if not approved:
logger.info("计划模式: 计划被拒绝")
yield {
"type": "plan_rejected",
"content": "计划已被拒绝",
"plan_data": plan.to_dict(),
"iteration": 0,
"session_id": self.context.session_id,
}
return
yield {
"type": "plan_approved",
"content": "计划已批准,开始执行",
"iteration": 0,
"session_id": self.context.session_id,
}
except Exception as e:
logger.warning("计划生成失败,回退到直接执行: %s", e)
yield {"type": "plan_failed", "content": f"计划生成失败: {e}", "iteration": 0}
plan = None
# 3. ReAct 循环
llm = _LLMClient(self.config.llm)
tool_schemas = self.tool_manager.get_tool_schemas()
@@ -522,6 +860,18 @@ class AgentRuntime:
llm_callback_ctx = {"step_type": "think", "tool_name": None}
def _llm_callback(metrics: Dict[str, Any]):
# Token 预算追踪 (P2)
if self._token_budget:
prompt_tok = metrics.get("prompt_tokens", 0)
comp_tok = metrics.get("completion_tokens", 0)
if prompt_tok <= 0:
prompt_tok = self._token_budget.input_tokens # fallback estimate
self._token_budget.record_llm_call(
prompt_tokens=prompt_tok,
completion_tokens=comp_tok,
iteration=self.context.iteration,
step_type=llm_callback_ctx["step_type"],
)
if self.on_llm_call:
metrics.update({
"session_id": self.context.session_id,
@@ -533,6 +883,31 @@ class AgentRuntime:
while self.context.iteration < max_iter:
self.context.iteration += 1
# Token 预算检查:每次迭代前更新输入 token 估计
if self._token_budget:
self._token_budget.update_from_counter(self.context.messages)
self._token_budget.reset_compaction_attempts()
# 对话自动压缩 (参考 Claude Code autoCompact) + Token 预算驱动压缩
if self.compaction_engine and self.context.iteration > 1:
if self._token_budget and self._token_budget.needs_compaction:
self._token_budget.record_compaction_attempt()
logger.info("TokenBudget 触发自动压缩: %s", self._token_budget.status_line)
compact_result = await self.compaction_engine.maybe_compact(
self.context.messages,
self.config.llm.context_window,
)
if compact_result.strategy != CompactionStrategy.NONE:
self.context.replace_internal_messages(
[m for m in compact_result.messages
if m.get("role") != "system"]
)
logger.debug(
"压缩完成: strategy=%s saved=%d tokens",
compact_result.strategy.value, compact_result.tokens_saved,
)
messages = self.memory.trim_messages(self.context.messages)
# 预算检查LLM 调用次数(在调用 LLM 之前检查,避免浪费额度)
@@ -626,6 +1001,7 @@ class AgentRuntime:
self.context.add_user_message(fix_prompt)
continue # 回到 ReAct 循环,让 LLM 修正
token_usage_final = self._token_budget.summary() if self._token_budget else None
yield {
"type": "final",
"content": final_text,
@@ -634,6 +1010,7 @@ class AgentRuntime:
"iterations_used": self.context.iteration,
"tool_calls_made": self.context.tool_calls_made,
"session_id": self.context.session_id,
"token_usage": token_usage_final,
}
await self.memory.save_context(user_input, final_text, self.context.messages)
# 保存学习模式
@@ -695,6 +1072,24 @@ class AgentRuntime:
except (json.JSONDecodeError, TypeError):
targs = {}
# Hook: PreToolUse — 可拦截/修改工具调用 (流式)
hook_ctx = HookContext(
event=HookEvent.PRE_TOOL_USE,
tool_name=tname,
tool_input=targs,
session_id=self.context.session_id,
agent_name=self.config.name,
user_id=self.config.user_id,
)
hook_res = await self.hook_manager.trigger(HookEvent.PRE_TOOL_USE, hook_ctx)
if not hook_res.allowed:
result = json.dumps({"error": hook_res.reason}, ensure_ascii=False)
yield {"type": "tool_result", "name": tname, "result": result, "iteration": self.context.iteration}
self.context.add_tool_result(tcid, tname, result)
continue
if hook_res.modified_input:
targs = hook_res.modified_input
# yield tool_call 事件
yield {
"type": "tool_call",
@@ -760,6 +1155,21 @@ class AgentRuntime:
self.context.add_tool_result(tcid, tname, result)
self.context.tool_calls_made += 1
# Hook: PostToolUse — 工具执行后处理 (流式)
post_ctx = HookContext(
event=HookEvent.POST_TOOL_USE,
tool_name=tname,
tool_input=targs,
tool_output=result,
session_id=self.context.session_id,
agent_name=self.config.name,
user_id=self.config.user_id,
)
await self.hook_manager.trigger(HookEvent.POST_TOOL_USE, post_ctx)
# 崩溃恢复快照 (P4)
self._fire_recovery_snapshot()
# 预算检查:工具调用次数
if self.context.tool_calls_made > budget.max_tool_calls:
err = f"已超过工具调用预算({budget.max_tool_calls} 次)"
@@ -783,6 +1193,15 @@ class AgentRuntime:
data={"tool_name": tname, "result_preview": preview},
)
# Hook: Stop — 对话完成
stop_ctx = HookContext(
event=HookEvent.STOP,
session_id=self.context.session_id,
agent_name=self.config.name,
user_id=self.config.user_id,
)
await self.hook_manager.trigger(HookEvent.STOP, stop_ctx)
# 达到最大迭代次数
last_content = ""
for m in reversed(self.context.messages):
@@ -802,6 +1221,7 @@ class AgentRuntime:
# 提取知识到全局知识池(即便截断,工具调用序列仍有参考价值)
if last_content:
await self._extract_global_knowledge(user_input, last_content, steps)
token_usage_truncated = self._token_budget.summary() if self._token_budget else None
yield {
"type": "final",
"content": last_content or "已达最大迭代次数,但模型未返回最终回答。",
@@ -810,8 +1230,123 @@ class AgentRuntime:
"tool_calls_made": self.context.tool_calls_made,
"truncated": True,
"session_id": self.context.session_id,
"token_usage": token_usage_truncated,
}
async def _compose_system_prompt(self, query: str = "") -> str:
"""使用分层装配构建完整系统提示词。
将静态段 + 动态段并行解析后拼接,替代原先的字符串拼接方式。
返回最终的 system_prompt 字符串。
"""
if not self._prompt_composer:
# 降级:使用原有字符串拼接方式
enriched = self.config.system_prompt.rstrip("\n")
mem_text = await self.memory.initialize(query=query)
if mem_text:
enriched += "\n\n" + mem_text
if self.config.memory.learning_enabled:
pattern_hint = await self._inject_learning_patterns(query)
if pattern_hint:
enriched += "\n\n" + pattern_hint
if self._memdir and self._memdir_manifest:
memdir_text = await self._inject_memdir_context(query)
if memdir_text:
enriched += "\n\n" + memdir_text
try:
enriched = knowledge_retriever.inject_knowledge(enriched, query)
except Exception:
pass
return enriched
# 分层装配路径
ps_config = self.config.prompt_sections
d_switches = ps_config.dynamic_sections
# 清除上一次运行的动态段
# (静态段保留缓存,动态段每次重算)
self._prompt_composer._dynamic_sections.clear()
# 动态段:环境信息
if d_switches.get("environment", True):
self._prompt_composer.add_dynamic(PromptSection(
"environment",
lambda uid=self.config.user_id: section_environment(uid),
cache_break=True,
))
# 动态段:语言偏好
if d_switches.get("language", True):
lang = ps_config.language
if lang:
self._prompt_composer.add_dynamic(PromptSection(
"language",
lambda l=lang: section_language(l),
cache_break=False,
))
# 动态段:长期记忆上下文
if d_switches.get("memory_context", True):
mem_text = await self.memory.initialize(query=query)
if mem_text:
self._prompt_composer.add_dynamic(PromptSection(
"memory_context",
lambda t=mem_text: f"# Long-term Memory\n\n{t}",
cache_break=True,
))
# 动态段:学习模式提示
if self.config.memory.learning_enabled:
pattern_hint = await self._inject_learning_patterns(query)
if pattern_hint:
self._prompt_composer.add_dynamic(PromptSection(
"learning_patterns",
lambda p=pattern_hint: p,
cache_break=True,
))
# 动态段:文件式记忆
if self._memdir and self._memdir_manifest:
memdir_text = await self._inject_memdir_context(query)
if memdir_text:
self._prompt_composer.add_dynamic(PromptSection(
"memdir",
lambda t=memdir_text: t,
cache_break=True,
))
# 动态段:知识库检索
if d_switches.get("memory_context", True):
try:
base_enriched = knowledge_retriever.inject_knowledge(
self.config.system_prompt, query
)
if base_enriched != self.config.system_prompt:
# 提取增量部分
knowledge_delta = base_enriched[len(self.config.system_prompt):].strip()
if knowledge_delta:
self._prompt_composer.add_dynamic(PromptSection(
"knowledge_base",
lambda kd=knowledge_delta: f"# Relevant Knowledge\n\n{kd}",
cache_break=True,
))
except Exception:
pass
# 工具列表段(默认关闭,太长)
if d_switches.get("tool_list", False):
tool_names = self.tool_manager.tool_names()
if tool_names:
tool_list_text = "\n".join(f"- {n}" for n in sorted(tool_names))
self._prompt_composer.add_dynamic(PromptSection(
"tool_list",
lambda t=tool_list_text: f"# Available Tools\n\n{t}",
cache_break=False,
))
# 解析 + 装配
return await self._prompt_composer.assemble_full()
async def _inject_memory_context(self, query: str = "") -> None:
"""加载长期记忆并注入 system prompt。"""
mem_text = await self.memory.initialize(query=query)
@@ -826,9 +1361,65 @@ class AgentRuntime:
if pattern_hint:
enriched += "\n\n" + pattern_hint
# 注入文件式记忆 (MEMORY.md)
if self._memdir and self._memdir_manifest:
memdir_text = await self._inject_memdir_context(query)
if memdir_text:
enriched += "\n\n" + memdir_text
self.context.set_system_prompt(enriched)
logger.info("Agent 已注入长期记忆上下文")
async def _inject_memdir_context(self, query: str) -> str:
"""加载文件式记忆并构建注入文本。"""
if not self._memdir or not self._memdir_manifest:
return ""
parts: List[str] = []
# 记忆操作指导(首次注入)
memdir_prompt = self._memdir.build_system_prompt()
parts.append(memdir_prompt)
# AI 驱动的相关性选择
if self._memdir_manifest.entries:
try:
selected = await memory_selector.select(
query=query,
manifest=self._memdir_manifest,
recent_tools=self.tool_manager.tool_names(),
)
if selected:
# 读取选中的记忆文件
parts.append("\n## 相关记忆\n")
for fn in selected:
entry = next(
(e for e in self._memdir_manifest.entries
if e.filename == fn), None
)
if entry:
# 加载完整内容
try:
with open(entry.filepath, "r", encoding="utf-8") as _f:
_, content = parse_frontmatter(_f.read())
except Exception:
content = entry.content
if not content:
content = entry.content
staleness = entry.staleness_note
parts.append(
f"<system-reminder>\n"
f"### [{entry.mem_type.value}] {entry.name}\n"
f"{content[:2000]}"
)
if staleness:
parts.append(f"\n{staleness}")
parts.append("</system-reminder>")
except Exception as e:
logger.warning("AI 记忆选择失败: %s", e)
return "\n".join(parts)
async def _inject_learning_patterns(self, query: str) -> str:
"""查询学习模式,返回格式化的提示文本。"""
from app.core.database import SessionLocal
@@ -1062,9 +1653,17 @@ class AgentRuntime:
@staticmethod
def _is_retryable(err_str: str) -> bool:
"""判断错误是否可重试。"""
err_lower = err_str.lower()
return any(kw in err_lower for kw in _RETRYABLE_ERRORS)
"""判断错误是否可重试(使用 ErrorClassifier"""
try:
error_type, _ = _error_classifier.classify(Exception(err_str))
return error_type == ErrorType.RETRYABLE
except Exception:
err_lower = err_str.lower()
return any(kw in err_lower for kw in (
"timed out", "timeout", "connection error",
"rate limit", "too many requests", "internal server error",
"service unavailable", "temporarily unavailable",
))
# LLM 缓存辅助
@@ -1193,6 +1792,35 @@ class _LLMClient:
response = await client.chat.completions.create(**kwargs)
except Exception as e:
last_error = e
# Reactive Compact: 上下文超限时压缩后重试 (Tier 3)
if (
self.compaction_engine
and is_context_length_error(e)
and self.compaction_engine.config.reactive_compact_enabled
):
logger.warning("检测到上下文超限,触发 ReactiveCompact: %s", str(e)[:100])
try:
compact_result = await self.compaction_engine.reactive_compact(
messages, e, self._config.context_window,
)
if compact_result.strategy != CompactionStrategy.NONE:
logger.info(
"ReactiveCompact 完成: saved=%d tokens, 重试中...",
compact_result.tokens_saved,
)
return await self._do_chat(
api_key=api_key, base_url=base_url,
model=model,
messages=compact_result.messages,
tools=tools,
iteration=iteration,
on_completion=on_completion,
_is_fallback=_is_fallback,
)
except Exception as ce:
logger.error("ReactiveCompact 失败: %s", ce)
# 降级回退:主模型失败时尝试 fallback_llm
fallback = self._config.fallback_llm
if fallback and isinstance(fallback, dict) and not _is_fallback:

View File

@@ -38,6 +38,12 @@ class AgentMemory:
max_history: int = 20,
vector_memory_enabled: bool = True,
vector_memory_top_k: int = 5,
vector_memory_rerank: bool = False,
memory_type_filter: Optional[List[str]] = None,
team_id: Optional[str] = None,
team_share_enabled: bool = False,
memory_dir_enabled: bool = False,
memory_dir_path: str = "",
):
self.scope_kind = scope_kind
self.scope_id = scope_id or "default"
@@ -46,11 +52,28 @@ class AgentMemory:
self.max_history = max_history
self.vector_memory_enabled = vector_memory_enabled
self.vector_memory_top_k = vector_memory_top_k
self.vector_memory_rerank = vector_memory_rerank
self.memory_type_filter = memory_type_filter # None = 全部类型
self.team_id = team_id # 团队共享 ID
self.team_share_enabled = team_share_enabled # 是否自动发布到团队池
# 文件式记忆
self.memory_dir_enabled = memory_dir_enabled
self.memory_dir_path = memory_dir_path
self._file_store = None # 延迟初始化
# 记忆类型分类: user / feedback / project / reference
self.MEMORY_TYPES = ("user", "feedback", "project", "reference")
# 从长期记忆加载的上下文(启动时加载)
self._long_term_context: Dict[str, Any] = {}
# 记录已压缩的消息数,避免重复压缩
self._last_compressed_msg_count = 0
def _get_file_store(self):
"""延迟初始化文件记忆存储。"""
if self._file_store is None and self.memory_dir_enabled:
from app.services.file_memory_service import get_file_memory_store
self._file_store = get_file_memory_store(self.memory_dir_path)
return self._file_store
async def initialize(self, query: str = "") -> str:
"""
初始化记忆:从 DB/Redis 加载长期记忆 + 向量检索相关历史。
@@ -95,7 +118,22 @@ class AgentMemory:
if vector_text:
parts.append(vector_text)
# 3. 全局知识检索:从 GlobalKnowledge 表加载相关条目
# 3. P7 文件式记忆:从本地 MEMORY.md 加载
store = self._get_file_store()
if store and store.memory_count > 0 and query:
file_results = store.search(query, top_k=3)
if file_results:
lines = ["## 文件记忆(本地 MEMORY.md"]
for i, r in enumerate(file_results, 1):
mem_type = r.get("type", "reference")
content = r.get("content", "")[:300]
score = r.get("score", 0)
lines.append(f"{i}. [{mem_type}] {content}")
if score < 1.0:
lines[-1] += f" (匹配度: {score:.2f})"
parts.append("\n".join(lines))
# 4. 全局知识检索:从 GlobalKnowledge 表加载相关条目
global_text = await self._global_knowledge_search(query)
if global_text:
parts.append(global_text)
@@ -106,6 +144,7 @@ class AgentMemory:
"""
向量检索语义相关的历史记忆,返回格式化的文本块。
若无 query 则返回最近 Top-5 条记忆。
支持 memory_type_filter 按类型过滤 + LLM Rerank 精选。
"""
from app.models.agent_vector_memory import AgentVectorMemory
@@ -113,22 +152,46 @@ class AgentMemory:
try:
db = SessionLocal()
# 查询当前 scope 的所有向量记忆(按时间倒序)
rows = (
query_builder = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.scope_kind == self.scope_kind,
AgentVectorMemory.scope_id == self.scope_id,
)
)
rows = (
query_builder
.order_by(AgentVectorMemory.created_at.desc())
.limit(50) # 最多取最近 50 条做相似度计算
.limit(50)
.all()
)
# P6 团队共享:同时查询团队记忆池
if self.team_id:
team_rows = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.scope_kind == "team",
AgentVectorMemory.scope_id == self.team_id,
)
.order_by(AgentVectorMemory.created_at.desc())
.limit(30)
.all()
)
rows = list(rows) + list(team_rows)
if not rows:
return ""
entries: List[VectorEntry] = []
for row in rows:
# 类型过滤memory_type_filter 不为空时生效)
meta = row.metadata_ or {}
row_memory_type = meta.get("memory_type", meta.get("type", "conversation_turn"))
if self.memory_type_filter:
if row_memory_type not in self.memory_type_filter:
continue
emb = embedding_service.deserialize_embedding(row.embedding) if row.embedding else []
entries.append({
"id": row.id,
@@ -136,17 +199,35 @@ class AgentMemory:
"scope_id": row.scope_id,
"content_text": row.content_text,
"embedding": emb,
"metadata": row.metadata_ or {},
"metadata": meta,
})
if not entries:
return ""
matched: List[VectorEntry] = []
if query and query.strip():
# 有 query生成 embedding 做语义搜索
query_emb = await embedding_service.generate_embedding(query)
if query_emb:
matched = await embedding_service.similarity_search(
query_emb, entries, top_k=self.vector_memory_top_k
# 向量检索取 top_k * 4 候选(为 rerank 留余量),最少 20 条
candidate_k = max(20, self.vector_memory_top_k * 4)
candidates = await embedding_service.similarity_search(
query_emb, entries, top_k=min(candidate_k, len(entries))
)
# LLM Rerank向量粗筛 → LLM 精选
if self.vector_memory_rerank and len(candidates) > self.vector_memory_top_k:
matched = await self._llm_rerank(query, candidates)
if not matched:
matched = candidates[: self.vector_memory_top_k]
else:
# P5 离线兜底Embedding API 不可用时降级为关键词匹配
logger.info("Embedding 不可用,降级为离线关键词匹配")
matched = embedding_service.keyword_search(
query, entries, top_k=self.vector_memory_top_k, min_score=0.05,
)
else:
# 无 query返回最近几条
@@ -162,8 +243,14 @@ class AgentMemory:
for i, m in enumerate(matched, 1):
text = m.get("content_text", "")[:500]
meta = m.get("metadata", {})
entry_type = meta.get("type", "对话")
lines.append(f"{i}. [{entry_type}] {text}")
mem_type = meta.get("memory_type", meta.get("type", "对话"))
scope_kind = m.get("scope_kind", "")
# 标注团队共享来源
source_tag = ""
if scope_kind == "team":
shared_by = meta.get("shared_by", meta.get("source_scope", "unknown"))
source_tag = f" [团队共享]"
lines.append(f"{i}. [{mem_type}]{source_tag} {text}")
if m.get("score", 1.0) < 1.0:
lines[-1] += f" (匹配度: {m['score']:.2f})"
@@ -176,6 +263,84 @@ class AgentMemory:
if db:
db.close()
async def _llm_rerank(
self, query: str, candidates: List[VectorEntry],
) -> List[VectorEntry]:
"""
LLM Rerank用轻量模型对向量粗筛结果打分排序返回精选 top-K。
流程:取向量检索 top-N 候选 → LLM 按与 query 相关性打分 (1-10)
→ 取 top-K 高分结果。失败时降级返回原始排序。
"""
from openai import AsyncOpenAI
from app.core.config import settings
if not candidates or len(candidates) <= self.vector_memory_top_k:
return candidates[: self.vector_memory_top_k]
try:
# 构建候选列表
items_text = []
for idx, c in enumerate(candidates):
content = c.get("content_text", "")[:300]
mem_type = c.get("metadata", {}).get("memory_type", "unknown")
items_text.append(f"[{idx}] [{mem_type}] {content}")
rerank_prompt = (
"你是一个记忆检索排序助手。请根据用户查询对以下记忆条目按相关性打分1-10分\n"
"只输出 JSON 数组,每个元素包含 index 和 score按 score 降序排列。\n"
"只保留 score >= 4 的结果。最多返回 {} 条。\n\n"
"用户查询: {}\n\n记忆条目:\n{}"
).format(
self.vector_memory_top_k,
query[:500],
"\n".join(items_text),
)
api_key = settings.DEEPSEEK_API_KEY or settings.OPENAI_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or settings.OPENAI_BASE_URL or "https://api.deepseek.com"
if api_key == "your-openai-api-key":
api_key = settings.DEEPSEEK_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
if not api_key:
return candidates[: self.vector_memory_top_k]
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
resp = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": rerank_prompt}],
temperature=0.1,
max_tokens=512,
timeout=15,
)
raw = resp.choices[0].message.content or ""
raw = raw.strip().removeprefix("```json").removesuffix("```").strip()
import json
scored = json.loads(raw)
if not isinstance(scored, list):
return candidates[: self.vector_memory_top_k]
# 按 score 排序取 top-K
scored.sort(key=lambda x: x.get("score", 0), reverse=True)
result: List[VectorEntry] = []
for item in scored[: self.vector_memory_top_k]:
idx = item.get("index", -1)
if 0 <= idx < len(candidates):
candidates[idx]["score"] = float(item.get("score", 5.0)) / 10.0
result.append(candidates[idx])
if result:
logger.info("LLM Rerank: %d 候选 → %d 精选", len(candidates), len(result))
return result
return candidates[: self.vector_memory_top_k]
except Exception as e:
logger.warning("LLM Rerank 失败,使用向量排序: %s", e)
return candidates[: self.vector_memory_top_k]
async def _global_knowledge_search(self, query: str = "") -> str:
"""从 GlobalKnowledge 表检索相关的全局知识条目。"""
from datetime import datetime
@@ -340,33 +505,52 @@ class AgentMemory:
self, user_message: str, assistant_reply: str,
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""将单轮对话保存到长期记忆。如有消息列表LLM 自动压缩总结。"""
"""将单轮对话保存到长期记忆。
快速路径(同步完成):向量记忆写入 + 基础上下文更新。
慢速路径fire-and-forgetLLM 压缩总结 → persistent_memory 更新。
后台压缩不阻塞对话响应。
"""
if not self.persist or not self.scope_id:
return
# 更新上下文
# 快速:更新基础上下文
ctx = self._long_term_context.get("context", {})
ctx["last_user_message"] = user_message[:500]
ctx["last_assistant_reply"] = assistant_reply[:500]
self._long_term_context["context"] = ctx
# 如果有完整消息列表且新增了足够多的消息,运行 LLM 压缩总结
# 后台LLM 压缩总结fire-and-forget不阻塞主对话
if messages and len(messages) > self._last_compressed_msg_count + 2:
await self._compress_and_summarize(messages)
self._last_compressed_msg_count = len(messages)
import asyncio as _asyncio
_asyncio.ensure_future(self._background_compress_and_save(messages))
db: Optional[Session] = None
try:
db = SessionLocal()
# 快速:保存基础上下文到 persistent_memory后续后台压缩会覆盖更新
save_persistent_memory(
db, self.scope_kind, self.scope_id,
self.session_key, self._long_term_context,
)
# 保存向量记忆(异步生成 embedding 并存储)
# 快速:保存向量记忆
if self.vector_memory_enabled:
mem_type = self._infer_memory_type(user_message, assistant_reply)
await self._save_vector_memory(
db, user_message, assistant_reply
db, user_message, assistant_reply, memory_type=mem_type,
)
# P7 文件式记忆兜底:同步写入本地 MEMORY.md
store = self._get_file_store()
if store:
mem_type = self._infer_memory_type(user_message, assistant_reply)
content = f"用户: {user_message[:300]}\n助手: {assistant_reply[:300]}"
store.save(
name=f"{self.scope_id}_{self.session_key}_{len(ctx)}",
content=content,
mem_type=mem_type,
)
except Exception as e:
logger.warning("保存长期记忆失败: %s", e)
@@ -376,6 +560,7 @@ class AgentMemory:
async def _save_vector_memory(
self, db: Session, user_message: str, assistant_reply: str,
memory_type: str = "conversation_turn",
) -> None:
"""生成 embedding 并保存到向量记忆表。"""
from app.models.agent_vector_memory import AgentVectorMemory
@@ -396,16 +581,66 @@ class AgentMemory:
content_text=content_text[:2000],
embedding=embedding_json or None,
metadata_={
"type": "conversation_turn",
"type": memory_type,
"memory_type": memory_type,
},
)
db.add(record)
db.commit()
logger.debug("已保存向量记忆 (scope=%s/%s)", self.scope_kind, self.scope_id)
# P6 团队共享:自动将记忆副本发布到团队池
if self.team_id and self.team_share_enabled:
try:
team_record = AgentVectorMemory(
scope_kind="team",
scope_id=self.team_id,
session_key=self.session_key,
content_text=content_text[:2000],
embedding=embedding_json or None,
metadata_={
"type": memory_type,
"memory_type": memory_type,
"source_scope": f"{self.scope_kind}/{self.scope_id}",
"shared_by": self.scope_id,
},
)
db.add(team_record)
db.commit()
logger.debug("已同步到团队记忆池 (team=%s)", self.team_id)
except Exception:
db.rollback() # 团队同步失败不影响主流程
logger.debug("已保存向量记忆 (scope=%s/%s, type=%s)", self.scope_kind, self.scope_id, memory_type)
except Exception as e:
logger.warning("保存向量记忆失败: %s", e)
db.rollback()
async def _background_compress_and_save(
self, messages: List[Dict[str, Any]],
) -> None:
"""
后台异步LLM 压缩总结 + 写入 persistent_memory。
从 save_context 中 fire-and-forget 调用,不阻塞对话响应。
"""
try:
await self._compress_and_summarize(messages)
# 将压缩更新后的长期上下文写回 DB
db: Optional[Session] = None
try:
db = SessionLocal()
save_persistent_memory(
db, self.scope_kind, self.scope_id,
self.session_key, self._long_term_context,
)
except Exception as e:
logger.warning("后台压缩保存 persistent_memory 失败: %s", e)
finally:
if db:
db.close()
except Exception as e:
logger.warning("后台压缩总结失败: %s", e)
async def _compress_and_summarize(
self, messages: List[Dict[str, Any]]
) -> None:
@@ -506,11 +741,71 @@ class AgentMemory:
"updated" if new_profile else "unchanged",
len(new_facts), len(topics))
# P1: 将压缩摘要向量化写入 AgentVectorMemory使其可被语义检索
await self._save_compressed_memories(summary, new_facts, topics)
except json.JSONDecodeError:
logger.warning("记忆压缩LLM 返回非 JSON 格式,跳过")
except Exception as e:
logger.warning("记忆压缩失败: %s", e)
async def _save_compressed_memories(
self, summary: str, facts: List[str], topics: List[str],
) -> None:
"""
将 LLM 压缩总结的结果向量化写入 AgentVectorMemory。
每个 fact/summary/topic 单独写入,标注 memory_type=project来自对话压缩
失败不影响主流程。
"""
from app.models.agent_vector_memory import AgentVectorMemory
memories_to_save: List[tuple] = [] # (content, memory_type)
if summary:
memories_to_save.append((f"[对话摘要] {summary[:1500]}", "project"))
for fact in facts:
if fact and len(fact) > 10:
memories_to_save.append((f"[关键事实] {fact[:1500]}", "reference"))
for topic in topics:
if topic:
memories_to_save.append((f"[话题] {topic[:500]}", "project"))
if not memories_to_save:
return
db: Optional[Session] = None
try:
db = SessionLocal()
for content, mem_type in memories_to_save:
try:
embedding = await embedding_service.generate_embedding(content)
embedding_json = embedding_service.serialize_embedding(embedding) if embedding else ""
record = AgentVectorMemory(
scope_kind=self.scope_kind,
scope_id=self.scope_id,
session_key=self.session_key,
content_text=content[:2000],
embedding=embedding_json or None,
metadata_={
"type": "compressed_summary",
"memory_type": mem_type,
"source": "auto_compress",
},
)
db.add(record)
except Exception:
pass # 单条失败不阻塞其他写入
db.commit()
logger.info("已向量化压缩记忆: %d 条 (scope=%s/%s)",
len(memories_to_save), self.scope_kind, self.scope_id)
except Exception as e:
logger.warning("压缩记忆向量化失败: %s", e)
if db:
db.rollback()
finally:
if db:
db.close()
def trim_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
裁剪消息列表:保留最近的 N 条,但始终保留第一条 system 消息。
@@ -566,3 +861,41 @@ class AgentMemory:
if m.get("role") == "user":
turns += 1
return f"{turns} 轮历史对话(详情已存入长期记忆)"
@staticmethod
def _infer_memory_type(user_message: str, assistant_reply: str) -> str:
"""
根据对话内容推断记忆类型 (user / feedback / project / reference)。
基于关键词快速分类,不做 LLM 调用。
"""
combined = (user_message + " " + assistant_reply).lower()
# feedback: 纠错、反馈、报错
feedback_keywords = [
"不对", "错误", "错了", "报错", "bug", "不正确", "有问题",
"改一下", "修正", "纠正", "不要这样", "不行", "不是这个",
"不对的", "反馈", "建议", "应该", "能不能", "可以不要",
]
if any(kw in combined for kw in feedback_keywords):
return "feedback"
# reference: 链接、配置、系统信息
reference_keywords = [
"http://", "https://", "配置", ".env", "api", "端口",
"数据库", "地址", "密码", "密钥", "token", "url",
"路径", "文件", "目录", "安装", "部署",
]
if any(kw in combined for kw in reference_keywords):
return "reference"
# project: 任务、目标、进度
project_keywords = [
"任务", "目标", "进度", "完成", "计划", "需求", "项目",
"开发", "测试", "上线", "版本", "发布", "迭代",
"bug", "修复", "功能", "实现", "提交",
]
if any(kw in combined for kw in project_keywords):
return "project"
# user: 默认,包含偏好、个人信息等
return "user"

View File

@@ -0,0 +1,228 @@
"""
工具安全分级与权限检查
参考 Claude Code Tool.ts 的 checkPermissions / PermissionResult 设计:
- 4 级权限: bypass > acceptEdits > default > plan
- 工具标记: is_read_only / is_destructive
- 自动批准规则: 基于工具名 + 参数模式匹配
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
import re
import logging
logger = logging.getLogger(__name__)
# ──────────────────────────── 权限级别 ────────────────────────────
class PermissionLevel(str, Enum):
"""权限级别 — 参考 Claude Code PermissionMode"""
BYPASS = "bypass" # 完全跳过权限检查
ACCEPT_EDITS = "acceptEdits" # 自动批准文件编辑(读+写)
DEFAULT = "default" # 每次询问(写操作需确认)
PLAN = "plan" # 只读 + 计划工具
# ──────────────────────────── 权限结果 ────────────────────────────
class PermissionAction(str, Enum):
ALLOW = "allow"
DENY = "deny"
ASK = "ask" # 需要用户确认
@dataclass
class PermissionResult:
"""权限检查结果"""
action: PermissionAction
message: str = ""
updated_input: Optional[Dict[str, Any]] = None # Hook 可修改参数
# ──────────────────────────── 工具安全标记 ────────────────────────────
# 只读工具 — PLAN 模式下仍然可用
READ_ONLY_TOOLS: set = {
"file_read", "grep", "glob", "web_search", "web_fetch",
"list_files", "read_lints", "codebase_search",
"math_calculate", "text", "json", "csv",
"database_query", "agent_list", "knowledge_base_search",
}
# 破坏性工具 — 不可逆操作
DESTRUCTIVE_TOOLS: set = {
"file_write", "file_delete", "command_exec", "shell_exec",
"docker_manage", "git_push", "git_reset_hard",
"database_execute", "deploy_push", "agent_delete",
}
# 编辑工具 — ACCEPT_EDITS 级别自动批准
EDIT_TOOLS: set = {
"file_edit", "file_write", "notebook_edit",
}
def is_read_only_tool(tool_name: str) -> bool:
"""判断工具是否只读"""
return tool_name in READ_ONLY_TOOLS
def is_destructive_tool(tool_name: str) -> bool:
"""判断工具是否具有破坏性"""
return tool_name in DESTRUCTIVE_TOOLS
def is_edit_tool(tool_name: str) -> bool:
"""判断工具是否为编辑类"""
return tool_name in EDIT_TOOLS
# ──────────────────────────── 自动批准规则 ────────────────────────────
@dataclass
class AutoApproveRule:
"""自动批准规则 — 参考 Claude Code alwaysAllowRules"""
tool_pattern: str # 工具名匹配 (支持 * 通配符)
param_conditions: Optional[Dict[str, Any]] = None # 参数条件
description: str = ""
def matches(self, tool_name: str, params: Optional[Dict[str, Any]] = None) -> bool:
"""检查工具是否匹配此规则"""
# 通配符匹配
if self.tool_pattern == "*":
return True
if self.tool_pattern.endswith("*"):
prefix = self.tool_pattern[:-1]
if not tool_name.startswith(prefix):
return False
elif tool_name != self.tool_pattern:
return False
# 参数条件匹配
if self.param_conditions and params:
for key, expected in self.param_conditions.items():
actual = params.get(key)
if isinstance(expected, str) and expected.startswith("regex:"):
pattern = expected[6:]
if not re.search(pattern, str(actual)):
return False
elif actual != expected:
return False
return True
# 默认自动批准规则
DEFAULT_AUTO_APPROVE_RULES: List[AutoApproveRule] = [
AutoApproveRule(tool_pattern="file_read", description="读取文件总是安全"),
AutoApproveRule(tool_pattern="grep", description="代码搜索总是安全"),
AutoApproveRule(tool_pattern="glob", description="文件搜索总是安全"),
AutoApproveRule(tool_pattern="web_search", description="网页搜索只读"),
AutoApproveRule(tool_pattern="web_fetch", description="网页抓取只读"),
AutoApproveRule(tool_pattern="math_calculate", description="数学计算无副作用"),
AutoApproveRule(tool_pattern="list_files", description="列出文件无副作用"),
AutoApproveRule(tool_pattern="read_lints", description="读取 lint 结果无副作用"),
AutoApproveRule(tool_pattern="knowledge_base_search", description="知识库搜索只读"),
]
# ──────────────────────────── 权限检查器 ────────────────────────────
class PermissionChecker:
"""
工具权限检查器 — 参考 Claude Code useCanUseTool 流程。
检查顺序:
1. BYPASS 模式 → 直接放行
2. 拒绝列表 → 直接拒绝
3. 自动批准规则 → 放行
4. PLAN 模式 → 只允许只读工具
5. ACCEPT_EDITS 模式 → 只读 + 编辑工具自动放行
6. DEFAULT 模式 → 编辑/破坏性工具需确认
"""
def __init__(
self,
level: PermissionLevel = PermissionLevel.DEFAULT,
auto_approve_rules: Optional[List[AutoApproveRule]] = None,
deny_rules: Optional[List[str]] = None,
):
self.level = level
self.auto_approve_rules = auto_approve_rules or list(DEFAULT_AUTO_APPROVE_RULES)
self.deny_tools: set = set(deny_rules or [])
def check(
self,
tool_name: str,
params: Optional[Dict[str, Any]] = None,
) -> PermissionResult:
"""
检查工具调用权限。
Returns:
PermissionResult 指示 allow / deny / ask
"""
# 1. BYPASS — 完全放行
if self.level == PermissionLevel.BYPASS:
return PermissionResult(action=PermissionAction.ALLOW)
# 2. 拒绝列表
if tool_name in self.deny_tools:
return PermissionResult(
action=PermissionAction.DENY,
message=f"工具 {tool_name} 已被管理员禁用",
)
# 3. 自动批准规则
for rule in self.auto_approve_rules:
if rule.matches(tool_name, params):
logger.debug(f"工具 {tool_name} 匹配自动批准规则: {rule.description}")
return PermissionResult(action=PermissionAction.ALLOW)
# 4. PLAN 模式 — 只允许只读
if self.level == PermissionLevel.PLAN:
if is_read_only_tool(tool_name):
return PermissionResult(action=PermissionAction.ALLOW)
return PermissionResult(
action=PermissionAction.DENY,
message=f"PLAN 模式下不允许使用 {tool_name}(仅支持只读工具)",
)
# 5. ACCEPT_EDITS — 只读 + 编辑自动放行
if self.level == PermissionLevel.ACCEPT_EDITS:
if is_read_only_tool(tool_name) or is_edit_tool(tool_name):
return PermissionResult(action=PermissionAction.ALLOW)
# 6. DEFAULT — 破坏性工具需确认
if is_destructive_tool(tool_name):
return PermissionResult(
action=PermissionAction.ASK,
message=f"工具 {tool_name} 可能产生不可逆操作,是否继续?",
)
# 编辑工具在 DEFAULT 下也需确认
if is_edit_tool(tool_name):
return PermissionResult(
action=PermissionAction.ASK,
message=f"确认编辑操作: {tool_name}",
)
# 未知工具默认放行
return PermissionResult(action=PermissionAction.ALLOW)
def add_auto_approve_rule(self, rule: AutoApproveRule):
"""添加自动批准规则"""
self.auto_approve_rules.append(rule)
def add_deny_tool(self, tool_name: str):
"""添加拒绝工具"""
self.deny_tools.add(tool_name)
def set_level(self, level: PermissionLevel):
"""切换权限级别"""
logger.info(f"权限级别切换: {self.level.value}{level.value}")
self.level = level

View File

@@ -18,7 +18,7 @@ class AgentToolConfig(BaseModel):
exclude_tools: List[str] = Field(default_factory=list, description="排除的工具名称黑名单")
require_approval: List[str] = Field(default_factory=list, description="需要人工审批的工具名列表")
@field_validator("include_tools", "exclude_tools", "require_approval", "cache_tool_whitelist", mode="before")
@field_validator("include_tools", "exclude_tools", "require_approval", "cache_tool_whitelist", "auto_approve_rules", "deny_tools", mode="before")
@classmethod
def coerce_none_to_empty(cls, v: Any) -> Any:
return v if v is not None else []
@@ -29,6 +29,17 @@ class AgentToolConfig(BaseModel):
cache_tool_whitelist: List[str] = Field(default_factory=list, description="启用缓存的工具名(空=确定性工具默认)")
cache_ttl_ms: int = Field(default=3600000, description="缓存 TTL毫秒默认 1 小时")
# 工具安全分级 (P3 — 参考 Claude Code PermissionMode)
permission_level: str = Field(
default="default",
description="权限级别: bypass | acceptEdits | default | plan"
)
auto_approve_rules: List[Dict[str, Any]] = Field(
default_factory=list,
description="自动批准规则: [{tool_pattern, param_conditions, description}]"
)
deny_tools: List[str] = Field(default_factory=list, description="禁用的工具名列表")
class AgentMemoryConfig(BaseModel):
"""Agent 记忆配置"""
@@ -38,7 +49,16 @@ class AgentMemoryConfig(BaseModel):
persist_to_db: bool = True # 是否写入 MySQL 长期记忆
vector_memory_enabled: bool = True # 是否启用向量记忆(语义检索)
vector_memory_top_k: int = 5 # 向量检索 Top-K
vector_memory_rerank: bool = False # 是否启用 LLM Rerank向量 top-20 → LLM 精选 top-K
memory_type_filter: Optional[List[str]] = None # 记忆类型过滤,如 ["user","project"]None=全部
team_id: Optional[str] = None # 团队共享 ID非空时记忆在团队间共享
team_share_enabled: bool = False # 是否将新记忆自动发布到团队池
learning_enabled: bool = True # 是否启用自主学习(工具模式学习)
# 文件式记忆 (MEMORY.md — 参考 Claude Code memdir)
memory_dir_enabled: bool = False # 是否启用文件式自动记忆
memory_dir_path: str = "" # 记忆目录路径(空=自动使用项目 .claude/memory
# 对话自动压缩 (参考 Claude Code src/services/compact/)
compaction: Optional[Any] = None # CompactionConfig — 惰性导入避免循环依赖
class AgentLLMConfig(BaseModel):
@@ -56,6 +76,12 @@ class AgentLLMConfig(BaseModel):
cache_enabled: bool = False # LLM 响应缓存(默认关闭,语义缓存有风险)
cache_ttl_ms: int = 300000 # LLM 缓存 TTL默认 5 分钟
fallback_llm: Optional[Dict[str, Any]] = None # 降级模型配置 {provider, model, api_key, base_url}
# 计划模式 (P2 — 参考 Claude Code EnterPlanModeTool)
plan_mode_enabled: bool = False # 是否启用计划模式
plan_approval_required: bool = True # 是否需要用户审批计划
plan_model: Optional[str] = None # 计划生成使用的模型(默认复用主模型)
# 上下文窗口 (用于 Compaction 触发计算)
context_window: int = 128000 # 模型上下文窗口大小token 数)
class AgentBudgetConfig(BaseModel):
@@ -64,6 +90,47 @@ class AgentBudgetConfig(BaseModel):
max_tool_calls: int = 500 # 工具调用次数上限
class AgentTokenBudgetConfig(BaseModel):
"""Token 预算管理配置 — 参考 Claude Code tokenBudget.ts"""
enabled: bool = True
context_window: int = 0 # 模型上下文窗口0=自动检测)
output_reserve: int = 8192 # 留给模型输出的空间
warning_threshold_pct: float = Field(default=0.75, ge=0.1, le=1.0)
compact_threshold_pct: float = Field(default=0.85, ge=0.1, le=1.0)
hard_limit_pct: float = Field(default=0.95, ge=0.1, le=1.0)
user_budget: Optional[int] = None # 用户累计 token 目标(如 500000
auto_continue: bool = False # 预算用尽自动继续
compaction_after_warning: bool = True
max_compaction_attempts: int = 3
class AgentPromptSectionsConfig(BaseModel):
"""系统提示词分层装配配置 — 参考 Claude Code systemPromptSections.ts"""
# 是否启用分层装配(关闭则退回到简单的 system_prompt 字符串)
enabled: bool = True
# 静态段开关(段名 → 是否启用)
static_sections: Dict[str, bool] = Field(default_factory=lambda: {
"persona": True,
"capabilities": True,
"tool_instructions": True,
"safety_rules": True,
"output_style": True,
})
# 动态段开关
dynamic_sections: Dict[str, bool] = Field(default_factory=lambda: {
"environment": True,
"language": True,
"memory_context": True,
"conversation_summary": True,
"tool_list": False, # 工具列表默认关闭(太长)
})
# 语言偏好(用于 language 段)
language: Optional[str] = None
class AgentConfig(BaseModel):
"""Agent 完整配置"""
name: str = "default_agent"
@@ -77,6 +144,10 @@ class AgentConfig(BaseModel):
memory_scope_id: Optional[str] = None
# 是否开启输出质量自检(结束前用轻量 LLM 评审,不达标则追加修正)
self_review_enabled: bool = False
# 系统提示词分层装配 (P2 — 参考 Claude Code prompts.ts + systemPromptSections.ts)
prompt_sections: AgentPromptSectionsConfig = Field(default_factory=AgentPromptSectionsConfig)
# Token 预算管理 (P2 — 参考 Claude Code tokenBudget.ts)
token_budget: AgentTokenBudgetConfig = Field(default_factory=AgentTokenBudgetConfig)
class AgentMessage(BaseModel):
@@ -99,6 +170,27 @@ class AgentStep(BaseModel):
reasoning: Optional[str] = Field(default=None, description="思考过程")
class TokenUsageInfo(BaseModel):
"""Token 预算信息 — 随 AgentResult 返回给前端展示用量条"""
input_tokens: int = 0
input_remaining: int = 0
input_usage_pct: float = 0.0
effective_window: int = 128_000
context_window: int = 128_000
cumulative_total: int = 0
cumulative_prompt: int = 0
cumulative_completion: int = 0
llm_call_count: int = 0
is_warning: bool = False
is_critical: bool = False
is_exhausted: bool = False
compaction_attempts: int = 0
user_budget: Optional[int] = None
user_budget_used: Optional[int] = None
user_budget_remaining: Optional[int] = None
user_budget_pct: Optional[float] = None
class AgentResult(BaseModel):
"""Agent 执行结果"""
success: bool = True
@@ -108,3 +200,4 @@ class AgentResult(BaseModel):
tool_calls_made: int = 0
error: Optional[str] = None
steps: List[AgentStep] = Field(default_factory=list, description="执行追踪步骤详情")
token_usage: Optional[TokenUsageInfo] = Field(default=None, description="Token 预算摘要")