diff --git a/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx b/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx index 085677d7..1cd87920 100644 --- a/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx +++ b/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx @@ -154,6 +154,73 @@ function StatusCard() { + +
+
+
{tt.llmSuggested}
+ {status.last_consolidation_suggestions.consolidations.length === + 0 && + status.last_consolidation_suggestions.prunings.length === 0 ? ( + + {tt.noActions} + + ) : ( +
    + {status.last_consolidation_suggestions.consolidations.map( + (c, i) => ( +
  • + {(c.from ?? "?") + " → " + (c.into ?? "?")} + {c.reason ? `(${c.reason})` : ""} +
  • + ), + )} + {status.last_consolidation_suggestions.prunings.map( + (p, i) => ( +
  • + {p.name ?? "?"} + {p.reason ? `(${p.reason})` : ""} +
  • + ), + )} +
+ )} +
+
+
{tt.actuallyDid}
+ {status.last_actual_actions.length === 0 ? ( + + {tt.noActions} + + ) : ( +
    + {status.last_actual_actions.map((a, i) => ( +
  • + {a.tool ?? "?"} + {typeof a.args?.name === "string" && ( + + {String(a.args.name)} + + )} + + {a.status} + +
  • + ))} +
+ )} +
+
+
) : null} diff --git a/frontend-web/src/core/curator/types.ts b/frontend-web/src/core/curator/types.ts index 6269e0d2..24ac064e 100644 --- a/frontend-web/src/core/curator/types.ts +++ b/frontend-web/src/core/curator/types.ts @@ -11,6 +11,29 @@ export interface CuratorConfig { curator: CuratorSubConfig; } +export interface CuratorAction { + tool: string | null; + args: Record; + status: string; + result: string; +} + +export interface CuratorConsolidation { + from?: string; + into?: string; + reason?: string; +} + +export interface CuratorPruning { + name?: string; + reason?: string; +} + +export interface CuratorSuggestions { + consolidations: CuratorConsolidation[]; + prunings: CuratorPruning[]; +} + export interface CuratorStatus { enabled: boolean; run_count: number; @@ -23,6 +46,10 @@ export interface CuratorStatus { stale: number; archived: number; }; + /** 上次运行 LLM 自述的建议(它"说"要做什么)。 */ + last_consolidation_suggestions: CuratorSuggestions; + /** 上次运行的真实工具调用记录(它"实际"做了什么)。 */ + last_actual_actions: CuratorAction[]; } export interface CuratorLogRecord { diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 6c97c052..b5699e4d 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -557,6 +557,10 @@ export const enUS: Translations = { total: "Total", runNow: "Run now", runStarted: "Background run started", + lastActions: "Last run", + llmSuggested: "LLM suggested (self-report)", + actuallyDid: "Actually done (tool calls)", + noActions: "None", }, config: { title: "Configuration", diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index a2f1455c..6a5a0d9b 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -482,6 +482,10 @@ export interface Translations { total: string; runNow: string; runStarted: string; + lastActions: string; + llmSuggested: string; + actuallyDid: string; + noActions: string; }; config: { title: string; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 8cf3eda5..1e8d9508 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -533,6 +533,10 @@ export const zhCN: Translations = { total: "合计", runNow: "立即执行", runStarted: "已触发后台执行", + lastActions: "最近一次运行", + llmSuggested: "LLM 建议(自述)", + actuallyDid: "实际执行(工具调用)", + noActions: "无", }, config: { title: "配置", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 8dbdfefe..62d84aa0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -69,8 +69,25 @@ _enabled_skills_refresh_version = 0 _enabled_skills_refresh_event = threading.Event() +def _drop_archived_skills(skills: list[Skill]) -> list[Skill]: + """从注入提示词的技能列表里剔除已归档(usage state=archived)的技能。 + + 技能归档是软状态:SKILL.md 仍留在 custom//、enabled 标志不变,所以 + load_skills(enabled_only=True) 仍会返回它。但归档技能不应再出现在 agent 的系统 + 提示词里 —— 对 agent 而言它应等同于"不存在"。失败时不过滤(返回原列表)。 + """ + try: + from deerflow.skills.usage import STATE_ARCHIVED, all_entries + + entries = all_entries() + return [s for s in skills if entries.get(s.name, {}).get("state") != STATE_ARCHIVED] + except Exception: + logger.debug("Failed to filter archived skills for prompt injection", exc_info=True) + return skills + + def _load_enabled_skills_sync() -> list[Skill]: - return list(get_or_new_skill_storage().load_skills(enabled_only=True)) + return _drop_archived_skills(list(get_or_new_skill_storage().load_skills(enabled_only=True))) def _start_enabled_skills_refresh_thread() -> None: @@ -171,7 +188,7 @@ def _get_enabled_skills_for_config(app_config: AppConfig | None = None) -> list[ """ if app_config is None: return _get_enabled_skills() - return list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True)) + return _drop_archived_skills(list(get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=True))) def _skill_mutability_label(category: SkillCategory | str) -> str: @@ -479,11 +496,11 @@ You: "Deploying to staging..." [proceed] {skills_section} -{deferred_tools_section} - -{writing_mode_section} - -{subagent_section} +{deferred_tools_section} + +{writing_mode_section} + +{subagent_section} - User uploads: `/mnt/user-data/uploads` - Files uploaded by the user (automatically listed in context) @@ -796,17 +813,17 @@ 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 apply_prompt_template( - subagent_enabled: bool = False, - max_concurrent_subagents: int = 3, - *, - agent_id: str | None = None, - agent_name: str | None = None, - writing_mode: bool = False, - writing_artifact_path: str | None = None, - available_skills: set[str] | None = None, - app_config: AppConfig | None = None, -) -> str: +def apply_prompt_template( + subagent_enabled: bool = False, + max_concurrent_subagents: int = 3, + *, + agent_id: str | None = None, + agent_name: str | None = None, + writing_mode: bool = False, + writing_artifact_path: str | None = None, + available_skills: set[str] | None = None, + app_config: AppConfig | None = None, +) -> str: storage_agent_id = agent_id if agent_id is not None else agent_name # Get memory context @@ -838,33 +855,33 @@ def apply_prompt_template( skills_section = get_skills_prompt_section(available_skills, app_config=app_config) # Get deferred tools section (tool_search) - deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config) - current_writing_artifact = ( - f"\n**Current Markdown Artifact**\n- Continue editing this existing Markdown file unless the user explicitly asks for a new document: `{writing_artifact_path}`\n" - if writing_artifact_path - else "" - ) - writing_mode_section = ( - f""" - -Writing mode is enabled for this run. - -**Mandatory Output Contract** -- Treat the user's request as a writing/report-generation task. -- Your final deliverable MUST be a Markdown file, not only an inline chat answer. -- If a current Markdown artifact is provided below, you MUST revise that existing file rather than creating a new file, unless the user explicitly asks for a separate/new document. -- When revising an existing Markdown artifact, read the file first if needed, then overwrite the same path with the complete updated Markdown content. -- Write the deliverable to `/mnt/user-data/outputs/` with a clear `.md` filename, for example `/mnt/user-data/outputs/report.md`. -- Use `write_file` or an equivalent file-writing tool to save the complete Markdown content. -- After writing the file, call `present_files` with the Markdown file path so the user can open it as an artifact/canvas. -- The final chat response should be brief and point to the generated Markdown artifact. Do not duplicate the full report inline unless the user explicitly asks. -- If you revise an existing Markdown artifact, update the Markdown file and present it again. -{current_writing_artifact} - -""" - if writing_mode - else "" - ) + deferred_tools_section = get_deferred_tools_prompt_section(app_config=app_config) + current_writing_artifact = ( + f"\n**Current Markdown Artifact**\n- Continue editing this existing Markdown file unless the user explicitly asks for a new document: `{writing_artifact_path}`\n" + if writing_artifact_path + else "" + ) + writing_mode_section = ( + f""" + +Writing mode is enabled for this run. + +**Mandatory Output Contract** +- Treat the user's request as a writing/report-generation task. +- Your final deliverable MUST be a Markdown file, not only an inline chat answer. +- If a current Markdown artifact is provided below, you MUST revise that existing file rather than creating a new file, unless the user explicitly asks for a separate/new document. +- When revising an existing Markdown artifact, read the file first if needed, then overwrite the same path with the complete updated Markdown content. +- Write the deliverable to `/mnt/user-data/outputs/` with a clear `.md` filename, for example `/mnt/user-data/outputs/report.md`. +- Use `write_file` or an equivalent file-writing tool to save the complete Markdown content. +- After writing the file, call `present_files` with the Markdown file path so the user can open it as an artifact/canvas. +- The final chat response should be brief and point to the generated Markdown artifact. Do not duplicate the full report inline unless the user explicitly asks. +- If you revise an existing Markdown artifact, update the Markdown file and present it again. +{current_writing_artifact} + +""" + if writing_mode + else "" + ) # Build ACP agent section only if ACP agents are configured acp_section = _build_acp_section(app_config=app_config) @@ -878,10 +895,10 @@ Writing mode is enabled for this run. prompt = SYSTEM_PROMPT_TEMPLATE.format( agent_name=agent_name or storage_agent_id or "CM 助手", soul=get_agent_soul(storage_agent_id), - skills_section=skills_section, - deferred_tools_section=deferred_tools_section, - writing_mode_section=writing_mode_section, - memory_context=memory_context, + skills_section=skills_section, + deferred_tools_section=deferred_tools_section, + writing_mode_section=writing_mode_section, + memory_context=memory_context, network_policy_section=network_policy_section, subagent_section=subagent_section, subagent_reminder=subagent_reminder, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/curator.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/curator.py index f491ed4f..c493f2b6 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/curator.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/curator.py @@ -278,7 +278,7 @@ def should_run_now(user_id: str) -> bool: last_dt = datetime.fromisoformat(last_run) return datetime.now(UTC) >= last_dt + timedelta(hours=interval_hours) except Exception: - return True + return False def apply_automatic_transitions(user_id: str, now: datetime | None = None) -> dict: @@ -954,4 +954,8 @@ def get_curator_status(user_id: str) -> dict: "stale": counts[STATE_STALE], "archived": counts[STATE_ARCHIVED], }, + # 上次运行的两份记录:LLM 自述的建议 + 实际发生的工具调用,供前端对照展示。 + "last_consolidation_suggestions": state.get("last_consolidation_suggestions") + or {"consolidations": [], "prunings": []}, + "last_actual_actions": state.get("last_actual_actions") or [], } diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py index 44660f8d..d2722103 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py @@ -59,6 +59,66 @@ def _load_agent_skill_whitelist(agent_id: str | None) -> set[str] | None: return None +def _resolve_effective_user_id() -> str | None: + """当前请求/线程绑定的用户 id;解析失败时返回 None。""" + try: + from deerflow.runtime.user_context import get_effective_user_id + + return get_effective_user_id() + except Exception: + logger.debug("Failed to resolve effective user id for skill_list", exc_info=True) + return None + + +def _run_coro_blocking(coro): + """在同步工具里把一个协程跑到完成。 + + ``skill_list`` 是同步 ``@tool``:LangChain 通常在线程池里执行它(无运行中的 + 事件循环),但 curator 评审会在已有事件循环的线程里调用它。两种情况都要能 + 拿到 ``SkillStore`` 的异步查询结果 —— 检测到运行中的事件循环就转交独立线程 + 执行 ``asyncio.run``,否则直接跑。 + """ + import asyncio + + try: + asyncio.get_running_loop() + in_loop = True + except RuntimeError: + in_loop = False + + if in_loop: + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(asyncio.run, coro).result() + return asyncio.run(coro) + + +def _load_owned_skill_names(user_id: str | None) -> set[str] | None: + """返回 ``owner_user_id == user_id`` 的自定义技能名集合。 + + 用于默认 lead agent 的可见性过滤:只算"用户自己拥有的"自定义技能,不含 + 其他用户发布的。返回 ``None`` 表示 ``SkillStore`` 不可用(未配置数据库 + 等)—— 调用方应降级为"放行全部自定义技能",避免离线无库部署下技能凭空 + 消失。 + """ + if not user_id: + return None + try: + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.skills import make_skill_store + + factory = get_session_factory() + if factory is None: + return None + store = make_skill_store(factory) + records = _run_coro_blocking(store.list_visible(user_id)) + return {r["name"] for r in records if r.get("owner_user_id") == user_id} + except Exception: + logger.debug("Failed to load owned skill names for skill_list filter", exc_info=True) + return None + + @tool("skill_view") def skill_view_tool(name: str) -> str: """Read the full SKILL.md content of a skill by name. @@ -106,28 +166,47 @@ def skill_list_tool(runtime: ToolRuntime) -> str: under the ``skills:`` field — the agent only sees what it was wired to use. - For the **default lead agent** (no agent_id in runtime), there is no - per-agent whitelist and every visible skill is returned. + For the **default lead agent** (no agent_id in runtime), the result is + filtered to non-archived skills that are either built-in (public) or + owned by the current user. Other users' published custom skills and + archived skills are excluded. Returns a JSON array with fields: name, description, category, enabled, state, use_count, view_count, patch_count, last_activity_at, pinned, source. """ from deerflow.skills.storage import get_or_new_skill_storage - from deerflow.skills.usage import all_entries, latest_activity_at + from deerflow.skills.types import SkillCategory + from deerflow.skills.usage import STATE_ARCHIVED, all_entries, latest_activity_at agent_id = _resolve_active_agent_id(runtime) allowed = _load_agent_skill_whitelist(agent_id) + # 默认 lead agent 没有 per-agent 白名单 —— 改为只暴露"未归档"且"内置或属于 + # 当前用户"的技能。owned 为 None 表示 SkillStore 不可用,降级放行全部自定义 + # 技能(见 _load_owned_skill_names)。 + owned: set[str] | None = None + if allowed is None: + owned = _load_owned_skill_names(_resolve_effective_user_id()) + try: storage = get_or_new_skill_storage() skills = storage.load_skills(enabled_only=False) usage = all_entries() result = [] for skill in skills: - if allowed is not None and skill.name not in allowed: - continue entry = usage.get(skill.name, {}) + if allowed is not None: + # 自定义 agent:严格按 config.yaml 的 skills 白名单过滤。 + if skill.name not in allowed: + continue + else: + # 默认 agent:跳过已归档技能。 + if entry.get("state") == STATE_ARCHIVED: + continue + # 自定义技能仅当属于当前用户时可见;内置 public 技能始终可见。 + if skill.category == SkillCategory.CUSTOM and owned is not None and skill.name not in owned: + continue result.append({ "name": skill.name, "description": skill.description, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py index a3e8a05c..2c58c0fb 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py @@ -84,12 +84,26 @@ def get_available_tools( builtin_tools = BUILTIN_TOOLS.copy() if not is_scheduled_run: builtin_tools.append(scheduled_task_tool) - skill_evolution_config = getattr(config, "skill_evolution", None) + # skill_list / skill_view 是只读工具(发现、查看技能),与"自我进化"写能力无关, + # 始终提供给 agent。 + from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool + + builtin_tools.extend([skill_list_tool, skill_view_tool]) + + # skill_manage 能创建/修改/删除技能,属于"自我进化"写能力,按 skill_evolution.enabled + # 门控。该开关用户可在 per-user 配置里覆盖,所以按"生效配置(含 per-user 覆盖)"判断 —— + # Gateway 每次 run 重建 agent,用户改了配置下次 run 即生效;解析失败退回全局 config.yaml。 + try: + from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user + from deerflow.runtime.user_context import get_effective_user_id + + skill_evolution_config = load_skill_evolution_config_for_user(get_effective_user_id()) + except Exception: # noqa: BLE001 - 解析失败不能阻断 agent 构建 + skill_evolution_config = getattr(config, "skill_evolution", None) if getattr(skill_evolution_config, "enabled", False): - from deerflow.tools.builtins.skill_tools import skill_list_tool, skill_view_tool from deerflow.tools.skill_manage_tool import skill_manage_tool - builtin_tools.extend([skill_list_tool, skill_view_tool, skill_manage_tool]) + builtin_tools.append(skill_manage_tool) # Add subagent tools only if enabled via runtime parameter if subagent_enabled: