From ff43c7c9aa35ca68898a36d805d671d7c0dfec12 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 7 Oct 2026 07:44:31 +0000 Subject: [PATCH] Unify intent routing behind the route package and enable NFKC Why: I1 WP3 - chat routing and the HITL free-text pivot now share one router consuming the library's command list, and both intent hosts fold compatibility-equivalent input. Evidence: go vet clean; go test ./... 30 packages ok, including the existing workflow-route and server e2e suites unchanged (behavior parity) and the new route package tests. Impact: new internal/route package (unique router with explicit priority rules); intentgate carries Commands and folds NFKC; server.go and runmgr.go route through it; nested pins at intent 2554b50 and squad 6b7a991. Blueprint actions resolve to a squad decision at the router level; per-run squad entry stays a recorded follow-up (no silent misrouting). Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md I1 WP3/WP4 --- deps/agent-intent | 2 +- deps/agent-squad | 2 +- internal/intentgate/gate.go | 6 +- internal/route/route.go | 116 +++++++++++++++++++++++++++++++++++ internal/route/route_test.go | 85 +++++++++++++++++++++++++ internal/runmgr/runmgr.go | 13 +++- internal/server/server.go | 39 ++++++++---- 7 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 internal/route/route.go create mode 100644 internal/route/route_test.go diff --git a/deps/agent-intent b/deps/agent-intent index 65aaebc..2554b50 160000 --- a/deps/agent-intent +++ b/deps/agent-intent @@ -1 +1 @@ -Subproject commit 65aaebca1ea69ab7d48eda51d33ef1d4eea5a0bf +Subproject commit 2554b509e81383308f6264e2b3d03e443bb0d113 diff --git a/deps/agent-squad b/deps/agent-squad index 7ecc04e..6b7a991 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit 7ecc04ee61f6210a5dd484384b789f8d3ce29806 +Subproject commit 6b7a991750e5e57c7384d0b34c8bd267220222f2 diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go index b7c7141..81f8ec5 100644 --- a/internal/intentgate/gate.go +++ b/internal/intentgate/gate.go @@ -24,6 +24,9 @@ type MatchResult struct { ID string Slots map[string]string Missing []SlotQuestion + // Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。 + // guard_hit/expectation_hit 被折叠为 fallback 时为空。 + Commands []agentintent.Command } type Gate struct { @@ -54,7 +57,7 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error { } specs = append(specs, s) } - b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3}) + b := agentintent.NewRegistryBuilder(agentintent.Options{TopN: 3, NFKC: true}) if err := b.Register(specs...); err != nil { return fmt.Errorf("intentgate: register: %w", err) } @@ -95,6 +98,7 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult { switch res.Branch { case agentintent.BranchMatched: out.Branch = "matched" + out.Commands = res.Commands case agentintent.BranchAmbiguous: out.Branch = "ambiguous" } diff --git a/internal/route/route.go b/internal/route/route.go new file mode 100644 index 0000000..4b5347f --- /dev/null +++ b/internal/route/route.go @@ -0,0 +1,116 @@ +// Package route 是 Backend 的唯一意图 Router(I1 WP3)。 +// 它把识别器产出的命令列表统一裁决为路由决策,收编原散落在 +// server(chat 内联分支)与 runmgr(HITL 自由输入转轨)的两套逻辑。 +// +// 优先级明文(提案 §6 要求 I1 写明): +// - auto/handler:命令总线 > 工作流 > 普通回合(与现网行为一致)。 +// - 显式 command/workflow/blueprint:按声明执行;声明目标在当前环境 +// 不可解析(命令不存在、工作流未注册、小队未启用)时不静默错配—— +// 回落普通回合并把原因写进 Reason。 +// - PromptTrigger(技能触发)是提示词装配、不参与路由决胜; +// 意图产出 Start/Clarify 类命令时路由优先。 +// +// 当前版本的已知边界(如实记录,不静默): +// - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项); +// 本版本调用方按普通回合处理并保留 Decision 供审计。 +// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机 +// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为 +// fallback,Start 只来自 matched)。 +package route + +import ( + agentintent "agent-intent/intent" + + "agent-system-backend/internal/intentgate" + core "agent-system-core" +) + +// Kind 是路由决策类型。 +type Kind string + +const ( + KindCommand Kind = "command" + KindWorkflow Kind = "workflow" + KindSquad Kind = "squad" + KindTurn Kind = "turn" +) + +// Decision 是一次路由裁决。 +type Decision struct { + Kind Kind + CommandID string + Workflow core.WorkflowDef + Blueprint string + Slots map[string]string + Missing []intentgate.SlotQuestion + Reason string // 回落 turn 的原因(显式声明不可解析时) + StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用) +} + +// Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。 +type Env struct { + HasCommand func(id string) bool + WorkflowFor func(intentID string) (core.WorkflowDef, bool) + SquadReady bool +} + +// Decide 裁决一次识别结果。规则: +// 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。 +// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。 +// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流; +// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。 +func Decide(res intentgate.MatchResult, env Env) Decision { + d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing} + if res.Branch != "matched" { + return d + } + var start *agentintent.Command + for i := range res.Commands { + if res.Commands[i].Kind == agentintent.CommandStart { + start = &res.Commands[i] + break + } + } + action := agentintent.ActionRef{Kind: agentintent.ActionAuto, ID: res.ID} + if start != nil { + d.StartFound = true + action = start.Action + if len(start.Slots) > 0 { + d.Slots = start.Slots + } + } + switch action.Kind { + case agentintent.ActionCommand: + if env.HasCommand != nil && env.HasCommand(action.ID) { + d.Kind, d.CommandID = KindCommand, action.ID + return d + } + d.Reason = "声明的命令不存在:" + action.ID + case agentintent.ActionWorkflow: + if env.WorkflowFor != nil { + if def, ok := env.WorkflowFor(action.ID); ok { + d.Kind, d.Workflow = KindWorkflow, def + return d + } + } + d.Reason = "声明的工作流未注册:" + action.ID + case agentintent.ActionBlueprint: + if env.SquadReady { + d.Kind, d.Blueprint = KindSquad, action.ID + return d + } + d.Reason = "声明的小队未启用:" + action.ID + default: // auto / handler + if env.HasCommand != nil && env.HasCommand(action.ID) { + d.Kind, d.CommandID = KindCommand, action.ID + return d + } + if env.WorkflowFor != nil { + if def, ok := env.WorkflowFor(action.ID); ok { + d.Kind, d.Workflow = KindWorkflow, def + return d + } + } + } + return d +} diff --git a/internal/route/route_test.go b/internal/route/route_test.go new file mode 100644 index 0000000..cb2913d --- /dev/null +++ b/internal/route/route_test.go @@ -0,0 +1,85 @@ +package route + +import ( + "testing" + + agentintent "agent-intent/intent" + + "agent-system-backend/internal/intentgate" + core "agent-system-core" +) + +func startCmd(action agentintent.ActionRef) agentintent.Command { + return agentintent.Command{Kind: agentintent.CommandStart, Action: action} +} + +func TestDecideAutoPrefersCommandThenWorkflow(t *testing.T) { + in := intentgate.MatchResult{Branch: "matched", ID: "demo.x", + Commands: []agentintent.Command{startCmd(agentintent.ActionRef{Kind: agentintent.ActionAuto, ID: "demo.x"})}} + d := Decide(in, Env{HasCommand: func(string) bool { return true }}) + if d.Kind != KindCommand || d.CommandID != "demo.x" { + t.Fatalf("auto command = %+v", d) + } + d = Decide(in, Env{ + HasCommand: func(string) bool { return false }, + WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true }, + }) + if d.Kind != KindWorkflow || d.Workflow.ID != "wf" { + t.Fatalf("auto workflow = %+v", d) + } + d = Decide(in, Env{}) + if d.Kind != KindTurn || !d.StartFound { + t.Fatalf("auto turn = %+v", d) + } +} + +func TestDecideExplicitKinds(t *testing.T) { + mk := func(kind, id string) intentgate.MatchResult { + return intentgate.MatchResult{Branch: "matched", ID: "demo.x", + Commands: []agentintent.Command{startCmd(agentintent.ActionRef{Kind: kind, ID: id})}} + } + if d := Decide(mk(agentintent.ActionCommand, "cmd-1"), Env{HasCommand: func(id string) bool { return id == "cmd-1" }}); d.Kind != KindCommand || d.CommandID != "cmd-1" { + t.Fatalf("explicit command = %+v", d) + } + d := Decide(mk(agentintent.ActionCommand, "nope"), Env{ + WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true }, + }) + if d.Kind != KindTurn || d.Reason == "" { + t.Fatalf("unresolvable command = %+v", d) + } + if d := Decide(mk(agentintent.ActionWorkflow, "wf-1"), Env{ + WorkflowFor: func(id string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: id}, true }, + }); d.Kind != KindWorkflow || d.Workflow.ID != "wf-1" { + t.Fatalf("explicit workflow = %+v", d) + } + if d := Decide(mk(agentintent.ActionBlueprint, "bp-1"), Env{SquadReady: true}); d.Kind != KindSquad || d.Blueprint != "bp-1" { + t.Fatalf("blueprint = %+v", d) + } + if d := Decide(mk(agentintent.ActionBlueprint, "bp-1"), Env{}); d.Kind != KindTurn || d.Reason == "" { + t.Fatalf("blueprint without squad = %+v", d) + } +} + +func TestDecideNonMatchedAndLegacyFallback(t *testing.T) { + if d := Decide(intentgate.MatchResult{Branch: "fallback"}, Env{}); d.Kind != KindTurn { + t.Fatalf("fallback = %+v", d) + } + // matched 但命令列表为空(旧消费方):按 TopID 走 auto,与现网一致。 + d := Decide(intentgate.MatchResult{Branch: "matched", ID: "demo.x"}, Env{HasCommand: func(string) bool { return true }}) + if d.Kind != KindCommand || d.StartFound { + t.Fatalf("legacy auto = %+v", d) + } +} + +func TestSlotsFromStartCommand(t *testing.T) { + in := intentgate.MatchResult{Branch: "matched", ID: "demo.x", + Commands: []agentintent.Command{{ + Kind: agentintent.CommandStart, + Action: agentintent.ActionRef{Kind: agentintent.ActionAuto, ID: "demo.x"}, + Slots: map[string]string{"topic": "电机"}, + }}} + d := Decide(in, Env{HasCommand: func(string) bool { return true }}) + if d.Slots["topic"] != "电机" { + t.Fatalf("slots = %v", d.Slots) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 7d35b1a..16fecb2 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -24,7 +24,9 @@ import ( "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/ingest" + "agent-system-backend/internal/intentgate" "agent-system-backend/internal/profiles" + "agent-system-backend/internal/route" "agent-system-backend/internal/runtime" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" @@ -1269,12 +1271,21 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv answer = resp.FreeText trimmed := strings.TrimSpace(resp.FreeText) // 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。 + // 命中与否经唯一 Router(internal/route)判定,与 chat 路由同一裁决(I1 WP3)。 pivot := false pivotReason := "" if strings.HasPrefix(trimmed, "/") { pivot, pivotReason = true, "command" } else if m.gate != nil { - if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" { + if rec, ok := m.gate.(interface { + Recognize(ctx context.Context, input string) intentgate.MatchResult + }); ok { + res := rec.Recognize(context.Background(), trimmed) + dec := route.Decide(res, route.Env{}) + if dec.StartFound { + pivot, pivotReason = true, "intent:" + res.ID + } + } else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" { pivot, pivotReason = true, "intent:"+topID } } diff --git a/internal/server/server.go b/internal/server/server.go index a404a64..9cd08a7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -27,6 +27,7 @@ import ( "agent-system-backend/internal/profiles" "agent-system-backend/internal/prompts" "agent-system-backend/internal/ratelimit" + "agent-system-backend/internal/route" "agent-system-backend/internal/runmgr" "agent-system-backend/internal/settings" "agent-system-backend/internal/store" @@ -646,20 +647,35 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { // 先路由再落库。回合和工作流要先抢槽位,忙则 409 且不写任何数据。 // 意图命中的命令不占槽位,会话忙时也能用。 - route := "turn" + // 路由裁决统一走唯一 Router(internal/route;I1 WP3)。 + routeKind := route.KindTurn var recognized intentgate.MatchResult + var workflowDef core.WorkflowDef if s.Gate != nil && !strings.HasPrefix(text, "/") { recognized = s.recognize(r.Context(), text) - if recognized.Branch == "matched" && s.Bus != nil && s.Bus.HasCommand(recognized.ID) { - route = "command" - } else if recognized.Branch == "matched" && s.Workflows != nil { - if _, ok := s.Workflows.ByIntent(recognized.ID); ok { - route = "workflow" - } + dec := route.Decide(recognized, route.Env{ + HasCommand: func(id string) bool { return s.Bus != nil && s.Bus.HasCommand(id) }, + WorkflowFor: func(intentID string) (core.WorkflowDef, bool) { + if s.Workflows == nil { + return core.WorkflowDef{}, false + } + return s.Workflows.ByIntent(intentID) + }, + // SquadReady 保持 false:per-run entry 覆盖未就位(见 route 包注释的已知边界)。 + }) + switch dec.Kind { + case route.KindCommand: + routeKind = route.KindCommand + recognized.ID = dec.CommandID + recognized.Slots = dec.Slots + case route.KindWorkflow: + routeKind = route.KindWorkflow + workflowDef = dec.Workflow + recognized.Slots = dec.Slots } } claimed := false - if route == "turn" || route == "workflow" { + if routeKind == route.KindTurn || routeKind == route.KindWorkflow { if err := s.mgr.ClaimConversation(convID); err != nil { writeStoreErr(w, err) return @@ -682,7 +698,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - if route == "command" { + if routeKind == route.KindCommand { resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner, Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID, @@ -701,13 +717,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { } var run core.Run var startErr error - if route == "workflow" { - def, _ := s.Workflows.ByIntent(recognized.ID) + if routeKind == route.KindWorkflow { missing := make([]workflow.SlotQuestion, len(recognized.Missing)) for i, q := range recognized.Missing { missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question} } - run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, def, recognized.Slots, missing) + run, startErr = s.mgr.StartClaimedWorkflowRun(conv, msg, workflowDef, recognized.Slots, missing) } else { run, startErr = s.mgr.StartClaimedConversationRun(conv, msg, parsedDocs, inputs) }