feat: agent memory management — CRUD API + Android management screen
Some checks failed
CI/CD Pipeline / Backend — Lint & Test (push) Has been cancelled
CI/CD Pipeline / Frontend — Lint & Build (push) Has been cancelled
CI/CD Pipeline / Docker — Build Check (push) Has been cancelled

Backend:
- New /api/v1/agents/{id}/memory endpoints: CRUD for global_knowledge,
  knowledge_entities, learning_patterns, vector_memories + import/export
- Fix scope_id column overflow: 3 model columns expanded to hold compound
  keys (user_id:agent_id format, 73 chars vs old VARCHAR(36))
- Config: allow unknown env vars (extra="ignore") for optional overrides

Android:
- MemoryManageScreen: 4-tab UI (全局知识/知识实体/学习模式/对话记忆)
  with search, delete, and FAB to add new entries
- Import/export via ShareSheet and file picker
- AgentListScreen: long-press dropdown menu → 记忆管理 entry point
- NavGraph: memory_manage/{agentId}/{agentName} route with URL encoding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 02:23:45 +08:00
parent 43d5347458
commit cffe4a52d6
15 changed files with 1876 additions and 14 deletions

View File

@@ -0,0 +1,680 @@
"""
Agent 记忆管理 API — 全局知识 / 知识实体 / 学习模式的 CRUD + 导入导出
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import or_
from app.core.database import get_db
from app.api.auth import get_current_user
from app.models.user import User
from app.models.agent import Agent, GlobalKnowledge, KnowledgeEntity, KnowledgeRelation
from app.models.agent_learning_pattern import AgentLearningPattern
from app.models.agent_vector_memory import AgentVectorMemory
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1/agents",
tags=["agent-memory"],
)
SCOPE_AGENT = "agent"
def _check_agent(db: Session, agent_id: str, user: User):
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
if agent.user_id and agent.user_id != user.id and user.role != "admin":
raise HTTPException(status_code=403, detail="无权访问该 Agent")
return agent
# ─────────── Pydantic models ───────────
class GlobalKnowledgeItem(BaseModel):
id: str
content: str
source_agent_id: Optional[str] = None
source_user_id: Optional[str] = None
tags: Optional[list] = None
confidence: str = "medium"
scope_kind: str = "agent"
scope_id: str = ""
expires_at: Optional[str] = None
created_at: Optional[str] = None
class GlobalKnowledgeCreate(BaseModel):
content: str
tags: Optional[list] = None
confidence: str = "medium"
class GlobalKnowledgeUpdate(BaseModel):
content: Optional[str] = None
tags: Optional[list] = None
confidence: Optional[str] = None
class GlobalKnowledgeList(BaseModel):
items: List[GlobalKnowledgeItem]
total: int
class KnowledgeEntityItem(BaseModel):
id: str
name: str
entity_type: str = "concept"
description: Optional[str] = None
source: str = "extracted"
confidence: str = "medium"
tags: Optional[list] = None
scope_kind: str = "agent"
scope_id: str = ""
created_at: Optional[str] = None
class KnowledgeEntityCreate(BaseModel):
name: str
entity_type: str = "concept"
description: Optional[str] = None
tags: Optional[list] = None
confidence: str = "medium"
class KnowledgeEntityUpdate(BaseModel):
name: Optional[str] = None
entity_type: Optional[str] = None
description: Optional[str] = None
tags: Optional[list] = None
confidence: Optional[str] = None
class KnowledgeEntityList(BaseModel):
items: List[KnowledgeEntityItem]
total: int
class LearningPatternItem(BaseModel):
id: str
scope_kind: str
scope_id: str
task_category: str
task_keywords: Optional[str] = None
suggested_tools: Optional[str] = None
effectiveness_score: float = 0.0
total_runs: int = 0
successful_runs: int = 0
avg_iterations: float = 0.0
avg_tool_calls: float = 0.0
last_used_at: Optional[str] = None
created_at: Optional[str] = None
class LearningPatternList(BaseModel):
items: List[LearningPatternItem]
total: int
class VectorMemoryItem(BaseModel):
id: str
scope_kind: str = "agent"
scope_id: str = ""
session_key: str = ""
content_text: str
metadata: Optional[dict] = None
created_at: Optional[str] = None
class VectorMemoryList(BaseModel):
items: List[VectorMemoryItem]
total: int
class MemoryExportResponse(BaseModel):
agent_id: str
exported_at: str
global_knowledge: List[dict] = []
knowledge_entities: List[dict] = []
knowledge_relations: List[dict] = []
learning_patterns: List[dict] = []
vector_memories: List[dict] = []
class MemoryImportRequest(BaseModel):
global_knowledge: List[dict] = []
knowledge_entities: List[dict] = []
knowledge_relations: List[dict] = []
learning_patterns: List[dict] = []
vector_memories: List[dict] = []
# ─────────── 辅助 ───────────
def _gk_to_item(gk: GlobalKnowledge) -> GlobalKnowledgeItem:
return GlobalKnowledgeItem(
id=gk.id,
content=gk.content,
source_agent_id=gk.source_agent_id,
source_user_id=gk.source_user_id,
tags=gk.tags,
confidence=gk.confidence or "medium",
scope_kind=gk.scope_kind or SCOPE_AGENT,
scope_id=gk.scope_id or "",
expires_at=gk.expires_at.isoformat() if gk.expires_at else None,
created_at=gk.created_at.isoformat() if gk.created_at else None,
)
def _ke_to_item(ke: KnowledgeEntity) -> KnowledgeEntityItem:
return KnowledgeEntityItem(
id=ke.id,
name=ke.name,
entity_type=ke.entity_type or "concept",
description=getattr(ke, "description", None),
source=ke.source or "extracted",
confidence=ke.confidence or "medium",
tags=getattr(ke, "metadata_", None),
scope_kind=ke.scope_kind or SCOPE_AGENT,
scope_id=ke.scope_id or "",
created_at=ke.created_at.isoformat() if ke.created_at else None,
)
def _vm_to_item(vm: AgentVectorMemory) -> VectorMemoryItem:
return VectorMemoryItem(
id=vm.id,
scope_kind=vm.scope_kind or SCOPE_AGENT,
scope_id=vm.scope_id or "",
session_key=vm.session_key or "",
content_text=vm.content_text or "",
metadata=vm.metadata_ or {},
created_at=vm.created_at.isoformat() if vm.created_at else None,
)
# ─────────── Global Knowledge CRUD ───────────
@router.get("/{agent_id}/memory/global-knowledge", response_model=GlobalKnowledgeList)
def list_global_knowledge(
agent_id: str,
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
q = db.query(GlobalKnowledge).filter(
GlobalKnowledge.scope_kind == SCOPE_AGENT,
GlobalKnowledge.scope_id == agent_id,
)
if search:
q = q.filter(GlobalKnowledge.content.contains(search))
total = q.count()
items = q.order_by(GlobalKnowledge.created_at.desc()).offset(skip).limit(limit).all()
return GlobalKnowledgeList(items=[_gk_to_item(it) for it in items], total=total)
@router.post("/{agent_id}/memory/global-knowledge", response_model=GlobalKnowledgeItem)
def create_global_knowledge(
agent_id: str,
body: GlobalKnowledgeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
gk = GlobalKnowledge(
id=str(uuid.uuid4()),
content=body.content,
tags=body.tags,
confidence=body.confidence,
scope_kind=SCOPE_AGENT,
scope_id=agent_id,
source_user_id=current_user.id,
)
db.add(gk)
db.commit()
db.refresh(gk)
return _gk_to_item(gk)
@router.put("/{agent_id}/memory/global-knowledge/{knowledge_id}", response_model=GlobalKnowledgeItem)
def update_global_knowledge(
agent_id: str,
knowledge_id: str,
body: GlobalKnowledgeUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
gk = db.query(GlobalKnowledge).filter(
GlobalKnowledge.id == knowledge_id,
GlobalKnowledge.scope_kind == SCOPE_AGENT,
GlobalKnowledge.scope_id == agent_id,
).first()
if not gk:
raise HTTPException(status_code=404, detail="知识条目不存在")
if body.content is not None:
gk.content = body.content
if body.tags is not None:
gk.tags = body.tags
if body.confidence is not None:
gk.confidence = body.confidence
db.commit()
db.refresh(gk)
return _gk_to_item(gk)
@router.delete("/{agent_id}/memory/global-knowledge/{knowledge_id}")
def delete_global_knowledge(
agent_id: str,
knowledge_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
gk = db.query(GlobalKnowledge).filter(
GlobalKnowledge.id == knowledge_id,
GlobalKnowledge.scope_kind == SCOPE_AGENT,
GlobalKnowledge.scope_id == agent_id,
).first()
if not gk:
raise HTTPException(status_code=404, detail="知识条目不存在")
db.delete(gk)
db.commit()
return {"ok": True}
# ─────────── Knowledge Entities CRUD ───────────
@router.get("/{agent_id}/memory/knowledge-entities", response_model=KnowledgeEntityList)
def list_knowledge_entities(
agent_id: str,
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
q = db.query(KnowledgeEntity).filter(
KnowledgeEntity.scope_kind == SCOPE_AGENT,
KnowledgeEntity.scope_id == agent_id,
)
if search:
q = q.filter(
(KnowledgeEntity.name.contains(search))
| (KnowledgeEntity.description.contains(search))
)
total = q.count()
items = q.order_by(KnowledgeEntity.created_at.desc()).offset(skip).limit(limit).all()
return KnowledgeEntityList(items=[_ke_to_item(it) for it in items], total=total)
@router.post("/{agent_id}/memory/knowledge-entities", response_model=KnowledgeEntityItem)
def create_knowledge_entity(
agent_id: str,
body: KnowledgeEntityCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
ke = KnowledgeEntity(
id=str(uuid.uuid4()),
name=body.name,
entity_type=body.entity_type,
description=body.description,
source="manual",
confidence=body.confidence,
metadata_=body.tags,
scope_kind=SCOPE_AGENT,
scope_id=agent_id,
user_id=current_user.id,
)
db.add(ke)
db.commit()
db.refresh(ke)
return _ke_to_item(ke)
@router.put("/{agent_id}/memory/knowledge-entities/{entity_id}", response_model=KnowledgeEntityItem)
def update_knowledge_entity(
agent_id: str,
entity_id: str,
body: KnowledgeEntityUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
ke = db.query(KnowledgeEntity).filter(
KnowledgeEntity.id == entity_id,
KnowledgeEntity.scope_kind == SCOPE_AGENT,
KnowledgeEntity.scope_id == agent_id,
).first()
if not ke:
raise HTTPException(status_code=404, detail="实体不存在")
if body.name is not None:
ke.name = body.name
if body.entity_type is not None:
ke.entity_type = body.entity_type
if body.description is not None:
ke.description = body.description
if body.tags is not None:
ke.metadata_ = body.tags
if body.confidence is not None:
ke.confidence = body.confidence
db.commit()
db.refresh(ke)
return _ke_to_item(ke)
@router.delete("/{agent_id}/memory/knowledge-entities/{entity_id}")
def delete_knowledge_entity(
agent_id: str,
entity_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
ke = db.query(KnowledgeEntity).filter(
KnowledgeEntity.id == entity_id,
KnowledgeEntity.scope_kind == SCOPE_AGENT,
KnowledgeEntity.scope_id == agent_id,
).first()
if not ke:
raise HTTPException(status_code=404, detail="实体不存在")
# Also remove related relations
db.query(KnowledgeRelation).filter(
(KnowledgeRelation.source_entity_id == entity_id)
| (KnowledgeRelation.target_entity_id == entity_id)
).delete()
db.delete(ke)
db.commit()
return {"ok": True}
# ─────────── Learning Patterns ───────────
@router.get("/{agent_id}/memory/learning-patterns", response_model=LearningPatternList)
def list_learning_patterns(
agent_id: str,
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
q = db.query(AgentLearningPattern).filter(
AgentLearningPattern.scope_kind == SCOPE_AGENT,
AgentLearningPattern.scope_id == agent_id,
)
total = q.count()
items = q.order_by(AgentLearningPattern.updated_at.desc()).offset(skip).limit(limit).all()
return LearningPatternList(
items=[
LearningPatternItem(
id=lp.id,
scope_kind=lp.scope_kind or SCOPE_AGENT,
scope_id=lp.scope_id or "",
task_category=lp.task_category or "",
task_keywords=lp.task_keywords,
suggested_tools=lp.suggested_tools,
effectiveness_score=lp.effectiveness_score or 0.0,
total_runs=lp.total_runs or 0,
successful_runs=lp.successful_runs or 0,
avg_iterations=lp.avg_iterations or 0.0,
avg_tool_calls=lp.avg_tool_calls or 0.0,
last_used_at=lp.last_used_at.isoformat() if lp.last_used_at else None,
created_at=lp.created_at.isoformat() if lp.created_at else None,
)
for lp in items
],
total=total,
)
@router.delete("/{agent_id}/memory/learning-patterns/{pattern_id}")
def delete_learning_pattern(
agent_id: str,
pattern_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
lp = db.query(AgentLearningPattern).filter(
AgentLearningPattern.id == pattern_id,
AgentLearningPattern.scope_kind == SCOPE_AGENT,
AgentLearningPattern.scope_id == agent_id,
).first()
if not lp:
raise HTTPException(status_code=404, detail="学习模式不存在")
db.delete(lp)
db.commit()
return {"ok": True}
# ─────────── Vector Memories ───────────
@router.get("/{agent_id}/memory/vector-memories", response_model=VectorMemoryList)
def list_vector_memories(
agent_id: str,
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
uid = current_user.id
# scope_id may be: agent_id, or {user_id}:{agent_id}, or {user_id}:{agent_id}:{session_id}
q = db.query(AgentVectorMemory).filter(
AgentVectorMemory.scope_kind == SCOPE_AGENT,
or_(
AgentVectorMemory.scope_id == agent_id,
AgentVectorMemory.scope_id.like(f"%:{agent_id}"),
AgentVectorMemory.scope_id.like(f"{uid}:%"),
),
)
if search:
q = q.filter(AgentVectorMemory.content_text.contains(search))
total = q.count()
items = q.order_by(AgentVectorMemory.created_at.desc()).offset(skip).limit(limit).all()
return VectorMemoryList(items=[_vm_to_item(it) for it in items], total=total)
@router.delete("/{agent_id}/memory/vector-memories/{memory_id}")
def delete_vector_memory(
agent_id: str,
memory_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
uid = current_user.id
vm = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.id == memory_id,
AgentVectorMemory.scope_kind == SCOPE_AGENT,
or_(
AgentVectorMemory.scope_id == agent_id,
AgentVectorMemory.scope_id.like(f"%:{agent_id}"),
AgentVectorMemory.scope_id.like(f"{uid}:%"),
),
)
.first()
)
if not vm:
raise HTTPException(status_code=404, detail="向量记忆不存在")
db.delete(vm)
db.commit()
return {"ok": True}
# ─────────── Import / Export ───────────
def _serialize(row):
"""Serialize a SQLAlchemy row to dict, handling datetime."""
d = {}
for c in row.__table__.columns:
val = getattr(row, c.name)
if isinstance(val, datetime):
val = val.isoformat()
d[c.name] = val
return d
@router.post("/{agent_id}/memory/export", response_model=MemoryExportResponse)
def export_memory(
agent_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
gk_list = (
db.query(GlobalKnowledge)
.filter(GlobalKnowledge.scope_kind == SCOPE_AGENT, GlobalKnowledge.scope_id == agent_id)
.all()
)
ke_list = (
db.query(KnowledgeEntity)
.filter(KnowledgeEntity.scope_kind == SCOPE_AGENT, KnowledgeEntity.scope_id == agent_id)
.all()
)
kr_list = (
db.query(KnowledgeRelation)
.filter(KnowledgeRelation.scope_kind == SCOPE_AGENT, KnowledgeRelation.scope_id == agent_id)
.all()
)
lp_list = (
db.query(AgentLearningPattern)
.filter(AgentLearningPattern.scope_kind == SCOPE_AGENT, AgentLearningPattern.scope_id == agent_id)
.all()
)
vm_list = (
db.query(AgentVectorMemory)
.filter(
AgentVectorMemory.scope_kind == SCOPE_AGENT,
or_(
AgentVectorMemory.scope_id == agent_id,
AgentVectorMemory.scope_id.like(f"%:{agent_id}"),
AgentVectorMemory.scope_id.like(f"{current_user.id}:%"),
),
)
.all()
)
return MemoryExportResponse(
agent_id=agent_id,
exported_at=datetime.utcnow().isoformat(),
global_knowledge=[_serialize(gk) for gk in gk_list],
knowledge_entities=[_serialize(ke) for ke in ke_list],
knowledge_relations=[_serialize(kr) for kr in kr_list],
learning_patterns=[_serialize(lp) for lp in lp_list],
vector_memories=[_serialize(vm) for vm in vm_list],
)
@router.post("/{agent_id}/memory/import")
def import_memory(
agent_id: str,
body: MemoryImportRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_check_agent(db, agent_id, current_user)
imported = {"global_knowledge": 0, "knowledge_entities": 0, "knowledge_relations": 0, "learning_patterns": 0}
for item in body.global_knowledge:
gk = GlobalKnowledge(
id=str(uuid.uuid4()),
content=item.get("content", ""),
tags=item.get("tags"),
confidence=item.get("confidence", "medium"),
scope_kind=SCOPE_AGENT,
scope_id=agent_id,
source_agent_id=item.get("source_agent_id"),
source_user_id=current_user.id,
)
db.add(gk)
imported["global_knowledge"] += 1
for item in body.knowledge_entities:
ke = KnowledgeEntity(
id=str(uuid.uuid4()),
name=item.get("name", ""),
entity_type=item.get("entity_type", "concept"),
description=item.get("description"),
source="imported",
confidence=item.get("confidence", "medium"),
metadata_=item.get("metadata") or item.get("tags"),
scope_kind=SCOPE_AGENT,
scope_id=agent_id,
user_id=current_user.id,
)
db.add(ke)
imported["knowledge_entities"] += 1
for item in body.knowledge_relations:
kr = KnowledgeRelation(
id=str(uuid.uuid4()),
source_entity_id=item.get("source_entity_id", ""),
target_entity_id=item.get("target_entity_id", ""),
relation_type=item.get("relation_type", "related_to"),
description=item.get("description"),
weight=item.get("weight", "1.0"),
scope_kind=SCOPE_AGENT,
scope_id=agent_id,
)
db.add(kr)
imported["knowledge_relations"] += 1
for item in body.learning_patterns:
lp = AgentLearningPattern(
id=str(uuid.uuid4()),
scope_kind=SCOPE_AGENT,
scope_id=agent_id,
task_category=item.get("task_category", "general"),
task_keywords=item.get("task_keywords", ""),
suggested_tools=item.get("suggested_tools", "[]"),
effectiveness_score=item.get("effectiveness_score", 0.0),
total_runs=item.get("total_runs", 1),
successful_runs=item.get("successful_runs", 1),
avg_iterations=item.get("avg_iterations", 1.0),
avg_tool_calls=item.get("avg_tool_calls", 1.0),
)
db.add(lp)
imported["learning_patterns"] += 1
imported["vector_memories"] = 0
for item in body.vector_memories:
vm = AgentVectorMemory(
id=str(uuid.uuid4()),
scope_kind=SCOPE_AGENT,
scope_id=agent_id,
session_key=item.get("session_key", ""),
content_text=item.get("content_text", ""),
metadata_=item.get("metadata"),
)
db.add(vm)
imported["vector_memories"] += 1
db.commit()
return {"ok": True, "imported": imported}