diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go index feb602a..079d10e 100644 --- a/cmd/agent-system/main.go +++ b/cmd/agent-system/main.go @@ -15,6 +15,7 @@ import ( "agent-system-backend/internal/app" "agent-system-backend/internal/config" "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" "agent-system-backend/pkg/identity" ) @@ -73,7 +74,19 @@ func main() { pgIdleSec := fs.Int("pg-conn-max-idle-sec", 0, "PG 空闲连接回收秒数(0=默认 300)") pgLifetimeSec := fs.Int("pg-conn-max-lifetime-sec", 0, "PG 连接最长生命周期秒数(0=不限)") pgQueryTimeoutSec := fs.Int("pg-query-timeout-sec", 0, "PG statement_timeout 秒数(0=不限;覆盖全部业务查询)") + extension.RegisterCLIFlags(fs) fs.Parse(os.Args[1:]) + handled, hookErr := extension.RunCLIHooks(extension.Options{ + Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock, + StorageDSN: *dsn, AuthMode: *auth, DisableExecTools: *noExec, ExecBackend: *execBackend, + }) + if hookErr != nil { + fmt.Fprintln(os.Stderr, "agent-system:", hookErr) + os.Exit(1) + } + if handled { + return + } cliSet := visited(fs) resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{ @@ -94,11 +107,36 @@ func main() { } os.Exit(1) } + pluginDefaults, merr := extension.MergeHostOptions(extension.Options{}) + if merr != nil { + fmt.Fprintln(os.Stderr, "agent-system:", merr) + os.Exit(1) + } + disableExec := resolved.DisableExecTools + if !cliSet["disableExecTools"] { + disableExec = disableExec || pluginDefaults.DisableExecTools + } + execBackendResolved := resolved.ExecBackend + if !cliSet["execBackend"] && execBackendResolved == "" { + execBackendResolved = pluginDefaults.ExecBackend + } + extOpt := extension.Options{ + Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, + DisableUploadIndexing: pluginDefaults.DisableUploadIndexing, + DisableConvCardMirror: pluginDefaults.DisableConvCardMirror, + DisableExecTools: disableExec, ExecBackend: execBackendResolved, + } + if err := extension.PrepareCompiled(&extOpt); err != nil { + fmt.Fprintln(os.Stderr, "agent-system:", err) + os.Exit(1) + } if err := app.Run(app.Options{ Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock, StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode, InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(), - DisableExecTools: resolved.DisableExecTools, ExecBackend: resolved.ExecBackend, ExecDepsDir: resolved.ExecDepsDir, + DisableUploadIndexing: extOpt.DisableUploadIndexing, + DisableConvCardMirror: extOpt.DisableConvCardMirror, + DisableExecTools: extOpt.DisableExecTools, ExecBackend: extOpt.ExecBackend, ExecDepsDir: resolved.ExecDepsDir, ExecWorkspace: resolved.ExecWorkspace, Tier: resolved.Tier, PGPool: store.PoolOptions{ diff --git a/internal/server/run_process_events_test.go b/internal/server/run_process_events_test.go index c879d6d..d65527a 100644 --- a/internal/server/run_process_events_test.go +++ b/internal/server/run_process_events_test.go @@ -70,8 +70,24 @@ func TestRunProcessEventsAreOrderedAndBoundToConversation(t *testing.T) { t.Fatalf("child page: %d %v", code, childPage) } childRows := childPage["events"].([]any) - if len(childRows) != 2 || childRows[0].(map[string]any)["type"] != string(core.EvAgentProgress) || childRows[1].(map[string]any)["type"] != string(core.EvAgentResult) { - t.Fatalf("child progress/result not retained or raw child output leaked: %v", childRows) + var childTypes []string + for _, row := range childRows { + ev := row.(map[string]any) + typ, _ := ev["type"].(string) + if typ == string(core.EvToolOutput) { + payload, _ := ev["payload"].(map[string]any) + if payload["text"] == "child raw output" { + t.Fatalf("raw child output leaked: %v", childRows) + } + } + if typ == string(core.EvAgentProgress) || typ == string(core.EvAgentResult) { + childTypes = append(childTypes, typ) + } + } + // 父运行的终态事件可能在 waitRunTerminal 之后才落库,序号会落在这次追加的 + // 工具输出和子运行事件之间。这里只要求子运行进度与结果按序保留。 + if len(childTypes) != 2 || childTypes[0] != string(core.EvAgentProgress) || childTypes[1] != string(core.EvAgentResult) { + t.Fatalf("child progress/result not retained: types=%v rows=%v", childTypes, childRows) } code, _ = doJSON(t, h, http.MethodGet, "/api/conversations/"+otherConvID+"/runs/"+runID+"/events", nil) if code != http.StatusNotFound { diff --git a/internal/server/server.go b/internal/server/server.go index c183f7c..94e2f77 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2045,10 +2045,15 @@ func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) { // registry 统一注册表视图(来源标记 + 按来源启停入口)。 func (s *Server) registry(w http.ResponseWriter, r *http.Request) { + compiledIDs := make([]string, 0, len(s.CompiledPlugins)) + for _, cp := range s.CompiledPlugins { + compiledIDs = append(compiledIDs, cp.ID) + } reg := map[string]any{ - "tools": s.visibleTools(s.owner(r)), - "parsers": ingest.ParsersDescription(), - "panels": flattenPanels(s.plugins.List()), + "tools": s.visibleTools(s.owner(r)), + "parsers": ingest.ParsersDescription(), + "panels": flattenPanels(s.plugins.List()), + "compiled": compiledIDs, } writeJSON(w, http.StatusOK, reg) } diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index bbfe1d1..f28df47 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -6,6 +6,8 @@ import ( "context" "encoding/json" "errors" + "flag" + "fmt" "sync" core "agent-system-core" @@ -36,7 +38,9 @@ import ( // ResourceAuthzQuery.Source for per-document publication authorization. // 2.13.0-experimental: optional AccountDirectoryProvider and trusted in-process // AccountDirectoryRegistrar for scoped, current account candidate decisions. -const SurfaceVersion = "2.13.0-experimental" +// 2.14.0-experimental:InProc.HostOptions / CLIHook,以及 MergeHostOptions、 +// RegisterCLIFlags、RunCLIHooks(组合二进制把插件声明并进基座启动,不覆盖显式参数)。 +const SurfaceVersion = "2.14.0-experimental" // ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") @@ -164,10 +168,24 @@ type IntentSpec struct { Description string `json:"description,omitempty"` } +// CLIHook 让编译插件在基座 serve 旗标集上登记自己的旗标,并在解析后决定是否接管本次进程。 +// 插件只登记自己的旗标,不重复基座已有旗标。基座在 flag.Parse 前调用 Register,解析后调用 AfterParse。 +type CLIHook struct { + Register func(fs *flag.FlagSet) + AfterParse func(opt Options) (handled bool, err error) +} + // InProc 是随下游二进制编译进来的扩展。 type InProc struct { ID string Register func(reg Registry) error + // HostOptions 可选。组合二进制把它当作启动默认值:布尔开关按“任一为 true 则为 true” + // 合并,且不覆盖调用方显式传入的参数。未设置则无产品默认。 + HostOptions func() Options + // CLI 可选。组合二进制在基座 serve 旗标上挂接插件专属旗标(如 bootstrap-admin)。 + CLI *CLIHook + // Prepare 可选。基座合并 HostOptions 之后、进入服务之前调用,可改写 opt 或拒绝启动。 + Prepare func(opt *Options) error } // Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。 @@ -203,6 +221,80 @@ var ( runner func(Options) error ) +// MergeHostOptions 把已登记插件的 HostOptions 合并进 base。 +// 布尔开关按“任一为 true 则为 true”。ExecBackend 取首个非空值,彼此冲突则返回错误。 +// Addr、DataDir、PluginsDir、StorageDSN、AuthMode 等运行参数不从插件合并,避免覆盖调用方。 +func MergeHostOptions(base Options) (Options, error) { + for _, e := range Compiled() { + if e.HostOptions == nil { + continue + } + opt := e.HostOptions() + if opt.DisableUploadIndexing { + base.DisableUploadIndexing = true + } + if opt.DisableConvCardMirror { + base.DisableConvCardMirror = true + } + if opt.DisableExecTools { + base.DisableExecTools = true + } + if opt.ExecBackend == "" { + continue + } + if base.ExecBackend != "" && base.ExecBackend != opt.ExecBackend { + return base, fmt.Errorf("extension: plugin %s exec backend %q conflicts with %q", e.ID, opt.ExecBackend, base.ExecBackend) + } + if base.ExecBackend == "" { + base.ExecBackend = opt.ExecBackend + } + } + return base, nil +} + +// RegisterCLIFlags 让每个已登记插件往 fs 登记自己的旗标。须在 Parse 之前调用。 +func RegisterCLIFlags(fs *flag.FlagSet) { + if fs == nil { + return + } + for _, e := range Compiled() { + if e.CLI != nil && e.CLI.Register != nil { + e.CLI.Register(fs) + } + } +} + +// RunCLIHooks 在旗标解析之后依次调用插件 AfterParse。 +// 第一个 handled 的插件接管本次进程,基座不应再进入服务循环。 +func RunCLIHooks(opt Options) (bool, error) { + for _, e := range Compiled() { + if e.CLI == nil || e.CLI.AfterParse == nil { + continue + } + handled, err := e.CLI.AfterParse(opt) + if err != nil || handled { + return handled, err + } + } + return false, nil +} + +// PrepareCompiled 在 HostOptions 合并进 opt 之后调用各插件 Prepare。任一错误拒绝启动。 +func PrepareCompiled(opt *Options) error { + if opt == nil { + return errors.New("extension: nil options") + } + for _, e := range Compiled() { + if e.Prepare == nil { + continue + } + if err := e.Prepare(opt); err != nil { + return fmt.Errorf("extension: %s: %w", e.ID, err) + } + } + return nil +} + // RegisterCompiled 在 init 中登记进程内扩展。 func RegisterCompiled(e InProc) { mu.Lock() diff --git a/pkg/extension/hostoptions_test.go b/pkg/extension/hostoptions_test.go new file mode 100644 index 0000000..cabbd02 --- /dev/null +++ b/pkg/extension/hostoptions_test.go @@ -0,0 +1,111 @@ +package extension + +import ( + "errors" + "flag" + "testing" +) + +func TestMergeHostOptionsORAndExecConflict(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + RegisterCompiled(InProc{ + ID: "a", + HostOptions: func() Options { + return Options{DisableUploadIndexing: true, ExecBackend: "sandbox"} + }, + }) + RegisterCompiled(InProc{ + ID: "b", + HostOptions: func() Options { + return Options{DisableExecTools: true, DisableConvCardMirror: true} + }, + }) + got, err := MergeHostOptions(Options{Addr: "127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + if !got.DisableUploadIndexing || !got.DisableExecTools || !got.DisableConvCardMirror { + t.Fatalf("bool merge: %+v", got) + } + if got.ExecBackend != "sandbox" || got.Addr != "127.0.0.1:1" { + t.Fatalf("kept caller addr and first exec backend: %+v", got) + } + RegisterCompiled(InProc{ + ID: "c", + HostOptions: func() Options { return Options{ExecBackend: "direct"} }, + }) + if _, err := MergeHostOptions(Options{}); err == nil { + t.Fatal("expected exec backend conflict") + } +} + +func TestPrepareCompiledCanRejectStartup(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + RegisterCompiled(InProc{ + ID: "rdm", + Prepare: func(opt *Options) error { + if opt.ExecBackend == "sandbox" { + return errors.New("no bwrap") + } + opt.DisableExecTools = true + return nil + }, + }) + opt := Options{ExecBackend: "sandbox"} + if err := PrepareCompiled(&opt); err == nil { + t.Fatal("expected prepare error") + } + opt.ExecBackend = "direct" + if err := PrepareCompiled(&opt); err != nil { + t.Fatal(err) + } + if !opt.DisableExecTools { + t.Fatal("prepare should be able to rewrite options") + } +} + +func TestCLIHookHandlesBeforeServe(t *testing.T) { + ResetCompiled() + t.Cleanup(ResetCompiled) + var admin *string + RegisterCompiled(InProc{ + ID: "rdm", + CLI: &CLIHook{ + Register: func(fs *flag.FlagSet) { + admin = fs.String("bootstrap-admin", "", "owner") + }, + AfterParse: func(opt Options) (bool, error) { + if admin == nil || *admin == "" { + return false, nil + } + if opt.DataDir == "" { + return true, errors.New("missing data") + } + return true, nil + }, + }, + }) + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + RegisterCLIFlags(fs) + if err := fs.Parse([]string{"-bootstrap-admin", "owner"}); err != nil { + t.Fatal(err) + } + if fs.Lookup("data") != nil { + t.Fatal("plugin must not redefine base flags") + } + handled, err := RunCLIHooks(Options{DataDir: "/tmp/data"}) + if err != nil || !handled { + t.Fatalf("handled=%v err=%v", handled, err) + } + fs2 := flag.NewFlagSet("serve", flag.ContinueOnError) + RegisterCLIFlags(fs2) + if err := fs2.Parse(nil); err != nil { + t.Fatal(err) + } + handled, err = RunCLIHooks(Options{DataDir: "/tmp/data"}) + if err != nil || handled { + t.Fatalf("empty flag should not handle: handled=%v err=%v", handled, err) + } +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index ca7118a..3fe18e7 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -25,6 +25,7 @@ AccountSearcher.SearchAccounts AdminProvider AdminProvider.IsAdmin AdminZoneSpec +CLIHook Compiled DelegatedProjectProjectionHost DelegatedProjectProjectionHost.ProjectProjectionForCreator @@ -50,9 +51,11 @@ HostClient InProc IntentSpec Main +MergeHostOptions NewHostClient NewScopedHost Options +PrepareCompiled ProjectHost ProjectHost.EnsureProject ProjectHost.OwnedProject @@ -64,6 +67,7 @@ ProjectHost.RenameOwnedProject ProjectLifecycle ProjectLifecycleRegistry ProjectLifecycleRegistry.RegisterProjectLifecycle +RegisterCLIFlags RegisterCompiled Registry Registry.AddAdminZone @@ -79,6 +83,7 @@ ResetCompiled ResourceAuthorizer ResourceAuthorizerV2 ResourceAuthzQuery +RunCLIHooks SetRunner SetScopedHostFactory SurfaceHost