本文档由 g2rain-agent-runtime 的
# 意图路由与选人设计
docs/INTENT_ROUTING.md 自动同步生成。本文档描述 Runtime 每一轮如何决定"任务怎么推进"和"交给哪个数字员工",覆盖入口分流、意图分类、结果校正与选人。内容是 engine.flow.entry、engine.flow.decision 与 domain.decision 的设计依据;本轮的回复文案也在选人环节确定,后续环节只负责输出,实现以本文为准。
# 目录
# 1. 目标与非目标
# 1.1 目标
| # | 目标 |
|---|---|
| G1 | 新对话、寒暄、普通办事请求不出现澄清追问 |
| G2 | 用户回答补参问题时直接续跑,不调用意图模型 |
| G3 | 指定 agentId 时,本轮只在该员工上判断,不换人;明显超出其职责时直接说明 |
| G4 | 待确认的写操作只能由显式确认动作推进,文字输入不能触发写操作 |
| G5 | 意图模型返回空、输出无法解析或模式非法时,有确定的兜底且有日志 |
| G6 | 提示词是一份完整可读的文本;"提示词允许的模式"与"代码接受的模式"来自同一份清单 |
# 1.2 非目标
- 不为"指定员工"或"单员工租户"维护独立的提示词模板。
- 不在意图阶段合并技能预选。
- 不引入解码层的 JSON 约束(guided decoding / response_format)。
# 2. 整体流程
DefaultTurnEntryGuard(入口,确定性规则,不调用模型)
├─ 显式动作:确认 / 拒绝 / 取消 / 回复 → 续跑挂起任务
├─ 请求 Agent 与挂起任务绑定 Agent 不一致 → 换人
├─ FOLLOW_UP 断点 + 无显式动作 → 按 REPLY 续跑(用户输入即补参内容)
├─ APPROVAL / VERIFY 断点 + 无显式动作 + 有文字 → 交给调度器
└─ 无挂起任务 → 交给调度器
DefaultTurnDispatcher(调度器)
├─ 场景 scene = TurnScene.of(有活动任务, 有任务前情)
├─ 候选 指定 agentId 时只保留该员工,技能目录不截断
├─ 分类 DefaultIntentClassifier:SYSTEM + USER(完整模板,填入场景、上下文、用户原话)
└─ 校正 settle()
① 结论缺失或不在 scene.modes() 中 → scene.fallback()
② 有待确认写操作且结论为 CONTINUE_TASK → UNCERTAIN
③ 需要选人但 agentId 为空、且给出了 userReply → REPLY_ONLY
结果为 UNCERTAIN 时写入确认文案:VERIFY 断点用 VERIFY_ASK,其余用 ROUTE_UNCERTAIN
DefaultAgentResolver(选人)
显式 agentId → 续跑锁任务 Agent → 意图 agentId(仅选人模式)→ 当前任务 Agent → 前情 Agent → 租户唯一 Agent → 无人可选
DefaultFinalizer(收口,不改动)
UNCERTAIN / REPLY_ONLY 输出 IntentResult.userReply
# 2.1 场景与可选模式
| 场景 | 判定 | 可选模式(按规则匹配顺序) | 兜底 |
|---|---|---|---|
FRESH | 无活动任务,无前情 | START_FRESH、REPLY_ONLY | START_FRESH |
CONTINUITY | 无活动任务,有前情 | START_WITH_CONTEXT、START_FRESH、REPLY_ONLY | START_FRESH |
ACTIVE_TASK | 有 RUNNING / SUSPENDED 任务 | CANCEL_TASK、SWITCH_AGENT¹、REPLACE_TASK、CONTINUE_TASK、REPLY_ONLY | CONTINUE_TASK² |
¹ 指定 agentId 时不可选。 ² 有待确认写操作时,经校正第 ② 步变为 UNCERTAIN。
UNCERTAIN 不在任何场景的可选模式中,意图模型不输出它,只由校正第 ② 步产生。
# 3. 关键设计决策
每项列出采用方案、备选方案与取舍理由。
# D1 补参回复在入口直接续跑
- 采用:FOLLOW_UP 断点下,无显式动作即按断点默认动作 REPLY 续跑,无论用户是否输入文字。
- 备选:交给意图模型判断是否在补参。
- 理由:补参回答通常很短("上海""是的"),让模型判断既慢又容易误判。用户在补参时改口("算了,换个事")由执行环节识别处理,这比把每次补参都交给模型的风险小得多。
# D2 意图模型不输出 UNCERTAIN
- 采用:可选模式中没有 UNCERTAIN,它只由代码在"有待确认写操作 + 判为续跑"时产生。
- 备选:允许模型在拿不准时输出 UNCERTAIN。
- 理由:澄清追问唯一必要的场景是写操作安全,这是确定性规则,不应依赖模型是否听话。其它拿不准的情况,按最可能的意图推进,体验优于追问。
# D3 场景由代码确定
- 采用:
TurnScene由运行态算出,写入提示词【当前场景】,模型只在该场景的规则内判断。 - 备选:给模型若干布尔字段,由模型自行推断场景。
- 理由:场景是确定事实,不应交给模型推断;同一份
TurnScene.modes()同时用于提示词说明与结果校验,两者不会不一致。
# D4 提示词为完整静态文本
- 采用:
IntentPrompt.USER是一份完整模板,三个场景的规则都写在其中,代码只填入场景名、上下文 JSON 和用户原话三个值。 - 备选:按场景动态拼接,只渲染当前场景的规则。
- 理由:完整文本能直接审阅、调优、比对版本;动态拼接让最终提示词只能在运行时看到。代价是模型多读两组不适用的规则(约十余行),由【当前场景】明确指向,影响很小。
# D5 指令用自然语言,上下文用 JSON
- 采用:任务说明、规则、用户原话、输出指令用自然语言;会话上下文用 JSON(
NON_NULL)。 - 备选:全部渲染为自然语言分段。
- 理由:规则通过字段名(
continuation、activeTask、agentLocked等)精确引用数据;JSON 由序列化直接得到,加字段无需改渲染代码。user 消息以明确的输出指令结尾,模型的生成起点紧跟指令。如果后续观察到模型对对话记录理解偏差,再单独把dialogue改为文本分段。
# D6 分类失败按场景兜底
- 采用:返回空、无法解析、模式非法或调用异常时,取
scene.fallback(),并记录原因日志。 - 备选:统一返回澄清追问。
- 理由:分类失败是系统问题,不应转嫁给用户回答。兜底模式都不会触发写操作:写操作由校正第 ② 步拦截。
# D7 指定数字员工用同一模板
- 采用:
agentLocked=true写入上下文,候选只保留该员工、技能目录不截断,规则中说明"只判断能否处理",可选模式去掉 SWITCH_AGENT。 - 备选:为指定员工维护独立模板。
- 理由:差异只有三点,用数据和一条规则即可表达,不必维护第二份模板。
# D8 无法匹配员工时仅回复说明
- 采用:需要选人但 agentId 为空、且 userReply 有说明时,校正为 REPLY_ONLY,把说明返回给用户。
- 备选:本轮标记为 FAILED。
- 理由:这是正常的业务结论(不在服务范围),不是系统失败,任务生命周期也不应受影响。
# D9 寒暄由意图模型直接回复
- 采用:REPLY_ONLY 时,模型在 userReply 中给出简短回复。
- 备选:统一返回固定文案"好的。"。
- 理由:同一次模型调用顺带产出回复,没有额外成本;固定文案对"你好"之类的输入不自然。
# 4. 提示词
support/prompt/IntentPrompt.java:
/**
* 本轮意图分类提示词。
*
* <p>{@link #SYSTEM} 说明身份与职责;{@link #USER} 是完整的本轮指令模板,结构为
* 任务 → 场景 → 规则 → 上下文 → 用户输入 → 输出指令。</p>
*/
public final class IntentPrompt {
/**
* 身份与职责。
*/
public static final String SYSTEM = """
你是企业智能助手的意图路由器,负责判断用户本轮输入如何推进任务,并选择执行的数字员工。
只依据给定信息判断,不得臆造。
""";
/**
* 本轮指令模板。占位符依次为:当前场景、会话上下文 JSON、本轮用户输入。
* <pre>{@code
* String user = IntentPrompt.USER.formatted(scene.name(), contextJson, userInput);
* }</pre>
*/
public static final String USER = """
请根据以下规则和会话上下文,判断本轮用户输入如何推进任务,并在需要时选择执行的数字员工。
【当前场景】
%s
【推进规则】
按当前场景对应的一组规则判断,从上到下取第一条符合的。
FRESH(新对话):
- 需要办理事项 → START_FRESH
- 寒暄、致谢、闲聊等无需办理的内容 → REPLY_ONLY
CONTINUITY(无进行中的任务,上一个任务的摘要见 continuation):
- 新的事项明确依赖上一个任务的结果 → START_WITH_CONTEXT
- 需要办理的其它事项 → START_FRESH
- 寒暄、致谢、闲聊等无需办理的内容 → REPLY_ONLY
ACTIVE_TASK(有进行中的任务,见 activeTask):
- 只取消当前任务、不再办理 → CANCEL_TASK
- 明确要求换一个数字员工 → SWITCH_AGENT(agentLocked=true 时不可用)
- 放弃当前任务、改办别的事 → REPLACE_TASK
- 仍在推进当前任务,包括回答它的提问 → CONTINUE_TASK
- 与任务无关的寒暄、致谢、闲聊 → REPLY_ONLY
【选人规则】
- 只有 START_FRESH、START_WITH_CONTEXT、SWITCH_AGENT 需要选人,从 agentCandidates 中选一个 agentId;其它模式 agentId 为 null。
- 按候选职责和 skillCatalog 做语义匹配;表达口语化或模糊时选最贴近者,难以区分时选排在前面的;只有一个候选时直接选它。
- START_WITH_CONTEXT 优先选择 continuityAgentId。
- agentLocked=true 表示已指定数字员工(agentCandidates 中唯一的一项);寒暄或说法模糊但可能相关时,视为它能处理。
- 只有诉求与候选职责明显无关时,agentId 为 null,并在 userReply 中说明可以办理的事项,举 1~2 个例子。
【userReply】
- REPLY_ONLY:给用户的简短回复。
- 无法匹配数字员工:说明可以办理的事项。
- 其它情况为 null。使用与用户输入相同的语言。
【会话上下文】
%s
【本轮用户输入】
%s
【输出】
请按当前场景的推进规则判断本轮用户输入,并按选人规则选择数字员工,只输出如下格式的一个 JSON 对象,不要输出其它任何文字:
{"mode":"<当前场景可选的模式>","agentId":<agentId 或 null>,"goalSummary":"<一句话概括用户目标>","userReply":<文本或 null>}
""";
private IntentPrompt() {
}
}
自审要点:
- 结尾指令同时包含任务和格式;输出格式只给字段说明,不给可能被照抄的具体值。
- 每个场景都有 REPLY_ONLY 出口;"必须选人"与"无法匹配时为 null"在同一条规则链中先后说明,不矛盾。
- 规则引用的字段名与上下文 JSON 的键一致:
continuation、activeTask、agentCandidates、skillCatalog、continuityAgentId、agentLocked。
# 5. 代码改动
| 类 | 改动 |
|---|---|
domain.decision.TurnScene | 新增:场景、可选模式、兜底模式 |
domain.decision.TurnMode | 新增 selectsAgent()、@JsonCreator of(String);更新 UNCERTAIN 注释 |
domain.decision.IntentResult | complete() 只为选人模式保留候选内的 agentId;删除 UNCERTAIN 补文案分支 |
support.prompt.IntentPrompt | 拆为 SYSTEM + USER |
engine.flow.decision.IntentRequest | 用 scene、agentLocked 取代两个布尔字段;scene、userInput 不进 JSON;NON_NULL |
engine.flow.decision.DefaultIntentClassifier | 用 USER 模板组装;失败原因日志 |
engine.flow.decision.DefaultTurnDispatcher | 场景、候选收窄、settle()、decisionOf()、确认文案写入;删除 remember()、postValidate() |
engine.flow.decision.DefaultAgentResolver | 按最终模式判断是否选人;删除"无法匹配即失败"分支 |
engine.flow.entry.DefaultTurnEntryGuard | FOLLOW_UP 断点默认 REPLY 续跑 |
enums.RuntimeMessage | ROUTE_UNCERTAIN 只改文案,不改名 |
IntentResult.useContinuity 保留,由 mode 推导;现有读取方不受影响。DefaultFinalizer 不改动:它对 UNCERTAIN / REPLY_ONLY 本就优先输出 IntentResult.userReply。
# 5.1 TurnScene
package com.g2rain.agent.runtime.domain.decision;
import java.util.List;
/**
* 本轮意图判定所处的会话场景。
*
* <p>场景由运行态确定性得出,给出意图模型可选的推进模式(按规则匹配顺序排列)与模型无有效结论时的兜底模式。
* 提示词中的场景规则与 {@link #modes(boolean)} 保持一致,调度器据此校正模型结论。</p>
*
* <pre>{@code
* TurnScene scene = TurnScene.of(false, true); // CONTINUITY
* scene.modes(false); // [START_WITH_CONTEXT, START_FRESH, REPLY_ONLY]
* scene.fallback(); // START_FRESH
* }</pre>
*
* @author alpha
* @since 2026/10/10
*/
public enum TurnScene {
/**
* 新对话:没有活动任务,也没有可接续的任务前情。
*/
FRESH(TurnMode.START_FRESH, List.of(TurnMode.START_FRESH, TurnMode.REPLY_ONLY)),
/**
* 可接续:没有活动任务,上一任务留有存活前情。
*/
CONTINUITY(TurnMode.START_FRESH,
List.of(TurnMode.START_WITH_CONTEXT, TurnMode.START_FRESH, TurnMode.REPLY_ONLY)),
/**
* 任务进行中:存在运行或挂起中的任务。
*/
ACTIVE_TASK(TurnMode.CONTINUE_TASK, List.of(TurnMode.CANCEL_TASK, TurnMode.SWITCH_AGENT,
TurnMode.REPLACE_TASK, TurnMode.CONTINUE_TASK, TurnMode.REPLY_ONLY));
/**
* 模型无有效结论时的兜底模式。
*/
private final TurnMode fallback;
/**
* 本场景可选模式,顺序即规则匹配顺序。
*/
private final List<TurnMode> modes;
TurnScene(TurnMode fallback, List<TurnMode> modes) {
this.fallback = fallback;
this.modes = modes;
}
/**
* 由运行态得出场景;有活动任务时优先视为任务进行中。
*
* @param activeTask 是否存在运行或挂起中的任务
* @param continuity 是否存在存活的任务前情
* @return 本轮场景
*/
public static TurnScene of(boolean activeTask, boolean continuity) {
if (activeTask) {
return ACTIVE_TASK;
}
return continuity ? CONTINUITY : FRESH;
}
/**
* 本场景可选模式;指定数字员工时不允许换人。
*
* @param agentLocked 本轮是否已指定数字员工
* @return 按规则匹配顺序排列的可选模式
*/
public List<TurnMode> modes(boolean agentLocked) {
return agentLocked ? modes.stream().filter(mode -> mode != TurnMode.SWITCH_AGENT).toList() : modes;
}
/**
* 模型无有效结论时采用的模式。
*
* @return 兜底模式
*/
public TurnMode fallback() {
return fallback;
}
}
# 5.2 TurnMode
/**
* 判断该模式是否需要为本轮选择数字员工。
*
* @return START_FRESH / START_WITH_CONTEXT / SWITCH_AGENT 为 true
*/
public boolean selectsAgent() {
return this == START_FRESH || this == START_WITH_CONTEXT || this == SWITCH_AGENT;
}
/**
* 宽松解析模型输出的模式名:忽略大小写与首尾空白,无法识别时返回 {@code null}。
* <pre>{@code
* TurnMode.of(" start_fresh "); // START_FRESH
* TurnMode.of("NEW_TASK"); // null
* }</pre>
*
* @param raw 模型输出的模式名
* @return 对应模式;无法识别为 {@code null}
*/
@JsonCreator
public static @Nullable TurnMode of(@Nullable String raw) {
if (Strings.isBlank(raw)) {
return null;
}
String key = raw.trim().toUpperCase(Locale.ROOT);
return Arrays.stream(values()).filter(mode -> mode.name().equals(key)).findFirst().orElse(null);
}
UNCERTAIN 注释:有待确认的写操作、用户未显式确认时由调度器产生,意图模型不输出此值;本轮只向用户确认,不推进任务。
# 5.3 IntentResult.complete
/**
* 规范化模型结论:清理文本;只有选人模式保留 agentId,且必须来自本轮候选。
*
* @param candidates 本轮候选;可空
* @return this
*/
public IntentResult complete(@Nullable List<AgentSelection> candidates) {
useContinuity = mode == TurnMode.START_WITH_CONTEXT;
goalSummary = Strings.blankToNull(goalSummary);
userReply = Strings.blankToNull(userReply);
reason = Strings.blankToNull(reason);
boolean listed = Objects.nonNull(agentId) && Collections.isNotEmpty(candidates)
&& candidates.stream().anyMatch(candidate -> agentId.equals(candidate.getAgentId()));
if (Objects.isNull(mode) || !mode.selectsAgent() || !listed) {
agentId = null;
}
return this;
}
# 5.4 IntentRequest
@JsonInclude(JsonInclude.Include.NON_NULL)
public record IntentRequest(@JsonIgnore TurnScene scene, boolean agentLocked, @JsonIgnore @Nullable String userInput,
@Nullable Long continuityAgentId, @Nullable ContinuitySnippet continuation,
List<AgentSelection> agentCandidates, @Nullable DialogueWindow dialogue,
@Nullable String recentEpisodes, List<SkillCatalogItem> skillCatalog,
@Nullable ActiveTask activeTask) {
}
scene与userInput在提示词中有独立段落,不重复进入 JSON。- 嵌套记录同样加
NON_NULL。 - 类注释中的字段说明同步更新。
# 5.5 DefaultIntentClassifier
/**
* 日志中模型原文的最大预览长度。
*/
private static final int PREVIEW_LENGTH = 200;
@Override
public @Nullable IntentResult classify(IntentRequest request) {
try {
String user = IntentPrompt.USER.formatted(
request.scene().name(), JSON.obj2str(request), Objects.toString(request.userInput(), "")
);
ChatMessage assistant = OpenAiChatClientConverter.firstMessage(modelGateway.chat(ModelUse.INTENT,
ChatCompletionRequests.of().system(IntentPrompt.SYSTEM).user(user).build()
));
// 空正文或无法解析都返回 null,由调度器按场景兜底
String content = Objects.isNull(assistant) ? null : assistant.getContent();
if (Strings.isBlank(content)) {
log.warn("[IntentClassifier] empty reply, scene={}", request.scene());
return null;
}
IntentResult parsed = JSON.str2obj(RuntimeUtils.extractObject(content), IntentResult.class);
if (Objects.isNull(parsed) || Objects.isNull(parsed.getMode())) {
log.warn("[IntentClassifier] unrecognized reply, scene={}, content={}", request.scene(),
RuntimeUtils.abbreviate(content, PREVIEW_LENGTH));
return null;
}
return parsed.complete(request.agentCandidates());
} catch (RuntimeException ex) {
log.warn("[IntentClassifier] classify failed, scene={}", request.scene(), ex);
return null;
}
}
# 5.6 DefaultTurnDispatcher
/**
* 意图分类读取的对话窗口条数。
*/
private static final int DIALOGUE_LIMIT = 12;
private TurnDecision decideMode(TurnContext ctx, EntryResult entry) {
if (entry.forceContinue()) {
audit(ctx, "protocol", null, TurnMode.CONTINUE_TASK);
return TurnDecision.continueTask();
}
if (entry.agentMismatch()) {
audit(ctx, "protocol", null, TurnMode.SWITCH_AGENT);
return TurnDecision.switchAgent();
}
TurnScope turn = ctx.getTurn();
boolean write = writePending(turn);
TurnScene scene = TurnScene.of(hasActiveTask(turn), !turn.records().isEmpty());
Long lockedAgentId = entry.explicitAgentId();
IntentResult intent = intentClassifier.classify(buildIntentRequest(ctx, scene, lockedAgentId, write));
ctx.setIntentResult(intent);
TurnMode mode = settle(intent, scene, Objects.nonNull(lockedAgentId), write);
audit(ctx, scene.name(), Objects.isNull(intent) ? null : intent.getMode(), mode);
if (mode == TurnMode.UNCERTAIN) {
// 确认追问只用固定文案,替换模型结论,避免输出模型的 userReply
ctx.setIntentResult(confirmation(turn));
}
return decisionOf(mode);
}
/**
* 待确认写操作的追问结论:回查断点询问写操作是否已完成,其余询问是否继续执行。
*
* @param turn 当前会话视图
* @return 携带确认文案的 UNCERTAIN 结论
*/
private static IntentResult confirmation(TurnScope turn) {
boolean verify = turn.checkpoint()
.map(TaskCheckpoint::getType)
.filter(type -> type == CheckpointType.VERIFY)
.isPresent();
IntentResult result = new IntentResult();
result.setMode(TurnMode.UNCERTAIN);
result.setUserReply((verify ? RuntimeMessage.VERIFY_ASK : RuntimeMessage.ROUTE_UNCERTAIN).text());
return result.complete(null);
}
/**
* 将模型结论校正为本轮生效的模式。
*
* <ol>
* <li>结论缺失或不在场景可选范围内 → 场景兜底模式
* <li>存在待确认写操作且判为续跑 → UNCERTAIN(写操作只由显式确认推进)
* <li>需要选人但无人可选、且已给出说明 → REPLY_ONLY(只回复说明,不建任务)
* </ol>
*
* @param intent 模型结论;可空
* @param scene 本轮场景
* @param agentLocked 是否已指定数字员工
* @param writePending 是否存在待确认的写操作
* @return 本轮生效的模式
*/
static TurnMode settle(@Nullable IntentResult intent, TurnScene scene, boolean agentLocked, boolean writePending) {
TurnMode mode = Optional.ofNullable(intent)
.map(IntentResult::getMode)
.filter(scene.modes(agentLocked)::contains)
.orElse(scene.fallback());
if (writePending && mode == TurnMode.CONTINUE_TASK) {
return TurnMode.UNCERTAIN;
}
if (mode.selectsAgent() && Objects.nonNull(intent) && Objects.isNull(intent.getAgentId())
&& Strings.isNotBlank(intent.getUserReply())) {
return TurnMode.REPLY_ONLY;
}
return mode;
}
private static TurnDecision decisionOf(TurnMode mode) {
return switch (mode) {
case START_FRESH -> TurnDecision.startFresh();
case START_WITH_CONTEXT -> TurnDecision.startFollowup();
case CONTINUE_TASK -> TurnDecision.continueTask();
case REPLACE_TASK -> TurnDecision.replace();
case SWITCH_AGENT -> TurnDecision.switchAgent();
case CANCEL_TASK -> TurnDecision.cancelTask();
case REPLY_ONLY -> TurnDecision.empty();
case UNCERTAIN -> TurnDecision.uncertain();
};
}
private IntentRequest buildIntentRequest(TurnContext ctx, TurnScene scene, @Nullable Long lockedAgentId,
boolean writePending) {
TurnScope turn = ctx.getTurn();
boolean locked = Objects.nonNull(lockedAgentId);
List<AgentCatalogItemVo> catalog = candidatesOf(lockedAgentId);
TaskRecord latest = turn.records().isEmpty() ? null : turn.records().getFirst();
return new IntentRequest(
scene,
locked,
ctx.getRequest().getUserInput(),
continuityAgentId(turn),
IntentRequest.ContinuitySnippet.from(latest),
AgentSelection.of(catalog),
IntentRequest.DialogueWindow.from(turn.dialogue(DIALOGUE_LIMIT)),
null,
resolveSkillCatalog(catalog, locked),
activeTaskOf(turn, scene == TurnScene.ACTIVE_TASK, writePending)
);
}
/**
* 本轮候选:未指定时为全部启用员工;指定时只保留该员工,目录未命中再单条解析。
*
* @param lockedAgentId 指定的员工;可空
* @return 候选目录
*/
private List<AgentCatalogItemVo> candidatesOf(@Nullable Long lockedAgentId) {
List<AgentCatalogItemVo> active = agentProfiles.listActiveCatalog();
if (Objects.isNull(lockedAgentId)) {
return active;
}
return active.stream()
.filter(item -> lockedAgentId.equals(item.getAgentId()))
.findFirst()
.or(() -> Optional.ofNullable(agentProfiles.resolveProfile(lockedAgentId)))
.map(List::of)
.orElse(List.of());
}
/**
* 判断是否存在运行或挂起中的任务。
*/
private static boolean hasActiveTask(TurnScope turn) {
return turn.task().map(TaskMetadata::getStatus).filter(TaskStatus::isTransient).isPresent();
}
private static void audit(TurnContext ctx, String stage, @Nullable TurnMode proposed, TurnMode settled) {
log.info("[IntentAudit] traceId={} stage={} proposed={} settled={}",
ctx.getTurn().traceId(), stage, proposed, settled);
}
resolveSkillCatalog(catalog, fullCatalog):fullCatalog=true(指定员工)时,每员工上限取总上限,保证能否处理的判断看得到该员工的全部技能。删除 remember()、postValidate()。
# 5.7 DefaultAgentResolver.route
替换原有的 pickMode 判断,以及"意图写了无法匹配的回复即失败"分支(该情况已由 settle() 转为 REPLY_ONLY):
IntentResult intent = ctx.getIntentResult();
Long picked = decision.mode().selectsAgent() && Objects.nonNull(intent) ? intent.getAgentId() : null;
if (Objects.nonNull(picked)) {
AgentSelection byId = agentCatalog.findActiveById(picked);
if (Objects.isNull(byId)) {
failNoMatch(ctx, RuntimeMessage.AGENT_MISSING, TurnStatus.FAILED);
return;
}
bind(ctx, decision, byId);
return;
}
// 回退链:当前任务 Agent → 前情 Agent → 租户唯一 Agent → 无人可选(不变)
# 5.8 DefaultTurnEntryGuard.guard
if (Objects.isNull(request.getAction())) {
// 断点有安全默认动作(FOLLOW_UP → REPLY)时直接续跑,用户输入作为回复内容
ResumeAction implied = resolveAction(null, checkpoint);
if (Objects.nonNull(implied)) {
return EntryResult.continueSuspended(agentId, focusNodeId, checkpoint, implied);
}
// 审批与回查没有安全默认动作:有文字交给意图判断,无文字要求显式选择
return Strings.isNotBlank(request.getUserInput())
? EntryResult.accept(agentId)
: EntryResult.reject(new RejectedReply(conversationId, taskId, RuntimeMessage.ACTION_MISSING.text()));
}
# 5.9 RuntimeMessage
ROUTE_UNCERTAIN("route.uncertain", "还有一项操作等待您确认,请确认是否继续执行。"),
只改文案。触发条件收窄为"待确认写操作 + 文字输入 + 判为续跑",文案与之对应。
# 6. 验收用例
| # | 场景 | 输入 | 期望路径 | 期望结果 |
|---|---|---|---|---|
| 1 | 新对话,多员工 | "帮我开张发票" | FRESH → START_FRESH + agentId | 绑定匹配员工,建任务 |
| 2 | 新对话 | "你好" | FRESH → REPLY_ONLY | 返回模型 userReply |
| 3 | 新对话,模型返回空 | 任意 | FRESH → 兜底 START_FRESH | 走选人回退链;日志 empty reply |
| 4 | 新对话,指定 agentId | "帮我开张发票" | 候选仅该员工 → START_FRESH | 绑定指定员工 |
| 5 | 指定 agentId,诉求无关 | "今天天气怎样" | START_FRESH + agentId=null + userReply → REPLY_ONLY | 返回"它能办理的事项" |
| 6 | 指定 agentId,模型返回空 | 任意 | 兜底 START_FRESH | 绑定指定员工 |
| 7 | 上一任务已结束 | "那退款进度呢" | CONTINUITY → START_WITH_CONTEXT | 优先前情员工,带前情建任务 |
| 8 | FOLLOW_UP 挂起 | "上海" | 入口按 REPLY 续跑 | 不调用意图模型 |
| 9 | APPROVAL 挂起 | "好的,继续" | ACTIVE_TASK → CONTINUE_TASK → UNCERTAIN | ROUTE_UNCERTAIN 确认文案,不执行写操作 |
| 10 | APPROVAL 挂起 | "算了,不办了" | ACTIVE_TASK → CANCEL_TASK | 取消任务 |
| 11 | APPROVAL 挂起 | 点击确认(action=CONFIRM) | 入口硬协议续跑 | 执行写操作,不调用意图模型 |
| 12 | VERIFY 挂起,模型返回空 | 任意文字 | 兜底 CONTINUE_TASK → UNCERTAIN | VERIFY_ASK |
| 13 | 模型输出 "start_fresh" | — | TurnMode.of 识别 | 正常生效 |
| 14 | 模型输出 "NEW_TASK" | — | 解析为 null → 场景兜底 | 日志 unrecognized reply |
| 15 | FRESH 下模型输出 CONTINUE_TASK | — | 不在可选清单 → START_FRESH | 正常建任务 |
| 16 | 模型选了不存在的 agentId | — | complete() 清空 → 回退链 | 不启动不存在的员工 |
| 17 | 指定 agentId,任务进行中,要求换员工 | "换个数字员工" | SWITCH_AGENT 不在可选清单 → 兜底 | 不换人 |
| 18 | 请求 agentId 与挂起任务员工不同 | 任意 | 入口 agentMismatch → SWITCH_AGENT | 换人重开,不调用意图模型 |
# 7. 单元测试
| 测试类 | 覆盖 |
|---|---|
TurnSceneTest | of() 三种场景;modes(true) 不含 SWITCH_AGENT;兜底值 |
TurnModeTest | of() 大小写、空白、未知值;selectsAgent() |
IntentResultTest | 非选人模式清空 agentId;候选外 agentId 清空;空候选清空 |
DefaultTurnDispatcherTest | 用例 3、5、6、9、12、14、15、17 对应的 settle() 分支;UNCERTAIN 时写入确认文案并替换模型结论,VERIFY 与 APPROVAL 文案区分 |
DefaultTurnEntryGuardTest | 用例 8、11、18;APPROVAL 无文字时拒绝 |
IntentPromptTest | USER.formatted(...) 恰好三个占位符;结尾为输出指令 |
# 8. 关联文档更新
SSE_EVENTS.md:"意图不明确"一行改为新文案与触发条件(待确认写操作 + 文字输入)。KNOWN_ISSUES.md:Q11 移入"已修复",引用本文档。index.md:在"架构"中加入本文档链接。