fix: Feishu channel agents file_write permission blocked + memory system tests & docs
- Fix 8 Feishu agent handlers to use permission_level="acceptEdits" so file_write tool works without Web UI approval popup (lingxi/renshenguo/suyao/tiantian/orange/main/schedule) - Add P5-P7 memory improvements: offline keyword fallback, team sharing, file-based memory - Add auto_dream_service for daily memory consolidation - Add 99 memory system test cases (basic 18 + advanced 43 + pytest 38) - Add platform capability assessment report and unfinished project checklist Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,35 @@ from app.agent_runtime.context import AgentContext
|
||||
from app.agent_runtime.memory import AgentMemory
|
||||
from app.agent_runtime.tool_manager import AgentToolManager
|
||||
from app.core.exceptions import WorkflowExecutionError
|
||||
from app.core.hooks import HookManager, HookEvent, HookContext, HookResult
|
||||
from app.agent_runtime.plan_mode import PlanMode, Plan, PlanStatus
|
||||
from app.core.error_recovery import ErrorClassifier, ErrorType, ConversationRecovery
|
||||
from app.core.memdir import MemoryDir, MemoryType as MemType, MemoryManifest, parse_frontmatter
|
||||
from app.core.memory_selector import memory_selector
|
||||
from app.core.compaction import CompactionEngine, CompactionResult, CompactionStrategy
|
||||
from app.core.compaction_config import CompactionConfig
|
||||
from app.core.token_counter import is_context_length_error
|
||||
from app.core.streamlined_output import (
|
||||
StreamlinedTransformer,
|
||||
create_streamlined_transformer,
|
||||
get_tool_summary_text,
|
||||
ToolCounts,
|
||||
categorize_tool,
|
||||
)
|
||||
from app.core.prompt_sections import (
|
||||
PromptComposer,
|
||||
PromptSection,
|
||||
create_prompt_composer,
|
||||
create_default_static_sections,
|
||||
create_default_dynamic_sections,
|
||||
section_environment,
|
||||
section_language,
|
||||
)
|
||||
from app.core.token_budget import (
|
||||
TokenBudget,
|
||||
TokenBudgetConfig,
|
||||
create_token_budget,
|
||||
)
|
||||
from app.services.agent_learning_service import (
|
||||
extract_pattern_from_result,
|
||||
format_pattern_hint,
|
||||
@@ -54,18 +83,8 @@ class LLMCallMetrics(TypedDict, total=False):
|
||||
status: str # success / error
|
||||
error_message: Optional[str]
|
||||
|
||||
# 可重试的 API 异常
|
||||
_RETRYABLE_ERRORS = (
|
||||
"timed out",
|
||||
"timeout",
|
||||
"connection error",
|
||||
"temporarily unavailable",
|
||||
"server disconnected",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"internal server error",
|
||||
"service unavailable",
|
||||
)
|
||||
# 全局错误分类器(可重试判定 + 退避策略)
|
||||
_error_classifier = ErrorClassifier()
|
||||
|
||||
|
||||
class AgentRuntime:
|
||||
@@ -86,6 +105,8 @@ class AgentRuntime:
|
||||
execution_logger: Optional[Any] = None,
|
||||
on_tool_executed: Optional[Callable[[str], Any]] = None,
|
||||
on_llm_call: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
hook_manager: Optional[HookManager] = None,
|
||||
streamlined: bool = False,
|
||||
):
|
||||
self.config = config or AgentConfig()
|
||||
self.context = context or AgentContext(
|
||||
@@ -97,6 +118,14 @@ class AgentRuntime:
|
||||
scope_id=_mem_scope,
|
||||
max_history=self.config.memory.max_history_messages,
|
||||
persist=self.config.memory.persist_to_db,
|
||||
vector_memory_enabled=self.config.memory.vector_memory_enabled,
|
||||
vector_memory_top_k=self.config.memory.vector_memory_top_k,
|
||||
vector_memory_rerank=self.config.memory.vector_memory_rerank,
|
||||
memory_type_filter=self.config.memory.memory_type_filter,
|
||||
team_id=self.config.memory.team_id,
|
||||
team_share_enabled=self.config.memory.team_share_enabled,
|
||||
memory_dir_enabled=self.config.memory.memory_dir_enabled,
|
||||
memory_dir_path=self.config.memory.memory_dir_path,
|
||||
)
|
||||
self.tool_manager = tool_manager or AgentToolManager(
|
||||
include_tools=self.config.tools.include_tools,
|
||||
@@ -104,6 +133,9 @@ class AgentRuntime:
|
||||
cache_enabled=self.config.tools.cache_enabled,
|
||||
cache_tool_whitelist=self.config.tools.cache_tool_whitelist,
|
||||
cache_ttl_ms=self.config.tools.cache_ttl_ms,
|
||||
permission_level=self.config.tools.permission_level,
|
||||
auto_approve_rules=self.config.tools.auto_approve_rules,
|
||||
deny_tools=self.config.tools.deny_tools,
|
||||
)
|
||||
self.execution_logger = execution_logger
|
||||
self.on_tool_executed = on_tool_executed
|
||||
@@ -113,10 +145,119 @@ class AgentRuntime:
|
||||
# 自主学习作用域:bare 聊天用 "bare",Agent 用 "agent"
|
||||
self._learning_scope_kind = "bare" if "bare" in str(_mem_scope) else "agent"
|
||||
|
||||
# Hook 管理器 (P1)
|
||||
self.hook_manager = hook_manager or HookManager()
|
||||
|
||||
# 计划模式 (P2)
|
||||
self.plan_mode = PlanMode(self.config.llm) if self.config.llm.plan_mode_enabled else None
|
||||
|
||||
# 对话自动压缩 (参考 Claude Code compact)
|
||||
self.compaction_engine: Optional[CompactionEngine] = None
|
||||
compaction_cfg = getattr(self.config.memory, 'compaction', None)
|
||||
if compaction_cfg is None:
|
||||
compaction_cfg = CompactionConfig()
|
||||
if compaction_cfg.enabled:
|
||||
self.compaction_engine = CompactionEngine(
|
||||
config=compaction_cfg,
|
||||
model=self.config.llm.model,
|
||||
)
|
||||
logger.info("对话压缩引擎已启用 (model=%s, window=%d)",
|
||||
self.config.llm.model, self.config.llm.context_window)
|
||||
|
||||
# 工具结果流式美化 (参考 Claude Code streamlinedTransform)
|
||||
self.streamlined = streamlined
|
||||
self._streamlined_transformer: Optional[StreamlinedTransformer] = None
|
||||
if streamlined:
|
||||
self._streamlined_transformer = create_streamlined_transformer(enabled=True)
|
||||
logger.info("工具结果流式美化已启用")
|
||||
|
||||
# 系统提示词分层装配 (P2 — 参考 Claude Code systemPromptSections.ts)
|
||||
self._prompt_composer: Optional[PromptComposer] = None
|
||||
self._prompt_sections_enabled = self.config.prompt_sections.enabled
|
||||
if self._prompt_sections_enabled:
|
||||
ps_config = self.config.prompt_sections
|
||||
# 构建静态段(按开关过滤)
|
||||
static_sections = []
|
||||
s_switches = ps_config.static_sections
|
||||
if s_switches.get("persona", True):
|
||||
static_sections.append(PromptSection(
|
||||
"persona",
|
||||
lambda cfg=self.config: f"{cfg.system_prompt}\n\n"
|
||||
))
|
||||
if s_switches.get("capabilities", True):
|
||||
from app.core.prompt_sections import section_capabilities
|
||||
static_sections.append(PromptSection("capabilities", section_capabilities))
|
||||
if s_switches.get("tool_instructions", True):
|
||||
from app.core.prompt_sections import section_tool_instructions
|
||||
static_sections.append(PromptSection("tool_instructions", section_tool_instructions))
|
||||
if s_switches.get("safety_rules", True):
|
||||
from app.core.prompt_sections import section_safety_rules
|
||||
static_sections.append(PromptSection("safety_rules", section_safety_rules))
|
||||
if s_switches.get("output_style", True):
|
||||
from app.core.prompt_sections import section_output_style
|
||||
static_sections.append(PromptSection("output_style", section_output_style))
|
||||
|
||||
self._prompt_composer = PromptComposer()
|
||||
self._prompt_composer.add_static_sections(static_sections)
|
||||
logger.info("系统提示词分层装配已启用 (%d 静态段)", len(static_sections))
|
||||
|
||||
# Token 预算管理 (P2 — 参考 Claude Code tokenBudget.ts)
|
||||
self._token_budget: Optional[TokenBudget] = None
|
||||
tb_config = self.config.token_budget
|
||||
if tb_config.enabled:
|
||||
self._token_budget = TokenBudget(
|
||||
config=TokenBudgetConfig(
|
||||
enabled=True,
|
||||
context_window=tb_config.context_window or self.config.llm.context_window,
|
||||
output_reserve=tb_config.output_reserve,
|
||||
warning_threshold_pct=tb_config.warning_threshold_pct,
|
||||
compact_threshold_pct=tb_config.compact_threshold_pct,
|
||||
hard_limit_pct=tb_config.hard_limit_pct,
|
||||
user_budget=tb_config.user_budget,
|
||||
auto_continue=tb_config.auto_continue,
|
||||
compaction_after_warning=tb_config.compaction_after_warning,
|
||||
max_compaction_attempts=tb_config.max_compaction_attempts,
|
||||
),
|
||||
model=self.config.llm.model,
|
||||
)
|
||||
logger.info("Token 预算管理已启用 (window=%d, compact@%d%%)",
|
||||
self._token_budget.config.context_window,
|
||||
int(tb_config.compact_threshold_pct * 100))
|
||||
|
||||
# 崩溃恢复 (P4)
|
||||
self.recovery = ConversationRecovery()
|
||||
self._recovery_snapshot_counter = 0
|
||||
|
||||
# 文件式记忆 (MEMORY.md)
|
||||
self._memdir: Optional[MemoryDir] = None
|
||||
self._memdir_manifest: Optional[MemoryManifest] = None
|
||||
if self.config.memory.memory_dir_enabled:
|
||||
mem_path = self.config.memory.memory_dir_path
|
||||
if not mem_path:
|
||||
# 默认路径: 项目根目录下的 .claude/memory
|
||||
import os as _os
|
||||
mem_path = _os.path.join(
|
||||
_os.path.dirname(_os.path.dirname(_os.path.dirname(__file__))),
|
||||
".claude", "memory",
|
||||
)
|
||||
self._memdir = MemoryDir(mem_path)
|
||||
# 启动时扫描一次
|
||||
self._memdir_manifest = self._memdir.scan()
|
||||
memory_selector.reset()
|
||||
logger.info("文件式记忆已启用: %s (%d 条)", mem_path,
|
||||
self._memdir_manifest.total_files)
|
||||
|
||||
# 预算回调:供 WorkflowEngine 注入,使 Agent 内部计数计入工作流预算
|
||||
# 返回 True 表示预算充足;返回 False 或抛出异常表示超限
|
||||
self.on_llm_invocation: Optional[Callable[[], Any]] = None
|
||||
|
||||
def _attach_token_usage(self, result: AgentResult) -> AgentResult:
|
||||
"""将 TokenBudget 摘要附加到 AgentResult(若启用)。"""
|
||||
if self._token_budget:
|
||||
from app.agent_runtime.schemas import TokenUsageInfo
|
||||
result.token_usage = TokenUsageInfo(**self._token_budget.summary())
|
||||
return result
|
||||
|
||||
def _build_execution_log_kwargs(self, user_input: str, result: AgentResult, latency_ms: int) -> dict:
|
||||
"""从 AgentResult 构建 execution_logger 所需的参数字典。"""
|
||||
tool_chain = []
|
||||
@@ -151,6 +292,27 @@ class AgentRuntime:
|
||||
provider=self.config.llm.provider,
|
||||
)
|
||||
|
||||
def _fire_recovery_snapshot(self):
|
||||
"""Fire-and-forget 保存崩溃恢复快照(每 5 次工具调用保存一次)。"""
|
||||
self._recovery_snapshot_counter += 1
|
||||
if self._recovery_snapshot_counter % 5 != 0:
|
||||
return
|
||||
try:
|
||||
import asyncio
|
||||
asyncio.ensure_future(
|
||||
self.recovery.save_snapshot(
|
||||
session_id=self.context.session_id,
|
||||
messages=self.context.messages,
|
||||
extra={
|
||||
"agent_name": self.config.name,
|
||||
"iteration": self.context.iteration,
|
||||
"tool_calls_made": self.context.tool_calls_made,
|
||||
},
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _fire_execution_log(self, user_input: str, result: AgentResult, start_time: float):
|
||||
"""Fire-and-forget 记录执行日志(非阻塞)。"""
|
||||
try:
|
||||
@@ -172,17 +334,49 @@ class AgentRuntime:
|
||||
self._llm_invocations = 0 # 每次 run() 重置 LLM 调用计数
|
||||
_run_start = time.time() # 执行开始时间,用于计算总延迟
|
||||
|
||||
# 1. 首次运行时加载长期记忆到 system prompt
|
||||
if not self._memory_context_loaded:
|
||||
# 1. 系统提示词分层装配(首次加载全部段,后续只刷新动态段)
|
||||
if self._prompt_sections_enabled:
|
||||
system_prompt = await self._compose_system_prompt(user_input)
|
||||
self.context.set_system_prompt(system_prompt)
|
||||
if not self._memory_context_loaded:
|
||||
self._memory_context_loaded = True
|
||||
logger.info("分层装配已完成(静态段 + 动态段)")
|
||||
elif not self._memory_context_loaded:
|
||||
await self._inject_memory_context(user_input)
|
||||
self._memory_context_loaded = True
|
||||
|
||||
# 1.5 知识检索增强:从知识库注入相关经验到 system prompt
|
||||
await self._inject_knowledge_context(user_input)
|
||||
await self._inject_knowledge_context(user_input)
|
||||
|
||||
# 2. 追加用户消息
|
||||
self.context.add_user_message(user_input)
|
||||
|
||||
# 2.5 计划模式 (P2) — 生成执行计划
|
||||
plan: Optional[Plan] = None
|
||||
if self.plan_mode and self.config.llm.plan_mode_enabled:
|
||||
try:
|
||||
plan = await self.plan_mode.generate_plan(
|
||||
user_input=user_input,
|
||||
available_tools=self.tool_manager.tool_names(),
|
||||
messages_history=self.context.messages,
|
||||
)
|
||||
logger.info("计划模式: 已生成计划 (%d 步骤)", len(plan.steps))
|
||||
if self.config.llm.plan_approval_required:
|
||||
approved = await self.plan_mode.present_plan(plan)
|
||||
if not approved:
|
||||
logger.info("计划模式: 计划被拒绝")
|
||||
result = AgentResult(
|
||||
success=False,
|
||||
content=f"计划已被拒绝。\n\n{plan.to_markdown()}",
|
||||
iterations_used=0,
|
||||
tool_calls_made=0,
|
||||
error="plan_rejected",
|
||||
)
|
||||
self._fire_execution_log(user_input, result, _run_start)
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("计划生成失败,回退到直接执行: %s", e)
|
||||
plan = None
|
||||
|
||||
# 3. ReAct 循环
|
||||
llm = _LLMClient(self.config.llm)
|
||||
tool_schemas = self.tool_manager.get_tool_schemas()
|
||||
@@ -194,6 +388,18 @@ class AgentRuntime:
|
||||
llm_callback_ctx = {"step_type": "think", "tool_name": None}
|
||||
|
||||
def _llm_callback(metrics: Dict[str, Any]):
|
||||
# Token 预算追踪 (P2)
|
||||
if self._token_budget:
|
||||
prompt_tok = metrics.get("prompt_tokens", 0)
|
||||
comp_tok = metrics.get("completion_tokens", 0)
|
||||
if prompt_tok <= 0:
|
||||
prompt_tok = self._token_budget.input_tokens # fallback estimate
|
||||
self._token_budget.record_llm_call(
|
||||
prompt_tokens=prompt_tok,
|
||||
completion_tokens=comp_tok,
|
||||
iteration=self.context.iteration,
|
||||
step_type=llm_callback_ctx["step_type"],
|
||||
)
|
||||
if self.on_llm_call:
|
||||
metrics.update({
|
||||
"session_id": self.context.session_id,
|
||||
@@ -206,6 +412,31 @@ class AgentRuntime:
|
||||
while self.context.iteration < max_iter:
|
||||
self.context.iteration += 1
|
||||
|
||||
# Token 预算检查:每次迭代前更新输入 token 估计
|
||||
if self._token_budget:
|
||||
self._token_budget.update_from_counter(self.context.messages)
|
||||
self._token_budget.reset_compaction_attempts()
|
||||
|
||||
# 对话自动压缩 (参考 Claude Code autoCompact) + Token 预算驱动压缩
|
||||
_should_compact = self.compaction_engine and self.context.iteration > 1
|
||||
if _should_compact and self._token_budget and self._token_budget.needs_compaction:
|
||||
self._token_budget.record_compaction_attempt()
|
||||
logger.info("TokenBudget 触发自动压缩: %s", self._token_budget.status_line)
|
||||
if self.compaction_engine and self.context.iteration > 1:
|
||||
compact_result = await self.compaction_engine.maybe_compact(
|
||||
self.context.messages,
|
||||
self.config.llm.context_window,
|
||||
)
|
||||
if compact_result.strategy != CompactionStrategy.NONE:
|
||||
self.context.replace_internal_messages(
|
||||
[m for m in compact_result.messages
|
||||
if m.get("role") != "system"] # 去掉 system(由 context 管理)
|
||||
)
|
||||
logger.debug(
|
||||
"压缩完成: strategy=%s saved=%d tokens",
|
||||
compact_result.strategy.value, compact_result.tokens_saved,
|
||||
)
|
||||
|
||||
# 裁剪过长历史
|
||||
messages = self.memory.trim_messages(self.context.messages)
|
||||
|
||||
@@ -221,6 +452,7 @@ class AgentRuntime:
|
||||
tool_calls_made=self.context.tool_calls_made,
|
||||
steps=steps, error=err)
|
||||
self._fire_execution_log(user_input, result, _run_start)
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
|
||||
# 调用外部 LLM 预算回调(WorkflowEngine 注入,将 Agent 的 LLM 计入工作流预算)
|
||||
@@ -237,6 +469,7 @@ class AgentRuntime:
|
||||
tool_calls_made=self.context.tool_calls_made,
|
||||
steps=steps, error=str(e))
|
||||
self._fire_execution_log(user_input, result, _run_start)
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
|
||||
# 调用 LLM
|
||||
@@ -267,6 +500,7 @@ class AgentRuntime:
|
||||
error=err_str,
|
||||
)
|
||||
self._fire_execution_log(user_input, result, _run_start)
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
|
||||
# 记录 LLM 调用次数(内部计数)
|
||||
@@ -337,6 +571,7 @@ class AgentRuntime:
|
||||
steps=steps,
|
||||
)
|
||||
self._fire_execution_log(user_input, result, _run_start)
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
|
||||
# 有工具调用 → 先记录 assistant 消息(含 tool_calls)
|
||||
@@ -380,6 +615,26 @@ class AgentRuntime:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
targs = {}
|
||||
|
||||
# Hook: PreToolUse — 可拦截/修改工具调用
|
||||
hook_ctx = HookContext(
|
||||
event=HookEvent.PRE_TOOL_USE,
|
||||
tool_name=tname,
|
||||
tool_input=targs,
|
||||
session_id=self.context.session_id,
|
||||
agent_name=self.config.name,
|
||||
user_id=self.config.user_id,
|
||||
)
|
||||
hook_res = await self.hook_manager.trigger(HookEvent.PRE_TOOL_USE, hook_ctx)
|
||||
if not hook_res.allowed:
|
||||
result = json.dumps({"error": hook_res.reason}, ensure_ascii=False)
|
||||
self.context.add_tool_result(tcid, tname, result)
|
||||
continue
|
||||
if hook_res.modified_input:
|
||||
targs = hook_res.modified_input
|
||||
# 审批检查需要原始参数,所以审批在前;但如果 hook 改了参数,需要重新构建
|
||||
if hook_res.modified_input and tname in self.config.tools.require_approval:
|
||||
tfn["arguments"] = json.dumps(targs, ensure_ascii=False)
|
||||
|
||||
# 工具执行前审批检查
|
||||
if tname in self.config.tools.require_approval:
|
||||
from app.services.approval_manager import approval_manager as _am
|
||||
@@ -420,6 +675,21 @@ class AgentRuntime:
|
||||
self.context.add_tool_result(tcid, tname, result)
|
||||
self.context.tool_calls_made += 1
|
||||
|
||||
# Hook: PostToolUse — 工具执行后处理
|
||||
post_ctx = HookContext(
|
||||
event=HookEvent.POST_TOOL_USE,
|
||||
tool_name=tname,
|
||||
tool_input=targs,
|
||||
tool_output=result,
|
||||
session_id=self.context.session_id,
|
||||
agent_name=self.config.name,
|
||||
user_id=self.config.user_id,
|
||||
)
|
||||
await self.hook_manager.trigger(HookEvent.POST_TOOL_USE, post_ctx)
|
||||
|
||||
# 崩溃恢复快照 (P4)
|
||||
self._fire_recovery_snapshot()
|
||||
|
||||
# 预算检查:工具调用次数
|
||||
if self.context.tool_calls_made > budget.max_tool_calls:
|
||||
err = f"已超过工具调用预算({budget.max_tool_calls} 次)"
|
||||
@@ -431,6 +701,7 @@ class AgentRuntime:
|
||||
tool_calls_made=self.context.tool_calls_made,
|
||||
steps=steps, error=err)
|
||||
self._fire_execution_log(user_input, result, _run_start)
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
|
||||
if self.on_tool_executed:
|
||||
@@ -484,11 +755,32 @@ class AgentRuntime:
|
||||
error=truncation_msg,
|
||||
)
|
||||
self._fire_execution_log(user_input, result, _run_start)
|
||||
self._attach_token_usage(result)
|
||||
return result
|
||||
|
||||
async def run_stream(self, user_input: str) -> AsyncGenerator[dict, None]:
|
||||
"""
|
||||
流式执行 Agent 单轮对话。
|
||||
流式执行 Agent 单轮对话(支持 streamlined 模式)。
|
||||
|
||||
与 run() 逻辑相同,但在每个关键步骤 yield SSE 事件。
|
||||
当 streamlined=True 时,工具调用会被折叠为累计摘要。
|
||||
"""
|
||||
if self._streamlined_transformer:
|
||||
self._streamlined_transformer.reset()
|
||||
async for event in self._run_stream_impl(user_input):
|
||||
transformed = self._streamlined_transformer.transform(event)
|
||||
if transformed is not None:
|
||||
yield transformed
|
||||
flushed = self._streamlined_transformer.flush()
|
||||
if flushed:
|
||||
yield flushed
|
||||
else:
|
||||
async for event in self._run_stream_impl(user_input):
|
||||
yield event
|
||||
|
||||
async def _run_stream_impl(self, user_input: str) -> AsyncGenerator[dict, None]:
|
||||
"""
|
||||
流式执行 Agent 单轮对话(内部实现)。
|
||||
|
||||
与 run() 逻辑相同,但在每个关键步骤 yield SSE 事件:
|
||||
- think: LLM 思考中,准备调用工具
|
||||
@@ -501,17 +793,63 @@ class AgentRuntime:
|
||||
self.context.iteration = 0
|
||||
self.context.tool_calls_made = 0
|
||||
|
||||
# 1. 首次运行时加载长期记忆到 system prompt
|
||||
if not self._memory_context_loaded:
|
||||
# 1. 系统提示词分层装配
|
||||
if self._prompt_sections_enabled:
|
||||
system_prompt = await self._compose_system_prompt(user_input)
|
||||
self.context.set_system_prompt(system_prompt)
|
||||
if not self._memory_context_loaded:
|
||||
self._memory_context_loaded = True
|
||||
logger.info("分层装配已完成(静态段 + 动态段)")
|
||||
elif not self._memory_context_loaded:
|
||||
await self._inject_memory_context(user_input)
|
||||
self._memory_context_loaded = True
|
||||
|
||||
# 1.5 知识检索增强:从知识库注入相关经验到 system prompt
|
||||
await self._inject_knowledge_context(user_input)
|
||||
await self._inject_knowledge_context(user_input)
|
||||
|
||||
# 2. 追加用户消息
|
||||
self.context.add_user_message(user_input)
|
||||
|
||||
# 2.5 计划模式 (P2) — 流式生成执行计划
|
||||
plan: Optional[Plan] = None
|
||||
if self.plan_mode and self.config.llm.plan_mode_enabled:
|
||||
yield {"type": "plan_generating", "content": "正在生成执行计划…", "iteration": 0}
|
||||
try:
|
||||
plan = await self.plan_mode.generate_plan(
|
||||
user_input=user_input,
|
||||
available_tools=self.tool_manager.tool_names(),
|
||||
messages_history=self.context.messages,
|
||||
)
|
||||
logger.info("计划模式: 已生成计划 (%d 步骤)", len(plan.steps))
|
||||
yield {
|
||||
"type": "plan",
|
||||
"content": plan.to_markdown(),
|
||||
"plan_data": plan.to_dict(),
|
||||
"iteration": 0,
|
||||
"session_id": self.context.session_id,
|
||||
}
|
||||
if self.config.llm.plan_approval_required:
|
||||
# 等待外部审批(通过 on_approval_required 回调)
|
||||
approved = await self.plan_mode.present_plan(plan)
|
||||
if not approved:
|
||||
logger.info("计划模式: 计划被拒绝")
|
||||
yield {
|
||||
"type": "plan_rejected",
|
||||
"content": "计划已被拒绝",
|
||||
"plan_data": plan.to_dict(),
|
||||
"iteration": 0,
|
||||
"session_id": self.context.session_id,
|
||||
}
|
||||
return
|
||||
yield {
|
||||
"type": "plan_approved",
|
||||
"content": "计划已批准,开始执行",
|
||||
"iteration": 0,
|
||||
"session_id": self.context.session_id,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("计划生成失败,回退到直接执行: %s", e)
|
||||
yield {"type": "plan_failed", "content": f"计划生成失败: {e}", "iteration": 0}
|
||||
plan = None
|
||||
|
||||
# 3. ReAct 循环
|
||||
llm = _LLMClient(self.config.llm)
|
||||
tool_schemas = self.tool_manager.get_tool_schemas()
|
||||
@@ -522,6 +860,18 @@ class AgentRuntime:
|
||||
llm_callback_ctx = {"step_type": "think", "tool_name": None}
|
||||
|
||||
def _llm_callback(metrics: Dict[str, Any]):
|
||||
# Token 预算追踪 (P2)
|
||||
if self._token_budget:
|
||||
prompt_tok = metrics.get("prompt_tokens", 0)
|
||||
comp_tok = metrics.get("completion_tokens", 0)
|
||||
if prompt_tok <= 0:
|
||||
prompt_tok = self._token_budget.input_tokens # fallback estimate
|
||||
self._token_budget.record_llm_call(
|
||||
prompt_tokens=prompt_tok,
|
||||
completion_tokens=comp_tok,
|
||||
iteration=self.context.iteration,
|
||||
step_type=llm_callback_ctx["step_type"],
|
||||
)
|
||||
if self.on_llm_call:
|
||||
metrics.update({
|
||||
"session_id": self.context.session_id,
|
||||
@@ -533,6 +883,31 @@ class AgentRuntime:
|
||||
|
||||
while self.context.iteration < max_iter:
|
||||
self.context.iteration += 1
|
||||
|
||||
# Token 预算检查:每次迭代前更新输入 token 估计
|
||||
if self._token_budget:
|
||||
self._token_budget.update_from_counter(self.context.messages)
|
||||
self._token_budget.reset_compaction_attempts()
|
||||
|
||||
# 对话自动压缩 (参考 Claude Code autoCompact) + Token 预算驱动压缩
|
||||
if self.compaction_engine and self.context.iteration > 1:
|
||||
if self._token_budget and self._token_budget.needs_compaction:
|
||||
self._token_budget.record_compaction_attempt()
|
||||
logger.info("TokenBudget 触发自动压缩: %s", self._token_budget.status_line)
|
||||
compact_result = await self.compaction_engine.maybe_compact(
|
||||
self.context.messages,
|
||||
self.config.llm.context_window,
|
||||
)
|
||||
if compact_result.strategy != CompactionStrategy.NONE:
|
||||
self.context.replace_internal_messages(
|
||||
[m for m in compact_result.messages
|
||||
if m.get("role") != "system"]
|
||||
)
|
||||
logger.debug(
|
||||
"压缩完成: strategy=%s saved=%d tokens",
|
||||
compact_result.strategy.value, compact_result.tokens_saved,
|
||||
)
|
||||
|
||||
messages = self.memory.trim_messages(self.context.messages)
|
||||
|
||||
# 预算检查:LLM 调用次数(在调用 LLM 之前检查,避免浪费额度)
|
||||
@@ -626,6 +1001,7 @@ class AgentRuntime:
|
||||
self.context.add_user_message(fix_prompt)
|
||||
continue # 回到 ReAct 循环,让 LLM 修正
|
||||
|
||||
token_usage_final = self._token_budget.summary() if self._token_budget else None
|
||||
yield {
|
||||
"type": "final",
|
||||
"content": final_text,
|
||||
@@ -634,6 +1010,7 @@ class AgentRuntime:
|
||||
"iterations_used": self.context.iteration,
|
||||
"tool_calls_made": self.context.tool_calls_made,
|
||||
"session_id": self.context.session_id,
|
||||
"token_usage": token_usage_final,
|
||||
}
|
||||
await self.memory.save_context(user_input, final_text, self.context.messages)
|
||||
# 保存学习模式
|
||||
@@ -695,6 +1072,24 @@ class AgentRuntime:
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
targs = {}
|
||||
|
||||
# Hook: PreToolUse — 可拦截/修改工具调用 (流式)
|
||||
hook_ctx = HookContext(
|
||||
event=HookEvent.PRE_TOOL_USE,
|
||||
tool_name=tname,
|
||||
tool_input=targs,
|
||||
session_id=self.context.session_id,
|
||||
agent_name=self.config.name,
|
||||
user_id=self.config.user_id,
|
||||
)
|
||||
hook_res = await self.hook_manager.trigger(HookEvent.PRE_TOOL_USE, hook_ctx)
|
||||
if not hook_res.allowed:
|
||||
result = json.dumps({"error": hook_res.reason}, ensure_ascii=False)
|
||||
yield {"type": "tool_result", "name": tname, "result": result, "iteration": self.context.iteration}
|
||||
self.context.add_tool_result(tcid, tname, result)
|
||||
continue
|
||||
if hook_res.modified_input:
|
||||
targs = hook_res.modified_input
|
||||
|
||||
# yield tool_call 事件
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
@@ -760,6 +1155,21 @@ class AgentRuntime:
|
||||
self.context.add_tool_result(tcid, tname, result)
|
||||
self.context.tool_calls_made += 1
|
||||
|
||||
# Hook: PostToolUse — 工具执行后处理 (流式)
|
||||
post_ctx = HookContext(
|
||||
event=HookEvent.POST_TOOL_USE,
|
||||
tool_name=tname,
|
||||
tool_input=targs,
|
||||
tool_output=result,
|
||||
session_id=self.context.session_id,
|
||||
agent_name=self.config.name,
|
||||
user_id=self.config.user_id,
|
||||
)
|
||||
await self.hook_manager.trigger(HookEvent.POST_TOOL_USE, post_ctx)
|
||||
|
||||
# 崩溃恢复快照 (P4)
|
||||
self._fire_recovery_snapshot()
|
||||
|
||||
# 预算检查:工具调用次数
|
||||
if self.context.tool_calls_made > budget.max_tool_calls:
|
||||
err = f"已超过工具调用预算({budget.max_tool_calls} 次)"
|
||||
@@ -783,6 +1193,15 @@ class AgentRuntime:
|
||||
data={"tool_name": tname, "result_preview": preview},
|
||||
)
|
||||
|
||||
# Hook: Stop — 对话完成
|
||||
stop_ctx = HookContext(
|
||||
event=HookEvent.STOP,
|
||||
session_id=self.context.session_id,
|
||||
agent_name=self.config.name,
|
||||
user_id=self.config.user_id,
|
||||
)
|
||||
await self.hook_manager.trigger(HookEvent.STOP, stop_ctx)
|
||||
|
||||
# 达到最大迭代次数
|
||||
last_content = ""
|
||||
for m in reversed(self.context.messages):
|
||||
@@ -802,6 +1221,7 @@ class AgentRuntime:
|
||||
# 提取知识到全局知识池(即便截断,工具调用序列仍有参考价值)
|
||||
if last_content:
|
||||
await self._extract_global_knowledge(user_input, last_content, steps)
|
||||
token_usage_truncated = self._token_budget.summary() if self._token_budget else None
|
||||
yield {
|
||||
"type": "final",
|
||||
"content": last_content or "已达最大迭代次数,但模型未返回最终回答。",
|
||||
@@ -810,8 +1230,123 @@ class AgentRuntime:
|
||||
"tool_calls_made": self.context.tool_calls_made,
|
||||
"truncated": True,
|
||||
"session_id": self.context.session_id,
|
||||
"token_usage": token_usage_truncated,
|
||||
}
|
||||
|
||||
async def _compose_system_prompt(self, query: str = "") -> str:
|
||||
"""使用分层装配构建完整系统提示词。
|
||||
|
||||
将静态段 + 动态段并行解析后拼接,替代原先的字符串拼接方式。
|
||||
返回最终的 system_prompt 字符串。
|
||||
"""
|
||||
if not self._prompt_composer:
|
||||
# 降级:使用原有字符串拼接方式
|
||||
enriched = self.config.system_prompt.rstrip("\n")
|
||||
mem_text = await self.memory.initialize(query=query)
|
||||
if mem_text:
|
||||
enriched += "\n\n" + mem_text
|
||||
if self.config.memory.learning_enabled:
|
||||
pattern_hint = await self._inject_learning_patterns(query)
|
||||
if pattern_hint:
|
||||
enriched += "\n\n" + pattern_hint
|
||||
if self._memdir and self._memdir_manifest:
|
||||
memdir_text = await self._inject_memdir_context(query)
|
||||
if memdir_text:
|
||||
enriched += "\n\n" + memdir_text
|
||||
try:
|
||||
enriched = knowledge_retriever.inject_knowledge(enriched, query)
|
||||
except Exception:
|
||||
pass
|
||||
return enriched
|
||||
|
||||
# 分层装配路径
|
||||
ps_config = self.config.prompt_sections
|
||||
d_switches = ps_config.dynamic_sections
|
||||
|
||||
# 清除上一次运行的动态段
|
||||
# (静态段保留缓存,动态段每次重算)
|
||||
self._prompt_composer._dynamic_sections.clear()
|
||||
|
||||
# 动态段:环境信息
|
||||
if d_switches.get("environment", True):
|
||||
self._prompt_composer.add_dynamic(PromptSection(
|
||||
"environment",
|
||||
lambda uid=self.config.user_id: section_environment(uid),
|
||||
cache_break=True,
|
||||
))
|
||||
|
||||
# 动态段:语言偏好
|
||||
if d_switches.get("language", True):
|
||||
lang = ps_config.language
|
||||
if lang:
|
||||
self._prompt_composer.add_dynamic(PromptSection(
|
||||
"language",
|
||||
lambda l=lang: section_language(l),
|
||||
cache_break=False,
|
||||
))
|
||||
|
||||
# 动态段:长期记忆上下文
|
||||
if d_switches.get("memory_context", True):
|
||||
mem_text = await self.memory.initialize(query=query)
|
||||
if mem_text:
|
||||
self._prompt_composer.add_dynamic(PromptSection(
|
||||
"memory_context",
|
||||
lambda t=mem_text: f"# Long-term Memory\n\n{t}",
|
||||
cache_break=True,
|
||||
))
|
||||
|
||||
# 动态段:学习模式提示
|
||||
if self.config.memory.learning_enabled:
|
||||
pattern_hint = await self._inject_learning_patterns(query)
|
||||
if pattern_hint:
|
||||
self._prompt_composer.add_dynamic(PromptSection(
|
||||
"learning_patterns",
|
||||
lambda p=pattern_hint: p,
|
||||
cache_break=True,
|
||||
))
|
||||
|
||||
# 动态段:文件式记忆
|
||||
if self._memdir and self._memdir_manifest:
|
||||
memdir_text = await self._inject_memdir_context(query)
|
||||
if memdir_text:
|
||||
self._prompt_composer.add_dynamic(PromptSection(
|
||||
"memdir",
|
||||
lambda t=memdir_text: t,
|
||||
cache_break=True,
|
||||
))
|
||||
|
||||
# 动态段:知识库检索
|
||||
if d_switches.get("memory_context", True):
|
||||
try:
|
||||
base_enriched = knowledge_retriever.inject_knowledge(
|
||||
self.config.system_prompt, query
|
||||
)
|
||||
if base_enriched != self.config.system_prompt:
|
||||
# 提取增量部分
|
||||
knowledge_delta = base_enriched[len(self.config.system_prompt):].strip()
|
||||
if knowledge_delta:
|
||||
self._prompt_composer.add_dynamic(PromptSection(
|
||||
"knowledge_base",
|
||||
lambda kd=knowledge_delta: f"# Relevant Knowledge\n\n{kd}",
|
||||
cache_break=True,
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 工具列表段(默认关闭,太长)
|
||||
if d_switches.get("tool_list", False):
|
||||
tool_names = self.tool_manager.tool_names()
|
||||
if tool_names:
|
||||
tool_list_text = "\n".join(f"- {n}" for n in sorted(tool_names))
|
||||
self._prompt_composer.add_dynamic(PromptSection(
|
||||
"tool_list",
|
||||
lambda t=tool_list_text: f"# Available Tools\n\n{t}",
|
||||
cache_break=False,
|
||||
))
|
||||
|
||||
# 解析 + 装配
|
||||
return await self._prompt_composer.assemble_full()
|
||||
|
||||
async def _inject_memory_context(self, query: str = "") -> None:
|
||||
"""加载长期记忆并注入 system prompt。"""
|
||||
mem_text = await self.memory.initialize(query=query)
|
||||
@@ -826,9 +1361,65 @@ class AgentRuntime:
|
||||
if pattern_hint:
|
||||
enriched += "\n\n" + pattern_hint
|
||||
|
||||
# 注入文件式记忆 (MEMORY.md)
|
||||
if self._memdir and self._memdir_manifest:
|
||||
memdir_text = await self._inject_memdir_context(query)
|
||||
if memdir_text:
|
||||
enriched += "\n\n" + memdir_text
|
||||
|
||||
self.context.set_system_prompt(enriched)
|
||||
logger.info("Agent 已注入长期记忆上下文")
|
||||
|
||||
async def _inject_memdir_context(self, query: str) -> str:
|
||||
"""加载文件式记忆并构建注入文本。"""
|
||||
if not self._memdir or not self._memdir_manifest:
|
||||
return ""
|
||||
|
||||
parts: List[str] = []
|
||||
|
||||
# 记忆操作指导(首次注入)
|
||||
memdir_prompt = self._memdir.build_system_prompt()
|
||||
parts.append(memdir_prompt)
|
||||
|
||||
# AI 驱动的相关性选择
|
||||
if self._memdir_manifest.entries:
|
||||
try:
|
||||
selected = await memory_selector.select(
|
||||
query=query,
|
||||
manifest=self._memdir_manifest,
|
||||
recent_tools=self.tool_manager.tool_names(),
|
||||
)
|
||||
if selected:
|
||||
# 读取选中的记忆文件
|
||||
parts.append("\n## 相关记忆\n")
|
||||
for fn in selected:
|
||||
entry = next(
|
||||
(e for e in self._memdir_manifest.entries
|
||||
if e.filename == fn), None
|
||||
)
|
||||
if entry:
|
||||
# 加载完整内容
|
||||
try:
|
||||
with open(entry.filepath, "r", encoding="utf-8") as _f:
|
||||
_, content = parse_frontmatter(_f.read())
|
||||
except Exception:
|
||||
content = entry.content
|
||||
if not content:
|
||||
content = entry.content
|
||||
staleness = entry.staleness_note
|
||||
parts.append(
|
||||
f"<system-reminder>\n"
|
||||
f"### [{entry.mem_type.value}] {entry.name}\n"
|
||||
f"{content[:2000]}"
|
||||
)
|
||||
if staleness:
|
||||
parts.append(f"\n{staleness}")
|
||||
parts.append("</system-reminder>")
|
||||
except Exception as e:
|
||||
logger.warning("AI 记忆选择失败: %s", e)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
async def _inject_learning_patterns(self, query: str) -> str:
|
||||
"""查询学习模式,返回格式化的提示文本。"""
|
||||
from app.core.database import SessionLocal
|
||||
@@ -1062,9 +1653,17 @@ class AgentRuntime:
|
||||
|
||||
@staticmethod
|
||||
def _is_retryable(err_str: str) -> bool:
|
||||
"""判断错误是否可重试。"""
|
||||
err_lower = err_str.lower()
|
||||
return any(kw in err_lower for kw in _RETRYABLE_ERRORS)
|
||||
"""判断错误是否可重试(使用 ErrorClassifier)。"""
|
||||
try:
|
||||
error_type, _ = _error_classifier.classify(Exception(err_str))
|
||||
return error_type == ErrorType.RETRYABLE
|
||||
except Exception:
|
||||
err_lower = err_str.lower()
|
||||
return any(kw in err_lower for kw in (
|
||||
"timed out", "timeout", "connection error",
|
||||
"rate limit", "too many requests", "internal server error",
|
||||
"service unavailable", "temporarily unavailable",
|
||||
))
|
||||
|
||||
|
||||
# LLM 缓存辅助
|
||||
@@ -1193,6 +1792,35 @@ class _LLMClient:
|
||||
response = await client.chat.completions.create(**kwargs)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
# Reactive Compact: 上下文超限时压缩后重试 (Tier 3)
|
||||
if (
|
||||
self.compaction_engine
|
||||
and is_context_length_error(e)
|
||||
and self.compaction_engine.config.reactive_compact_enabled
|
||||
):
|
||||
logger.warning("检测到上下文超限,触发 ReactiveCompact: %s", str(e)[:100])
|
||||
try:
|
||||
compact_result = await self.compaction_engine.reactive_compact(
|
||||
messages, e, self._config.context_window,
|
||||
)
|
||||
if compact_result.strategy != CompactionStrategy.NONE:
|
||||
logger.info(
|
||||
"ReactiveCompact 完成: saved=%d tokens, 重试中...",
|
||||
compact_result.tokens_saved,
|
||||
)
|
||||
return await self._do_chat(
|
||||
api_key=api_key, base_url=base_url,
|
||||
model=model,
|
||||
messages=compact_result.messages,
|
||||
tools=tools,
|
||||
iteration=iteration,
|
||||
on_completion=on_completion,
|
||||
_is_fallback=_is_fallback,
|
||||
)
|
||||
except Exception as ce:
|
||||
logger.error("ReactiveCompact 失败: %s", ce)
|
||||
|
||||
# 降级回退:主模型失败时尝试 fallback_llm
|
||||
fallback = self._config.fallback_llm
|
||||
if fallback and isinstance(fallback, dict) and not _is_fallback:
|
||||
|
||||
@@ -38,6 +38,12 @@ class AgentMemory:
|
||||
max_history: int = 20,
|
||||
vector_memory_enabled: bool = True,
|
||||
vector_memory_top_k: int = 5,
|
||||
vector_memory_rerank: bool = False,
|
||||
memory_type_filter: Optional[List[str]] = None,
|
||||
team_id: Optional[str] = None,
|
||||
team_share_enabled: bool = False,
|
||||
memory_dir_enabled: bool = False,
|
||||
memory_dir_path: str = "",
|
||||
):
|
||||
self.scope_kind = scope_kind
|
||||
self.scope_id = scope_id or "default"
|
||||
@@ -46,11 +52,28 @@ class AgentMemory:
|
||||
self.max_history = max_history
|
||||
self.vector_memory_enabled = vector_memory_enabled
|
||||
self.vector_memory_top_k = vector_memory_top_k
|
||||
self.vector_memory_rerank = vector_memory_rerank
|
||||
self.memory_type_filter = memory_type_filter # None = 全部类型
|
||||
self.team_id = team_id # 团队共享 ID
|
||||
self.team_share_enabled = team_share_enabled # 是否自动发布到团队池
|
||||
# 文件式记忆
|
||||
self.memory_dir_enabled = memory_dir_enabled
|
||||
self.memory_dir_path = memory_dir_path
|
||||
self._file_store = None # 延迟初始化
|
||||
# 记忆类型分类: user / feedback / project / reference
|
||||
self.MEMORY_TYPES = ("user", "feedback", "project", "reference")
|
||||
# 从长期记忆加载的上下文(启动时加载)
|
||||
self._long_term_context: Dict[str, Any] = {}
|
||||
# 记录已压缩的消息数,避免重复压缩
|
||||
self._last_compressed_msg_count = 0
|
||||
|
||||
def _get_file_store(self):
|
||||
"""延迟初始化文件记忆存储。"""
|
||||
if self._file_store is None and self.memory_dir_enabled:
|
||||
from app.services.file_memory_service import get_file_memory_store
|
||||
self._file_store = get_file_memory_store(self.memory_dir_path)
|
||||
return self._file_store
|
||||
|
||||
async def initialize(self, query: str = "") -> str:
|
||||
"""
|
||||
初始化记忆:从 DB/Redis 加载长期记忆 + 向量检索相关历史。
|
||||
@@ -95,7 +118,22 @@ class AgentMemory:
|
||||
if vector_text:
|
||||
parts.append(vector_text)
|
||||
|
||||
# 3. 全局知识检索:从 GlobalKnowledge 表加载相关条目
|
||||
# 3. P7 文件式记忆:从本地 MEMORY.md 加载
|
||||
store = self._get_file_store()
|
||||
if store and store.memory_count > 0 and query:
|
||||
file_results = store.search(query, top_k=3)
|
||||
if file_results:
|
||||
lines = ["## 文件记忆(本地 MEMORY.md)"]
|
||||
for i, r in enumerate(file_results, 1):
|
||||
mem_type = r.get("type", "reference")
|
||||
content = r.get("content", "")[:300]
|
||||
score = r.get("score", 0)
|
||||
lines.append(f"{i}. [{mem_type}] {content}")
|
||||
if score < 1.0:
|
||||
lines[-1] += f" (匹配度: {score:.2f})"
|
||||
parts.append("\n".join(lines))
|
||||
|
||||
# 4. 全局知识检索:从 GlobalKnowledge 表加载相关条目
|
||||
global_text = await self._global_knowledge_search(query)
|
||||
if global_text:
|
||||
parts.append(global_text)
|
||||
@@ -106,6 +144,7 @@ class AgentMemory:
|
||||
"""
|
||||
向量检索语义相关的历史记忆,返回格式化的文本块。
|
||||
若无 query 则返回最近 Top-5 条记忆。
|
||||
支持 memory_type_filter 按类型过滤 + LLM Rerank 精选。
|
||||
"""
|
||||
from app.models.agent_vector_memory import AgentVectorMemory
|
||||
|
||||
@@ -113,22 +152,46 @@ class AgentMemory:
|
||||
try:
|
||||
db = SessionLocal()
|
||||
# 查询当前 scope 的所有向量记忆(按时间倒序)
|
||||
rows = (
|
||||
query_builder = (
|
||||
db.query(AgentVectorMemory)
|
||||
.filter(
|
||||
AgentVectorMemory.scope_kind == self.scope_kind,
|
||||
AgentVectorMemory.scope_id == self.scope_id,
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
query_builder
|
||||
.order_by(AgentVectorMemory.created_at.desc())
|
||||
.limit(50) # 最多取最近 50 条做相似度计算
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
|
||||
# P6 团队共享:同时查询团队记忆池
|
||||
if self.team_id:
|
||||
team_rows = (
|
||||
db.query(AgentVectorMemory)
|
||||
.filter(
|
||||
AgentVectorMemory.scope_kind == "team",
|
||||
AgentVectorMemory.scope_id == self.team_id,
|
||||
)
|
||||
.order_by(AgentVectorMemory.created_at.desc())
|
||||
.limit(30)
|
||||
.all()
|
||||
)
|
||||
rows = list(rows) + list(team_rows)
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
entries: List[VectorEntry] = []
|
||||
for row in rows:
|
||||
# 类型过滤(memory_type_filter 不为空时生效)
|
||||
meta = row.metadata_ or {}
|
||||
row_memory_type = meta.get("memory_type", meta.get("type", "conversation_turn"))
|
||||
if self.memory_type_filter:
|
||||
if row_memory_type not in self.memory_type_filter:
|
||||
continue
|
||||
|
||||
emb = embedding_service.deserialize_embedding(row.embedding) if row.embedding else []
|
||||
entries.append({
|
||||
"id": row.id,
|
||||
@@ -136,17 +199,35 @@ class AgentMemory:
|
||||
"scope_id": row.scope_id,
|
||||
"content_text": row.content_text,
|
||||
"embedding": emb,
|
||||
"metadata": row.metadata_ or {},
|
||||
"metadata": meta,
|
||||
})
|
||||
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
matched: List[VectorEntry] = []
|
||||
|
||||
if query and query.strip():
|
||||
# 有 query:生成 embedding 做语义搜索
|
||||
query_emb = await embedding_service.generate_embedding(query)
|
||||
if query_emb:
|
||||
matched = await embedding_service.similarity_search(
|
||||
query_emb, entries, top_k=self.vector_memory_top_k
|
||||
# 向量检索取 top_k * 4 候选(为 rerank 留余量),最少 20 条
|
||||
candidate_k = max(20, self.vector_memory_top_k * 4)
|
||||
candidates = await embedding_service.similarity_search(
|
||||
query_emb, entries, top_k=min(candidate_k, len(entries))
|
||||
)
|
||||
|
||||
# LLM Rerank:向量粗筛 → LLM 精选
|
||||
if self.vector_memory_rerank and len(candidates) > self.vector_memory_top_k:
|
||||
matched = await self._llm_rerank(query, candidates)
|
||||
|
||||
if not matched:
|
||||
matched = candidates[: self.vector_memory_top_k]
|
||||
else:
|
||||
# P5 离线兜底:Embedding API 不可用时降级为关键词匹配
|
||||
logger.info("Embedding 不可用,降级为离线关键词匹配")
|
||||
matched = embedding_service.keyword_search(
|
||||
query, entries, top_k=self.vector_memory_top_k, min_score=0.05,
|
||||
)
|
||||
else:
|
||||
# 无 query:返回最近几条
|
||||
@@ -162,8 +243,14 @@ class AgentMemory:
|
||||
for i, m in enumerate(matched, 1):
|
||||
text = m.get("content_text", "")[:500]
|
||||
meta = m.get("metadata", {})
|
||||
entry_type = meta.get("type", "对话")
|
||||
lines.append(f"{i}. [{entry_type}] {text}")
|
||||
mem_type = meta.get("memory_type", meta.get("type", "对话"))
|
||||
scope_kind = m.get("scope_kind", "")
|
||||
# 标注团队共享来源
|
||||
source_tag = ""
|
||||
if scope_kind == "team":
|
||||
shared_by = meta.get("shared_by", meta.get("source_scope", "unknown"))
|
||||
source_tag = f" [团队共享]"
|
||||
lines.append(f"{i}. [{mem_type}]{source_tag} {text}")
|
||||
if m.get("score", 1.0) < 1.0:
|
||||
lines[-1] += f" (匹配度: {m['score']:.2f})"
|
||||
|
||||
@@ -176,6 +263,84 @@ class AgentMemory:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
async def _llm_rerank(
|
||||
self, query: str, candidates: List[VectorEntry],
|
||||
) -> List[VectorEntry]:
|
||||
"""
|
||||
LLM Rerank:用轻量模型对向量粗筛结果打分排序,返回精选 top-K。
|
||||
|
||||
流程:取向量检索 top-N 候选 → LLM 按与 query 相关性打分 (1-10)
|
||||
→ 取 top-K 高分结果。失败时降级返回原始排序。
|
||||
"""
|
||||
from openai import AsyncOpenAI
|
||||
from app.core.config import settings
|
||||
|
||||
if not candidates or len(candidates) <= self.vector_memory_top_k:
|
||||
return candidates[: self.vector_memory_top_k]
|
||||
|
||||
try:
|
||||
# 构建候选列表
|
||||
items_text = []
|
||||
for idx, c in enumerate(candidates):
|
||||
content = c.get("content_text", "")[:300]
|
||||
mem_type = c.get("metadata", {}).get("memory_type", "unknown")
|
||||
items_text.append(f"[{idx}] [{mem_type}] {content}")
|
||||
|
||||
rerank_prompt = (
|
||||
"你是一个记忆检索排序助手。请根据用户查询,对以下记忆条目按相关性打分(1-10分)。\n"
|
||||
"只输出 JSON 数组,每个元素包含 index 和 score,按 score 降序排列。\n"
|
||||
"只保留 score >= 4 的结果。最多返回 {} 条。\n\n"
|
||||
"用户查询: {}\n\n记忆条目:\n{}"
|
||||
).format(
|
||||
self.vector_memory_top_k,
|
||||
query[:500],
|
||||
"\n".join(items_text),
|
||||
)
|
||||
|
||||
api_key = settings.DEEPSEEK_API_KEY or settings.OPENAI_API_KEY or ""
|
||||
base_url = settings.DEEPSEEK_BASE_URL or settings.OPENAI_BASE_URL or "https://api.deepseek.com"
|
||||
if api_key == "your-openai-api-key":
|
||||
api_key = settings.DEEPSEEK_API_KEY or ""
|
||||
base_url = settings.DEEPSEEK_BASE_URL or "https://api.deepseek.com"
|
||||
|
||||
if not api_key:
|
||||
return candidates[: self.vector_memory_top_k]
|
||||
|
||||
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
|
||||
resp = await client.chat.completions.create(
|
||||
model="deepseek-v4-flash",
|
||||
messages=[{"role": "user", "content": rerank_prompt}],
|
||||
temperature=0.1,
|
||||
max_tokens=512,
|
||||
timeout=15,
|
||||
)
|
||||
raw = resp.choices[0].message.content or ""
|
||||
raw = raw.strip().removeprefix("```json").removesuffix("```").strip()
|
||||
|
||||
import json
|
||||
scored = json.loads(raw)
|
||||
if not isinstance(scored, list):
|
||||
return candidates[: self.vector_memory_top_k]
|
||||
|
||||
# 按 score 排序取 top-K
|
||||
scored.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
result: List[VectorEntry] = []
|
||||
for item in scored[: self.vector_memory_top_k]:
|
||||
idx = item.get("index", -1)
|
||||
if 0 <= idx < len(candidates):
|
||||
candidates[idx]["score"] = float(item.get("score", 5.0)) / 10.0
|
||||
result.append(candidates[idx])
|
||||
|
||||
if result:
|
||||
logger.info("LLM Rerank: %d 候选 → %d 精选", len(candidates), len(result))
|
||||
return result
|
||||
|
||||
return candidates[: self.vector_memory_top_k]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("LLM Rerank 失败,使用向量排序: %s", e)
|
||||
return candidates[: self.vector_memory_top_k]
|
||||
|
||||
async def _global_knowledge_search(self, query: str = "") -> str:
|
||||
"""从 GlobalKnowledge 表检索相关的全局知识条目。"""
|
||||
from datetime import datetime
|
||||
@@ -340,33 +505,52 @@ class AgentMemory:
|
||||
self, user_message: str, assistant_reply: str,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""将单轮对话保存到长期记忆。如有消息列表,LLM 自动压缩总结。"""
|
||||
"""将单轮对话保存到长期记忆。
|
||||
|
||||
快速路径(同步完成):向量记忆写入 + 基础上下文更新。
|
||||
慢速路径(fire-and-forget):LLM 压缩总结 → persistent_memory 更新。
|
||||
后台压缩不阻塞对话响应。
|
||||
"""
|
||||
if not self.persist or not self.scope_id:
|
||||
return
|
||||
|
||||
# 更新上下文
|
||||
# 快速:更新基础上下文
|
||||
ctx = self._long_term_context.get("context", {})
|
||||
ctx["last_user_message"] = user_message[:500]
|
||||
ctx["last_assistant_reply"] = assistant_reply[:500]
|
||||
self._long_term_context["context"] = ctx
|
||||
|
||||
# 如果有完整消息列表且新增了足够多的消息,运行 LLM 压缩总结
|
||||
# 后台:LLM 压缩总结(fire-and-forget,不阻塞主对话)
|
||||
if messages and len(messages) > self._last_compressed_msg_count + 2:
|
||||
await self._compress_and_summarize(messages)
|
||||
self._last_compressed_msg_count = len(messages)
|
||||
import asyncio as _asyncio
|
||||
_asyncio.ensure_future(self._background_compress_and_save(messages))
|
||||
|
||||
db: Optional[Session] = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
# 快速:保存基础上下文到 persistent_memory(后续后台压缩会覆盖更新)
|
||||
save_persistent_memory(
|
||||
db, self.scope_kind, self.scope_id,
|
||||
self.session_key, self._long_term_context,
|
||||
)
|
||||
|
||||
# 保存向量记忆(异步生成 embedding 并存储)
|
||||
# 快速:保存向量记忆
|
||||
if self.vector_memory_enabled:
|
||||
mem_type = self._infer_memory_type(user_message, assistant_reply)
|
||||
await self._save_vector_memory(
|
||||
db, user_message, assistant_reply
|
||||
db, user_message, assistant_reply, memory_type=mem_type,
|
||||
)
|
||||
|
||||
# P7 文件式记忆兜底:同步写入本地 MEMORY.md
|
||||
store = self._get_file_store()
|
||||
if store:
|
||||
mem_type = self._infer_memory_type(user_message, assistant_reply)
|
||||
content = f"用户: {user_message[:300]}\n助手: {assistant_reply[:300]}"
|
||||
store.save(
|
||||
name=f"{self.scope_id}_{self.session_key}_{len(ctx)}",
|
||||
content=content,
|
||||
mem_type=mem_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("保存长期记忆失败: %s", e)
|
||||
@@ -376,6 +560,7 @@ class AgentMemory:
|
||||
|
||||
async def _save_vector_memory(
|
||||
self, db: Session, user_message: str, assistant_reply: str,
|
||||
memory_type: str = "conversation_turn",
|
||||
) -> None:
|
||||
"""生成 embedding 并保存到向量记忆表。"""
|
||||
from app.models.agent_vector_memory import AgentVectorMemory
|
||||
@@ -396,16 +581,66 @@ class AgentMemory:
|
||||
content_text=content_text[:2000],
|
||||
embedding=embedding_json or None,
|
||||
metadata_={
|
||||
"type": "conversation_turn",
|
||||
"type": memory_type,
|
||||
"memory_type": memory_type,
|
||||
},
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
logger.debug("已保存向量记忆 (scope=%s/%s)", self.scope_kind, self.scope_id)
|
||||
|
||||
# P6 团队共享:自动将记忆副本发布到团队池
|
||||
if self.team_id and self.team_share_enabled:
|
||||
try:
|
||||
team_record = AgentVectorMemory(
|
||||
scope_kind="team",
|
||||
scope_id=self.team_id,
|
||||
session_key=self.session_key,
|
||||
content_text=content_text[:2000],
|
||||
embedding=embedding_json or None,
|
||||
metadata_={
|
||||
"type": memory_type,
|
||||
"memory_type": memory_type,
|
||||
"source_scope": f"{self.scope_kind}/{self.scope_id}",
|
||||
"shared_by": self.scope_id,
|
||||
},
|
||||
)
|
||||
db.add(team_record)
|
||||
db.commit()
|
||||
logger.debug("已同步到团队记忆池 (team=%s)", self.team_id)
|
||||
except Exception:
|
||||
db.rollback() # 团队同步失败不影响主流程
|
||||
|
||||
logger.debug("已保存向量记忆 (scope=%s/%s, type=%s)", self.scope_kind, self.scope_id, memory_type)
|
||||
except Exception as e:
|
||||
logger.warning("保存向量记忆失败: %s", e)
|
||||
db.rollback()
|
||||
|
||||
async def _background_compress_and_save(
|
||||
self, messages: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
"""
|
||||
后台异步:LLM 压缩总结 + 写入 persistent_memory。
|
||||
从 save_context 中 fire-and-forget 调用,不阻塞对话响应。
|
||||
"""
|
||||
try:
|
||||
await self._compress_and_summarize(messages)
|
||||
|
||||
# 将压缩更新后的长期上下文写回 DB
|
||||
db: Optional[Session] = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
save_persistent_memory(
|
||||
db, self.scope_kind, self.scope_id,
|
||||
self.session_key, self._long_term_context,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("后台压缩保存 persistent_memory 失败: %s", e)
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning("后台压缩总结失败: %s", e)
|
||||
|
||||
async def _compress_and_summarize(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
@@ -506,11 +741,71 @@ class AgentMemory:
|
||||
"updated" if new_profile else "unchanged",
|
||||
len(new_facts), len(topics))
|
||||
|
||||
# P1: 将压缩摘要向量化写入 AgentVectorMemory,使其可被语义检索
|
||||
await self._save_compressed_memories(summary, new_facts, topics)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("记忆压缩:LLM 返回非 JSON 格式,跳过")
|
||||
except Exception as e:
|
||||
logger.warning("记忆压缩失败: %s", e)
|
||||
|
||||
async def _save_compressed_memories(
|
||||
self, summary: str, facts: List[str], topics: List[str],
|
||||
) -> None:
|
||||
"""
|
||||
将 LLM 压缩总结的结果向量化写入 AgentVectorMemory。
|
||||
每个 fact/summary/topic 单独写入,标注 memory_type=project(来自对话压缩)。
|
||||
失败不影响主流程。
|
||||
"""
|
||||
from app.models.agent_vector_memory import AgentVectorMemory
|
||||
|
||||
memories_to_save: List[tuple] = [] # (content, memory_type)
|
||||
|
||||
if summary:
|
||||
memories_to_save.append((f"[对话摘要] {summary[:1500]}", "project"))
|
||||
for fact in facts:
|
||||
if fact and len(fact) > 10:
|
||||
memories_to_save.append((f"[关键事实] {fact[:1500]}", "reference"))
|
||||
for topic in topics:
|
||||
if topic:
|
||||
memories_to_save.append((f"[话题] {topic[:500]}", "project"))
|
||||
|
||||
if not memories_to_save:
|
||||
return
|
||||
|
||||
db: Optional[Session] = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
for content, mem_type in memories_to_save:
|
||||
try:
|
||||
embedding = await embedding_service.generate_embedding(content)
|
||||
embedding_json = embedding_service.serialize_embedding(embedding) if embedding else ""
|
||||
record = AgentVectorMemory(
|
||||
scope_kind=self.scope_kind,
|
||||
scope_id=self.scope_id,
|
||||
session_key=self.session_key,
|
||||
content_text=content[:2000],
|
||||
embedding=embedding_json or None,
|
||||
metadata_={
|
||||
"type": "compressed_summary",
|
||||
"memory_type": mem_type,
|
||||
"source": "auto_compress",
|
||||
},
|
||||
)
|
||||
db.add(record)
|
||||
except Exception:
|
||||
pass # 单条失败不阻塞其他写入
|
||||
db.commit()
|
||||
logger.info("已向量化压缩记忆: %d 条 (scope=%s/%s)",
|
||||
len(memories_to_save), self.scope_kind, self.scope_id)
|
||||
except Exception as e:
|
||||
logger.warning("压缩记忆向量化失败: %s", e)
|
||||
if db:
|
||||
db.rollback()
|
||||
finally:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
def trim_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
裁剪消息列表:保留最近的 N 条,但始终保留第一条 system 消息。
|
||||
@@ -566,3 +861,41 @@ class AgentMemory:
|
||||
if m.get("role") == "user":
|
||||
turns += 1
|
||||
return f"共 {turns} 轮历史对话(详情已存入长期记忆)"
|
||||
|
||||
@staticmethod
|
||||
def _infer_memory_type(user_message: str, assistant_reply: str) -> str:
|
||||
"""
|
||||
根据对话内容推断记忆类型 (user / feedback / project / reference)。
|
||||
基于关键词快速分类,不做 LLM 调用。
|
||||
"""
|
||||
combined = (user_message + " " + assistant_reply).lower()
|
||||
|
||||
# feedback: 纠错、反馈、报错
|
||||
feedback_keywords = [
|
||||
"不对", "错误", "错了", "报错", "bug", "不正确", "有问题",
|
||||
"改一下", "修正", "纠正", "不要这样", "不行", "不是这个",
|
||||
"不对的", "反馈", "建议", "应该", "能不能", "可以不要",
|
||||
]
|
||||
if any(kw in combined for kw in feedback_keywords):
|
||||
return "feedback"
|
||||
|
||||
# reference: 链接、配置、系统信息
|
||||
reference_keywords = [
|
||||
"http://", "https://", "配置", ".env", "api", "端口",
|
||||
"数据库", "地址", "密码", "密钥", "token", "url",
|
||||
"路径", "文件", "目录", "安装", "部署",
|
||||
]
|
||||
if any(kw in combined for kw in reference_keywords):
|
||||
return "reference"
|
||||
|
||||
# project: 任务、目标、进度
|
||||
project_keywords = [
|
||||
"任务", "目标", "进度", "完成", "计划", "需求", "项目",
|
||||
"开发", "测试", "上线", "版本", "发布", "迭代",
|
||||
"bug", "修复", "功能", "实现", "提交",
|
||||
]
|
||||
if any(kw in combined for kw in project_keywords):
|
||||
return "project"
|
||||
|
||||
# user: 默认,包含偏好、个人信息等
|
||||
return "user"
|
||||
|
||||
228
backend/app/agent_runtime/permissions.py
Normal file
228
backend/app/agent_runtime/permissions.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
工具安全分级与权限检查
|
||||
|
||||
参考 Claude Code Tool.ts 的 checkPermissions / PermissionResult 设计:
|
||||
- 4 级权限: bypass > acceptEdits > default > plan
|
||||
- 工具标记: is_read_only / is_destructive
|
||||
- 自动批准规则: 基于工具名 + 参数模式匹配
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────── 权限级别 ────────────────────────────
|
||||
|
||||
class PermissionLevel(str, Enum):
|
||||
"""权限级别 — 参考 Claude Code PermissionMode"""
|
||||
BYPASS = "bypass" # 完全跳过权限检查
|
||||
ACCEPT_EDITS = "acceptEdits" # 自动批准文件编辑(读+写)
|
||||
DEFAULT = "default" # 每次询问(写操作需确认)
|
||||
PLAN = "plan" # 只读 + 计划工具
|
||||
|
||||
|
||||
# ──────────────────────────── 权限结果 ────────────────────────────
|
||||
|
||||
class PermissionAction(str, Enum):
|
||||
ALLOW = "allow"
|
||||
DENY = "deny"
|
||||
ASK = "ask" # 需要用户确认
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionResult:
|
||||
"""权限检查结果"""
|
||||
action: PermissionAction
|
||||
message: str = ""
|
||||
updated_input: Optional[Dict[str, Any]] = None # Hook 可修改参数
|
||||
|
||||
|
||||
# ──────────────────────────── 工具安全标记 ────────────────────────────
|
||||
|
||||
# 只读工具 — PLAN 模式下仍然可用
|
||||
READ_ONLY_TOOLS: set = {
|
||||
"file_read", "grep", "glob", "web_search", "web_fetch",
|
||||
"list_files", "read_lints", "codebase_search",
|
||||
"math_calculate", "text", "json", "csv",
|
||||
"database_query", "agent_list", "knowledge_base_search",
|
||||
}
|
||||
|
||||
# 破坏性工具 — 不可逆操作
|
||||
DESTRUCTIVE_TOOLS: set = {
|
||||
"file_write", "file_delete", "command_exec", "shell_exec",
|
||||
"docker_manage", "git_push", "git_reset_hard",
|
||||
"database_execute", "deploy_push", "agent_delete",
|
||||
}
|
||||
|
||||
# 编辑工具 — ACCEPT_EDITS 级别自动批准
|
||||
EDIT_TOOLS: set = {
|
||||
"file_edit", "file_write", "notebook_edit",
|
||||
}
|
||||
|
||||
|
||||
def is_read_only_tool(tool_name: str) -> bool:
|
||||
"""判断工具是否只读"""
|
||||
return tool_name in READ_ONLY_TOOLS
|
||||
|
||||
|
||||
def is_destructive_tool(tool_name: str) -> bool:
|
||||
"""判断工具是否具有破坏性"""
|
||||
return tool_name in DESTRUCTIVE_TOOLS
|
||||
|
||||
|
||||
def is_edit_tool(tool_name: str) -> bool:
|
||||
"""判断工具是否为编辑类"""
|
||||
return tool_name in EDIT_TOOLS
|
||||
|
||||
|
||||
# ──────────────────────────── 自动批准规则 ────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class AutoApproveRule:
|
||||
"""自动批准规则 — 参考 Claude Code alwaysAllowRules"""
|
||||
tool_pattern: str # 工具名匹配 (支持 * 通配符)
|
||||
param_conditions: Optional[Dict[str, Any]] = None # 参数条件
|
||||
description: str = ""
|
||||
|
||||
def matches(self, tool_name: str, params: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""检查工具是否匹配此规则"""
|
||||
# 通配符匹配
|
||||
if self.tool_pattern == "*":
|
||||
return True
|
||||
if self.tool_pattern.endswith("*"):
|
||||
prefix = self.tool_pattern[:-1]
|
||||
if not tool_name.startswith(prefix):
|
||||
return False
|
||||
elif tool_name != self.tool_pattern:
|
||||
return False
|
||||
|
||||
# 参数条件匹配
|
||||
if self.param_conditions and params:
|
||||
for key, expected in self.param_conditions.items():
|
||||
actual = params.get(key)
|
||||
if isinstance(expected, str) and expected.startswith("regex:"):
|
||||
pattern = expected[6:]
|
||||
if not re.search(pattern, str(actual)):
|
||||
return False
|
||||
elif actual != expected:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# 默认自动批准规则
|
||||
DEFAULT_AUTO_APPROVE_RULES: List[AutoApproveRule] = [
|
||||
AutoApproveRule(tool_pattern="file_read", description="读取文件总是安全"),
|
||||
AutoApproveRule(tool_pattern="grep", description="代码搜索总是安全"),
|
||||
AutoApproveRule(tool_pattern="glob", description="文件搜索总是安全"),
|
||||
AutoApproveRule(tool_pattern="web_search", description="网页搜索只读"),
|
||||
AutoApproveRule(tool_pattern="web_fetch", description="网页抓取只读"),
|
||||
AutoApproveRule(tool_pattern="math_calculate", description="数学计算无副作用"),
|
||||
AutoApproveRule(tool_pattern="list_files", description="列出文件无副作用"),
|
||||
AutoApproveRule(tool_pattern="read_lints", description="读取 lint 结果无副作用"),
|
||||
AutoApproveRule(tool_pattern="knowledge_base_search", description="知识库搜索只读"),
|
||||
]
|
||||
|
||||
|
||||
# ──────────────────────────── 权限检查器 ────────────────────────────
|
||||
|
||||
class PermissionChecker:
|
||||
"""
|
||||
工具权限检查器 — 参考 Claude Code useCanUseTool 流程。
|
||||
|
||||
检查顺序:
|
||||
1. BYPASS 模式 → 直接放行
|
||||
2. 拒绝列表 → 直接拒绝
|
||||
3. 自动批准规则 → 放行
|
||||
4. PLAN 模式 → 只允许只读工具
|
||||
5. ACCEPT_EDITS 模式 → 只读 + 编辑工具自动放行
|
||||
6. DEFAULT 模式 → 编辑/破坏性工具需确认
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
level: PermissionLevel = PermissionLevel.DEFAULT,
|
||||
auto_approve_rules: Optional[List[AutoApproveRule]] = None,
|
||||
deny_rules: Optional[List[str]] = None,
|
||||
):
|
||||
self.level = level
|
||||
self.auto_approve_rules = auto_approve_rules or list(DEFAULT_AUTO_APPROVE_RULES)
|
||||
self.deny_tools: set = set(deny_rules or [])
|
||||
|
||||
def check(
|
||||
self,
|
||||
tool_name: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
) -> PermissionResult:
|
||||
"""
|
||||
检查工具调用权限。
|
||||
|
||||
Returns:
|
||||
PermissionResult 指示 allow / deny / ask
|
||||
"""
|
||||
# 1. BYPASS — 完全放行
|
||||
if self.level == PermissionLevel.BYPASS:
|
||||
return PermissionResult(action=PermissionAction.ALLOW)
|
||||
|
||||
# 2. 拒绝列表
|
||||
if tool_name in self.deny_tools:
|
||||
return PermissionResult(
|
||||
action=PermissionAction.DENY,
|
||||
message=f"工具 {tool_name} 已被管理员禁用",
|
||||
)
|
||||
|
||||
# 3. 自动批准规则
|
||||
for rule in self.auto_approve_rules:
|
||||
if rule.matches(tool_name, params):
|
||||
logger.debug(f"工具 {tool_name} 匹配自动批准规则: {rule.description}")
|
||||
return PermissionResult(action=PermissionAction.ALLOW)
|
||||
|
||||
# 4. PLAN 模式 — 只允许只读
|
||||
if self.level == PermissionLevel.PLAN:
|
||||
if is_read_only_tool(tool_name):
|
||||
return PermissionResult(action=PermissionAction.ALLOW)
|
||||
return PermissionResult(
|
||||
action=PermissionAction.DENY,
|
||||
message=f"PLAN 模式下不允许使用 {tool_name}(仅支持只读工具)",
|
||||
)
|
||||
|
||||
# 5. ACCEPT_EDITS — 只读 + 编辑自动放行
|
||||
if self.level == PermissionLevel.ACCEPT_EDITS:
|
||||
if is_read_only_tool(tool_name) or is_edit_tool(tool_name):
|
||||
return PermissionResult(action=PermissionAction.ALLOW)
|
||||
|
||||
# 6. DEFAULT — 破坏性工具需确认
|
||||
if is_destructive_tool(tool_name):
|
||||
return PermissionResult(
|
||||
action=PermissionAction.ASK,
|
||||
message=f"工具 {tool_name} 可能产生不可逆操作,是否继续?",
|
||||
)
|
||||
|
||||
# 编辑工具在 DEFAULT 下也需确认
|
||||
if is_edit_tool(tool_name):
|
||||
return PermissionResult(
|
||||
action=PermissionAction.ASK,
|
||||
message=f"确认编辑操作: {tool_name}",
|
||||
)
|
||||
|
||||
# 未知工具默认放行
|
||||
return PermissionResult(action=PermissionAction.ALLOW)
|
||||
|
||||
def add_auto_approve_rule(self, rule: AutoApproveRule):
|
||||
"""添加自动批准规则"""
|
||||
self.auto_approve_rules.append(rule)
|
||||
|
||||
def add_deny_tool(self, tool_name: str):
|
||||
"""添加拒绝工具"""
|
||||
self.deny_tools.add(tool_name)
|
||||
|
||||
def set_level(self, level: PermissionLevel):
|
||||
"""切换权限级别"""
|
||||
logger.info(f"权限级别切换: {self.level.value} → {level.value}")
|
||||
self.level = level
|
||||
@@ -18,7 +18,7 @@ class AgentToolConfig(BaseModel):
|
||||
exclude_tools: List[str] = Field(default_factory=list, description="排除的工具名称黑名单")
|
||||
require_approval: List[str] = Field(default_factory=list, description="需要人工审批的工具名列表")
|
||||
|
||||
@field_validator("include_tools", "exclude_tools", "require_approval", "cache_tool_whitelist", mode="before")
|
||||
@field_validator("include_tools", "exclude_tools", "require_approval", "cache_tool_whitelist", "auto_approve_rules", "deny_tools", mode="before")
|
||||
@classmethod
|
||||
def coerce_none_to_empty(cls, v: Any) -> Any:
|
||||
return v if v is not None else []
|
||||
@@ -29,6 +29,17 @@ class AgentToolConfig(BaseModel):
|
||||
cache_tool_whitelist: List[str] = Field(default_factory=list, description="启用缓存的工具名(空=确定性工具默认)")
|
||||
cache_ttl_ms: int = Field(default=3600000, description="缓存 TTL(毫秒),默认 1 小时")
|
||||
|
||||
# 工具安全分级 (P3 — 参考 Claude Code PermissionMode)
|
||||
permission_level: str = Field(
|
||||
default="default",
|
||||
description="权限级别: bypass | acceptEdits | default | plan"
|
||||
)
|
||||
auto_approve_rules: List[Dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="自动批准规则: [{tool_pattern, param_conditions, description}]"
|
||||
)
|
||||
deny_tools: List[str] = Field(default_factory=list, description="禁用的工具名列表")
|
||||
|
||||
|
||||
class AgentMemoryConfig(BaseModel):
|
||||
"""Agent 记忆配置"""
|
||||
@@ -38,7 +49,16 @@ class AgentMemoryConfig(BaseModel):
|
||||
persist_to_db: bool = True # 是否写入 MySQL 长期记忆
|
||||
vector_memory_enabled: bool = True # 是否启用向量记忆(语义检索)
|
||||
vector_memory_top_k: int = 5 # 向量检索 Top-K
|
||||
vector_memory_rerank: bool = False # 是否启用 LLM Rerank(向量 top-20 → LLM 精选 top-K)
|
||||
memory_type_filter: Optional[List[str]] = None # 记忆类型过滤,如 ["user","project"],None=全部
|
||||
team_id: Optional[str] = None # 团队共享 ID,非空时记忆在团队间共享
|
||||
team_share_enabled: bool = False # 是否将新记忆自动发布到团队池
|
||||
learning_enabled: bool = True # 是否启用自主学习(工具模式学习)
|
||||
# 文件式记忆 (MEMORY.md — 参考 Claude Code memdir)
|
||||
memory_dir_enabled: bool = False # 是否启用文件式自动记忆
|
||||
memory_dir_path: str = "" # 记忆目录路径(空=自动使用项目 .claude/memory)
|
||||
# 对话自动压缩 (参考 Claude Code src/services/compact/)
|
||||
compaction: Optional[Any] = None # CompactionConfig — 惰性导入避免循环依赖
|
||||
|
||||
|
||||
class AgentLLMConfig(BaseModel):
|
||||
@@ -56,6 +76,12 @@ class AgentLLMConfig(BaseModel):
|
||||
cache_enabled: bool = False # LLM 响应缓存(默认关闭,语义缓存有风险)
|
||||
cache_ttl_ms: int = 300000 # LLM 缓存 TTL,默认 5 分钟
|
||||
fallback_llm: Optional[Dict[str, Any]] = None # 降级模型配置 {provider, model, api_key, base_url}
|
||||
# 计划模式 (P2 — 参考 Claude Code EnterPlanModeTool)
|
||||
plan_mode_enabled: bool = False # 是否启用计划模式
|
||||
plan_approval_required: bool = True # 是否需要用户审批计划
|
||||
plan_model: Optional[str] = None # 计划生成使用的模型(默认复用主模型)
|
||||
# 上下文窗口 (用于 Compaction 触发计算)
|
||||
context_window: int = 128000 # 模型上下文窗口大小(token 数)
|
||||
|
||||
|
||||
class AgentBudgetConfig(BaseModel):
|
||||
@@ -64,6 +90,47 @@ class AgentBudgetConfig(BaseModel):
|
||||
max_tool_calls: int = 500 # 工具调用次数上限
|
||||
|
||||
|
||||
class AgentTokenBudgetConfig(BaseModel):
|
||||
"""Token 预算管理配置 — 参考 Claude Code tokenBudget.ts"""
|
||||
enabled: bool = True
|
||||
context_window: int = 0 # 模型上下文窗口(0=自动检测)
|
||||
output_reserve: int = 8192 # 留给模型输出的空间
|
||||
warning_threshold_pct: float = Field(default=0.75, ge=0.1, le=1.0)
|
||||
compact_threshold_pct: float = Field(default=0.85, ge=0.1, le=1.0)
|
||||
hard_limit_pct: float = Field(default=0.95, ge=0.1, le=1.0)
|
||||
user_budget: Optional[int] = None # 用户累计 token 目标(如 500000)
|
||||
auto_continue: bool = False # 预算用尽自动继续
|
||||
compaction_after_warning: bool = True
|
||||
max_compaction_attempts: int = 3
|
||||
|
||||
|
||||
class AgentPromptSectionsConfig(BaseModel):
|
||||
"""系统提示词分层装配配置 — 参考 Claude Code systemPromptSections.ts"""
|
||||
# 是否启用分层装配(关闭则退回到简单的 system_prompt 字符串)
|
||||
enabled: bool = True
|
||||
|
||||
# 静态段开关(段名 → 是否启用)
|
||||
static_sections: Dict[str, bool] = Field(default_factory=lambda: {
|
||||
"persona": True,
|
||||
"capabilities": True,
|
||||
"tool_instructions": True,
|
||||
"safety_rules": True,
|
||||
"output_style": True,
|
||||
})
|
||||
|
||||
# 动态段开关
|
||||
dynamic_sections: Dict[str, bool] = Field(default_factory=lambda: {
|
||||
"environment": True,
|
||||
"language": True,
|
||||
"memory_context": True,
|
||||
"conversation_summary": True,
|
||||
"tool_list": False, # 工具列表默认关闭(太长)
|
||||
})
|
||||
|
||||
# 语言偏好(用于 language 段)
|
||||
language: Optional[str] = None
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Agent 完整配置"""
|
||||
name: str = "default_agent"
|
||||
@@ -77,6 +144,10 @@ class AgentConfig(BaseModel):
|
||||
memory_scope_id: Optional[str] = None
|
||||
# 是否开启输出质量自检(结束前用轻量 LLM 评审,不达标则追加修正)
|
||||
self_review_enabled: bool = False
|
||||
# 系统提示词分层装配 (P2 — 参考 Claude Code prompts.ts + systemPromptSections.ts)
|
||||
prompt_sections: AgentPromptSectionsConfig = Field(default_factory=AgentPromptSectionsConfig)
|
||||
# Token 预算管理 (P2 — 参考 Claude Code tokenBudget.ts)
|
||||
token_budget: AgentTokenBudgetConfig = Field(default_factory=AgentTokenBudgetConfig)
|
||||
|
||||
|
||||
class AgentMessage(BaseModel):
|
||||
@@ -99,6 +170,27 @@ class AgentStep(BaseModel):
|
||||
reasoning: Optional[str] = Field(default=None, description="思考过程")
|
||||
|
||||
|
||||
class TokenUsageInfo(BaseModel):
|
||||
"""Token 预算信息 — 随 AgentResult 返回给前端展示用量条"""
|
||||
input_tokens: int = 0
|
||||
input_remaining: int = 0
|
||||
input_usage_pct: float = 0.0
|
||||
effective_window: int = 128_000
|
||||
context_window: int = 128_000
|
||||
cumulative_total: int = 0
|
||||
cumulative_prompt: int = 0
|
||||
cumulative_completion: int = 0
|
||||
llm_call_count: int = 0
|
||||
is_warning: bool = False
|
||||
is_critical: bool = False
|
||||
is_exhausted: bool = False
|
||||
compaction_attempts: int = 0
|
||||
user_budget: Optional[int] = None
|
||||
user_budget_used: Optional[int] = None
|
||||
user_budget_remaining: Optional[int] = None
|
||||
user_budget_pct: Optional[float] = None
|
||||
|
||||
|
||||
class AgentResult(BaseModel):
|
||||
"""Agent 执行结果"""
|
||||
success: bool = True
|
||||
@@ -108,3 +200,4 @@ class AgentResult(BaseModel):
|
||||
tool_calls_made: int = 0
|
||||
error: Optional[str] = None
|
||||
steps: List[AgentStep] = Field(default_factory=list, description="执行追踪步骤详情")
|
||||
token_usage: Optional[TokenUsageInfo] = Field(default=None, description="Token 预算摘要")
|
||||
|
||||
Reference in New Issue
Block a user