From 80faf3edbd2f915d96f6a1dd7f3a334c6c1ae98c Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:15:03 +0000 Subject: [PATCH] Resolve squad routes from the run owner and project Why: F13 - the squad factory read the default account with an empty project, pairing the run user's model name with another account's endpoint and credentials; direct and squad paths disagreed on reasoning split and route contract. Evidence: app/runtime/squadadapter tests pass (verify-u10b.json); run-scoped snapshot, project override, profile override and fail-closed cases added with synthetic keys. Impact: squad execution now uses the same effective-config rules as the direct path; default setups unchanged. Refs: feature/agent-stack-audit-remediation-b1 U10 --- internal/app/app.go | 103 +---- internal/app/squad_config_test.go | 443 ++++++++++++++++++++ internal/app/squad_native.go | 149 +++++++ internal/runtime/llm.go | 9 +- internal/runtime/model_test.go | 131 ++++++ internal/runtime/route_contract_eof_test.go | 66 ++- internal/runtime/run_config.go | 62 +++ internal/runtime/squad_runner.go | 6 +- internal/runtime/sub_loop.go | 2 +- internal/runtime/verify.go | 2 +- internal/runtime/workflow_compiled.go | 2 +- internal/squadadapter/adapter.go | 5 + internal/squadadapter/entry.go | 4 +- 13 files changed, 876 insertions(+), 108 deletions(-) create mode 100644 internal/app/squad_config_test.go create mode 100644 internal/app/squad_native.go create mode 100644 internal/runtime/run_config.go diff --git a/internal/app/app.go b/internal/app/app.go index 175ae5c..7da439c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -24,13 +24,10 @@ import ( "logging-system/logsys" "logging-system/sink" - "agent-native/runtime/checkpoint" agentllm "agent-native/runtime/llm" agentmcp "agent-native/runtime/mcp" - "agent-native/runtime/policy" "agent-native/runtime/telemetry" agenttools "agent-native/runtime/tools" - nativeadp "agent-squad/adapters/native" "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" @@ -332,98 +329,20 @@ func Run(opt Options) error { } } tools.SetSkillsRoot(skillsRoot) - // 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析): - // 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置; - // 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。 + // 小队的 agent-native 内核工厂(组合根装配,逐次运行时按当前运行的用户/项目 + // 解析):模型走 settings.models.chat 同一路由与同一 per-route 规则; + // 搜索/解释器/MCP/技能根复用产品配置;委派桥接产品 SpawnSubagent。 + // 主模型未配置时返回未启用(不阻塞产品)。装配见 squad_native.go。 var pluginHost *plugins.Host - squadadapter.AgentNativeOptions = func(rc squadadapter.Ctx) (nativeadp.Config, bool) { - effNow := setSvc.Effective(store.OwnerDefault, "") - route := runtime.ResolveChatModel(core.AgentProfile{}, effNow) - if route.Model == "" || route.Base == "" { - return nativeadp.Config{}, false - } - mode := "default" - if src, ok := rc.(interface{ PermissionMode() string }); ok { - mode = src.PermissionMode() - } - cfg := nativeadp.Config{ - Client: &agentllm.Client{BaseURL: route.Base, APIKey: route.Key, HTTPClient: &http.Client{}}, - DefaultModel: route.Model, - MaxTurns: 0, - Timeout: 0, - Subagent: squadadapter.SubagentFactory(rc), - RepeatGuard: true, - BeforeTool: func(call agentllm.ToolCall) error { - effect := policy.Decide(policy.Request{ - Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments, - Traits: policy.TraitsFor(call.Name), - }, nil) - if effect == policy.EffectDeny { - return fmt.Errorf("计划模式拒绝了工具 %s", call.Name) - } + squadadapter.AgentNativeOptions = squadAgentNativeFactory(squadNativeDeps{ + set: setSvc, secrets: st, skillsRoot: skillsRoot, trace: traceFn, mcp: mcpManagerFor, + pluginMCP: func() []core.PluginMCPServer { + if pluginHost == nil { return nil - }, - } - cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream) - if keys := tavilyKeyPool(st, effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 { - cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys) - } - if effNow.GetBool("runtime.exec", true) { - cfg.Exec = tools.InterpreterExecConfig() - } - if st2, err := os.Stat(skillsRoot); err == nil && st2.IsDir() { - cfg.SkillsRoots = []string{skillsRoot} - } - rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", "")) - if pluginHost != nil { - rawMCP = mergeMCPServers(rawMCP, pluginHost.MCPServers()) - } - if mgr := mcpManagerFor(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil { - cfg.MCP = mgr - } - // Native squad tools do not yet expose a fail-closed result hook to the - // project observation outbox. Project runs therefore use the audited - // runCtx retrieval/tool path only; enabling these direct providers would - // let their results reach the next model turn without source analysis. - if rc.Run().ProjectID != "" { - cfg.Search = nil - cfg.Exec = nil - cfg.Fetch = nil - cfg.MCP = nil - } - cfg.CodeOrchestration = rc.Run().ProjectID == "" && effNow.GetBool("runtime.codeOrchestration", false) - if ask, ok := rc.(interface { - AskUser(card core.HITLCard) (core.HITLResponse, error) - }); ok { - cfg.Ask = func(_ context.Context, question string) (string, error) { - resp, err := ask.AskUser(core.HITLCard{ - Question: question, Kind: core.HITLKindInput, AllowFreeInput: true, - }) - if err != nil { - return "", err - } - if strings.TrimSpace(resp.FreeText) != "" { - return strings.TrimSpace(resp.FreeText), nil - } - return resp.ChoiceID, nil } - } - cfg.Trace = traceFn - // N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达 - // runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。 - cfg.PermissionAsk = runtime.PermissionAskOf(rc) - if host, ok := rc.(interface { - OpenCheckpoints() (*checkpoint.Log, error) - CheckpointAnchor() string - }); ok { - if lg, err := host.OpenCheckpoints(); err == nil { - cfg.Checkpoints = lg - anchor := host.CheckpointAnchor() - cfg.CheckpointAnchor = func() string { return anchor } - } - } - return cfg, true - } + return pluginHost.MCPServers() + }, + }) profSvc := profiles.New(st) if err := profSvc.EnsureDefault(); err != nil { return fmt.Errorf("default profile: %w", err) diff --git a/internal/app/squad_config_test.go b/internal/app/squad_config_test.go new file mode 100644 index 0000000..5434712 --- /dev/null +++ b/internal/app/squad_config_test.go @@ -0,0 +1,443 @@ +// 小队内核配置的归属测试(F13 / KTD3):内核用的模型、端点、密钥必须整条来自 +// 「本次运行」的生效快照,不得按默认账号重新解析——否则一个用户的模型名会配 +// 上另一个账号的端点/密钥。全部使用合成密钥与本地假服务。 +package app + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + agentllm "agent-native/runtime/llm" + "agent-system-backend/internal/runtime" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/squadadapter" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +// squadRunCtx 是适配器窄端口的最小宿主实现:只有配置作用域(无 Profile 端口, +// 属主只能来自 run)。 +type squadRunCtx struct { + run core.Run + eff core.EffectiveSettings +} + +func (c *squadRunCtx) Context() context.Context { return context.Background() } +func (c *squadRunCtx) Run() core.Run { return c.run } +func (c *squadRunCtx) Settings() core.EffectiveSettings { return c.eff } +func (c *squadRunCtx) Emit(core.StreamEventType, any) error { return nil } +func (c *squadRunCtx) UpdatePlan(core.PlanSnapshot) error { return nil } +func (c *squadRunCtx) SpawnSubagent(string, string, string) (core.AgentResultPayload, error) { + return core.AgentResultPayload{}, nil +} +func (c *squadRunCtx) AskHITL(core.HITLCard) (core.HITLResponse, error) { + return core.HITLResponse{}, nil +} +func (c *squadRunCtx) WriteArtifact(string, string, string, []byte) (core.Artifact, error) { + return core.Artifact{}, nil +} + +var _ squadadapter.Ctx = (*squadRunCtx)(nil) + +// squadProfileRunCtx 是带画像端口的宿主(runmgr 的 runCtx 形态):画像的 +// Owner 与 run.Owner 一致,模型覆盖按直连规则生效。 +type squadProfileRunCtx struct { + *squadRunCtx + prof core.AgentProfile +} + +func (c *squadProfileRunCtx) Profile() core.AgentProfile { return c.prof } + +// squadSecretStub 是受管密钥来源桩(Tavily 池按 owner 查)。 +type squadSecretStub map[string]string + +func (s squadSecretStub) ActiveSecret(owner, _ string) (string, bool) { + v, ok := s[owner] + return v, ok +} + +func squadStore(t *testing.T) (*store.Store, *settings.Service) { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "squad.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + return st, settings.NewService(st) +} + +// squadLLMCall 是一次上游请求的观测(只记合成字段)。 +type squadLLMCall struct { + Path string + Authorization string + Model string + ReasoningSplit bool +} + +func squadLLMServer(t *testing.T) (*httptest.Server, func() []squadLLMCall) { + t.Helper() + var mu sync.Mutex + var calls []squadLLMCall + frame := func(v any) string { + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return "data: " + string(raw) + "\n\n" + } + body := frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": "ok"}}}}) + + frame(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}}) + + frame(map[string]any{"choices": []any{}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) + + "data: [DONE]\n\n" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var decoded struct { + Model string `json:"model"` + ReasoningSplit bool `json:"reasoning_split"` + } + _ = json.Unmarshal(raw, &decoded) + mu.Lock() + calls = append(calls, squadLLMCall{ + Path: r.URL.Path, Authorization: r.Header.Get("Authorization"), + Model: decoded.Model, ReasoningSplit: decoded.ReasoningSplit, + }) + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, body) + })) + t.Cleanup(server.Close) + return server, func() []squadLLMCall { + mu.Lock() + defer mu.Unlock() + return append([]squadLLMCall(nil), calls...) + } +} + +// squadCall 驱动装配出的内核流一次,返回终态事件。 +func squadCall(t *testing.T, stream agentllm.StreamFunc, model string) []agentllm.Event { + t.Helper() + if stream == nil { + t.Fatal("squad config produced no stream") + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + var terminal []agentllm.Event + for ev := range stream(ctx, model, []agentllm.Message{{Role: agentllm.RoleUser, Content: "hi"}}, nil, agentllm.StreamOptions{}) { + if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError { + terminal = append(terminal, ev) + } + } + if len(terminal) != 1 || terminal[0].Kind != agentllm.KindMessageEnd || terminal[0].Err != nil { + t.Fatalf("terminal events = %+v, want one clean message_end", terminal) + } + return terminal +} + +func chatEff(model, base, key string) core.EffectiveSettings { + return core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{ + "model": model, "baseUrl": base, "apiKey": key, + }}} +} + +// 两位用户的端点/密钥/模型各不相同:装配必须整条取自本次运行的快照。 +func TestSquadNativeOptionsResolvesRouteFromRunScope(t *testing.T) { + aliceSrv, aliceCalls := squadLLMServer(t) + bobSrv, bobCalls := squadLLMServer(t) + + // 默认账号:与两位用户都不同;小队路径不得解析到它。 + _, svc := squadStore(t) + if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{ + "model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic", + }}}); err != nil { + t.Fatal(err) + } + factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}}) + + for _, tc := range []struct { + owner core.ID + model string + base string + key string + calls func() []squadLLMCall + foreign []string + }{ + {"alice", "alice-model", aliceSrv.URL + "/v1", "sk-alice-synthetic", aliceCalls, + []string{"bob-model", "sk-bob-synthetic", "default-account-model", "sk-default-synthetic"}}, + {"bob", "bob-model", bobSrv.URL + "/v1", "sk-bob-synthetic", bobCalls, + []string{"alice-model", "sk-alice-synthetic", "default-account-model", "sk-default-synthetic"}}, + } { + t.Run(string(tc.owner), func(t *testing.T) { + cfg, enabled := factory(&squadRunCtx{ + run: core.Run{ID: "run_" + string(tc.owner), Owner: tc.owner}, + eff: chatEff(tc.model, tc.base, tc.key), + }) + if !enabled || cfg.Client == nil { + t.Fatalf("factory disabled: enabled=%v cfg=%+v", enabled, cfg) + } + if cfg.DefaultModel != tc.model { + t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model) + } + if cfg.Client.BaseURL != tc.base || cfg.Client.APIKey != tc.key { + t.Fatalf("route = %q/%q, want %q/%q", cfg.Client.BaseURL, cfg.Client.APIKey, tc.base, tc.key) + } + for _, foreign := range tc.foreign { + if strings.Contains(cfg.DefaultModel+cfg.Client.BaseURL+cfg.Client.APIKey, foreign) { + t.Fatalf("squad config leaked another account's value %q: model=%q base=%q key=%q", + foreign, cfg.DefaultModel, cfg.Client.BaseURL, cfg.Client.APIKey) + } + } + // 真正发一次请求:模型名、端点与密钥必须成对出现在同一次调用里。 + squadCall(t, cfg.Stream, cfg.DefaultModel) + calls := tc.calls() + if len(calls) != 1 { + t.Fatalf("upstream calls = %d, want exactly 1", len(calls)) + } + if calls[0].Path != "/v1/chat/completions" { + t.Fatalf("request path = %q, want the run's own endpoint", calls[0].Path) + } + if calls[0].Authorization != "Bearer "+tc.key { + t.Fatalf("authorization = %q, want the run's own key", calls[0].Authorization) + } + if calls[0].Model != tc.model { + t.Fatalf("request model = %q, want %q", calls[0].Model, tc.model) + } + }) + } +} + +// 受管密钥按属主隔离:画像里的 Owner 决定密钥层归属,不得借默认/系统账号的。 +func TestSquadNativeOptionsNeverBorrowsAnotherOwnerSecret(t *testing.T) { + st, svc := squadStore(t) + srv, _ := squadLLMServer(t) + base := srv.URL + "/v1" + if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{ + "model": "shared-model", "baseUrl": base, + }}}); err != nil { + t.Fatal(err) + } + for owner, value := range map[string]string{ + settings.OwnerSystemRow: "sk-system-synthetic", + "alice": "sk-alice-synthetic", + "bob": "sk-bob-synthetic", + store.OwnerDefault: "sk-default-synthetic", + } { + if _, err := st.AddSecret(owner, "models.chat", "test", value); err != nil { + t.Fatal(err) + } + } + // 与组合根同一套解析规则:属主 → 系统行 → 无。 + saved := runtime.SecretLookup + runtime.SecretLookup = func(owner, service string) (string, string, bool) { + if owner == "" { + owner = store.OwnerDefault + } + if v, ok := st.ActiveSecret(owner, service); ok { + return v, "owner", true + } + if v, ok := st.ActiveSecret(settings.OwnerSystemRow, service); ok { + return v, "system", true + } + return "", "", false + } + t.Cleanup(func() { runtime.SecretLookup = saved }) + + factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}}) + for _, tc := range []struct { + owner, model, key string + }{ + {"alice", "alice-profile-model", "sk-alice-synthetic"}, + {"bob", "bob-profile-model", "sk-bob-synthetic"}, + // 无自有密钥的租户仍按既有规则回落系统行受管密钥(不是默认账号的)。 + {"carol", "shared-model", "sk-system-synthetic"}, + } { + t.Run(tc.owner, func(t *testing.T) { + prof := core.AgentProfile{Owner: tc.owner} + if tc.model != "shared-model" { + prof.Model = core.ModelRoute{Chat: tc.model} + } + cfg, enabled := factory(&squadProfileRunCtx{ + squadRunCtx: &squadRunCtx{ + run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner}, + eff: svc.Effective(tc.owner, ""), + }, + prof: prof, + }) + if !enabled { + t.Fatal("factory disabled for a configured run") + } + if cfg.DefaultModel != tc.model || cfg.Client.APIKey != tc.key { + t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.APIKey, tc.model, tc.key) + } + if cfg.Client.BaseURL != base { + t.Fatalf("base = %q, want %q", cfg.Client.BaseURL, base) + } + // 无 Profile 端口时属主只能来自 run.Owner——密钥层不得退化成默认账号。 + bare, _ := factory(&squadRunCtx{ + run: core.Run{ID: "run_" + tc.owner, Owner: tc.owner}, + eff: svc.Effective(tc.owner, ""), + }) + if bare.Client.APIKey != tc.key || bare.Client.BaseURL != base { + t.Fatalf("owner from run = %q/%q, want %q/%q", bare.Client.APIKey, bare.Client.BaseURL, tc.key, base) + } + }) + } +} + +// 项目运行消费的是「用户层 + 项目层」合并后的快照:项目覆盖的键取项目, +// 未覆盖的键仍留用户层,绝不整包换回默认账号。 +func TestSquadNativeOptionsHonorsProjectLayerSnapshot(t *testing.T) { + _, svc := squadStore(t) + if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{ + "model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic", + }}}); err != nil { + t.Fatal(err) + } + projSrv, projCalls := squadLLMServer(t) + userLayer := map[string]any{ + "models": map[string]any{"chat": map[string]any{ + "model": "user-model", "baseUrl": "https://user.invalid/v1", "apiKey": "sk-user-synthetic", + }}, + "tools": map[string]any{"tavily": map[string]any{"apiKeys": "tvly-user-synthetic"}}, + "runtime": map[string]any{"codeOrchestration": true, "exec": true}, + } + projectLayer := map[string]any{"models": map[string]any{"chat": map[string]any{ + "baseUrl": projSrv.URL + "/v1", "apiKey": "sk-project-synthetic", + }}} + eff := core.EffectiveSettings(core.MergeSettings(userLayer, projectLayer)) + + factory := squadAgentNativeFactory(squadNativeDeps{ + set: svc, secrets: squadSecretStub{store.OwnerDefault: "tvly-active-synthetic"}, + }) + cfg, enabled := factory(&squadProfileRunCtx{ + squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_proj", Owner: "alice", ProjectID: "proj1"}, eff: eff}, + prof: core.AgentProfile{Owner: "alice"}, + }) + if !enabled { + t.Fatal("factory disabled for a configured project run") + } + if cfg.DefaultModel != "user-model" { + t.Fatalf("model = %q, want the user-layer value %q", cfg.DefaultModel, "user-model") + } + if cfg.Client.BaseURL != projSrv.URL+"/v1" || cfg.Client.APIKey != "sk-project-synthetic" { + t.Fatalf("route = %q/%q, want the project-layer endpoint and key", cfg.Client.BaseURL, cfg.Client.APIKey) + } + // 直接驱动内核客户端(受保护的 cfg.Stream 在无项目上下文端口的桩上按 + // 设计 fail-closed,不用于本断言):模型名、端点与密钥成对出现。 + squadCall(t, cfg.Client.Stream, cfg.DefaultModel) + calls := projCalls() + if len(calls) != 1 || calls[0].Authorization != "Bearer sk-project-synthetic" || calls[0].Model != "user-model" { + t.Fatalf("upstream calls = %+v, want the project endpoint with the user-layer model", calls) + } + // 项目来源的观察/授权限制不得因为改配置解析而放宽。 + if cfg.Search != nil || cfg.Exec != nil || cfg.Fetch != nil || cfg.MCP != nil { + t.Fatalf("project run kept direct providers: search=%v exec=%v fetch=%v mcp=%v", cfg.Search != nil, cfg.Exec != nil, cfg.Fetch != nil, cfg.MCP != nil) + } + if cfg.CodeOrchestration { + t.Fatal("project run kept code orchestration") + } +} + +// per-route 规则与直连同源:画像模型覆盖后,reasoning split 与受限 EOF 契约 +// 都按「覆盖后这条路由」判定。 +func TestSquadNativeOptionsMatchesDirectRouteRules(t *testing.T) { + _, svc := squadStore(t) + // 契约只认规范化后的 https 端点,所以这里用合成端点(不发请求)。 + base := "https://model.example.com/v1" + if err := svc.MergeSystem(map[string]any{"models": map[string]any{ + "chat": map[string]any{"model": "MiniMax-M1", "baseUrl": base}, + "routeContractEOF": map[string]any{ + "model": "MiniMax-M1", + "baseUrl": base, + "identitySha256": agentllm.RouteContractIdentity("MiniMax-M1", base), + }, + }}); err != nil { + t.Fatal(err) + } + factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}}) + eff := svc.Effective("alice", "") + + for _, tc := range []struct { + name string + profile core.AgentProfile + model string + wantSplit bool + wantContract bool + }{ + {"settings route", core.AgentProfile{Owner: "alice"}, "MiniMax-M1", true, true}, + {"profile keeps reasoning split, drops contract", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "MiniMax-M2"}}, "MiniMax-M2", true, false}, + {"profile override to plain model", core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "plain-model"}}, "plain-model", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + prof := tc.profile + cfg, enabled := factory(&squadProfileRunCtx{ + squadRunCtx: &squadRunCtx{run: core.Run{ID: "run_rules", Owner: "alice"}, eff: eff}, + prof: prof, + }) + if !enabled { + t.Fatal("factory disabled for a configured run") + } + if cfg.DefaultModel != tc.model { + t.Fatalf("model = %q, want %q", cfg.DefaultModel, tc.model) + } + if cfg.Client.ReasoningSplit != tc.wantSplit { + t.Fatalf("reasoning split = %v, want %v", cfg.Client.ReasoningSplit, tc.wantSplit) + } + if (cfg.Client.RouteContract != nil) != tc.wantContract { + t.Fatalf("route contract = %+v, want attached=%v", cfg.Client.RouteContract, tc.wantContract) + } + // 逐字段等于直连路径同一快照构造出的客户端。 + direct := runtime.OpenRouteClient(runtime.ResolveChatModel(prof, eff), eff) + if cfg.Client.BaseURL != direct.BaseURL || cfg.Client.APIKey != direct.APIKey || + cfg.Client.ReasoningSplit != direct.ReasoningSplit { + t.Fatalf("squad client %+v differs from the direct-path client %+v", cfg.Client, direct) + } + if (cfg.Client.RouteContract == nil) != (direct.RouteContract == nil) { + t.Fatalf("contract decision differs from the direct path: squad=%v direct=%v", + cfg.Client.RouteContract, direct.RouteContract) + } + }) + } +} + +// 宿主没有 Settings 端口(旧骨架/测试桩)时仍按默认账号快照装配,行为不变。 +func TestSquadNativeOptionsFallsBackToDefaultAccountSnapshot(t *testing.T) { + _, svc := squadStore(t) + if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{ + "model": "default-account-model", "baseUrl": "https://default.invalid/v1", "apiKey": "sk-default-synthetic", + }}}); err != nil { + t.Fatal(err) + } + factory := squadAgentNativeFactory(squadNativeDeps{set: svc, secrets: squadSecretStub{}}) + for name, eff := range map[string]core.EffectiveSettings{ + "nil snapshot": nil, + "empty snapshot": {}, + "settings without run": {"models": map[string]any{"chat": map[string]any{"model": "snapshot-model", "baseUrl": "https://snapshot.invalid/v1"}}}, + } { + t.Run(name, func(t *testing.T) { + cfg, enabled := factory(&squadRunCtx{run: core.Run{ID: "run_default", Owner: store.OwnerDefault}, eff: eff}) + if !enabled { + t.Fatal("default-config run stopped working") + } + want := "snapshot-model" + wantBase := "https://snapshot.invalid/v1" + if len(eff) == 0 { + want, wantBase = "default-account-model", "https://default.invalid/v1" + } + if cfg.DefaultModel != want || cfg.Client.BaseURL != wantBase { + t.Fatalf("route = %q/%q, want %q/%q", cfg.DefaultModel, cfg.Client.BaseURL, want, wantBase) + } + }) + } + // 没有快照来源也没有默认账号配置时保持未启用(不阻塞产品)。 + if _, enabled := squadAgentNativeFactory(squadNativeDeps{})(&squadRunCtx{run: core.Run{ID: "run_none"}}); enabled { + t.Fatal("factory reported enabled without any configuration source") + } +} diff --git a/internal/app/squad_native.go b/internal/app/squad_native.go new file mode 100644 index 0000000..bb2501b --- /dev/null +++ b/internal/app/squad_native.go @@ -0,0 +1,149 @@ +// 小队 agent-native 内核工厂的装配(组合根注入 squadadapter.AgentNativeOptions)。 +// +// 配置解析的硬约束(KTD3 / 审计 F13):内核用的模型、端点、密钥必须整条来自 +// 「本次运行」的生效快照——rc.Settings() 已按 run.Owner + run.ProjectID 合并, +// 画像模型覆盖叠加其上,per-route 规则(reasoning split / 受限 EOF 契约)由 +// runtime.OpenRouteClient 与直连路径同一函数判定。不得在这里按默认账号重新 +// 解析:那会把一个用户的模型名和另一个账号的端点/密钥拼在一起。 +package app + +import ( + "context" + "fmt" + "os" + "strings" + + "agent-native/runtime/checkpoint" + agentllm "agent-native/runtime/llm" + agentmcp "agent-native/runtime/mcp" + "agent-native/runtime/policy" + agenttools "agent-native/runtime/tools" + nativeadp "agent-squad/adapters/native" + "agent-system-backend/internal/runtime" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/squadadapter" + "agent-system-backend/internal/store" + "agent-system-backend/internal/tools" + core "agent-system-core" +) + +// squadNativeDeps 是内核工厂的依赖。mcp 与 pluginMCP 取函数而非值:插件宿主在 +// Run 晚些时候才装配,工厂必须按调用时读,不能把启动期的空值固化进去。 +type squadNativeDeps struct { + // set 提供回落快照:宿主没有 Settings 端口时(骨架/旧宿主)才用默认账号。 + set *settings.Service + // secrets 是受管密钥来源(Tavily 池)。 + secrets activeSecretSource + // skillsRoot 是交付配方技能根。 + skillsRoot string + trace func(name string, attrs map[string]string) func() + mcp func(raw, overrides string) *agentmcp.Manager + pluginMCP func() []core.PluginMCPServer +} + +// squadAgentNativeFactory 返回注入 squadadapter.AgentNativeOptions 的工厂。 +// 主模型未配置时返回未启用(不阻塞产品);其余装配对每次运行重新解析。 +func squadAgentNativeFactory(deps squadNativeDeps) func(squadadapter.Ctx) (nativeadp.Config, bool) { + return func(rc squadadapter.Ctx) (nativeadp.Config, bool) { + // 当前运行的用户/项目快照;宿主无 Settings 端口时才回落默认账号。 + effNow, ok := runtime.RunEffectiveSettings(rc) + if !ok { + if deps.set == nil { + return nativeadp.Config{}, false + } + effNow = deps.set.Effective(store.OwnerDefault, "") + } + // 画像模型覆盖走同一条解析规则(Owner 决定密钥层归属)。 + route := runtime.ResolveChatModel(runtime.RunProfile(rc), effNow) + if route.Model == "" || route.Base == "" { + return nativeadp.Config{}, false + } + mode := "default" + if src, ok := rc.(interface{ PermissionMode() string }); ok { + mode = src.PermissionMode() + } + cfg := nativeadp.Config{ + Client: runtime.OpenRouteClient(route, effNow), + DefaultModel: route.Model, + MaxTurns: 0, + Timeout: 0, + Subagent: squadadapter.SubagentFactory(rc), + RepeatGuard: true, + BeforeTool: func(call agentllm.ToolCall) error { + effect := policy.Decide(policy.Request{ + Mode: policy.Normalize(mode), Name: call.Name, Args: call.Arguments, + Traits: policy.TraitsFor(call.Name), + }, nil) + if effect == policy.EffectDeny { + return fmt.Errorf("计划模式拒绝了工具 %s", call.Name) + } + return nil + }, + } + cfg.Stream = runtime.GuardProjectStream(rc, "", cfg.Client.Stream) + var keys []string + if deps.secrets != nil { + keys = tavilyKeyPool(deps.secrets, effNow.GetString("tools.tavily.apiKeys", "")) + } + if len(keys) > 0 { + cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys) + } + if effNow.GetBool("runtime.exec", true) { + cfg.Exec = tools.InterpreterExecConfig() + } + if st2, err := os.Stat(deps.skillsRoot); err == nil && st2.IsDir() { + cfg.SkillsRoots = []string{deps.skillsRoot} + } + rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", "")) + if deps.pluginMCP != nil { + rawMCP = mergeMCPServers(rawMCP, deps.pluginMCP()) + } + if deps.mcp != nil { + if mgr := deps.mcp(rawMCP, strings.TrimSpace(effNow.GetString("tools.mcp.overrides", ""))); mgr != nil { + cfg.MCP = mgr + } + } + // Native squad tools do not yet expose a fail-closed result hook to the + // project observation outbox. Project runs therefore use the audited + // runCtx retrieval/tool path only; enabling these direct providers would + // let their results reach the next model turn without source analysis. + if rc.Run().ProjectID != "" { + cfg.Search = nil + cfg.Exec = nil + cfg.Fetch = nil + cfg.MCP = nil + } + cfg.CodeOrchestration = rc.Run().ProjectID == "" && effNow.GetBool("runtime.codeOrchestration", false) + if ask, ok := rc.(interface { + AskUser(card core.HITLCard) (core.HITLResponse, error) + }); ok { + cfg.Ask = func(_ context.Context, question string) (string, error) { + resp, err := ask.AskUser(core.HITLCard{ + Question: question, Kind: core.HITLKindInput, AllowFreeInput: true, + }) + if err != nil { + return "", err + } + if strings.TrimSpace(resp.FreeText) != "" { + return strings.TrimSpace(resp.FreeText), nil + } + return resp.ChoiceID, nil + } + } + cfg.Trace = deps.trace + // N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达 + // runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。 + cfg.PermissionAsk = runtime.PermissionAskOf(rc) + if host, ok := rc.(interface { + OpenCheckpoints() (*checkpoint.Log, error) + CheckpointAnchor() string + }); ok { + if lg, err := host.OpenCheckpoints(); err == nil { + cfg.Checkpoints = lg + anchor := host.CheckpointAnchor() + cfg.CheckpointAnchor = func() string { return anchor } + } + } + return cfg, true + } +} diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 6a7c17c..0385f52 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -252,7 +252,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { if stream == nil { // 重试经库默认(WP6:runtime.llmRetries → Config.RetryAttempts 映射), // 此处不再显式包装(防双重)。 - client := openRouteClient(route, eff) + client := OpenRouteClient(route, eff) stream = client.Stream } stream = annotateKeyFailure(stream, route) @@ -601,13 +601,6 @@ func openLLMClient(base, key, model string) *agentllm.Client { return c } -// openRouteClient 按一次解析出的路由构造客户端,并为该路由独立判定 RouteContract。 -func openRouteClient(route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client { - c := openLLMClient(route.Base, route.Key, route.Model) - c.RouteContract = routeContractFor(eff, route) - return c -} - // routeContractFor 只看本客户端实际解析出的 model 与 base,不看 route.Kind, // 也不读画像:三项系统设置任一为空、模型名不逐字相等、规范化后的端点与配置 // 不逐字相等(配置本身须已是规范形态)或身份摘要不符,都返回 nil(关闭)。 diff --git a/internal/runtime/model_test.go b/internal/runtime/model_test.go index ec93d99..d3c0fa2 100644 --- a/internal/runtime/model_test.go +++ b/internal/runtime/model_test.go @@ -90,3 +90,134 @@ func TestResolveVisionModel(t *testing.T) { t.Fatalf("text fallbacks %#v", fb) } } + +// 只有 Settings 端口的宿主:快照可取,没有就报缺失(调用方自己决定回落)。 +type settingsOnlyCtx struct{ eff core.EffectiveSettings } + +func (c settingsOnlyCtx) Settings() core.EffectiveSettings { return c.eff } + +// 只有 Run 端口的宿主:属主只能来自 run。 +type runOnlyCtx struct{ owner string } + +func (c runOnlyCtx) Run() core.Run { return core.Run{ID: "run_t", Owner: core.ID(c.owner)} } + +// 两个端口都有的宿主(runmgr 的 runCtx 形态)。 +type runProfileCtx struct { + settingsOnlyCtx + runOnlyCtx + prof core.AgentProfile +} + +func (c runProfileCtx) Profile() core.AgentProfile { return c.prof } + +func TestRunEffectiveSettingsNeedsHostPort(t *testing.T) { + eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "m"}}}) + if got, ok := RunEffectiveSettings(settingsOnlyCtx{eff: eff}); !ok || got.GetString("models.chat.model", "") != "m" { + t.Fatalf("run snapshot = %+v ok=%v, want the host snapshot", got, ok) + } + if _, ok := RunEffectiveSettings(settingsOnlyCtx{}); ok { + t.Fatal("empty snapshot must be reported as missing so the caller can fall back") + } + if _, ok := RunEffectiveSettings(runOnlyCtx{owner: "alice"}); ok { + t.Fatal("host without a Settings port must not be reported as having one") + } +} + +func TestRunProfileNeverFallsBackToDefaultOwner(t *testing.T) { + prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "alice-model"}} + both := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "alice"}, prof: prof} + if got := RunProfile(both); got.Owner != "alice" || got.Model.Chat != "alice-model" { + t.Fatalf("profile port must win: %+v", got) + } + if got := RunProfile(both).Owner; got != "alice" { + t.Fatalf("owner = %q, want alice", got) + } + // 画像端口缺席(或没带 Owner)时取 run.Owner——密钥层归属不能退化成默认账号。 + bare := runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}} + if got := RunProfile(bare).Owner; got != "bob" { + t.Fatalf("owner = %q, want the run owner bob", got) + } + if got := RunProfile(runProfileCtx{settingsOnlyCtx: settingsOnlyCtx{}, runOnlyCtx: runOnlyCtx{owner: "bob"}, prof: core.AgentProfile{}}).Owner; got != "bob" { + t.Fatalf("empty profile owner = %q, want the run owner bob", got) + } + if got := RunProfile(runOnlyCtx{}).Owner; got != "" { + t.Fatalf("owner without any port = %q, want empty", got) + } +} + +// 带自定义快照的运行上下文(其余端口由 fakeRunCtx 满足)。 +type runSnapCtx struct { + *fakeRunCtx + eff core.EffectiveSettings +} + +func (c *runSnapCtx) Settings() core.EffectiveSettings { return c.eff } + +// 运行内解析主路由:快照 + 画像覆盖 + 属主密钥层,与直连同一条规则。 +func TestRunChatRouteUsesRunSnapshotAndOwnerKey(t *testing.T) { + saved := SecretLookup + defer func() { SecretLookup = saved }() + secrets := map[string]string{"alice": "sk-alice-synthetic", "bob": "sk-bob-synthetic"} + SecretLookup = func(owner, _ string) (string, string, bool) { + if owner == "" { + owner = "local" + } + v, ok := secrets[owner] + return v, "owner", ok + } + eff := effOf(map[string]any{"models": map[string]any{"chat": map[string]any{ + "model": "settings-model", "baseUrl": "https://shared.example/v1", "apiKey": "sk-settings-synthetic", + }}}) + for _, owner := range []string{"alice", "bob"} { + rc := &runSnapCtx{fakeRunCtx: &fakeRunCtx{prof: core.AgentProfile{Owner: owner, Model: core.ModelRoute{Chat: owner + "-profile-model"}}}, eff: eff} + got := RunChatRoute(rc) + want := ResolveChatModel(rc.Profile(), rc.Settings()) + if got != want { + t.Fatalf("%s: run route %+v != direct rule %+v", owner, got, want) + } + if got.Model != owner+"-profile-model" || got.Base != "https://shared.example/v1" { + t.Fatalf("%s: route = %+v, want the profile model on the shared endpoint", owner, got) + } + if got.Key != "sk-"+owner+"-synthetic" || got.KeySource != "owner" { + t.Fatalf("%s: key = %q (%s), want that owner's own secret", owner, got.Key, got.KeySource) + } + } +} + +// 客户端构造:reasoning split 与受限 EOF 契约都只看本路由自己的 model+base, +// 画像覆盖后的路由重新判定(与直连路径同一个构造器)。 +func TestOpenRouteClientDecidesPerRoute(t *testing.T) { + const base = "https://api.example.com/v1" + contract := routeEOFMatching(routeEOFModel, base) + eff := routeEOFEff(map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"}, contract) + + route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: base, Key: "k"} + client := OpenRouteClient(route, eff) + if client.BaseURL != base || client.APIKey != "k" { + t.Fatalf("client route = %q/%q, want %q/k", client.BaseURL, client.APIKey, base) + } + if client.ReasoningSplit { + t.Fatal("non-MiniMax model must not enable reasoning split") + } + if client.RouteContract == nil { + t.Fatal("matching configuration must attach the contract") + } + // 画像覆盖改的是模型名:分流与契约都按覆盖后的路由重新判定。 + for _, tc := range []struct { + model string + wantSplit bool + wantEOfRule bool + }{ + {"MiniMax-M2", true, false}, + {routeEOFModel, false, true}, + {"other-model", false, false}, + } { + prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: tc.model}} + overridden := ResolveChatModel(prof, eff) + got := OpenRouteClient(overridden, eff) + if got.ReasoningSplit != tc.wantSplit || (got.RouteContract != nil) != tc.wantEOfRule { + t.Fatalf("profile model %q: split=%v contract=%v, want split=%v contract=%v", + tc.model, got.ReasoningSplit, got.RouteContract != nil, tc.wantSplit, tc.wantEOfRule) + } + } +} diff --git a/internal/runtime/route_contract_eof_test.go b/internal/runtime/route_contract_eof_test.go index ba824b6..55f08f9 100644 --- a/internal/runtime/route_contract_eof_test.go +++ b/internal/runtime/route_contract_eof_test.go @@ -41,7 +41,7 @@ func routeEOFMatching(model, base string) map[string]any { func TestRouteContractDefaultOff(t *testing.T) { route := ResolvedModel{Kind: "chat", Model: routeEOFModel, Base: "https://api.example.com/v1", Key: "k"} - if c := openRouteClient(route, core.EffectiveSettings{}); c.RouteContract != nil { + if c := OpenRouteClient(route, core.EffectiveSettings{}); c.RouteContract != nil { t.Fatalf("no settings must leave RouteContract nil, got %+v", c.RouteContract) } full := routeEOFMatching(routeEOFModel, "https://api.example.com/v1") @@ -206,7 +206,7 @@ func routeEOFHTTPClient(t *testing.T, server *httptest.Server) *http.Client { func routeEOFClient(t *testing.T, server *httptest.Server, route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client { t.Helper() - c := openRouteClient(route, eff) + c := OpenRouteClient(route, eff) c.HTTPClient = routeEOFHTTPClient(t, server) return c } @@ -373,3 +373,65 @@ func TestRouteContractEOFReleasesOneToolCallInLoop(t *testing.T) { } }) } + +// 小队路径的路由来自同一份运行快照:契约对得上的路由与直连一样放行一次 +// 工具调用;画像把模型名改掉之后,契约不再跟随,放行也一并消失。 +func TestRouteContractEOFForRunScopedRoute(t *testing.T) { + var requests atomic.Int32 + server := routeEOFServer(t, &requests) + base := server.URL + "/v1" + models := map[string]any{ + "chat": map[string]any{"model": routeEOFModel, "baseUrl": base, "apiKey": "k"}, + "routeContractEOF": routeEOFMatching(routeEOFModel, base), + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + decls := []agentllm.ToolDecl{{Name: routeEOFTool, Description: "report", Parameters: routeEOFParams}} + + run := func(t *testing.T, prof core.AgentProfile) (*agentllm.Client, []agentllm.Event) { + t.Helper() + rc := &runSnapCtx{ + fakeRunCtx: &fakeRunCtx{ctx: ctx, prof: prof}, + eff: core.EffectiveSettings{"models": models}, + } + route := RunChatRoute(rc) + client := routeEOFClient(t, server, route, rc.Settings()) + stream := GuardProjectStream(rc, "", annotateKeyFailure(client.Stream, route)) + var terminal []agentllm.Event + for ev := range stream(ctx, route.Model, []agentllm.Message{{Role: agentllm.RoleUser, Content: "hi"}}, decls, agentllm.StreamOptions{}) { + if ev.Kind == agentllm.KindMessageEnd || ev.Kind == agentllm.KindError { + terminal = append(terminal, ev) + } + } + return client, terminal + } + + t.Run("route matching the configuration releases one call", func(t *testing.T) { + client, terminal := run(t, core.AgentProfile{Owner: "alice"}) + if client.RouteContract == nil { + t.Fatal("run-scoped route must carry the contract, as on the direct path") + } + if len(terminal) != 1 || terminal[0].Kind != agentllm.KindMessageEnd || terminal[0].Err != nil { + t.Fatalf("terminal events = %+v, want one clean message_end", terminal) + } + if len(terminal[0].Message.ToolCalls) != 1 || terminal[0].Message.ToolCalls[0].Name != routeEOFTool { + t.Fatalf("released calls = %+v, want exactly one %s", terminal[0].Message.ToolCalls, routeEOFTool) + } + if terminal[0].CompletionBasis != agentllm.CompletionRouteContractEOF { + t.Fatalf("completion basis = %q, want %q", terminal[0].CompletionBasis, agentllm.CompletionRouteContractEOF) + } + }) + + t.Run("profile override drops the contract and the release", func(t *testing.T) { + client, terminal := run(t, core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "profile-model"}}) + if client.RouteContract != nil { + t.Fatalf("overridden route kept the contract: %+v", client.RouteContract) + } + if len(terminal) != 1 || terminal[0].Err == nil || !strings.Contains(terminal[0].Err.Error(), routeEOFRejection) { + t.Fatalf("terminal events = %+v, want the %q rejection", terminal, routeEOFRejection) + } + if len(terminal[0].Message.ToolCalls) != 0 { + t.Fatalf("tool call released without the contract: %+v", terminal[0].Message.ToolCalls) + } + }) +} diff --git a/internal/runtime/run_config.go b/internal/runtime/run_config.go new file mode 100644 index 0000000..7c7657a --- /dev/null +++ b/internal/runtime/run_config.go @@ -0,0 +1,62 @@ +package runtime + +import ( + "strings" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +// 运行配置解析的窄端口。直连 RunCtx 与小队 squadadapter.Ctx 共用同一组 +// 可选接口:任一执行面都能从「本次运行」拿到同一份生效快照,绝不按默认账号 +// 另起一套解析(KTD3)。 + +type runSettingsPort interface{ Settings() core.EffectiveSettings } +type runProfilePort interface{ Profile() core.AgentProfile } +type runScopePort interface{ Run() core.Run } + +// RunEffectiveSettings 返回本次运行生效的配置快照(Settings 端口已由宿主按 +// run.Owner + run.ProjectID 合并 defaults→system→user→project)。 +// ok=false 表示宿主没有该端口或快照为空;调用方自行决定回落策略, +// 不得在有快照时回落到别的属主。 +func RunEffectiveSettings(rc any) (core.EffectiveSettings, bool) { + port, ok := rc.(runSettingsPort) + if !ok { + return nil, false + } + eff := port.Settings() + if len(eff) == 0 { + return nil, false + } + return eff, true +} + +// RunProfile 返回本次运行的画像:宿主实现 Profile 端口时直接取;否则按 +// run.Owner 合成零值画像——Owner 决定密钥层归属(overlayKey),缺失时 +// 绝不能退化成默认账号,否则会用别人的密钥。 +func RunProfile(rc any) core.AgentProfile { + var prof core.AgentProfile + if port, ok := rc.(runProfilePort); ok { + prof = port.Profile() + } + if strings.TrimSpace(prof.Owner) == "" { + if scope, ok := rc.(runScopePort); ok { + prof.Owner = scope.Run().Owner + } + } + return prof +} + +// RunChatRoute 解析本次运行的主对话路由(画像 ModelRoute > 当前用户/项目设置层)。 +// SquadRunner 用它给小队入口定模型,使小队与直连共用同一条解析规则。 +func RunChatRoute(rc RunCtx) ResolvedModel { return ResolveChatModel(rc.Profile(), rc.Settings()) } + +// OpenRouteClient 按一次解析出的路由构造 OpenAI 兼容客户端:端点与密钥取自 +// 本路由自己的解析结果,MiniMax 系模型的 reasoning split 与受限 EOF route +// contract 都只看本路由的 model+base。直连、小队与其他执行面必须共用它, +// 避免同一个 run 里出现两套 per-route 规则(分流的思考、EOF 放行会不一致)。 +func OpenRouteClient(route ResolvedModel, eff core.EffectiveSettings) *agentllm.Client { + c := openLLMClient(route.Base, route.Key, route.Model) + c.RouteContract = routeContractFor(eff, route) + return c +} diff --git a/internal/runtime/squad_runner.go b/internal/runtime/squad_runner.go index 7bbcb82..7a68bbf 100644 --- a/internal/runtime/squad_runner.go +++ b/internal/runtime/squad_runner.go @@ -80,7 +80,9 @@ func (s *SquadRunner) Execute(rc RunCtx) error { Hitl: s.Hitl, MaxTurns: maxTurns, MaxToolCalls: maxTools, - Model: ResolveChatModel(prof, rc.Settings()).Model, + // 模型走本次运行的生效配置(当前用户/项目 + 画像覆盖),与直连同一 + // 解析规则:内核工厂用同一快照配端点/密钥,绝不与默认账号混搭。 + Model: RunChatRoute(rc).Model, }) if rc.Context().Err() != nil { return rc.Context().Err() @@ -143,7 +145,7 @@ func newModelRouter(rc RunCtx) modelRouter { if route.Model == "" || route.Base == "" { return modelRouter{} } - client := openRouteClient(route, rc.Settings()) + client := OpenRouteClient(route, rc.Settings()) return modelRouter{stream: GuardProjectStream(rc, "", client.Stream), model: route.Model} } diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go index d56af54..706fbd2 100644 --- a/internal/runtime/sub_loop.go +++ b/internal/runtime/sub_loop.go @@ -80,7 +80,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error { if route.Model == "" || route.Base == "" { return fmt.Errorf("llm-subagent: 模型未配置(当前解析:model=%q base=%q)", route.Model, route.Base) } - client := openRouteClient(route, rc.Settings()) + client := OpenRouteClient(route, rc.Settings()) stream = client.Stream } stream = GuardProjectStream(rc, subagentUserText(rc), stream) diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index 635a09b..589e13c 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -58,7 +58,7 @@ func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, if route.Model == "" || route.Base == "" { return gates.Verdict{}, fmt.Errorf("验证器调用失败") } - client := openRouteClient(route, rc.Settings()) + client := OpenRouteClient(route, rc.Settings()) // 验证器不经内核循环:显式接重试(WP6 同映射)与 5xx 回退链。 stream = agentllm.WithFallback( agentllm.WithRetry(client.Stream, retryAttemptsOf(rc.Settings()), nil), diff --git a/internal/runtime/workflow_compiled.go b/internal/runtime/workflow_compiled.go index 59de1b9..62b46a2 100644 --- a/internal/runtime/workflow_compiled.go +++ b/internal/runtime/workflow_compiled.go @@ -81,7 +81,7 @@ func judgeStreamFor(rc RunCtx) agentllm.StreamFunc { if route.Model == "" || route.Base == "" { return nil } - client := openRouteClient(route, rc.Settings()) + client := OpenRouteClient(route, rc.Settings()) model := route.Model base := func(ctx context.Context, _ string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { return client.Stream(ctx, model, msgs, tools, opts) diff --git a/internal/squadadapter/adapter.go b/internal/squadadapter/adapter.go index cff3128..6b50de2 100644 --- a/internal/squadadapter/adapter.go +++ b/internal/squadadapter/adapter.go @@ -225,6 +225,11 @@ func isHoldStatus(s string) bool { // AgentNativeOptions 可选组合根注入:返回配置且启用时,为每次小队运行注册 // "agent-native" 内核后端工厂(模型/搜索/执行/MCP/技能根由组合根从 settings // 解析;Subagent 工厂经 rc 桥接产品 SpawnSubagent)。 +// +// 组合根必须按「本次运行」解析配置:模型、端点与密钥整条来自 rc.Settings() +// 已合并的当前用户/项目快照(叠加 rc 的画像模型覆盖),per-route 规则与直连 +// 共用同一构造器。按默认账号重新解析会让一个用户的模型名配上另一个账号的 +// 端点/密钥。 var AgentNativeOptions func(rc Ctx) (nativeadp.Config, bool) // SubagentFactory 把内核委派端口(delegate_task)桥接到产品 SpawnSubagent: diff --git a/internal/squadadapter/entry.go b/internal/squadadapter/entry.go index 60c674d..b713023 100644 --- a/internal/squadadapter/entry.go +++ b/internal/squadadapter/entry.go @@ -27,7 +27,9 @@ type EntryOptions struct { MaxTurns int MaxToolCalls int RepairBudget int - Model string + // Model 是本次运行的主模型(与 AgentNativeOptions 解析出的路由同源: + // 同一份运行快照 + 画像覆盖),不得与工厂配的端点/密钥分属不同属主。 + Model string } // EntryResult 是入口跑完后的文本。工件登记由调用方在虚假交付检查之后做。