Files
Agent-System-Backend/internal/server/intent_session.go
T
weichen c2e3104970 Route the cancel intent through cmdbus with explicit targets
Why: A1 - the cancel library command was declined as host-unconsumed,
so "取消刚才的任务" could not act on a real run.
Evidence: route/server/intentsession tests pass (verify-a1c.json);
explicit-id, session-bound, ambiguous and cross-owner cases added;
confirmation card semantics unchanged.
Impact: cancel decisions dispatch via the existing host path; other
intents unchanged.
Refs: feature/intent-multi-turn-a1
2026-10-22 10:04:23 +00:00

329 lines
13 KiB
Go

package server
// 意图会话语义(A1/U-A1b):chat 入口消费库层的会话命令(set_slot/
// change_slot/cancel/expectation),把「动作可执行但槽位未就绪」的那一轮
// 记成会话里的挂起预期,等用户后续轮次把槽位补齐。
//
// 纪律:
// - 补齐后走的是与一次完整识别完全相同的 route.Decide + admitIntentCommand
// + Bus.Dispatch 路径;会话只携带槽位与预期,绝不携带授权——高危动作
// 的确认仍由 cmdbus 收口。
// - 没有挂起预期时本段整体空转:响应逐字节与既有行为一致。
// - 取消分两件事,宿主如实区分,绝不混为一谈:
// · 「放弃本次补充」(会话语义):cancel 命令 + 有挂起预期 + 解不出要
// 取消的运行 → 清预期并如实确认,不派发任何业务动作;
// · 「取消一个运行」(业务动作,A1/U-A1c):解出了唯一靶子 → 走既有
// 取消路径派发,见 intent_cancel.go。
// 两者都不会绕过 cmdbus 的确认收口。
import (
"errors"
"fmt"
"strings"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsession"
"agent-system-backend/internal/route"
core "agent-system-core"
)
// IntentSessions 是意图会话的宿主消费面(intentsession.Service 的端口)。
// 组合根/测试可注入替身;nil 时按存储自动装配。
type IntentSessions interface {
Pending(intentsession.Key) (*intentsession.Session, error)
Suspend(intentsession.Key, intentsession.Expectation, map[string]string) (*intentsession.Session, error)
ApplyAnswer(intentsession.Key, int, intentsession.Answer) (*intentsession.Outcome, error)
ApplyCommand(intentsession.Key, int, string, string, string) (*intentsession.Outcome, error)
Drop(intentsession.Key) error
Binding(intentsession.Key) (*intentsession.Binding, error)
RecordCancel(intentsession.Key, intentsession.Binding) (*intentsession.Session, error)
}
// SetIntentSessionService 注入意图会话服务(组合根与测试用;nil 回落按存储
// 自动装配)。
func (s *Server) SetIntentSessionService(svc IntentSessions) {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
s.sessions = svc
}
// intentSessionService 取会话服务:未注入时从存储惰性装配一次,并把槽位
// 校验接到命令总线的 DECLARED schema 上。
func (s *Server) intentSessionService() IntentSessions {
s.sessionsMu.Lock()
defer s.sessionsMu.Unlock()
if s.sessions == nil && s.st != nil {
svc := intentsession.New(s.st)
svc.SetSlotValidator(s.validateIntentSlot)
s.sessions = svc
}
return s.sessions
}
// intentSessionKey 是会话状态的键:owner + project + conversation(三元组
// 缺一不可,两个用户/项目在构造上互不可见)。
func intentSessionKey(owner string, conv core.Conversation) intentsession.Key {
return intentsession.Key{Owner: owner, Project: string(conv.ProjectID), Conversation: string(conv.ID)}
}
// validateIntentSlot 按动作声明的 schema 校验一个槽位值:声明不可解析
// (命令不存在/未启用)时不校验,交由既有准入面 fail-closed。
func (s *Server) validateIntentSlot(actionID, slot, value string) error {
if s.Bus == nil || actionID == "" {
return nil
}
spec, _, err := s.Bus.Registry().Resolve(actionID)
if err != nil {
return nil
}
props, _, ok := objectSchema(spec.ParamsSchema)
if !ok {
return nil
}
decl, declared := props[slot]
if !declared {
return fmt.Errorf("命令未声明参数:%s", slot)
}
if _, ok := castParam(decl, value); !ok {
return fmt.Errorf("参数类型与声明不符:%s", slot)
}
return nil
}
// intentSessionOutcome 是本轮会话语义的结果,供响应面如实回话。
type intentSessionOutcome struct {
// cancelled 表示用户取消了挂起的补充(不派发任何业务动作)。
cancelled bool
// clarify 是仍缺槽位时的澄清诉求(仍缺时才非空)。
clarify *clarifyPayload
// note 是取消这类「不需要派发」的如实回话。
note *intentSessionNote
}
// intentSessionNote 是会话语义的回话标记(既有响应上的附加字段,不新增 API 面)。
type intentSessionNote struct {
Kind string `json:"kind"`
Action string `json:"action,omitempty"`
Intent string `json:"intent,omitempty"`
RunID string `json:"runId,omitempty"`
Reply string `json:"reply"`
Pending bool `json:"pending"`
}
// fuseIntentSession 把本轮用户输入折叠进挂起预期:补齐即合成一条与完整
// 识别同形的 MatchResult(下游照常 route → admit → dispatch);仍缺则
// 如实重新澄清;取消则清理会话且不派发。
// 返回 nil 结果表示本轮与会话语义无关(无挂起预期、或属于别的任务),
// 调用方保持既有行为。
func (s *Server) fuseIntentSession(key intentsession.Key,
recognized intentgate.MatchResult) (intentgate.MatchResult, *intentSessionOutcome, error) {
svc := s.intentSessionService()
if svc == nil || !keyUsable(key) {
return recognized, nil, nil
}
pending, err := svc.Pending(key)
if err != nil {
return recognized, nil, err
}
// 「已取消」记录不是待补齐的动作:它不再吸收本轮的槽位补充(那一轮由
// 取消路由自己处理),只作为如实回话的依据。
if pending == nil || pending.Expectation == nil || pending.Cancelled != nil {
return recognized, nil, nil
}
answer := answerFromRecognition(pending, recognized)
if answer.Cancel {
return recognized, s.applyCancel(svc, key, pending, answer), nil
}
// 本轮对同一动作已是一次完整的新识别:它就是新的指令,按它派发;
// 旧累计槽位不混进新任务(会话就此作废)。
if recognized.Branch == "matched" && recognized.ID != "" &&
recognized.ID == pending.Expectation.IntentID && recognized.Complete() {
return recognized, nil, svc.Drop(key)
}
if len(answer.Slots) == 0 && len(answer.Invalid) == 0 {
// 本轮没有补槽信息(含「命中的是别的意图」):保持既有行为,挂起预期
// 原样留着,由续期或过期收尾——绝不拿别的任务的输入凑当前动作。
return recognized, nil, nil
}
out, err := svc.ApplyAnswer(key, pending.Revision, answer)
if errors.Is(err, intentsession.ErrStale) {
// 会话已被别的轮次推进或取消:陈旧回答作废,绝不据此执行动作。
return recognized, nil, nil
}
if err != nil || out == nil {
return recognized, nil, err
}
if out.Complete {
// 补齐:recognized 已是与完整识别同形的结果,直接走既有准入与
// 派发路径(无需任何附加回话字段)。
return completedRecognition(pending, out), &intentSessionOutcome{}, nil
}
return recognized, &intentSessionOutcome{clarify: sessionClarify(pending, out)}, nil
}
// applyCancel 消费库层 cancel:清空挂起预期并如实确认取消(不派发业务动作)。
func (s *Server) applyCancel(svc IntentSessions, key intentsession.Key,
pending *intentsession.Session, answer intentsession.Answer) *intentSessionOutcome {
out, err := svc.ApplyAnswer(key, pending.Revision, answer)
if errors.Is(err, intentsession.ErrStale) || err != nil || out == nil || !out.Cancelled {
return nil
}
action, intent := "", pending.Expectation.IntentID
if pending.Expectation.Action.ID != "" {
action = pending.Expectation.Action.ID
}
return &intentSessionOutcome{
cancelled: true,
note: &intentSessionNote{Kind: "cancelled", Action: action, Intent: intent, Pending: false,
Reply: "已取消本次槽位补充,未执行任何动作。"},
}
}
// answerFromRecognition 把一次识别投影成会话语义的答案:库层会话命令
// (cancel 优先)、挂起意图自身的槽位与非法原因。归属别的意图时一概不碰
// 本会话(别的任务的输入绝不混进当前动作)。
func answerFromRecognition(pending *intentsession.Session, recognized intentgate.MatchResult) intentsession.Answer {
answer := intentsession.Answer{}
sameIntent := recognized.ID != "" && recognized.ID == pending.Expectation.IntentID
otherIntent := recognized.ID != "" && !sameIntent
for _, cmd := range recognized.Commands {
switch cmd.Kind {
case intentgate.CommandCancel:
if !otherIntent { // 取消指向别的意图时不碰本会话
answer.Cancel = true
return answer
}
case intentgate.CommandSetSlot, intentgate.CommandChangeSlot:
if strings.TrimSpace(cmd.Name) == "" {
continue
}
if answer.Slots == nil {
answer.Slots = map[string]string{}
}
answer.Slots[cmd.Name] = cmd.Value // 显式落槽命令:不看意图归属
}
}
if !sameIntent {
return answer
}
if len(recognized.Slots) > 0 {
if answer.Slots == nil {
answer.Slots = make(map[string]string, len(recognized.Slots))
}
for k, v := range recognized.Slots {
answer.Slots[k] = v
}
}
for _, defect := range recognized.Invalid {
if answer.Invalid == nil {
answer.Invalid = map[string]string{}
}
answer.Invalid[defect.Key] = firstNonEmpty(defect.Reason, "value rejected")
}
return answer
}
// completedRecognition 合成一条与「一次完整识别」同形的 MatchResult:同一
// 意图 ID、累计槽位、同一动作引用的 Start 命令。风险与确认声明刻意不搬运
// ——会话不携带授权语义,确认始终由 cmdbus 按命令 danger 收口。
func completedRecognition(pending *intentsession.Session, out *intentsession.Outcome) intentgate.MatchResult {
action := pending.Expectation.Action
res := intentgate.MatchResult{
Branch: "matched",
ID: pending.Expectation.IntentID,
Slots: out.Slots,
Commands: []intentgate.Command{{Kind: intentgate.CommandStart, Action: action, Slots: out.Slots}},
}
if action.ID != "" {
ref := action
res.Action = &ref
}
return res
}
// sessionClarify 仍缺槽位时的澄清诉求:只报还差什么(缺失/非法各带原因)。
func sessionClarify(pending *intentsession.Session, out *intentsession.Outcome) *clarifyPayload {
action, intent := "", pending.Expectation.IntentID
if pending.Expectation.Action.ID != "" {
action = pending.Expectation.Action.ID
}
// 提问优先问「还差的那个」:缺失在前、非法在后,都没有才回落挂起时的
// 原始提问。
question := ""
if len(out.Remaining) > 0 {
question = strings.TrimSpace(out.Remaining[0].Question)
} else if len(out.Invalid) > 0 {
question = strings.TrimSpace(out.Invalid[0].Question)
}
if question == "" {
question = strings.TrimSpace(out.Question)
}
c := &clarifyPayload{Intent: intent, Command: action, Question: question,
Missing: append([]intentgate.SlotQuestion(nil), out.Remaining...),
Invalid: append([]intentgate.SlotDefect(nil), out.Invalid...)}
if c.Question == "" {
return nil
}
return c
}
// suspendIntentSession 把「动作可执行但槽位未就绪」的那一轮记成会话里的
// 挂起预期(U6 准入面:声明的必填参数无法由原文+槽位满足;路由面:缺失或
// 非法的必填槽位)。响应面不受影响——澄清话术仍由既有 clarify 字段给出。
func (s *Server) suspendIntentSession(key intentsession.Key, recognized intentgate.MatchResult,
dec route.Decision, adm *intentAdmission) error {
svc := s.intentSessionService()
if svc == nil || !keyUsable(key) {
return nil
}
commandID := dec.Clarification.Command
var missing []intentgate.SlotQuestion
question := strings.TrimSpace(dec.Clarification.Question)
switch {
case dec.Kind == route.KindCommand && adm != nil && adm.Blocked:
// 路由已认为动作可派发,但准入未过(声明的必填参数补不齐)。
commandID = dec.CommandID
for _, name := range adm.MissingParams {
missing = append(missing, intentgate.SlotQuestion{Key: name, Question: name})
}
case dec.Clarification.Needed && commandID != "":
// 路由层槽位缺口/非法:动作已解析到已存在的命令,只是槽位不齐。
missing = append(missing, dec.Clarification.Missing...)
for _, defect := range dec.Clarification.Invalid {
missing = append(missing, intentgate.SlotQuestion{Key: defect.Key, Question: defect.Question})
}
default:
return nil // 没有待补齐的可执行动作:不挂起
}
if commandID == "" {
return nil
}
if question == "" && len(missing) > 0 {
question = strings.TrimSpace(missing[0].Question)
}
_, err := svc.Suspend(key, intentsession.Expectation{
IntentID: recognized.ID,
Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: commandID},
Missing: missing, Question: question,
}, dec.Slots)
return err
}
// keyUsable 拒绝不完整的键(无归属即不落任何会话状态)。
func keyUsable(key intentsession.Key) bool { return key.Owner != "" && key.Conversation != "" }
// sessionRunTarget 把意图会话里绑定的运行上下文投影给路由器(取消靶子的
// 会话来源)。没有绑定、绑定已取消、或读取失败一律返回 false:宿主随即
// 如实澄清,绝不臆造一个靶子。
func (s *Server) sessionRunTarget(key intentsession.Key) (route.SessionRun, bool) {
svc := s.intentSessionService()
if svc == nil || !keyUsable(key) {
return route.SessionRun{}, false
}
b, err := svc.Binding(key)
if err != nil || b == nil || b.Cancelled || strings.TrimSpace(b.RunID) == "" {
return route.SessionRun{}, false
}
return route.SessionRun{RunID: b.RunID, Intent: b.Intent, Action: b.Action}, true
}