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:
@@ -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)
|
||||
|
||||
@@ -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":"主题是什么?"}]}`)
|
||||
}
|
||||
Reference in New Issue
Block a user