看一下官网上的描述先:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 快速启用
ReActAgent agent = ReActAgent.builder()
.name("Assistant")
.model(model)
.enablePlan()
.build();

// 自定义配置
PlanNotebook planNotebook = PlanNotebook.builder()
.maxSubtasks(15)
.build();

ReActAgent agent = ReActAgent.builder()
.name("Assistant")
.model(model)
.planNotebook(planNotebook)
.build();

那么要看的就是三个点

  • planNotebook 方法(直接enablePlan有默认实现也可以注意一下)
  • PlanNotebook 类

enablePlan先入手看看默认情况下的实现。

enablePlan

首先根据方法注释:

开启enablePlan等价于创建了一个默认的PlanNotebook

1
planNotebook(PlanNotebook.builder().build())

那其实也就回到了上面的问题:PlanNotebook类。

从管理来说先看一下类说明:

PlanNotebook 是一个用于通过结构化规划来管理复杂任务的笔记本。

它为智能体提供了创建、管理和跟踪计划的工具函数。通过基于钩子的机制,自动注入上下文提示来引导智能体执行。

核心特性:

  • 计划管理:创建、修订和完成包含多个子任务的计划
  • 自动提示注入:在每个推理步骤之前注入上下文提示
  • 状态跟踪:跟踪子任务状态(待办/进行中/已完成/已放弃)
  • 历史计划:存储和恢复历史计划

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用自定义配置创建 PlanNotebook
PlanNotebook planNotebook = PlanNotebook.builder()
.planToHint(new DefaultPlanToHint())
.storage(new InMemoryPlanStorage())
.maxSubtasks(10)
.build();

// 创建带有 PlanNotebook 的 Agent(自动注册工具和钩子)
ReActAgent agent = ReActAgent.builder()
.name("Assistant")
.model(model)
.toolkit(toolkit)
.planNotebook(planNotebook)
.build();

// 或使用默认的 PlanNotebook 配置
ReActAgent agent = ReActAgent.builder()
.name("Assistant")
.model(model)
.toolkit(toolkit)
.enablePlan()
.build();

// 现在智能体将在每个推理步骤之前自动接收提示
agent.call(msg).block();

工具函数: PlanNotebook 提供 10 个工具函数:

  • createPlan - 创建新计划
  • updatePlanInfo - 更新当前计划的名称、描述或预期结果
  • reviseCurrentPlan - 添加、修订或删除子任务
  • updateSubtaskState - 更新子任务状态
  • finishSubtask - 标记子任务为已完成
  • viewSubtasks - 查看子任务详情
  • getSubtaskCount - 获取当前计划中的子任务数量
  • finishPlan - 完成或放弃计划
  • viewHistoricalPlans - 查看历史计划
  • recoverHistoricalPlan - 恢复历史计划

所以当开启一个PlanNotebook之后本质上是在Agent中增加了一个Toolkit工具集。

然后回头看PlanNotebookBuilder看看具体有哪些配置项:

1
2
3
4
5
private PlanToHint planToHint = new DefaultPlanToHint();
private PlanStorage storage = new InMemoryPlanStorage();
private Integer maxSubtasks = null;
private boolean needUserConfirm = true;
private String keyPrefix = null;

PlanToHint

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 用于根据当前计划状态生成上下文提示的接口。
*
* 实现类会分析计划及其子任务,生成适当的指导消息,
* 帮助智能体理解下一步应采取什么行动。
*/
public interface PlanToHint {

/**
* 根据当前计划状态生成提示消息。
*
* @param plan 当前计划(如果不存在计划则可以为 null)
* @return 生成的提示消息,如果不适用则返回 null
*/
default String generateHint(Plan plan) {
return generateHint(plan, PlanNotebook.builder().needUserConfirm(true).build());
}

/**
* 根据当前计划状态和 PlanNotebook 配置生成提示消息。
*
* @param plan 当前计划(如果不存在计划则可以为 null)
* @param planNotebook 相关的 PlanNotebook 配置
* @return 生成的提示消息,如果不适用则返回 null
*/
String generateHint(Plan plan, PlanNotebook planNotebook);
}

根据接口来说, 根据PlanNotebook的状态来提供对应的提示信息,那怎么个提示法呢,先看看DefaultPlanToHint是怎么提示的。

看起来无非几个状态:

  • 无计划: 引导智能体为复杂任务创建计划
  • 初始阶段: 所有子任务都处于待办状态
  • 子任务进行中: 一个子任务正在积极执行
  • 无子任务进行中: 一些任务已完成,但当前没有进行中的任务
  • 结束阶段: 所有子任务都已完成或放弃

无计划

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// no plan description with optional append rules
StringBuilder appendRules = new StringBuilder();
if (planNotebook.isNeedUserConfirm()) {
appendRules.append(confirmationRule);
}
if (planNotebook.getMaxSubtasks() != null) {
appendRules.append(
RULE_SUBTASK_LIMIT.replace(
"{max_subtasks}", String.valueOf(planNotebook.getMaxSubtasks())));
}
if (appendRules.isEmpty()) {
hint = NO_PLAN;
} else {
hint = NO_PLAN + IMPORTANT_RULES_SEPARATOR + appendRules;
}

根据上述代码所写的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
flowchart TD
Start([开始生成无计划提示]) --> Init[初始化 appendRules]
Init --> CheckConfirm{需要用户确认?}
CheckConfirm -->|是| AddConfirm[添加 confirmationRule]
CheckConfirm -->|否| CheckMax{设置了 maxSubtasks?}
AddConfirm --> CheckMax
CheckMax -->|是| AddMax[添加 RULE_SUBTASK_LIMIT 并替换占位符]
CheckMax -->|否| CheckEmpty{appendRules 为空?}
AddMax --> CheckEmpty
CheckEmpty -->|是| Hint1[hint = NO_PLAN]
CheckEmpty -->|否| Hint2[hint = NO_PLAN + IMPORTANT_RULES_SEPARATOR + appendRules]
Hint1 --> End([返回 hint])
Hint2 --> End

流程说明:

  1. 首先初始化一个 StringBuilder 用于存储附加规则
  2. 如果启用了用户确认(needUserConfirm=true),则添加确认规则
  3. 如果设置了最大子任务数(maxSubtasks),则添加子任务限制规则
  4. 根据是否有附加规则,决定最终的提示内容:
    • 无附加规则:仅返回基础的 NO_PLAN 提示
    • 有附加规则:返回基础提示 + 分隔符 + 附加规则

其中:

NO_PLAN

1
2
3
4
5
6
7
8
9
10
11
private static final String NO_PLAN =
"If the user's query is complex (e.g. programming a website, game or app), or requires"
+ " a long chain of steps to complete (e.g. conduct research on a certain topic"
+ " from different sources), you NEED to create a plan first by calling"
+ " 'create_plan'. Otherwise, you can directly execute the user's query without"
+ " planning.\n";

// translate the NO_PLAN to ZH_CN in following lines.

private static final String NO_PLAN_ZH =
"当用户查询较为复杂(例如:编程实现网站、游戏或应用),或需要多个步骤才能完成(例如:从不同渠道调研某个主题)时,你需要先调用 `create_plan` 创建计划。否则,你可以直接执行用户查询而无需规划。";

IMPORTANT_RULES_SEPARATOR

1
private static final String IMPORTANT_RULES_SEPARATOR = "Important Rules: \n";

RULE_WAIT_FOR_CONFIRMATION

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static final String RULE_WAIT_FOR_CONFIRMATION =
"⚠️ WAIT FOR USER CONFIRMATION:\n"
+ "- Present the plan and confirm with user before execution\n"
+ "- If user's request already implies execution intent (e.g., \"execute\","
+ " \"execute the plan\"), proceed directly without asking\n"
+ "- Otherwise, ask: \"Should I proceed with this plan?\"\n"
+ "- Start execution only after user confirms (e.g., \"yes\", \"go ahead\","
+ " \"proceed\", \"do it\")\n"
+ "- If user says anything else (questions, modifications, unrelated topics),"
+ " respond accordingly but DO NOT start execution\n";

// translate the RULE_WAIT_FOR_CONFIRMATION to ZH_CN in following lines.

private static final String RULE_WAIT_FOR_CONFIRMATION_ZH =
"⚠️ 等待用户确认:\n"
+ "- 在执行前向用户展示计划并确认\n"
+ "- 如果用户的请求已暗示执行意图(例如:\"执行\"、\"执行计划\"),则直接进行无需询问\n"
+ "- 否则,询问:\"我是否应该继续执行这个计划?\"\n"
+ "- 仅在用户确认后才开始执行(例如:\"是的\"、\"继续\"、\"开始\"、\"做吧\")\n"
+ "- 如果用户说了其他内容(问题、修改、无关话题),相应地回应但不要开始执行\n";

RULE_SUBTASK_LIMIT

1
2
private static final String RULE_SUBTASK_LIMIT =
"- Subtask Limit: Ensure the plan consists of no more than {max_subtasks} subtasks\n";

从上述三个prompt可以看出来整体还是从提示词工程上做了对应的约束,maybe在工具调用层创建plan的时候会约束一下子任务实际大小?

初始阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Count subtasks by state
// 待办任务项
int nTodo = 0;
// 进行中的任务项
int nInProgress = 0;
// 已完成的任务项
int nDone = 0;
// 已放弃的任务项
int nAbandoned = 0;

for (int i = 0; i < plan.getSubtasks().size(); i++) {
SubTask subtask = plan.getSubtasks().get(i);
switch (subtask.getState()) {
case TODO -> nTodo++;
case IN_PROGRESS -> {
nInProgress++;
inProgressIdx = i;
}
case DONE -> nDone++;
case ABANDONED -> nAbandoned++;
}
}

// All subtasks are todo - at the beginning
// 初始阶段,所有任务均在待办状态
hint =
AT_THE_BEGINNING.replace("{plan}", plan.toMarkdown(false))
+ IMPORTANT_RULES_SEPARATOR
+ confirmationRule
+ RULE_COMMON;

AT_THE_BEGINNING

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private static final String AT_THE_BEGINNING =
"The current plan:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "Your options include:\n"
+ "- Mark the first subtask as 'in_progress' by calling 'update_subtask_state' with"
+ " subtask_idx=0 and state='in_progress', and start executing it.\n"
+ "- If the first subtask is not executable, analyze why and what you can do to"
+ " advance the plan, e.g. ask user for more information, revise the plan by"
+ " calling 'revise_current_plan'.\n"
+ "- If the user asks you to do something unrelated to the plan, prioritize the"
+ " completion of user's query first, and then return to the plan afterward.\n"
+ "- If the user no longer wants to perform the current plan, confirm with the user"
+ " and call the 'finish_plan' function.\n";

// translate the AT_THE_BEGINNING to ZH_CN in following lines.

private static final String AT_THE_BEGINNING_ZH =
"当前计划:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "你可以选择:\n"
+ "- 调用 'update_subtask_state',设置 subtask_idx=0 和 state='in_progress',将第一个子任务标记为'进行中',并开始执行。\n"
+ "- 如果第一个子任务无法执行,分析原因以及你可以采取什么行动来推进计划,例如:向用户询问更多信息,或调用 'revise_current_plan' 修订计划。\n"
+ "- 如果用户要求你执行与计划无关的事情,优先完成用户的请求,然后再回到计划。\n"
+ "- 如果用户不再想执行当前计划,与用户确认后调用 'finish_plan' 函数。\n";

子任务进行中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// One subtask is in_progress
SubTask inProgressSubtask = plan.getSubtasks().get(inProgressIdx);
String subtaskName = inProgressSubtask.getName();
if (subtaskName == null) {
subtaskName = "Unnamed Subtask";
}
hint =
WHEN_A_SUBTASK_IN_PROGRESS
.replace("{plan}", plan.toMarkdown(false))
.replace("{subtask_idx}", String.valueOf(inProgressIdx))
.replace("{subtask_name}", subtaskName)
.replace("{subtask}", inProgressSubtask.toMarkdown(true))
+ IMPORTANT_RULES_SEPARATOR
+ RULE_COMMON;

从代码上看,这里的inProgressIdx是待处理的任务项的最后一个。

WHEN_A_SUBTASK_IN_PROGRESS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private static final String WHEN_A_SUBTASK_IN_PROGRESS =
"The current plan:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "Now the subtask at index {subtask_idx}, named '{subtask_name}', is "
+ "'in_progress'. Its details are as follows:\n"
+ "```\n"
+ "{subtask}\n"
+ "```\n"
+ "Your options include:\n"
+ "- Go on execute the subtask and get the outcome.\n"
+ "- Call 'finish_subtask' with the specific outcome if the subtask is "
+ "finished.\n"
+ "- Ask the user for more information if you need.\n"
+ "- Revise the plan by calling 'revise_current_plan' if necessary.\n"
+ "- If the user asks you to do something unrelated to the plan, "
+ "prioritize the completion of user's query first, and then return to "
+ "the plan afterward.\n";

// translate the WHEN_A_SUBTASK_IN_PROGRESS to ZH_CN in following lines.

pri
vate static final String WHEN_A_SUBTASK_IN_PROGRESS_ZH =
"当前计划:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "现在索引为 {subtask_idx}、名称为'{subtask_name}'的子任务处于'进行中'状态。其详细信息如下:\n"
+ "```\n"
+ "{subtask}\n"
+ "```\n"
+ "你可以选择:\n"
+ "- 继续执行该子任务并获取结果。\n"
+ "- 如果子任务已完成,调用 'finish_subtask' 并指定具体结果。\n"
+ "- 如果需要,向用户询问更多信息。\n"
+ "- 如有必要,调用 'revise_current_plan' 修订计划。\n"
+ "- 如果用户要求你执行与计划无关的事情,优先完成用户的请求,然后再回到计划。\n";

本质上和结束阶段一致 👇

无子任务进行中

同理可得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private static final String WHEN_NO_SUBTASK_IN_PROGRESS =
"The current plan:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "The first {index} subtasks are done, and there is no subtask "
+ "'in_progress'. Now Your options include:\n"
+ "- Mark the next subtask as 'in_progress' by calling "
+ "'update_subtask_state', and start executing it.\n"
+ "- Ask the user for more information if you need.\n"
+ "- Revise the plan by calling 'revise_current_plan' if necessary.\n"
+ "- If the user asks you to do something unrelated to the plan, "
+ "prioritize the completion of user's query first, and then return to "
+ "the plan afterward.\n";

// translate the WHEN_NO_SUBTASK_IN_PROGRESS to ZH_CN in following lines.

private static final String WHEN_NO_SUBTASK_IN_PROGRESS_ZH =
"当前计划:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "前 {index} 个子任务已完成,当前没有'进行中'的子任务。现在你可以:\n"
+ "- 调用 'update_subtask_state' 将下一个子任务标记为'进行中',并开始执行。\n"
+ "- 如果需要,向用户询问更多信息。\n"
+ "- 如有必要,调用 'revise_current_plan' 修订计划。\n"
+ "- 如果用户要求你执行与计划无关的事情,优先完成用户的请求,然后再回到计划。\n";

到了这里我想补充一下:

从上述的代码可以发现这样一个点,当下述等式存在时:

总共有 n 个任务项,其中 m 个已经开始处理, 完成了 k 个。

那么当 m 个均完成时(k=m)也会进入无任务阶段,这时候会提示大模型重新将任务项加入处理队列,并不是一开始就将所有的任务就直接加入处理队列的。又或者说整个任务队列其实是动态更新的(可修订,可新增)。

结束阶段

已完成任务数量 nDone + 已放弃任务数量 nAbandoned = 任务总数

视为任务均已完成。已完成的 hint 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static final String AT_THE_END =
"The current plan:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "All the subtasks are done. Now your options are:\n"
+ "- Finish the plan by calling 'finish_plan' with the specific "
+ "outcome, and summarize the whole process and outcome to the user.\n"
+ "- Revise the plan by calling 'revise_current_plan' if necessary.\n"
+ "- If the user asks you to do something unrelated to the plan, "
+ "prioritize the completion of user's query first, and then return to "
+ "the plan afterward.\n";

// translate the AT_THE_END to ZH_CN in following lines.

private static final String AT_THE_END_ZH =
"当前计划:\n"
+ "```\n"
+ "{plan}\n"
+ "```\n"
+ "所有子任务均已完成。现在你可以:\n"
+ "- 调用 'finish_plan' 并指定具体结果来完成计划,同时向用户总结整个流程和结果。\n"
+ "- 如有必要,调用 'revise_current_plan' 来修订计划。\n"
+ "- 如果用户要求你执行与计划无关的事情,优先完成用户的请求,然后再回到计划。\n";

然后,在上述的除了结束阶段外,所有的阶段在其本身的提示词后都加了一段通用规则

RULE_COMMON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private static final String RULE_COMMON =
"- Update before processing each subtask: When processing each subtask, call"
+ " get_subtask_count and view_subtasks to confirm the latest information:"
+ " get_subtask_count is used to confirm the total number of subtasks to avoid"
+ " omissions;view_subtasks is used to query subtask information, execute subtasks"
+ " strictly according to the latest information, and pay attention to ignoring the"
+ " original request.\n"
+ "- User May Modify Plan: Users can directly add, edit, or delete subtasks without"
+ " going through you.\n"
+ "- Only focus on the current content: Always follow the latest plan content,"
+ " especially when the original plan conflicts with the latest queried plan,"
+ " follow the latest queried plan without considering the initial requirements.\n"
+ "- Do not modify plan: Do not modify or amend the plan without a clear plan"
+ " modification instruction from user\n"
+ "- Language consistency: Respond to users in the same language as the plan\n";

// translate the RULE_COMMON to ZH_CN in following lines.

private static final String RULE_COMMON_ZH =
"- 处理每个子任务前更新信息:处理每个子任务时,调用 get_subtask_count 和 view_subtasks 确认最新信息:"
+ "get_subtask_count 用于确认子任务总数以避免遗漏;"
+ "view_subtasks 用于查询子任务信息,严格按照最新信息执行子任务,并注意忽略原始请求。\n"
+ "- 用户可能修改计划:用户可以直接添加、编辑或删除子任务,无需经过你。\n"
+ "- 仅关注当前内容:始终遵循最新的计划内容,"
+ "特别是当原始计划与最新查询的计划冲突时,"
+ "遵循最新查询的计划,不考虑初始需求。\n"
+ "- 不要修改计划:在没有用户明确的计划修改指令时,不要修改或修订计划。\n"
+ "- 语言一致性:使用与计划相同的语言响应用户。\n";

SubTask::toMarkdown

然后这里很有意思的一个点再补充看一下,SubTask类下面有一个toMarkdown方法用于格式化plan的文本。

从代码引用上看,所有的正式代码目前传的都是false,猜测应该是专门把true留给了用户打开,默认关闭。

先看一下Plan下的直接使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Convert to markdown representation.
*
* @param detailed Whether to include detailed information for subtasks
* @return Markdown string representation
*/
public String toMarkdown(boolean detailed) {
StringBuilder sb = new StringBuilder();
sb.append("# ").append(name).append("\n");
sb.append("**Description**: ").append(description).append("\n");
sb.append("**Expected Outcome**: ").append(expectedOutcome).append("\n");
sb.append("**State**: ").append(state.getValue()).append("\n");
sb.append("**Created At**: ").append(createdAt).append("\n");
sb.append("## Subtasks\n");

for (SubTask subtask : subtasks) {
sb.append(subtask.toMarkdown(detailed)).append("\n");
}

return sb.toString();
}

然后才到SubTask,已知默认为false,因此下面的默认都是走到!detailed里的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* Convert to markdown representation.
*
* @param detailed Whether to include detailed information
* @return Markdown string representation
*/
public String toMarkdown(boolean detailed) {
if (!detailed) {
return toOneLineMarkdown();
}

StringBuilder sb = new StringBuilder();
sb.append(toOneLineMarkdown()).append("\n");
sb.append("\t- Created At: ").append(createdAt != null ? createdAt : "N/A").append("\n");
sb.append("\t- Description: ")
.append(description != null ? description : "N/A")
.append("\n");
sb.append("\t- Expected Outcome: ")
.append(expectedOutcome != null ? expectedOutcome : "N/A")
.append("\n");
sb.append("\t- State: ").append(state.getValue());

if (state == SubTaskState.DONE) {
sb.append("\n");
sb.append("\t- Finished At: ")
.append(finishedAt != null ? finishedAt : "N/A")
.append("\n");
sb.append("\t- Actual Outcome: ").append(outcome != null ? outcome : "N/A");
}

return sb.toString();
}

SubTask::toOneLineMarkdown

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Convert to one-line markdown representation.
*
* @return One-line markdown string
*/
public String toOneLineMarkdown() {
String statusPrefix =
switch (state) {
case TODO -> "- [ ]";
case IN_PROGRESS -> "- [ ] [WIP]";
case DONE -> "- [x]";
case ABANDONED -> "- [ ] [Abandoned]";
};

String displayName = (name != null) ? name : "Unnamed Subtask";
return statusPrefix + " " + displayName;
}

detailed 参数对比总结

detailed 参数控制 SubTask 的 Markdown 输出详细程度,两种模式的区别如下:

detailed = false(默认模式)

  • 调用 toOneLineMarkdown() 方法
  • 仅输出一行简洁的任务状态标识
  • 格式:- [ ] 任务名- [x] 任务名(根据状态不同)
  • 适用场景:快速概览、任务列表展示、减少输出体积

detailed = true(详细模式)

  • 输出完整的任务信息,包含:
    • 创建时间(Created At)
    • 任务描述(Description)
    • 预期结果(Expected Outcome)
    • 当前状态(State)
    • 如果任务已完成(DONE),还会额外显示:
      • 完成时间(Finished At)
      • 实际结果(Actual Outcome)
  • 适用场景:任务详情查看、调试分析、审计追踪

设计意图分析:

  • 默认 false 体现了渐进式信息披露原则,避免信息过载
  • 将详细信息留给用户主动开启,符合”按需展示”的设计理念
  • 在 PlanNotebook 中,Agent 通常只需要关注任务的完成状态,不需要所有细节

PlanStorage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* Storage interface for persisting and retrieving plans.
*
* <p>Implementations can store plans in memory, database, or any other persistent storage.
*/
public interface PlanStorage {

/**
* Add a plan to storage.
*
* @param plan The plan to store
* @return Mono that completes when the plan is stored
*/
Mono<Void> addPlan(Plan plan);

/**
* Get a plan by its ID.
*
* @param planId The plan ID
* @return Mono emitting the plan, or empty if not found
*/
Mono<Plan> getPlan(String planId);

/**
* Get all plans from storage.
*
* @return Mono emitting a list of all plans
*/
Mono<List<Plan>> getPlans();
}

这个类感觉说起来也简单,Plan存哪儿,怎么取。一句话足以,然后自行实现对应逻辑。
默认给的实现方式是InMemory,直接内存存储,那也没什么好说的了。如果说需要的话可以关联一下数据库做持久化任务(在长程任务的时候我觉得还是有所必要的,不然做一半down了岂不是不可恢复)。

MaxSubTasks

这里就是上面提到的最大子任务数。不过应该具体的使用要到下面的Toolkit里看看。这里先跳过。

NeedUserConfirm

同理,是否需要用户确认计划执行~

keyPrefix

这个是用来把PlanNotebook的上下文存到session里所需要的。作为前缀存在。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Load PlanNotebook state from the session.
*
* @param session the session to load state from
* @param sessionKey the session identifier
*/
@Override
public void loadFrom(Session session, SessionKey sessionKey) {
// Clear existing state first to avoid stale data
this.currentPlan = null;
session.get(sessionKey, keyPrefix + "_state", PlanNotebookState.class)
.ifPresent(state -> this.currentPlan = state.currentPlan());
}

★ PlanNotebook Toolkit

OK啊,来到了具体的Toolkit,来看看是怎么把上述这些逻辑包装成工具打包给agent的。

分为两步:

  1. PlanNotebook 自身作为Toolkit实现了@Tool方法。
  2. ReActAgentbuild 的时候 registerr 了对应的 PlanNotebook,并且注册了相应的 Hook

回到 PlanNotebook

createPlan - 创建新计划

一步一步来看,作为整个PlanNotebook中最重要的工具,从它的方法注释先入手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
* Create a plan by given name and sub-tasks.
*
* @param name The plan name, should be concise, descriptive and not exceed 10 words
* @param description The plan description, including the constraints, target and outcome
* @param expectedOutcome The expected outcome of the plan
* @param subtasks A list of sequential sub-tasks that make up the plan
* @return Tool response message
*/

// todo: translate the above content to ZH_CN

/**
* @param name 计划名称,应简洁、具有描述性且不超过 10 个词
* @param description 计划描述,包括约束条件、目标和预期结果
* @param expectedOutcome 计划的预期结果
* @param subtasks 构成计划的顺序子任务列表
* @return 工具响应消息
*/```


别的其实都还好说,不过出乎我的意料的是,整个子任务列表都交给了`Agent`自行生成,通过`Param`的校验进行输入。

这里主要再看一下是怎么约束`SubTask`的入参的

```java
@ToolParam(
name = "subtasks",
description =
"A list of sequential sub-tasks. Each subtask must be an object"
+ " with: 'name' (string, required), 'description'"
+ " (string), 'expected_outcome' (string). Example:"
+ " [{\"name\": \"Calculate area\", \"description\":"
+ " \"Multiply length by width\", \"expected_outcome\":"
+ " \"Area value\"}]")
List<Map<String, Object>> subtasks

// todo translate the description of the @ToolParam to ZH_CN AND remove the format of Java and only PlainText

/*
* 顺序子任务列表。每个子任务必须是一个对象,包含:
* - `name`(字符串,必填):子任务名称
* - `description`(字符串):子任务描述
* - `expected_outcome`(字符串):预期结果
*
* 示例:
* [{"name": "计算面积", "description": "长乘以宽", "expected_outcome": "面积值"}]
*/```


总结来说通过`Map`的灵活性以及`Few-shot`来进行接参。

当然,代码中也做了对应的兜底:

```java
/**
* Converts a Map representation of a subtask to a SubTask object.
*
* <p>Handles null values by providing defaults: empty strings for description and outcome,
* "Unnamed Subtask" for missing names.
*
* @param subtaskMap Map containing "name", "description", and "expected_outcome" keys
* @return A SubTask object with validated fields
*/
private SubTask mapToSubTask(Map<String, Object> subtaskMap) {
String subtaskName = (String) subtaskMap.get("name");
String subtaskDesc = (String) subtaskMap.get("description");
String subtaskOutcome = (String) subtaskMap.get("expected_outcome");

// Validate and set defaults
if (subtaskName == null || subtaskName.trim().isEmpty()) {
subtaskName = "Unnamed Subtask";
}
if (subtaskDesc == null) {
subtaskDesc = "";
}
if (subtaskOutcome == null) {
subtaskOutcome = "";
}

return new SubTask(subtaskName, subtaskDesc, subtaskOutcome);
}

接下来就像我设想的,需要用maxSubtasks进行对应的生成子任务的validation

最后notify所有对应的PlanChangeHook

updatePlanInfo - 更新当前计划的名称、描述或预期结果

简单来说根据三个参数,name, description, expectedOutcome(也就是创建时候的三个),
进行对应Plan的修改。一个覆盖的逻辑,比较简单,不做展开。

reviseCurrentPlan - 添加、修订或删除子任务

有这么几个简单的点:

  1. 指定参数action,让操作限制在add,revise,delete,然后分别String.equals进入对应逻辑
  2. 最后返回的时候用了一个yield关键字,还蛮有意思的。

(Java 14) 在 switch 表达式的 代码块分支 {} 里,必须用 yield 返回值

updateSubtaskState - 更新子任务状态

根据 subtaskIdx 进行状态修改,比较简单。

finishSubtask - 标记子任务为已完成

同上。多了一步给subtask复制outcome,以及启动新的subtask

viewSubtasks - 查看子任务详情

getList,略。

getSubtaskCount - 获取当前计划中的子任务数量

getList + countState,略。

finishPlan - 完成或放弃计划

修改任务状态并且持久化(Insert or Update,总之是调了addPlan

viewHistoricalPlans - 查看历史计划

从上面的storage里getList。

recoverHistoricalPlan - 恢复历史计划

根据planId重启计划。


整体来说还是在我的预期之内的,没有特别亮眼的一些设计,不过这些提示词倒是可以给我做个参考,fine。