Merge pull request 'Unify intent routing behind the route package and enable NFKC (I1 WP3/WP4)' (#74) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 42s

This commit was merged in pull request #74.
This commit is contained in:
2026-10-07 07:46:07 +00:00
7 changed files with 247 additions and 16 deletions
+5 -1
View File
@@ -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"
}
+116
View File
@@ -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
}
+85
View File
@@ -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)
}
}
+12 -1
View File
@@ -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
}
}
+27 -12
View File
@@ -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)
}