feat: 智能体创建流程优化与管理员记忆页面
- 创建智能体页面:保存后自动跳转列表页、技能列表增加搜索过滤框 - Bootstrap 模式:开放全部技能可见性,注入专属提示词(skill_list 匹配意图、禁止 skill_manage) - 管理员记忆页面:新增 AdminMemoryPage 及路由,支持查看/编辑用户记忆条目 - 内存配置与管理员用户接口同步更新 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
18c2a4122f
commit
3748513996
12 files changed
+692
-143
No files matched your search
@@ -19,12 +19,12 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import (
|
||||
get_agent_store,
|
||||
get_checkpointer,
|
||||
get_current_user_from_request,
|
||||
get_thread_store,
|
||||
)
|
||||
from deerflow.agents.memory import BuiltinFileProvider
|
||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.runtime import serialize_channel_values
|
||||
|
||||
@@ -127,10 +127,22 @@ async def list_user_thread_messages(
|
||||
|
||||
|
||||
class AdminMemoryBucket(BaseModel):
|
||||
name: str = Field(..., description="Bucket name: 'global' or a custom-agent name")
|
||||
name: str = Field(..., description="Bucket name: 'global' or the agent id (used for CRUD)")
|
||||
display_name: str = Field("", description="Human-readable name shown in the UI")
|
||||
data: dict = Field(default_factory=dict, description="Raw memory data for the bucket")
|
||||
|
||||
|
||||
class AdminMemoryEntryAddRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, description="Entry content to add")
|
||||
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
|
||||
|
||||
|
||||
class AdminMemoryEntryReplaceRequest(BaseModel):
|
||||
old_text: str = Field(..., min_length=1, description="Short unique substring to locate the entry")
|
||||
content: str = Field(..., min_length=1, description="New content to replace with")
|
||||
agent: str | None = Field(None, description="Agent name; None = global USER.md bucket")
|
||||
|
||||
|
||||
class AdminMemoryResponse(BaseModel):
|
||||
user_id: str
|
||||
buckets: list[AdminMemoryBucket] = Field(default_factory=list)
|
||||
@@ -154,25 +166,103 @@ async def get_user_memory(user_id: str, request: Request) -> AdminMemoryResponse
|
||||
|
||||
Memory V2 layout: the ``global`` bucket is ``USER.md`` (user profile);
|
||||
each per-agent bucket is that agent's ``MEMORY.md`` working notes.
|
||||
Agent list is sourced from the database (all agents owned by this user)
|
||||
so agents without any existing MEMORY.md still appear.
|
||||
"""
|
||||
await _require_admin(request)
|
||||
|
||||
buckets: list[AdminMemoryBucket] = [
|
||||
AdminMemoryBucket(name="global", data=_read_memory_bucket(user_id, None))
|
||||
AdminMemoryBucket(name="global", display_name="用户画像", data=_read_memory_bucket(user_id, None))
|
||||
]
|
||||
|
||||
agents_root = get_paths().user_dir(user_id) / "agents"
|
||||
if agents_root.is_dir():
|
||||
for entry in sorted(agents_root.iterdir(), key=lambda p: p.name.lower()):
|
||||
if not entry.is_dir() or not (entry / "MEMORY.md").exists():
|
||||
continue
|
||||
if not AGENT_NAME_PATTERN.match(entry.name):
|
||||
continue
|
||||
try:
|
||||
data = _read_memory_bucket(user_id, entry.name)
|
||||
except Exception:
|
||||
logger.warning("Failed to read memory bucket %s for user", entry.name)
|
||||
continue
|
||||
buckets.append(AdminMemoryBucket(name=entry.name, data=data))
|
||||
# Source agents from the database: only agents owned by this specific user.
|
||||
agent_store = get_agent_store(request)
|
||||
all_agents = await agent_store.list_visible(user_id)
|
||||
owned_agents = sorted(
|
||||
[a for a in all_agents if a.get("user_id") == user_id],
|
||||
key=lambda a: (a.get("name") or a["id"]).lower(),
|
||||
)
|
||||
|
||||
for agent in owned_agents:
|
||||
agent_id: str = agent["id"]
|
||||
display_name: str = agent.get("name") or agent_id
|
||||
try:
|
||||
data = _read_memory_bucket(user_id, agent_id)
|
||||
except Exception:
|
||||
data = {"entries": []}
|
||||
buckets.append(AdminMemoryBucket(name=agent_id, display_name=display_name, data=data))
|
||||
|
||||
return AdminMemoryResponse(user_id=user_id, buckets=buckets)
|
||||
|
||||
|
||||
@router.post("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||||
async def admin_add_memory_entry(
|
||||
user_id: str,
|
||||
body: AdminMemoryEntryAddRequest,
|
||||
request: Request,
|
||||
) -> AdminMemoryBucket:
|
||||
"""Add a memory entry for a user (admin only)."""
|
||||
await _require_admin(request)
|
||||
target = "user" if body.agent is None else "memory"
|
||||
provider = BuiltinFileProvider()
|
||||
provider.initialize(user_id=user_id, agent_id=body.agent)
|
||||
try:
|
||||
result = provider.add(target, body.content)
|
||||
if result is False:
|
||||
raise HTTPException(status_code=400, detail="Failed to add entry (quota exceeded or write error)")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("admin_add_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to write memory entry") from exc
|
||||
bucket_name = "global" if body.agent is None else body.agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
|
||||
|
||||
|
||||
@router.put("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||||
async def admin_replace_memory_entry(
|
||||
user_id: str,
|
||||
body: AdminMemoryEntryReplaceRequest,
|
||||
request: Request,
|
||||
) -> AdminMemoryBucket:
|
||||
"""Replace a memory entry for a user (admin only)."""
|
||||
await _require_admin(request)
|
||||
target = "user" if body.agent is None else "memory"
|
||||
provider = BuiltinFileProvider()
|
||||
provider.initialize(user_id=user_id, agent_id=body.agent)
|
||||
try:
|
||||
result = provider.replace(target, body.old_text, body.content)
|
||||
if result is False:
|
||||
raise HTTPException(status_code=404, detail="Entry not found or write failed")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("admin_replace_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to replace memory entry") from exc
|
||||
bucket_name = "global" if body.agent is None else body.agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, body.agent))
|
||||
|
||||
|
||||
@router.delete("/{user_id}/memory/entries", response_model=AdminMemoryBucket)
|
||||
async def admin_delete_memory_entry(
|
||||
user_id: str,
|
||||
request: Request,
|
||||
old_text: str = Query(..., min_length=1, description="Short unique substring to locate the entry"),
|
||||
agent: str | None = Query(None, description="Agent name; None = global USER.md bucket"),
|
||||
) -> AdminMemoryBucket:
|
||||
"""Delete a memory entry for a user (admin only)."""
|
||||
await _require_admin(request)
|
||||
target = "user" if agent is None else "memory"
|
||||
provider = BuiltinFileProvider()
|
||||
provider.initialize(user_id=user_id, agent_id=agent)
|
||||
try:
|
||||
result = provider.remove(target, old_text)
|
||||
if result is False:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("admin_delete_memory_entry failed for user %s: %s", user_id, exc, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to delete memory entry") from exc
|
||||
bucket_name = "global" if agent is None else agent
|
||||
return AdminMemoryBucket(name=bucket_name, data=_read_memory_bucket(user_id, agent))
|
||||
+3
-2
@@ -375,7 +375,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
config["metadata"] = {}
|
||||
|
||||
if is_bootstrap:
|
||||
runtime_available_skills: list[str] | None = ["bootstrap"]
|
||||
runtime_available_skills: list[str] | None = None # all enabled skills visible in bootstrap
|
||||
elif agent_config:
|
||||
# Custom agents must use an explicit allowlist. If omitted, default to none.
|
||||
runtime_available_skills = list(agent_config.skills or [])
|
||||
@@ -416,7 +416,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
writing_mode=writing_mode,
|
||||
writing_artifact_path=writing_artifact_path,
|
||||
available_skills=set(["bootstrap"]),
|
||||
available_skills=None,
|
||||
is_bootstrap=True,
|
||||
app_config=resolved_app_config,
|
||||
),
|
||||
state_schema=ThreadState,
|
||||
|
||||
+28
@@ -496,6 +496,8 @@ You: "Deploying to staging..." [proceed]
|
||||
|
||||
{skills_section}
|
||||
|
||||
{bootstrap_section}
|
||||
|
||||
{deferred_tools_section}
|
||||
|
||||
{writing_mode_section}
|
||||
@@ -819,6 +821,27 @@ def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str:
|
||||
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
|
||||
|
||||
|
||||
def _build_bootstrap_section() -> str:
|
||||
return """<bootstrap_agent_creation>
|
||||
你正在帮助用户创建一个自定义智能体。
|
||||
|
||||
**技能匹配规则(必须遵守):**
|
||||
1. 先调用 `skill_list` 查看用户当前所有可用技能及其描述。
|
||||
2. 根据用户对新智能体的描述/意图,从已有技能中选出最相关的 1-5 个技能名称(skill name,使用 hyphen-case)。
|
||||
3. 将选出的技能名称填入 `setup_agent` 的 `skills` 参数。
|
||||
4. **严禁在本次创建流程中调用 `skill_manage`(创建、编辑或删除技能)**。如用户提出需要新技能,请告知他们创建完智能体后再单独创建。
|
||||
5. 如果没有任何技能与用户意图相关,可将 `skills` 设为空列表,并向用户说明原因。
|
||||
|
||||
**工作流程:**
|
||||
1. 与用户充分沟通,明确新智能体的用途、专长和边界。
|
||||
2. 调用 `skill_list` 列出现有技能。
|
||||
3. 在思考中匹配技能,选出最合适的。
|
||||
4. 草拟 SOUL.md(定义智能体的人设、工作方式、输出要求)。
|
||||
5. 向用户确认配置后,调用 `setup_agent` 提交。
|
||||
</bootstrap_agent_creation>
|
||||
"""
|
||||
|
||||
|
||||
def apply_prompt_template(
|
||||
subagent_enabled: bool = False,
|
||||
max_concurrent_subagents: int = 3,
|
||||
@@ -828,6 +851,7 @@ def apply_prompt_template(
|
||||
writing_mode: bool = False,
|
||||
writing_artifact_path: str | None = None,
|
||||
available_skills: set[str] | None = None,
|
||||
is_bootstrap: bool = False,
|
||||
app_config: AppConfig | None = None,
|
||||
) -> str:
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
@@ -857,6 +881,9 @@ def apply_prompt_template(
|
||||
else ""
|
||||
)
|
||||
|
||||
# Bootstrap section: skill-matching guidance (only in agent creation flow)
|
||||
bootstrap_section = _build_bootstrap_section() if is_bootstrap else ""
|
||||
|
||||
# Get skills section
|
||||
skills_section = get_skills_prompt_section(available_skills, app_config=app_config)
|
||||
|
||||
@@ -902,6 +929,7 @@ Writing mode is enabled for this run.
|
||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||
soul=get_agent_soul(storage_agent_id),
|
||||
skills_section=skills_section,
|
||||
bootstrap_section=bootstrap_section,
|
||||
deferred_tools_section=deferred_tools_section,
|
||||
writing_mode_section=writing_mode_section,
|
||||
memory_context=memory_context,
|
||||
|
||||
@@ -73,7 +73,7 @@ class HindsightMemoryConfig(BaseModel):
|
||||
description="agent 私有工作 bank 命名模板",
|
||||
)
|
||||
memory_mode: str = Field(
|
||||
default="context",
|
||||
default="hybrid",
|
||||
description="工具暴露策略:context(仅自动) / tools(仅工具) / hybrid(两者)",
|
||||
)
|
||||
bank_mission: str = Field(default="", description="reflect 推理的身份/框架描述")
|
||||
@@ -136,13 +136,15 @@ class MemoryConfig(BaseModel):
|
||||
# 不在此表中的字段必须改 config.yaml 并重启 (例如 provider / api_key / 字符配额)。
|
||||
USER_WRITABLE_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"injection.enabled",
|
||||
# injection.enabled and injection.include_builtin are always ON;
|
||||
# removing them from the whitelist makes _filter_to_whitelist drop
|
||||
# any per-user override for these fields, so stored "false" values
|
||||
# in existing memory_config.json files are silently ignored.
|
||||
"injection.max_tokens",
|
||||
"injection.include_builtin",
|
||||
"injection.include_hindsight",
|
||||
"hindsight.memory_mode",
|
||||
# hindsight.memory_mode is fixed to "hybrid"; not user-writable.
|
||||
# hindsight.bank_mission is fixed; not user-writable.
|
||||
"hindsight.recall_budget",
|
||||
"hindsight.bank_mission",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user