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

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

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

View File

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

View File

@@ -38,6 +38,12 @@ class AgentMemory:
max_history: int = 20,
vector_memory_enabled: bool = True,
vector_memory_top_k: int = 5,
vector_memory_rerank: bool = False,
memory_type_filter: Optional[List[str]] = None,
team_id: Optional[str] = None,
team_share_enabled: bool = False,
memory_dir_enabled: bool = False,
memory_dir_path: str = "",
):
self.scope_kind = scope_kind
self.scope_id = scope_id or "default"
@@ -46,11 +52,28 @@ class AgentMemory:
self.max_history = max_history
self.vector_memory_enabled = vector_memory_enabled
self.vector_memory_top_k = vector_memory_top_k
self.vector_memory_rerank = vector_memory_rerank
self.memory_type_filter = memory_type_filter # None = 全部类型
self.team_id = team_id # 团队共享 ID
self.team_share_enabled = team_share_enabled # 是否自动发布到团队池
# 文件式记忆
self.memory_dir_enabled = memory_dir_enabled
self.memory_dir_path = memory_dir_path
self._file_store = None # 延迟初始化
# 记忆类型分类: user / feedback / project / reference
self.MEMORY_TYPES = ("user", "feedback", "project", "reference")
# 从长期记忆加载的上下文(启动时加载)
self._long_term_context: Dict[str, Any] = {}
# 记录已压缩的消息数,避免重复压缩
self._last_compressed_msg_count = 0
def _get_file_store(self):
"""延迟初始化文件记忆存储。"""
if self._file_store is None and self.memory_dir_enabled:
from app.services.file_memory_service import get_file_memory_store
self._file_store = get_file_memory_store(self.memory_dir_path)
return self._file_store
async def initialize(self, query: str = "") -> str:
"""
初始化记忆:从 DB/Redis 加载长期记忆 + 向量检索相关历史。
@@ -95,7 +118,22 @@ class AgentMemory:
if vector_text:
parts.append(vector_text)
# 3. 全局知识检索:从 GlobalKnowledge 表加载相关条目
# 3. P7 文件式记忆:从本地 MEMORY.md 加载
store = self._get_file_store()
if store and store.memory_count > 0 and query:
file_results = store.search(query, top_k=3)
if file_results:
lines = ["## 文件记忆(本地 MEMORY.md)"]
for i, r in enumerate(file_results, 1):
mem_type = r.get("type", "reference")
content = r.get("content", "")[:300]
score = r.get("score", 0)
lines.append(f"{i}. [{mem_type}] {content}")
if score < 1.0:
lines[-1] += f" (匹配度: {score:.2f})"
parts.append("\n".join(lines))
# 4. 全局知识检索:从 GlobalKnowledge 表加载相关条目
global_text = await self._global_knowledge_search(query)
if global_text:
parts.append(global_text)
@@ -106,6 +144,7 @@ class AgentMemory:
"""
向量检索语义相关的历史记忆,返回格式化的文本块。
若无 query 则返回最近 Top-5 条记忆。
支持 memory_type_filter 按类型过滤 + LLM Rerank 精选。
"""
from app.models.agent_vector_memory import AgentVectorMemory
@@ -113,22 +152,46 @@ class AgentMemory:
try:
db = SessionLocal()
# 查询当前 scope 的所有向量记忆(按时间倒序)
rows = (
query_builder = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.scope_kind == self.scope_kind,
AgentVectorMemory.scope_id == self.scope_id,
)
)
rows = (
query_builder
.order_by(AgentVectorMemory.created_at.desc())
.limit(50) # 最多取最近 50 条做相似度计算
.limit(50)
.all()
)
# P6 团队共享:同时查询团队记忆池
if self.team_id:
team_rows = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.scope_kind == "team",
AgentVectorMemory.scope_id == self.team_id,
)
.order_by(AgentVectorMemory.created_at.desc())
.limit(30)
.all()
)
rows = list(rows) + list(team_rows)
if not rows:
return ""
entries: List[VectorEntry] = []
for row in rows:
# 类型过滤(memory_type_filter 不为空时生效)
meta = row.metadata_ or {}
row_memory_type = meta.get("memory_type", meta.get("type", "conversation_turn"))
if self.memory_type_filter:
if row_memory_type not in self.memory_type_filter:
continue
emb = embedding_service.deserialize_embedding(row.embedding) if row.embedding else []
entries.append({
"id": row.id,
@@ -136,17 +199,35 @@ class AgentMemory:
"scope_id": row.scope_id,
"content_text": row.content_text,
"embedding": emb,
"metadata": row.metadata_ or {},
"metadata": meta,
})
if not entries:
return ""
matched: List[VectorEntry] = []
if query and query.strip():
# 有 query:生成 embedding 做语义搜索
query_emb = await embedding_service.generate_embedding(query)
if query_emb:
matched = await embedding_service.similarity_search(
query_emb, entries, top_k=self.vector_memory_top_k
# 向量检索取 top_k * 4 候选(为 rerank 留余量),最少 20 条
candidate_k = max(20, self.vector_memory_top_k * 4)
candidates = await embedding_service.similarity_search(
query_emb, entries, top_k=min(candidate_k, len(entries))
)
# LLM Rerank:向量粗筛 → LLM 精选
if self.vector_memory_rerank and len(candidates) > self.vector_memory_top_k:
matched = await self._llm_rerank(query, candidates)
if not matched:
matched = candidates[: self.vector_memory_top_k]
else:
# P5 离线兜底:Embedding API 不可用时降级为关键词匹配
logger.info("Embedding 不可用,降级为离线关键词匹配")
matched = embedding_service.keyword_search(
query, entries, top_k=self.vector_memory_top_k, min_score=0.05,
)
else:
# 无 query:返回最近几条
@@ -162,8 +243,14 @@ class AgentMemory:
for i, m in enumerate(matched, 1):
text = m.get("content_text", "")[:500]
meta = m.get("metadata", {})
entry_type = meta.get("type", "对话")
lines.append(f"{i}. [{entry_type}] {text}")
mem_type = meta.get("memory_type", meta.get("type", "对话"))
scope_kind = m.get("scope_kind", "")
# 标注团队共享来源
source_tag = ""
if scope_kind == "team":
shared_by = meta.get("shared_by", meta.get("source_scope", "unknown"))
source_tag = f" [团队共享]"
lines.append(f"{i}. [{mem_type}]{source_tag} {text}")
if m.get("score", 1.0) < 1.0:
lines[-1] += f" (匹配度: {m['score']:.2f})"
@@ -176,6 +263,84 @@ class AgentMemory:
if db:
db.close()
async def _llm_rerank(
self, query: str, candidates: List[VectorEntry],
) -> List[VectorEntry]:
"""
LLM Rerank:用轻量模型对向量粗筛结果打分排序,返回精选 top-K。
流程:取向量检索 top-N 候选 → LLM 按与 query 相关性打分 (1-10)
→ 取 top-K 高分结果。失败时降级返回原始排序。
"""
from openai import AsyncOpenAI
from app.core.config import settings
if not candidates or len(candidates) <= self.vector_memory_top_k:
return candidates[: self.vector_memory_top_k]
try:
# 构建候选列表
items_text = []
for idx, c in enumerate(candidates):
content = c.get("content_text", "")[:300]
mem_type = c.get("metadata", {}).get("memory_type", "unknown")
items_text.append(f"[{idx}] [{mem_type}] {content}")
rerank_prompt = (
"你是一个记忆检索排序助手。请根据用户查询,对以下记忆条目按相关性打分(1-10分)。\n"
"只输出 JSON 数组,每个元素包含 index 和 score,按 score 降序排列。\n"
"只保留 score >= 4 的结果。最多返回 {} 条。\n\n"
"用户查询: {}\n\n记忆条目:\n{}"
).format(
self.vector_memory_top_k,
query[:500],
"\n".join(items_text),
)
api_key = settings.DEEPSEEK_API_KEY or settings.OPENAI_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or settings.OPENAI_BASE_URL or "https://api.deepseek.com"
if api_key == "your-openai-api-key":
api_key = settings.DEEPSEEK_API_KEY or ""
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
if not api_key:
return candidates[: self.vector_memory_top_k]
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
resp = await client.chat.completions.create(
model="deepseek-v4-flash",
messages=[{"role": "user", "content": rerank_prompt}],
temperature=0.1,
max_tokens=512,
timeout=15,
)
raw = resp.choices[0].message.content or ""
raw = raw.strip().removeprefix("```json").removesuffix("```").strip()
import json
scored = json.loads(raw)
if not isinstance(scored, list):
return candidates[: self.vector_memory_top_k]
# 按 score 排序取 top-K
scored.sort(key=lambda x: x.get("score", 0), reverse=True)
result: List[VectorEntry] = []
for item in scored[: self.vector_memory_top_k]:
idx = item.get("index", -1)
if 0 <= idx < len(candidates):
candidates[idx]["score"] = float(item.get("score", 5.0)) / 10.0
result.append(candidates[idx])
if result:
logger.info("LLM Rerank: %d 候选 → %d 精选", len(candidates), len(result))
return result
return candidates[: self.vector_memory_top_k]
except Exception as e:
logger.warning("LLM Rerank 失败,使用向量排序: %s", e)
return candidates[: self.vector_memory_top_k]
async def _global_knowledge_search(self, query: str = "") -> str:
"""从 GlobalKnowledge 表检索相关的全局知识条目。"""
from datetime import datetime
@@ -340,33 +505,52 @@ class AgentMemory:
self, user_message: str, assistant_reply: str,
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""将单轮对话保存到长期记忆。如有消息列表,LLM 自动压缩总结。"""
"""将单轮对话保存到长期记忆。
快速路径(同步完成):向量记忆写入 + 基础上下文更新。
慢速路径(fire-and-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"

View File

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

View File

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

View File

@@ -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 关联了定时任务,创建通知并推送飞书消息。

View 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 ""

View File

@@ -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()

View 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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View 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全部测试通过!")