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:
+58
-17
@@ -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 报告是否装载了意图(诊断/设置展示用)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user