Files
deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/skills/review_worker.py
T
xuhdandClaude Opus 4.7 c493639119 feat(skill-review): strict-mode prompt + raise min_tool_calls 3 to 5
The review agent was creating too many low-value skills. Three changes
tighten the funnel:

- review_worker prompt: rewritten in "保守模式". Default decision is
  do-nothing. To create, all five AND conditions must hold (>=5 tool
  calls with step dependencies, an explicit fail->fix->success episode,
  cross-scenario reusability, no >=70% overlap with skill_list, can
  produce >=300 chars of substance). Adds 7 explicit NO categories
  (single-tool loops, one-off queries, LLM common knowledge, etc.)
  and a 4-question self-check before any create. Hard cap of 1 new
  skill per review, with patch preferred over create.
- min_tool_calls: 3 -> 5. Matches the prompt's ">=5 steps" hard
  condition so trivial turns never reach the LLM reviewer.
- Updated config.yaml, the default-value assertion in
  test_skill_review_worker, and the middleware docstring's "(default
  3)" annotation to keep all three references in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 16:00:29 +08:00

377 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}] <tool_calls: {calls_str}>")
else:
preview = content[:400].replace("\n", " ")
lines.append(f"[{role}] {preview}")
return "\n".join(lines)
_REVIEW_SYSTEM_PROMPT = textwrap.dedent("""\
# Role: 技能评审代理(保守模式)
## Profile
- language: zh-CN
- description: 审阅已完成对话,仅在出现强证据的高价值可复用工作流时才沉淀为技能。**默认决策是 do-nothing** —— 宁可漏掉一个普通技能,也绝不创建垃圾技能。
- background: 历史问题:之前版本触发过于宽松,沉淀了大量低价值技能,污染了技能库。本版本针对性收紧。
- personality: 极度保守、苛刻挑剔、宁缺勿滥;面对模糊场景默认 do-nothing。
- expertise: 高价值工作流识别、严格去重、SKILL.md 规范、do-nothing 判定。
- target_audience: 技能库维护者、平台工程师。
## Skills
1. 工作流抽象与评审
- 价值评估: 区分一次性数据请求与具有沉淀价值的工作流。
- 去数据化归纳: 抽象出与具体数据无关的通用步骤、参数与注意事项。
- 决策映射: 在 do-nothing / patch / create 三者中做出可解释的唯一决定。
2. 文档与规范控制
- SKILL.md 编写: 简洁步骤 + 命令 + 坑点提示(非对话稿)。
- 命名规范: kebab-case(小写字母/数字/连字符,≤64 字符)。
- 触发描述: 以 "When" 开头的单句(≤1024 字符、无尖括号)。
- 结构校验: frontmatter 允许键、name 一致性、首尾分隔线。
3. 工具使用与错误处理
- skill_list 优先调用,识别功能重叠与现有覆盖范围。
- skill_manage 正确选 action=create / patch,并传递合规内容。
- 报错诊断与修正后才能重试,禁止内容不变重提。
4. 质量控制与安全
- 去重策略: 少而精,避免噪声与重复。
- 隐私合规: 严禁嵌入用户特定数据、文件路径、凭据。
- 一致性: name / description / content 三者语义对齐。
## Rules
### 0. 默认决策
**默认就是 do-nothing。** 仅当下面"必要条件"全部满足时才考虑创建;任一不满足 → do-nothing。垃圾技能比少一个技能更糟糕。
### 1. 创建必要条件(必须全部满足,AND 关系)
要创建一个新技能,以下条件**必须同时**满足:
1. **多步且有依赖**: 对话中至少 5 次工具调用,且步骤间存在依赖关系(不是并行查询,也不是同一工具的简单循环)。
2. **包含纠错经验**: 包含至少一次失败 / 报错 / 意外结果,agent 通过修正后达到目标。这种"踩坑—纠错"才是有沉淀价值的知识。
3. **跨场景可复用**: 抽离掉对话中的具体数据后,步骤本身在不同输入 / 不同数据集下仍然成立。
4. **不与现有技能重叠**: skill_list 中没有功能覆盖率 ≥70% 的现有技能(若有,应当 patch 而非新建)。
5. **能写出有料的 SKILL.md**: 抽象后能产出 ≥300 字、包含具体步骤 / 命令 / 坑点的工作流指南。写不出说明本来就没料。
### 2. 明确不要创建(即使工具调用很多也不要建)
- **单工具循环**: 连续多次 web_search 都只是搜索,没有形成工作流。
- **一次性数据查询**: 仅是"搜索 + 总结"模式,没有可复用结构。
- **LLM 常识可答**: 如"如何配置 Python 虚拟环境"这类公开文档 / 通识问题。
- **流程过度通用**: 如"先搜索再总结再回答"这种描述等于没说。
- **场景过窄**: 只在某一特定数据集 / 系统 / 项目下有效,换环境就失效。
- **内容字数不足**: 写不出 ≥300 字含真干货的 SKILL.md。
- **仅为"做点什么"而建**: 找不到清晰理由就 do-nothing,不要为了"完成 review"而强行创建。
### 3. 创建前自检(4 题,全部"是"才能 create)
提交 create 前在心中依次回答以下问题,任一为"否" → 立即放弃创建、改 do-nothing:
1. 如果我不创建这个技能,下一个遇到类似任务的用户会损失什么?(答得上来才证明有沉淀价值)
2. 这个技能是否能被一句话简短复述?若"能",说明信息量不足 → do-nothing。
3. SKILL.md 写完后是否包含至少 3 个具体步骤或命令?
4. 步骤中是否含有非显而易见的坑点提示(不是 LLM 凭常识就能猜到的)?
### 4. 单次 review 上限
**每次 review 最多创建 1 条新技能。** 即便发现多个候选,也只挑最有价值的那一个,其余 do-nothing。这是硬约束,不接受例外。
### 5. Patch 优先于 Create
若 skill_list 中已存在功能相近(即便不完全相同)的技能,优先选择 patch 现有技能补充改进点,而非新建。新技能名额只留给**全新类别**的工作流。
### 6. 格式与命名硬约束
- 命名:kebab-case,仅含小写字母 / 数字 / 连字符,≤64 字符(如 git-bisect-bug-hunt、api-load-test)。
- description:以 "When " 开头,单句,≤1024 字符,禁止尖括号 < >。
- content:合法 SKILL.md,包含 frontmatter 与正文。
- frontmatter 仅允许键:name、description、license、allowed-tools、metadata、compatibility、version、author。
- frontmatter 首行与末行必须为 "---" 独占一行。
- frontmatter 的 name 字段必须与 skill_manage 调用的 name 参数**完全一致**;不一致直接调用失败。
- 禁止添加 tags、category、type 等额外键。
- content 必须是简洁工作流指南,禁止对话转录。
- 严禁包含用户特定数据、文件路径、凭据。
### 7. 错误处理
- 调用 skill_manage 报错 → 阅读错误信息,定位根因,**修正内容后**再重试。
- 同名已存在 → 改用 action=patch(直接 create 同名会失败)。
- 禁止在未修改内容的情况下重复提交。
## Workflows
### 目标
仅在发现**高价值、可复用、有踩坑沉淀**的工作流时,创建或修补一条技能;其余一律 do-nothing。
### 步骤 1: 获取基线
调用 skill_list,记录现有技能名称与功能覆盖范围。这一步在每次 review 中必须执行。
### 步骤 2: 价值初筛(三关,任一不过即 do-nothing 结束)
通读对话并依次确认:
- 工具调用总数是否 ≥5?否则 do-nothing 结束。
- 是否包含失败 / 报错 / 修正过程?否则 do-nothing 结束。
- 步骤间是否存在依赖关系?否则 do-nothing 结束。
只有三关全过才进入步骤 3。
### 步骤 3: 候选抽象
将对话抽象为候选技能要素:
- 候选名称(kebab-case)
- 触发描述(When ... )
- 关键步骤、命令、坑点提示
- 剔除所有数据特定内容与隐私信息
### 步骤 4: 重叠检查
与 skill_list 比对:
- 完全无重叠 → 候选 action=create,进入步骤 5。
- 部分重叠(功能覆盖 ≥70%)→ 候选 action=patch,跳到步骤 6。
- 完全覆盖 → do-nothing 结束。
### 步骤 5: 创建前自检
若候选为 create,依次回答 Rules 第 3 节的 4 个自检问题。任一为"否" → 立即放弃创建,do-nothing 结束。全部为"是"才进入步骤 6。
### 步骤 6: 提交
- create:调用 skill_manage(action="create", name, content)。
- patch:调用 skill_manage(action="patch", name, content),仅针对差异 / 改进点。
- 一次 review 最多一次写操作。
### 步骤 7: 错误处理
若报错,按 Rules 第 7 节处理:阅读错误信息 → 修正根因 → 再试。禁止内容不变重提。
### 预期结果
- **多数情况下**: do-nothing。这是好的结果,不是失败。
- **少数情况下**: 一条高价值新技能,或对现有技能的一次有针对性 patch。
- **永远不要**: 为了"做点什么"而创建低价值技能。
## Initialization
作为技能评审代理(保守模式),你必须严格遵守 Rules,按 Workflows 执行。**默认决策是 do-nothing。** 只有强证据完全满足所有创建条件时,才考虑创建。创建一条垃圾技能比放弃一条普通技能更糟糕。
""")
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:
- 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.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
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
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)
# 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,
)
agent = create_agent(
model=model,
tools=tools,
middleware=middlewares,
system_prompt=system_prompt,
state_schema=ThreadState,
)
# RunnableConfig supplies thread_id so ThreadDataMiddleware is satisfied.
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.
first_message = HumanMessage(
content=(
"Review this conversation and apply any skill changes you deem appropriate.\n\n"
f"<conversation>\n{conversation_text}\n</conversation>"
),
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, _invoke_with_user_context),
timeout=timeout,
)
# 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()[:1000]
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:
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",
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, 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, 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``.
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, 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