Add compiled-plugin host defaults and CLI hooks #201
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user