Add compiled-plugin host defaults and CLI hooks #201

Merged
weichen merged 5 commits from feature/svc-plugin-host-options into main 2026-10-15 03:54:16 +00:00
6 changed files with 274 additions and 7 deletions
+39 -1
View File
@@ -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{
+18 -2
View File
@@ -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 {
+8 -3
View File
@@ -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)
}
+93 -1
View File
@@ -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()
+111
View File
@@ -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)
}
}
+5
View File
@@ -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