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

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