Merge pull request 'Wire the intent slow lane behind settings flags (I2 WP3)' (#83) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 40s

Wire the intent slow lane behind settings flags (I2 WP3)
This commit was merged in pull request #83.
This commit is contained in:
2026-10-07 11:23:57 +00:00
9 changed files with 509 additions and 3 deletions
+17 -2
View File
@@ -36,6 +36,7 @@ import (
"agent-system-backend/internal/config"
"agent-system-backend/internal/hostapi"
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/intentsem"
"agent-system-backend/internal/pgbus"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
@@ -330,12 +331,26 @@ func Run(opt Options) error {
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
pipe := rag.NewPipeline(st, setSvc, nil, nil)
pipe.SetEmbedder(rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
embedder := rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string {
return setSvc.Effective(store.OwnerDefault, "").GetString("models.embedding.provider", "")
}})
}}
pipe.SetEmbedder(embedder)
pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute))
mgr.SetRetriever(pipe)
mgr.SetIndexer(pipe)
// 意图慢通道(I2):语义向量裁决 + 轻量模型命令生成。
// 两个开关都默认关——此时识别行为与 I1 完全一致(fail-closed 分层);
// 开启语义需已配置 embedding 端点,开启生成需可用的轻量/主模型档。
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentSemantic", false) {
gate.SetSemantic(intentsem.EmbeddingScorer{Provider: embedder}, 0)
}
if setSvc.Effective(store.OwnerDefault, "").GetBool("runtime.intentGenerator", false) {
effNow := setSvc.Effective(store.OwnerDefault, "")
light := runtime.ResolveLightweightModel(core.AgentProfile{}, effNow)
client := &agentllm.Client{BaseURL: light.Base, APIKey: light.Key, HTTPClient: &http.Client{}}
gate.SetGenerator(intentsem.LightGenerator{Stream: client.Stream, Model: light.Model})
}
// fs 预留能力沙箱根:<DataDir>/workspaces/<owner>/<conversationID>/。
mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces"))
+29
View File
@@ -0,0 +1,29 @@
package app
import (
"testing"
"agent-system-backend/internal/settings"
core "agent-system-core"
)
// 慢通道开关默认关(行为与 I1 一致的机器证明):
// 两条 runtime.intentSemantic / runtime.intentGenerator 必须注册且默认 false。
func TestIntentSlowLaneFlagsDefaultOff(t *testing.T) {
seen := map[string]core.SettingDesc{}
for _, d := range settings.Schema() {
seen[d.Key] = d
}
for _, key := range []string{"runtime.intentSemantic", "runtime.intentGenerator"} {
d, ok := seen[key]
if !ok {
t.Fatalf("设置未注册:%s", key)
}
if d.Type != core.SettingBool {
t.Fatalf("%s 类型应为 bool,得到 %v", key, d.Type)
}
if def, isBool := d.Default.(bool); !isBool || def {
t.Fatalf("%s 必须默认 false,得到 %v", key, d.Default)
}
}
}
+35
View File
@@ -51,12 +51,47 @@ type Gate struct {
rec *agentintent.FunnelRecognizer
specs int
specsByID map[string]agentintent.IntentSpec
// 慢通道(I2):nil = 不启用(默认,行为与 I1 一致)。
sem agentintent.SemanticScorer
semThresh float64
gen agentintent.CommandGenerator
}
func New() *Gate {
return &Gate{rec: agentintent.NewRecognizer(agentintent.Options{}, nil, nil)}
}
// SetSemantic 接线语义打分器(慢通道中位)。识别器随之重建;
// 传 nil 关闭语义层。阈值 ≤0 用库默认(0.8)。
func (g *Gate) SetSemantic(s agentintent.SemanticScorer, threshold float64) {
g.mu.Lock()
defer g.mu.Unlock()
g.sem, g.semThresh = s, threshold
g.rebuildRecognizerLocked()
}
// SetGenerator 接线模型命令生成器(慢通道末位)。识别器随之重建;
// 传 nil 关闭生成层。
func (g *Gate) SetGenerator(gen agentintent.CommandGenerator) {
g.mu.Lock()
defer g.mu.Unlock()
g.gen = gen
g.rebuildRecognizerLocked()
}
// rebuildRecognizerLocked 按当前慢通道配置重建识别器(调用方持写锁)。
func (g *Gate) rebuildRecognizerLocked() {
if g.sem == nil && g.gen == nil {
g.rec = agentintent.NewRecognizer(agentintent.Options{}, nil, nil)
return
}
g.rec = agentintent.NewRecognizer(agentintent.Options{
Semantic: g.sem,
SemanticThreshold: g.semThresh,
Generator: g.gen,
}, nil, nil)
}
// Rebuild 用插件意图 overlay 重建注册表;失败保留上一快照(fail-closed)并返回错误。
func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error {
if len(overlay) == 0 {
+85
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"testing"
agentintent "agent-intent/intent"
)
func raw(t *testing.T, v any) json.RawMessage {
@@ -91,3 +93,86 @@ func TestRecognizeKeepsSlots(t *testing.T) {
t.Fatalf("Match wrapper = %s %s", branch, id)
}
}
// ---- I2 慢通道接线(默认关;SetSemantic/SetGenerator 开启)----
// stubScorer 固定返回脚本分(按 examples[0] 查表)。
type stubScorer struct{ scores map[string]float64 }
func (s stubScorer) Score(_ context.Context, _ string, examples []string) (float64, error) {
if len(examples) == 0 {
return 0, nil
}
return s.scores[examples[0]], nil
}
// stubGenerator 固定返回脚本命令。
type stubGenerator struct{ cmds []Command }
func (g stubGenerator) Generate(_ context.Context, _ string, _ []agentintent.CatalogSpec) ([]agentintent.Command, error) {
return g.cmds, nil
}
func ambiguousOverlay(t *testing.T) []json.RawMessage {
t.Helper()
mk := func(id string, ex []string) json.RawMessage {
return raw(t, map[string]any{
"id": id, "domain": "demo", "version": 1, "priority": 5,
"matcher": map[string]any{"includes_any": []string{"模糊词"}},
"examples": ex,
})
}
return []json.RawMessage{mk("demo.alpha", []string{"查运行进度"}), mk("demo.beta", []string{"闲聊问候"})}
}
// 语义层:歧义被过阈打分裁决为 matched(默认关时不启用)。
func TestGateSemanticResolvesAmbiguous(t *testing.T) {
g := New()
if err := g.Rebuild(context.Background(), ambiguousOverlay(t)); err != nil {
t.Fatal(err)
}
// 默认(未接线):规则层歧义原样透出。
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("默认关:branch = %s", branch)
}
g.SetSemantic(stubScorer{scores: map[string]float64{"查运行进度": 0.95, "闲聊问候": 0.4}}, 0)
branch, top := g.Match(context.Background(), "模糊词")
if branch != "matched" || top != "demo.alpha" {
t.Fatalf("语义裁决:%s/%s", branch, top)
}
// 关闭后回到歧义。
g.SetSemantic(nil, 0)
if branch, _ := g.Match(context.Background(), "模糊词"); branch != "ambiguous" {
t.Fatalf("关闭语义层:branch = %s", branch)
}
}
// 生成层:fallback 时产出合法 start 命令 → matched + Commands。
func TestGateGeneratorProducesCommands(t *testing.T) {
g := New()
overlay := []json.RawMessage{raw(t, map[string]any{
"id": "report.generate", "domain": "report", "version": 1,
"description": "生成报告",
"matcher": map[string]any{"includes_any": []string{"生成报告"}},
"handler": "reportHandler",
})}
if err := g.Rebuild(context.Background(), overlay); err != nil {
t.Fatal(err)
}
g.SetGenerator(stubGenerator{cmds: []Command{{
Kind: CommandStart,
Action: ActionRef{Kind: ActionHandler, ID: "reportHandler"},
}}})
res := g.Recognize(context.Background(), "完全不相关的一句话")
if res.Branch != "matched" || res.ID != "report.generate" {
t.Fatalf("生成器命中:%+v", res)
}
if len(res.Commands) != 1 || res.Commands[0].Kind != CommandStart {
t.Fatalf("commands: %+v", res.Commands)
}
// 关闭后回到 fallback。
g.SetGenerator(nil)
if branch, _ := g.Match(context.Background(), "完全不相关的一句话"); branch != "fallback" {
t.Fatalf("关闭生成层:branch = %s", branch)
}
}
+129
View File
@@ -0,0 +1,129 @@
package intentsem
import (
"context"
"encoding/json"
"fmt"
"strings"
agentintent "agent-intent/intent"
agentllm "agent-native/runtime/llm"
)
// LightGenerator 用轻量模型实现 agentintent.CommandGenerator
// (Rasa 式 LLM command generator:只把相关流程交给生成器)。
// 提示词给出能力目录(spec id/描述/槽位键),要求模型输出严格的
// 命令 JSON 数组;解析失败或模型不可达都返回错误——库侧约定生成器
// 出错时识别保持 fallback(fail-closed)。
type LightGenerator struct {
// Stream 提供方边界(注入点;测试可脚本化)。
Stream agentllm.StreamFunc
// Model 是轻量档模型名。
Model string
// APIBase/APIKey 走 StreamOptions(客户端可按次覆盖)。
APIBase string
APIKey string
// MaxTokens 上限;0 = 1024。
MaxTokens int
}
// 生成输出契约:action 与 agentintent.ActionRef 同形(kind/id)。
type generatedCommand struct {
Kind string `json:"kind"`
Action generatedAction `json:"action,omitempty"`
Slots map[string]string `json:"slots,omitempty"`
}
type generatedAction struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
// Generate 基于输入与能力目录产出命令列表。目录为空时不调用模型。
func (g LightGenerator) Generate(ctx context.Context, input string, catalog []agentintent.CatalogSpec) ([]agentintent.Command, error) {
if g.Stream == nil || len(catalog) == 0 || strings.TrimSpace(input) == "" {
return nil, nil
}
maxTok := g.MaxTokens
if maxTok <= 0 {
maxTok = 1024
}
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: generatorSystemPrompt(catalog)},
{Role: agentllm.RoleUser, Content: input},
}
ch := g.Stream(ctx, g.Model, msgs, nil, agentllm.StreamOptions{
MaxTokens: maxTok, APIBase: g.APIBase, APIKey: g.APIKey,
})
var sb strings.Builder
for ev := range ch {
switch ev.Kind {
case agentllm.KindTextDelta:
sb.WriteString(ev.Text)
case agentllm.KindError:
if ev.Err != nil {
return nil, fmt.Errorf("intentsem: stream: %w", ev.Err)
}
return nil, fmt.Errorf("intentsem: stream: %s", ev.ErrorMessage)
}
}
return parseCommands(sb.String())
}
// parseCommands 从模型输出解析命令数组:剥代码围栏,取首个 [ 到末个 ],
// 严格 JSON。任何形状不符都报错(由库侧 fail-closed 到 fallback)。
func parseCommands(raw string) ([]agentintent.Command, error) {
text := strings.TrimSpace(raw)
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(text, "```")
text = strings.TrimSpace(text)
lo, hi := strings.Index(text, "["), strings.LastIndex(text, "]")
if lo < 0 || hi <= lo {
return nil, fmt.Errorf("intentsem: 输出不含命令数组")
}
var gen []generatedCommand
if err := json.Unmarshal([]byte(text[lo:hi+1]), &gen); err != nil {
return nil, fmt.Errorf("intentsem: 命令 JSON: %w", err)
}
out := make([]agentintent.Command, 0, len(gen))
for _, c := range gen {
out = append(out, agentintent.Command{
Kind: agentintent.CommandKind(c.Kind),
Action: agentintent.ActionRef{Kind: c.Action.Kind, ID: c.Action.ID},
Slots: c.Slots,
})
}
return out, nil
}
// generatorSystemPrompt 渲染能力目录。只暴露 id/描述/槽位键——
// 不暴露模式与示例(避免提示词注入借道示例文本进入慢通道)。
func generatorSystemPrompt(catalog []agentintent.CatalogSpec) string {
var sb strings.Builder
sb.WriteString("你是意图命令生成器。根据用户输入,从下面的能力目录选择并输出命令。\n")
sb.WriteString("只输出一个 JSON 数组,不要输出任何其他文字。数组元素形如:\n")
sb.WriteString(`[{"kind":"start","action":{"kind":"workflow","id":"<目录中的id>"},"slots":{"<槽位键>":"<值>"}}]` + "\n")
sb.WriteString("规则:kind 取 start/set_slot/cancel/clarify/chitchat/handoff 之一;")
sb.WriteString("start 必须带 action,action.kind 取 auto/command/workflow/blueprint/handler;")
sb.WriteString("action.id 与 slots 的键必须来自目录;用户闲聊用 chitchat(不带 action);")
sb.WriteString("无法判断时输出空数组 []。\n能力目录:\n")
for _, spec := range catalog {
sb.WriteString("- id=" + spec.ID)
if spec.Description != "" {
sb.WriteString(" 描述=" + spec.Description)
}
if spec.Handler != "" {
sb.WriteString(" handler=" + spec.Handler)
}
if len(spec.Slots) > 0 {
keys := make([]string, 0, len(spec.Slots))
for _, sl := range spec.Slots {
keys = append(keys, sl.Key)
}
sb.WriteString(" 槽位=[" + strings.Join(keys, ",") + "]")
}
sb.WriteString("\n")
}
return sb.String()
}
+149
View File
@@ -0,0 +1,149 @@
package intentsem
import (
"context"
"errors"
"strings"
"testing"
agentintent "agent-intent/intent"
agentllm "agent-native/runtime/llm"
)
// fakeEmbedder 返回脚本化向量:texts[i] → vecs[i](循环取模)。
type fakeEmbedder struct {
vecs [][]float32
err error
}
func (f fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
if f.err != nil {
return nil, f.err
}
out := make([][]float32, len(texts))
for i := range texts {
out[i] = f.vecs[i%len(f.vecs)]
}
return out, nil
}
func TestEmbeddingScorerMaxCosine(t *testing.T) {
// input 与 example2 同向(相似度 1),与 example1 正交(0)。
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{
{1, 0}, {0, 1}, {2, 0},
}}}
got, err := s.Score(context.Background(), "输入", []string{"例一", "例二"})
if err != nil {
t.Fatal(err)
}
if got != 1 {
t.Fatalf("期望最大余弦 1,得到 %v", got)
}
}
func TestEmbeddingScorerErrorPropagates(t *testing.T) {
s := EmbeddingScorer{Provider: fakeEmbedder{err: errors.New("boom")}}
if _, err := s.Score(context.Background(), "x", []string{"y"}); err == nil {
t.Fatal("期望错误上抛(库侧保持 Ambiguous)")
}
}
func TestEmbeddingScorerEmptyExamples(t *testing.T) {
s := EmbeddingScorer{Provider: fakeEmbedder{vecs: [][]float32{{1}}}}
got, err := s.Score(context.Background(), "x", nil)
if err != nil || got != 0 {
t.Fatalf("空示例应得 0 分无错误,得到 %v / %v", got, err)
}
}
// scriptedStream 回放固定文本增量后关闭通道。
func scriptedStream(text string, err error) agentllm.StreamFunc {
return func(_ context.Context, _ string, msgs []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
ch := make(chan agentllm.Event, 3)
if err != nil {
ch <- agentllm.Event{Kind: agentllm.KindError, Err: err}
} else {
ch <- agentllm.Event{Kind: agentllm.KindTextDelta, Text: text}
}
close(ch)
return ch
}
}
func catalogFixture() []agentintent.CatalogSpec {
return []agentintent.CatalogSpec{
{ID: "deploy.run", Description: "部署应用", Slots: []agentintent.CatalogSlot{{Key: "env", Required: true}}},
}
}
func TestLightGeneratorParsesCommands(t *testing.T) {
g := LightGenerator{
Stream: scriptedStream(" ```json\n[{\"kind\":\"start\",\"action\":{\"kind\":\"workflow\",\"id\":\"deploy.run\"},\"slots\":{\"env\":\"prod\"}}]\n``` ", nil),
Model: "lite",
}
cmds, err := g.Generate(context.Background(), "把应用部署到生产", catalogFixture())
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandStart {
t.Fatalf("期望 1 条 start 命令,得到 %+v", cmds)
}
if cmds[0].Action.Kind != "workflow" || cmds[0].Action.ID != "deploy.run" {
t.Fatalf("action 不符:%+v", cmds[0].Action)
}
if cmds[0].Slots["env"] != "prod" {
t.Fatalf("槽位不符:%+v", cmds[0].Slots)
}
}
func TestLightGeneratorEmptyCatalogNoCall(t *testing.T) {
called := false
g := LightGenerator{Stream: func(_ context.Context, _ string, _ []agentllm.Message, _ []agentllm.ToolDecl, _ agentllm.StreamOptions) <-chan agentllm.Event {
called = true
ch := make(chan agentllm.Event)
close(ch)
return ch
}}
cmds, err := g.Generate(context.Background(), "你好", nil)
if err != nil || cmds != nil || called {
t.Fatalf("空目录不应调用模型:cmds=%v err=%v called=%v", cmds, err, called)
}
}
func TestLightGeneratorStreamError(t *testing.T) {
g := LightGenerator{Stream: scriptedStream("", errors.New("dial refused"))}
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil ||
!strings.Contains(err.Error(), "dial refused") {
t.Fatalf("期望流错误上抛,得到 %v", err)
}
}
func TestLightGeneratorBadJSON(t *testing.T) {
g := LightGenerator{Stream: scriptedStream("我无法判断", nil)}
if _, err := g.Generate(context.Background(), "部署", catalogFixture()); err == nil {
t.Fatal("期望解析失败返回错误(库侧 fail-closed 回 fallback)")
}
}
func TestParseCommandsFencedAndChatty(t *testing.T) {
raw := "好的,命令如下:\n```json\n[{\"kind\":\"chitchat\"}]\n```\n以上。"
cmds, err := parseCommands(raw)
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || cmds[0].Kind != agentintent.CommandChitchat {
t.Fatalf("期望 chitchat,得到 %+v", cmds)
}
}
func TestGeneratorPromptContainsCatalogOnly(t *testing.T) {
p := generatorSystemPrompt(catalogFixture())
for _, want := range []string{"deploy.run", "部署应用", "env"} {
if !strings.Contains(p, want) {
t.Fatalf("提示词缺少 %q", want)
}
}
if strings.Contains(p, "正例") || strings.Contains(p, "examples") {
t.Fatal("提示词不应包含示例文本(注入面)")
}
}
+62
View File
@@ -0,0 +1,62 @@
// Package intentsem 慢通道实现(I2 增量三):把 agent-intent 的
// SemanticScorer / CommandGenerator 端口接到宿主已有的向量与轻量模型
// 能力上。两个实现都是可插拔的——装配层默认不接线(设置开关关),
// 此时识别行为与 I1 完全一致。
package intentsem
import (
"context"
"math"
core "agent-system-core"
)
// EmbeddingScorer 用语义向量实现 agentintent.SemanticScorer:
// 对 input 与每个 example 取向量,返回最大余弦相似度。
// 任何错误都原样上抛——库侧约定 scorer 出错时识别保持 Ambiguous,
// 不会使 Recognize 失败(fail-open 到规则层结论)。
type EmbeddingScorer struct {
Provider core.EmbeddingProvider
}
// Score 返回 input 与 examples 的最高余弦相似度,取值 [0,1]。
func (s EmbeddingScorer) Score(ctx context.Context, input string, examples []string) (float64, error) {
if s.Provider == nil || len(examples) == 0 {
return 0, nil
}
texts := make([]string, 0, len(examples)+1)
texts = append(texts, input)
texts = append(texts, examples...)
vecs, err := s.Provider.Embed(ctx, texts)
if err != nil {
return 0, err
}
if len(vecs) != len(texts) || len(vecs[0]) == 0 {
return 0, nil
}
best := 0.0
for _, v := range vecs[1:] {
if sim := cosine(vecs[0], v); sim > best {
best = sim
}
}
return best, nil
}
// cosine 计算两个等长向量的余弦相似度;任一零向量或不等长返回 0。
func cosine(a, b []float32) float64 {
if len(a) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
fa, fb := float64(a[i]), float64(b[i])
dot += fa * fb
na += fa * fa
nb += fb * fb
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
+2
View File
@@ -94,6 +94,8 @@ func Schema() []core.SettingDesc {
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"},
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},