diff --git a/deps/agent-intent b/deps/agent-intent index f57645c..343b1e6 160000 --- a/deps/agent-intent +++ b/deps/agent-intent @@ -1 +1 @@ -Subproject commit f57645c6452acd12e47dca4236bdae3258c64f47 +Subproject commit 343b1e6d9a5b58e47bf7e594826221036ae8175c diff --git a/internal/app/app.go b/internal/app/app.go index b983b4f..d17f63b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 预留能力沙箱根:/workspaces///。 mgr.SetWorkspaceRoot(filepath.Join(opt.DataDir, "workspaces")) diff --git a/internal/app/intent_flags_test.go b/internal/app/intent_flags_test.go new file mode 100644 index 0000000..0d47f5a --- /dev/null +++ b/internal/app/intent_flags_test.go @@ -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) + } + } +} diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go index 68e04d7..8512b32 100644 --- a/internal/intentgate/gate.go +++ b/internal/intentgate/gate.go @@ -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 { diff --git a/internal/intentgate/gate_test.go b/internal/intentgate/gate_test.go index 813f41b..3201387 100644 --- a/internal/intentgate/gate_test.go +++ b/internal/intentgate/gate_test.go @@ -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) + } +} diff --git a/internal/intentsem/generator.go b/internal/intentsem/generator.go new file mode 100644 index 0000000..2f7ed75 --- /dev/null +++ b/internal/intentsem/generator.go @@ -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() +} diff --git a/internal/intentsem/intentsem_test.go b/internal/intentsem/intentsem_test.go new file mode 100644 index 0000000..fd8bfc7 --- /dev/null +++ b/internal/intentsem/intentsem_test.go @@ -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("提示词不应包含示例文本(注入面)") + } +} diff --git a/internal/intentsem/scorer.go b/internal/intentsem/scorer.go new file mode 100644 index 0000000..b7d8818 --- /dev/null +++ b/internal/intentsem/scorer.go @@ -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)) +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index f3b65df..0558b3a 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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: "每次对话附在上下文末尾,不改系统前缀"},