Route a matched intent to its workflow after commands

Why: A hit intent should start the bound workflow, and a command that shares the same id still has to win.
Evidence: go test ./internal/intentgate/ ./internal/runmgr/ ./internal/server/ passed. Recognize keeps slots and the missing-slot question. StartWorkflowRun copies the definition and selects the workflow runner even when mock is on.
Impact: Ordinary turns are unchanged. The workflow runner body is still a stub until the executor lands.
Refs: feature/agent-harness-p4
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 16:04:49 +00:00
co-authored by Cursor
parent 63ea1a262c
commit ebeb6c599c
9 changed files with 408 additions and 33 deletions
+58 -17
View File
@@ -12,11 +12,26 @@ import (
agentintent "agent-intent/intent"
)
// SlotQuestion 是命中意图后仍缺的必填槽位,以及建议的提问。
type SlotQuestion struct {
Key string
Question string
}
// MatchResult 是一次识别。Branch 与 Match 相同。
type MatchResult struct {
Branch string
ID string
Slots map[string]string
Missing []SlotQuestion
}
type Gate struct {
mu sync.RWMutex
reg *agentintent.Registry
rec *agentintent.FunnelRecognizer
specs int
mu sync.RWMutex
reg *agentintent.Registry
rec *agentintent.FunnelRecognizer
specs int
specsByID map[string]agentintent.IntentSpec
}
func New() *Gate {
@@ -27,7 +42,7 @@ func New() *Gate {
func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
if len(overlay) == 0 {
g.mu.Lock()
g.reg, g.specs = nil, 0
g.reg, g.specs, g.specsByID = nil, 0, nil
g.mu.Unlock()
return nil
}
@@ -47,8 +62,12 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
if err != nil {
return fmt.Errorf("intentgate: build: %w", err)
}
byID := make(map[string]agentintent.IntentSpec, len(specs))
for _, s := range specs {
byID[s.ID] = s
}
g.mu.Lock()
g.reg, g.specs = reg, len(specs)
g.reg, g.specs, g.specsByID = reg, len(specs), byID
g.mu.Unlock()
return nil
}
@@ -56,28 +75,50 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
// Match 识别自由输入;返回 (branch, topID)。
// branch:matched | ambiguous | fallback | ""(未配置/系统级错误 → 回退通道)。
func (g *Gate) Match(ctx context.Context, input string) (string, string) {
res := g.Recognize(ctx, input)
return res.Branch, res.ID
}
// Recognize 在 Match 的结果上带上槽位和缺失提问。
func (g *Gate) Recognize(ctx context.Context, input string) MatchResult {
g.mu.RLock()
reg, rec := g.reg, g.rec
reg, rec, byID := g.reg, g.rec, g.specsByID
g.mu.RUnlock()
if reg == nil {
return "", ""
return MatchResult{}
}
res, err := rec.Recognize(ctx, input, reg.Snapshot())
if err != nil {
return "", ""
}
topID := ""
if res.Top != nil {
topID = res.Top.ID
return MatchResult{}
}
out := MatchResult{Branch: "fallback"}
switch res.Branch {
case agentintent.BranchMatched:
return "matched", topID
out.Branch = "matched"
case agentintent.BranchAmbiguous:
return "ambiguous", topID
default:
return "fallback", topID
out.Branch = "ambiguous"
}
if res.Top == nil {
return out
}
out.ID = res.Top.ID
if len(res.Top.Slots) > 0 {
out.Slots = map[string]string{}
for k, v := range res.Top.Slots {
out.Slots[k] = v
}
}
spec := byID[out.ID]
for _, key := range res.Top.MissingRequiredSlots {
q := key
for _, sl := range spec.Slots {
if sl.Key == key && sl.Question != "" {
q = sl.Question
}
}
out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: q})
}
return out
}
// Enabled 报告是否装载了意图(诊断/设置展示用)。
+26
View File
@@ -65,3 +65,29 @@ func TestGateFailClosed(t *testing.T) {
t.Error("empty rebuild should disable gate")
}
}
func TestRecognizeKeepsSlots(t *testing.T) {
g := New()
spec := raw(t, map[string]any{
"id": "demo.brief", "domain": "demo", "version": 1,
"matcher": map[string]any{"includes_any": []string{"简报"}},
"slots": []map[string]any{{
"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`,
"question": "主题是什么?",
}},
})
if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil {
t.Fatal(err)
}
miss := g.Recognize(context.Background(), "写一份简报")
if miss.Branch != "matched" || miss.ID != "demo.brief" || len(miss.Missing) != 1 || miss.Missing[0].Question != "主题是什么?" {
t.Fatalf("%+v", miss)
}
hit := g.Recognize(context.Background(), "主题:核能 写一份简报")
if hit.Slots["topic"] != "核能" || len(hit.Missing) != 0 {
t.Fatalf("%+v", hit)
}
if branch, id := g.Match(context.Background(), "写一份简报"); branch != "matched" || id != "demo.brief" {
t.Fatalf("Match wrapper = %s %s", branch, id)
}
}
+8 -4
View File
@@ -4,6 +4,7 @@ import (
"testing"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
@@ -13,16 +14,19 @@ func (n nameRunner) Name() string { return n.name }
func (n nameRunner) Execute(rc runtime.RunCtx) error { return nil }
func TestSelectConversationRunner(t *testing.T) {
m := &Manager{runner: nameRunner{name: "direct"}, mock: nameRunner{name: "mock"}, squad: nameRunner{name: "squad"}}
if got := selectConversationRunner(m, core.EffectiveSettings{}); got.Name() != "direct" {
m := &Manager{runner: nameRunner{name: "direct"}, mock: nameRunner{name: "mock"}, squad: nameRunner{name: "squad"}, wfRunner: nameRunner{name: "workflow"}}
if got := selectConversationRunner(m, core.EffectiveSettings{}, nil); got.Name() != "direct" {
t.Fatalf("default = %s", got.Name())
}
squadEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad"}}
if got := selectConversationRunner(m, squadEff); got.Name() != "squad" {
if got := selectConversationRunner(m, squadEff, nil); got.Name() != "squad" {
t.Fatalf("squad entry = %s", got.Name())
}
mockEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad", "mock": true}}
if got := selectConversationRunner(m, mockEff); got.Name() != "mock" {
if got := selectConversationRunner(m, mockEff, nil); got.Name() != "mock" {
t.Fatalf("mock wins = %s", got.Name())
}
if got := selectConversationRunner(m, mockEff, &workflow.Launch{}); got.Name() != "workflow" {
t.Fatalf("workflow wins = %s", got.Name())
}
}
+46 -7
View File
@@ -20,6 +20,7 @@ import (
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
@@ -34,6 +35,7 @@ type Manager struct {
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
mockSub runtime.Runner // 子代理脚本回放
squad runtime.Runner // runtime.entry=squad 时的对话入口
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.WorkflowRunner
gate IntentGate
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
@@ -147,6 +149,15 @@ type runCtx struct {
subTask string
subHint string
subAgent string
launch *workflow.Launch
}
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
func (c *runCtx) Workflow() (workflow.Launch, bool) {
if c.launch == nil {
return workflow.Launch{}, false
}
return *c.launch, true
}
func (c *runCtx) Context() context.Context { return c.ctx }
@@ -693,8 +704,18 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]
// SetSquadRunner 注入 runtime.entry=squad 时使用的对话入口。
func (m *Manager) SetSquadRunner(r runtime.Runner) { m.squad = r }
// selectConversationRunner 按设置选择入口。mock 优先;squad 仅在注入了入口时生效。
func selectConversationRunner(m *Manager, eff core.EffectiveSettings) runtime.Runner {
// SetWorkflowRunner 注入工作流执行器。测试用来替换默认实现。
func (m *Manager) SetWorkflowRunner(r runtime.Runner) { m.wfRunner = r }
// selectConversationRunner 按设置选择入口。带工作流定义时走工作流执行器;
// 否则 mock 优先,squad 仅在注入了入口时生效。
func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *workflow.Launch) runtime.Runner {
if launch != nil {
if m.wfRunner != nil {
return m.wfRunner
}
return runtime.WorkflowRunner{}
}
if eff.GetBool("runtime.mock", false) {
return m.mock
}
@@ -711,7 +732,25 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
if err := m.claimConversation(conv.ID); err != nil {
return core.Run{}, err
}
return m.startClaimedRun(conv, userMessage, attachments, inputs)
return m.startClaimedRun(conv, userMessage, attachments, inputs, nil)
}
// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝,
// 之后插件卸载不会改到已经在跑的这份。
func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
if err := m.claimConversation(conv.ID); err != nil {
return core.Run{}, err
}
copiedSlots := map[string]string{}
for k, v := range slots {
copiedSlots[k] = v
}
launch := &workflow.Launch{
Def: workflow.Clone(def),
Slots: copiedSlots,
Missing: append([]workflow.SlotQuestion(nil), missing...),
}
return m.startClaimedRun(conv, userMessage, nil, nil, launch)
}
func (m *Manager) claimConversation(conversationID string) error {
@@ -727,7 +766,7 @@ func (m *Manager) claimConversation(conversationID string) error {
return nil
}
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) {
prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID)
eff := m.settings.Effective(conv.Owner, conv.ProjectID)
run := core.Run{
@@ -744,9 +783,9 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
m.active[run.ID] = h
m.mu.Unlock()
runner := selectConversationRunner(m, eff)
runner := selectConversationRunner(m, eff, launch)
inputPaths, _ := m.stageInputs(conv, eff, inputs)
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths}
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch}
go m.execute(rc, runner)
return run, nil
}
@@ -1235,7 +1274,7 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
m.releaseConversation(convID)
return err
}
if _, err := m.startClaimedRun(conv, msg, nil, nil); err != nil {
if _, err := m.startClaimedRun(conv, msg, nil, nil, nil); err != nil {
m.releaseConversation(convID)
return err
}
+82
View File
@@ -0,0 +1,82 @@
package runmgr
import (
"path/filepath"
"testing"
"time"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
type launchCapture struct {
ch chan workflow.Launch
}
func (c launchCapture) Name() string { return "workflow-capture" }
func (c launchCapture) Execute(rc runtime.RunCtx) error {
src, ok := rc.(interface {
Workflow() (workflow.Launch, bool)
})
if !ok {
return nil
}
launch, ok := src.Workflow()
if ok {
c.ch <- launch
}
return nil
}
func TestStartWorkflowRunCopiesDefinition(t *testing.T) {
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "wf.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry()
prof := profiles.New(st)
if err := prof.EnsureDefault(); err != nil {
t.Fatal(err)
}
direct := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "direct"}}}
cap := launchCapture{ch: make(chan workflow.Launch, 1)}
mgr := New(st, bus.New(), reg, prof, settings.NewService(st), direct, noopRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
mgr.SetWorkflowRunner(cap)
proj, err := st.CreateProject(store.OwnerDefault, "p")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation(store.OwnerDefault, proj.ID, "c", "")
if err != nil {
t.Fatal(err)
}
msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "写简报"}}}
def := core.WorkflowDef{ID: "demo.brief", Title: "简报", Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}}}
if _, err := mgr.StartWorkflowRun(conv, msg, def, map[string]string{"topic": "核能"}, []workflow.SlotQuestion{{Key: "audience", Question: "给谁看?"}}); err != nil {
t.Fatal(err)
}
def.Title = "被改掉"
select {
case launch := <-cap.ch:
if launch.Def.ID != "demo.brief" || launch.Def.Title != "简报" || launch.Slots["topic"] != "核能" {
t.Fatalf("%+v", launch)
}
if len(launch.Missing) != 1 || launch.Missing[0].Question != "给谁看?" {
t.Fatalf("missing %+v", launch.Missing)
}
case <-time.After(time.Second):
t.Fatal("workflow runner was not selected")
}
}
+31
View File
@@ -0,0 +1,31 @@
package runtime
import (
"fmt"
"agent-system-backend/internal/workflow"
)
// WorkflowRunner 执行启动时拷贝下来的工作流定义。
type WorkflowRunner struct{}
func (WorkflowRunner) Name() string { return "workflow" }
func (WorkflowRunner) Execute(rc RunCtx) error {
return runWorkflow(rc)
}
type workflowSource interface {
Workflow() (workflow.Launch, bool)
}
func runWorkflow(rc RunCtx) error {
src, ok := rc.(workflowSource)
if !ok {
return fmt.Errorf("workflow: 当前运行没有工作流定义")
}
if _, ok := src.Workflow(); !ok {
return fmt.Errorf("workflow: 当前运行没有工作流定义")
}
return fmt.Errorf("workflow: 执行器尚未接上")
}
+30 -5
View File
@@ -22,6 +22,7 @@ import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/prompts"
@@ -70,6 +71,18 @@ type NLIntentGate interface {
Match(ctx context.Context, input string) (string, string)
}
type intentRecognizer interface {
Recognize(ctx context.Context, input string) intentgate.MatchResult
}
func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchResult {
if rg, ok := s.Gate.(intentRecognizer); ok {
return rg.Recognize(ctx, text)
}
branch, id := s.Gate.Match(ctx, text)
return intentgate.MatchResult{Branch: branch, ID: id}
}
// WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。
// 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。
func (s *Server) WireBuiltinCommands() {
@@ -562,14 +575,15 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
// chat 入口同源(D27):意图闸门命中且命令总线承接 → 统一分发(同审计),不起 run。
if s.Bus != nil && s.Gate != nil && !strings.HasPrefix(text, "/") {
if branch, topID := s.Gate.Match(r.Context(), text); branch == "matched" && s.Bus.HasCommand(topID) {
// 先命令总线,再绑定工作流,最后才是普通回合。
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) {
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: topID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner,
Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID,
})
out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": topID}
out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": recognized.ID}
if resp.Card != nil {
out["card"] = resp.Card
}
@@ -581,6 +595,17 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, out)
return
}
if recognized.Branch == "matched" && s.Workflows != nil {
if def, ok := s.Workflows.ByIntent(recognized.ID); ok {
missing := make([]workflow.SlotQuestion, len(recognized.Missing))
for i, q := range recognized.Missing {
missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question}
}
run, err := s.mgr.StartWorkflowRun(conv, msg, def, recognized.Slots, missing)
writeJSONOr(w, map[string]any{"message": msg, "run": run}, err)
return
}
}
}
run, err := s.mgr.StartConversationRun(conv, msg, parsedDocs, inputs)
writeJSONOr(w, map[string]any{"message": msg, "run": run}, err)
+111
View File
@@ -0,0 +1,111 @@
package server
import (
"context"
"encoding/json"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
type routeCapture struct {
ch chan workflow.Launch
}
func (c routeCapture) Name() string { return "route-capture" }
func (c routeCapture) Execute(rc runtime.RunCtx) error {
src, _ := rc.(interface {
Workflow() (workflow.Launch, bool)
})
if src == nil {
return nil
}
if launch, ok := src.Workflow(); ok {
c.ch <- launch
}
return nil
}
func TestCommandBusBeatsWorkflow(t *testing.T) {
srv, st := newTestServer(t)
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
t.Fatal(err)
}
ch := make(chan workflow.Launch, 1)
srv.mgr.SetWorkflowRunner(routeCapture{ch})
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
reg := workflow.NewRegistry()
reg.Set([]core.WorkflowDef{{
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
}}, nil)
srv.Workflows = reg
if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.brief", Title: "简报命令"}, "", func(context.Context, cmdbus.Request) (any, error) {
return map[string]any{"ok": true}, nil
}); err != nil {
t.Fatal(err)
}
_, convID := setupConversation(t, srv.Handler())
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
if code != 200 || out["command"] != "demo.brief" {
t.Fatalf("status %d body %v", code, out)
}
if _, ok := out["run"]; ok {
t.Fatal("a command hit must not start a workflow run")
}
select {
case <-ch:
t.Fatal("workflow runner started for a command")
default:
}
}
func TestIntentStartsWorkflowRun(t *testing.T) {
srv, st := newTestServer(t)
if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil {
t.Fatal(err)
}
ch := make(chan workflow.Launch, 1)
srv.mgr.SetWorkflowRunner(routeCapture{ch})
gate := intentgate.New()
if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil {
t.Fatal(err)
}
srv.Gate = gate
reg := workflow.NewRegistry()
reg.Set([]core.WorkflowDef{{
ID: "demo.brief", Title: "简报", IntentID: "demo.brief",
Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}},
}}, nil)
srv.Workflows = reg
_, convID := setupConversation(t, srv.Handler())
code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"})
if code != 200 || out["run"] == nil {
t.Fatalf("status %d body %v", code, out)
}
select {
case launch := <-ch:
if launch.Def.ID != "demo.brief" || len(launch.Missing) != 1 || launch.Missing[0].Question != "主题是什么?" {
t.Fatalf("%+v", launch)
}
case <-time.After(time.Second):
t.Fatal("workflow runner was not selected")
}
}
func briefIntent() json.RawMessage {
return json.RawMessage(`{"id":"demo.brief","domain":"demo","version":1,"matcher":{"includes_any":["简报"]},"slots":[{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"}]}`)
}
+16
View File
@@ -77,6 +77,22 @@ func (r *Registry) Script(workflowID, path string) (string, bool) {
return body, ok
}
// SlotQuestion 是启动工作流时还没填上的必填槽位。
type SlotQuestion struct {
Key string
Question string
}
// Launch 是一次工作流 run 启动时的副本。之后插件卸载不能改到它。
type Launch struct {
Def core.WorkflowDef
Slots map[string]string
Missing []SlotQuestion
}
// Clone 拷贝定义,调用方之后的修改不影响副本。
func Clone(def core.WorkflowDef) core.WorkflowDef { return cloneDef(def) }
func cloneDef(def core.WorkflowDef) core.WorkflowDef {
raw, err := json.Marshal(def)
if err != nil {