Current section
Files
Jump to
Current section
Files
lib/cmdc/plan.ex
defmodule CMDC.Plan do
@moduledoc """
结构化执行计划 — Agent Planning Pattern 的运行时实体。
`Plan` 将一个高层目标(goal)拆成一列可执行的 **Step**,并通过
状态机语义 `:pending → :in_progress → :completed | :failed | :skipped`
记录 Agent 的进度。Plugin / Tool / 用户都可以读写计划,从而实现
"规划→执行→反馈"的闭环。
配套插件:`CMDC.Plugin.Builtin.Planning`(v0.3)
## 构造
iex> plan = CMDC.Plan.new("发布新版", ["写 CHANGELOG", "跑 preflight", "mix hex.publish"])
iex> length(plan.steps)
3
iex> {:ok, plan} = CMDC.Plan.from_markdown("发布", \"\"\"
...> - [ ] 写 CHANGELOG
...> - [ ] 跑 preflight
...> - [x] 跑 mix format
...> \"\"\")
iex> Enum.count(plan.steps, &(&1.status == :completed))
1
## Step 状态流转
- `step_started/2`:`:pending → :in_progress`
- `step_completed/3`:`:pending | :in_progress → :completed`
- `step_failed/3`:`:pending | :in_progress → :failed`
- `step_skipped/3`:`:pending → :skipped`
非法流转(如 `:completed → :in_progress`)返回 `{:error, {:illegal_transition, from, to}}`。
## 查询
iex> plan = CMDC.Plan.new("task", ["a", "b", "c"])
iex> {:ok, plan} = CMDC.Plan.step_completed(plan, "step-1")
iex> CMDC.Plan.progress(plan)
%{completed: 1, total: 3, failed: 0, skipped: 0, pending: 2, percentage: 33.3}
iex> plan = CMDC.Plan.new("task", ["a", "b"])
iex> %{id: "step-1"} = CMDC.Plan.current_step(plan)
## 渲染
- `to_markdown/1`:渲染为 GitHub Checklist(`- [x]`)
- `to_prompt_section/1`:渲染为注入 system prompt 的结构化段落
## 与 Plugin 的配合
`CMDC.Plugin.Builtin.Planning` 在 Plugin state 里保存 `%CMDC.Plan{}`,
并在关键生命周期 emit `:plan_generated` / `:plan_step_completed` / `:plan_completed` 事件。
第三方 Tool 可通过 Plugin state 或 EventBus 读写。
"""
alias __MODULE__
# ==========================================================================
# Step — 单个可执行步骤
# ==========================================================================
defmodule Step do
@moduledoc "Plan 里的单个步骤。"
@derive Jason.Encoder
@type status :: :pending | :in_progress | :completed | :failed | :skipped
@type t :: %__MODULE__{
id: String.t(),
description: String.t(),
status: status(),
result: any() | nil,
error: String.t() | nil,
notes: String.t() | nil,
started_at: DateTime.t() | nil,
completed_at: DateTime.t() | nil
}
@enforce_keys [:id, :description]
defstruct [
:id,
:description,
:result,
:error,
:notes,
:started_at,
:completed_at,
status: :pending
]
end
# ==========================================================================
# Plan — 顶层结构
# ==========================================================================
@derive Jason.Encoder
@type t :: %__MODULE__{
goal: String.t(),
steps: [Step.t()],
created_at: DateTime.t() | nil,
approved_at: DateTime.t() | nil,
metadata: map()
}
@enforce_keys [:goal, :steps]
defstruct [
:goal,
:steps,
:created_at,
:approved_at,
metadata: %{}
]
# ==========================================================================
# 构造
# ==========================================================================
@doc """
从目标 + 步骤描述列表构建新 Plan。
每个步骤自动分配 `step-N` 格式的 id。
iex> plan = CMDC.Plan.new("任务", ["A", "B"])
iex> Enum.map(plan.steps, & &1.id)
["step-1", "step-2"]
iex> Enum.all?(plan.steps, &(&1.status == :pending))
true
"""
@spec new(String.t(), [String.t()]) :: t()
def new(goal, step_descriptions)
when is_binary(goal) and is_list(step_descriptions) do
steps =
step_descriptions
|> Enum.with_index(1)
|> Enum.map(fn {desc, i} ->
%Step{id: "step-#{i}", description: desc}
end)
%Plan{
goal: goal,
steps: steps,
created_at: DateTime.utc_now()
}
end
@doc """
从 markdown checklist 字符串解析 Plan。
支持以下两种格式:
- `- [ ]` / `- [x]` / `- [X]` — GitHub Checklist
- `1. xxx` / `1) xxx` — 有序列表(全部按 pending 处理)
未能识别为列表项的行被忽略,可用于 LLM 在 checklist 前后附加解释性文字。
iex> md = \"\"\"
...> 这是计划:
...>
...> - [ ] 步骤一
...> - [x] 步骤二 已完成
...> - [ ] 步骤三
...>
...> 希望有用。
...> \"\"\"
iex> {:ok, plan} = CMDC.Plan.from_markdown("demo", md)
iex> length(plan.steps)
3
iex> Enum.at(plan.steps, 1).status
:completed
返回 `{:error, :no_steps_found}` 当解析结果为空。
"""
@spec from_markdown(String.t(), String.t()) :: {:ok, t()} | {:error, :no_steps_found}
def from_markdown(goal, markdown) when is_binary(goal) and is_binary(markdown) do
steps =
markdown
|> String.split("\n", trim: false)
|> Enum.map(&parse_line/1)
|> Enum.reject(&is_nil/1)
|> Enum.with_index(1)
|> Enum.map(fn {{status, desc}, i} ->
%Step{id: "step-#{i}", description: desc, status: status}
end)
case steps do
[] ->
{:error, :no_steps_found}
_ ->
{:ok,
%Plan{
goal: goal,
steps: steps,
created_at: DateTime.utc_now()
}}
end
end
# ==========================================================================
# Step 状态流转(Railway pattern — 接受 plan 或 {:ok, plan})
# ==========================================================================
@doc """
标记某个 step 已开始执行(`:pending → :in_progress`)。
iex> plan = CMDC.Plan.new("t", ["a"])
iex> {:ok, plan} = CMDC.Plan.step_started(plan, "step-1")
iex> CMDC.Plan.get_step(plan, "step-1").status
:in_progress
"""
@spec step_started(t() | {:ok, t()}, String.t()) ::
{:ok, t()} | {:error, :not_found | {:illegal_transition, Step.status(), atom()}}
def step_started(input, step_id), do: transition(input, step_id, :in_progress)
@doc """
标记某个 step 已完成,可附带结果数据(`:pending | :in_progress → :completed`)。
`Plan.step_completed/2` 为 **任务 11.16** 定义的 API,允许 Tool/外部调用方
在完成某个步骤后批量推进计划。
iex> plan = CMDC.Plan.new("t", ["a", "b"])
iex> {:ok, plan} = CMDC.Plan.step_completed(plan, "step-1", result: "ok")
iex> step = CMDC.Plan.get_step(plan, "step-1")
iex> step.status
:completed
iex> step.result
"ok"
"""
@spec step_completed(t() | {:ok, t()}, String.t(), keyword()) ::
{:ok, t()} | {:error, :not_found | {:illegal_transition, Step.status(), atom()}}
def step_completed(input, step_id, opts \\ []) do
transition(input, step_id, :completed, Keyword.take(opts, [:result, :notes]))
end
@doc """
标记某个 step 已失败。
iex> plan = CMDC.Plan.new("t", ["a"])
iex> {:ok, plan} = CMDC.Plan.step_failed(plan, "step-1", "网络超时")
iex> CMDC.Plan.get_step(plan, "step-1").error
"网络超时"
"""
@spec step_failed(t() | {:ok, t()}, String.t(), String.t()) ::
{:ok, t()} | {:error, :not_found | {:illegal_transition, Step.status(), atom()}}
def step_failed(input, step_id, error) when is_binary(error) do
transition(input, step_id, :failed, error: error)
end
@doc """
标记某个 step 已跳过(`:pending → :skipped`)。
"""
@spec step_skipped(t() | {:ok, t()}, String.t(), String.t() | nil) ::
{:ok, t()} | {:error, :not_found | {:illegal_transition, Step.status(), atom()}}
def step_skipped(input, step_id, reason \\ nil) do
notes_opt = if reason, do: [notes: reason], else: []
transition(input, step_id, :skipped, notes_opt)
end
@doc """
附加 notes 到某个 step,不改变其状态。可在 Step 执行前后写入上下文信息。
iex> plan = CMDC.Plan.new("t", ["a"])
iex> {:ok, plan} = CMDC.Plan.annotate_step(plan, "step-1", "需要审批")
iex> CMDC.Plan.get_step(plan, "step-1").notes
"需要审批"
"""
@spec annotate_step(t() | {:ok, t()} | {:error, term()}, String.t(), String.t()) ::
{:ok, t()} | {:error, :not_found | term()}
def annotate_step({:ok, %Plan{} = plan}, step_id, notes),
do: annotate_step(plan, step_id, notes)
def annotate_step({:error, _} = err, _step_id, _notes), do: err
def annotate_step(%Plan{} = plan, step_id, notes) when is_binary(notes) do
case get_step(plan, step_id) do
nil ->
{:error, :not_found}
_ ->
{:ok, update_step(plan, step_id, fn step -> %{step | notes: notes} end)}
end
end
@doc "用户批准 Plan,记录 approved_at 时间戳(供后续审计)。"
@spec approve(t()) :: t()
def approve(%Plan{} = plan), do: %{plan | approved_at: DateTime.utc_now()}
# ==========================================================================
# 动态重规划(运行期 step 修改 / 增删 / 重排)
# ==========================================================================
@doc """
**完全替换** Plan 的步骤列表,重新从 `step-1` 开始编号。
当 LLM 根据新情况(障碍 / 新信息 / 用户反馈)重新生成整份计划时使用。
执行后 `approved_at` 会被清空(需要重新批准),`metadata[:last_replanned_at]`
记录重规划时间戳。
iex> plan = CMDC.Plan.new("旅行", ["订机票", "订酒店"])
iex> plan = CMDC.Plan.approve(plan)
iex> plan = CMDC.Plan.replace_steps(plan, ["重新选择目的地", "订机票", "订酒店"])
iex> length(plan.steps)
3
iex> plan.approved_at
nil
"""
@spec replace_steps(t(), [String.t()]) :: t()
def replace_steps(%Plan{} = plan, step_descriptions) when is_list(step_descriptions) do
new_steps =
step_descriptions
|> Enum.with_index(1)
|> Enum.map(fn {desc, i} -> %Step{id: "step-#{i}", description: desc} end)
plan
|> Map.put(:steps, new_steps)
|> Map.put(:approved_at, nil)
|> put_metadata(:last_replanned_at, DateTime.utc_now())
end
@doc """
在计划末尾追加一个步骤。自动分配下一个 `step-N` id(基于现有最大编号 + 1)。
iex> plan = CMDC.Plan.new("t", ["a"])
iex> plan = CMDC.Plan.add_step(plan, "b")
iex> Enum.map(plan.steps, & &1.id)
["step-1", "step-2"]
"""
@spec add_step(t(), String.t(), keyword()) :: t()
def add_step(%Plan{} = plan, description, opts \\ []) when is_binary(description) do
new_id = opts[:id] || next_step_id(plan)
new_step = %Step{id: new_id, description: description}
plan
|> Map.put(:steps, plan.steps ++ [new_step])
|> put_metadata(:last_replanned_at, DateTime.utc_now())
end
@doc """
在指定 step_id 之前(`before: true`)或之后(默认)插入新步骤。
新步骤的 id 自动分配。不存在的 step_id 返回 `{:error, :not_found}`。
iex> plan = CMDC.Plan.new("t", ["a", "c"])
iex> {:ok, plan} = CMDC.Plan.insert_step(plan, "step-1", "b")
iex> Enum.map(plan.steps, & &1.description)
["a", "b", "c"]
"""
@spec insert_step(t(), String.t(), String.t(), keyword()) ::
{:ok, t()} | {:error, :not_found}
def insert_step(%Plan{} = plan, anchor_id, description, opts \\ [])
when is_binary(anchor_id) and is_binary(description) do
case Enum.find_index(plan.steps, &(&1.id == anchor_id)) do
nil ->
{:error, :not_found}
idx ->
new_id = opts[:id] || next_step_id(plan)
new_step = %Step{id: new_id, description: description}
offset = if opts[:before], do: 0, else: 1
{before_part, after_part} = Enum.split(plan.steps, idx + offset)
new_plan =
plan
|> Map.put(:steps, before_part ++ [new_step] ++ after_part)
|> put_metadata(:last_replanned_at, DateTime.utc_now())
{:ok, new_plan}
end
end
@doc """
删除指定 id 的步骤。不存在返回 `{:error, :not_found}`。
iex> plan = CMDC.Plan.new("t", ["a", "b"])
iex> {:ok, plan} = CMDC.Plan.remove_step(plan, "step-1")
iex> Enum.map(plan.steps, & &1.description)
["b"]
"""
@spec remove_step(t(), String.t()) :: {:ok, t()} | {:error, :not_found}
def remove_step(%Plan{} = plan, step_id) when is_binary(step_id) do
case get_step(plan, step_id) do
nil ->
{:error, :not_found}
_ ->
new_steps = Enum.reject(plan.steps, &(&1.id == step_id))
{:ok,
plan
|> Map.put(:steps, new_steps)
|> put_metadata(:last_replanned_at, DateTime.utc_now())}
end
end
# ==========================================================================
# 查询
# ==========================================================================
@doc "根据 step_id 获取 Step,找不到返回 `nil`。"
@spec get_step(t(), String.t()) :: Step.t() | nil
def get_step(%Plan{steps: steps}, step_id) do
Enum.find(steps, &(&1.id == step_id))
end
@doc """
返回当前应执行的 step(第一个 `:in_progress`,否则第一个 `:pending`)。
全部完成/失败/跳过时返回 `nil`。
"""
@spec current_step(t()) :: Step.t() | nil
def current_step(%Plan{steps: steps}) do
Enum.find(steps, &(&1.status == :in_progress)) ||
Enum.find(steps, &(&1.status == :pending))
end
@doc """
返回 Plan 进度摘要:各状态计数 + 百分比。
iex> plan = CMDC.Plan.new("t", ["a", "b", "c", "d"])
iex> {:ok, plan} = CMDC.Plan.step_completed(plan, "step-1")
iex> {:ok, plan} = CMDC.Plan.step_failed(plan, "step-2", "err")
iex> {:ok, plan} = CMDC.Plan.step_skipped(plan, "step-3")
iex> CMDC.Plan.progress(plan)
%{completed: 1, failed: 1, skipped: 1, pending: 1, total: 4, percentage: 75.0}
"""
@spec progress(t()) :: %{
completed: non_neg_integer(),
failed: non_neg_integer(),
skipped: non_neg_integer(),
pending: non_neg_integer(),
total: non_neg_integer(),
percentage: float()
}
def progress(%Plan{steps: steps}) do
counts =
Enum.reduce(steps, %{completed: 0, failed: 0, skipped: 0, pending: 0, in_progress: 0}, fn
step, acc -> Map.update!(acc, step.status, &(&1 + 1))
end)
total = length(steps)
finished = counts.completed + counts.failed + counts.skipped
pending = counts.pending + counts.in_progress
percentage =
case total do
0 -> 0.0
_ -> Float.round(finished * 100 / total, 1)
end
%{
completed: counts.completed,
failed: counts.failed,
skipped: counts.skipped,
pending: pending,
total: total,
percentage: percentage
}
end
@doc """
判断所有 step 是否都进入终态(completed / failed / skipped)。
仅在全部 step 都已推进时返回 `true`。
"""
@spec all_finished?(t()) :: boolean()
def all_finished?(%Plan{steps: steps}) do
Enum.all?(steps, &(&1.status in [:completed, :failed, :skipped]))
end
@doc "判断是否所有 step 都成功完成。"
@spec all_completed?(t()) :: boolean()
def all_completed?(%Plan{steps: steps}) do
Enum.all?(steps, &(&1.status == :completed))
end
# ==========================================================================
# 渲染
# ==========================================================================
@doc """
将 Plan 渲染为 Markdown Checklist。`:completed` 为 `[x]`,其他状态附带 tag。
iex> plan = CMDC.Plan.new("发布", ["写 CHANGELOG", "发布"])
iex> {:ok, plan} = CMDC.Plan.step_completed(plan, "step-1")
iex> CMDC.Plan.to_markdown(plan)
"- [x] 写 CHANGELOG\\n- [ ] 发布"
"""
@spec to_markdown(t()) :: String.t()
def to_markdown(%Plan{steps: steps}) do
Enum.map_join(steps, "\n", &render_step_md/1)
end
@doc """
将 Plan 渲染为注入 system prompt 的结构化段落。含目标、进度摘要、全部步骤、当前步骤。
"""
@spec to_prompt_section(t()) :: String.t()
def to_prompt_section(%Plan{goal: goal} = plan) do
%{completed: c, failed: f, skipped: s, total: t, percentage: p} = progress(plan)
current =
case current_step(plan) do
nil -> "(全部步骤完成)"
%Step{id: id, description: desc} -> "#{id}: #{desc}"
end
"""
## Current Plan
Goal: #{goal}
Progress: #{c}/#{t} completed · #{f} failed · #{s} skipped (#{p}%)
Current step: #{current}
Steps:
#{to_markdown(plan)}
"""
end
# ==========================================================================
# 私有 — 状态流转核心
# ==========================================================================
defp transition(input, step_id, new_status, extras \\ [])
defp transition({:ok, %Plan{} = plan}, step_id, new_status, extras),
do: transition(plan, step_id, new_status, extras)
defp transition({:error, _} = err, _step_id, _new_status, _extras), do: err
defp transition(%Plan{} = plan, step_id, new_status, extras) do
case get_step(plan, step_id) do
nil ->
{:error, :not_found}
%Step{status: current} ->
if allowed?(current, new_status) do
{:ok, update_step(plan, step_id, &apply_transition(&1, new_status, extras))}
else
{:error, {:illegal_transition, current, new_status}}
end
end
end
defp apply_transition(%Step{} = step, new_status, extras) do
now = DateTime.utc_now()
step
|> Map.put(:status, new_status)
|> merge_extras(extras)
|> stamp_transition(new_status, now)
end
defp merge_extras(step, []), do: step
defp merge_extras(step, extras) do
Enum.reduce(extras, step, fn
{k, v}, acc when k in [:result, :notes, :error] -> Map.put(acc, k, v)
_, acc -> acc
end)
end
defp stamp_transition(step, :in_progress, now), do: %{step | started_at: now}
defp stamp_transition(step, status, now)
when status in [:completed, :failed, :skipped] do
%{step | completed_at: now}
end
defp stamp_transition(step, _status, _now), do: step
@doc false
def update_step(%Plan{steps: steps} = plan, step_id, fun) when is_function(fun, 1) do
new_steps =
Enum.map(steps, fn
%Step{id: ^step_id} = s -> fun.(s)
other -> other
end)
%{plan | steps: new_steps}
end
# 允许的状态流转矩阵
defp allowed?(:pending, :in_progress), do: true
defp allowed?(:pending, :completed), do: true
defp allowed?(:pending, :failed), do: true
defp allowed?(:pending, :skipped), do: true
defp allowed?(:in_progress, :completed), do: true
defp allowed?(:in_progress, :failed), do: true
defp allowed?(:in_progress, :skipped), do: true
defp allowed?(same, same), do: true
defp allowed?(_from, _to), do: false
# 计算下一个 step-N id(基于现有 step-N 的最大数字 + 1)
defp next_step_id(%Plan{steps: steps}) do
max_n =
steps
|> Enum.map(& &1.id)
|> Enum.map(&extract_step_number/1)
|> Enum.reject(&is_nil/1)
|> Enum.max(fn -> 0 end)
"step-#{max_n + 1}"
end
defp extract_step_number("step-" <> rest) do
case Integer.parse(rest) do
{n, ""} -> n
_ -> nil
end
end
defp extract_step_number(_), do: nil
defp put_metadata(%Plan{metadata: meta} = plan, key, value) do
%{plan | metadata: Map.put(meta || %{}, key, value)}
end
# ==========================================================================
# 私有 — markdown 解析 & 渲染
# ==========================================================================
# 返回 {status, description} | nil
defp parse_line(line) do
trimmed = String.trim_leading(line)
cond do
match = Regex.run(~r/^[-*]\s+\[([ xX])\]\s+(.+)$/u, trimmed) ->
[_, check, desc] = match
status = if check in ["x", "X"], do: :completed, else: :pending
{status, String.trim(desc)}
match = Regex.run(~r/^\d+[\.\)]\s+(.+)$/u, trimmed) ->
[_, desc] = match
{:pending, String.trim(desc)}
true ->
nil
end
end
defp render_step_md(%Step{status: :completed, description: desc}), do: "- [x] #{desc}"
defp render_step_md(%Step{status: :failed, description: desc, error: err}) do
err_note = if err, do: " — #{err}", else: ""
"- [!] #{desc} [failed]#{err_note}"
end
defp render_step_md(%Step{status: :skipped, description: desc}), do: "- [~] #{desc} [skipped]"
defp render_step_md(%Step{status: :in_progress, description: desc}),
do: "- [→] #{desc} [in progress]"
defp render_step_md(%Step{description: desc}), do: "- [ ] #{desc}"
end