From 80faf3edbd2f915d96f6a1dd7f3a334c6c1ae98c Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:15:03 +0000 Subject: [PATCH 1/8] 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 是入口跑完后的文本。工件登记由调用方在虚假交付检查之后做。 From 406cffdc375d3859af00222b969ee3a4314a1fc7 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:15:51 +0000 Subject: [PATCH 2/8] Bump the nested agent-intent pin to the U5 snapshot fix Why: U6 needs the authoritative recognition result with full slot validation and isolated snapshots landed by U5. Evidence: pin only; library verified in deps/agent-intent (verify-u5). Impact: nested gitlink pointer change. Refs: feature/agent-stack-audit-remediation-b1 --- deps/agent-intent | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/agent-intent b/deps/agent-intent index 651a2e1..765a7e7 160000 --- a/deps/agent-intent +++ b/deps/agent-intent @@ -1 +1 @@ -Subproject commit 651a2e1e03c4326888ef9f742ce592140ae30ab8 +Subproject commit 765a7e717737dcbe0d684ece3b7e2b38923c53d5 From 0e55875329f43783283f38a7435e439514513809 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:34:12 +0000 Subject: [PATCH 3/8] Admit intents with full slot validity and schema-typed slots Why: F09 - invalid slot information was dropped at the gate, the router saw only Start, and the command entrance passed untyped text, so invalid-slot recognitions could still reach business execution paths. Evidence: intentgate/route/server tests pass (verify-u6.json); admission regression cases added. Impact: invalid or missing slots never start executable actions; clarification flows unchanged; cmdbus confirmation untouched. Refs: feature/agent-stack-audit-remediation-b1 U6 --- internal/intentgate/gate.go | 93 +++++- internal/intentgate/gate_test.go | 147 +++++++++ internal/route/route.go | 197 +++++++++-- internal/route/route_test.go | 121 +++++++ internal/server/intent_admission.go | 206 ++++++++++++ internal/server/intent_admission_test.go | 397 +++++++++++++++++++++++ internal/server/server.go | 33 +- internal/server/workflow_route_test.go | 6 +- 8 files changed, 1158 insertions(+), 42 deletions(-) create mode 100644 internal/server/intent_admission.go create mode 100644 internal/server/intent_admission_test.go diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go index 8512b32..de95c66 100644 --- a/internal/intentgate/gate.go +++ b/internal/intentgate/gate.go @@ -7,6 +7,8 @@ import ( "context" "encoding/json" "fmt" + "sort" + "strings" "sync" agentintent "agent-intent/intent" @@ -14,8 +16,21 @@ import ( // SlotQuestion 是命中意图后仍缺的必填槽位,以及建议的提问。 type SlotQuestion struct { - Key string - Question string + Key string `json:"key"` + Question string `json:"question"` +} + +// SlotDefect 是一条槽位缺陷,来自权威识别结果的校验(F09): +// - Invalid=true:捕获到了值但没通过确定性校验(枚举/格式/校验器/跨度重叠/ +// 生成路径的未知键),Reason 是库层原因原文; +// - Invalid=false:必填槽位根本没给(等价于 SlotQuestion,Reason 为空)。 +// +// 宿主据此区分「没给」与「给错了」,两种情形都不派发业务动作。 +type SlotDefect struct { + Key string `json:"key"` + Reason string `json:"reason,omitempty"` + Question string `json:"question"` + Invalid bool `json:"invalid"` } // MatchResult 是一次识别。Branch 与 Match 相同。 @@ -24,11 +39,22 @@ type MatchResult struct { ID string Slots map[string]string Missing []SlotQuestion + // Invalid 是捕获到但未通过权威校验的槽位(含生成路径的未知键)。 + // 与 Missing 分开投影:必填且非法只进此处,不重复计入 Missing。 + Invalid []SlotDefect + // Risk/RequiresConfirm/Action 是权威候选的声明(规则路径与生成路径同款): + // 风险档、是否要求显式确认、spec.action 显式动作引用。nil Action 表示未声明。 + Risk string + RequiresConfirm bool + Action *ActionRef // Commands 是库层展开的命令列表(I1);Router 消费它做唯一裁决。 // guard_hit/expectation_hit 被折叠为 fallback 时为空。 Commands []Command } +// Complete 报告槽位是否齐全且全部通过权威校验:false 时业务动作不得派发。 +func (m MatchResult) Complete() bool { return len(m.Missing) == 0 && len(m.Invalid) == 0 } + // 命令类型经闸门投影(别名):生态库按边界规则只许本包导入, // Router/runmgr 等消费方只依赖本包表面。 type ( @@ -37,12 +63,18 @@ type ( ) const ( - CommandStart = agentintent.CommandStart - ActionAuto = agentintent.ActionAuto - ActionCommand = agentintent.ActionCommand - ActionWorkflow = agentintent.ActionWorkflow - ActionBlueprint = agentintent.ActionBlueprint - ActionHandler = agentintent.ActionHandler + CommandStart = agentintent.CommandStart + CommandSetSlot = agentintent.CommandSetSlot + CommandChangeSlot = agentintent.CommandChangeSlot + CommandCancel = agentintent.CommandCancel + CommandClarify = agentintent.CommandClarify + CommandChitchat = agentintent.CommandChitchat + CommandHandoff = agentintent.CommandHandoff + ActionAuto = agentintent.ActionAuto + ActionCommand = agentintent.ActionCommand + ActionWorkflow = agentintent.ActionWorkflow + ActionBlueprint = agentintent.ActionBlueprint + ActionHandler = agentintent.ActionHandler ) type Gate struct { @@ -133,7 +165,7 @@ func (g *Gate) Match(ctx context.Context, input string) (string, string) { return res.Branch, res.ID } -// Recognize 在 Match 的结果上带上槽位和缺失提问。 +// Recognize 在 Match 的结果上带上槽位、缺失提问、非法原因与风险/确认/动作声明。 func (g *Gate) Recognize(ctx context.Context, input string) MatchResult { g.mu.RLock() reg, rec, byID := g.reg, g.rec, g.specsByID @@ -163,16 +195,45 @@ func (g *Gate) Recognize(ctx context.Context, input string) MatchResult { out.Slots[k] = v } } + // 风险/确认/动作声明:规则路径与生成路径(candidateFromGenerated)同款字段, + // 宿主据此决定是否必须走确认,不在本包内裁决。 + out.Risk = res.Top.Risk + out.RequiresConfirm = res.Top.RequiresConfirm + if res.Top.Action != nil { + action := *res.Top.Action + out.Action = &action + } spec := byID[out.ID] for _, key := range res.Top.MissingRequiredSlots { - q := key - for _, sl := range spec.Slots { - if sl.Key == key && sl.Question != "" { - q = sl.Question - } - } - out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: q}) + out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: questionOf(spec, key)}) } + // 非法槽位按键名排序(识别结果里的 map 无序,投影必须确定)。 + for _, key := range sortedKeys(res.Top.InvalidSlots) { + out.Invalid = append(out.Invalid, SlotDefect{ + Key: key, Reason: res.Top.InvalidSlots[key], Invalid: true, + Question: questionOf(spec, key), + }) + } + return out +} + +// questionOf 取 spec 为该槽位声明的建议提问;没有就回落键名本身。 +func questionOf(spec agentintent.IntentSpec, key string) string { + for _, sl := range spec.Slots { + if sl.Key == key && strings.TrimSpace(sl.Question) != "" { + return sl.Question + } + } + return key +} + +// sortedKeys 按键名排序,保证同一识别结果的投影顺序确定。 +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) return out } diff --git a/internal/intentgate/gate_test.go b/internal/intentgate/gate_test.go index 3201387..c3ce128 100644 --- a/internal/intentgate/gate_test.go +++ b/internal/intentgate/gate_test.go @@ -3,6 +3,7 @@ package intentgate import ( "context" "encoding/json" + "strings" "testing" agentintent "agent-intent/intent" @@ -176,3 +177,149 @@ func TestGateGeneratorProducesCommands(t *testing.T) { t.Fatalf("关闭生成层:branch = %s", branch) } } + +// ---- F09:非法槽位与风险/确认/动作声明的透传(规则路径与生成路径同款)---- + +// modeSpec 声明一个带枚举校验的风险意图:mode 与 topic 都是必填。 +func modeSpec(t *testing.T) json.RawMessage { + t.Helper() + return raw(t, map[string]any{ + "id": "demo.mode", "domain": "demo", "version": 1, + "matcher": map[string]any{"includes_any": []string{"切换"}}, + "risk": "high", + "requires_confirm": true, + "action": map[string]any{"kind": "command", "id": "demo.mode"}, + "slots": []map[string]any{ + {"key": "mode", "required": true, "pattern": `模式[::]\s*(\S+)`, "enum": []string{"摘要", "全文"}, "question": "要哪种模式?"}, + {"key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`, "question": "主题是什么?"}, + }, + }) +} + +func TestRecognizeKeepsInvalidSlotDetail(t *testing.T) { + g := New() + if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil { + t.Fatal(err) + } + bad := g.Recognize(context.Background(), "切换 主题:核能 模式:随手写") + if bad.Branch != "matched" || bad.ID != "demo.mode" { + t.Fatalf("识别分支:%+v", bad) + } + if bad.Complete() { + t.Fatalf("非法枚举值不得算作完整:%+v", bad) + } + if len(bad.Missing) != 0 { + // 必填且非法只进 Invalid,不重复进 Missing。 + t.Fatalf("非法槽位不应重复计入缺失:%+v", bad.Missing) + } + if len(bad.Invalid) != 1 { + t.Fatalf("非法槽位详情:%+v", bad.Invalid) + } + d := bad.Invalid[0] + if d.Key != "mode" || !d.Invalid || d.Reason != "value not in enum" || d.Question != "要哪种模式?" { + t.Fatalf("非法原因/提问未透传:%+v", d) + } + if _, leaked := bad.Slots["mode"]; leaked { + t.Fatalf("非法值不得进入已校验槽位表:%+v", bad.Slots) + } + if bad.Slots["topic"] != "核能" { + t.Fatalf("合法槽位丢失:%+v", bad.Slots) + } + // 风险/确认/动作声明一并透传(宿主据此决定确认与派发)。 + if bad.Risk != "high" || !bad.RequiresConfirm { + t.Fatalf("风险元数据未透传:%+v", bad) + } + if bad.Action == nil || bad.Action.Kind != ActionCommand || bad.Action.ID != "demo.mode" { + t.Fatalf("动作声明未透传:%+v", bad.Action) + } + + ok := g.Recognize(context.Background(), "切换 主题:核能 模式:摘要") + if !ok.Complete() || ok.Slots["mode"] != "摘要" || len(ok.Invalid) != 0 { + t.Fatalf("合法槽位识别:%+v", ok) + } +} + +// 生成路径同样经过权威槽位校验:非法值与未知键都要带原因回到宿主。 +func TestRecognizeGeneratedPathCarriesSlotDetail(t *testing.T) { + g := New() + if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil { + t.Fatal(err) + } + g.SetGenerator(stubGenerator{cmds: []Command{{ + Kind: CommandStart, + Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"}, + Slots: map[string]string{"mode": "随手写", "topic": "核能"}, + }}}) + res := g.Recognize(context.Background(), "跟昨天一样") + if res.Branch != "matched" || res.ID != "demo.mode" { + t.Fatalf("生成路径识别:%+v", res) + } + if len(res.Invalid) != 1 || res.Invalid[0].Key != "mode" || res.Invalid[0].Reason != "value not in enum" || + res.Invalid[0].Question != "要哪种模式?" { + t.Fatalf("生成路径非法详情:%+v", res.Invalid) + } + if _, leaked := res.Slots["mode"]; leaked || res.Complete() { + t.Fatalf("生成路径非法值不得进入槽位表:%+v", res.Slots) + } + if res.Risk != "high" || !res.RequiresConfirm || res.Action == nil { + t.Fatalf("生成路径风险元数据:%+v", res) + } + + // 未知键:库层判为 invalid(reason=unknown slot key),宿主据此拒绝派发。 + g.SetGenerator(stubGenerator{cmds: []Command{{ + Kind: CommandStart, + Action: ActionRef{Kind: ActionCommand, ID: "demo.mode"}, + Slots: map[string]string{"mode": "摘要", "topic": "核能"}, + }}}) + clean := g.Recognize(context.Background(), "跟昨天一样") + if !clean.Complete() || clean.Slots["mode"] != "摘要" { + t.Fatalf("生成路径合法槽位:%+v", clean) + } +} + +// 必填槽位缺失与非法是两条独立通道:缺失走 Missing,非法走 Invalid。 +func TestRecognizeDistinguishesMissingFromInvalid(t *testing.T) { + g := New() + if err := g.Rebuild(context.Background(), []json.RawMessage{modeSpec(t)}); err != nil { + t.Fatal(err) + } + res := g.Recognize(context.Background(), "切换 主题:核能") + if len(res.Missing) != 1 || res.Missing[0].Key != "mode" || res.Missing[0].Question != "要哪种模式?" { + t.Fatalf("缺失投影:%+v", res.Missing) + } + if len(res.Invalid) != 0 { + t.Fatalf("缺失不得混进非法:%+v", res.Invalid) + } + if res.Complete() { + t.Fatal("缺必填槽位不得算作完整") + } +} + +// 格式校验失败与枚举失败同款透传(原因原文来自库层,不做改写)。 +func TestRecognizeKeepsFormatFailureReason(t *testing.T) { + g := New() + spec := raw(t, map[string]any{ + "id": "demo.ticket", "domain": "demo", "version": 1, + "matcher": map[string]any{"includes_any": []string{"开工单"}}, + "slots": []map[string]any{{ + "key": "issue", "required": true, "pattern": `编号[::]\s*(\S+)`, + "format": `[A-Z]{2}-\d+`, "question": "编号是什么?", + }}, + }) + if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil { + t.Fatal(err) + } + bad := g.Recognize(context.Background(), "开工单 编号:随手写的") + if len(bad.Invalid) != 1 || bad.Invalid[0].Key != "issue" || bad.Invalid[0].Question != "编号是什么?" { + t.Fatalf("格式失败明细:%+v", bad.Invalid) + } + if !strings.HasPrefix(bad.Invalid[0].Reason, "format mismatch") { + t.Fatalf("格式失败原因:%+v", bad.Invalid[0]) + } + if bad.Complete() || len(bad.Missing) != 0 { + t.Fatalf("格式失败不得算作完整或缺失:%+v", bad) + } + if ok := g.Recognize(context.Background(), "开工单 编号:AB-12"); !ok.Complete() || ok.Slots["issue"] != "AB-12" { + t.Fatalf("格式合法:%+v", ok) + } +} diff --git a/internal/route/route.go b/internal/route/route.go index 2d12269..841c71e 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -10,15 +10,22 @@ // - PromptTrigger(技能触发)是提示词装配、不参与路由决胜; // 意图产出 Start/Clarify 类命令时路由优先。 // +// 槽位准入(F09):可执行业务动作(命令总线)必须在槽位齐全且全部通过 +// 权威校验时才派发;缺失或非法一律不派发,转澄清诉求(Clarification)。 +// 工作流是「边收集边执行」的草稿:按既有规则照常启动,缺失清单随决策透出, +// 由流程自身追问——非法值不会进入槽位表(库层只把合法值写进 Slots)。 +// // 当前版本的已知边界(如实记录,不静默): // - squad 决策需要 runmgr 的 per-run entry 覆盖才能真正执行(独立设计项); // 本版本调用方按普通回合处理并保留 Decision 供审计。 -// - SetSlot/Cancel/Handoff 命令由库层产出,但 Backend 尚无会话槽位状态机 -// 与语义层,Router v1 不消费它们(expectation_hit 在 intentgate 已被折叠为 -// fallback,Start 只来自 matched)。 +// - SetSlot/ChangeSlot/Cancel/Handoff 由库层产出,但 Backend 尚无会话槽位 +// 状态机与语义层:它们记入 Unsupported 并回落普通回合,绝不假装已支持 +// (例如用户说「取消」时不会把同一句话里的 Start 当成业务动作派发)。 package route import ( + "strings" + "agent-system-backend/internal/intentgate" core "agent-system-core" ) @@ -33,6 +40,18 @@ const ( KindTurn Kind = "turn" ) +// Clarification 是「业务动作未派发,需要向用户澄清」的诉求。 +// 槽位缺失与非法都带结构化原因(key + reason + question), +// 宿主据此渲染澄清话术或转成回合提示,不再自行猜测。 +type Clarification struct { + Needed bool + Question string + Missing []intentgate.SlotQuestion + Invalid []intentgate.SlotDefect + // Command 是被拦下的动作 ID(诊断与审计用,未派发)。 + Command string +} + // Decision 是一次路由裁决。 type Decision struct { Kind Kind @@ -41,8 +60,17 @@ type Decision struct { Blueprint string Slots map[string]string Missing []intentgate.SlotQuestion - Reason string // 回落 turn 的原因(显式声明不可解析时) + Invalid []intentgate.SlotDefect + Risk string // 权威意图声明的风险档(low/medium/high) + Reason string // 回落 turn 的原因(显式声明不可解析、准入未过等) StartFound bool // 是否存在 Start 命令(runmgr 转轨判定用) + // RequiresConfirm 是意图声明的显式确认要求(与命令自身的 danger 分级正交, + // 本包只透传,不替代 cmdbus 的高危确认收口)。 + RequiresConfirm bool + // Clarification 非空时业务动作未派发(Needed=true 时必有槽位原因)。 + Clarification Clarification + // Unsupported 是本次识别里宿主不消费的库层命令种类(如 cancel/set_slot)。 + Unsupported []string } // Env 是路由裁决的环境解析器(由调用方注入;nil 视为不可解析)。 @@ -52,23 +80,52 @@ type Env struct { SquadReady bool } +// unconsumedCommandKinds 是宿主 v1 不消费的库层命令种类(无会话槽位状态机)。 +// 命中即不派发业务动作:把「取消」当成「启动」是比不响更糟的错配。 +var unconsumedCommandKinds = map[string]bool{ + string(intentgate.CommandSetSlot): true, + string(intentgate.CommandChangeSlot): true, + string(intentgate.CommandCancel): true, + string(intentgate.CommandHandoff): true, + string(intentgate.CommandChitchat): true, +} + // Decide 裁决一次识别结果。规则: // 1. 非 matched(fallback/guard/ambiguous 经闸门折叠后)→ turn。 -// 2. 找到首个 Start 命令;没有时按 TopID 走 auto(兼容兜底,与现网一致)。 -// 3. 按 ActionRef.Kind 解析:command→命令总线;workflow→工作流; -// blueprint→squad 决策;handler/auto→命令总线 > 工作流 > turn。 +// 2. 出现宿主不消费的库层命令(cancel/set_slot/…)→ turn,并如实记录 +// Unsupported 与 Reason(不假装支持,也不派发同句的 Start)。 +// 3. 找到首个 Start 命令;没有时按 TopID(或显式 action 声明)走 auto +// (兼容兜底,与现网一致)。 +// 4. 按 ActionRef.Kind 解析:command→命令总线(先过槽位准入); +// workflow→工作流(草稿,缺槽照常启动);blueprint→squad 决策; +// handler/auto→命令总线 > 工作流 > turn。 func Decide(res intentgate.MatchResult, env Env) Decision { - d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing} + d := Decision{Kind: KindTurn, Slots: res.Slots, Missing: res.Missing, Invalid: res.Invalid, + Risk: res.Risk, RequiresConfirm: res.RequiresConfirm} if res.Branch != "matched" { return d } - var start *intentgate.Command + d.Unsupported = unconsumedKinds(res.Commands) + var start, clarify *intentgate.Command for i := range res.Commands { - if res.Commands[i].Kind == intentgate.CommandStart { - start = &res.Commands[i] - break + switch res.Commands[i].Kind { + case intentgate.CommandStart: + if start == nil { + start = &res.Commands[i] + } + case intentgate.CommandClarify: + if clarify == nil { + clarify = &res.Commands[i] + } } } + if len(d.Unsupported) > 0 { + d.Reason = "宿主未消费库层命令:" + strings.Join(d.Unsupported, "、") + if clarify != nil { + d.Clarification = clarificationFrom("", res, clarify) + } + return d + } action := intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: res.ID} if start != nil { d.StartFound = true @@ -76,19 +133,19 @@ func Decide(res intentgate.MatchResult, env Env) Decision { if len(start.Slots) > 0 { d.Slots = start.Slots } + } else if res.Action != nil { + action = *res.Action } switch action.Kind { case intentgate.ActionCommand: if env.HasCommand != nil && env.HasCommand(action.ID) { - d.Kind, d.CommandID = KindCommand, action.ID - return d + return d.dispatchCommand(action.ID, res, clarify) } d.Reason = "声明的命令不存在:" + action.ID case intentgate.ActionWorkflow: if env.WorkflowFor != nil { if def, ok := env.WorkflowFor(action.ID); ok { - d.Kind, d.Workflow = KindWorkflow, def - return d + return d.startWorkflowDraft(def) } } d.Reason = "声明的工作流未注册:" + action.ID @@ -100,15 +157,115 @@ func Decide(res intentgate.MatchResult, env Env) Decision { d.Reason = "声明的小队未启用:" + action.ID default: // auto / handler if env.HasCommand != nil && env.HasCommand(action.ID) { - d.Kind, d.CommandID = KindCommand, action.ID - return d + return d.dispatchCommand(action.ID, res, clarify) } if env.WorkflowFor != nil { if def, ok := env.WorkflowFor(action.ID); ok { - d.Kind, d.Workflow = KindWorkflow, def - return d + return d.startWorkflowDraft(def) } } } return d } + +// dispatchCommand 派发可执行业务动作前的最后一道闸:槽位必须齐全且通过权威校验。 +// 未就绪时不碰业务动作,改为带结构化原因的澄清诉求(Kind 留在 turn)。 +func (d Decision) dispatchCommand(id string, res intentgate.MatchResult, clarify *intentgate.Command) Decision { + if !res.Complete() { + d.Clarification = clarificationFrom(id, res, clarify) + d.Reason = "槽位未就绪,未派发命令:" + id + return d + } + d.Kind, d.CommandID = KindCommand, id + return d +} + +// startWorkflowDraft 工作流是收集槽位的草稿:缺失与非法都不阻断启动 +// (非法值本就进不了 Slots),缺失清单随 Decision 透出,由流程自身追问。 +func (d Decision) startWorkflowDraft(def core.WorkflowDef) Decision { + d.Kind, d.Workflow = KindWorkflow, def + return d +} + +// unconsumedKinds 列出命令列表里宿主不消费的种类(按首次出现序,确定性)。 +func unconsumedKinds(cmds []intentgate.Command) []string { + var out []string + for _, c := range cmds { + if !unconsumedCommandKinds[string(c.Kind)] { + continue + } + if contains(out, string(c.Kind)) { + continue + } + out = append(out, string(c.Kind)) + } + return out +} + +func contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} + +// clarificationFrom 组装澄清诉求:优先用 spec 声明的建议提问, +// 缺失时回落库层 clarify 命令里的 Question,再缺就按缺陷键列一句兜底。 +func clarificationFrom(id string, res intentgate.MatchResult, clarify *intentgate.Command) Clarification { + c := Clarification{Needed: true, Command: id, + Missing: append([]intentgate.SlotQuestion(nil), res.Missing...), + Invalid: append([]intentgate.SlotDefect(nil), res.Invalid...)} + if len(c.Missing) > 0 { + c.Question = strings.TrimSpace(c.Missing[0].Question) + } + if c.Question == "" && len(c.Invalid) > 0 { + c.Question = strings.TrimSpace(c.Invalid[0].Question) + } + if clarify != nil { + if c.Question == "" { + c.Question = strings.TrimSpace(clarify.Question) + } + // 库层 clarify 的 Missing 是缺失与非法键的合并;补齐宿主侧没有投影的键。 + for _, k := range clarify.Missing { + if defectKeyKnown(c, k) { + continue + } + c.Missing = append(c.Missing, intentgate.SlotQuestion{Key: k, Question: k}) + } + } + if c.Question == "" { + c.Question = fallbackQuestion(c) + } + return c +} + +func defectKeyKnown(c Clarification, key string) bool { + for _, m := range c.Missing { + if m.Key == key { + return true + } + } + for _, iv := range c.Invalid { + if iv.Key == key { + return true + } + } + return false +} + +// fallbackQuestion 没有任何声明提问时的兜底话术:按缺失在前、非法在后列键。 +func fallbackQuestion(c Clarification) string { + var keys []string + for _, m := range c.Missing { + keys = append(keys, m.Key) + } + for _, iv := range c.Invalid { + keys = append(keys, iv.Key) + } + if len(keys) == 0 { + return "" + } + return "请补充:" + strings.Join(keys, "、") +} diff --git a/internal/route/route_test.go b/internal/route/route_test.go index 86bbfab..713516c 100644 --- a/internal/route/route_test.go +++ b/internal/route/route_test.go @@ -81,3 +81,124 @@ func TestSlotsFromStartCommand(t *testing.T) { t.Fatalf("slots = %v", d.Slots) } } + +// ---- F09:槽位准入——业务动作必须齐全且合法,否则转澄清 ---- + +func matchedCommand(action intentgate.ActionRef) intentgate.MatchResult { + return intentgate.MatchResult{Branch: "matched", ID: action.ID, + Commands: []intentgate.Command{startCmd(action)}} +} + +func hasCommandEnv() Env { return Env{HasCommand: func(string) bool { return true }} } + +func TestMissingSlotsBlockCommandDispatch(t *testing.T) { + in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}) + in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}} + d := Decide(in, hasCommandEnv()) + if d.Kind != KindTurn || d.CommandID != "" { + t.Fatalf("缺必填槽位不得派发命令:%+v", d) + } + if !d.Clarification.Needed || d.Clarification.Command != "demo.brief" { + t.Fatalf("澄清诉求缺失:%+v", d.Clarification) + } + if d.Clarification.Question != "主题是什么?" || len(d.Clarification.Missing) != 1 { + t.Fatalf("澄清提问未透传:%+v", d.Clarification) + } + if d.Reason == "" || !d.StartFound { + t.Fatalf("回落原因或 Start 标记缺失:%+v", d) + } +} + +func TestInvalidSlotsBlockCommandWithReason(t *testing.T) { + in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"}) + in.Invalid = []intentgate.SlotDefect{{Key: "mode", Reason: "value not in enum", Question: "要哪种模式?", Invalid: true}} + d := Decide(in, hasCommandEnv()) + if d.Kind != KindTurn || d.CommandID != "" { + t.Fatalf("非法槽位不得派发命令:%+v", d) + } + if len(d.Clarification.Invalid) != 1 || d.Clarification.Invalid[0].Reason != "value not in enum" { + t.Fatalf("非法原因未透传:%+v", d.Clarification) + } + if d.Clarification.Question != "要哪种模式?" { + t.Fatalf("澄清提问未透传:%+v", d.Clarification) + } +} + +func TestCompleteSlotsDispatchCommand(t *testing.T) { + in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}) + in.Slots = map[string]string{"topic": "核能"} + in.Risk, in.RequiresConfirm = "high", true + d := Decide(in, hasCommandEnv()) + if d.Kind != KindCommand || d.CommandID != "demo.brief" { + t.Fatalf("完整合法槽位应派发:%+v", d) + } + if d.Risk != "high" || !d.RequiresConfirm || d.Clarification.Needed { + t.Fatalf("风险元数据未透传或误报澄清:%+v", d) + } +} + +// 工作流是「边收集边执行」的草稿:缺失清单照常启动,由流程自身追问。 +func TestWorkflowDraftStartsWhileGatheringSlots(t *testing.T) { + in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}) + in.Missing = []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}} + d := Decide(in, Env{ + HasCommand: func(string) bool { return false }, + WorkflowFor: func(string) (core.WorkflowDef, bool) { return core.WorkflowDef{ID: "wf"}, true }, + }) + if d.Kind != KindWorkflow || d.Workflow.ID != "wf" { + t.Fatalf("工作流草稿应照常启动:%+v", d) + } + if len(d.Missing) != 1 || d.Clarification.Needed { + t.Fatalf("缺失清单应随决策透出:%+v", d) + } +} + +// 显式的取消/改槽命令不得被静默当成已支持:回落普通回合,如实记录。 +func TestUnsupportedLibraryCommandsAreNotClaimed(t *testing.T) { + in := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{ + {Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionAuto, ID: "demo.brief"}}, + {Kind: intentgate.CommandCancel, Target: "demo.brief"}, + }} + d := Decide(in, hasCommandEnv()) + if d.Kind != KindTurn || d.CommandID != "" { + t.Fatalf("未消费的库层命令不得派发业务动作:%+v", d) + } + if len(d.Unsupported) != 1 || d.Unsupported[0] != string(intentgate.CommandCancel) { + t.Fatalf("未消费命令种类未如实记录:%+v", d.Unsupported) + } + if d.Reason == "" { + t.Fatalf("回落原因缺失:%+v", d) + } + // 只有改槽命令时同样回落,且不得声称支持。 + only := intentgate.MatchResult{Branch: "matched", ID: "demo.brief", Commands: []intentgate.Command{ + {Kind: intentgate.CommandSetSlot, Name: "topic", Value: "核能"}, + }} + got := Decide(only, hasCommandEnv()) + if got.Kind != KindTurn || got.CommandID != "" || len(got.Unsupported) != 1 { + t.Fatalf("set_slot 不得被当成可派发:%+v", got) + } +} + +// 澄清提问优先用 spec 声明,缺失时回落库层 clarify 命令,最后按键名兜底。 +func TestClarificationQuestionSources(t *testing.T) { + in := matchedCommand(intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.mode"}) + in.Missing = []intentgate.SlotQuestion{{Key: "mode"}} + in.Commands = append(in.Commands, intentgate.Command{Kind: intentgate.CommandClarify, Question: "要哪种模式?"}) + if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "要哪种模式?" { + t.Fatalf("库层澄清提问未用上:%q", q) + } + in.Commands = in.Commands[:1] + if q := Decide(in, hasCommandEnv()).Clarification.Question; q != "请补充:mode" { + t.Fatalf("兜底提问:%q", q) + } +} + +// 旧消费方(命令列表为空)仍按 TopID 走 auto,且必须槽位完整。 +func TestLegacyAutoStillNeedsCompleteSlots(t *testing.T) { + in := intentgate.MatchResult{Branch: "matched", ID: "demo.x", + Missing: []intentgate.SlotQuestion{{Key: "topic", Question: "主题是什么?"}}} + d := Decide(in, hasCommandEnv()) + if d.Kind != KindTurn || d.CommandID != "" || !d.Clarification.Needed { + t.Fatalf("旧路径也必须过槽位准入:%+v", d) + } +} diff --git a/internal/server/intent_admission.go b/internal/server/intent_admission.go new file mode 100644 index 0000000..7d2a9e1 --- /dev/null +++ b/internal/server/intent_admission.go @@ -0,0 +1,206 @@ +package server + +// 意图接纳(F09):把「一次识别」落到「一次命令派发」之前, +// 按命令 DECLARED schema 完成参数映射与准入判定: +// - 只映射命令声明过的槽位键,并按声明类型转换(string/number/integer/boolean); +// 未声明的键一律丢弃——不把任意字符串复制进 params。 +// - 声明的必填参数无法由「原文 + 已识别槽位」满足时不派发:不把拒绝留给 +// 总线层的 schema 校验(那会变成一条让人困惑的 "params: missing required +// param x"),而是转成澄清诉求,与路由层的槽位缺口同一出口。 +// +// 确认语义(高危命令的确认卡)完全由 cmdbus 收口,本文件不参与。 + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/intentgate" + "agent-system-backend/internal/route" +) + +// chatTextParam 是 chat 入口恒定提供的原始文本参数名(既有文本处理器契约)。 +const chatTextParam = "text" + +// intentAdmission 是一次「意图命中 → 命令派发」的接纳结果。 +type intentAdmission struct { + // Params 是派发参数:恒含 text,其余按声明 schema 映射。 + Params map[string]any + // Blocked 表示准入未过(业务动作不得派发)。 + Blocked bool + // Reason 是准入未过的原因(诊断用;面向用户的说法走澄清诉求)。 + Reason string + // MissingParams 是声明要求但槽位无法满足的参数名(按声明序)。 + MissingParams []string +} + +// admitIntentCommand 按命令声明的 schema 把已识别槽位映射成派发参数。 +// 声明不可解析(命令不存在/未启用)时按 fail-closed 返回 Blocked。 +func admitIntentCommand(bus *cmdbus.Bus, commandID string, slots map[string]string, text string) intentAdmission { + if bus == nil { + return intentAdmission{Blocked: true, Reason: "命令总线不可用:" + commandID} + } + spec, _, err := bus.Registry().Resolve(commandID) + if err != nil { + return intentAdmission{Blocked: true, Reason: "命令不可解析:" + commandID} + } + params, missing := mapSchemaParams(spec.ParamsSchema, slots) + params[chatTextParam] = text + if len(missing) > 0 { + return intentAdmission{Params: params, Blocked: true, + Reason: "命令必填参数未满足:" + strings.Join(missing, "、"), + MissingParams: missing} + } + return intentAdmission{Params: params} +} + +// mapSchemaParams 把槽位按 JSON Schema(draft-07 子集,与总线侧校验同款) +// 投影成参数。只认声明过的键,并按声明类型转换;转换不成的不写参数 +// (数组/对象等非标量声明也不会收到字符串)。必填参数里无法满足的按键名返回。 +func mapSchemaParams(schema json.RawMessage, slots map[string]string) (map[string]any, []string) { + params := map[string]any{} + props, required, ok := objectSchema(schema) + if !ok { + // 没有可用声明:不复制任何槽位(fail-closed),只保留原文入口参数。 + return params, nil + } + keys := make([]string, 0, len(slots)) + for k := range slots { + if _, declared := props[k]; declared { + keys = append(keys, k) + } + } + sort.Strings(keys) + for _, k := range keys { + if v, ok := castParam(props[k], slots[k]); ok { + params[k] = v + } + } + var missing []string + for _, k := range required { + if k == chatTextParam { + continue // 原文入口恒供 + } + if _, ok := params[k]; !ok { + missing = append(missing, k) + } + } + return params, missing +} + +// objectSchema 解析命令的 paramsSchema;非对象/非法 schema 返回 ok=false。 +func objectSchema(schema json.RawMessage) (props map[string]any, required []string, ok bool) { + if len(schema) == 0 { + return nil, nil, false + } + var sc struct { + Type string `json:"type"` + Required []string `json:"required"` + Properties map[string]any `json:"properties"` + } + if err := json.Unmarshal(schema, &sc); err != nil { + return nil, nil, false + } + if sc.Type != "" && sc.Type != "object" { + return nil, nil, false + } + if sc.Properties == nil { + sc.Properties = map[string]any{} + } + return sc.Properties, sc.Required, true +} + +// castParam 按声明类型把槽位字符串转成参数值;类型不支持或转换失败返回 false。 +func castParam(decl any, raw string) (any, bool) { + m, _ := decl.(map[string]any) + want, _ := m["type"].(string) + switch want { + case "", "string": + return raw, true + case "number": + f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return nil, false + } + return f, true + case "integer": + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return nil, false + } + return n, true + case "boolean": + b, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return nil, false + } + return b, true + default: + // array/object 等非标量声明:拒绝把字符串塞进去。 + return nil, false + } +} + +// clarifyPayload 是回给调用方的澄清诉求(postMessage 响应里的 clarify 字段)。 +// 既有端点上的附加字段,不新增 API 面:缺失与非法各自带 key/原因/建议提问。 +type clarifyPayload struct { + Intent string `json:"intent,omitempty"` + Command string `json:"command,omitempty"` + Question string `json:"question,omitempty"` + Reason string `json:"reason,omitempty"` + Missing []intentgate.SlotQuestion `json:"missing,omitempty"` + Invalid []intentgate.SlotDefect `json:"invalid,omitempty"` +} + +// newClarifyPayload 把路由裁决与准入结果合成一条澄清诉求;无需澄清时返回 nil。 +func newClarifyPayload(res intentgate.MatchResult, dec route.Decision, adm *intentAdmission) *clarifyPayload { + if !dec.Clarification.Needed && (adm == nil || !adm.Blocked) { + return nil + } + c := dec.Clarification + out := &clarifyPayload{ + Intent: res.ID, + Command: firstNonEmpty(c.Command, dec.CommandID), + Question: strings.TrimSpace(c.Question), + Missing: append([]intentgate.SlotQuestion(nil), c.Missing...), + Invalid: append([]intentgate.SlotDefect(nil), c.Invalid...), + } + if adm != nil && adm.Blocked { + out.Reason = adm.Reason + names := make([]string, 0, len(adm.MissingParams)) + for _, name := range adm.MissingParams { + if slotQuestionKnown(out.Missing, name) { + continue + } + out.Missing = append(out.Missing, intentgate.SlotQuestion{Key: name, Question: name}) + names = append(names, name) + } + if out.Question == "" && len(names) > 0 { + out.Question = "请补充:" + strings.Join(names, "、") + } + } + if out.Question == "" { + return nil + } + return out +} + +func slotQuestionKnown(list []intentgate.SlotQuestion, key string) bool { + for _, q := range list { + if q.Key == key { + return true + } + } + return false +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/server/intent_admission_test.go b/internal/server/intent_admission_test.go new file mode 100644 index 0000000..981737b --- /dev/null +++ b/internal/server/intent_admission_test.go @@ -0,0 +1,397 @@ +package server + +import ( + "context" + "encoding/json" + "sync" + "testing" + + agentcommand "agent-command" + agentintent "agent-intent/intent" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/intentgate" +) + +// F09 意图接纳:槽位缺失/非法一律不派发业务动作;合法槽位按命令 DECLARED +// schema 映射后进处理器;高危命令的确认语义完全由 cmdbus 收口(此处不动)。 + +// reportIntent 声明一个带枚举校验与多个可选槽位的意图(合成数据)。 +func reportIntent() json.RawMessage { + return json.RawMessage(`{"id":"demo.report","domain":"demo","version":1, + "matcher":{"includes_any":["生成报告"]}, + "slots":[ + {"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"}, + {"key":"mode","required":true,"pattern":"模式[::]\\s*(\\S+)","enum":["摘要","全文"],"question":"要哪种模式?"}, + {"key":"count","pattern":"份数[::]\\s*(\\S+)"}, + {"key":"enabled","pattern":"归档[::]\\s*(\\S+)"}, + {"key":"note","pattern":"备注[::]\\s*(\\S+)"}]}`) +} + +// reportCommandSpec 声明参数 schema:count 是 integer、enabled 是 boolean, +// note 未声明——未声明的键不得被塞进 params。 +func reportCommandSpec() agentcommand.CommandSpec { + return agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令", + ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic","mode"], + "properties":{"topic":{"type":"string"},"mode":{"type":"string"}, + "count":{"type":"integer"},"enabled":{"type":"boolean"}}}`)} +} + +// dispatchLog 记录每次真实派发(处理器被调用才算执行)。 +type dispatchLog struct { + mu sync.Mutex + calls []cmdbus.Request + params []map[string]any +} + +func (l *dispatchLog) record(req cmdbus.Request) { + l.mu.Lock() + defer l.mu.Unlock() + copied := map[string]any{} + for k, v := range req.Params { + copied[k] = v + } + l.calls = append(l.calls, req) + l.params = append(l.params, copied) +} + +func (l *dispatchLog) snapshot() []map[string]any { + l.mu.Lock() + defer l.mu.Unlock() + return append([]map[string]any(nil), l.params...) +} + +func (l *dispatchLog) byOwner() map[string]map[string]any { + l.mu.Lock() + defer l.mu.Unlock() + out := map[string]map[string]any{} + for _, req := range l.calls { + out[req.Owner] = req.Params + } + return out +} + +// admissionServer 组装带意图闸门与 demo.report 命令的测试服务器。 +func admissionServer(t *testing.T) (*Server, *dispatchLog) { + t.Helper() + return admissionServerWith(t, reportCommandSpec()) +} + +func admissionServerWith(t *testing.T, spec agentcommand.CommandSpec) (*Server, *dispatchLog) { + t.Helper() + srv, _ := newTestServer(t) + log := &dispatchLog{} + if err := srv.Bus.RegisterBuiltin(spec, "", func(_ context.Context, req cmdbus.Request) (any, error) { + log.record(req) + return map[string]any{"ok": true}, nil + }); err != nil { + t.Fatal(err) + } + gate := intentgate.New() + if err := gate.Rebuild(context.Background(), []json.RawMessage{reportIntent()}); err != nil { + t.Fatal(err) + } + srv.Gate = gate + return srv, log +} + +func clarifyOf(t *testing.T, out map[string]any) map[string]any { + t.Helper() + c, ok := out["clarify"].(map[string]any) + if !ok { + t.Fatalf("响应缺少澄清诉求:%v", out) + } + return c +} + +// 缺必填槽位:不执行业务命令,澄清诉求带正确的提问。 +func TestMissingSlotBlocksCommandAndSurfacesClarification(t *testing.T) { + srv, log := admissionServer(t) + h := srv.Handler() + _, convID := setupConversation(t, h) + code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", + map[string]any{"text": "生成报告 模式:摘要"}) + if code != 200 { + t.Fatalf("status %d body %v", code, out) + } + if _, executed := out["command"]; executed { + t.Fatalf("缺必填槽位不得执行业务命令:%v", out) + } + if out["run"] == nil { + t.Fatalf("未派发命令时应回落普通回合:%v", out) + } + c := clarifyOf(t, out) + if c["question"] != "主题是什么?" { + t.Fatalf("澄清提问:%v", c) + } + if c["command"] != "demo.report" || c["intent"] != "demo.report" { + t.Fatalf("被拦下的动作未标明:%v", c) + } + missing, _ := c["missing"].([]any) + if len(missing) != 1 { + t.Fatalf("缺失槽位明细:%v", c) + } + if m := missing[0].(map[string]any); m["key"] != "topic" || m["question"] != "主题是什么?" { + t.Fatalf("缺失槽位键/提问:%v", m) + } + if _, bad := c["invalid"]; bad { + t.Fatalf("缺失不应混进非法:%v", c) + } + if got := log.snapshot(); len(got) != 0 { + t.Fatalf("命令处理器被调用:%v", got) + } +} + +// 非法枚举值:不执行业务命令,结构化原因原样透出。 +func TestInvalidSlotBlocksCommandWithReason(t *testing.T) { + srv, log := admissionServer(t) + h := srv.Handler() + _, convID := setupConversation(t, h) + code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", + map[string]any{"text": "生成报告 主题:核能 模式:随手写"}) + if code != 200 { + t.Fatalf("status %d body %v", code, out) + } + if _, executed := out["command"]; executed { + t.Fatalf("非法槽位不得执行业务命令:%v", out) + } + c := clarifyOf(t, out) + if c["question"] != "要哪种模式?" { + t.Fatalf("澄清提问:%v", c) + } + invalid, _ := c["invalid"].([]any) + if len(invalid) != 1 { + t.Fatalf("非法槽位明细:%v", c) + } + got := invalid[0].(map[string]any) + if got["key"] != "mode" || got["reason"] != "value not in enum" || got["question"] != "要哪种模式?" { + t.Fatalf("非法原因/提问未透传:%v", got) + } + if got["invalid"] != true { + t.Fatalf("非法标记:%v", got) + } + if _, missing := c["missing"]; missing { + t.Fatalf("非法不应重复计入缺失:%v", c) + } + if len(log.snapshot()) != 0 { + t.Fatalf("命令处理器被调用:%v", log.snapshot()) + } +} + +// 合法槽位:按声明 schema 映射后到达处理器(类型正确、未声明键不复制)。 +func TestValidSlotsReachHandlerMappedPerSchema(t *testing.T) { + srv, log := admissionServer(t) + h := srv.Handler() + _, convID := setupConversation(t, h) + text := "生成报告 主题:核能 模式:摘要 份数:3 归档:true 备注:随便" + code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text}) + if code != 200 || out["command"] != "demo.report" { + t.Fatalf("status %d body %v", code, out) + } + if _, ok := out["clarify"]; ok { + t.Fatalf("完整合法槽位不应带澄清诉求:%v", out) + } + calls := log.snapshot() + if len(calls) != 1 { + t.Fatalf("处理器调用次数:%d", len(calls)) + } + params := calls[0] + if params["topic"] != "核能" || params["mode"] != "摘要" || params["text"] != text { + t.Fatalf("字符串参数映射:%v", params) + } + if params["count"] != int64(3) { + t.Fatalf("integer 参数映射:%#v", params["count"]) + } + if params["enabled"] != true { + t.Fatalf("boolean 参数映射:%#v", params["enabled"]) + } + if _, ok := params["note"]; ok { + t.Fatalf("未声明的槽位不得复制进 params:%v", params) + } + if len(params) != 5 { + t.Fatalf("参数量超出声明:%v", params) + } +} + +// 声明的必填参数无法由原文+槽位满足:不派发,转澄清(不再由总线报 schema 错)。 +func TestDeclaredRequiredParamBlocksDispatch(t *testing.T) { + srv, log := admissionServerWith(t, agentcommand.CommandSpec{ID: "demo.report", Title: "生成报告命令", + ParamsSchema: json.RawMessage(`{"type":"object","required":["text","target"], + "properties":{"topic":{"type":"string"},"target":{"type":"string"}}}`)}) + h := srv.Handler() + _, convID := setupConversation(t, h) + code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", + map[string]any{"text": "生成报告 主题:核能 模式:摘要"}) + if code != 200 { + t.Fatalf("status %d body %v", code, out) + } + if _, executed := out["command"]; executed { + t.Fatalf("必填参数缺失不得执行业务命令:%v", out) + } + c := clarifyOf(t, out) + missing, _ := c["missing"].([]any) + found := false + for _, m := range missing { + if m.(map[string]any)["key"] == "target" { + found = true + } + } + if !found { + t.Fatalf("缺失必填参数未透出:%v", c) + } + if c["reason"] == "" { + t.Fatalf("准入原因缺失:%v", c) + } + if len(log.snapshot()) != 0 { + t.Fatalf("命令处理器被调用:%v", log.snapshot()) + } +} + +// 高危命令仍走 cmdbus 的确认卡收口:接纳通过后创建确认卡,处理器不执行。 +func TestHighRiskCommandStillRequiresConfirmation(t *testing.T) { + srv, log := admissionServer(t) + h := srv.Handler() + gate := intentgate.New() + if err := gate.Rebuild(context.Background(), []json.RawMessage{ + reportIntent(), + json.RawMessage(`{"id":"demo.purge","domain":"demo","version":1, + "matcher":{"includes_any":["清除报告"]}, + "action":{"kind":"command","id":"demo.purge"}, + "slots":[{"key":"topic","required":true,"pattern":"对象[::]\\s*(\\S+)","question":"清除哪个对象?"}]}`), + }); err != nil { + t.Fatal(err) + } + srv.Gate = gate + if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.purge", Title: "清除报告", + Danger: agentcommand.DangerHigh, + ParamsSchema: json.RawMessage(`{"type":"object","required":["text","topic"], + "properties":{"topic":{"type":"string"}}}`)}, "", func(context.Context, cmdbus.Request) (any, error) { + t.Fatalf("高危命令未经确认不得执行处理器") + return nil, nil + }); err != nil { + t.Fatal(err) + } + + // 缺必填槽位时连确认卡都不该出现(业务动作根本没到总线)。 + _, blocked := setupConversation(t, h) + code, out := doJSON(t, h, "POST", "/api/conversations/"+blocked+"/messages", + map[string]any{"text": "清除报告"}) + if code != 200 { + t.Fatalf("status %d body %v", code, out) + } + if _, hasCard := out["card"]; hasCard { + t.Fatalf("槽位未就绪不得创建确认卡:%v", out) + } + if _, executed := out["command"]; executed { + t.Fatalf("槽位未就绪不得执行业务命令:%v", out) + } + + // 槽位齐全:派发到总线,高危收口创建确认卡,处理器仍未执行。 + _, ready := setupConversation(t, h) + code, out = doJSON(t, h, "POST", "/api/conversations/"+ready+"/messages", + map[string]any{"text": "清除报告 对象:旧归档"}) + if code != 200 || out["command"] != "demo.purge" { + t.Fatalf("status %d body %v", code, out) + } + if out["card"] == nil { + t.Fatalf("高危命令必须要求确认:%v", out) + } + if len(log.snapshot()) != 0 { + t.Fatalf("确认前不得派发其他命令:%v", log.snapshot()) + } +} + +// 显式取消命令宿主未实现:如实回落普通回合,不派发同一句里的 Start。 +type cancelGenerator struct{} + +func (cancelGenerator) Generate(context.Context, string, []agentintent.CatalogSpec) ([]agentintent.Command, error) { + return []agentintent.Command{ + {Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"}}, + {Kind: intentgate.CommandCancel, Target: "demo.report"}, + }, nil +} + +func TestUnsupportedCancelCommandFallsBackToTurn(t *testing.T) { + srv, log := admissionServer(t) + h := srv.Handler() + gate, ok := srv.Gate.(*intentgate.Gate) + if !ok { + t.Fatalf("闸门类型:%T", srv.Gate) + } + gate.SetGenerator(cancelGenerator{}) + _, convID := setupConversation(t, h) + code, out := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", + map[string]any{"text": "算了吧 生成报告"}) + if code != 200 { + t.Fatalf("status %d body %v", code, out) + } + if _, executed := out["command"]; executed { + t.Fatalf("未支持的取消命令不得派发业务动作:%v", out) + } + if out["run"] == nil { + t.Fatalf("未支持的库层命令应回落普通回合:%v", out) + } + if len(log.snapshot()) != 0 { + t.Fatalf("命令处理器被调用:%v", log.snapshot()) + } +} + +// 两个租户的槽位上下文互不串味:各自只看到自己那句输入映射出的参数。 +func TestIntentSlotsDoNotLeakAcrossOwners(t *testing.T) { + srv, log := admissionServer(t) + srv.Auth = NewAuthenticator(srv.st, true) + convID := map[string]string{} + for _, owner := range []string{"alice", "bob"} { + if err := srv.st.CreateAccount(owner, owner, "", true); err != nil { + t.Fatal(err) + } + proj, err := srv.st.CreateProject(owner, owner+" 的项目") + if err != nil { + t.Fatal(err) + } + conv, err := srv.st.CreateConversation(owner, proj.ID, owner+" 的会话", "") + if err != nil { + t.Fatal(err) + } + convID[owner] = conv.ID + } + h := srv.Handler() + send := func(owner, text string) map[string]any { + t.Helper() + key, err := srv.st.CreateAPIKey(owner, owner+"-key") + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(map[string]any{"text": text}) + if err != nil { + t.Fatal(err) + } + rec := bearerRequest(h, "POST", "/api/conversations/"+convID[owner]+"/messages", string(body), key) + out := map[string]any{} + if rec.Body.Len() > 0 { + json.Unmarshal(rec.Body.Bytes(), &out) + } + if rec.Code != 200 { + t.Fatalf("%s 发送 %q: status %d body %s", owner, text, rec.Code, rec.Body.String()) + } + return out + } + if out := send("alice", "生成报告 主题:核能 模式:摘要"); out["command"] != "demo.report" { + t.Fatalf("alice 派发:%v", out) + } + if out := send("bob", "生成报告 主题:水利 模式:全文"); out["command"] != "demo.report" { + t.Fatalf("bob 派发:%v", out) + } + if got := log.byOwner(); len(got) != 2 { + t.Fatalf("派发归属:%v", got) + } + byOwner := log.byOwner() + if byOwner["alice"]["topic"] != "核能" || byOwner["alice"]["mode"] != "摘要" { + t.Fatalf("alice 收到别人的槽位:%v", byOwner["alice"]) + } + if byOwner["bob"]["topic"] != "水利" || byOwner["bob"]["mode"] != "全文" { + t.Fatalf("bob 收到别人的槽位:%v", byOwner["bob"]) + } + if byOwner["alice"]["topic"] == byOwner["bob"]["topic"] { + t.Fatalf("两个租户共享了同一份槽位上下文:%v", byOwner) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 0013465..c7de95b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1150,6 +1150,8 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { routeKind := route.KindTurn var recognized intentgate.MatchResult var workflowDef core.WorkflowDef + var admission *intentAdmission + var clarify *clarifyPayload if s.Gate != nil && !strings.HasPrefix(text, "/") && req.TaskRef == nil { recognized = s.recognize(r.Context(), text) dec := route.Decide(recognized, route.Env{ @@ -1164,13 +1166,23 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { }) switch dec.Kind { case route.KindCommand: - routeKind = route.KindCommand - recognized.ID = dec.CommandID - recognized.Slots = dec.Slots + // 接纳:按命令 DECLARED schema 把槽位映射成参数;准入未过 + // (必填参数无法由原文+槽位满足)就不派发,降为澄清回合(F09)。 + adm := admitIntentCommand(s.Bus, dec.CommandID, dec.Slots, text) + clarify = newClarifyPayload(recognized, dec, &adm) + if !adm.Blocked { + routeKind = route.KindCommand + recognized.ID = dec.CommandID + recognized.Slots = dec.Slots + admission = &adm + } case route.KindWorkflow: routeKind = route.KindWorkflow workflowDef = dec.Workflow recognized.Slots = dec.Slots + clarify = newClarifyPayload(recognized, dec, nil) + default: + clarify = newClarifyPayload(recognized, dec, nil) } } claimed := false @@ -1204,8 +1216,14 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { return } if routeKind == route.KindCommand { + // 参数按命令 DECLARED schema 映射(槽位→声明键/声明类型);原文恒供。 + params := map[string]any{chatTextParam: text} + if admission != nil && admission.Params != nil { + params = admission.Params + params[chatTextParam] = text + } resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ - ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner, + ID: recognized.ID, Params: params, Entrance: "chat", Actor: owner, Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID, HostProjectID: string(conv.ProjectID), }) out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": recognized.ID} @@ -1242,7 +1260,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { return } msg.RunID = run.ID - writeJSON(w, http.StatusOK, map[string]any{"message": msg, "run": run}) + out := map[string]any{"message": msg, "run": run} + if clarify != nil { + // 澄清诉求(业务动作未派发):既有端点上的附加字段,缺失/非法各带结构化原因。 + out["clarify"] = clarify + } + writeJSON(w, http.StatusOK, out) } // writeExistingMessage 把同一 requestId 的已有消息和仍在进行的 run 原样返回。 diff --git a/internal/server/workflow_route_test.go b/internal/server/workflow_route_test.go index dc6ec67..9e1419c 100644 --- a/internal/server/workflow_route_test.go +++ b/internal/server/workflow_route_test.go @@ -59,10 +59,14 @@ func TestCommandBusBeatsWorkflow(t *testing.T) { t.Fatal(err) } _, convID := setupConversation(t, srv.Handler()) - code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"}) + // 槽位齐全才派发命令(F09):必填槽位缺失时命令不得执行,改走澄清回合。 + code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "主题:核能 写一份简报"}) if code != 200 || out["command"] != "demo.brief" { t.Fatalf("status %d body %v", code, out) } + if _, ok := out["clarify"]; ok { + t.Fatalf("complete slots must not clarify: %v", out) + } if _, ok := out["run"]; ok { t.Fatal("a command hit must not start a workflow run") } From e8d0c9bd2f687cb2406b5d3fcaf34f99bcb2bbc3 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:34:13 +0000 Subject: [PATCH 4/8] Bump the nested agent-command pin to the U7A receipt fix Why: U7B needs unknown-with-trusted-operation-ID receipts and the executed-vs-not result distinction landed by U7A. Evidence: pin only; library verified in deps/agent-command (verify-u7a). Impact: nested gitlink pointer change. Refs: feature/agent-stack-audit-remediation-b1 --- deps/agent-command | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/agent-command b/deps/agent-command index 4f583ca..fe7987d 160000 --- a/deps/agent-command +++ b/deps/agent-command @@ -1 +1 @@ -Subproject commit 4f583ca3eeeb07a56a714c5086de84dcd6ad2980 +Subproject commit fe7987dc03e96d59705a547e2099cdea13affcd2 From 4030449f4b542c1a080800ac733ea8e2fc08517f Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 16:55:01 +0000 Subject: [PATCH 5/8] Report honest receipts when handlers err after side effects Why: F10 - any handler error became a no-side-effect rejected receipt and an audit-store failure dropped the generated operation ID, losing the reconciliation handle and inviting unsafe retries. Evidence: cmdbus tests pass (verify-u7b.json); post-execution-unknown, audit-failure-keeps-ID and confirmation-idempotency cases added. Impact: unknown receipts now carry the trusted operation ID via the U7A constructor; rejected stays ID-free; no auto-replay anywhere. Refs: feature/agent-stack-audit-remediation-b1 U7B --- internal/cmdbus/account_write_fence_test.go | 46 ++++ internal/cmdbus/cmdbus.go | 187 ++++++++++++- internal/cmdbus/cmdbus_test.go | 259 +++++++++++++++++- internal/cmdbus/pending_store_test.go | 34 +++ internal/server/operation_query.go | 87 ++++++ internal/server/operation_query_test.go | 96 +++++++ .../server/project_iam_integration_test.go | 7 +- internal/server/server.go | 6 + 8 files changed, 703 insertions(+), 19 deletions(-) create mode 100644 internal/server/operation_query.go create mode 100644 internal/server/operation_query_test.go diff --git a/internal/cmdbus/account_write_fence_test.go b/internal/cmdbus/account_write_fence_test.go index 6f38692..dec5d8f 100644 --- a/internal/cmdbus/account_write_fence_test.go +++ b/internal/cmdbus/account_write_fence_test.go @@ -2,7 +2,10 @@ package cmdbus import ( "context" + "errors" + "strings" "testing" + "time" agentcommand "agent-command" ) @@ -16,3 +19,46 @@ func TestStrictAccountWritesRejectLegacyBusPorts(t *testing.T) { t.Fatalf("legacy port wrote under strict mode: %+v, cards=%d audit=%d pending=%d", resp.Receipt, len(cards.created), len(audit.logs), len(b.pending)) } } + +// fencedAuditStore 是"账号围栏内审计落盘失败"的替身:围栏写路径已经跑完, +// 只有审计行写不进去(围栏内审计失败=结果不确定,不是"什么都没发生")。 +type fencedAuditStore struct{ appends int } + +func (f *fencedAuditStore) AppendAudit(string, agentcommand.AuditEnvelope) error { + f.appends++ + return errors.New("audit fenced") +} + +func (f *fencedAuditStore) AppendAuditAccountChecked(string, agentcommand.AuditEnvelope, bool) error { + f.appends++ + return errors.New("audit fenced") +} + +// TestStrictAccountWriteAuditLossKeepsOperationID:围栏档下 handler 已提交、 +// 审计写失败时,回执仍是 unknown 且带上 handler 拿到的 operationID, +// 调用方才能在围栏外按记录对账(F10 Bug 2 在围栏路径上的同一语义)。 +func TestStrictAccountWriteAuditLossKeepsOperationID(t *testing.T) { + audit := &fencedAuditStore{} + b := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + b.RequireAccountCheckedWrites(true) + var seen string + if err := b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.fenced", Title: "围栏写入"}, "", func(_ context.Context, req Request) (any, error) { + seen = req.OperationID + return "written", nil + }); err != nil { + t.Fatal(err) + } + resp := b.Dispatch(context.Background(), Request{ID: "demo.fenced", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") { + t.Fatalf("fenced audit loss must stay unknown: %+v", resp.Receipt) + } + if seen == "" || resp.Receipt.OperationID != seen { + t.Fatalf("fenced audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil { + t.Fatalf("fenced receipt must declare possible side effects and validate: %+v", resp.Receipt) + } + if audit.appends != 1 { + t.Fatalf("account-checked audit appends = %d, want 1", audit.appends) + } +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index f3d388c..76fe912 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -26,6 +26,43 @@ type AuditStore interface { AppendAudit(owner string, env agentcommand.AuditEnvelope) error } +// OperationRecord 是"已记录操作"的最小对账投影(R7/KTD6)。它回答的是 +// “这个 operationID 在本 owner/project 名下到底是什么结果”,而不是重放入口。 +// 刻意保持最小:审计信封只存状态与标识,不存理由文本,所以这里没有 Reason—— +// 不为不存在的字段留位置。 +type OperationRecord struct { + OperationID string + CommandID string + State agentcommand.ResultState + Time time.Time +} + +// OperationQuery 是 operationID 的对账查询端口(组合根装配;nil = 不提供)。 +// +// 授权口径:owner 与 projectID 都是可信过滤条件,实现必须把它们放进查询谓词 +// (SQL 的 owner/project 等值条件),不得由调用方参数扩大可见范围;查不到就 +// 返回 not found,不得回退为“未执行”。 +// +// 诚实边界:本端口只解析“已经落成记录”的 operation。宿主侧的命令回执记录就是 +// 命令审计信封(command_audit 按 owner+project 归属);handler 自己的领域操作表 +// (如文档投递 document_render_deliveries)由各自领域查询负责,不经本端口。某些 +// 命令根本不持久化 req.OperationID(内置的 builtin.help、runtime.mock、 +// models.set、conversation.profile、conversation.create 都是无操作表的即时用例), +// 对它们的 operationID 查询恒为 not found——这类命令只能按领域资源(会话、画像、 +// 设置项)对账,不得声称可按 operationID 查询。 +type OperationQuery interface { + LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) +} + +var ( + // ErrOperationScope 缺少可信 owner/project/operation 作用域:fail-closed,无作用域即无可见性。 + ErrOperationScope = errors.New("cmdbus: operation lookup requires owner, project and operation scope") + // ErrOperationNotRecorded 该作用域内没有此 operation 的记录(未记录 ≠ 未执行)。 + ErrOperationNotRecorded = errors.New("cmdbus: no operation record in this owner and project scope") + // ErrOperationQueryUnavailable 组合根未装配对账查询端口。 + ErrOperationQueryUnavailable = errors.New("cmdbus: operation query unavailable") +) + // CardPort 确认卡所需卡片能力(cardsvc.Service 实现)。 type CardPort interface { Create(owner string, card core.ActivityCard, ref string) (core.ActivityCard, error) @@ -35,6 +72,39 @@ type CardPort interface { // Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。 type Handler func(ctx context.Context, req Request) (any, error) +// ErrNotExecuted 标记"失败确定发生在任何副作用之前"的 handler 错误(F10/U7b)。 +// 语义口径:只有带此标记的错误才可归 rejected(确定未执行、无副作用、回执不得 +// 携带 operationID);未标记的 handler 错误一律按"已执行随后不确定"归 unknown, +// 并带上本次分发已签发的 trusted operation ID 交由对账收敛。宿主不得据 +// "处理方报了错"臆断未执行——那会把已写库的操作诱使成不安全重试。 +// +// 进程内插件(pkg/extension.Handler)当前还没有等价的声明位:插件命令的 +// handler 错误一律按 unknown 对账,直到扩展面补上对应标记为止。 +var ErrNotExecuted = errors.New("cmdbus: not executed") + +// NotExecutedError 是带"确定未执行"声明的错误包装。Error() 保留原始文案 +// (回执 reason 不因标记而变形),Unwrap/Is 让 errors.Is 同时匹配 +// ErrNotExecuted 与被包装的原始错误。 +type NotExecutedError struct{ Err error } + +func (e *NotExecutedError) Error() string { return e.Err.Error() } +func (e *NotExecutedError) Unwrap() error { return e.Err } +func (e *NotExecutedError) Is(target error) bool { + return target == ErrNotExecuted +} + +// NotExecuted 声明该失败确定未执行(校验/权限/确认缺失/领域事务回滚等任何 +// 副作用之前的拒绝)。nil 原样返回;重复标记幂等。 +func NotExecuted(err error) error { + if err == nil || IsNotExecuted(err) { + return err + } + return &NotExecutedError{Err: err} +} + +// IsNotExecuted 报告该错误是否声明了"确定未执行"。 +func IsNotExecuted(err error) bool { return errors.Is(err, ErrNotExecuted) } + // CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。 // Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。 type CommandTool struct { @@ -171,6 +241,8 @@ type Bus struct { strictAccountWrites bool allowImplicitLocal bool projectWorkPreflight func(context.Context, Request) error + // operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。 + operationQuery OperationQuery } func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) { @@ -200,6 +272,33 @@ func (b *Bus) Registry() *agentcommand.Registry { return b.reg } // SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。 func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st } +// SetOperationQuery 装配 operationID 对账查询端口(组合根;启动期一次)。 +func (b *Bus) SetOperationQuery(q OperationQuery) { b.operationQuery = q } + +// LookupOperation 按 owner/project 作用域查一个已记录 operation 的当前状态 +// (R7/KTD6)。这是 unknown 回执的对账入口:拿到 operationID 的调用方先查 +// 记录,再决定下一步,而不是重发命令。 +// +// 三重 fail-closed:作用域任一为空即拒(不猜、不按 owner=“”放行)、未装配端口 +// 即拒、查不到即 ErrOperationNotRecorded(不推断“未执行”)。跨 owner 查询由 +// 端口的 owner 等值谓词拒绝——本层只把 owner 当可信条件往下传。 +func (b *Bus) LookupOperation(owner, projectID, operationID string) (OperationRecord, error) { + if owner == "" || projectID == "" || operationID == "" { + return OperationRecord{}, ErrOperationScope + } + if b.operationQuery == nil { + return OperationRecord{}, ErrOperationQueryUnavailable + } + record, found, err := b.operationQuery.LookupOperation(owner, projectID, operationID) + if err != nil { + return OperationRecord{}, err + } + if !found || record.OperationID == "" { + return OperationRecord{}, ErrOperationNotRecorded + } + return record, nil +} + // SetEffectiveAccountGuard installs the composition-root check at startup. // Handler Store writes still need their own final transaction fence. func (b *Bus) SetEffectiveAccountGuard(g EffectiveAccountGuard) { b.accountGuard = g } @@ -405,10 +504,12 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { if b.pendingStore != nil { raw, _ := json.Marshal(req) if err := b.savePending(card.ID, accountActor(req), string(raw)); err != nil { - return receipt(agentcommand.StateUnknown, "", "confirm pending: "+err.Error()) + // 确认卡已创建(副作用已发生),挂起态却没落库:结果不确定,且 + // 卡 ID 就是本次受理的操作标识——随 unknown 一并给出,供对账。 + return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: "+err.Error()) } } else if b.strictAccountWrites { - return receipt(agentcommand.StateUnknown, "", "confirm pending: account-checked store required") + return receipt(agentcommand.StateUnknown, card.ID, "confirm pending: account-checked store required") } b.pendMu.Lock() b.pending[card.ID] = pending{req: req, cardID: card.ID} @@ -425,7 +526,9 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { } // The domain handler can persist this trusted ID with its transaction/outbox. // Entropy, not the clock or caller Params, distinguishes concurrent operations. - // A handler failure still writes only a rejected audit receipt without an ID. + // A handler error keeps the ID only on the "executed then uncertain" path + // (unknown): rejected must never carry one, and unknown without a queryable + // handle would be a dead end for reconciliation. var opBytes [24]byte if _, err := rand.Read(opBytes[:]); err != nil { return receipt(agentcommand.StateUnknown, "", "operation ID unavailable") @@ -433,11 +536,26 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { req.OperationID = "op_" + hex.EncodeToString(opBytes[:]) payload, err := h(ctx, req) if err != nil { - return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) + if IsNotExecuted(err) { + // handler 声明失败发生在任何副作用之前:确定未执行,可安全归 + // rejected(回执契约禁止 rejected 携带 operationID)。 + return receipt(agentcommand.StateRejected, "", "rejected: "+err.Error()) + } + // handler 已开始执行才报错:宿主无法断言"无副作用"(F10)。按"已执行 + // 随后不确定"归 unknown,并带上本次分发已签发的 trusted operation ID, + // 调用方据此按记录对账,而不是盲目重试。本路径不做任何自动重放。 + return b.unknownAfterExecution(req, "handler failed after execution began: "+err.Error(), start) } return Response{Receipt: b.auditReceipt(req, agentcommand.StateCommitted, req.OperationID, "", start), Payload: payload} } +// unknownAfterExecution 记录并返回"已执行随后不确定"的回执(F10/U7b):审计 +// 信封带 state=unknown 与已签发的 operationID(可查凭证),回执走库内受信 +// 构造器 NewUnknownAfterExecution,保证 Validate() 通过且对账句柄不丢。 +func (b *Bus) unknownAfterExecution(req Request, reason string, start time.Time) Response { + return Response{Receipt: b.auditReceipt(req, agentcommand.StateUnknown, req.OperationID, reason, start)} +} + // Confirm 确认卡回道:actionID=confirm 执行挂起请求(entrance 归一 card),其余视为取消。 func (b *Bus) Confirm(owner, cardID, actionID string) Response { return b.ConfirmContext(context.Background(), owner, cardID, actionID) @@ -456,7 +574,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string p, ok := b.pending[cardID] b.pendMu.Unlock() if ok && p.req.Owner != "" && p.req.Owner != owner { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: p.req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } if b.pendingStore != nil { storedOwner, payload, found, err := b.takePending(cardID, owner) @@ -466,7 +584,7 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string if found { var req Request if storedOwner != owner || json.Unmarshal([]byte(payload), &req) != nil { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "invalid pending confirm", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } p = pending{req: req, cardID: cardID} ok = true @@ -477,11 +595,11 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string ok = false } if !ok { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } req := p.req if req.Owner != "" && req.Owner != owner { - return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", Time: b.now().UTC()}} + return Response{Receipt: agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateRejected, Reason: "confirm owner mismatch", SideEffects: agentcommand.SideEffectsNone, Time: b.now().UTC()}} } b.pendMu.Lock() delete(b.pending, cardID) @@ -497,15 +615,29 @@ func (b *Bus) ConfirmContext(ctx context.Context, owner, cardID, actionID string } resp := b.Dispatch(ctx, req) if b.cards != nil { - if resp.Receipt.State == agentcommand.StateCommitted { + switch resp.Receipt.State { + case agentcommand.StateCommitted: _, _ = b.setConfirmCardStatus(owner, cardID, core.CardResolved, "ok", "confirmed") - } else { + case agentcommand.StateUnknown: + // 已执行随后不确定:卡片必须落到终态(悬在 action_required 会诱导 + // 二次确认,而挂起项已被消费,重放只会得到"已解决"),但不得断言 + // 失败——tone 用 warn,note 写明不确定原因与对账用 operationID。 + _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "warn", uncertainNote(resp.Receipt)) + default: _, _ = b.setConfirmCardStatus(owner, cardID, core.CardFailed, "danger", resp.Receipt.Reason) } } return resp } +// uncertainNote 组成"结果未知"卡片的说明:不确定原因 + 对账句柄。 +func uncertainNote(r agentcommand.ExecReceipt) string { + if r.OperationID == "" { + return r.Reason + } + return r.Reason + " (operation " + r.OperationID + ")" +} + func accountActor(req Request) string { // Plugin and delegated commands use Actor for an audit label such as a // plugin ID; Owner is the effective account whose state controls effects. @@ -528,7 +660,7 @@ func (b *Bus) checkEffectiveAccount(ctx context.Context, actor string) error { func (b *Bus) accountRejected(commandID string, start time.Time) Response { return Response{Receipt: agentcommand.ExecReceipt{ CommandID: commandID, State: agentcommand.StateRejected, - Reason: "effective account unavailable", Time: start.UTC(), + Reason: "effective account unavailable", SideEffects: agentcommand.SideEffectsNone, Time: start.UTC(), }} } @@ -555,10 +687,37 @@ func (b *Bus) auditReceipt(req Request, state agentcommand.ResultState, op, reas err = b.audit.AppendAudit(req.Owner, env) } if err != nil { - // 审计不可用:结果标记未知,不臆断成功(四态语义)。 - return agentcommand.ExecReceipt{CommandID: req.ID, State: agentcommand.StateUnknown, Reason: "audit unavailable: " + err.Error(), Time: b.now().UTC()} + // 审计不可用:结果标记未知,不臆断成功(四态语义)。已经签发过操作标识的 + // 路径(committed/accepted/已执行后不确定)连同该标识一并返回——信封丢失 + // 不等于操作没发生,调用方仍需按 operationID 对账;确定未执行的路径不带 + // 标识(rejected 契约禁止带 ID,unknown 无 ID 时也无需对账)。 + return unknownReceipt(req.ID, state, op, "audit unavailable: "+err.Error(), b.now().UTC()) } - return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, Time: b.now().UTC()} + if state == agentcommand.StateUnknown && op != "" { + // 已执行随后不确定:只有库内受信构造器允许 unknown 携带本流程签发的 ID。 + return agentcommand.NewUnknownAfterExecution(req.ID, op, reason, b.now().UTC()) + } + return agentcommand.ExecReceipt{CommandID: req.ID, State: state, OperationID: op, Reason: reason, + SideEffects: sideEffectsFor(state), Time: b.now().UTC()} +} + +// unknownReceipt 构造"结果未知"回执:已签发过操作标识时走受信构造器携带该标识 +// (供对账),否则返回不带标识的 unknown。state=rejected 恒不带标识。 +func unknownReceipt(commandID string, state agentcommand.ResultState, op, reason string, at time.Time) agentcommand.ExecReceipt { + if op != "" && state != agentcommand.StateRejected { + return agentcommand.NewUnknownAfterExecution(commandID, op, reason, at) + } + return agentcommand.ExecReceipt{CommandID: commandID, State: agentcommand.StateUnknown, Reason: reason, Time: at} +} + +// sideEffectsFor 回执的副作用声明:总线自身发起的 rejected 均为任何副作用之前 +// 的拒绝(未知命令/参数校验/项目前置/账号预检/无处理器/确认取消/handler 声明的 +// not-executed),故断言 none;committed/accepted 维持今日语义(未声明)。 +func sideEffectsFor(state agentcommand.ResultState) agentcommand.SideEffects { + if state == agentcommand.StateRejected { + return agentcommand.SideEffectsNone + } + return agentcommand.SideEffectsUnset } func digest(params map[string]any) string { diff --git a/internal/cmdbus/cmdbus_test.go b/internal/cmdbus/cmdbus_test.go index e3c37bb..3991cea 100644 --- a/internal/cmdbus/cmdbus_test.go +++ b/internal/cmdbus/cmdbus_test.go @@ -52,7 +52,8 @@ func newTestBus(a AuditStore, c CardPort) *Bus { }, "/risky", func(ctx context.Context, req Request) (any, error) { t, _ := req.Params["target"].(string) if t == "boom" { - return nil, errors.New("handler rejected") + // 参数级拒绝发生在任何副作用之前:显式声明 not-executed(F10)。 + return nil, NotExecuted(errors.New("handler rejected")) } return map[string]any{"did": t}, nil }) @@ -106,13 +107,20 @@ func TestHandlerReceivesCommittedOperationIDBeforeDomainWrite(t *testing.T) { if req.OperationID == "" { t.Fatal("handler must receive a tentative host operation ID") } - return nil, errors.New("domain transaction rejected") + // 领域事务回滚 = 确定未执行:声明 not-executed 才是 rejected 的依据。 + return nil, NotExecuted(errors.New("domain transaction rejected")) }); err != nil { t.Fatal(err) } failed := bus.Dispatch(context.Background(), Request{ID: "rdm.fail", Actor: "alice", Owner: "alice"}) if failed.Receipt.State != agentcommand.StateRejected || failed.Receipt.OperationID != "" || len(audit.logs) != 2 || audit.logs[1].OperationID != "" { - t.Fatalf("failed handler was marked committed: %+v audits=%+v", failed.Receipt, audit.logs) + t.Fatalf("not-executed handler was not rejected: %+v audits=%+v", failed.Receipt, audit.logs) + } + if err := failed.Receipt.Validate(); err != nil { + t.Fatalf("rejected receipt must satisfy the library contract: %v", err) + } + if failed.Receipt.SideEffects != agentcommand.SideEffectsNone { + t.Fatalf("rejected receipt must declare no side effects, got %q", failed.Receipt.SideEffects) } } @@ -342,3 +350,248 @@ func TestHighDangerPluginCommandConfirmsFirst(t *testing.T) { t.Fatalf("confirm: %+v executed=%d", resp2.Receipt, executed) } } + +// ---- F10/U7b:确定未执行 vs 已执行后不确定 + operationID 对账 ---- + +// memOperationQuery 是按 owner/project 等值谓词过滤的操作记录替身(模拟 SQL +// 作用域):跨 owner 或跨 project 的 operationID 一律查不到。 +type memOperationQuery struct { + records map[string]OperationRecord // key: owner|projectID|operationID +} + +func operationKey(owner, projectID, operationID string) string { + return owner + "|" + projectID + "|" + operationID +} + +func (q *memOperationQuery) LookupOperation(owner, projectID, operationID string) (OperationRecord, bool, error) { + rec, ok := q.records[operationKey(owner, projectID, operationID)] + return rec, ok, nil +} + +// recordAudit 把总线真实落下的审计信封登记为可查操作记录(对齐"审计信封就是 +// 宿主侧操作记录"的口径),用于验证 unknown 回执给出的对账句柄真的查得到。 +func (q *memOperationQuery) recordAudit(owner, projectID string, env agentcommand.AuditEnvelope) { + if env.OperationID == "" { + return + } + q.records[operationKey(owner, projectID, env.OperationID)] = OperationRecord{ + OperationID: env.OperationID, CommandID: env.CommandID, State: env.State, Time: env.Time, + } +} + +// TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID 是 F10 的核心回归: +// handler 已经写库、之后才失败时,回执必须是 unknown 且携带它拿到的 +// operationID,理由点明"执行开始后失败",绝不退化成 rejected(会诱发不安全重试)。 +func TestHandlerErrorAfterStoreWriteIsUnknownWithOperationID(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + var writes []string + var seen string + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(_ context.Context, req Request) (any, error) { + seen = req.OperationID + writes = append(writes, req.OperationID) // 副作用已发生 + return nil, errors.New("commit acknowledgement lost") + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown { + t.Fatalf("post-execution failure must not be reported as rejected: %+v", resp.Receipt) + } + if seen == "" || resp.Receipt.OperationID != seen { + t.Fatalf("unknown receipt lost the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + } + if !strings.Contains(resp.Receipt.Reason, "after execution began") { + t.Fatalf("reason must name post-execution uncertainty: %q", resp.Receipt.Reason) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible { + t.Fatalf("side effects = %q, want %q", resp.Receipt.SideEffects, agentcommand.SideEffectsPossible) + } + if err := resp.Receipt.Validate(); err != nil { + t.Fatalf("trusted unknown receipt must satisfy the library contract: %v", err) + } + if len(writes) != 1 { + t.Fatalf("handler ran %d times, want 1", len(writes)) + } + // 对账凭证:审计信封落 unknown + 同一个 operationID。 + if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != seen { + t.Fatalf("audit envelope is not reconcilable: %+v", audit.logs) + } + // 宿主不得自动重放:再次分发是一次新操作(新的 operationID),旧的不被复用。 + again := bus.Dispatch(context.Background(), Request{ID: "demo.write", Actor: "alice", Owner: "alice"}) + if again.Receipt.State != agentcommand.StateUnknown || again.Receipt.OperationID == resp.Receipt.OperationID || len(writes) != 2 { + t.Fatalf("re-dispatch must be a new operation, not a replay: %+v writes=%d", again.Receipt, len(writes)) + } +} + +// TestHandlerNotExecutedErrorStaysRejected:handler 显式声明未执行才是 +// rejected 的依据;回执不得携带 operationID(库契约)。 +func TestHandlerNotExecutedErrorStaysRejected(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + calls := 0 + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.guard", Title: "守卫"}, "", func(context.Context, Request) (any, error) { + calls++ + boom := errors.New("permission denied") + return nil, NotExecuted(boom) + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.guard", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" { + t.Fatalf("not-executed failure must stay rejected without an ID: %+v", resp.Receipt) + } + if resp.Receipt.Reason != "rejected: permission denied" { + t.Fatalf("not-executed marking must not distort the reason: %q", resp.Receipt.Reason) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || resp.Receipt.Validate() != nil { + t.Fatalf("rejected receipt must declare no side effects and validate: %+v", resp.Receipt) + } + if calls != 1 || len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" { + t.Fatalf("unexpected effects: calls=%d audits=%+v", calls, audit.logs) + } + // 标记语义:errors.Is 同时命中 ErrNotExecuted 与原始错误;nil 幂等。 + wrapped := NotExecuted(errors.New("boom")) + if !IsNotExecuted(wrapped) || !errors.Is(wrapped, ErrNotExecuted) || wrapped.Error() != "boom" { + t.Fatalf("not-executed wrapper semantics: %v / %v", wrapped, IsNotExecuted(wrapped)) + } + if NotExecuted(nil) != nil || !IsNotExecuted(NotExecuted(wrapped)) { + t.Fatal("not-executed marking must be nil-safe and idempotent") + } +} + +// TestCommittedWithAuditFailureKeepsOperationID 是 F10 Bug 2 的回归:审计 +// 落盘失败时回执仍是 unknown,但必须带着已交给 handler 的 operationID, +// 理由点名审计不可用,调用方才能按记录对账而不是重发。 +func TestCommittedWithAuditFailureKeepsOperationID(t *testing.T) { + audit := &memAudit{fail: true} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + var seen string + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "", func(_ context.Context, req Request) (any, error) { + seen = req.OperationID + return map[string]any{"pong": true}, nil + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") { + t.Fatalf("audit loss must stay unknown and name the audit: %+v", resp.Receipt) + } + if seen == "" || resp.Receipt.OperationID != seen { + t.Fatalf("audit loss dropped the reconciliation handle: receipt=%q handler=%q", resp.Receipt.OperationID, seen) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsPossible || resp.Receipt.Validate() != nil { + t.Fatalf("audit-loss receipt must declare possible side effects and validate: %+v", resp.Receipt) + } + if len(audit.logs) != 0 { + t.Fatalf("audit rows written while failing: %+v", audit.logs) + } + // 校验:未执行路径(rejected)在审计不可用时不得凭空出现 operationID。 + rejected := bus.Dispatch(context.Background(), Request{ID: "demo.absent"}) + if rejected.Receipt.OperationID != "" || rejected.Receipt.State != agentcommand.StateUnknown { + t.Fatalf("audit loss must not invent an operation ID: %+v", rejected.Receipt) + } +} + +// TestValidationRejectionNeverRunsHandler:显式校验拒绝仍是 rejected、无 ID、 +// 无副作用,且 handler 一次都不许跑。 +func TestValidationRejectionNeverRunsHandler(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + calls := 0 + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ + ID: "demo.needs", Title: "需要参数", + ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`), + }, "", func(context.Context, Request) (any, error) { + calls++ + return "ran", nil + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.needs", Owner: "alice", Params: map[string]any{}}) + if resp.Receipt.State != agentcommand.StateRejected || resp.Receipt.OperationID != "" || resp.Receipt.Validate() != nil { + t.Fatalf("validation rejection: %+v", resp.Receipt) + } + if resp.Receipt.SideEffects != agentcommand.SideEffectsNone || calls != 0 { + t.Fatalf("validation rejection ran the handler or claimed effects: calls=%d %+v", calls, resp.Receipt) + } + if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateRejected || audit.logs[0].OperationID != "" { + t.Fatalf("validation audit row: %+v", audit.logs) + } +} + +// TestRepeatedConfirmExecutesOnce:确认卡重复确认不得二次执行(F10 分类改动 +// 不得放宽既有收口语义);unknown 卡面也不得悬在待确认。 +func TestRepeatedConfirmExecutesOnce(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + bus := New(audit, cards, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + executed := 0 + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ + ID: "demo.once", Title: "只执行一次", Danger: agentcommand.DangerHigh, + }, "/once", func(context.Context, Request) (any, error) { + executed++ + return "done", nil + }); err != nil { + t.Fatal(err) + } + if r := bus.Dispatch(context.Background(), Request{ID: "demo.once", Owner: "alice", ConversationID: "cnv_1"}); r.Receipt.State != agentcommand.StateAccepted { + t.Fatalf("pre-confirm: %+v", r.Receipt) + } + first := bus.Confirm("alice", "card_x1", "confirm") + second := bus.Confirm("alice", "card_x1", "confirm") + if first.Receipt.State != agentcommand.StateCommitted || executed != 1 { + t.Fatalf("confirm: %+v executed=%d", first.Receipt, executed) + } + if second.Receipt.State != agentcommand.StateRejected || second.Receipt.OperationID != "" || executed != 1 { + t.Fatalf("re-confirm executed twice: %+v executed=%d", second.Receipt, executed) + } +} + +// TestLookupOperationIsOwnerProjectScoped(R7/KTD6):unknown 回执的对账查询 +// 复用 owner/project 等值作用域;跨 owner、跨 project、缺作用域、未装配端口 +// 一律 fail-closed,查不到绝不推断"未执行"。 +func TestLookupOperationIsOwnerProjectScoped(t *testing.T) { + audit := &memAudit{} + query := &memOperationQuery{records: map[string]OperationRecord{}} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + if _, err := bus.LookupOperation("alice", "proj_1", "op_1"); !errors.Is(err, ErrOperationQueryUnavailable) { + t.Fatalf("unwired query must fail closed: %v", err) + } + bus.SetOperationQuery(query) + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, "", func(context.Context, Request) (any, error) { + // 副作用之后失败 → unknown + operationID(对账句柄)。 + return nil, errors.New("ack lost") + }); err != nil { + t.Fatal(err) + } + resp := bus.Dispatch(context.Background(), Request{ID: "demo.write", Owner: "alice", Actor: "alice"}) + if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" { + t.Fatalf("setup dispatch: %+v", resp.Receipt) + } + // 对账记录=总线真实落下的审计信封(同一 owner/project 快照)。 + for _, env := range audit.logs { + query.recordAudit("alice", "proj_1", env) + } + op := resp.Receipt.OperationID + record, err := bus.LookupOperation("alice", "proj_1", op) + if err != nil { + t.Fatalf("owner-scoped lookup: %v", err) + } + if record.OperationID != op || record.CommandID != "demo.write" || record.State != agentcommand.StateUnknown { + t.Fatalf("lookup returned the wrong record: %+v", record) + } + if _, err := bus.LookupOperation("bob", "proj_1", op); !errors.Is(err, ErrOperationNotRecorded) { + t.Fatalf("cross-owner lookup must not resolve: %v", err) + } + if _, err := bus.LookupOperation("alice", "proj_other", op); !errors.Is(err, ErrOperationNotRecorded) { + t.Fatalf("cross-project lookup must not resolve: %v", err) + } + for _, scope := range [][3]string{{"", "proj_1", op}, {"alice", "", op}, {"alice", "proj_1", ""}} { + if _, err := bus.LookupOperation(scope[0], scope[1], scope[2]); !errors.Is(err, ErrOperationScope) { + t.Fatalf("scope %v must fail closed: %v", scope, err) + } + } + if _, err := bus.LookupOperation("bob", "proj_1", "op_absent"); !errors.Is(err, ErrOperationNotRecorded) { + t.Fatalf("unknown operation must not be reported as executed: %v", err) + } +} diff --git a/internal/cmdbus/pending_store_test.go b/internal/cmdbus/pending_store_test.go index e515208..ff6d9cf 100644 --- a/internal/cmdbus/pending_store_test.go +++ b/internal/cmdbus/pending_store_test.go @@ -2,7 +2,9 @@ package cmdbus import ( "context" + "errors" "path/filepath" + "strings" "testing" agentcommand "agent-command" @@ -31,3 +33,35 @@ func TestPendingConfirmRestoredAfterRestart(t *testing.T) { t.Fatalf("restored confirm: %+v", again.Receipt) } } + +// failingPendingStore 是"确认卡已创建、挂起态写不进去"的替身:受理已经发生 +// (卡在收件箱里),只是结果不可知。 +type failingPendingStore struct{ err error } + +func (p *failingPendingStore) SavePendingConfirm(string, string, string) error { return p.err } +func (p *failingPendingStore) TakePendingConfirm(string) (string, string, bool, error) { + return "", "", false, p.err +} + +// TestConfirmPendingWriteFailureKeepsCardOperationID:挂起态落盘失败不得吞掉操作 +// 标识——卡 ID 就是本次受理的句柄,unknown 必须带出去(否则受理事实无凭证)。 +func TestConfirmPendingWriteFailureKeepsCardOperationID(t *testing.T) { + audit, cards := &memAudit{}, &memCards{} + b := newTestBus(audit, cards) + b.SetPendingStore(&failingPendingStore{err: errors.New("pending store down")}) + resp := b.Dispatch(context.Background(), Request{ + ID: "demo.risky", Params: map[string]any{"target": "prod"}, ConversationID: "cnv_1", Owner: "alice", + }) + if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "confirm pending") { + t.Fatalf("pending write failure: %+v", resp.Receipt) + } + if resp.Receipt.OperationID != "card_x1" || resp.Receipt.Validate() != nil { + t.Fatalf("pending write failure dropped the card operation handle: %+v", resp.Receipt) + } + if len(cards.created) != 1 || len(b.pending) != 0 { + t.Fatalf("card created=%d pending=%d", len(cards.created), len(b.pending)) + } + if len(audit.logs) != 1 || audit.logs[0].State != agentcommand.StateUnknown || audit.logs[0].OperationID != "card_x1" { + t.Fatalf("audit envelope: %+v", audit.logs) + } +} diff --git a/internal/server/operation_query.go b/internal/server/operation_query.go new file mode 100644 index 0000000..8816424 --- /dev/null +++ b/internal/server/operation_query.go @@ -0,0 +1,87 @@ +package server + +import ( + "errors" + "net/http" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" +) + +// commandOperationQuery 把"已记录 operation 的状态查询"落在既有的 owner+project +// 作用域审计查询上(store.ListProjectCommandAudits),不新建通用幂等平台:不加表、 +// 不加索引、不引入新的重试语义。命令回执(审计信封)本就是宿主侧的操作记录, +// handler 自己事务/发件箱里的领域操作表(如文档投递 document_render_deliveries) +// 仍由各自领域查询负责。 +// +// 诚实边界: +// - 只有成功落下审计信封的 operation 查得到。审计不可用时总线回 unknown + +// operationID,但那一行没有落库:这里返回"无记录",绝不推断"未执行"。 +// - 扫描有界(最近若干页):超出窗口的旧操作返回无记录,而不是全表扫描。 +// - project_id 是追加时按可信会话快照下来的归属;改绑/删除会话不改写历史归属, +// 与既有项目审计查询同一口径。 +type commandOperationQuery struct{ st *store.Store } + +const ( + operationLookupPageSize = 100 + operationLookupMaxPages = 20 // 有界扫描:最近 2000 行,超窗即"无记录" +) + +func (q commandOperationQuery) LookupOperation(owner, projectID, operationID string) (cmdbus.OperationRecord, bool, error) { + if q.st == nil || owner == "" || projectID == "" || operationID == "" { + return cmdbus.OperationRecord{}, false, nil + } + var before, cutoff int64 + for page := 0; page < operationLookupMaxPages; page++ { + rows, resolvedCutoff, next, err := q.st.ListProjectCommandAudits(owner, projectID, before, cutoff, operationLookupPageSize) + if err != nil { + return cmdbus.OperationRecord{}, false, err + } + cutoff = resolvedCutoff // 首帧固定游标,后续页在同一提交前缀内翻页 + for _, row := range rows { + if row.Envelope.OperationID != operationID { + continue + } + return cmdbus.OperationRecord{ + OperationID: row.Envelope.OperationID, + CommandID: row.Envelope.CommandID, + State: row.Envelope.State, + Time: row.Envelope.Time, + }, true, nil + } + if next == 0 { + return cmdbus.OperationRecord{}, false, nil + } + before = next + } + return cmdbus.OperationRecord{}, false, nil +} + +// getCommandOperation 是 unknown 回执的对账入口(R7/KTD6): +// GET /api/commands/operations/{operationId}?projectId=… +// +// 授权复用既有口径:owner 只取自认证上下文(不读请求参数),projectId 仅用于 +// 收窄到该 owner 名下的项目——与 /api/commands/audit 同源,跨租户天然不可见。 +// 查不到一律 404:HTTP 层的"没有记录"同样不得被读成"未执行"。 +func (s *Server) getCommandOperation(w http.ResponseWriter, r *http.Request) { + if s.Bus == nil { + http.Error(w, "commands disabled", http.StatusServiceUnavailable) + return + } + record, err := s.Bus.LookupOperation(s.owner(r), r.URL.Query().Get("projectId"), r.PathValue("operationId")) + switch { + case errors.Is(err, cmdbus.ErrOperationScope): + http.Error(w, "projectId and operationId are required", http.StatusBadRequest) + return + case errors.Is(err, cmdbus.ErrOperationQueryUnavailable): + http.Error(w, "operation query unavailable", http.StatusServiceUnavailable) + return + case errors.Is(err, cmdbus.ErrOperationNotRecorded): + http.Error(w, "no operation record in this scope", http.StatusNotFound) + return + case err != nil: + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"operation": record}) +} diff --git a/internal/server/operation_query_test.go b/internal/server/operation_query_test.go new file mode 100644 index 0000000..e51be2c --- /dev/null +++ b/internal/server/operation_query_test.go @@ -0,0 +1,96 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" +) + +// TestCommandOperationLookupIsOwnerProjectScoped 覆盖 U7b/R7 的对账查询: +// unknown 回执给出的 operationID 能按认证 owner + project 查到落库记录; +// 跨 owner、错 project、缺 projectId 一律查不到或拒(fail-closed),任何 +// "查不到"都不得被读成"未执行"。 +func TestCommandOperationLookupIsOwnerProjectScoped(t *testing.T) { + srv, st := newAuthTestServer(t) + srv.Bus = cmdbus.New(st, nil, time.Now) + srv.WireBuiltinCommands() // 生产装配点:总线拿到宿主侧对账查询端口 + h := srv.Handler() + + aliceKey, err := createLegacyAPIKeyFixture(t, st, "alice", "e2e") + if err != nil { + t.Fatal(err) + } + bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "e2e") + if err != nil { + t.Fatal(err) + } + project, err := st.CreateProject("alice", "对账项目") + if err != nil { + t.Fatal(err) + } + conversation, err := st.CreateConversation("alice", project.ID, "对账会话", "") + if err != nil { + t.Fatal(err) + } + + // 写库之后才失败的命令:unknown + operationID(审计信封按会话快照落项目)。 + if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "demo.write", Title: "写入"}, + func(context.Context, cmdbus.Request) (any, error) { return nil, errors.New("ack lost") }); err != nil { + t.Fatal(err) + } + resp := srv.Bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "demo.write", Owner: "alice", Actor: "alice", ConversationID: conversation.ID, + }) + if resp.Receipt.State != agentcommand.StateUnknown || resp.Receipt.OperationID == "" { + t.Fatalf("post-execution failure must be unknown with an operation ID: %+v", resp.Receipt) + } + operationID := resp.Receipt.OperationID + + get := func(key, url string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + rec := get(aliceKey, "/api/commands/operations/"+operationID+"?projectId="+project.ID) + if rec.Code != http.StatusOK { + t.Fatalf("owner lookup: %d %s", rec.Code, rec.Body.String()) + } + var out struct { + Operation struct { + OperationID string `json:"OperationID"` + CommandID string `json:"CommandID"` + State string `json:"State"` + } `json:"operation"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Operation.OperationID != operationID || out.Operation.CommandID != "demo.write" || out.Operation.State != string(agentcommand.StateUnknown) { + t.Fatalf("lookup projection wrong: %+v", out.Operation) + } + + for _, tc := range []struct { + name, key, url string + want int + }{ + {"cross owner", bobKey, "/api/commands/operations/" + operationID + "?projectId=" + project.ID, http.StatusNotFound}, + {"wrong project", aliceKey, "/api/commands/operations/" + operationID + "?projectId=prj_other", http.StatusNotFound}, + {"missing project", aliceKey, "/api/commands/operations/" + operationID, http.StatusBadRequest}, + {"missing operation", aliceKey, "/api/commands/operations/op_absent?projectId=" + project.ID, http.StatusNotFound}, + } { + if rec := get(tc.key, tc.url); rec.Code != tc.want { + t.Fatalf("%s: %d %s (want %d)", tc.name, rec.Code, rec.Body.String(), tc.want) + } + } +} diff --git a/internal/server/project_iam_integration_test.go b/internal/server/project_iam_integration_test.go index d810e78..58b9c8c 100644 --- a/internal/server/project_iam_integration_test.go +++ b/internal/server/project_iam_integration_test.go @@ -266,10 +266,13 @@ func newIAMFixture(t *testing.T) *iamFixture { member, _ := req.Params["member"].(string) err := f.provider.addMember(ctx, srv.lifecycleHost(req.Owner), projectID, member) if failure, ok := categorizeWorkFailure(extension.WorkOperationMemberAdd, err); ok { - return nil, failure + // 假 provider 的每条失败路径都发生在改成员之前(唯一写点在最后一行 + // 且随后返回 nil),因此显式声明 not-executed:拒绝语义与 4xx 映射 + // 由此成立,而不是靠“报了错就算没执行”(F10)。 + return nil, cmdbus.NotExecuted(failure) } if err != nil { - return nil, err + return nil, cmdbus.NotExecuted(err) } return map[string]any{"ok": true}, nil }); err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index c7de95b..a4075f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -197,11 +197,14 @@ func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchRes // WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。 // 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。 +// 组合根在注入 Bus 之后调用一次(启动期);此处也是 operationID 对账查询端口的 +// 装配点:总线只在启动时刻拿到宿主端口,不在请求路径上改写总线状态。 func (s *Server) WireBuiltinCommands() { b := s.Bus if b == nil { return } + b.SetOperationQuery(commandOperationQuery{st: s.st}) must := func(err error) { if err != nil { panic("server: builtin command: " + err.Error()) // 装配期错误属编程错误 @@ -421,6 +424,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/workspaces", s.listWorkspaces) mux.HandleFunc("POST /api/commands/{id}", s.execCommand) mux.HandleFunc("GET /api/commands/audit", s.listAudits) + // U7b/R7:operationID 对账查询(unknown 回执的收敛入口;owner 取自认证 + // 上下文,projectId 只收窄作用域)。 + mux.HandleFunc("GET /api/commands/operations/{operationId}", s.getCommandOperation) mux.HandleFunc("GET /api/parsers", s.listParsers) mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge) mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge) From 49c1259e00cc68d140ef7b6d073f267cd0c59132 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 17:35:53 +0000 Subject: [PATCH 6/8] Keep the server admission test inside the intent boundary Why: the U6 admission test imported agent-intent directly from internal/server, which lint-boundaries forbids (only intentgate, intentsem and the squad intent adapter may import it). Evidence: repair receipt success; server/intentgate/route tests pass (verify-u6r.json); fixtures moved behind intentgate helpers. Impact: test-only rework plus a small intentgate fixture helper. Refs: feature/agent-stack-audit-remediation-b1 U6 repair --- internal/intentgate/gate.go | 11 +++++++---- internal/server/e2e_test.go | 2 +- internal/server/intent_admission_test.go | 7 ++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go index de95c66..f9bd0e1 100644 --- a/internal/intentgate/gate.go +++ b/internal/intentgate/gate.go @@ -55,11 +55,14 @@ type MatchResult struct { // Complete 报告槽位是否齐全且全部通过权威校验:false 时业务动作不得派发。 func (m MatchResult) Complete() bool { return len(m.Missing) == 0 && len(m.Invalid) == 0 } -// 命令类型经闸门投影(别名):生态库按边界规则只许本包导入, -// Router/runmgr 等消费方只依赖本包表面。 +// 命令与目录类型经闸门投影(别名):生态库按边界规则只许本包导入, +// Router/runmgr 等消费方只依赖本包表面。CatalogSpec/CatalogSlot 随生成器端口 +// (SetGenerator 的入参)投影,实现生成器不必越过本包直接引用生态库。 type ( - Command = agentintent.Command - ActionRef = agentintent.ActionRef + Command = agentintent.Command + ActionRef = agentintent.ActionRef + CatalogSpec = agentintent.CatalogSpec + CatalogSlot = agentintent.CatalogSlot ) const ( diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index e1b1e0a..6ee4b6f 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -946,7 +946,7 @@ func TestStaticServed(t *testing.T) { _ = io.Discard } -// TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(agent-intent 闸门)。 +// TestHITLIntentPivot:装载插件意图后,自由输入命中其他意图 → 转轨(intentgate 闸门)。 func TestHITLIntentPivot(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() diff --git a/internal/server/intent_admission_test.go b/internal/server/intent_admission_test.go index 981737b..d2d6bba 100644 --- a/internal/server/intent_admission_test.go +++ b/internal/server/intent_admission_test.go @@ -7,7 +7,6 @@ import ( "testing" agentcommand "agent-command" - agentintent "agent-intent/intent" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/intentgate" ) @@ -303,8 +302,10 @@ func TestHighRiskCommandStillRequiresConfirmation(t *testing.T) { // 显式取消命令宿主未实现:如实回落普通回合,不派发同一句里的 Start。 type cancelGenerator struct{} -func (cancelGenerator) Generate(context.Context, string, []agentintent.CatalogSpec) ([]agentintent.Command, error) { - return []agentintent.Command{ +// 生成器只经 intentgate 表面接线:目录与命令类型都由闸门投影, +// 宿主实现不必(也不应)直接导入生态库。 +func (cancelGenerator) Generate(context.Context, string, []intentgate.CatalogSpec) ([]intentgate.Command, error) { + return []intentgate.Command{ {Kind: intentgate.CommandStart, Action: intentgate.ActionRef{Kind: intentgate.ActionCommand, ID: "demo.report"}}, {Kind: intentgate.CommandCancel, Target: "demo.report"}, }, nil From f8a190801c348b858d596a2e44e0fb0a4c6092ac Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 17:46:17 +0000 Subject: [PATCH 7/8] Pin nested agent-native and agent-squad to the batch-1 fixes Why: composition consistency - Dev pins agent-native at the U3A tip and agent-squad at the U3B/U4 tip; the backend container's nested copies must match (check-pins). Evidence: pin only; all libraries verified per-unit. Impact: nested gitlink pointer changes. Refs: feature/agent-stack-audit-remediation-b1 --- deps/agent-native | 2 +- deps/agent-squad | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deps/agent-native b/deps/agent-native index 2fc66be..ac91b84 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 2fc66be65201232ea38ba64d457efad5565d869d +Subproject commit ac91b8436ea9f5c80861bb4b1b1534ede0b9918f diff --git a/deps/agent-squad b/deps/agent-squad index c7121bd..cdd51c8 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit c7121bda2a681dc46078350f7929e56e142db57f +Subproject commit cdd51c86789364554df0df37582aa95876161866 From 1f61131e72a61d0f2fddbbafb91eed31d54414a2 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 21 Oct 2026 18:06:17 +0000 Subject: [PATCH 8/8] Pin the nested agent-squad to the sqlite test alignment Why: composition consistency - the squad container advanced to the U3B repair tip; backend's nested copy must match (check-pins). Evidence: pin only; squad store tests pass (verify-u3br2). Impact: nested gitlink pointer change. Refs: feature/agent-stack-audit-remediation-b1 --- deps/agent-squad | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/agent-squad b/deps/agent-squad index cdd51c8..0f1daa8 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit cdd51c86789364554df0df37582aa95876161866 +Subproject commit 0f1daa8b89b158cc4a0fe85d33050f48cedf129a