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:
@@ -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:
|
||||
|
||||
@@ -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-forget):LLM 压缩总结 → 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"
|
||||
|
||||
228
backend/app/agent_runtime/permissions.py
Normal file
228
backend/app/agent_runtime/permissions.py
Normal 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
|
||||
@@ -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 预算摘要")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Agent 定时任务服务:cron 解析、执行触发、下次执行时间计算"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
@@ -353,6 +354,170 @@ def check_and_run_autonomy_ticks() -> int:
|
||||
db.close()
|
||||
|
||||
|
||||
async def run_scheduler_loop() -> None:
|
||||
"""内置调度器循环:每 60 秒检查到期定时任务并直接执行(无需 Celery)。
|
||||
|
||||
同时检查 Auto Dream 每日记忆整合是否到期(凌晨 3:00)。
|
||||
在 FastAPI startup 事件中作为后台 asyncio 任务启动。
|
||||
"""
|
||||
import asyncio
|
||||
logger.info("内置调度器循环已启动,每60秒检查一次(含 Auto Dream 每日整合)")
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
triggered = await _check_and_run_due_schedules_direct()
|
||||
if triggered:
|
||||
logger.info("内置调度器触发 %d 个任务", triggered)
|
||||
|
||||
# Auto Dream:每日凌晨 3:00 记忆整合
|
||||
from app.services.auto_dream_service import _should_dream_today, run_auto_dream
|
||||
if _should_dream_today():
|
||||
asyncio.ensure_future(run_auto_dream())
|
||||
except Exception as e:
|
||||
logger.error("内置调度器循环异常: %s", e)
|
||||
|
||||
|
||||
async def _check_and_run_due_schedules_direct() -> int:
|
||||
"""直接执行到期的定时任务(不使用 Celery)。"""
|
||||
from app.models.agent_schedule import AgentSchedule
|
||||
from app.models.agent import Agent
|
||||
from app.models.execution import Execution
|
||||
from app.agent_runtime.core import AgentRuntime
|
||||
from app.agent_runtime.schemas import AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig, AgentBudgetConfig
|
||||
|
||||
db: Optional[Session] = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
due_schedules = (
|
||||
db.query(AgentSchedule)
|
||||
.filter(
|
||||
AgentSchedule.enabled == True,
|
||||
AgentSchedule.next_run_at <= now,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
triggered = 0
|
||||
for sched in due_schedules:
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.id == sched.agent_id).first()
|
||||
if not agent:
|
||||
logger.warning("定时任务 %s 的 Agent %s 不存在", sched.id, sched.agent_id)
|
||||
_mark_schedule_failed(db, sched, now, "Agent not found")
|
||||
continue
|
||||
|
||||
# 构建 AgentConfig
|
||||
wf = agent.workflow_config or {}
|
||||
nodes = wf.get("nodes", [])
|
||||
system_prompt = "你是一个有用的AI助手。"
|
||||
model_name = "deepseek-v4-pro"
|
||||
provider = "deepseek"
|
||||
temperature = 0.8
|
||||
max_iterations = 15
|
||||
tools_include = []
|
||||
|
||||
for node in nodes:
|
||||
nd = node.get("data", {}) if isinstance(node, dict) else {}
|
||||
node_type = node.get("type", "") if isinstance(node, dict) else ""
|
||||
if node_type == "start":
|
||||
system_prompt = nd.get("system_prompt", system_prompt)
|
||||
elif node_type in ("agent", "llm"):
|
||||
tools_include = nd.get("tools", nd.get("selected_tools", tools_include))
|
||||
model_name = nd.get("model", model_name)
|
||||
provider = nd.get("provider", provider)
|
||||
temperature = float(nd.get("temperature", temperature))
|
||||
max_iterations = int(nd.get("max_iterations", nd.get("max_tool_iterations", max_iterations)))
|
||||
|
||||
config = AgentConfig(
|
||||
name=agent.name,
|
||||
system_prompt=system_prompt,
|
||||
user_id=str(agent.user_id) if agent.user_id else None,
|
||||
llm=AgentLLMConfig(provider=provider, model=model_name, temperature=temperature, max_iterations=max_iterations),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_include if tools_include else [],
|
||||
exclude_tools=[],
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(enabled=True, persist_to_db=True, learning_enabled=True),
|
||||
budget=AgentBudgetConfig(),
|
||||
)
|
||||
|
||||
# 创建执行记录
|
||||
execution = Execution(
|
||||
agent_id=sched.agent_id,
|
||||
schedule_id=sched.id,
|
||||
input_data={
|
||||
"USER_INPUT": f"[定时任务提醒] {sched.input_message}",
|
||||
"query": f"[定时任务提醒] {sched.input_message}",
|
||||
"message": sched.input_message,
|
||||
"is_scheduled_reminder": True,
|
||||
},
|
||||
status="running",
|
||||
)
|
||||
db.add(execution)
|
||||
db.flush()
|
||||
|
||||
# 在当前事件循环中直接运行(不再创建嵌套事件循环,Windows 不兼容)
|
||||
try:
|
||||
runtime = AgentRuntime(config=config)
|
||||
result = await runtime.run(
|
||||
user_input=f"[定时任务提醒] {sched.input_message}",
|
||||
)
|
||||
execution.output_data = {"result": result.get("output", str(result)) if isinstance(result, dict) else str(result)}
|
||||
execution.status = "completed"
|
||||
except Exception as run_err:
|
||||
execution.status = "failed"
|
||||
execution.error_message = f"执行失败: {run_err!s}"
|
||||
logger.error("定时任务 %s 执行失败: %s", sched.id, run_err)
|
||||
|
||||
db.commit()
|
||||
if execution.status == "failed":
|
||||
_mark_schedule_failed(db, sched, now, execution.error_message)
|
||||
logger.info("定时任务直接执行完成(失败): name=%s agent=%s", sched.name, agent.name)
|
||||
else:
|
||||
_mark_schedule_completed(db, sched, now)
|
||||
logger.info("定时任务直接执行完成: name=%s agent=%s", sched.name, agent.name)
|
||||
|
||||
# 推送飞书通知(fire-and-forget,失败不影响主流程)
|
||||
try:
|
||||
notify_schedule_result(db, execution, execution.status, execution.error_message)
|
||||
except Exception as notify_err:
|
||||
logger.warning("定时任务通知失败: %s", notify_err)
|
||||
|
||||
triggered += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("定时任务 %s 处理失败: %s", sched.id, e)
|
||||
try:
|
||||
_mark_schedule_failed(db, sched, now, str(e))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return triggered
|
||||
except Exception as e:
|
||||
logger.error("检查定时任务失败: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
|
||||
def _mark_schedule_completed(db: Session, sched, now: datetime) -> None:
|
||||
sched.last_run_at = now
|
||||
sched.last_run_status = "success"
|
||||
sched.next_run_at = compute_next_run(sched.cron_expression, after=now, tz=sched.timezone or "UTC")
|
||||
db.commit()
|
||||
|
||||
|
||||
def _mark_schedule_failed(db: Session, sched, now: datetime, error: str) -> None:
|
||||
sched.last_run_at = now
|
||||
sched.last_run_status = "failed"
|
||||
sched.next_run_at = compute_next_run(sched.cron_expression, after=now, tz=sched.timezone or "UTC")
|
||||
db.commit()
|
||||
|
||||
|
||||
def notify_schedule_result(db: Session, execution, status: str, error_message: Optional[str] = None) -> None:
|
||||
"""如果 execution 关联了定时任务,创建通知并推送飞书消息。
|
||||
|
||||
|
||||
249
backend/app/services/auto_dream_service.py
Normal file
249
backend/app/services/auto_dream_service.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Auto Dream — 夜间记忆整合服务。
|
||||
|
||||
参考 Claude Code 的 Auto Dream 机制:
|
||||
- 每天凌晨 3:00 触发
|
||||
- 扫描过去 24 小时的向量记忆
|
||||
- 合并相似条目(余弦相似度 > 0.85)
|
||||
- 生成整合摘要并写入向量记忆池
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.embedding_service import embedding_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 相似度阈值:高于此值视为可合并
|
||||
MERGE_SIMILARITY_THRESHOLD = 0.85
|
||||
|
||||
# 每天凌晨 3:00 触发(UTC+8)
|
||||
DREAM_HOUR = 3
|
||||
DREAM_MINUTE = 0
|
||||
|
||||
# 上次整合日期(模块级,进程重启后重置)
|
||||
_last_dream_date: Optional[str] = None
|
||||
_dream_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _should_dream_today() -> bool:
|
||||
"""检查是否到了今天的整合时间且尚未执行。"""
|
||||
global _last_dream_date
|
||||
|
||||
now = datetime.now(timezone.utc).astimezone(
|
||||
timezone(timedelta(hours=8))
|
||||
)
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
|
||||
# 已执行过
|
||||
if _last_dream_date == today_str:
|
||||
return False
|
||||
|
||||
# 在凌晨 3:00-4:00 之间触发
|
||||
if now.hour != DREAM_HOUR:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def run_auto_dream() -> dict:
|
||||
"""
|
||||
执行一次记忆整合。
|
||||
|
||||
返回: {"merged": int, "deleted": int, "dreams": int, "elapsed_s": float}
|
||||
"""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
if not _dream_lock.locked():
|
||||
async with _dream_lock:
|
||||
return await _do_consolidate()
|
||||
|
||||
logger.info("Auto Dream:上一次整合仍在进行中,跳过")
|
||||
return {"merged": 0, "deleted": 0, "dreams": 0, "elapsed_s": 0, "skipped": True}
|
||||
|
||||
|
||||
async def _do_consolidate() -> dict:
|
||||
"""实际的整合逻辑。"""
|
||||
global _last_dream_date
|
||||
|
||||
from app.models.agent_vector_memory import AgentVectorMemory
|
||||
|
||||
now = datetime.now(timezone.utc).astimezone(
|
||||
timezone(timedelta(hours=8))
|
||||
)
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
cutoff = now - timedelta(hours=24)
|
||||
|
||||
db: Optional[Session] = None
|
||||
merged = 0
|
||||
deleted = 0
|
||||
dreams = 0
|
||||
|
||||
try:
|
||||
db = SessionLocal()
|
||||
|
||||
# 1. 获取过去 24h 的所有向量记忆
|
||||
rows = (
|
||||
db.query(AgentVectorMemory)
|
||||
.filter(AgentVectorMemory.created_at >= cutoff)
|
||||
.order_by(AgentVectorMemory.created_at.desc())
|
||||
.limit(200)
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(rows) < 3:
|
||||
logger.info("Auto Dream:最近 24h 记忆不足(%d 条),跳过", len(rows))
|
||||
_last_dream_date = today_str
|
||||
return {"merged": 0, "deleted": 0, "dreams": 0, "elapsed_s": 0}
|
||||
|
||||
logger.info("Auto Dream:开始整合 %d 条最近记忆", len(rows))
|
||||
|
||||
# 2. 构建带 embedding 的条目列表
|
||||
entries: List[Tuple[Any, List[float]]] = []
|
||||
for row in rows:
|
||||
if not row.embedding:
|
||||
continue
|
||||
emb = embedding_service.deserialize_embedding(row.embedding)
|
||||
if emb and len(emb) > 0:
|
||||
entries.append((row, emb))
|
||||
|
||||
if len(entries) < 3:
|
||||
_last_dream_date = today_str
|
||||
return {"merged": 0, "deleted": 0, "dreams": 0, "elapsed_s": 0}
|
||||
|
||||
# 3. 两两计算相似度,找出可合并的对
|
||||
to_delete_ids: set = set()
|
||||
to_merge_pairs: List[Tuple[Any, Any]] = []
|
||||
|
||||
for i in range(len(entries)):
|
||||
if entries[i][0].id in to_delete_ids:
|
||||
continue
|
||||
for j in range(i + 1, len(entries)):
|
||||
if entries[j][0].id in to_delete_ids:
|
||||
continue
|
||||
sim = embedding_service.cosine_similarity(
|
||||
entries[i][1], entries[j][1]
|
||||
)
|
||||
if sim >= MERGE_SIMILARITY_THRESHOLD:
|
||||
# 保留较新的,删除较旧的
|
||||
newer = entries[i][0] if entries[i][0].created_at >= entries[j][0].created_at else entries[j][0]
|
||||
older = entries[j][0] if newer is entries[i][0] else entries[i][0]
|
||||
to_delete_ids.add(older.id)
|
||||
to_merge_pairs.append((newer, older))
|
||||
|
||||
# 4. 执行合并删除
|
||||
if to_delete_ids:
|
||||
for row_id in to_delete_ids:
|
||||
try:
|
||||
db.query(AgentVectorMemory).filter(
|
||||
AgentVectorMemory.id == row_id
|
||||
).delete()
|
||||
deleted += 1
|
||||
except Exception:
|
||||
db.rollback()
|
||||
db.commit()
|
||||
logger.info("Auto Dream:合并删除 %d 条重复记忆", deleted)
|
||||
merged = len(to_merge_pairs)
|
||||
|
||||
# 5. 生成整合摘要(Dream Summary)
|
||||
dream_text = await _generate_dream_summary(rows[:50])
|
||||
if dream_text:
|
||||
from app.services.embedding_service import embedding_service as es
|
||||
try:
|
||||
emb = await es.generate_embedding(dream_text)
|
||||
embedding_json = es.serialize_embedding(emb) if emb else ""
|
||||
dream_record = AgentVectorMemory(
|
||||
scope_kind="system",
|
||||
scope_id="auto_dream",
|
||||
session_key=f"dream_{today_str}",
|
||||
content_text=dream_text[:2000],
|
||||
embedding=embedding_json or None,
|
||||
metadata_={
|
||||
"type": "dream_summary",
|
||||
"memory_type": "project",
|
||||
"source": "auto_dream",
|
||||
"dream_date": today_str,
|
||||
"merged_count": merged,
|
||||
"deleted_count": deleted,
|
||||
},
|
||||
)
|
||||
db.add(dream_record)
|
||||
db.commit()
|
||||
dreams = 1
|
||||
logger.info("Auto Dream:已生成整合摘要 (%d 字)", len(dream_text))
|
||||
except Exception as e:
|
||||
logger.warning("Auto Dream 摘要写入失败: %s", e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Auto Dream 整合失败: %s", e)
|
||||
if db:
|
||||
db.rollback()
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
_last_dream_date = today_str
|
||||
elapsed = time.time() - start
|
||||
logger.info("Auto Dream 完成: merged=%d deleted=%d dreams=%d elapsed=%.1fs",
|
||||
merged, deleted, dreams, elapsed)
|
||||
return {"merged": merged, "deleted": deleted, "dreams": dreams, "elapsed_s": elapsed}
|
||||
|
||||
|
||||
async def _generate_dream_summary(rows: list) -> str:
|
||||
"""
|
||||
用 LLM 从最近记忆生成整合摘要。
|
||||
返回空字符串表示失败(不阻塞主流程)。
|
||||
"""
|
||||
if not rows or len(rows) < 3:
|
||||
return ""
|
||||
|
||||
# 提取记忆内容
|
||||
items = []
|
||||
for r in rows[-30:]: # 最近 30 条
|
||||
content = r.content_text[:300] if r.content_text else ""
|
||||
meta = r.metadata_ or {}
|
||||
mem_type = meta.get("memory_type", "unknown")
|
||||
items.append(f"[{mem_type}] {content}")
|
||||
|
||||
if not items:
|
||||
return ""
|
||||
|
||||
prompt = (
|
||||
"你是一个记忆整合助手。请分析以下 24 小时内的 Agent 对话记忆,\n"
|
||||
"生成一份简洁的每日摘要(200字以内),包含:\n"
|
||||
"1. 用户讨论了哪些主要话题\n"
|
||||
"2. 用户表达了哪些偏好或需求\n"
|
||||
"3. 有哪些值得保留的关键信息\n\n"
|
||||
"记忆条目:\n"
|
||||
) + "\n".join(f"- {item}" for item in items)
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
from app.core.config import settings
|
||||
|
||||
api_key = settings.DEEPSEEK_API_KEY or ""
|
||||
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
|
||||
|
||||
if not api_key:
|
||||
return ""
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
resp = await client.chat.completions.create(
|
||||
model="deepseek-v4-flash",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=600,
|
||||
timeout=30,
|
||||
)
|
||||
return resp.choices[0].message.content or ""
|
||||
except Exception as e:
|
||||
logger.warning("Auto Dream 摘要生成失败: %s", e)
|
||||
return ""
|
||||
@@ -224,6 +224,79 @@ class EmbeddingService:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
# ─── 离线兜底:关键词匹配(无需任何外部 API) ───
|
||||
|
||||
@staticmethod
|
||||
def _tokenize(text: str) -> set:
|
||||
"""
|
||||
轻量分词:中文用字符二元组,英文/数字用空格分词。
|
||||
混合文本同时提取中英文 token。零外部依赖,完全离线可用。
|
||||
"""
|
||||
tokens: set = set()
|
||||
text_lower = text.lower()
|
||||
|
||||
# 分离 CJK 字符和 ASCII/数字
|
||||
import re
|
||||
# 提取所有英文/数字词(>=2字符)
|
||||
alpha_words = re.findall(r'[a-z0-9]{2,}', text_lower)
|
||||
for w in alpha_words:
|
||||
tokens.add(w)
|
||||
|
||||
# 提取所有连续 CJK 字符段,生成二元组 + 单字
|
||||
cjk_segments = re.findall(r'[\u4e00-\u9fff]+', text_lower)
|
||||
for seg in cjk_segments:
|
||||
for i in range(len(seg) - 1):
|
||||
tokens.add(seg[i:i+2])
|
||||
for c in seg:
|
||||
tokens.add(c)
|
||||
|
||||
# 提取数字
|
||||
numbers = re.findall(r'\d+', text_lower)
|
||||
for n in numbers:
|
||||
tokens.add(n)
|
||||
|
||||
return tokens
|
||||
|
||||
def keyword_search(
|
||||
self,
|
||||
query: str,
|
||||
entries: List[VectorEntry],
|
||||
top_k: int = 5,
|
||||
min_score: float = 0.1,
|
||||
) -> List[VectorEntry]:
|
||||
"""
|
||||
离线关键词匹配(Embedding API 不可用时的兜底方案)。
|
||||
|
||||
对每个 entry 计算与 query 的 Jaccard 关键词重叠分数,
|
||||
返回 top-K 结果。
|
||||
|
||||
完全不依赖外部 API,零网络请求。
|
||||
"""
|
||||
q_tokens = self._tokenize(query)
|
||||
if not q_tokens:
|
||||
return entries[:top_k]
|
||||
|
||||
scored: List[VectorEntry] = []
|
||||
for entry in entries:
|
||||
text = entry.get("content_text", "")
|
||||
t_tokens = self._tokenize(text)
|
||||
if not t_tokens:
|
||||
continue
|
||||
intersection = q_tokens & t_tokens
|
||||
union = q_tokens | t_tokens
|
||||
score = len(intersection) / len(union) if union else 0.0
|
||||
if score >= min_score:
|
||||
entry["score"] = score
|
||||
scored.append(entry)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
@property
|
||||
def offline_available(self) -> bool:
|
||||
"""离线兜底始终可用(关键词匹配无需外部依赖)。"""
|
||||
return True
|
||||
|
||||
|
||||
# 全局单例
|
||||
embedding_service = EmbeddingService()
|
||||
|
||||
419
backend/app/services/file_memory_service.py
Normal file
419
backend/app/services/file_memory_service.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""
|
||||
文件式记忆存储 (MEMORY.md) — 参考 Claude Code memdir 架构。
|
||||
|
||||
提供完全离线的文件系统记忆读写,数据库不可用时作为兜底。
|
||||
格式:YAML frontmatter + markdown 内容,MEMORY.md 索引。
|
||||
|
||||
使用方式:
|
||||
store = FileMemoryStore("/path/to/memory_dir")
|
||||
store.save("用户偏好", "用户喜欢用Python", mem_type="user")
|
||||
results = store.search("Python") # 关键词检索
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MEMORY.md 索引每行最大长度
|
||||
INDEX_LINE_MAX_LENGTH = 150
|
||||
# 索引总行数上限(不含标题和空行)
|
||||
INDEX_MAX_ENTRIES = 200
|
||||
# 单文件最大大小
|
||||
MAX_FILE_SIZE = 40_000
|
||||
|
||||
|
||||
class FileMemoryStore:
|
||||
"""
|
||||
文件式记忆存储。
|
||||
|
||||
- 所有记忆以 .md 文件存储,含 YAML frontmatter
|
||||
- MEMORY.md 维护索引(一行一条)
|
||||
- 支持按类型分组目录
|
||||
- 支持关键词检索
|
||||
- 完全离线,零外部依赖
|
||||
"""
|
||||
|
||||
def __init__(self, memory_dir: str = ""):
|
||||
self._base_dir = Path(memory_dir) if memory_dir else Path.home() / ".tiangong" / "memory"
|
||||
self._base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._memories_dir = self._base_dir / "memories"
|
||||
self._memories_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._index_path = self._base_dir / "MEMORY.md"
|
||||
self._ensure_index()
|
||||
|
||||
# ─── Public API ───
|
||||
|
||||
@property
|
||||
def base_dir(self) -> str:
|
||||
return str(self._base_dir)
|
||||
|
||||
@property
|
||||
def memory_count(self) -> int:
|
||||
"""返回已索引的记忆数量。"""
|
||||
return len(self._parse_index())
|
||||
|
||||
def save(
|
||||
self, name: str, content: str,
|
||||
mem_type: str = "reference",
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
保存一条记忆。
|
||||
|
||||
1. 写入 {memories_dir}/{safe_name}.md
|
||||
2. 更新 MEMORY.md 索引
|
||||
成功返回 True。
|
||||
"""
|
||||
try:
|
||||
safe_name = self._safe_filename(name)
|
||||
file_path = self._memories_dir / f"{safe_name}.md"
|
||||
|
||||
# 构建 frontmatter + content
|
||||
now = datetime.now(timezone.utc).astimezone(
|
||||
timezone(timedelta(hours=8))
|
||||
).isoformat(timespec="seconds")
|
||||
tags_yaml = f"[{', '.join(tags)}]" if tags else "[]"
|
||||
|
||||
body = (
|
||||
f"---\n"
|
||||
f"name: {name}\n"
|
||||
f"description: {self._one_line(content)}\n"
|
||||
f"type: {mem_type}\n"
|
||||
f"created: {now}\n"
|
||||
f"tags: {tags_yaml}\n"
|
||||
f"---\n\n"
|
||||
f"{content}\n"
|
||||
)
|
||||
|
||||
# 检查是否已存在同名文件(更新而非追加)
|
||||
if file_path.exists():
|
||||
existing = file_path.read_text(encoding="utf-8")
|
||||
if len(existing) + len(content) > MAX_FILE_SIZE:
|
||||
# 保留最近 2000 字 + 追加新内容
|
||||
existing = existing[-2000:]
|
||||
body = existing.rstrip() + f"\n\n---\n## 更新 {now}\n\n{content}\n"
|
||||
|
||||
file_path.write_text(body, encoding="utf-8")
|
||||
|
||||
# 更新索引
|
||||
self._update_index(name, mem_type, safe_name)
|
||||
|
||||
logger.debug("文件记忆已保存: %s (%s)", name, mem_type)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("文件记忆保存失败: %s", e)
|
||||
return False
|
||||
|
||||
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
关键词检索所有记忆文件。
|
||||
对 query 分词后匹配文件内容,返回得分排序的结果。
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
return self._recent(top_k)
|
||||
|
||||
tokens = self._tokenize(query)
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
scored: List[tuple] = [] # (score, file_path, name, mem_type)
|
||||
for md_file in self._memories_dir.glob("*.md"):
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8")
|
||||
text_tokens = self._tokenize(text)
|
||||
if not text_tokens:
|
||||
continue
|
||||
intersection = tokens & text_tokens
|
||||
union = tokens | text_tokens
|
||||
score = len(intersection) / len(union) if union else 0
|
||||
|
||||
if score > 0:
|
||||
frontmatter = self._parse_frontmatter(text)
|
||||
name = frontmatter.get("name", md_file.stem)
|
||||
mem_type = frontmatter.get("type", "reference")
|
||||
scored.append((score, str(md_file), name, mem_type, text))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
scored.sort(key=lambda x: x[0], reverse=True)
|
||||
results = []
|
||||
for score, path, name, mem_type, text in scored[:top_k]:
|
||||
# 提取匹配片段
|
||||
snippet = self._extract_snippet(text, tokens, max_len=300)
|
||||
results.append({
|
||||
"name": name,
|
||||
"type": mem_type,
|
||||
"content": snippet,
|
||||
"score": round(score, 3),
|
||||
"source": "file",
|
||||
"path": path,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def list_by_type(self, mem_type: str = "") -> List[Dict[str, Any]]:
|
||||
"""列出指定类型的所有记忆。"""
|
||||
entries = self._parse_index()
|
||||
if mem_type:
|
||||
entries = [e for e in entries if e.get("type") == mem_type]
|
||||
|
||||
results = []
|
||||
for entry in entries:
|
||||
file_path = self._memories_dir / f"{entry.get('file', '')}.md"
|
||||
content = ""
|
||||
if file_path.exists():
|
||||
try:
|
||||
text = file_path.read_text(encoding="utf-8")
|
||||
frontmatter = self._parse_frontmatter(text)
|
||||
content = frontmatter.get("description", "")[:300]
|
||||
except Exception:
|
||||
pass
|
||||
results.append({
|
||||
"name": entry.get("name", ""),
|
||||
"type": entry.get("type", ""),
|
||||
"content": content,
|
||||
"source": "file",
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
"""删除一条记忆(文件 + 索引)。"""
|
||||
try:
|
||||
safe_name = self._safe_filename(name)
|
||||
file_path = self._memories_dir / f"{safe_name}.md"
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
self._remove_from_index(name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("删除文件记忆失败: %s", e)
|
||||
return False
|
||||
|
||||
# ─── Private helpers ───
|
||||
|
||||
def _ensure_index(self) -> None:
|
||||
"""确保 MEMORY.md 存在。"""
|
||||
if not self._index_path.exists():
|
||||
self._index_path.write_text(
|
||||
"# Memory Index\n\n"
|
||||
"> 文件式记忆存储 — 数据库不可用时的离线兜底。\n"
|
||||
"> 格式参考 Claude Code memdir 架构。\n\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _parse_index(self) -> List[Dict[str, str]]:
|
||||
"""解析 MEMORY.md 索引,返回条目列表。"""
|
||||
entries = []
|
||||
if not self._index_path.exists():
|
||||
return entries
|
||||
|
||||
try:
|
||||
lines = self._index_path.read_text(encoding="utf-8").split("\n")
|
||||
for line in lines:
|
||||
# 匹配 `- [Name](file.md) — description` 或 `- [Name](file.md)`
|
||||
m = re.match(r'- \[(.+?)\]\((.+?)\)\s*[-—]?\s*(.*)', line)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
filename = m.group(2).strip().removesuffix(".md")
|
||||
desc = m.group(3).strip()
|
||||
mem_type = desc.split(" ")[0] if desc else "reference"
|
||||
entries.append({
|
||||
"name": name,
|
||||
"file": filename,
|
||||
"description": desc,
|
||||
"type": mem_type,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def _update_index(self, name: str, mem_type: str, safe_name: str) -> None:
|
||||
"""更新 MEMORY.md 索引。"""
|
||||
# 先移除旧条目
|
||||
self._remove_from_index(name)
|
||||
|
||||
try:
|
||||
# 读取现有内容
|
||||
content = self._index_path.read_text(encoding="utf-8")
|
||||
lines = content.rstrip().split("\n")
|
||||
|
||||
# 构建新行
|
||||
new_line = f"- [{name}]({safe_name}.md) — {mem_type}"
|
||||
if len(new_line) > INDEX_LINE_MAX_LENGTH:
|
||||
new_line = new_line[:INDEX_LINE_MAX_LENGTH - 3] + "..."
|
||||
|
||||
# 找到最后一个列表项后插入(或添加到末尾)
|
||||
insert_at = len(lines)
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if lines[i].startswith("- ["):
|
||||
insert_at = i + 1
|
||||
break
|
||||
|
||||
lines.insert(insert_at, new_line)
|
||||
|
||||
# 裁剪索引到上限
|
||||
list_lines = [l for l in lines if l.startswith("- [")]
|
||||
if len(list_lines) > INDEX_MAX_ENTRIES:
|
||||
# 移除最早的条目
|
||||
excess = len(list_lines) - INDEX_MAX_ENTRIES
|
||||
new_lines = []
|
||||
removed = 0
|
||||
for line in lines:
|
||||
if line.startswith("- [") and removed < excess:
|
||||
removed += 1
|
||||
continue
|
||||
new_lines.append(line)
|
||||
lines = new_lines
|
||||
|
||||
self._index_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("更新 MEMORY.md 索引失败: %s", e)
|
||||
|
||||
def _remove_from_index(self, name: str) -> None:
|
||||
"""从 MEMORY.md 索引中移除指定条目。"""
|
||||
try:
|
||||
if not self._index_path.exists():
|
||||
return
|
||||
lines = self._index_path.read_text(encoding="utf-8").split("\n")
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
if f"[{name}]" in line:
|
||||
continue
|
||||
new_lines.append(line)
|
||||
self._index_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _recent(self, top_k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""返回最近修改的记忆文件。"""
|
||||
files = sorted(
|
||||
self._memories_dir.glob("*.md"),
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
results = []
|
||||
for f in files[:top_k]:
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8")
|
||||
fm = self._parse_frontmatter(text)
|
||||
results.append({
|
||||
"name": fm.get("name", f.stem),
|
||||
"type": fm.get("type", "reference"),
|
||||
"content": fm.get("description", "")[:300],
|
||||
"score": 1.0,
|
||||
"source": "file",
|
||||
"path": str(f),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _parse_frontmatter(text: str) -> Dict[str, Any]:
|
||||
"""解析 YAML frontmatter。轻量版,不依赖 PyYAML。"""
|
||||
result: Dict[str, Any] = {}
|
||||
if not text.startswith("---"):
|
||||
return result
|
||||
|
||||
parts = text.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return result
|
||||
|
||||
fm_text = parts[1].strip()
|
||||
for line in fm_text.split("\n"):
|
||||
line = line.strip()
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("'\"")
|
||||
# 解析简单列表 [a, b, c]
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
value = [v.strip().strip("'\"") for v in value[1:-1].split(",") if v.strip()]
|
||||
result[key] = value
|
||||
|
||||
# 提取正文第一段作为 description(如果 frontmatter 里没有)
|
||||
if "description" not in result:
|
||||
body = parts[2].strip()
|
||||
result["description"] = body[:200]
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""将名称转换为安全的文件名。"""
|
||||
# 只保留中文、英文、数字、下划线、连字符
|
||||
safe = re.sub(r'[^\w\u4e00-\u9fff\-]', '_', name)
|
||||
return safe.strip('_')[:64] or "memory"
|
||||
|
||||
@staticmethod
|
||||
def _one_line(text: str) -> str:
|
||||
"""提取文本的第一行或前 120 字符。"""
|
||||
first_line = text.split("\n")[0].strip()
|
||||
if len(first_line) > 120:
|
||||
first_line = first_line[:117] + "..."
|
||||
return first_line or text[:120]
|
||||
|
||||
@staticmethod
|
||||
def _tokenize(text: str) -> set:
|
||||
"""分词(复用 embedding_service 的逻辑)。"""
|
||||
tokens: set = set()
|
||||
text_lower = text.lower()
|
||||
|
||||
# 英文/数字词
|
||||
alpha_words = re.findall(r'[a-z0-9]{2,}', text_lower)
|
||||
for w in alpha_words:
|
||||
tokens.add(w)
|
||||
|
||||
# 中文二元组 + 单字
|
||||
cjk_segments = re.findall(r'[\u4e00-\u9fff]+', text_lower)
|
||||
for seg in cjk_segments:
|
||||
for i in range(len(seg) - 1):
|
||||
tokens.add(seg[i:i+2])
|
||||
for c in seg:
|
||||
tokens.add(c)
|
||||
|
||||
# 数字
|
||||
numbers = re.findall(r'\d+', text_lower)
|
||||
for n in numbers:
|
||||
tokens.add(n)
|
||||
|
||||
return tokens
|
||||
|
||||
@staticmethod
|
||||
def _extract_snippet(text: str, tokens: set, max_len: int = 300) -> str:
|
||||
"""从文本中提取包含关键词的片段。"""
|
||||
# 跳过 frontmatter
|
||||
if text.startswith("---"):
|
||||
parts = text.split("---", 2)
|
||||
text = parts[2] if len(parts) >= 3 else text
|
||||
|
||||
# 按段落找第一个匹配
|
||||
paragraphs = text.split("\n\n")
|
||||
for para in paragraphs:
|
||||
para_lower = para.lower()
|
||||
if any(t in para_lower for t in tokens):
|
||||
if len(para) > max_len:
|
||||
return para[:max_len - 3] + "..."
|
||||
return para
|
||||
|
||||
return text[:max_len]
|
||||
|
||||
|
||||
# 全局单例(延迟初始化,默认路径在首次使用项目目录时设置)
|
||||
_file_store: Optional[FileMemoryStore] = None
|
||||
|
||||
|
||||
def get_file_memory_store(memory_dir: str = "") -> FileMemoryStore:
|
||||
"""获取文件记忆存储单例。"""
|
||||
global _file_store
|
||||
if _file_store is None or (memory_dir and _file_store.base_dir != memory_dir):
|
||||
_file_store = FileMemoryStore(memory_dir)
|
||||
return _file_store
|
||||
@@ -89,10 +89,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("灵犀回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.lingxi_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("灵犀回复卡片失败: %s", e)
|
||||
|
||||
@@ -197,7 +199,10 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -214,7 +219,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -270,6 +291,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -312,7 +312,10 @@ class MainAgentService:
|
||||
max_iterations=int(_cfg("max_iterations", 5)),
|
||||
request_timeout=float(_cfg("request_timeout", 60.0)),
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
)
|
||||
|
||||
runtime = AgentRuntime(agent_config)
|
||||
|
||||
@@ -100,11 +100,13 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("橙子回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
"""通过橙子应用回复卡片消息。"""
|
||||
try:
|
||||
from app.services.orange_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("橙子回复卡片失败: %s", e)
|
||||
|
||||
@@ -230,7 +232,7 @@ async def _handle_message_async(data):
|
||||
temperature=temperature,
|
||||
max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(),
|
||||
tools=AgentToolConfig(permission_level="acceptEdits"), # 飞书渠道无Web弹窗
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -256,7 +258,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"🍊 {agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"🍊 {agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -321,6 +339,19 @@ def _build_event_handler():
|
||||
verification_token="",
|
||||
)
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -108,10 +108,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("人参果1号回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.renshenguo2_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("人参果1号回复卡片失败: %s", e)
|
||||
|
||||
@@ -260,7 +262,10 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 40)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 10)),
|
||||
@@ -277,7 +282,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -333,6 +354,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -117,10 +117,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("人参果回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.renshenguo_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("人参果回复卡片失败: %s", e)
|
||||
|
||||
@@ -265,7 +267,10 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(include_tools=tools_whitelist),
|
||||
tools=AgentToolConfig(
|
||||
include_tools=tools_whitelist,
|
||||
permission_level="acceptEdits", # 飞书渠道无Web弹窗,编辑工具自动批准
|
||||
),
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 40)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 10)),
|
||||
@@ -282,7 +287,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -338,6 +359,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -100,11 +100,13 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("苏瑶回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
"""通过苏瑶应用回复卡片消息。"""
|
||||
try:
|
||||
from app.services.suyao_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("苏瑶回复卡片失败: %s", e)
|
||||
|
||||
@@ -214,7 +216,7 @@ async def _handle_message_async(data):
|
||||
temperature=temperature,
|
||||
max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(),
|
||||
tools=AgentToolConfig(permission_level="acceptEdits"), # 飞书渠道无Web弹窗
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -231,7 +233,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -296,6 +314,19 @@ def _build_event_handler():
|
||||
verification_token="",
|
||||
)
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
@@ -89,10 +89,12 @@ def _reply_to_feishu(open_id: str, text: str):
|
||||
logger.warning("甜甜回复消息失败: %s", e)
|
||||
|
||||
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info"):
|
||||
def _reply_card(open_id: str, title: str, content: str, status: str = "info",
|
||||
execution_log_id: str = None, agent_name: str = None):
|
||||
try:
|
||||
from app.services.tiantian_app_service import send_message_to_user
|
||||
send_message_to_user(open_id, title, content, status=status)
|
||||
send_message_to_user(open_id, title, content, status=status,
|
||||
execution_log_id=execution_log_id, agent_name=agent_name)
|
||||
except Exception as e:
|
||||
logger.warning("甜甜回复卡片失败: %s", e)
|
||||
|
||||
@@ -195,7 +197,7 @@ async def _handle_message_async(data):
|
||||
model=model, provider=provider,
|
||||
temperature=temperature, max_iterations=max_iterations,
|
||||
),
|
||||
tools=AgentToolConfig(),
|
||||
tools=AgentToolConfig(permission_level="acceptEdits"), # 飞书渠道无Web弹窗
|
||||
memory=AgentMemoryConfig(
|
||||
max_history_messages=int(cfg.get("memory_max_history", 20)),
|
||||
vector_memory_top_k=int(cfg.get("memory_vector_top_k", 5)),
|
||||
@@ -212,7 +214,23 @@ async def _handle_message_async(data):
|
||||
result = await runtime.run(text)
|
||||
|
||||
if result.content:
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success")
|
||||
# Look up execution log for feedback buttons
|
||||
exec_log_id = None
|
||||
try:
|
||||
from app.models.agent_execution_log import AgentExecutionLog
|
||||
log_entry = (
|
||||
db.query(AgentExecutionLog)
|
||||
.filter(AgentExecutionLog.agent_name == agent.name)
|
||||
.order_by(AgentExecutionLog.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if log_entry:
|
||||
exec_log_id = str(log_entry.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_reply_card(open_id, f"{agent.name}", result.content.strip(), status="success",
|
||||
execution_log_id=exec_log_id, agent_name=agent.name)
|
||||
else:
|
||||
_reply_to_feishu(open_id, "Agent 未返回有效回复,请重试。")
|
||||
|
||||
@@ -268,6 +286,19 @@ def _build_event_handler():
|
||||
|
||||
builder = EventDispatcherHandler.builder(encrypt_key="", verification_token="")
|
||||
builder.register_p2_im_message_receive_v1(on_message_receive)
|
||||
|
||||
# Register card action handler for feedback buttons
|
||||
from app.services.feishu_card_actions import card_action_handler
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTrigger,
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
def on_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
||||
return card_action_handler(event)
|
||||
|
||||
builder.register_p2_card_action_trigger(on_card_action)
|
||||
|
||||
return builder.build()
|
||||
|
||||
|
||||
|
||||
1067
backend/tests/test_memory_advanced.py
Normal file
1067
backend/tests/test_memory_advanced.py
Normal file
File diff suppressed because it is too large
Load Diff
421
backend/tests/test_memory_system.py
Normal file
421
backend/tests/test_memory_system.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
天工 Agent 记忆系统 — 全功能测试用例
|
||||
|
||||
覆盖:P0 分类 / P1 向量化 / P2 Rerank / P3 异步压缩 / P4 Auto Dream
|
||||
P5 离线兜底 / P6 团队共享 / P7 文件记忆 / 核心嵌入 / 压缩 / 知识池
|
||||
|
||||
运行:cd backend && python tests/test_memory_system.py
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
import shutil
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# ─── 测试框架 ───
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
SKIP = 0
|
||||
|
||||
def test(name: str):
|
||||
"""装饰器风格的测试标记"""
|
||||
def decorator(fn):
|
||||
global PASS, FAIL, SKIP
|
||||
try:
|
||||
result = fn()
|
||||
if asyncio.iscoroutine(result):
|
||||
result = asyncio.run(result)
|
||||
if result is False:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name}")
|
||||
elif result is True:
|
||||
PASS += 1
|
||||
print(f" PASS {name}")
|
||||
else:
|
||||
PASS += 1
|
||||
print(f" PASS {name} ({result})")
|
||||
except Exception as e:
|
||||
FAIL += 1
|
||||
print(f" FAIL {name}: {e}")
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
|
||||
# ─── 测试用例 ───
|
||||
|
||||
@test("1.1 Embedding 服务 (SiliconFlow BGE-M3)")
|
||||
def test_embedding_generation():
|
||||
from app.services.embedding_service import embedding_service
|
||||
emb = asyncio.run(embedding_service.generate_embedding("天工智能体平台记忆测试"))
|
||||
assert emb and len(emb) == 1024, f"期望 1024 维,实际 {len(emb) if emb else 0}"
|
||||
return f"OK dims=1024"
|
||||
|
||||
|
||||
@test("1.2 离线关键词分词器")
|
||||
def test_offline_tokenizer():
|
||||
from app.services.embedding_service import embedding_service
|
||||
|
||||
# 中文二元组
|
||||
tokens = embedding_service._tokenize("今天天气真好")
|
||||
assert "今天" in tokens or "天气" in tokens, "中文二元组缺失"
|
||||
assert len(tokens) > 2, f"tokens太少: {len(tokens)}"
|
||||
|
||||
# 混合中英文
|
||||
tokens = embedding_service._tokenize("Python写代码")
|
||||
assert "python" in tokens, f"英文token缺失: {tokens}"
|
||||
|
||||
# 数字提取
|
||||
tokens = embedding_service._tokenize("IP: 101.43.95.130")
|
||||
assert "101" in tokens and "130" in tokens, f"数字token缺失: {tokens}"
|
||||
|
||||
return f"OK tokens={len(tokens)}"
|
||||
|
||||
|
||||
@test("1.3 离线关键词搜索")
|
||||
def test_keyword_search():
|
||||
from app.services.embedding_service import embedding_service
|
||||
|
||||
entries = [
|
||||
{"id": "1", "content_text": "数据库地址是101.43.95.130", "embedding": [], "metadata": {}},
|
||||
{"id": "2", "content_text": "今天天气很好适合出去玩", "embedding": [], "metadata": {}},
|
||||
{"id": "3", "content_text": "Python是一门很好的编程语言", "embedding": [], "metadata": {}},
|
||||
{"id": "4", "content_text": "天工平台有7个飞书机器人", "embedding": [], "metadata": {}},
|
||||
]
|
||||
|
||||
results = embedding_service.keyword_search("Python编程", entries, top_k=2)
|
||||
assert len(results) > 0, "关键词搜索无结果"
|
||||
assert "Python" in results[0]["content_text"], f"首条结果不相关: {results[0]['content_text'][:50]}"
|
||||
|
||||
results = embedding_service.keyword_search("飞书机器人", entries, top_k=2)
|
||||
assert any("飞书机器人" in r["content_text"] for r in results), "飞书搜索失败"
|
||||
|
||||
return f"OK 命中{len(results)}条"
|
||||
|
||||
|
||||
@test("2.1 记忆类型推断 (P0)")
|
||||
def test_memory_type_inference():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
cases = [
|
||||
("我喜欢用Python写代码", "好的", "user"),
|
||||
("这个功能报错了,不对", "让我看看", "feedback"),
|
||||
("数据库的地址是什么?", "地址是101.43.95.130", "reference"),
|
||||
("这个任务的进度怎么样了?", "任务完成80%", "project"),
|
||||
("帮我提交一下代码", "已提交", "project"),
|
||||
("记住我不喜欢吃辣", "记住了", "user"),
|
||||
]
|
||||
for um, ar, expected in cases:
|
||||
result = AgentMemory._infer_memory_type(um, ar)
|
||||
assert result == expected, f"\"{um[:20]}\" 期望 {expected},实际 {result}"
|
||||
|
||||
return f"OK {len(cases)} cases"
|
||||
|
||||
|
||||
@test("2.2 记忆类型过滤")
|
||||
def test_memory_type_filter():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(scope_id="test_filter", memory_type_filter=["user", "feedback"])
|
||||
assert mem.memory_type_filter == ["user", "feedback"]
|
||||
assert mem.MEMORY_TYPES == ("user", "feedback", "project", "reference")
|
||||
|
||||
# 无过滤
|
||||
mem2 = AgentMemory(scope_id="test_nofilter")
|
||||
assert mem2.memory_type_filter is None
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("3.1 LLM Rerank 配置 (P2)")
|
||||
def test_rerank_config():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(scope_id="test_rerank", vector_memory_rerank=True)
|
||||
assert mem.vector_memory_rerank is True
|
||||
assert hasattr(mem, "_llm_rerank"), "缺少 _llm_rerank 方法"
|
||||
|
||||
mem2 = AgentMemory(scope_id="test_norerank")
|
||||
assert mem2.vector_memory_rerank is False
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("3.2 消息裁剪保留配对完整性")
|
||||
def test_trim_messages():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(scope_id="test", max_history=4)
|
||||
|
||||
# 构造含 tool_calls + tool_result 的消息序列
|
||||
msgs = [
|
||||
{"role": "system", "content": "你是助手"},
|
||||
{"role": "user", "content": "查天气"},
|
||||
{"role": "assistant", "content": "好的", "tool_calls": [{"name": "get_weather", "id": "1"}]},
|
||||
{"role": "tool", "content": "晴天 25度", "tool_call_id": "1"},
|
||||
{"role": "assistant", "content": "今天晴天25度"},
|
||||
{"role": "user", "content": "谢谢"},
|
||||
]
|
||||
|
||||
trimmed = mem.trim_messages(msgs)
|
||||
# system msg 应保留
|
||||
assert trimmed[0]["role"] == "system", "system消息应保留"
|
||||
# 不应有孤立的 tool 消息开头
|
||||
assert trimmed[1]["role"] != "tool", "裁剪后首条不应是孤立 tool 消息"
|
||||
|
||||
return f"OK trimmed to {len(trimmed)}"
|
||||
|
||||
|
||||
@test("4.1 后台异步压缩结构完整 (P3)")
|
||||
def test_background_compress_structure():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(scope_id="test_bg")
|
||||
assert hasattr(mem, "_background_compress_and_save"), "缺少 _background_compress_and_save"
|
||||
assert hasattr(mem, "_compress_and_summarize"), "缺少 _compress_and_summarize"
|
||||
assert hasattr(mem, "_save_compressed_memories"), "缺少 _save_compressed_memories (P1)"
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("5.1 Auto Dream 阈值配置 (P4)")
|
||||
def test_auto_dream_config():
|
||||
from app.services.auto_dream_service import MERGE_SIMILARITY_THRESHOLD, _should_dream_today
|
||||
|
||||
assert 0.8 <= MERGE_SIMILARITY_THRESHOLD <= 0.95, "合并阈值不合理"
|
||||
# 非凌晨3点不应触发
|
||||
assert _should_dream_today() is False, "非凌晨3点不应触发 dream"
|
||||
|
||||
return f"OK threshold={MERGE_SIMILARITY_THRESHOLD}"
|
||||
|
||||
|
||||
@test("5.2 Auto Dream 服务导入正常")
|
||||
def test_auto_dream_import():
|
||||
from app.services.auto_dream_service import run_auto_dream, _should_dream_today
|
||||
assert callable(run_auto_dream)
|
||||
assert callable(_should_dream_today)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("6.1 团队共享记忆 (P6)")
|
||||
def test_team_sharing():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(scope_id="agent_1", team_id="team_alpha", team_share_enabled=True)
|
||||
assert mem.team_id == "team_alpha"
|
||||
assert mem.team_share_enabled is True
|
||||
|
||||
mem2 = AgentMemory(scope_id="agent_2", team_id="team_alpha", team_share_enabled=False)
|
||||
assert mem2.team_id == "team_alpha"
|
||||
assert mem2.team_share_enabled is False
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("7.1 文件式记忆存储 (P7)")
|
||||
def test_file_memory_store():
|
||||
from app.services.file_memory_service import FileMemoryStore
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="tmem_")
|
||||
try:
|
||||
store = FileMemoryStore(tmpdir)
|
||||
|
||||
# 保存
|
||||
store.save("用户偏好", "用户喜欢用Python开发", mem_type="user")
|
||||
store.save("数据库配置", "MySQL地址101.43.95.130", mem_type="reference")
|
||||
store.save("项目信息", "天工平台有7个飞书机器人", mem_type="project")
|
||||
|
||||
# 计数
|
||||
assert store.memory_count == 3, f"期望 3,实际 {store.memory_count}"
|
||||
|
||||
# 搜索
|
||||
results = store.search("Python")
|
||||
assert len(results) > 0, "Python搜索无结果"
|
||||
|
||||
results = store.search("飞书机器人")
|
||||
assert len(results) > 0, "飞书搜索无结果"
|
||||
|
||||
# 按类型列出
|
||||
user_items = store.list_by_type("user")
|
||||
assert len(user_items) >= 1, "user类型缺失"
|
||||
|
||||
# 删除
|
||||
store.delete("用户偏好")
|
||||
assert store.memory_count == 2, f"删除后期望 2,实际 {store.memory_count}"
|
||||
|
||||
# MEMORY.md 存在
|
||||
index_path = os.path.join(tmpdir, "MEMORY.md")
|
||||
assert os.path.exists(index_path), "MEMORY.md 不存在"
|
||||
|
||||
return f"OK saved=3 searched=2 deleted=1"
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
@test("7.2 文件记忆读取")
|
||||
def test_file_memory_read():
|
||||
from app.services.file_memory_service import FileMemoryStore
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="tmem_")
|
||||
try:
|
||||
store = FileMemoryStore(tmpdir)
|
||||
store.save("测试记忆", "这是一条测试记忆内容,包含关键词Python和天工", mem_type="reference")
|
||||
|
||||
# 通过搜索读取
|
||||
results = store.search("Python")
|
||||
assert len(results) == 1
|
||||
assert results[0]["source"] == "file"
|
||||
assert "Python" in results[0]["content"]
|
||||
|
||||
return f"OK content={results[0]['content'][:30]}"
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
@test("8.1 余弦相似度计算")
|
||||
def test_cosine_similarity():
|
||||
from app.services.embedding_service import embedding_service
|
||||
|
||||
# 相同向量
|
||||
sim = embedding_service.cosine_similarity([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])
|
||||
assert abs(sim - 1.0) < 0.001, f"相同向量相似度应为1.0,实际{sim}"
|
||||
|
||||
# 正交向量
|
||||
sim = embedding_service.cosine_similarity([1.0, 0.0], [0.0, 1.0])
|
||||
assert abs(sim - 0.0) < 0.001, f"正交向量相似度应为0.0,实际{sim}"
|
||||
|
||||
# 维度不同
|
||||
sim = embedding_service.cosine_similarity([1.0], [1.0, 2.0])
|
||||
assert sim == 0.0, f"不同维度应返回0"
|
||||
|
||||
# 空向量
|
||||
sim = embedding_service.cosine_similarity([], [1.0, 2.0])
|
||||
assert sim == 0.0, "空向量应返回0"
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("9.1 压缩记忆向量化可调用 (P1)")
|
||||
def test_compressed_memory_vectorization():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(scope_id="test_cmv")
|
||||
assert hasattr(mem, "_save_compressed_memories")
|
||||
assert callable(mem._save_compressed_memories)
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("9.2 全局知识保存结构")
|
||||
def test_global_knowledge_structure():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(scope_id="test_gk")
|
||||
assert hasattr(mem, "save_global_knowledge")
|
||||
assert hasattr(mem, "_global_knowledge_search")
|
||||
|
||||
return "OK"
|
||||
|
||||
|
||||
@test("10.1 完整记忆生命周期模拟")
|
||||
def test_full_lifecycle():
|
||||
"""模拟一次完整的记忆生命周期:创建 → 检索 → 保存 → 压缩 → 整合"""
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(
|
||||
scope_id="test_lifecycle",
|
||||
vector_memory_enabled=True,
|
||||
vector_memory_top_k=3,
|
||||
vector_memory_rerank=False,
|
||||
memory_type_filter=None,
|
||||
team_id="test_team",
|
||||
team_share_enabled=True,
|
||||
memory_dir_enabled=True,
|
||||
memory_dir_path=tempfile.mkdtemp(prefix="tlife_"),
|
||||
)
|
||||
|
||||
# 初始化
|
||||
text = asyncio.run(mem.initialize("Python开发"))
|
||||
assert isinstance(text, str), "initialize 应返回字符串"
|
||||
|
||||
# 保存上下文
|
||||
asyncio.run(mem.save_context(
|
||||
"我喜欢用Python写代码",
|
||||
"Python确实是很好的选择",
|
||||
))
|
||||
|
||||
# 消息裁剪
|
||||
msgs = [
|
||||
{"role": "system", "content": "你是助手"},
|
||||
{"role": "user", "content": "你好"},
|
||||
{"role": "assistant", "content": "你好!"},
|
||||
{"role": "user", "content": "帮我写Python"},
|
||||
{"role": "assistant", "content": "好的"},
|
||||
{"role": "user", "content": "谢谢"},
|
||||
]
|
||||
trimmed = mem.trim_messages(msgs)
|
||||
assert len(trimmed) <= mem.max_history + 1, "裁剪后应不超过 max_history"
|
||||
|
||||
# 清理文件记忆目录
|
||||
mp = mem.memory_dir_path
|
||||
if mp and os.path.exists(mp):
|
||||
shutil.rmtree(mp, ignore_errors=True)
|
||||
|
||||
return "OK init+save+trim"
|
||||
|
||||
|
||||
@test("10.2 AgentMemory 配置全量传递")
|
||||
def test_full_config_wiring():
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
|
||||
mem = AgentMemory(
|
||||
scope_kind="agent",
|
||||
scope_id="agent_78ba9dfb",
|
||||
session_key="session_001",
|
||||
persist=True,
|
||||
max_history=15,
|
||||
vector_memory_enabled=True,
|
||||
vector_memory_top_k=8,
|
||||
vector_memory_rerank=True,
|
||||
memory_type_filter=["user", "project"],
|
||||
team_id="team_feishu",
|
||||
team_share_enabled=True,
|
||||
memory_dir_enabled=True,
|
||||
memory_dir_path="/tmp/tiangong_mem",
|
||||
)
|
||||
|
||||
assert mem.scope_kind == "agent"
|
||||
assert mem.scope_id == "agent_78ba9dfb"
|
||||
assert mem.max_history == 15
|
||||
assert mem.vector_memory_top_k == 8
|
||||
assert mem.vector_memory_rerank is True
|
||||
assert mem.memory_type_filter == ["user", "project"]
|
||||
assert mem.team_id == "team_feishu"
|
||||
assert mem.team_share_enabled is True
|
||||
assert mem.memory_dir_enabled is True
|
||||
assert mem.memory_dir_path == "/tmp/tiangong_mem"
|
||||
|
||||
return "OK all 12 params"
|
||||
|
||||
|
||||
# ─── 运行 ───
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("天工 Agent 记忆系统 — 全功能测试")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# 所有 @test 装饰器在 import 时自动执行
|
||||
total = PASS + FAIL + SKIP
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"测试结果: {PASS} 通过 / {FAIL} 失败 / {SKIP} 跳过 (共 {total})")
|
||||
print("=" * 60)
|
||||
|
||||
if FAIL > 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n全部测试通过!")
|
||||
Reference in New Issue
Block a user