From 1126fedea8658b25a2bedde8bd4d92a251b55f21 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 13:48:10 +0800 Subject: [PATCH 01/46] =?UTF-8?q?chore(env):=20=E6=B7=BB=E5=8A=A0=E6=88=91?= =?UTF-8?q?=E7=9A=84=E7=AC=94=E8=AE=B0=E7=9B=B8=E5=85=B3=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VITE_AIPI_FRONTEND_URL: 我的笔记前端地址 - VITE_STUDIO_ENABLED: 控制侧边栏我的笔记入口显示 Co-Authored-By: Claude Sonnet 4.6 --- frontend-web/.env.production | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend-web/.env.production b/frontend-web/.env.production index ad196043..3de4ad1a 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope # 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL) VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn +# 我的笔记前端 +VITE_AIPI_FRONTEND_URL= xxx VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer # MaxKey 鉴权(getTokenByOnlineTicket) VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn @@ -33,3 +35,6 @@ VITE_STUDIO_ENABLED=true # 可选:Canvas 独立 LangGraph 地址 # VITE_CANVAS_LANGGRAPH_BASE_URL= + +# 我的笔记是否显示,侧边栏 +VITE_STUDIO_ENABLED=false From 69454ada69952908ea41535a6ede66ef5a8563d8 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:14:30 +0800 Subject: [PATCH 02/46] feat(skill-evolution): add ReviewWorkerConfig to SkillEvolutionConfig Co-Authored-By: Claude Sonnet 4.6 --- .../deerflow/config/skill_evolution_config.py | 69 +++++++++++-------- .../backend/tests/test_skill_review_worker.py | 19 +++++ 2 files changed, 58 insertions(+), 30 deletions(-) create mode 100644 offline-backend-20260512/backend/tests/test_skill_review_worker.py diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_config.py index a6813313..45962647 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_config.py @@ -1,30 +1,39 @@ -from pydantic import BaseModel, Field - - -class CuratorConfig(BaseModel): - """Configuration for the background skill curator.""" - - # 默认关闭:curator 仅在用户显式开启时才运行(配合 SkillEvolutionConfig.enabled - # 总开关,二者均为安全默认 False —— 未显式 opt-in 则整套技能进化不动作)。 - enabled: bool = Field(default=False, description="Whether the curator runs automatically.") - interval_hours: int = Field(default=168, description="Hours between curator runs (default: 7 days).") - stale_after_days: int = Field(default=30, description="Days of inactivity before a skill is marked stale.") - archive_after_days: int = Field(default=90, description="Days of inactivity before a stale skill is archived.") - # LLM 评审整体超时:agent.invoke 内部可能多轮调 LLM,模型层的单次 request_timeout - # 兜不住整体;超过此秒数则中止本次评审,curator 正常收尾并释放运行锁。 - review_timeout_seconds: int = Field(default=600, description="Overall timeout (seconds) for the LLM review pass.") - # curator LLM 评审使用的模型名(对应 config.yaml models[].name);为空则用主模型。 - model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass. Defaults to the primary chat model.") - - -class SkillEvolutionConfig(BaseModel): - """Configuration for agent-managed skill evolution.""" - - enabled: bool = Field( - default=False, - description="Whether the agent can create and modify skills under skills/custom.", - ) - curator: CuratorConfig = Field( - default_factory=CuratorConfig, - description="Configuration for the background skill curator.", - ) +from pydantic import BaseModel, Field + + +class ReviewWorkerConfig(BaseModel): + """Configuration for the post-turn background skill review worker.""" + + enabled: bool = Field(default=False, description="Whether the review worker is active.") + min_tool_calls: int = Field(default=3, description="Minimum tool calls in a turn to trigger review.") + max_messages: int = Field(default=60, description="Max messages passed to the review LLM.") + model_name: str | None = Field(default=None, description="Model to use for review; defaults to primary model.") + timeout_seconds: int = Field(default=120, description="Overall timeout for a single review run.") + + +class CuratorConfig(BaseModel): + """Configuration for the background skill curator.""" + + enabled: bool = Field(default=False, description="Whether the curator runs automatically.") + interval_hours: int = Field(default=168, description="Hours between curator runs (default: 7 days).") + stale_after_days: int = Field(default=30, description="Days of inactivity before a skill is marked stale.") + archive_after_days: int = Field(default=90, description="Days of inactivity before a stale skill is archived.") + review_timeout_seconds: int = Field(default=600, description="Overall timeout (seconds) for the LLM review pass.") + model_name: str | None = Field(default=None, description="Model name for the curator LLM review pass. Defaults to the primary chat model.") + + +class SkillEvolutionConfig(BaseModel): + """Configuration for agent-managed skill evolution.""" + + enabled: bool = Field( + default=False, + description="Whether the agent can create and modify skills under skills/custom.", + ) + curator: CuratorConfig = Field( + default_factory=CuratorConfig, + description="Configuration for the background skill curator.", + ) + review_worker: ReviewWorkerConfig = Field( + default_factory=ReviewWorkerConfig, + description="Configuration for the post-turn background skill review worker.", + ) diff --git a/offline-backend-20260512/backend/tests/test_skill_review_worker.py b/offline-backend-20260512/backend/tests/test_skill_review_worker.py new file mode 100644 index 00000000..436cdae5 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_skill_review_worker.py @@ -0,0 +1,19 @@ +from deerflow.config.skill_evolution_config import SkillEvolutionConfig, ReviewWorkerConfig + + +def test_review_worker_config_defaults(): + cfg = SkillEvolutionConfig() + assert cfg.review_worker.enabled is False + assert cfg.review_worker.min_tool_calls == 3 + assert cfg.review_worker.timeout_seconds == 120 + assert cfg.review_worker.model_name is None + + +def test_review_worker_config_from_dict(): + cfg = SkillEvolutionConfig.model_validate({ + "enabled": True, + "review_worker": {"enabled": True, "min_tool_calls": 5, "model_name": "my-model"}, + }) + assert cfg.review_worker.enabled is True + assert cfg.review_worker.min_tool_calls == 5 + assert cfg.review_worker.model_name == "my-model" From 0da91be9b149021d7892277457be6e638be42043 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:16:10 +0800 Subject: [PATCH 03/46] test(skill-evolution): fix unused import and add max_messages assertion --- .../backend/tests/test_skill_review_worker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/offline-backend-20260512/backend/tests/test_skill_review_worker.py b/offline-backend-20260512/backend/tests/test_skill_review_worker.py index 436cdae5..19ebecef 100644 --- a/offline-backend-20260512/backend/tests/test_skill_review_worker.py +++ b/offline-backend-20260512/backend/tests/test_skill_review_worker.py @@ -1,4 +1,4 @@ -from deerflow.config.skill_evolution_config import SkillEvolutionConfig, ReviewWorkerConfig +from deerflow.config.skill_evolution_config import SkillEvolutionConfig def test_review_worker_config_defaults(): @@ -7,6 +7,7 @@ def test_review_worker_config_defaults(): assert cfg.review_worker.min_tool_calls == 3 assert cfg.review_worker.timeout_seconds == 120 assert cfg.review_worker.model_name is None + assert cfg.review_worker.max_messages == 60 def test_review_worker_config_from_dict(): From ef3ef433c1781a9d64bf60765d832ad4de030ebc Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:20:20 +0800 Subject: [PATCH 04/46] feat(skill-evolution): add SkillReviewWorker for passive post-turn skill creation Co-Authored-By: Claude Sonnet 4.6 --- .../harness/deerflow/skills/review_worker.py | 183 ++++++++++++++++++ .../backend/tests/test_skill_review_worker.py | 82 ++++++++ 2 files changed, 265 insertions(+) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py new file mode 100644 index 00000000..b73895f2 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -0,0 +1,183 @@ +"""Post-turn background skill review worker. + +After a conversation turn completes, SkillReviewMiddleware calls +``maybe_start_review()``. This module runs a lightweight LLM React agent +(with skill_manage + skill_list tools) that reads the conversation history +and decides whether to create or patch skills. + +Concurrency model: per-thread asyncio.Lock + a single _pending slot. +If a review is already running for the thread, the newer messages overwrite +the pending slot; once the current review finishes, it drains that slot. +""" + +from __future__ import annotations + +import asyncio +import logging +import textwrap +from typing import TYPE_CHECKING, Any + +from langchain_core.messages import BaseMessage + +if TYPE_CHECKING: + from deerflow.config.app_config import AppConfig + +logger = logging.getLogger(__name__) + +_locks: dict[str, asyncio.Lock] = {} +_pending: dict[str, list[BaseMessage]] = {} + + +def _get_lock(thread_id: str) -> asyncio.Lock: + if thread_id not in _locks: + _locks[thread_id] = asyncio.Lock() + return _locks[thread_id] + + +def extract_tool_call_count(messages: list[BaseMessage]) -> int: + """Count total tool calls across all AI messages in the conversation.""" + count = 0 + for msg in messages: + tool_calls: Any = None + if hasattr(msg, "tool_calls"): + tool_calls = msg.tool_calls + elif isinstance(msg, dict): + tool_calls = msg.get("tool_calls") + if tool_calls: + count += len(tool_calls) + return count + + +def _format_messages_for_review(messages: list[BaseMessage], max_messages: int) -> str: + """Serialize recent messages to a readable text block for the LLM reviewer.""" + recent = messages[-max_messages:] + lines: list[str] = [] + for msg in recent: + role = type(msg).__name__.replace("Message", "") + content = msg.content if isinstance(msg.content, str) else str(msg.content) + tool_calls = getattr(msg, "tool_calls", None) + if tool_calls: + calls_str = ", ".join( + f"{tc.name if hasattr(tc, 'name') else tc.get('name', '?')}()" + for tc in tool_calls + ) + lines.append(f"[{role}] ") + else: + preview = content[:400].replace("\n", " ") + lines.append(f"[{role}] {preview}") + return "\n".join(lines) + + +_REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\ + You are a skill review agent. Your job is to analyze a completed conversation + and decide whether any reusable multi-step workflow should be saved as a skill. + + ## Decision rules + 1. Call skill_list first to see existing skills — avoid creating duplicates. + 2. CREATE a new skill if: the conversation demonstrates a non-trivial, repeatable + workflow (3+ distinct tool operations) that a future agent could reuse verbatim. + 3. PATCH an existing skill if: the conversation revealed a gap, error, or improvement + to a skill that was already invoked. + 4. DO NOTHING if: the task was one-off, data-specific, trivial, or already covered. + + ## Skill quality bar + - Name: kebab-case, describes WHAT the skill does (e.g. "git-bisect-bug-hunt"). + - Description: one sentence starting with "When" that states the trigger condition. + - Content: concise workflow guide (steps, commands, pitfalls) — NOT a transcript. + - Never embed user-specific data, file paths, or credentials. + + Be conservative. Fewer high-quality skills beat many noisy ones. +""") + + +async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: "AppConfig") -> None: + """Core review logic: spin up a minimal React agent and invoke it.""" + from langgraph.prebuilt import create_react_agent + + from deerflow.models.factory import create_chat_model + from deerflow.tools.builtins.skill_tools import skill_list_tool + from deerflow.tools.skill_manage_tool import skill_manage_tool + + evolution_cfg = app_config.skill_evolution + timeout = evolution_cfg.review_worker.timeout_seconds + max_messages = evolution_cfg.review_worker.max_messages + model_name = evolution_cfg.review_worker.model_name + + conversation_text = _format_messages_for_review(messages, max_messages) + + try: + model = create_chat_model(model_name) + except Exception: + logger.warning("skill_review_worker: could not load model %r, aborting review", model_name) + return + + tools = [skill_list_tool, skill_manage_tool] + agent = create_react_agent(model, tools, prompt=_REVIEW_SYSTEM_PROMPT) + + user_message = ( + "Review this conversation and apply any skill changes you deem appropriate.\n\n" + f"\n{conversation_text}\n" + ) + + try: + await asyncio.wait_for( + asyncio.get_event_loop().run_in_executor( + None, + lambda: agent.invoke({"messages": [("user", user_message)]}), + ), + timeout=timeout, + ) + logger.info("skill_review_worker: review completed for thread %s", thread_id) + except TimeoutError: + logger.warning("skill_review_worker: review timed out after %ds (thread %s)", timeout, thread_id) + except Exception: + logger.exception("skill_review_worker: review failed for thread %s", thread_id) + + +async def _run_review_with_pending( + thread_id: str, + messages: list[BaseMessage], + app_config: "AppConfig", +) -> None: + """Run one review; if a newer snapshot arrived while we ran, drain it.""" + lock = _get_lock(thread_id) + async with lock: + await _run_review(thread_id, messages, app_config) + + pending = _pending.pop(thread_id, None) + if pending is not None: + asyncio.create_task(_run_review_with_pending(thread_id, pending, app_config)) + + +def maybe_start_review( + thread_id: str, + messages: list[BaseMessage], + app_config: "AppConfig", +) -> bool: + """Schedule a background review for ``thread_id``. + + Returns True if a new task was started, False if queued or skipped. + """ + evolution_cfg = app_config.skill_evolution + if not evolution_cfg.enabled or not evolution_cfg.review_worker.enabled: + return False + + tool_count = extract_tool_call_count(messages) + if tool_count < evolution_cfg.review_worker.min_tool_calls: + logger.debug( + "skill_review_worker: skipping review for thread %s (%d tool calls < min %d)", + thread_id, + tool_count, + evolution_cfg.review_worker.min_tool_calls, + ) + return False + + lock = _get_lock(thread_id) + if lock.locked(): + _pending[thread_id] = messages + logger.debug("skill_review_worker: queued review for thread %s (review in progress)", thread_id) + return False + + asyncio.create_task(_run_review_with_pending(thread_id, messages, app_config)) + logger.info("skill_review_worker: started review for thread %s (%d tool calls)", thread_id, tool_count) + return True diff --git a/offline-backend-20260512/backend/tests/test_skill_review_worker.py b/offline-backend-20260512/backend/tests/test_skill_review_worker.py index 19ebecef..549db4b0 100644 --- a/offline-backend-20260512/backend/tests/test_skill_review_worker.py +++ b/offline-backend-20260512/backend/tests/test_skill_review_worker.py @@ -18,3 +18,85 @@ def test_review_worker_config_from_dict(): assert cfg.review_worker.enabled is True assert cfg.review_worker.min_tool_calls == 5 assert cfg.review_worker.model_name == "my-model" + + +from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +import pytest +from langchain_core.messages import HumanMessage + + +def _make_ai_message(tool_calls_data: list[dict]): + msg = MagicMock() + msg.__class__.__name__ = "AIMessage" + tcs = [] + for tc in tool_calls_data: + m = MagicMock() + m.name = tc["name"] + m.args = tc.get("args", {}) + tcs.append(m) + msg.tool_calls = tcs + return msg + + +def test_extract_tool_call_count_counts_all(): + from deerflow.skills.review_worker import extract_tool_call_count + msgs = [ + HumanMessage(content="do something"), + _make_ai_message([{"name": "bash"}, {"name": "read_file"}]), + _make_ai_message([{"name": "write_file"}]), + ] + assert extract_tool_call_count(msgs) == 3 + + +def test_extract_tool_call_count_empty(): + from deerflow.skills.review_worker import extract_tool_call_count + assert extract_tool_call_count([]) == 0 + + +def test_maybe_start_review_disabled_returns_false(): + from deerflow.skills.review_worker import maybe_start_review + cfg = MagicMock() + cfg.skill_evolution.enabled = False + cfg.skill_evolution.review_worker.enabled = True + cfg.skill_evolution.review_worker.min_tool_calls = 2 + msgs = [_make_ai_message([{"name": "bash"}, {"name": "read_file"}, {"name": "write_file"}])] + assert maybe_start_review("thread-1", msgs, cfg) is False + + +def test_maybe_start_review_too_few_tool_calls_returns_false(): + from deerflow.skills.review_worker import maybe_start_review + cfg = MagicMock() + cfg.skill_evolution.enabled = True + cfg.skill_evolution.review_worker.enabled = True + cfg.skill_evolution.review_worker.min_tool_calls = 5 + msgs = [_make_ai_message([{"name": "bash"}])] + assert maybe_start_review("thread-4", msgs, cfg) is False + + +def test_maybe_start_review_starts_task(): + from deerflow.skills import review_worker as rw + # Reset module state so thread-3 has no lock + rw._locks.pop("thread-3", None) + rw._pending.pop("thread-3", None) + + cfg = MagicMock() + cfg.skill_evolution.enabled = True + cfg.skill_evolution.review_worker.enabled = True + cfg.skill_evolution.review_worker.min_tool_calls = 2 + cfg.skill_evolution.review_worker.timeout_seconds = 30 + cfg.skill_evolution.review_worker.max_messages = 20 + cfg.skill_evolution.review_worker.model_name = None + + msgs = [_make_ai_message([{"name": "bash"}, {"name": "read_file"}, {"name": "write_file"}])] + + with patch("deerflow.skills.review_worker.asyncio") as mock_asyncio: + fake_lock = MagicMock() + fake_lock.locked.return_value = False + mock_asyncio.Lock.return_value = fake_lock + mock_asyncio.create_task = MagicMock() + + result = rw.maybe_start_review("thread-3", msgs, cfg) + + assert result is True + mock_asyncio.create_task.assert_called_once() From 0e7a1de4db853351e759252dfa33d4782e985c13 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:24:02 +0800 Subject: [PATCH 05/46] feat(skill-evolution): add SkillReviewMiddleware to trigger post-turn review Implements SkillReviewMiddleware that hooks into after_agent to call maybe_start_review() as a background task after each agent turn, without blocking the HTTP response. Exceptions are caught and logged only. Co-Authored-By: Claude Sonnet 4.6 --- .../middlewares/skill_review_middleware.py | 56 +++++++++++++++ .../tests/test_skill_review_middleware.py | 70 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py create mode 100644 offline-backend-20260512/backend/tests/test_skill_review_middleware.py diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py new file mode 100644 index 00000000..cf60c905 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py @@ -0,0 +1,56 @@ +"""SkillReviewMiddleware — triggers background skill review after each agent turn. + +Hooks into ``after_agent``: once the lead agent graph has finished a full turn, +this middleware counts the tool calls made and, if the threshold is met, hands +the conversation messages to the SkillReviewWorker as a background asyncio task. + +No blocking I/O or LLM calls happen in-band — the review runs fully in the +background and never delays the HTTP response. +""" + +from __future__ import annotations + +import logging +from typing import Any, override + +from langchain.agents import AgentState +from langchain.agents.middleware import AgentMiddleware +from langgraph.runtime import Runtime + +from deerflow.config.app_config import get_app_config +from deerflow.skills.review_worker import maybe_start_review + +logger = logging.getLogger(__name__) + + +class SkillReviewMiddlewareState(AgentState): + """Minimal state schema — only needs ``messages``.""" + pass + + +class SkillReviewMiddleware(AgentMiddleware[SkillReviewMiddlewareState]): + """Dispatch a background skill review after every substantial agent turn. + + 'Substantial' is defined by ``skill_evolution.review_worker.min_tool_calls`` + (default 3). Turns below the threshold are silently skipped to avoid + wasting LLM tokens on trivial exchanges. + """ + + state_schema = SkillReviewMiddlewareState + + @override + async def after_agent(self, state: Any, runtime: Runtime) -> None: + """Fire background review; never raises — failures are logged only.""" + try: + app_config = get_app_config() + messages = state.get("messages", []) if hasattr(state, "get") else [] + thread_id: str = ( + (runtime.config or {}) + .get("configurable", {}) + .get("thread_id", "") + ) + if not thread_id: + return + maybe_start_review(thread_id, messages, app_config) + except Exception: + logger.exception("SkillReviewMiddleware.after_agent: unexpected error (ignored)") diff --git a/offline-backend-20260512/backend/tests/test_skill_review_middleware.py b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py new file mode 100644 index 00000000..a6281889 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py @@ -0,0 +1,70 @@ +"""Tests for SkillReviewMiddleware.""" +from unittest.mock import MagicMock, patch + +import pytest +from langchain_core.messages import AIMessage, HumanMessage + +from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware + + +def _make_state(messages): + state = MagicMock() + state.get.side_effect = lambda key, default=None: messages if key == "messages" else default + return state + + +def _make_runtime(thread_id="thread-abc"): + runtime = MagicMock() + runtime.config = {"configurable": {"thread_id": thread_id}} + return runtime + + +@pytest.mark.asyncio +async def test_after_agent_calls_maybe_start_review(): + """When enabled and tool calls exist, maybe_start_review is called with correct thread_id.""" + middleware = SkillReviewMiddleware() + + ai_msg = MagicMock(spec=AIMessage) + tc = MagicMock() + tc.name = "bash" + ai_msg.tool_calls = [tc] + + state = _make_state([HumanMessage(content="hi"), ai_msg]) + runtime = _make_runtime("thread-xyz") + + app_cfg = MagicMock() + with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", return_value=app_cfg): + with patch("deerflow.agents.middlewares.skill_review_middleware.maybe_start_review") as mock_review: + await middleware.after_agent(state, runtime) + + mock_review.assert_called_once() + call_args = mock_review.call_args + assert call_args[0][0] == "thread-xyz" # thread_id + assert call_args[0][2] is app_cfg # app_config + + +@pytest.mark.asyncio +async def test_after_agent_no_thread_id_skips_review(): + """When thread_id is missing, maybe_start_review is NOT called.""" + middleware = SkillReviewMiddleware() + state = _make_state([HumanMessage(content="hi")]) + runtime = _make_runtime("") + + app_cfg = MagicMock() + with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", return_value=app_cfg): + with patch("deerflow.agents.middlewares.skill_review_middleware.maybe_start_review") as mock_review: + await middleware.after_agent(state, runtime) + + mock_review.assert_not_called() + + +@pytest.mark.asyncio +async def test_after_agent_exception_is_swallowed(): + """Exceptions in after_agent must not propagate.""" + middleware = SkillReviewMiddleware() + state = _make_state([HumanMessage(content="hi")]) + runtime = _make_runtime("thread-err") + + with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", side_effect=RuntimeError("boom")): + # Must not raise + await middleware.after_agent(state, runtime) From c645142f8200d2ff8db9b4be368b5d687c9f0f88 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:27:12 +0800 Subject: [PATCH 06/46] feat(skill-evolution): register SkillReviewMiddleware in lead agent chain Co-Authored-By: Claude Sonnet 4.6 --- .../deerflow/agents/lead_agent/agent.py | 7 ++++ .../tests/test_skill_review_middleware.py | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index ae1398fe..20bebfcc 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -321,6 +321,13 @@ def _build_middlewares( max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3) middlewares.append(SubagentLimitMiddleware(max_concurrent=max_concurrent_subagents)) + # SkillReviewMiddleware — trigger background skill review after each turn. + # Gated by skill_evolution.enabled + skill_evolution.review_worker.enabled. + if resolved_app_config.skill_evolution.enabled and resolved_app_config.skill_evolution.review_worker.enabled: + from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware + + middlewares.append(SkillReviewMiddleware()) + # LoopDetectionMiddleware — detect and break repetitive tool call loops middlewares.append(LoopDetectionMiddleware()) diff --git a/offline-backend-20260512/backend/tests/test_skill_review_middleware.py b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py index a6281889..9b6f0c88 100644 --- a/offline-backend-20260512/backend/tests/test_skill_review_middleware.py +++ b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py @@ -68,3 +68,45 @@ async def test_after_agent_exception_is_swallowed(): with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", side_effect=RuntimeError("boom")): # Must not raise await middleware.after_agent(state, runtime) + + +def test_skill_review_middleware_in_chain_when_enabled(): + """SkillReviewMiddleware appears in middleware chain before LoopDetectionMiddleware when enabled.""" + from unittest.mock import patch, MagicMock + from deerflow.agents.lead_agent.agent import _build_middlewares + from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware + + config = { + "configurable": { + "thread_id": "test", + "is_plan_mode": False, + "is_bootstrap": False, + "is_scheduled_run": False, + } + } + + app_cfg = MagicMock() + app_cfg.skill_evolution.enabled = True + app_cfg.skill_evolution.review_worker.enabled = True + app_cfg.token_usage.enabled = False + app_cfg.tool_search.enabled = False + app_cfg.skill_stop.enabled = False + app_cfg.summarization.enabled = False + app_cfg.memory = MagicMock() + app_cfg.guardrails.enabled = False + app_cfg.title = MagicMock() + app_cfg.title.model_name = None + + with patch("deerflow.agents.lead_agent.agent.get_app_config", return_value=app_cfg): + with patch("deerflow.agents.lead_agent.agent.build_lead_runtime_middlewares", return_value=[]): + with patch("deerflow.agents.lead_agent.agent._create_summarization_middleware", return_value=None): + with patch("deerflow.agents.lead_agent.agent._create_todo_list_middleware", return_value=None): + with patch("deerflow.models.vision.vision_enabled", return_value=False): + middlewares = _build_middlewares(config, model_name=None, app_config=app_cfg) + + type_names = [type(m).__name__ for m in middlewares] + assert "SkillReviewMiddleware" in type_names, f"SkillReviewMiddleware not in chain: {type_names}" + + review_idx = type_names.index("SkillReviewMiddleware") + loop_idx = type_names.index("LoopDetectionMiddleware") + assert review_idx < loop_idx, f"SkillReviewMiddleware ({review_idx}) must be before LoopDetectionMiddleware ({loop_idx})" From 0a21d3714684ee34abbe37ca539b58510e3544d3 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:29:41 +0800 Subject: [PATCH 07/46] chore(config): enable skill_evolution, review_worker, and curator (24h) in config.yaml --- offline-backend-20260512/backend/config.yaml | 38 +++++++++++++------ offline-backend-20260512/skill_evolution.json | 12 ++++-- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 345a2f46..75e5aca3 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -10,6 +10,19 @@ gateway: port: 8001 models: + - name: deepseek-chat + display_name: DeepSeek Chat + use: langchain_openai:ChatOpenAI + model: deepseek-chat + api_key: sk-1520028668cb4cc388ed8315ff1e856e + base_url: https://api.deepseek.com + request_timeout: 600.0 + max_retries: 2 + max_tokens: 4096 + temperature: 1.0 + supports_vision: false + supports_thinking: true + - name: zai-org/GLM-5-FP8 display_name: zai-org/GLM-5-FP8 use: langchain_openai:ChatOpenAI @@ -23,18 +36,7 @@ models: supports_vision: true supports_thinking: true - - name: deepseek-chat - display_name: DeepSeek Chat - use: langchain_openai:ChatOpenAI - model: deepseek-chat - api_key: sk-1520028668cb4cc388ed8315ff1e856e - base_url: https://api.deepseek.com - request_timeout: 600.0 - max_retries: 2 - max_tokens: 4096 - temperature: 1.0 - supports_vision: false - supports_thinking: true + - name: deepseek-v4-flash display_name: DeepSeek V4 Flash @@ -184,6 +186,18 @@ skills: security_scan: enabled: false +skill_evolution: + enabled: true + curator: + enabled: true # 每 24 小时自动清理 + 合并 skill + interval_hours: 24 + review_worker: + enabled: true # 被动复盘开关 + min_tool_calls: 3 # 触发阈值:本轮至少 3 次工具调用 + max_messages: 60 # 传给 LLM 的最大消息条数 + timeout_seconds: 120 # 单次 review 超时(秒) + model_name: # 留空则使用主模型 + agents_api: enabled: true diff --git a/offline-backend-20260512/skill_evolution.json b/offline-backend-20260512/skill_evolution.json index 967d2b5c..59458af8 100644 --- a/offline-backend-20260512/skill_evolution.json +++ b/offline-backend-20260512/skill_evolution.json @@ -1,10 +1,16 @@ { "enabled": true, - "moderation_model_name": null, "curator": { - "enabled": false, - "interval_hours": 168, + "enabled": true, + "interval_hours": 24, "stale_after_days": 30, "archive_after_days": 90 + }, + "review_worker": { + "enabled": true, + "min_tool_calls": 3, + "max_messages": 60, + "timeout_seconds": 120, + "model_name": null } } \ No newline at end of file From d713f0209d70db427b6f5a65d9a40fe791167b24 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:34:19 +0800 Subject: [PATCH 08/46] feat(curator): restrict per-user config to archiving thresholds only --- .../backend/app/gateway/routers/skills.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index fee8949e..0e48b222 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -2329,14 +2329,14 @@ class CuratorConfigResponse(BaseModel): class CuratorConfigRequest(BaseModel): - """Payload for updating skill_evolution overrides. + """Payload for updating per-user skill evolution overrides. - All fields are required so the frontend always sends a complete config; - backend writes the entire payload to skill_evolution.json atomically. + Users may only adjust archiving thresholds. All other settings (enabled, + interval, model, timeouts) are global and controlled via config.yaml. """ - enabled: bool = Field(..., description="Whether the agent can create and modify skills under skills/custom.") - curator: CuratorSubConfig + stale_after_days: int = Field(..., ge=1, description="Days of inactivity before a skill is marked stale.") + archive_after_days: int = Field(..., ge=1, description="Days of inactivity before a stale skill is archived.") class CuratorLogRecord(BaseModel): @@ -2386,21 +2386,22 @@ async def get_curator_config(request: Request) -> CuratorConfigResponse: @router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config") async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse: - """Persist per-user overrides to ``users/{user_id}/skill_evolution.json`` - and return the effective merged config. + """Persist per-user archiving thresholds to ``users/{user_id}/skill_evolution.json``. - The payload is validated against the ``SkillEvolutionConfig`` shape; unknown - fields are rejected. + Only ``stale_after_days`` and ``archive_after_days`` are user-editable. + All other settings (enabled, interval, model, timeouts) are global and + read from config.yaml; users cannot override them here. """ try: - from deerflow.config.skill_evolution_config import SkillEvolutionConfig from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides user_id = _current_user_id(request) - payload = body.model_dump() - # Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid. - SkillEvolutionConfig.model_validate(payload) - save_user_overrides(user_id, payload) + save_user_overrides(user_id, { + "curator": { + "stale_after_days": body.stale_after_days, + "archive_after_days": body.archive_after_days, + } + }) return _build_curator_config_response(load_skill_evolution_config_for_user(user_id)) except HTTPException: raise From 23a18ec3184c26573465704a78f9334cf9ebff23 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:36:52 +0800 Subject: [PATCH 09/46] fix(skill-review): after_agent must be sync, not async --- .../middlewares/skill_review_middleware.py | 2 +- .../tests/test_skill_review_middleware.py | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py index cf60c905..318ef746 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py @@ -39,7 +39,7 @@ class SkillReviewMiddleware(AgentMiddleware[SkillReviewMiddlewareState]): state_schema = SkillReviewMiddlewareState @override - async def after_agent(self, state: Any, runtime: Runtime) -> None: + def after_agent(self, state: Any, runtime: Runtime) -> None: """Fire background review; never raises — failures are logged only.""" try: app_config = get_app_config() diff --git a/offline-backend-20260512/backend/tests/test_skill_review_middleware.py b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py index 9b6f0c88..e7e1e753 100644 --- a/offline-backend-20260512/backend/tests/test_skill_review_middleware.py +++ b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py @@ -1,7 +1,6 @@ """Tests for SkillReviewMiddleware.""" from unittest.mock import MagicMock, patch -import pytest from langchain_core.messages import AIMessage, HumanMessage from deerflow.agents.middlewares.skill_review_middleware import SkillReviewMiddleware @@ -19,8 +18,7 @@ def _make_runtime(thread_id="thread-abc"): return runtime -@pytest.mark.asyncio -async def test_after_agent_calls_maybe_start_review(): +def test_after_agent_calls_maybe_start_review(): """When enabled and tool calls exist, maybe_start_review is called with correct thread_id.""" middleware = SkillReviewMiddleware() @@ -35,7 +33,7 @@ async def test_after_agent_calls_maybe_start_review(): app_cfg = MagicMock() with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", return_value=app_cfg): with patch("deerflow.agents.middlewares.skill_review_middleware.maybe_start_review") as mock_review: - await middleware.after_agent(state, runtime) + middleware.after_agent(state, runtime) mock_review.assert_called_once() call_args = mock_review.call_args @@ -43,8 +41,7 @@ async def test_after_agent_calls_maybe_start_review(): assert call_args[0][2] is app_cfg # app_config -@pytest.mark.asyncio -async def test_after_agent_no_thread_id_skips_review(): +def test_after_agent_no_thread_id_skips_review(): """When thread_id is missing, maybe_start_review is NOT called.""" middleware = SkillReviewMiddleware() state = _make_state([HumanMessage(content="hi")]) @@ -53,13 +50,12 @@ async def test_after_agent_no_thread_id_skips_review(): app_cfg = MagicMock() with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", return_value=app_cfg): with patch("deerflow.agents.middlewares.skill_review_middleware.maybe_start_review") as mock_review: - await middleware.after_agent(state, runtime) + middleware.after_agent(state, runtime) mock_review.assert_not_called() -@pytest.mark.asyncio -async def test_after_agent_exception_is_swallowed(): +def test_after_agent_exception_is_swallowed(): """Exceptions in after_agent must not propagate.""" middleware = SkillReviewMiddleware() state = _make_state([HumanMessage(content="hi")]) @@ -67,7 +63,7 @@ async def test_after_agent_exception_is_swallowed(): with patch("deerflow.agents.middlewares.skill_review_middleware.get_app_config", side_effect=RuntimeError("boom")): # Must not raise - await middleware.after_agent(state, runtime) + middleware.after_agent(state, runtime) def test_skill_review_middleware_in_chain_when_enabled(): From 2a8364e9021505957f8822f2fef41c6215fc3497 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:37:46 +0800 Subject: [PATCH 10/46] fix(skill-review): use runtime.context to get thread_id --- .../deerflow/agents/middlewares/skill_review_middleware.py | 6 +----- .../backend/tests/test_skill_review_middleware.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py index 318ef746..213b9480 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py @@ -44,11 +44,7 @@ class SkillReviewMiddleware(AgentMiddleware[SkillReviewMiddlewareState]): try: app_config = get_app_config() messages = state.get("messages", []) if hasattr(state, "get") else [] - thread_id: str = ( - (runtime.config or {}) - .get("configurable", {}) - .get("thread_id", "") - ) + thread_id: str = runtime.context.get("thread_id", "") if runtime.context else "" if not thread_id: return maybe_start_review(thread_id, messages, app_config) diff --git a/offline-backend-20260512/backend/tests/test_skill_review_middleware.py b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py index e7e1e753..7e42f971 100644 --- a/offline-backend-20260512/backend/tests/test_skill_review_middleware.py +++ b/offline-backend-20260512/backend/tests/test_skill_review_middleware.py @@ -14,7 +14,7 @@ def _make_state(messages): def _make_runtime(thread_id="thread-abc"): runtime = MagicMock() - runtime.config = {"configurable": {"thread_id": thread_id}} + runtime.context = {"thread_id": thread_id} if thread_id else {} return runtime From b2ed6f1acf0c1a9268eb8368c61ee92d11ec3492 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:40:04 +0800 Subject: [PATCH 11/46] fix(skill-review): add strict SKILL.md format spec to review prompt --- .../harness/deerflow/skills/review_worker.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index b73895f2..2b7ba663 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -81,11 +81,33 @@ _REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\ 4. DO NOTHING if: the task was one-off, data-specific, trivial, or already covered. ## Skill quality bar - - Name: kebab-case, describes WHAT the skill does (e.g. "git-bisect-bug-hunt"). + - Name: kebab-case, lowercase letters/digits/hyphens only, max 64 chars. + Examples: "git-bisect-bug-hunt", "python-venv-setup", "api-load-test". - Description: one sentence starting with "When" that states the trigger condition. + Max 1024 chars. No angle brackets (< >). - Content: concise workflow guide (steps, commands, pitfalls) — NOT a transcript. - Never embed user-specific data, file paths, or credentials. + ## SKILL.md format (STRICT — validation will reject any deviation) + The `content` argument passed to skill_manage must be a valid SKILL.md: + + ``` + --- + name: + description: + --- + + + ``` + + Rules: + - The `name` field in the frontmatter MUST exactly match the `name` argument + you pass to skill_manage. If they differ, the call will fail. + - Only these frontmatter keys are allowed: name, description, license, + allowed-tools, metadata, compatibility, version, author. + - The frontmatter block must start with `---` on its own line and end with `---`. + - Do NOT add extra keys like "tags", "category", "type", etc. + Be conservative. Fewer high-quality skills beat many noisy ones. """) From ad5bd22c5eb61a61ab06d07096dc63d3e706fd46 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:45:13 +0800 Subject: [PATCH 12/46] refactor(skill-review): use make_lead_agent instead of create_react_agent Reuses the full middleware chain (ToolErrorHandlingMiddleware, LLMErrorHandlingMiddleware, etc.) so tool errors like 'already exists' and invalid SKILL.md are converted to ToolMessages by the middleware, not silently swallowed or crashing the review task. --- .../harness/deerflow/skills/review_worker.py | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index 2b7ba663..99e886ea 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -108,17 +108,28 @@ _REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\ - The frontmatter block must start with `---` on its own line and end with `---`. - Do NOT add extra keys like "tags", "category", "type", etc. + ## Avoiding duplicate-creation errors + Before calling skill_manage with action="create": + 1. Always call skill_list first. + 2. If a skill with the same name already exists, use action="patch" instead. + Creating over an existing skill will raise an error. + 3. If you get a tool error, read the message carefully and fix the issue + (wrong name, bad frontmatter, duplicate, etc.) before retrying. + Be conservative. Fewer high-quality skills beat many noisy ones. """) async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: "AppConfig") -> None: - """Core review logic: spin up a minimal React agent and invoke it.""" - from langgraph.prebuilt import create_react_agent + """Core review logic: run a minimal lead agent with full middleware support. - from deerflow.models.factory import create_chat_model - from deerflow.tools.builtins.skill_tools import skill_list_tool - from deerflow.tools.skill_manage_tool import skill_manage_tool + Reuses make_lead_agent so that ToolErrorHandlingMiddleware, LLMErrorHandlingMiddleware, + and the rest of the middleware chain handle all error cases uniformly — no custom + handle_tool_errors hack needed. + """ + from langchain_core.runnables import RunnableConfig + + from deerflow.agents.lead_agent.agent import make_lead_agent evolution_cfg = app_config.skill_evolution timeout = evolution_cfg.review_worker.timeout_seconds @@ -126,26 +137,36 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " model_name = evolution_cfg.review_worker.model_name conversation_text = _format_messages_for_review(messages, max_messages) - - try: - model = create_chat_model(model_name) - except Exception: - logger.warning("skill_review_worker: could not load model %r, aborting review", model_name) - return - - tools = [skill_list_tool, skill_manage_tool] - agent = create_react_agent(model, tools, prompt=_REVIEW_SYSTEM_PROMPT) - user_message = ( - "Review this conversation and apply any skill changes you deem appropriate.\n\n" + _REVIEW_SYSTEM_PROMPT + + "\n\nReview this conversation and apply any skill changes you deem appropriate.\n\n" f"\n{conversation_text}\n" ) + # Use a synthetic thread id so this review run is isolated from the source thread. + review_thread_id = f"__skill-review__{thread_id}" + + configurable: dict = { + "thread_id": review_thread_id, + "is_bootstrap": True, # skip MemoryMiddleware injection + "is_scheduled_run": True, # skip TitleMiddleware + "subagent_enabled": False, # no task-delegation tool + "app_config": app_config, + } + if model_name: + configurable["model_name"] = model_name + + run_config = RunnableConfig(configurable=configurable) + agent = make_lead_agent(run_config) + try: await asyncio.wait_for( asyncio.get_event_loop().run_in_executor( None, - lambda: agent.invoke({"messages": [("user", user_message)]}), + lambda: agent.invoke( + {"messages": [("user", user_message)]}, + config=run_config, + ), ), timeout=timeout, ) From 5b5e4f8ff0390de2b048f3f7dded3d5f73bc1a2a Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:52:39 +0800 Subject: [PATCH 13/46] fix(thread-data): guard runtime.context None on line 110 --- .../deerflow/agents/middlewares/thread_data_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py index c13ad1fd..fba06cf4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/thread_data_middleware.py @@ -107,7 +107,7 @@ class ThreadDataMiddleware(AgentMiddleware[ThreadDataMiddlewareState]): content=last_message.content, id=last_message.id, name=last_message.name, - additional_kwargs={**last_message.additional_kwargs, "run_id": runtime.context.get("run_id"), "timestamp": datetime.now(UTC).isoformat()}, + additional_kwargs={**last_message.additional_kwargs, "run_id": (runtime.context or {}).get("run_id"), "timestamp": datetime.now(UTC).isoformat()}, ) return { From b5d80025e97b29c43f9d50c8fa938bd79e9842d4 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 16:54:17 +0800 Subject: [PATCH 14/46] fix(skill-review): lower CREATE threshold and log agent decision --- .../harness/deerflow/skills/review_worker.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index 99e886ea..75005616 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -74,11 +74,13 @@ _REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\ ## Decision rules 1. Call skill_list first to see existing skills — avoid creating duplicates. - 2. CREATE a new skill if: the conversation demonstrates a non-trivial, repeatable - workflow (3+ distinct tool operations) that a future agent could reuse verbatim. + 2. CREATE a new skill if: the conversation demonstrates ANY repeatable workflow + or technique that could be reused in future tasks — even a single-step pattern + is worth capturing if it's non-obvious or task-specific knowledge. 3. PATCH an existing skill if: the conversation revealed a gap, error, or improvement to a skill that was already invoked. - 4. DO NOTHING if: the task was one-off, data-specific, trivial, or already covered. + 4. DO NOTHING only if: the task was purely data-specific (one-off query with no + generalizable pattern) or already covered by an existing skill verbatim. ## Skill quality bar - Name: kebab-case, lowercase letters/digits/hyphens only, max 64 chars. @@ -160,7 +162,7 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " agent = make_lead_agent(run_config) try: - await asyncio.wait_for( + result = await asyncio.wait_for( asyncio.get_event_loop().run_in_executor( None, lambda: agent.invoke( @@ -170,7 +172,19 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " ), timeout=timeout, ) - logger.info("skill_review_worker: review completed for thread %s", thread_id) + # Log the final AI message so we can see what the review agent decided. + final_msgs = (result or {}).get("messages", []) + final_text = "" + for msg in reversed(final_msgs): + content = getattr(msg, "content", None) + if content and isinstance(content, str) and content.strip(): + final_text = content.strip()[:300] + break + logger.info( + "skill_review_worker: review completed for thread %s — agent said: %s", + thread_id, + final_text or "(no text response)", + ) except TimeoutError: logger.warning("skill_review_worker: review timed out after %ds (thread %s)", timeout, thread_id) except Exception: From 3955bd72b5e9c839e34052fe72d81485e74c90d1 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 17:04:05 +0800 Subject: [PATCH 15/46] refactor(skill-review): use create_agent with skill_evolution disabled to allow autonomous skill creation --- .../harness/deerflow/skills/review_worker.py | 86 ++++++++++++------- 1 file changed, 57 insertions(+), 29 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index 75005616..aebc67c4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -123,15 +123,26 @@ _REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: "AppConfig") -> None: - """Core review logic: run a minimal lead agent with full middleware support. + """Core review logic: create_agent with full middleware chain, no 'silent creation' constraint. - Reuses make_lead_agent so that ToolErrorHandlingMiddleware, LLMErrorHandlingMiddleware, - and the rest of the middleware chain handle all error cases uniformly — no custom - handle_tool_errors hack needed. + Uses create_agent (the deerflow agent factory) directly so we can: + - Reuse the full middleware chain (ToolErrorHandlingMiddleware, etc.) + - Reuse the full tool set + - But pass app_config with skill_evolution.enabled=False to strip the + "Never create skills silently without user confirmation" constraint + from the system prompt. + Review instructions are injected via PromptPrefixMiddleware through + additional_kwargs["prompt_prefix"] on the HumanMessage. """ - from langchain_core.runnables import RunnableConfig + from langchain.agents import create_agent + from langchain_core.messages import HumanMessage - from deerflow.agents.lead_agent.agent import make_lead_agent + from deerflow.agents.lead_agent.prompt import apply_prompt_template + from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware + from deerflow.agents.middlewares.tool_error_handling_middleware import build_lead_runtime_middlewares + from deerflow.agents.thread_state import ThreadState + from deerflow.models import create_chat_model + from deerflow.tools.tools import get_available_tools evolution_cfg = app_config.skill_evolution timeout = evolution_cfg.review_worker.timeout_seconds @@ -139,36 +150,53 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " model_name = evolution_cfg.review_worker.model_name conversation_text = _format_messages_for_review(messages, max_messages) - user_message = ( - _REVIEW_SYSTEM_PROMPT - + "\n\nReview this conversation and apply any skill changes you deem appropriate.\n\n" - f"\n{conversation_text}\n" + + # Disable skill_evolution so the system prompt does NOT include + # "Never create skills silently without user confirmation". + # Also prevents SkillReviewMiddleware from registering (no recursive reviews). + review_app_config = app_config.model_copy(deep=True) + review_app_config.skill_evolution.enabled = False + + try: + model = create_chat_model(name=model_name, app_config=review_app_config) + except Exception: + logger.warning("skill_review_worker: could not load model %r, aborting review", model_name) + return + + tools = get_available_tools(app_config=review_app_config, subagent_enabled=False) + middlewares = build_lead_runtime_middlewares(app_config=review_app_config) + middlewares.append(PromptPrefixMiddleware()) + + system_prompt = apply_prompt_template( + subagent_enabled=False, + max_concurrent_subagents=0, + app_config=review_app_config, + memory_injection_enabled=False, ) - # Use a synthetic thread id so this review run is isolated from the source thread. - review_thread_id = f"__skill-review__{thread_id}" + agent = create_agent( + model=model, + tools=tools, + middleware=middlewares, + system_prompt=system_prompt, + state_schema=ThreadState, + ) - configurable: dict = { - "thread_id": review_thread_id, - "is_bootstrap": True, # skip MemoryMiddleware injection - "is_scheduled_run": True, # skip TitleMiddleware - "subagent_enabled": False, # no task-delegation tool - "app_config": app_config, - } - if model_name: - configurable["model_name"] = model_name - - run_config = RunnableConfig(configurable=configurable) - agent = make_lead_agent(run_config) + # Review instructions arrive as prompt_prefix: PromptPrefixMiddleware prepends + # them before every model call without persisting them to state. + first_message = HumanMessage( + content=( + "Review this conversation and apply any skill changes you deem appropriate.\n\n" + f"\n{conversation_text}\n" + ), + additional_kwargs={"prompt_prefix": _REVIEW_SYSTEM_PROMPT}, + ) try: result = await asyncio.wait_for( asyncio.get_event_loop().run_in_executor( None, - lambda: agent.invoke( - {"messages": [("user", user_message)]}, - config=run_config, - ), + lambda: agent.invoke({"messages": [first_message]}), ), timeout=timeout, ) @@ -178,7 +206,7 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " for msg in reversed(final_msgs): content = getattr(msg, "content", None) if content and isinstance(content, str) and content.strip(): - final_text = content.strip()[:300] + final_text = content.strip()[:1000] break logger.info( "skill_review_worker: review completed for thread %s — agent said: %s", From 857798ff2f349c430dd1b6a36659a5a242e120a6 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 17:11:18 +0800 Subject: [PATCH 16/46] fix(skill-review): pass RunnableConfig with thread_id to satisfy ThreadDataMiddleware --- .../packages/harness/deerflow/skills/review_worker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index aebc67c4..dc485ff9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -136,6 +136,7 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " """ from langchain.agents import create_agent from langchain_core.messages import HumanMessage + from langchain_core.runnables import RunnableConfig from deerflow.agents.lead_agent.prompt import apply_prompt_template from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware @@ -182,6 +183,9 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " state_schema=ThreadState, ) + # RunnableConfig supplies thread_id so ThreadDataMiddleware is satisfied. + run_config = RunnableConfig(configurable={"thread_id": f"__skill-review__{thread_id}"}) + # Review instructions arrive as prompt_prefix: PromptPrefixMiddleware prepends # them before every model call without persisting them to state. first_message = HumanMessage( @@ -196,7 +200,7 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " result = await asyncio.wait_for( asyncio.get_event_loop().run_in_executor( None, - lambda: agent.invoke({"messages": [first_message]}), + lambda: agent.invoke({"messages": [first_message]}, config=run_config), ), timeout=timeout, ) From 335bc3cf48a58eae894fcf5e16df7dedc054aa02 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 17:17:51 +0800 Subject: [PATCH 17/46] fix(skill-review): propagate user_id through review chain for correct DB ownership --- .../middlewares/skill_review_middleware.py | 4 ++- .../harness/deerflow/skills/review_worker.py | 30 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py index 213b9480..a0288589 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/skill_review_middleware.py @@ -18,6 +18,7 @@ from langchain.agents.middleware import AgentMiddleware from langgraph.runtime import Runtime from deerflow.config.app_config import get_app_config +from deerflow.runtime.user_context import get_effective_user_id from deerflow.skills.review_worker import maybe_start_review logger = logging.getLogger(__name__) @@ -47,6 +48,7 @@ class SkillReviewMiddleware(AgentMiddleware[SkillReviewMiddlewareState]): thread_id: str = runtime.context.get("thread_id", "") if runtime.context else "" if not thread_id: return - maybe_start_review(thread_id, messages, app_config) + user_id = get_effective_user_id() + maybe_start_review(thread_id, messages, app_config, user_id) except Exception: logger.exception("SkillReviewMiddleware.after_agent: unexpected error (ignored)") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index dc485ff9..5293e8cf 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -122,7 +122,7 @@ _REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\ """) -async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: "AppConfig") -> None: +async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: "AppConfig", user_id: str = "default") -> None: """Core review logic: create_agent with full middleware chain, no 'silent creation' constraint. Uses create_agent (the deerflow agent factory) directly so we can: @@ -196,12 +196,22 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " additional_kwargs={"prompt_prefix": _REVIEW_SYSTEM_PROMPT}, ) + def _invoke_with_user_context() -> dict: + from deerflow.runtime.user_context import set_current_user, reset_current_user + + class _SimpleUser: + def __init__(self, uid: str) -> None: + self.id = uid + + token = set_current_user(_SimpleUser(user_id)) + try: + return agent.invoke({"messages": [first_message]}, config=run_config) + finally: + reset_current_user(token) + try: result = await asyncio.wait_for( - asyncio.get_event_loop().run_in_executor( - None, - lambda: agent.invoke({"messages": [first_message]}, config=run_config), - ), + asyncio.get_event_loop().run_in_executor(None, _invoke_with_user_context), timeout=timeout, ) # Log the final AI message so we can see what the review agent decided. @@ -227,21 +237,23 @@ async def _run_review_with_pending( thread_id: str, messages: list[BaseMessage], app_config: "AppConfig", + user_id: str = "default", ) -> None: """Run one review; if a newer snapshot arrived while we ran, drain it.""" lock = _get_lock(thread_id) async with lock: - await _run_review(thread_id, messages, app_config) + await _run_review(thread_id, messages, app_config, user_id) pending = _pending.pop(thread_id, None) if pending is not None: - asyncio.create_task(_run_review_with_pending(thread_id, pending, app_config)) + asyncio.create_task(_run_review_with_pending(thread_id, pending, app_config, user_id)) def maybe_start_review( thread_id: str, messages: list[BaseMessage], app_config: "AppConfig", + user_id: str = "default", ) -> bool: """Schedule a background review for ``thread_id``. @@ -267,6 +279,6 @@ def maybe_start_review( logger.debug("skill_review_worker: queued review for thread %s (review in progress)", thread_id) return False - asyncio.create_task(_run_review_with_pending(thread_id, messages, app_config)) - logger.info("skill_review_worker: started review for thread %s (%d tool calls)", thread_id, tool_count) + asyncio.create_task(_run_review_with_pending(thread_id, messages, app_config, user_id)) + logger.info("skill_review_worker: started review for thread %s (%d tool calls) user=%s", thread_id, tool_count, user_id) return True From 705de81c78e28d66cc3d56e0bfdbbe8e1eedb6d4 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 17:25:34 +0800 Subject: [PATCH 18/46] fix(skill-review): use uuid per review run to avoid checkpoint context bleed --- .../backend/packages/harness/deerflow/skills/review_worker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index 5293e8cf..1925c4c0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -184,7 +184,8 @@ async def _run_review(thread_id: str, messages: list[BaseMessage], app_config: " ) # RunnableConfig supplies thread_id so ThreadDataMiddleware is satisfied. - run_config = RunnableConfig(configurable={"thread_id": f"__skill-review__{thread_id}"}) + import uuid + run_config = RunnableConfig(configurable={"thread_id": f"__skill-review__{uuid.uuid4()}"}) # Review instructions arrive as prompt_prefix: PromptPrefixMiddleware prepends # them before every model call without persisting them to state. From 6f9afcdba20b0532581e2fb5b3b5afbe31df8cfd Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 17:37:27 +0800 Subject: [PATCH 19/46] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8agent=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../harness/deerflow/skills/review_worker.py | 138 ++++++++++++------ 1 file changed, 96 insertions(+), 42 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py index 1925c4c0..cad384ca 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py @@ -69,56 +69,110 @@ def _format_messages_for_review(messages: list[BaseMessage], max_messages: int) _REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\ - You are a skill review agent. Your job is to analyze a completed conversation - and decide whether any reusable multi-step workflow should be saved as a skill. + # Role: 技能评审代理 - ## Decision rules - 1. Call skill_list first to see existing skills — avoid creating duplicates. - 2. CREATE a new skill if: the conversation demonstrates ANY repeatable workflow - or technique that could be reused in future tasks — even a single-step pattern - is worth capturing if it's non-obvious or task-specific knowledge. - 3. PATCH an existing skill if: the conversation revealed a gap, error, or improvement - to a skill that was already invoked. - 4. DO NOTHING only if: the task was purely data-specific (one-off query with no - generalizable pattern) or already covered by an existing skill verbatim. +## Profile +- language: zh-CN +- description: 专注于审阅已完成对话,识别并沉淀可复用的工作流为高质量技能;通过调用 skill_list 与 skill_manage 执行创建或修补,严格把控命名、描述与 SKILL.md 结构,避免重复与格式违规。 +- background: 具备多代理系统编排、知识管理与技术文档标准化背景,熟悉工作流抽象、版本控制与质量审核流程。 +- personality: 严谨保守、注重细节、结果导向、可追溯、简洁清晰。 +- expertise: 工作流抽象与评审、命名与触发条件设计、SKILL.md 规范与验证、工具调用与错误恢复、质量与安全控制。 +- target_audience: 多代理编排系统、技能库维护者、平台工程师、DevOps/平台团队、知识管理团队。 - ## Skill quality bar - - Name: kebab-case, lowercase letters/digits/hyphens only, max 64 chars. - Examples: "git-bisect-bug-hunt", "python-venv-setup", "api-load-test". - - Description: one sentence starting with "When" that states the trigger condition. - Max 1024 chars. No angle brackets (< >). - - Content: concise workflow guide (steps, commands, pitfalls) — NOT a transcript. - - Never embed user-specific data, file paths, or credentials. +## Skills - ## SKILL.md format (STRICT — validation will reject any deviation) - The `content` argument passed to skill_manage must be a valid SKILL.md: +1. 工作流抽象与评审 + - 可复用性识别: 捕捉多步流程与非显而易见的单步技巧,判断其复用价值。 + - 去数据化归纳: 将对话中的特定数据与情境抽象为通用步骤、参数与注意事项。 + - 价值评估: 区分一次性数据请求与通用模式,评估创建或修补的必要性。 + - 决策映射: 将评审结论明确映射为 create、patch 或 do-nothing。 - ``` - --- - name: - description: - --- +2. 文档与规范控制 + - SKILL.md 编写: 产出简洁、可执行的步骤、命令与坑点提示,非对话稿。 + - 命名规范: 设计 kebab-case 名称(小写字母/数字/连字符,≤64 字符)。 + - 触发描述撰写: 撰写以“When”开头的单句描述(≤1024 字符、无尖括号)。 + - 结构校验: 校验 frontmatter 允许键、name 一致性与前后分隔线格式。 - - ``` +3. 工具使用与错误处理 + - skill_list 查询: 枚举现有技能,检测重名与覆盖度,避免重复创建。 + - skill_manage 操作: 正确选择 action=create 或 patch,并传递严格合规的内容。 + - 错误诊断: 解读工具错误信息(重名、frontmatter 不合规、键非法等),定位并修正。 + - 稳健重试: 仅在修正问题后再尝试,避免无差别重复调用。 - Rules: - - The `name` field in the frontmatter MUST exactly match the `name` argument - you pass to skill_manage. If they differ, the call will fail. - - Only these frontmatter keys are allowed: name, description, license, - allowed-tools, metadata, compatibility, version, author. - - The frontmatter block must start with `---` on its own line and end with `---`. - - Do NOT add extra keys like "tags", "category", "type", etc. +4. 质量控制与安全 + - 去重策略: 以少而精为准则,避免噪声技能与重复条目。 + - 隐私合规: 禁止嵌入用户特定数据、文件路径或凭据。 + - 一致性: 内容与名称、描述相互匹配,语义清晰、执行明确。 + - 兼容性: 仅使用允许的 frontmatter 键,确保验证通过。 - ## Avoiding duplicate-creation errors - Before calling skill_manage with action="create": - 1. Always call skill_list first. - 2. If a skill with the same name already exists, use action="patch" instead. - Creating over an existing skill will raise an error. - 3. If you get a tool error, read the message carefully and fix the issue - (wrong name, bad frontmatter, duplicate, etc.) before retrying. +## Rules - Be conservative. Fewer high-quality skills beat many noisy ones. +1. 基本原则: + - 先查后建: 在任何创建前必须先调用 skill_list,审查现有技能,避免重复。 + - 价值优先: 只要出现可复用工作流或非显而易见的单步技巧,即可创建;若对话暴露了已用技能的缺口、错误或改进点,则选择修补。 + - 保守高质: 宁少勿滥;仅当任务纯属数据特定或已被现有技能逐字覆盖时才不作为。 + - 标准至上: 严格遵守命名、描述、SKILL.md 结构与键的约束,任何偏离都会导致验证失败。 + +2. 行为准则: + - 精准抽象: 去除个案细节,产出通用、可执行的步骤与注意事项。 + - 明确决策: 在 create、patch、do-nothing 中做出唯一且可说明的决定。 + - 聚焦改进: patch 聚焦于补足缺口、修正错误或提升质量,不做无关变更。 + - 错误自愈: 遇到工具错误,先阅读与理解信息,修正根因后再操作。 + +3. 限制条件: + - 命名限制: 名称必须为 kebab-case,使用小写字母/数字/连字符,最长 64 字符(如 git-bisect-bug-hunt、python-venv-setup、api-load-test)。 + - 描述限制: description 必须以“When”开头、为单句、最长 1024 字符,且不得包含尖括号 < >。 + - 内容限制: content 为简明工作流指南(步骤、命令、坑点),不得是对话转录;严禁包含用户特定数据、文件路径或凭据。 + - Frontmatter 限制: + - content 必须是有效 SKILL.md,包含 frontmatter 与正文。 + - 仅允许键:name、description、license、allowed-tools、metadata、compatibility、version、author。 + - frontmatter 的第一行与最后一行必须分别为“---”且独占一行。 + - frontmatter 中的 name 必须与传给 skill_manage 的 name 参数完全一致;不一致将导致调用失败。 + - 禁止添加额外键(如 tags、category、type 等)。 + + - 避免重复错误: + - 在 action="create" 前必须调用 skill_list。 + - 若已存在同名技能,则改用 action="patch";对已有名称执行 create 会失败。 + - 若出现工具错误,需先修正问题(名称、frontmatter、重复等)再重试。 + +## Workflows + +- 目标: 从已完成对话中提炼可复用工作流,严格按规范创建或修补技能,或在确无通用价值时不进行任何操作。 + +- 步骤 1: 获取基线 + - 调用 skill_list,记录现有技能名称与要点,用于去重与覆盖判断。 + +- 步骤 2: 审阅与抽象 + - 通读对话,识别可复用的多步流程或非显而易见的单步技巧。 + - 抽象要素:候选名称、触发条件(When 开头的一句话)、关键步骤/命令、常见坑点与注意事项。 + - 剔除数据特定内容与私密信息。 + +- 步骤 3: 去重与决策 + - 将候选与 skill_list 比对:名称与功能是否已覆盖或可改进。 + - 决策: + - 若完全未覆盖,则 action=create。 + - 若已存在且对话提供改进点/纠错,则 action=patch。 + - 若仅为数据特定或已被现有技能逐字覆盖,则不操作。 + +- 步骤 4: 准备 SKILL.md(严格格式) + - name:kebab-case,小写/数字/连字符,≤64 字符。 + - description:以“When ”开头,单句,≤1024 字符,无尖括号。 + - frontmatter:仅使用允许键;第一行“---”,最后一行“---”;包含 name 与 description;name 与调用参数完全一致。 + - 正文 content:以简洁清单方式呈现步骤、命令与坑点,避免对话式表述与私密信息。 + +- 步骤 5: 提交与校验 + - 若 action=create:再次确认 skill_list 中无同名后,调用 skill_manage(action="create", name, content)。 + - 若 action=patch:调用 skill_manage(action="patch", name, content),仅针对差异进行改进。 + - 提交前自检:命名规则、描述格式、frontmatter 键与分隔线、name 一致性、内容非转录且无敏感信息。 + +- 步骤 6: 错误处理 + - 如收到工具错误(如重名、frontmatter 不合规、非法键、name 不一致等),阅读错误信息,定位并修正后再尝试。 + - 禁止在未变更内容的情况下重复提交。 + +- 预期结果: 产生一条创建或修补成功且通过严格验证的高质量技能(或在有明确理由时不进行操作),具备合规命名、正确描述与有效 SKILL.md,且无重复与私密数据。 + +## Initialization +作为技能评审代理,你必须遵守上述Rules,按照Workflows执行任务。 """) From d0c696e523ce073cb4e3538cde5dd84f785954bb Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 17:57:55 +0800 Subject: [PATCH 20/46] refactor(skill-evolution): simplify config UI to 3 user-editable fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端配置页只保留运行间隔、陈旧阈值、归档阈值三个字段, 其余配置(enabled、model_name 等)走 config.yaml 全局默认。 后端 PUT /api/curator/config 仅保存这三个字段到 per-user skill_evolution.json。 Co-Authored-By: Claude Sonnet 4.6 --- .../skills/skill-evolution-panel.tsx | 127 ++++-------------- frontend-web/src/core/curator/api.ts | 8 +- frontend-web/src/core/curator/hooks.ts | 3 +- .../backend/app/gateway/routers/skills.py | 24 ++-- 4 files changed, 43 insertions(+), 119 deletions(-) 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 7371bc92..82fc7b08 100644 --- a/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx +++ b/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx @@ -21,13 +21,6 @@ import { CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { useCuratorConfig, @@ -38,7 +31,6 @@ import { } from "@/core/curator/hooks"; import type { CuratorConfig } from "@/core/curator/types"; import { useI18n } from "@/core/i18n/hooks"; -import { useModels } from "@/core/models/hooks"; import { cn } from "@/lib/utils"; export function SkillEvolutionPanel() { @@ -273,20 +265,30 @@ function StatRow({ ); } -/** Select 不允许空字符串 value,用此哨兵代表"用主模型"(对应 model_name=null)。 */ -const DEFAULT_MODEL_VALUE = "__default__"; +interface CuratorDraft { + interval_hours: number; + stale_after_days: number; + archive_after_days: number; +} + +function toDraft(config: CuratorConfig): CuratorDraft { + return { + interval_hours: config.curator.interval_hours, + stale_after_days: config.curator.stale_after_days, + archive_after_days: config.curator.archive_after_days, + }; +} function ConfigCard() { const { t } = useI18n(); const tt = t.settings.skillEvolution.config; const { config, isLoading } = useCuratorConfig(); const updateConfig = useUpdateCuratorConfig(); - const { models } = useModels(); - const [draft, setDraft] = useState(null); + const [draft, setDraft] = useState(null); useEffect(() => { if (config && !draft) { - setDraft(config); + setDraft(toDraft(config)); } }, [config, draft]); @@ -305,14 +307,12 @@ function ConfigCard() { ); } - const isDirty = - !!config && - JSON.stringify(draft) !== JSON.stringify(config); + const original = config ? toDraft(config) : null; + const isDirty = !!original && JSON.stringify(draft) !== JSON.stringify(original); const handleSave = async () => { try { - const updated = await updateConfig.mutateAsync(draft); - setDraft(updated); + await updateConfig.mutateAsync(draft); toast.success(tt.saveSuccess); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); @@ -326,29 +326,6 @@ function ConfigCard() { {tt.description} - - setDraft({ ...draft, enabled: v })} - /> - - - - setDraft({ - ...draft, - curator: { ...draft.curator, enabled: v }, - }) - } - /> - - setDraft({ - ...draft, - curator: { - ...draft.curator, - interval_hours: Math.max(1, Number(e.target.value) || 1), - }, - }) + setDraft({ ...draft, interval_hours: Math.max(1, Number(e.target.value) || 1) }) } /> @@ -377,15 +348,9 @@ function ConfigCard() { type="number" min={1} className="max-w-[10rem]" - value={draft.curator.stale_after_days} + value={draft.stale_after_days} onChange={(e) => - setDraft({ - ...draft, - curator: { - ...draft.curator, - stale_after_days: Math.max(1, Number(e.target.value) || 1), - }, - }) + setDraft({ ...draft, stale_after_days: Math.max(1, Number(e.target.value) || 1) }) } /> @@ -397,61 +362,19 @@ function ConfigCard() { type="number" min={1} className="max-w-[10rem]" - value={draft.curator.archive_after_days} + value={draft.archive_after_days} onChange={(e) => - setDraft({ - ...draft, - curator: { - ...draft.curator, - archive_after_days: Math.max(1, Number(e.target.value) || 1), - }, - }) + setDraft({ ...draft, archive_after_days: Math.max(1, Number(e.target.value) || 1) }) } /> - - -
diff --git a/frontend-web/src/core/curator/api.ts b/frontend-web/src/core/curator/api.ts index da6256bb..d9bfc193 100644 --- a/frontend-web/src/core/curator/api.ts +++ b/frontend-web/src/core/curator/api.ts @@ -17,8 +17,14 @@ export async function getCuratorConfig(): Promise { return response.json() as Promise; } +export interface CuratorConfigUpdatePayload { + interval_hours: number; + stale_after_days: number; + archive_after_days: number; +} + export async function updateCuratorConfig( - payload: CuratorConfig, + payload: CuratorConfigUpdatePayload, ): Promise { const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, { method: "PUT", diff --git a/frontend-web/src/core/curator/hooks.ts b/frontend-web/src/core/curator/hooks.ts index 35df0d9f..58692761 100644 --- a/frontend-web/src/core/curator/hooks.ts +++ b/frontend-web/src/core/curator/hooks.ts @@ -10,6 +10,7 @@ import { runCurator, updateCuratorConfig, } from "./api"; +import type { CuratorConfigUpdatePayload } from "./api"; import type { CuratorConfig } from "./types"; // All curator-related queries are per-user data (status / config / logs all @@ -51,7 +52,7 @@ export function useUpdateCuratorConfig() { const queryClient = useQueryClient(); const keys = useCuratorKeys(); return useMutation({ - mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload), + mutationFn: (payload: CuratorConfigUpdatePayload) => updateCuratorConfig(payload), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: keys.config }); void queryClient.invalidateQueries({ queryKey: keys.status }); diff --git a/offline-backend-20260512/backend/app/gateway/routers/skills.py b/offline-backend-20260512/backend/app/gateway/routers/skills.py index 0e48b222..cd6ddf70 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/skills.py +++ b/offline-backend-20260512/backend/app/gateway/routers/skills.py @@ -2329,12 +2329,12 @@ class CuratorConfigResponse(BaseModel): class CuratorConfigRequest(BaseModel): - """Payload for updating per-user skill evolution overrides. + """Payload for updating the three user-facing curator thresholds in skill_evolution.json. - Users may only adjust archiving thresholds. All other settings (enabled, - interval, model, timeouts) are global and controlled via config.yaml. + All other settings (enabled, model, timeouts) are controlled via config.yaml. """ + interval_hours: int = Field(..., ge=1, description="Hours between curator runs.") stale_after_days: int = Field(..., ge=1, description="Days of inactivity before a skill is marked stale.") archive_after_days: int = Field(..., ge=1, description="Days of inactivity before a stale skill is archived.") @@ -2352,11 +2352,7 @@ class CuratorLogsResponse(BaseModel): def _build_curator_config_response(evo) -> CuratorConfigResponse: - """Pure helper so both the GET route and the post-PUT refresh share one shape. - - ``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user - overrides if any). - """ + """Pure helper so both the GET route and the post-PUT refresh share one shape.""" return CuratorConfigResponse( enabled=evo.enabled, curator=CuratorSubConfig( @@ -2372,8 +2368,7 @@ def _build_curator_config_response(evo) -> CuratorConfigResponse: @router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config") async def get_curator_config(request: Request) -> CuratorConfigResponse: - """Return the current curator config for the authenticated user (yaml - defaults + that user's overrides).""" + """Return the current curator config for the authenticated user (yaml defaults + per-user overrides).""" try: from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user @@ -2386,11 +2381,9 @@ async def get_curator_config(request: Request) -> CuratorConfigResponse: @router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config") async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse: - """Persist per-user archiving thresholds to ``users/{user_id}/skill_evolution.json``. + """Persist the three user-facing curator thresholds to the per-user ``skill_evolution.json``. - Only ``stale_after_days`` and ``archive_after_days`` are user-editable. - All other settings (enabled, interval, model, timeouts) are global and - read from config.yaml; users cannot override them here. + Other settings (enabled, model, timeouts) are global and controlled via config.yaml. """ try: from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides @@ -2398,9 +2391,10 @@ async def update_curator_config(body: CuratorConfigRequest, request: Request) -> user_id = _current_user_id(request) save_user_overrides(user_id, { "curator": { + "interval_hours": body.interval_hours, "stale_after_days": body.stale_after_days, "archive_after_days": body.archive_after_days, - } + }, }) return _build_curator_config_response(load_skill_evolution_config_for_user(user_id)) except HTTPException: From e9125b0900068a9ddb35caf670b355f50dc1d195 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 18:03:57 +0800 Subject: [PATCH 21/46] test(curator): add scheduler startup non-blocking and execution tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证 curator 调度器不阻塞启动、启动延迟后真实执行、无锁时跳过。 Co-Authored-By: Claude Sonnet 4.6 --- .../tests/test_curator_scheduler_startup.py | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 offline-backend-20260512/backend/tests/test_curator_scheduler_startup.py diff --git a/offline-backend-20260512/backend/tests/test_curator_scheduler_startup.py b/offline-backend-20260512/backend/tests/test_curator_scheduler_startup.py new file mode 100644 index 00000000..c508fa17 --- /dev/null +++ b/offline-backend-20260512/backend/tests/test_curator_scheduler_startup.py @@ -0,0 +1,156 @@ +"""Tests that the curator scheduler does not block application startup. + +Verifies: +1. _curator_scheduler_loop is launched as a non-blocking asyncio task. +2. The scheduler sleeps for the startup delay before doing any work. +3. maybe_run_curator is NOT called during the startup delay window. +4. When the lock is unavailable the loop exits immediately without calling the curator. + +NOTE: ``import app.gateway.app as m`` resolves to the FastAPI *instance* because +``app/gateway/__init__.py`` re-exports ``app = FastAPI(...)``. The actual module +object lives in ``sys.modules["app.gateway.app"]`` and is used here via a helper. +""" + +from __future__ import annotations + +import asyncio +import sys +from unittest.mock import MagicMock, patch + + +def _gateway_module(): + """Return the real app.gateway.app *module* (not the FastAPI instance).""" + import importlib + importlib.import_module("app.gateway.app") # side-effect: populates sys.modules + return sys.modules["app.gateway.app"] + + +_STARTUP_DELAY = 300 # matches _CURATOR_STARTUP_DELAY_SECONDS in app.gateway.app +_CHECK_INTERVAL = 3600 # matches _CURATOR_CHECK_INTERVAL_SECONDS + + +def test_scheduler_does_not_block_startup(): + """create_task must return immediately — startup must not await the loop body.""" + mod = _gateway_module() + curator_called = False + + async def fake_sleep(_delay): + pass + + async def fake_curator(): + nonlocal curator_called + curator_called = True + + async def run(): + with ( + patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()), + patch.object(mod, "_release_singleton_lock"), + patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY), + patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL), + patch("deerflow.skills.curator.maybe_run_curator", fake_curator), + patch("asyncio.sleep", fake_sleep), + ): + task = asyncio.create_task(mod._curator_scheduler_loop()) + # create_task must return immediately; the coroutine is still pending + assert not task.done(), "task should be running in the background, not finished" + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + asyncio.run(run()) + assert not curator_called, "curator must not fire before the startup delay completes" + + +def test_scheduler_sleeps_startup_delay_before_first_curator_call(): + """The very first asyncio.sleep must use the startup-delay value (300 s).""" + mod = _gateway_module() + sleep_calls: list[float] = [] + curator_called = False + + async def fake_sleep(delay): + sleep_calls.append(delay) + raise asyncio.CancelledError # stop after recording first sleep + + async def fake_curator(): + nonlocal curator_called + curator_called = True + + async def run(): + with ( + patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()), + patch.object(mod, "_release_singleton_lock"), + patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY), + patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL), + patch("deerflow.skills.curator.maybe_run_curator", fake_curator), + patch("asyncio.sleep", fake_sleep), + ): + task = asyncio.create_task(mod._curator_scheduler_loop()) + try: + await task + except asyncio.CancelledError: + pass + + asyncio.run(run()) + + assert sleep_calls, "asyncio.sleep was never called" + assert sleep_calls[0] == _STARTUP_DELAY, ( + f"first sleep must be the startup delay ({_STARTUP_DELAY}s), got {sleep_calls[0]}" + ) + assert not curator_called, "curator must not be called before startup delay elapses" + + +def test_scheduler_actually_calls_curator_after_startup_delay(): + """After the startup delay elapses, maybe_run_curator must be called.""" + mod = _gateway_module() + curator_call_count = 0 + + async def fake_sleep(*_): + pass # instant — simulate time passing + + async def fake_curator(): + nonlocal curator_call_count + curator_call_count += 1 + # After first call cancel the loop so the test terminates + raise asyncio.CancelledError + + async def run(): + with ( + patch.object(mod, "_acquire_singleton_lock", return_value=MagicMock()), + patch.object(mod, "_release_singleton_lock"), + patch.object(mod, "_CURATOR_STARTUP_DELAY_SECONDS", _STARTUP_DELAY), + patch.object(mod, "_CURATOR_CHECK_INTERVAL_SECONDS", _CHECK_INTERVAL), + patch("deerflow.skills.curator.maybe_run_curator", fake_curator), + patch("asyncio.sleep", fake_sleep), + ): + task = asyncio.create_task(mod._curator_scheduler_loop()) + try: + await task + except asyncio.CancelledError: + pass + + asyncio.run(run()) + assert curator_call_count >= 1, ( + f"maybe_run_curator should have been called at least once, got {curator_call_count}" + ) + + +def test_scheduler_skips_when_lock_unavailable(): + """When another worker holds the lock the loop must exit without doing any work.""" + mod = _gateway_module() + curator_called = False + + async def fake_curator(): + nonlocal curator_called + curator_called = True + + async def run(): + with ( + patch.object(mod, "_acquire_singleton_lock", return_value=None), + patch("deerflow.skills.curator.maybe_run_curator", fake_curator), + ): + await mod._curator_scheduler_loop() + + asyncio.run(run()) + assert not curator_called, "curator must not run when lock is not acquired" From 603d667608a5b6e25892f9e4980141c9239cfd90 Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 18:06:21 +0800 Subject: [PATCH 22/46] fix(skill-evolution): strip non-user-configurable fields from per-user overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_user_overrides 只保留 interval_hours/stale_after_days/archive_after_days, 过滤掉老文件中的 enabled、model_name 等字段,防止旧数据覆盖全局开关。 Co-Authored-By: Claude Sonnet 4.6 --- .../config/skill_evolution_runtime.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_runtime.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_runtime.py index 093f306f..9d99c0a7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_runtime.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/skill_evolution_runtime.py @@ -135,8 +135,18 @@ def resolve_user_path(user_id: str) -> Path: return get_paths().user_dir(user_id) / _FILENAME +# Only these curator sub-fields may be overridden per user. +# All other fields (enabled, model_name, timeouts, etc.) are global-only. +_USER_ALLOWED_CURATOR_KEYS = {"interval_hours", "stale_after_days", "archive_after_days"} + + def load_user_overrides(user_id: str) -> dict[str, Any] | None: - """Read the per-user overrides file for ``user_id`` (None if missing/invalid).""" + """Read the per-user overrides file for ``user_id`` (None if missing/invalid). + + Only the three curator threshold keys are honoured; any other keys + (e.g. legacy ``enabled`` or ``model_name``) are silently stripped so that + old files written by previous versions cannot override global-only settings. + """ path = resolve_user_path(user_id) if not path.exists(): return None @@ -154,7 +164,14 @@ def load_user_overrides(user_id: str) -> dict[str, Any] | None: if not isinstance(data, dict): logger.warning("User skill_evolution overrides at %s must be a JSON object", path) return None - return data + # Strip top-level keys that are not user-configurable (e.g. legacy "enabled"). + curator_raw = data.get("curator") + if not isinstance(curator_raw, dict): + return None + filtered_curator = {k: v for k, v in curator_raw.items() if k in _USER_ALLOWED_CURATOR_KEYS} + if not filtered_curator: + return None + return {"curator": filtered_curator} def save_user_overrides(user_id: str, payload: dict[str, Any]) -> Path: From 2c2650ff077de7f671e4d3553b4459e03fc88c8f Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 3 Jun 2026 18:08:21 +0800 Subject: [PATCH 23/46] feat(skill-evolution): expose model_name as per-user configurable field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端恢复模型选择器,后端允许 model_name 写入 per-user skill_evolution.json。 Co-Authored-By: Claude Sonnet 4.6 --- .../skills/skill-evolution-panel.tsx | 43 +++++++++++++++++++ frontend-web/src/core/curator/api.ts | 1 + .../backend/app/gateway/routers/skills.py | 6 ++- .../config/skill_evolution_runtime.py | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) 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 82fc7b08..349a0871 100644 --- a/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx +++ b/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx @@ -21,6 +21,13 @@ import { CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { useCuratorConfig, @@ -31,6 +38,7 @@ import { } from "@/core/curator/hooks"; import type { CuratorConfig } from "@/core/curator/types"; import { useI18n } from "@/core/i18n/hooks"; +import { useModels } from "@/core/models/hooks"; import { cn } from "@/lib/utils"; export function SkillEvolutionPanel() { @@ -269,13 +277,17 @@ interface CuratorDraft { interval_hours: number; stale_after_days: number; archive_after_days: number; + model_name: string | null; } +const DEFAULT_MODEL_VALUE = "__default__"; + function toDraft(config: CuratorConfig): CuratorDraft { return { interval_hours: config.curator.interval_hours, stale_after_days: config.curator.stale_after_days, archive_after_days: config.curator.archive_after_days, + model_name: config.curator.model_name, }; } @@ -284,6 +296,7 @@ function ConfigCard() { const tt = t.settings.skillEvolution.config; const { config, isLoading } = useCuratorConfig(); const updateConfig = useUpdateCuratorConfig(); + const { models } = useModels(); const [draft, setDraft] = useState(null); useEffect(() => { @@ -368,6 +381,36 @@ function ConfigCard() { } /> + + +