Current section

Files

Jump to
cmdc lib cmdc plugin builtin reflection.ex
Raw

lib/cmdc/plugin/builtin/reflection.ex

defmodule CMDC.Plugin.Builtin.Reflection do
@moduledoc """
P2 反思循环插件 —「生产者-评审者」模型。
在 Agent 即将完成前触发评审,未通过则注入修改指令让 Agent 继续工作,
直到通过或达到 `max_reviews`
## 两种评审模式
### 模式 A — **同智能体自评**(默认)
在 Agent 自身对话里注入 review prompt,让同一个 LLM 评审自己的输出。
优点:轻量、零额外成本;缺点:同模型自审存在"认知偏差"风险。
### 模式 B — **独立 Reviewer SubAgent**(推荐)
设置 `reviewer_subagent: true` 时,会启动一个独立的 SubAgent 作为
Reviewer(可搭配 `reviewer_model` 使用不同模型),对主 Agent 的最后一条
回复做评审。此模式实现真正的"生产者-评审者"角色分离,避开同模型自审偏差。
## 配置
{CMDC.Plugin.Builtin.Reflection,
reviewer_prompt: "你是一位严格的质量评审员...",
reviewer_model: "qwen3-max", # 可选,模式 B 时指定评审模型
reviewer_subagent: false, # 默认 false;true 启用 SubAgent
criteria: ["事实准确性", "完整性"], # 评审维度
max_reviews: 3, # 最大 review 轮数(防死循环)
pass_signal: "APPROVED" # 通过信号;可为字符串或 Regex
}
`max_iterations``max_reviews` 的历史别名,向后兼容,优先使用 `max_reviews`
## 状态机
:idle ──before_finish──> :reviewing ──after_response(pass)──> :done
├─after_response(fail)──> :reviewing (保持,等下次 before_finish)
└─max_reviews 达到──────> :done(强制通过)
一旦 `:done`,后续 `:before_finish` 不再触发评审(修复 v0.2 iteration reset bug)。
## 触发 Hook
- `:session_start` — 重置内部状态
- `:before_finish` — Agent 准备返回前触发评审
- `:after_response` — 评审期内检查回复是否含 `pass_signal`
"""
@behaviour CMDC.Plugin
require Logger
@default_max_reviews 3
@default_pass_signal "APPROVED"
@reviewer_timeout_ms 60_000
# ==========================================================================
# Plugin Callbacks
# ==========================================================================
@impl true
def init(opts) do
max_reviews =
Keyword.get(opts, :max_reviews) || Keyword.get(opts, :max_iterations, @default_max_reviews)
state = %{
reviewer_prompt: Keyword.get(opts, :reviewer_prompt, default_reviewer_prompt()),
reviewer_model: Keyword.get(opts, :reviewer_model),
reviewer_subagent: Keyword.get(opts, :reviewer_subagent, false),
criteria: Keyword.get(opts, :criteria, ["准确性", "完整性", "可读性"]),
max_reviews: max_reviews,
pass_signal: Keyword.get(opts, :pass_signal, @default_pass_signal),
phase: :idle,
iteration: 0
}
{:ok, state}
end
@impl true
def priority, do: 400
@impl true
def handle_event(:session_start, state, _ctx) do
{:continue, %{state | phase: :idle, iteration: 0}}
end
def handle_event(:before_finish, %{phase: :done} = state, _ctx) do
{:continue, state}
end
def handle_event(:before_finish, state, ctx) do
cond do
state.iteration >= state.max_reviews ->
Logger.warning("[Reflection] 达到最大评审轮数 #{state.max_reviews},强制通过")
{:continue, %{state | phase: :done}}
state.reviewer_subagent ->
run_subagent_review(state, ctx)
true ->
run_inline_review(state, ctx)
end
end
def handle_event({:after_response, assistant_msg}, %{phase: :reviewing} = state, _ctx) do
content = message_text(assistant_msg)
if passed?(content, state.pass_signal) do
Logger.info("[Reflection] 第 #{state.iteration} 轮评审通过,phase → :done")
{:continue, %{state | phase: :done}}
else
{:continue, state}
end
end
def handle_event(_event, state, _ctx), do: {:continue, state}
# ==========================================================================
# 模式 A — 内联自评
# ==========================================================================
defp run_inline_review(state, ctx) do
last_reply = get_last_assistant_reply(ctx)
review_prompt = build_review_prompt(last_reply, state)
Logger.info("[Reflection] 内联自评:第 #{state.iteration + 1}/#{state.max_reviews} 轮")
{:intervene, review_prompt, %{state | phase: :reviewing, iteration: state.iteration + 1}}
end
# ==========================================================================
# 模式 B — 独立 Reviewer SubAgent(ADP 推荐)
# ==========================================================================
defp run_subagent_review(state, ctx) do
last_reply = get_last_assistant_reply(ctx)
case spawn_reviewer_subagent(state, ctx, last_reply) do
{:ok, critique} ->
handle_subagent_critique(state, critique)
{:error, reason} ->
Logger.warning("[Reflection] Reviewer SubAgent 失败: #{inspect(reason)},降级为内联自评")
run_inline_review(state, ctx)
end
end
defp handle_subagent_critique(state, critique) do
if passed?(critique, state.pass_signal) do
Logger.info("[Reflection] SubAgent Reviewer 判定通过(第 #{state.iteration + 1} 轮)")
events = [{:reflection_approved, %{critique: critique, iteration: state.iteration + 1}}]
{:emit, events, %{state | phase: :done, iteration: state.iteration + 1}}
else
Logger.info("[Reflection] SubAgent Reviewer 给出修改建议(第 #{state.iteration + 1} 轮)")
intervene_prompt = build_revise_prompt(critique)
{:intervene, intervene_prompt, %{state | phase: :reviewing, iteration: state.iteration + 1}}
end
end
# 启动一个独立 SubAgent 作为 Reviewer,同步收集其最终回复作为 critique
defp spawn_reviewer_subagent(state, ctx, last_reply) do
review_task = build_review_prompt(last_reply, state)
reviewer_model = state.reviewer_model || Map.get(ctx, :model)
working_dir = Map.get(ctx, :working_dir, System.tmp_dir!())
parent_sid = Map.get(ctx, :session_id, "unknown")
session_id = "reflection-reviewer-#{parent_sid}-#{:rand.uniform(1_000_000)}"
agent_opts = [
session_id: session_id,
model: reviewer_model,
working_dir: working_dir,
blueprint_system_prompt: state.reviewer_prompt,
tools: [],
plugins: [],
prompt_mode: :task,
config: Map.get(ctx, :config) || %{}
]
start_result = CMDC.Agent.start_link(agent_opts)
case start_result do
{:ok, pid} when is_pid(pid) ->
CMDC.EventBus.subscribe(session_id)
CMDC.Agent.prompt(pid, review_task)
result = collect_reviewer_reply(session_id, "", @reviewer_timeout_ms)
CMDC.EventBus.unsubscribe(session_id)
safely_stop(pid)
result
{:error, reason} ->
{:error, reason}
:ignore ->
{:error, :reviewer_start_ignored}
end
rescue
e -> {:error, Exception.message(e)}
end
defp collect_reviewer_reply(session_id, acc, timeout) do
receive do
{:cmdc_event, ^session_id, {:response_complete, %{content: c}}} when is_binary(c) ->
collect_reviewer_reply(session_id, c, timeout)
{:cmdc_event, ^session_id, {:agent_end, _msgs, _usage}} ->
{:ok, acc}
{:cmdc_event, ^session_id, {:agent_abort, reason}} ->
{:error, {:aborted, reason}}
{:cmdc_event, ^session_id, _other} ->
collect_reviewer_reply(session_id, acc, timeout)
after
timeout -> {:error, :timeout}
end
end
defp safely_stop(pid) when is_pid(pid) do
if Process.alive?(pid), do: GenServer.stop(pid, :normal, 1_000)
rescue
_ -> :ok
catch
:exit, _ -> :ok
end
# ==========================================================================
# 私有辅助
# ==========================================================================
defp get_last_assistant_reply(%{last_assistant_reply: reply}) when is_binary(reply), do: reply
defp get_last_assistant_reply(_ctx), do: ""
defp message_text(%{content: c}) when is_binary(c), do: c
defp message_text(%{content: parts}) when is_list(parts), do: Enum.join(parts, "\n")
defp message_text(_), do: ""
defp passed?(content, %Regex{} = regex), do: Regex.match?(regex, content)
defp passed?(content, signal) when is_binary(signal) do
String.contains?(content, signal)
end
defp passed?(_content, _), do: false
defp build_review_prompt(output, state) do
criteria_text = Enum.map_join(state.criteria, "\n", &"- #{&1}")
pass_hint =
case state.pass_signal do
%Regex{source: src} -> "请确保评审结论中包含能匹配正则 `#{src}` 的文本"
s when is_binary(s) -> "如果所有维度均 >= 7 分,请在评审结论中包含 \"#{s}\""
_ -> "请在通过时明确标注 APPROVED"
end
"""
请评审以下输出,按照评审维度逐项评分(1-10 分),并给出改进建议。
评审维度:
#{criteria_text}
待评审内容:
---
#{String.slice(output, 0, 3000)}
---
#{pass_hint};否则请给出具体改进建议,Producer 会据此修改。
"""
end
defp build_revise_prompt(critique) do
"""
[Reviewer 反馈] Reviewer 给出了以下批评意见,请据此改进你的输出:
#{String.slice(critique, 0, 3000)}
请直接产出修订后的内容,不要重复 Reviewer 的意见。
"""
end
defp default_reviewer_prompt do
"你是一位严格的质量评审员。你的任务是评审 AI 助手的输出质量," <>
"确保其准确、完整、可读。按指定的评审维度逐项评分并给出改进建议。"
end
end