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
This commit is contained in:
+10
-91
@@ -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
|
||||
}
|
||||
return pluginHost.MCPServers()
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
profSvc := profiles.New(st)
|
||||
if err := profSvc.EnsureDefault(); err != nil {
|
||||
return fmt.Errorf("default profile: %w", err)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(关闭)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -27,6 +27,8 @@ type EntryOptions struct {
|
||||
MaxTurns int
|
||||
MaxToolCalls int
|
||||
RepairBudget int
|
||||
// Model 是本次运行的主模型(与 AgentNativeOptions 解析出的路由同源:
|
||||
// 同一份运行快照 + 画像覆盖),不得与工厂配的端点/密钥分属不同属主。
|
||||
Model string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user