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
+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":"主题是什么?"}]}`)
}