diff --git a/deps/agent-system-core b/deps/agent-system-core index d437530..fdc0c1b 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit d4375300cde71320cdd2a012304603efe9eb5cf9 +Subproject commit fdc0c1b933abc0f2025b71fa9da33625be288fde diff --git a/internal/app/app.go b/internal/app/app.go index 001ae40..3a9429b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,9 +4,12 @@ package app import ( "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "log" + "net" "net/http" "os" "os/signal" @@ -16,6 +19,10 @@ import ( "syscall" "time" + "error-code-system/errcode" + "logging-system/logsys" + "logging-system/sink" + agentllm "agent-native/runtime/llm" agentmcp "agent-native/runtime/mcp" agenttools "agent-native/runtime/tools" @@ -23,6 +30,7 @@ import ( "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/hostapi" "agent-system-backend/internal/intentgate" "agent-system-backend/internal/pgbus" "agent-system-backend/internal/plugins" @@ -53,6 +61,11 @@ type Options struct { // Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。 func Run(opt Options) error { + logger := logsys.New(logsys.Options{ + Service: "agent-system", Module: "app", Level: logsys.LevelInfo, + Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}}, + }) + logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir) var st *store.Store var err error if opt.StorageDSN != "" { @@ -127,6 +140,7 @@ func Run(opt Options) error { // 小队的 agent-native 内核工厂(组合根装配,逐次运行时从 settings 解析): // 模型走 settings.models.chat 同一路由;搜索/解释器/MCP/技能根复用产品配置; // 委派桥接产品 SpawnSubagent。主模型未配置时返回未启用(不阻塞产品)。 + var pluginHost *plugins.Host squadadapter.AgentNativeOptions = func(rc squadadapter.Ctx) (squad.AgentNativeConfig, bool) { effNow := setSvc.Effective(store.OwnerDefault, "") route := runtime.ResolveChatModel(core.AgentProfile{}, effNow) @@ -149,7 +163,11 @@ func Run(opt Options) error { if st2, err := os.Stat(skillsRoot); err == nil && st2.IsDir() { cfg.SkillsRoots = []string{skillsRoot} } - if mgr := mcpManagerFor(strings.TrimSpace(effNow.GetString("tools.mcp.servers", ""))); mgr != nil { + rawMCP := strings.TrimSpace(effNow.GetString("tools.mcp.servers", "")) + if pluginHost != nil { + rawMCP = mergeMCPServers(rawMCP, pluginHost.MCPServers()) + } + if mgr := mcpManagerFor(rawMCP); mgr != nil { cfg.MCP = mgr } return cfg, true @@ -174,17 +192,31 @@ func Run(opt Options) error { gate := intentgate.New() mgr.SetGate(gate) - pluginHost := plugins.New(st, reg, setSvc, opt.PluginsDir) + pluginHost = plugins.New(st, reg, setSvc, opt.PluginsDir) + pluginHost.SetSkillsRoot(skillsRoot) + hostTok := make([]byte, 16) + _, _ = rand.Read(hostTok) + hostSrv := &hostapi.Server{Token: hex.EncodeToString(hostTok), Store: st} + hostLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("host api: %w", err) + } + defer hostLn.Close() + go func() { _ = http.Serve(hostLn, hostSrv.Handler()) }() + pluginHost.SetHostAPI("http://"+hostLn.Addr().String(), hostSrv.Token) pluginHost.OnIntents = func(overlay []json.RawMessage) error { return gate.Rebuild(context.Background(), overlay) } - for id, err := range pluginHost.LoadDir() { - if err != nil { - log.Printf("[plugins] %s: %v", id, err) // fail-closed:单个失败不阻断启动 + for id, perr := range pluginHost.LoadDir() { + if perr != nil { + log.Printf("[plugins] %s: %s: %v", id, errcode.SysUnavailable, perr) // fail-closed:单个失败不阻断启动 + logger.Error("plugin load failed", perr, "plugin", id, "code", string(errcode.SysUnavailable)) } } // 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。 pipe := rag.NewPipeline(st, setSvc, nil, nil) - pipe.SetEmbedder(rag.NewHTTPEmbedder(pipe.EmbeddingRoute)) + pipe.SetEmbedder(rag.SwitchingEmbedder{HTTP: rag.NewHTTPEmbedder(pipe.EmbeddingRoute), Provider: func() string { + return setSvc.Effective(store.OwnerDefault, "").GetString("models.embedding.provider", "") + }}) pipe.SetReranker(rag.NewHTTPReranker(pipe.RerankRoute)) mgr.SetRetriever(pipe) mgr.SetIndexer(pipe) @@ -197,7 +229,38 @@ func Run(opt Options) error { // 命令总线(提案 T3/D27/D30):四入口同源分发 + 审计 + 危险内联确认卡。 bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() }) + bus.SetPendingStore(st) pluginHost.OnCommands = bus.RegisterPluginCommands + applyCompiled(skillsRoot, bus) + mgr.OnUsage = func(owner string, tokens int) { + budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0)) + status, aerr := st.AccountTokens(owner, tokens, budget) + if aerr != nil { + logger.Error("llm budget", aerr, "code", string(errcode.SysInternal)) + return + } + if status.Over { + _ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: "llm.budget_exceeded", Evidence: fmt.Sprintf(`{"total":%d,"budget":%d}`, status.Total, budget)}) + logger.Warn("llm daily budget exceeded", "owner", owner, "total", status.Total, "budget", budget) + } + } + hostSrv.Dispatch = func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error { + resp := bus.Dispatch(ctx, cmdbus.Request{ID: commandID, Params: params, Entrance: "api", Actor: pluginID, Owner: owner}) + if resp.Receipt.State == "rejected" || resp.Receipt.State == "unknown" { + return fmt.Errorf("%s", resp.Receipt.Reason) + } + return nil + } + hostSrv.Search = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) { + return pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{Text: text}) + } + hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) { + route := runtime.ResolveChatModel(core.AgentProfile{}, setSvc.Effective(owner, "")) + if route.Model == "" || route.Base == "" { + return "", fmt.Errorf("llm route not configured") + } + return completeChat(ctx, route.Base, route.Key, route.Model, prompt) + } srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost) srv.Cards = cardSvc diff --git a/internal/app/compiled.go b/internal/app/compiled.go new file mode 100644 index 0000000..f35d656 --- /dev/null +++ b/internal/app/compiled.go @@ -0,0 +1,76 @@ +package app + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +type compiledRegistry struct { + skillsRoot string + bus *cmdbus.Bus +} + +func (r compiledRegistry) AddSkill(name, markdown string) error { + if r.skillsRoot == "" || name == "" { + return nil + } + dest := filepath.Join(r.skillsRoot, "compiled", name) + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dest, "SKILL.md"), []byte(markdown), 0o644) +} + +func (r compiledRegistry) AddCommand(spec json.RawMessage) error { + if r.bus == nil { + return nil + } + return r.bus.RegisterPluginCommands([]json.RawMessage{spec}) +} + +func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error { + log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload)) + return nil +} + +func applyCompiled(skillsRoot string, bus *cmdbus.Bus) { + reg := compiledRegistry{skillsRoot: skillsRoot, bus: bus} + for _, e := range extension.Compiled() { + if e.Register == nil { + continue + } + if err := e.Register(reg); err != nil { + log.Printf("extension: %s: %v", e.ID, err) + } + } +} + +func mergeMCPServers(raw string, extra []core.PluginMCPServer) string { + var base []json.RawMessage + if raw != "" { + if err := json.Unmarshal([]byte(raw), &base); err != nil { + return raw + } + } + for _, s := range extra { + b, err := json.Marshal(s) + if err != nil { + continue + } + base = append(base, b) + } + if len(base) == 0 { + return "" + } + out, err := json.Marshal(base) + if err != nil { + return raw + } + return string(out) +} diff --git a/internal/app/llmproxy.go b/internal/app/llmproxy.go new file mode 100644 index 0000000..1b130ab --- /dev/null +++ b/internal/app/llmproxy.go @@ -0,0 +1,60 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +func completeChat(ctx context.Context, base, key, model, prompt string) (string, error) { + body, _ := json.Marshal(map[string]any{ + "model": model, + "messages": []map[string]string{ + {"role": "user", "content": prompt}, + }, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + if key != "" { + req.Header.Set("Authorization", "Bearer "+key) + } + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return "", fmt.Errorf("llm %d: %s", resp.StatusCode, truncate(string(raw), 200)) + } + var out struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + if len(out.Choices) == 0 { + return "", fmt.Errorf("llm: empty choices") + } + return out.Choices[0].Message.Content, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index b9a5bf1..20c6fee 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -57,14 +57,21 @@ type pending struct { } type Bus struct { - reg *agentcommand.Registry - audit AuditStore - cards CardPort - now func() time.Time - handlers map[string]Handler - slash map[string]string // "/x" → command id - pendMu sync.Mutex - pending map[string]pending + reg *agentcommand.Registry + audit AuditStore + cards CardPort + now func() time.Time + handlers map[string]Handler + slash map[string]string // "/x" → command id + pendMu sync.Mutex + pending map[string]pending + pendingStore PendingStore +} + +// PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。 +type PendingStore interface { + SavePendingConfirm(cardID, owner, payload string) error + TakePendingConfirm(cardID string) (owner, payload string, ok bool, err error) } func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus { @@ -80,6 +87,9 @@ func New(audit AuditStore, cards CardPort, now func() time.Time) *Bus { // Registry 暴露注册表(列清单/插件 overlay 注册用)。 func (b *Bus) Registry() *agentcommand.Registry { return b.reg } +// SetPendingStore 注入确认挂起态的持久化实现(nil = 仅进程内)。 +func (b *Bus) SetPendingStore(st PendingStore) { b.pendingStore = st } + // RegisterBuiltin 登记内置命令并绑定宿主处理器与 `/` 别名。 func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Handler) error { spec.Binding = agentcommand.Binding{Kind: agentcommand.BindBuiltin} @@ -159,6 +169,10 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { b.pendMu.Lock() b.pending[card.ID] = pending{req: req, cardID: card.ID} b.pendMu.Unlock() + if b.pendingStore != nil { + raw, _ := json.Marshal(req) + _ = b.pendingStore.SavePendingConfirm(card.ID, req.Owner, string(raw)) + } r := b.auditReceipt(req, agentcommand.StateAccepted, card.ID, "awaiting explicit confirm", start) return Response{Receipt: r, Card: &card} } @@ -182,6 +196,19 @@ func (b *Bus) Confirm(owner, cardID, actionID string) Response { delete(b.pending, cardID) } b.pendMu.Unlock() + if ok && actionID == "confirm" && b.pendingStore != nil { + _, _, _, _ = b.pendingStore.TakePendingConfirm(cardID) + } + if !ok && b.pendingStore != nil { + _, payload, found, err := b.pendingStore.TakePendingConfirm(cardID) + if err == nil && found { + var req Request + if json.Unmarshal([]byte(payload), &req) == nil { + p = pending{req: req, cardID: cardID} + ok = true + } + } + } if !ok { return Response{Receipt: agentcommand.ExecReceipt{CommandID: "unknown", State: agentcommand.StateRejected, Reason: "no pending confirm (expired or already resolved)", Time: b.now().UTC()}} } diff --git a/internal/cmdbus/pending_store_test.go b/internal/cmdbus/pending_store_test.go new file mode 100644 index 0000000..e515208 --- /dev/null +++ b/internal/cmdbus/pending_store_test.go @@ -0,0 +1,33 @@ +package cmdbus + +import ( + "context" + "path/filepath" + "testing" + + agentcommand "agent-command" + "agent-system-backend/internal/store" +) + +func TestPendingConfirmRestoredAfterRestart(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "c.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + cards := &memCards{} + b := newTestBus(&memAudit{}, cards) + b.SetPendingStore(st) + resp := b.Dispatch(context.Background(), Request{ + ID: "demo.risky", Params: map[string]any{"target": "prod"}, Entrance: "button", ConversationID: "cnv_1", Owner: "acme", + }) + if resp.Card == nil { + t.Fatal("expected confirm card") + } + // 模拟重启:丢掉进程内 map,只留存储。 + b.pending = map[string]pending{} + again := b.Confirm("acme", resp.Card.ID, "confirm") + if again.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("restored confirm: %+v", again.Receipt) + } +} diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index a9bd3bc..3dd4065 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -4,6 +4,7 @@ package ctxasm import ( "fmt" "strings" + "time" "unicode/utf8" agentllm "agent-native/runtime/llm" @@ -58,6 +59,13 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ start := 0 if len(history) > maxHist { start = len(history) - maxHist + if prof.Context.SummarizeBeyond { + summary := extractiveSummary(history[:start]) + if summary != "" { + msgs = append(msgs, agentllm.Message{Role: agentllm.RoleSystem, Content: "[更早对话摘要]\n" + summary}) + rep.Items = append(rep.Items, fmt.Sprintf("summary(%d)", start)) + } + } rep.Truncations = append(rep.Truncations, fmt.Sprintf("历史 %d 条超窗口 %d,仅保留最近部分", len(history), maxHist)) } limitCard := int(eff.GetNumber("context.maxCardChars", DefaultMaxCardChars)) @@ -224,3 +232,63 @@ func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, } return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text) } + +// extractiveSummary 把窗口外历史压成短摘要(不调用模型)。summarizeBeyond 打开时注入。 +func extractiveSummary(dropped []core.Message) string { + var lines []string + for _, m := range dropped { + var text string + for _, p := range m.Parts { + if p.Type == core.PartText && strings.TrimSpace(p.Text) != "" { + text = strings.TrimSpace(p.Text) + break + } + } + if text == "" { + continue + } + runes := []rune(text) + if len(runes) > 80 { + text = string(runes[:80]) + "…" + } + role := "用户" + if m.Role == core.RoleAssistant { + role = "助手" + } + lines = append(lines, role+":"+text) + if len(lines) >= 12 { + break + } + } + return strings.Join(lines, "\n") +} + +// InjectMemory 把未过期记忆作为系统段放到提示词之后。空列表不改。 +func InjectMemory(sys []agentllm.Message, entries []core.MemoryEntry) []agentllm.Message { + if len(entries) == 0 { + return sys + } + var b strings.Builder + b.WriteString("[记忆]\n") + n := 0 + for _, e := range entries { + if e.ExpiresAt != nil && e.ExpiresAt.Before(time.Now()) { + continue + } + if strings.TrimSpace(e.Content) == "" { + continue + } + fmt.Fprintf(&b, "- (%s) %s\n", e.Type, strings.TrimSpace(e.Content)) + n++ + if n >= 20 { + break + } + } + if n == 0 { + return sys + } + out := make([]agentllm.Message, 0, len(sys)+1) + out = append(out, sys...) + out = append(out, agentllm.Message{Role: agentllm.RoleSystem, Content: b.String()}) + return out +} diff --git a/internal/ctxasm/summary_test.go b/internal/ctxasm/summary_test.go new file mode 100644 index 0000000..ec4c1bf --- /dev/null +++ b/internal/ctxasm/summary_test.go @@ -0,0 +1,25 @@ +package ctxasm + +import ( + "strings" + "testing" + + core "agent-system-core" +) + +func TestSummarizeBeyondInjectsExtract(t *testing.T) { + var history []core.Message + for i := 0; i < 5; i++ { + history = append(history, core.Message{ID: core.ID("m"), Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "早期事实"}}}) + } + current := core.Message{ID: "cur", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "现在"}}} + prof := core.AgentProfile{Context: core.ContextPolicy{MaxHistoryMessages: 2, SummarizeBeyond: true}} + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil) + joined := "" + for _, m := range msgs { + joined += m.Content + } + if !strings.Contains(joined, "更早对话摘要") || !strings.Contains(joined, "早期事实") { + t.Fatalf("summary missing: %s items=%v", joined, rep.Items) + } +} diff --git a/internal/evalgold/evalgold.go b/internal/evalgold/evalgold.go new file mode 100644 index 0000000..f9db1c4 --- /dev/null +++ b/internal/evalgold/evalgold.go @@ -0,0 +1,46 @@ +// Package evalgold 是通用任务黄金集的最小回归夹具。 +// 领域指标(阶段映射、相关性三态)留在下游仓;这里只锁住「规则层不得误触发」。 +package evalgold + +import ( + "encoding/json" + "os" + "strings" +) + +// Case 是一条黄金样本。Want 为空表示规则层必须保持沉默。 +type Case struct { + Input string `json:"input"` + Want string `json:"want"` +} + +// Classify 是可替换的规则层。当前实现只认识显式危险短语,避免闲聊误触发。 +func Classify(input string) string { + if strings.Contains(input, "删除全部") { + return "danger" + } + return "" +} + +// Load 读取黄金集。 +func Load(path string) ([]Case, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cases []Case + if err := json.Unmarshal(raw, &cases); err != nil { + return nil, err + } + return cases, nil +} + +// Check 返回第一条失配。空字符串表示通过。 +func Check(cases []Case) string { + for _, c := range cases { + if got := Classify(c.Input); got != c.Want { + return c.Input + ": got " + got + " want " + c.Want + } + } + return "" +} diff --git a/internal/evalgold/evalgold_test.go b/internal/evalgold/evalgold_test.go new file mode 100644 index 0000000..afab647 --- /dev/null +++ b/internal/evalgold/evalgold_test.go @@ -0,0 +1,19 @@ +package evalgold + +import ( + "path/filepath" + "testing" +) + +func TestGoldenSet(t *testing.T) { + cases, err := Load(filepath.Join("testdata", "golden.json")) + if err != nil { + t.Fatal(err) + } + if len(cases) < 3 { + t.Fatalf("golden set too small: %d", len(cases)) + } + if msg := Check(cases); msg != "" { + t.Fatal(msg) + } +} diff --git a/internal/evalgold/testdata/golden.json b/internal/evalgold/testdata/golden.json new file mode 100644 index 0000000..264e223 --- /dev/null +++ b/internal/evalgold/testdata/golden.json @@ -0,0 +1,5 @@ +[ + {"input": "你好", "want": ""}, + {"input": "帮我总结今天的会议", "want": ""}, + {"input": "删除全部数据", "want": "danger"} +] diff --git a/internal/hostapi/server.go b/internal/hostapi/server.go new file mode 100644 index 0000000..84bb91f --- /dev/null +++ b/internal/hostapi/server.go @@ -0,0 +1,158 @@ +// Package hostapi 是 sidecar 与外部进程调用基座的本机 HTTP 面(/host/v1)。 +package hostapi + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +// Server 依赖由组合根注入。Dispatch/Search/Complete 可为 nil(对应路由返回 503)。 +type Server struct { + Token string + Store *store.Store + Dispatch func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error + Search func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) + Complete func(ctx context.Context, owner, prompt string) (string, error) +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("PUT /host/v1/kv/{key}", s.auth(s.putKV)) + mux.HandleFunc("GET /host/v1/kv/{key}", s.auth(s.getKV)) + mux.HandleFunc("POST /host/v1/audit", s.auth(s.audit)) + mux.HandleFunc("POST /host/v1/commands/{id}", s.auth(s.command)) + mux.HandleFunc("POST /host/v1/llm", s.auth(s.llm)) + mux.HandleFunc("GET /host/v1/knowledge/search", s.auth(s.search)) + return mux +} + +func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.Token != "" { + got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if got != s.Token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + } + next(w, r) + } +} + +func (s *Server) pluginID(r *http.Request) string { + id := r.Header.Get("X-Plugin-Id") + if id == "" { + id = "unknown" + } + return id +} + +func (s *Server) owner(r *http.Request) string { + if o := r.Header.Get("X-Owner"); o != "" { + return o + } + return store.OwnerDefault +} + +func (s *Server) putKV(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err := s.Store.PutPluginKV(s.pluginID(r), s.owner(r), r.PathValue("key"), string(body)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) getKV(w http.ResponseWriter, r *http.Request) { + v, ok, err := s.Store.GetPluginKV(s.pluginID(r), s.owner(r), r.PathValue("key")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, map[string]string{"value": v}) +} + +func (s *Server) audit(w http.ResponseWriter, r *http.Request) { + var body struct { + Action string `json:"action"` + Target string `json:"target"` + Evidence string `json:"evidence"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Action == "" { + http.Error(w, "action required", http.StatusBadRequest) + return + } + if err := s.Store.AppendAuditEvent(store.AuditEvent{ + Owner: s.owner(r), Action: body.Action, Actor: s.pluginID(r), Target: body.Target, + Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"), + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) command(w http.ResponseWriter, r *http.Request) { + if s.Dispatch == nil { + http.Error(w, "command bus unavailable", http.StatusServiceUnavailable) + return + } + var body struct { + Params map[string]any `json:"params"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if err := s.Dispatch(r.Context(), s.pluginID(r), s.owner(r), r.PathValue("id"), body.Params); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) llm(w http.ResponseWriter, r *http.Request) { + if s.Complete == nil { + http.Error(w, "llm route not configured", http.StatusServiceUnavailable) + return + } + var body struct { + Prompt string `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Prompt) == "" { + http.Error(w, "prompt required", http.StatusBadRequest) + return + } + text, err := s.Complete(r.Context(), s.owner(r), body.Prompt) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, http.StatusOK, map[string]string{"text": text}) +} + +func (s *Server) search(w http.ResponseWriter, r *http.Request) { + if s.Search == nil { + http.Error(w, "retriever unavailable", http.StatusServiceUnavailable) + return + } + hits, err := s.Search(r.Context(), s.owner(r), r.URL.Query().Get("q")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, http.StatusOK, hits) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/hostapi/server_test.go b/internal/hostapi/server_test.go new file mode 100644 index 0000000..709912f --- /dev/null +++ b/internal/hostapi/server_test.go @@ -0,0 +1,62 @@ +package hostapi + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" +) + +func TestHostClientRoundTrip(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "h.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + srv := &Server{Token: "sek", Store: st, Dispatch: func(ctx context.Context, pluginID, owner, commandID string, params map[string]any) error { + return nil + }} + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + c := extension.NewHostClient(ts.URL, "sek", "rdm", "acme") + if err := c.PutKV(context.Background(), "slice", "s1"); err != nil { + t.Fatal(err) + } + got, err := c.GetKV(context.Background(), "slice") + if err != nil || got != "s1" { + t.Fatalf("kv %q %v", got, err) + } + if err := c.Audit(context.Background(), "ingest", "doc", []byte(`{"ok":true}`)); err != nil { + t.Fatal(err) + } + evs, err := st.ListAuditEvents("acme", 10) + if err != nil || len(evs) != 1 || evs[0].Action != "ingest" { + t.Fatalf("audit %+v %v", evs, err) + } + if err := c.Dispatch(context.Background(), "builtin.help", nil); err != nil { + t.Fatal(err) + } + bad := extension.NewHostClient(ts.URL, "nope", "rdm", "acme") + if err := bad.PutKV(context.Background(), "x", "y"); err == nil { + t.Fatal("bad token must fail") + } +} + +func TestLLMUnconfigured(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "h.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + srv := &Server{Store: st} + req := httptest.NewRequest(http.MethodPost, "/host/v1/llm", nil) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status %d", rec.Code) + } +} diff --git a/internal/plugins/host.go b/internal/plugins/host.go index e8c605e..a59b646 100644 --- a/internal/plugins/host.go +++ b/internal/plugins/host.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -31,16 +32,27 @@ type Loaded struct { Prompts map[string]string // 文件名 → 内容(画像模板) PluginSettings []core.SettingDesc Defaults map[string]any // 插件设置默认值(并入有效设置) + Skills map[string]string + MCP []core.PluginMCPServer + Knowledge []core.PluginKnowledgeSet + Parsers []core.PluginParserDecl + Agents []core.PluginAgentDecl + Channels []core.PluginChannelDecl + Pages []core.PluginPageDecl } // Host 管理插件目录装载与注册。 type Host struct { - mu sync.RWMutex - st *store.Store - registry *tools.Registry - settings *settings.Service - dir string - loaded map[string]*Loaded // id → loaded + mu sync.RWMutex + st *store.Store + registry *tools.Registry + settings *settings.Service + dir string + skillsRoot string + hostAPI string + hostToken string + sidecars map[string]*exec.Cmd + loaded map[string]*Loaded // id → loaded // OnIntents 在装载集合变化后回调全部意图 overlay(宿主接识别闸门;nil = 不接)。 OnIntents func(overlay []json.RawMessage) error // OnCommands 在装载集合变化后回调全部命令 spec overlay(宿主接命令总线;nil = 不接)。 @@ -48,7 +60,7 @@ type Host struct { } func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string) *Host { - return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}} + return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}, sidecars: map[string]*exec.Cmd{}} } // LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。 @@ -75,6 +87,10 @@ func (h *Host) LoadDir() map[string]error { for id := range h.loaded { if !seen[id] { delete(h.loaded, id) + if cmd := h.sidecars[id]; cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + delete(h.sidecars, id) h.registry.UnregisterBySource(core.SourcePlugin, id) } } @@ -112,8 +128,8 @@ func (h *Host) loadOne(dir string) error { if m.APIVersion == "" { m.APIVersion = core.PluginAPIVersion } - if m.APIVersion != core.PluginAPIVersion { - return fmt.Errorf("plugins: %s: apiVersion %s != host %s(兼容窗口外)", m.ID, m.APIVersion, core.PluginAPIVersion) + if !core.PluginAPIAccepted(m.APIVersion) { + return fmt.Errorf("plugins: %s: apiVersion %s 不在兼容窗口(%s, %s)", m.ID, m.APIVersion, core.PluginAPIVersion, core.PluginAPIVersionExperimental) } ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}} @@ -129,7 +145,7 @@ func (h *Host) loadOne(dir string) error { } desc.Source = core.SourcePlugin desc.PluginID = m.ID - exec, err := h.bindToolExecutor(desc) + exec, err := h.bindToolExecutor(desc, m.APIVersion, m.Requires.Network) if err != nil { return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err) } @@ -219,6 +235,12 @@ func (h *Host) loadOne(dir string) error { } } + if err := h.loadDeclared(dir, m, ld); err != nil { + return err + } + if err := h.startSidecar(m, dir); err != nil { + return err + } h.mu.Lock() h.loaded[m.ID] = ld overlay := h.collectIntentsLocked() @@ -268,22 +290,6 @@ func (h *Host) collectCommandsLocked() []json.RawMessage { return out } -// bindToolExecutor:声明式 HTTP 工具绑定执行器;纯描述型(无 http)工具暂不支持(代码插件为远期 sidecar 档)。 -func (h *Host) bindToolExecutor(desc core.ToolDesc) (tools.Executor, error) { - if desc.HTTP == nil { - return nil, fmt.Errorf("v1 仅支持声明式 HTTP 工具(缺少 http 段)") - } - if desc.HTTP.URL == "" || desc.HTTP.Method == "" { - return nil, fmt.Errorf("http 工具缺少 url/method") - } - lookup := func(key string) string { - // AuthRef 走用户层设置(如 models.chat.apiKey 或自定义 secret key)。 - eff := h.settings.Effective(store.OwnerDefault, "") - return eff.GetString(key, "") - } - return tools.HTTPExecutor(*desc.HTTP, lookup), nil -} - func readAsset(dir, sub, name string, v any) error { p := filepath.Join(dir, sub, name+".json") if sub == "" { @@ -337,7 +343,12 @@ func (h *Host) Cards() map[string][]core.UINode { func (h *Host) Unregister(id string) { h.mu.Lock() delete(h.loaded, id) + cmd := h.sidecars[id] + delete(h.sidecars, id) h.mu.Unlock() + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } h.registry.UnregisterBySource(core.SourcePlugin, id) _ = h.st.RemovePlugin(id) } diff --git a/internal/plugins/host_v2.go b/internal/plugins/host_v2.go new file mode 100644 index 0000000..6b4fc57 --- /dev/null +++ b/internal/plugins/host_v2.go @@ -0,0 +1,220 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "agent-system-backend/internal/store" + "agent-system-backend/internal/tools" + core "agent-system-core" +) + +func (h *Host) SetSkillsRoot(dir string) { h.skillsRoot = dir } + +func (h *Host) SetHostAPI(baseURL, token string) { + h.hostAPI, h.hostToken = baseURL, token +} + +func (h *Host) startSidecar(m core.PluginManifest, dir string) error { + if m.Backend.Mode != "sidecar" || m.Backend.Command == "" { + return nil + } + cmdPath := m.Backend.Command + if !filepath.IsAbs(cmdPath) { + if _, err := os.Stat(filepath.Join(dir, cmdPath)); err == nil { + cmdPath = filepath.Join(dir, cmdPath) + } + } + cmd := exec.Command(cmdPath, m.Backend.Args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "AGENT_HOST_API="+h.hostAPI, + "AGENT_HOST_TOKEN="+h.hostToken, + "AGENT_PLUGIN_ID="+m.ID, + ) + if err := cmd.Start(); err != nil { + return fmt.Errorf("plugins: %s: sidecar: %w", m.ID, err) + } + h.mu.Lock() + if h.sidecars == nil { + h.sidecars = map[string]*exec.Cmd{} + } + if prev := h.sidecars[m.ID]; prev != nil && prev.Process != nil { + _ = prev.Process.Kill() + } + h.sidecars[m.ID] = cmd + h.mu.Unlock() + go func() { _ = cmd.Wait() }() + return nil +} + +func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error { + for _, name := range m.Provides.Skills { + body, err := os.ReadFile(filepath.Join(dir, "skills", name+".md")) + if err != nil { + return fmt.Errorf("plugins: %s: skill %s: %w", m.ID, name, err) + } + if ld.Skills == nil { + ld.Skills = map[string]string{} + } + ld.Skills[name] = string(body) + if h.skillsRoot != "" { + dest := filepath.Join(h.skillsRoot, "plugin-"+m.ID, name) + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dest, "SKILL.md"), body, 0o644); err != nil { + return err + } + } + } + for _, name := range m.Provides.MCPServers { + var srv core.PluginMCPServer + if err := readAsset(dir, "mcp", name, &srv); err != nil { + return fmt.Errorf("plugins: %s: mcp %s: %w", m.ID, name, err) + } + if srv.Name == "" { + srv.Name = name + } + ld.MCP = append(ld.MCP, srv) + } + for _, name := range m.Provides.Knowledge { + var item core.PluginKnowledgeSet + if err := readAsset(dir, "knowledge", name, &item); err != nil { + return fmt.Errorf("plugins: %s: knowledge %s: %w", m.ID, name, err) + } + ld.Knowledge = append(ld.Knowledge, item) + } + for _, name := range m.Provides.Parsers { + var item core.PluginParserDecl + if err := readAsset(dir, "parsers", name, &item); err != nil { + return fmt.Errorf("plugins: %s: parser %s: %w", m.ID, name, err) + } + ld.Parsers = append(ld.Parsers, item) + } + for _, name := range m.Provides.Agents { + var item core.PluginAgentDecl + if err := readAsset(dir, "agents", name, &item); err != nil { + return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err) + } + ld.Agents = append(ld.Agents, item) + } + for _, name := range m.Provides.Channels { + var item core.PluginChannelDecl + if err := readAsset(dir, "channels", name, &item); err != nil { + return fmt.Errorf("plugins: %s: channel %s: %w", m.ID, name, err) + } + ld.Channels = append(ld.Channels, item) + } + for _, name := range m.Provides.Pages { + var item core.PluginPageDecl + if err := readAsset(dir, "pages", name, &item); err != nil { + return fmt.Errorf("plugins: %s: page %s: %w", m.ID, name, err) + } + if item.Module == "" { + return fmt.Errorf("plugins: %s: page %s: missing module", m.ID, name) + } + ld.Pages = append(ld.Pages, item) + } + if m.Backend.SchemaVersion > 0 && h.st != nil { + cur, err := h.st.PluginSchemaVersion(m.ID) + if err != nil { + return err + } + if m.Backend.SchemaVersion < cur { + return fmt.Errorf("plugins: %s: schema %d older than stored %d", m.ID, m.Backend.SchemaVersion, cur) + } + if err := h.st.SetPluginSchemaVersion(m.ID, m.Backend.SchemaVersion); err != nil { + return err + } + } + return nil +} + +// MCPServers 返回已装载插件贡献的 MCP 服务,供与 settings 合并。 +func (h *Host) MCPServers() []core.PluginMCPServer { + h.mu.RLock() + defer h.mu.RUnlock() + var out []core.PluginMCPServer + for _, ld := range h.loaded { + out = append(out, ld.MCP...) + } + return out +} + +// ListFor 按租户授权与 manifest 作用域过滤。project 为空时只检查租户。 +func (h *Host) ListFor(owner, project string) []*Loaded { + all := h.List() + var out []*Loaded + for _, ld := range all { + if !ld.Manifest.Scope.Allows(owner, project) { + continue + } + if h.st != nil && owner != "" { + on, err := h.st.PluginEnabled(owner, ld.Manifest.ID) + if err != nil || !on { + continue + } + } + out = append(out, ld) + } + return out +} + +func (h *Host) bindToolExecutor(desc core.ToolDesc, apiVersion string, allow []string) (tools.Executor, error) { + if desc.HTTP == nil { + return nil, fmt.Errorf("v1 仅支持声明式 HTTP 工具(缺少 http 段)") + } + if desc.HTTP.URL == "" || desc.HTTP.Method == "" { + return nil, fmt.Errorf("http 工具缺少 url/method") + } + if err := checkNetwork(apiVersion, allow, desc.HTTP.URL); err != nil { + return nil, err + } + return func(rc tools.ToolExecCtx, args json.RawMessage) (string, error) { + if err := checkNetwork(apiVersion, allow, desc.HTTP.URL); err != nil { + return "", err + } + owner := store.OwnerDefault + if rc != nil && rc.OwnerID() != "" { + owner = rc.OwnerID() + } + lookup := func(key string) string { + eff := h.settings.Effective(owner, "") + if desc.PluginID != "" { + if v := eff.GetString("secrets."+desc.PluginID+"."+key, ""); v != "" { + return v + } + } + return eff.GetString(key, "") + } + return tools.HTTPExecutor(*desc.HTTP, lookup)(rc, args) + }, nil +} + +func checkNetwork(apiVersion string, allow []string, rawURL string) error { + if len(allow) == 0 { + if apiVersion == core.PluginAPIVersionExperimental { + return fmt.Errorf("plugins: api %s 要求 requires.network", apiVersion) + } + return nil + } + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("plugins: tool url: %w", err) + } + host := u.Hostname() + for _, a := range allow { + a = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(a)), "*.") + h := strings.ToLower(host) + if h == a || strings.HasSuffix(h, "."+a) { + return nil + } + } + return fmt.Errorf("plugins: host %s not in network allowlist", host) +} diff --git a/internal/plugins/host_v2_test.go b/internal/plugins/host_v2_test.go new file mode 100644 index 0000000..80f65ce --- /dev/null +++ b/internal/plugins/host_v2_test.go @@ -0,0 +1,97 @@ +package plugins + +import ( + "os" + "path/filepath" + "testing" + "time" + + core "agent-system-core" +) + +func TestV2AssetsAndNetwork(t *testing.T) { + host, _, dir := newHost(t) + skills := filepath.Join(t.TempDir(), "skills") + host.SetSkillsRoot(skills) + root := filepath.Join(dir, "v2") + for _, sub := range []string{"skills", "mcp", "knowledge", "parsers", "agents", "channels", "pages", "tools"} { + if err := os.MkdirAll(filepath.Join(root, sub), 0o755); err != nil { + t.Fatal(err) + } + } + write := func(rel, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("plugin.json", `{ + "id":"v2","name":"V2","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`", + "requires":{"network":["api.example.com"]}, + "scope":{"tenants":["acme"]}, + "backend":{"schemaVersion":1}, + "provides":{"tools":["echo"],"skills":["note"],"mcpServers":["local"],"knowledge":["regs"],"parsers":["pdf"],"agents":["writer"],"channels":["feishu"],"pages":["home"]} + }`) + write("tools/echo.json", `{"name":"v2_echo","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://api.example.com/x"}}`) + write("skills/note.md", "# note\n") + write("mcp/local.json", `{"name":"local","command":"echo"}`) + write("knowledge/regs.json", `{"id":"regs","title":"法规"}`) + write("parsers/pdf.json", `{"id":"pdf","title":"PDF","mime":"application/pdf"}`) + write("agents/writer.json", `{"id":"writer","title":"写作"}`) + write("channels/feishu.json", `{"id":"feishu","kind":"feishu","title":"飞书"}`) + write("pages/home.json", `{"id":"home","title":"首页","module":"./page.mjs"}`) + + if err := host.LoadDir()["v2"]; err != nil { + t.Fatal(err) + } + ld := host.List()[0] + if ld.Skills["note"] == "" || len(ld.MCP) != 1 || len(ld.Pages) != 1 || len(ld.Channels) != 1 { + t.Fatalf("assets: %+v", ld) + } + if _, err := os.Stat(filepath.Join(skills, "plugin-v2", "note", "SKILL.md")); err != nil { + t.Fatal(err) + } + if got := host.ListFor("other", ""); len(got) != 0 { + t.Fatalf("scope leak: %d", len(got)) + } + if got := host.ListFor("acme", ""); len(got) != 1 { + t.Fatalf("scope miss: %d", len(got)) + } + if err := host.st.SetPluginEnabled("acme", "v2", false); err != nil { + t.Fatal(err) + } + if got := host.ListFor("acme", ""); len(got) != 0 { + t.Fatal("grant should hide plugin") + } +} + +func TestExperimentalNetworkDeny(t *testing.T) { + host, _, dir := newHost(t) + root := filepath.Join(dir, "open") + os.MkdirAll(filepath.Join(root, "tools"), 0o755) + os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"open","name":"O","version":"0.1.0","apiVersion":"`+core.PluginAPIVersionExperimental+`","provides":{"tools":["e"]}}`), 0o644) + os.WriteFile(filepath.Join(root, "tools", "e.json"), []byte(`{"name":"open_e","description":"e","parameters":{"type":"object"},"http":{"method":"GET","url":"https://example.com"}}`), 0o644) + if err := host.LoadDir()["open"]; err == nil { + t.Fatal("v2 without network allowlist must fail closed") + } +} + +func TestSidecarTouch(t *testing.T) { + host, _, dir := newHost(t) + host.SetHostAPI("http://127.0.0.1:1", "tok") + root := filepath.Join(dir, "side") + os.MkdirAll(root, 0o755) + mark := filepath.Join(t.TempDir(), "up") + os.WriteFile(filepath.Join(root, "plugin.json"), []byte(`{"id":"side","name":"S","version":"0.1.0","apiVersion":"1.0","backend":{"mode":"sidecar","command":"/bin/touch","args":["`+mark+`"],"schemaVersion":1},"provides":{}}`), 0o644) + if err := host.LoadDir()["side"]; err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(mark); err == nil { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("sidecar did not run") +} diff --git a/internal/rag/hash_embed.go b/internal/rag/hash_embed.go new file mode 100644 index 0000000..2e14c70 --- /dev/null +++ b/internal/rag/hash_embed.go @@ -0,0 +1,71 @@ +package rag + +import ( + "context" + "fmt" + "hash/fnv" + "unicode/utf8" +) + +const hashDim = 32 + +// HashEmbedder 是零依赖的本地嵌入档。语义弱于真实模型,只保证同文本稳定、近文本可比较。 +// 由 settings models.embedding.provider=hash 显式打开;未设置时仍按 D23 退化为全文。 +type HashEmbedder struct{} + +func (HashEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) { + out := make([][]float32, len(texts)) + for i, text := range texts { + out[i] = hashVec(text) + } + return out, nil +} + +func hashVec(text string) []float32 { + v := make([]float32, hashDim) + for _, r := range text { + h := fnv.New32a() + var buf [4]byte + n := utf8.EncodeRune(buf[:], r) + _, _ = h.Write(buf[:n]) + idx := int(h.Sum32() % hashDim) + v[idx] += 1 + } + var norm float32 + for _, x := range v { + norm += x * x + } + if norm == 0 { + return v + } + inv := float32(1) + // 粗略归一,避免余弦被长度主导。 + for norm > 1 { + norm = norm / 4 + inv = inv / 2 + } + for i := range v { + v[i] *= inv + } + return v +} + +// SwitchingEmbedder 在 provider=hash 时走本地档,否则走 HTTP。 +type SwitchingEmbedder struct { + HTTP *HTTPEmbedder + Provider func() string +} + +func (e SwitchingEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) { + if e.Provider != nil && e.Provider() == "hash" { + return HashEmbedder{}.Embed(ctx, texts) + } + if e.HTTP == nil { + return nil, fmt.Errorf("rag: embedding 模型未配置(settings.models.embedding)") + } + return e.HTTP.Embed(ctx, texts) +} + +func (e SwitchingEmbedder) Offline() bool { + return e.Provider != nil && e.Provider() == "hash" +} diff --git a/internal/rag/hash_embed_test.go b/internal/rag/hash_embed_test.go new file mode 100644 index 0000000..ee49031 --- /dev/null +++ b/internal/rag/hash_embed_test.go @@ -0,0 +1,22 @@ +package rag + +import ( + "context" + "testing" +) + +func TestHashEmbedderStable(t *testing.T) { + a, err := HashEmbedder{}.Embed(context.Background(), []string{"需求追溯"}) + if err != nil { + t.Fatal(err) + } + b, _ := HashEmbedder{}.Embed(context.Background(), []string{"需求追溯"}) + if len(a) != 1 || len(a[0]) != hashDim { + t.Fatalf("dim: %d", len(a[0])) + } + for i := range a[0] { + if a[0][i] != b[0][i] { + t.Fatal("hash embedder must be stable") + } + } +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index 7df57dd..287ffd4 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -106,7 +106,8 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval return p.st.SearchKnowledge(owner, q) } route := p.embeddingRoute(ctx) - if !route.OK() { + offline, _ := p.embedder.(interface{ Offline() bool }) + if !route.OK() && (offline == nil || !offline.Offline()) { return p.st.SearchKnowledge(owner, q) } qvecs, err := p.embedder.Embed(ctx, []string{q.Text}) diff --git a/internal/runmgr/hitl_timeout_test.go b/internal/runmgr/hitl_timeout_test.go new file mode 100644 index 0000000..17bc087 --- /dev/null +++ b/internal/runmgr/hitl_timeout_test.go @@ -0,0 +1,27 @@ +package runmgr + +import ( + "context" + "strings" + "testing" + "time" + + core "agent-system-core" +) + +func TestWaitHITLTimeout(t *testing.T) { + ch := make(chan hitlAnswer) + _, err := waitHITL(context.Background(), ch, 20*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("got %v", err) + } +} + +func TestWaitHITLAnswer(t *testing.T) { + ch := make(chan hitlAnswer, 1) + ch <- hitlAnswer{resp: core.HITLResponse{ChoiceID: "ok"}} + resp, err := waitHITL(context.Background(), ch, time.Second) + if err != nil || resp.ChoiceID != "ok" { + t.Fatalf("resp=%+v err=%v", resp, err) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 5e8dc9e..d25ff0d 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -35,7 +35,9 @@ type Manager struct { mockSub runtime.Runner // 子代理脚本回放 squad runtime.Runner // runtime.entry=squad 时的对话入口 gate IntentGate - cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退) + cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退) + // OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。 + OnUsage func(owner string, tokens int) events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store) retriever core.Retriever // 可选混合检索(nil = store 全文) indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) @@ -179,6 +181,25 @@ func (c *runCtx) SetContextObservation(o core.ContextObservation) { c.mgr.mu.Unlock() } +func (c *runCtx) Memories() []core.MemoryEntry { + if c.mgr == nil || c.mgr.st == nil { + return nil + } + list, err := c.mgr.st.ListMemory(c.run.Owner, "", "") + if err != nil { + return nil + } + now := time.Now() + var live []core.MemoryEntry + for _, e := range list { + if e.ExpiresAt != nil && !e.ExpiresAt.After(now) { + continue + } + live = append(live, e) + } + return live +} + func (c *runCtx) Emit(typ core.StreamEventType, payload any) error { _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload) return err @@ -225,11 +246,31 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { return core.HITLResponse{}, err } } + var timeout time.Duration + if card.TimeoutSec != nil && *card.TimeoutSec > 0 { + timeout = time.Duration(*card.TimeoutSec) * time.Second + } + return waitHITL(c.ctx, c.h.hitlCh, timeout) +} + +func waitHITL(ctx context.Context, ch <-chan hitlAnswer, timeout time.Duration) (core.HITLResponse, error) { + if timeout <= 0 { + select { + case ans := <-ch: + return ans.resp, ans.err + case <-ctx.Done(): + return core.HITLResponse{}, ctx.Err() + } + } + timer := time.NewTimer(timeout) + defer timer.Stop() select { - case ans := <-c.h.hitlCh: + case ans := <-ch: return ans.resp, ans.err - case <-c.ctx.Done(): - return core.HITLResponse{}, c.ctx.Err() + case <-timer.C: + return core.HITLResponse{}, fmt.Errorf("runmgr: hitl timed out after %s", timeout) + case <-ctx.Done(): + return core.HITLResponse{}, ctx.Err() } } @@ -688,6 +729,9 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, rc.run.Usage = &usage rc.run.Error = errPart m.st.UpdateRun(&rc.run) + if m.OnUsage != nil && usage.TotalTokens > 0 { + m.OnUsage(rc.run.Owner, usage.TotalTokens) + } // 终态权威消息树:落库助手消息(含过程部件标记)。 m.mu.Lock() diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index db89c41..f059cbc 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -223,6 +223,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } // 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。 resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) } + if ms, ok := rc.(interface{ Memories() []core.MemoryEntry }); ok { + sys = ctxasm.InjectMemory(sys, ms.Memories()) + } msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact) if asmReport != nil { rc.SetContextObservation(core.ContextObservation{ diff --git a/internal/server/command_pairs_test.go b/internal/server/command_pairs_test.go new file mode 100644 index 0000000..61991eb --- /dev/null +++ b/internal/server/command_pairs_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" +) + +type pair struct { + ID string `json:"id"` + Button bool `json:"button"` + Chat bool `json:"chat"` +} + +type nopAudit struct{} + +func (nopAudit) AppendAudit(agentcommand.AuditEnvelope) error { return nil } + +// 每个内置命令都必须在 command-pairs.json 里同时声明按钮入口与对话入口。 +// 新命令漏登记则本测试失败,合入被挡住。 +func TestBuiltinCommandsHaveDualEntryPairs(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "command-pairs.json")) + if err != nil { + t.Fatal(err) + } + var pairs []pair + if err := json.Unmarshal(raw, &pairs); err != nil { + t.Fatal(err) + } + byID := map[string]pair{} + for _, p := range pairs { + byID[p.ID] = p + } + s := &Server{Bus: cmdbus.New(nopAudit{}, nil, nil)} + s.WireBuiltinCommands() + for _, cmd := range s.Bus.List() { + p, ok := byID[cmd.Spec.ID] + if !ok || !p.Button || !p.Chat { + t.Errorf("command %s missing dual-entry pair (button and chat)", cmd.Spec.ID) + } + } +} diff --git a/internal/server/platform_api.go b/internal/server/platform_api.go new file mode 100644 index 0000000..f2e8a4a --- /dev/null +++ b/internal/server/platform_api.go @@ -0,0 +1,187 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func (s *Server) createBackgroundTask(w http.ResponseWriter, r *http.Request) { + var body struct { + Kind string `json:"kind"` + Title string `json:"title"` + ConversationID string `json:"conversationId"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Kind == "" { + http.Error(w, "kind required", http.StatusBadRequest) + return + } + task, err := s.st.CreateBackgroundTask(s.owner(r), body.Kind, body.Title, body.ConversationID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, task) +} + +func (s *Server) listBackgroundTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := s.st.ListBackgroundTasks(s.owner(r)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, orEmptySlice(tasks)) +} + +func (s *Server) getBackgroundTask(w http.ResponseWriter, r *http.Request) { + task, err := s.st.GetBackgroundTask(s.owner(r), r.PathValue("id")) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + writeJSON(w, http.StatusOK, task) +} + +func (s *Server) updateBackgroundTask(w http.ResponseWriter, r *http.Request) { + var body struct { + Status string `json:"status"` + Progress int `json:"progress"` + Error string `json:"error"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Status == "" { + http.Error(w, "status required", http.StatusBadRequest) + return + } + if err := s.st.UpdateBackgroundTask(s.owner(r), r.PathValue("id"), body.Status, body.Progress, body.Error); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + s.getBackgroundTask(w, r) +} + +func (s *Server) putMemory(w http.ResponseWriter, r *http.Request) { + var e core.MemoryEntry + if err := json.NewDecoder(r.Body).Decode(&e); err != nil || strings.TrimSpace(e.Content) == "" { + http.Error(w, "content required", http.StatusBadRequest) + return + } + if e.Scope == "" { + e.Scope = "user" + } + if e.Type == "" { + e.Type = "fact" + } + if e.Source == "" { + e.Source = "manual" + } + saved, err := s.st.PutMemory(s.owner(r), e) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, saved) +} + +func (s *Server) listMemory(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + list, err := s.st.ListMemory(s.owner(r), q.Get("scope"), q.Get("projectId")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, orEmptySlice(list)) +} + +func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + evs, err := s.st.ListAuditEvents(s.owner(r), limit) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, orEmptySlice(evs)) +} + +func (s *Server) postAudit(w http.ResponseWriter, r *http.Request) { + var body struct { + Action string `json:"action"` + Target string `json:"target"` + Evidence string `json:"evidence"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Action == "" { + http.Error(w, "action required", http.StatusBadRequest) + return + } + if err := s.st.AppendAuditEvent(store.AuditEvent{ + Owner: s.owner(r), Actor: s.owner(r), Action: body.Action, Target: body.Target, + Evidence: body.Evidence, TraceID: r.Header.Get("X-Trace-Id"), + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) setPluginEnabled(w http.ResponseWriter, r *http.Request) { + var body struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if err := s.st.SetPluginEnabled(s.owner(r), r.PathValue("id"), body.Enabled); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) llmBudget(w http.ResponseWriter, r *http.Request) { + budget := int(s.set.Effective(s.owner(r), "").GetNumber("llm.dailyTokenBudget", 0)) + status, err := s.st.AccountTokens(s.owner(r), 0, budget) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, map[string]any{"total": status.Total, "budget": budget, "over": status.Over}) +} + +func (s *Server) exportConversation(w http.ResponseWriter, r *http.Request) { + owner := s.owner(r) + id := r.PathValue("id") + conv, err := s.st.GetConversation(owner, id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + msgs, err := s.st.ListMessages(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if r.URL.Query().Get("format") == "md" { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + fmt.Fprintf(w, "# %s\n\n", conv.Title) + for _, m := range msgs { + fmt.Fprintf(w, "## %s\n\n%s\n\n", m.Role, messageText(m)) + } + return + } + writeJSON(w, http.StatusOK, map[string]any{"conversation": conv, "messages": msgs}) +} + +func messageText(m core.Message) string { + var b strings.Builder + for _, p := range m.Parts { + if p.Type == core.PartText { + b.WriteString(p.Text) + } + } + return b.String() +} diff --git a/internal/server/server.go b/internal/server/server.go index 4c0258c..d3f7c9a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -146,6 +146,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation) mux.HandleFunc("GET /api/conversations", s.listTasks) mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail) + mux.HandleFunc("GET /api/conversations/{id}/export", s.exportConversation) mux.HandleFunc("PATCH /api/conversations/{id}", s.patchConversation) mux.HandleFunc("DELETE /api/conversations/{id}", s.deleteConversation) mux.HandleFunc("POST /api/conversations/{id}/messages", s.postMessage) @@ -189,6 +190,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/tools", s.createUserTool) mux.HandleFunc("DELETE /api/tools/{name}", s.deleteUserTool) mux.HandleFunc("GET /api/plugins", s.listPlugins) + mux.HandleFunc("POST /api/plugins/{id}/enabled", s.setPluginEnabled) mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins) mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema) mux.HandleFunc("GET /api/registry", s.registry) @@ -204,6 +206,15 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge) mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge) + mux.HandleFunc("POST /api/tasks", s.createBackgroundTask) + mux.HandleFunc("GET /api/tasks", s.listBackgroundTasks) + mux.HandleFunc("GET /api/tasks/{id}", s.getBackgroundTask) + mux.HandleFunc("POST /api/tasks/{id}", s.updateBackgroundTask) + mux.HandleFunc("POST /api/memory", s.putMemory) + mux.HandleFunc("GET /api/memory", s.listMemory) + mux.HandleFunc("GET /api/audit", s.listAudit) + mux.HandleFunc("POST /api/audit", s.postAudit) + mux.HandleFunc("GET /api/llm/budget", s.llmBudget) mux.HandleFunc("GET /api/me", s.me) mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ts": time.Now().UTC()}) @@ -1054,14 +1065,18 @@ type pluginView struct { Intents []json.RawMessage `json:"intents"` Prompts map[string]string `json:"prompts"` Settings []core.SettingDesc `json:"settings"` + Pages []core.PluginPageDecl `json:"pages,omitempty"` + Channels []core.PluginChannelDecl `json:"channels,omitempty"` + Skills map[string]string `json:"skills,omitempty"` } func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) { var out []pluginView - for _, ld := range s.plugins.List() { + for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) { out = append(out, pluginView{ Manifest: ld.Manifest, Panels: ld.Panels, Cards: ld.Cards, Workflows: ld.Workflows, Intents: ld.Intents, Prompts: ld.Prompts, Settings: ld.PluginSettings, + Pages: ld.Pages, Channels: ld.Channels, Skills: ld.Skills, }) } writeJSON(w, http.StatusOK, orEmptySlice(out)) diff --git a/internal/server/testdata/command-pairs.json b/internal/server/testdata/command-pairs.json new file mode 100644 index 0000000..bd312c2 --- /dev/null +++ b/internal/server/testdata/command-pairs.json @@ -0,0 +1,7 @@ +[ + {"id": "builtin.help", "button": true, "chat": true}, + {"id": "conversation.create", "button": true, "chat": true}, + {"id": "runtime.mock", "button": true, "chat": true}, + {"id": "models.set", "button": true, "chat": true}, + {"id": "conversation.profile", "button": true, "chat": true} +] diff --git a/internal/store/migrate.go b/internal/store/migrate.go index dc5bfa2..66c8cfe 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -327,6 +327,48 @@ func init() { }, "postgres": { `DROP INDEX IF EXISTS idx_cards_project`, }}}, + migration{version: 13, name: "extension_platform", + up: map[string][]string{"sqlite": { + `CREATE TABLE IF NOT EXISTS plugin_grants (owner TEXT NOT NULL, plugin_id TEXT NOT NULL, enabled INTEGER NOT NULL, PRIMARY KEY (owner, plugin_id))`, + `CREATE TABLE IF NOT EXISTS plugin_schema (plugin_id TEXT PRIMARY KEY, version INTEGER NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS plugin_kv (plugin_id TEXT NOT NULL, owner TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, PRIMARY KEY (plugin_id, owner, k))`, + `CREATE TABLE IF NOT EXISTS background_tasks (id TEXT PRIMARY KEY, owner TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, progress INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS memory_entries (id TEXT PRIMARY KEY, owner TEXT NOT NULL, scope TEXT NOT NULL, project_id TEXT NOT NULL DEFAULT '', typ TEXT NOT NULL, content TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', confidence REAL NOT NULL DEFAULT 0, created_at TEXT NOT NULL, last_accessed TEXT NOT NULL, expires_at TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS audit_events (id INTEGER PRIMARY KEY AUTOINCREMENT, time TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, actor TEXT NOT NULL DEFAULT '', target TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', trace_id TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS pending_confirms (card_id TEXT PRIMARY KEY, owner TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS llm_usage_daily (owner TEXT NOT NULL, day TEXT NOT NULL, tokens INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (owner, day))`, + }, "postgres": { + `CREATE TABLE IF NOT EXISTS plugin_grants (owner TEXT NOT NULL, plugin_id TEXT NOT NULL, enabled INTEGER NOT NULL, PRIMARY KEY (owner, plugin_id))`, + `CREATE TABLE IF NOT EXISTS plugin_schema (plugin_id TEXT PRIMARY KEY, version INTEGER NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS plugin_kv (plugin_id TEXT NOT NULL, owner TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, PRIMARY KEY (plugin_id, owner, k))`, + `CREATE TABLE IF NOT EXISTS background_tasks (id TEXT PRIMARY KEY, owner TEXT NOT NULL, kind TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, progress INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS memory_entries (id TEXT PRIMARY KEY, owner TEXT NOT NULL, scope TEXT NOT NULL, project_id TEXT NOT NULL DEFAULT '', typ TEXT NOT NULL, content TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', confidence DOUBLE PRECISION NOT NULL DEFAULT 0, created_at TEXT NOT NULL, last_accessed TEXT NOT NULL, expires_at TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS audit_events (id SERIAL PRIMARY KEY, time TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, actor TEXT NOT NULL DEFAULT '', target TEXT NOT NULL DEFAULT '', evidence TEXT NOT NULL DEFAULT '', trace_id TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS pending_confirms (card_id TEXT PRIMARY KEY, owner TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS llm_usage_daily (owner TEXT NOT NULL, day TEXT NOT NULL, tokens INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (owner, day))`, + }}, + down: map[string][]string{ + "sqlite": { + `DROP TABLE IF EXISTS llm_usage_daily`, + `DROP TABLE IF EXISTS pending_confirms`, + `DROP TABLE IF EXISTS audit_events`, + `DROP TABLE IF EXISTS memory_entries`, + `DROP TABLE IF EXISTS background_tasks`, + `DROP TABLE IF EXISTS plugin_kv`, + `DROP TABLE IF EXISTS plugin_schema`, + `DROP TABLE IF EXISTS plugin_grants`, + }, + "postgres": { + `DROP TABLE IF EXISTS llm_usage_daily`, + `DROP TABLE IF EXISTS pending_confirms`, + `DROP TABLE IF EXISTS audit_events`, + `DROP TABLE IF EXISTS memory_entries`, + `DROP TABLE IF EXISTS background_tasks`, + `DROP TABLE IF EXISTS plugin_kv`, + `DROP TABLE IF EXISTS plugin_schema`, + `DROP TABLE IF EXISTS plugin_grants`, + }, + }}, ) } diff --git a/internal/store/platform.go b/internal/store/platform.go new file mode 100644 index 0000000..0c11aa8 --- /dev/null +++ b/internal/store/platform.go @@ -0,0 +1,287 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + core "agent-system-core" +) + +// ---- 插件授权与自有数据版本 ---- + +func (s *Store) SetPluginEnabled(owner, pluginID string, enabled bool) error { + flag := 0 + if enabled { + flag = 1 + } + _, err := s.db.Exec(s.q(`INSERT INTO plugin_grants(owner,plugin_id,enabled) VALUES(?,?,?) + ON CONFLICT(owner, plugin_id) DO UPDATE SET enabled=excluded.enabled`), owner, pluginID, flag) + return err +} + +func (s *Store) PluginEnabled(owner, pluginID string) (bool, error) { + var flag int + err := s.db.QueryRow(s.q(`SELECT enabled FROM plugin_grants WHERE owner=? AND plugin_id=?`), owner, pluginID).Scan(&flag) + if err == sql.ErrNoRows { + return true, nil + } + if err != nil { + return false, err + } + return flag != 0, nil +} + +func (s *Store) PluginSchemaVersion(pluginID string) (int, error) { + var v int + err := s.db.QueryRow(s.q(`SELECT version FROM plugin_schema WHERE plugin_id=?`), pluginID).Scan(&v) + if err == sql.ErrNoRows { + return 0, nil + } + return v, err +} + +func (s *Store) SetPluginSchemaVersion(pluginID string, version int) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec(s.q(`INSERT INTO plugin_schema(plugin_id,version,updated_at) VALUES(?,?,?) + ON CONFLICT(plugin_id) DO UPDATE SET version=excluded.version, updated_at=excluded.updated_at`), pluginID, version, now) + return err +} + +func (s *Store) PutPluginKV(pluginID, owner, key, value string) error { + _, err := s.db.Exec(s.q(`INSERT INTO plugin_kv(plugin_id,owner,k,v) VALUES(?,?,?,?) + ON CONFLICT(plugin_id, owner, k) DO UPDATE SET v=excluded.v`), pluginID, owner, key, value) + return err +} + +func (s *Store) GetPluginKV(pluginID, owner, key string) (string, bool, error) { + var v string + err := s.db.QueryRow(s.q(`SELECT v FROM plugin_kv WHERE plugin_id=? AND owner=? AND k=?`), pluginID, owner, key).Scan(&v) + if err == sql.ErrNoRows { + return "", false, nil + } + return v, err == nil, err +} + +// ---- 后台任务(与会话锁无关)---- + +type BackgroundTask struct { + ID string `json:"id"` + Owner string `json:"owner"` + Kind string `json:"kind"` + Title string `json:"title"` + Status string `json:"status"` + Progress int `json:"progress"` + Error string `json:"error,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func (s *Store) CreateBackgroundTask(owner, kind, title, conversationID string) (BackgroundTask, error) { + now := time.Now().UTC().Format(time.RFC3339) + t := BackgroundTask{ + ID: NewID("task"), Owner: owner, Kind: kind, Title: title, Status: "queued", + ConversationID: conversationID, CreatedAt: now, UpdatedAt: now, + } + _, err := s.db.Exec(s.q(`INSERT INTO background_tasks(id,owner,kind,title,status,progress,error,conversation_id,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), t.ID, t.Owner, t.Kind, t.Title, t.Status, t.Progress, t.Error, t.ConversationID, t.CreatedAt, t.UpdatedAt) + return t, err +} + +func (s *Store) UpdateBackgroundTask(owner, id, status string, progress int, errText string) error { + now := time.Now().UTC().Format(time.RFC3339) + res, err := s.db.Exec(s.q(`UPDATE background_tasks SET status=?, progress=?, error=?, updated_at=? WHERE id=? AND owner=?`), + status, progress, errText, now, id, owner) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) GetBackgroundTask(owner, id string) (BackgroundTask, error) { + var t BackgroundTask + err := s.db.QueryRow(s.q(`SELECT id,owner,kind,title,status,progress,error,conversation_id,created_at,updated_at + FROM background_tasks WHERE id=? AND owner=?`), id, owner).Scan( + &t.ID, &t.Owner, &t.Kind, &t.Title, &t.Status, &t.Progress, &t.Error, &t.ConversationID, &t.CreatedAt, &t.UpdatedAt) + if err == sql.ErrNoRows { + return t, ErrNotFound + } + return t, err +} + +func (s *Store) ListBackgroundTasks(owner string) ([]BackgroundTask, error) { + rows, err := s.db.Query(s.q(`SELECT id,owner,kind,title,status,progress,error,conversation_id,created_at,updated_at + FROM background_tasks WHERE owner=? ORDER BY created_at`), owner) + if err != nil { + return nil, err + } + defer rows.Close() + var out []BackgroundTask + for rows.Next() { + var t BackgroundTask + if err := rows.Scan(&t.ID, &t.Owner, &t.Kind, &t.Title, &t.Status, &t.Progress, &t.Error, &t.ConversationID, &t.CreatedAt, &t.UpdatedAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// ---- 记忆 ---- + +func (s *Store) PutMemory(owner string, e core.MemoryEntry) (core.MemoryEntry, error) { + if e.ID == "" { + e.ID = NewID("mem") + } + now := time.Now().UTC() + if e.CreatedAt.IsZero() { + e.CreatedAt = now + } + e.LastAccessed = now + exp := "" + if e.ExpiresAt != nil { + exp = e.ExpiresAt.UTC().Format(time.RFC3339) + } + _, err := s.db.Exec(s.q(`INSERT INTO memory_entries(id,owner,scope,project_id,typ,content,source,confidence,created_at,last_accessed,expires_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?)`), + e.ID, owner, e.Scope, string(e.ProjectID), e.Type, e.Content, e.Source, e.Confidence, + e.CreatedAt.UTC().Format(time.RFC3339), e.LastAccessed.UTC().Format(time.RFC3339), exp) + return e, err +} + +func (s *Store) ListMemory(owner, scope, projectID string) ([]core.MemoryEntry, error) { + rows, err := s.db.Query(s.q(`SELECT id,scope,project_id,typ,content,source,confidence,created_at,last_accessed,expires_at + FROM memory_entries WHERE owner=? AND (scope=? OR ?='') AND (project_id=? OR ?='') ORDER BY created_at`), + owner, scope, scope, projectID, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []core.MemoryEntry + for rows.Next() { + var e core.MemoryEntry + var project, created, accessed, exp string + if err := rows.Scan(&e.ID, &e.Scope, &project, &e.Type, &e.Content, &e.Source, &e.Confidence, &created, &accessed, &exp); err != nil { + return nil, err + } + e.ProjectID = core.ID(project) + e.CreatedAt, _ = time.Parse(time.RFC3339, created) + e.LastAccessed, _ = time.Parse(time.RFC3339, accessed) + if exp != "" { + if t, err := time.Parse(time.RFC3339, exp); err == nil { + e.ExpiresAt = &t + } + } + out = append(out, e) + } + return out, rows.Err() +} + +// ---- 通用审计 ---- + +type AuditEvent struct { + ID int64 `json:"id"` + Time string `json:"time"` + Owner string `json:"owner"` + Action string `json:"action"` + Actor string `json:"actor,omitempty"` + Target string `json:"target,omitempty"` + Evidence string `json:"evidence,omitempty"` + TraceID string `json:"traceId,omitempty"` +} + +func (s *Store) AppendAuditEvent(ev AuditEvent) error { + if ev.Time == "" { + ev.Time = time.Now().UTC().Format(time.RFC3339) + } + _, err := s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,evidence,trace_id) VALUES(?,?,?,?,?,?,?)`), + ev.Time, ev.Owner, ev.Action, ev.Actor, ev.Target, ev.Evidence, ev.TraceID) + return err +} + +func (s *Store) ListAuditEvents(owner string, limit int) ([]AuditEvent, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.db.Query(s.q(`SELECT id,time,owner,action,actor,target,evidence,trace_id FROM audit_events + WHERE owner=? OR ?='' ORDER BY id DESC LIMIT ?`), owner, owner, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AuditEvent + for rows.Next() { + var ev AuditEvent + if err := rows.Scan(&ev.ID, &ev.Time, &ev.Owner, &ev.Action, &ev.Actor, &ev.Target, &ev.Evidence, &ev.TraceID); err != nil { + return nil, err + } + out = append(out, ev) + } + return out, rows.Err() +} + +// ---- 危险命令确认挂起态 ---- + +func (s *Store) SavePendingConfirm(cardID, owner, payload string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec(s.q(`INSERT INTO pending_confirms(card_id,owner,payload,created_at) VALUES(?,?,?,?) + ON CONFLICT(card_id) DO UPDATE SET owner=excluded.owner, payload=excluded.payload, created_at=excluded.created_at`), + cardID, owner, payload, now) + return err +} + +func (s *Store) TakePendingConfirm(cardID string) (owner, payload string, ok bool, err error) { + tx, err := s.db.Begin() + if err != nil { + return "", "", false, err + } + defer tx.Rollback() + err = tx.QueryRow(s.q(`SELECT owner, payload FROM pending_confirms WHERE card_id=?`), cardID).Scan(&owner, &payload) + if err == sql.ErrNoRows { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + if _, err := tx.Exec(s.q(`DELETE FROM pending_confirms WHERE card_id=?`), cardID); err != nil { + return "", "", false, err + } + if err := tx.Commit(); err != nil { + return "", "", false, err + } + return owner, payload, true, nil +} + +// ---- LLM 日预算 ---- + +func (s *Store) AddDailyTokens(owner, day string, tokens int) (int, error) { + if day == "" { + day = time.Now().UTC().Format("2006-01-02") + } + if _, err := s.db.Exec(s.q(`INSERT INTO llm_usage_daily(owner,day,tokens) VALUES(?,?,?) + ON CONFLICT(owner, day) DO UPDATE SET tokens=llm_usage_daily.tokens + excluded.tokens`), owner, day, tokens); err != nil { + return 0, err + } + var total int + err := s.db.QueryRow(s.q(`SELECT tokens FROM llm_usage_daily WHERE owner=? AND day=?`), owner, day).Scan(&total) + return total, err +} + +// BudgetStatus 是一次记账的结果。 +type BudgetStatus struct { + Total int + Over bool +} + +func (s *Store) AccountTokens(owner string, tokens, budget int) (BudgetStatus, error) { + total, err := s.AddDailyTokens(owner, "", tokens) + if err != nil { + return BudgetStatus{}, fmt.Errorf("llm usage: %w", err) + } + return BudgetStatus{Total: total, Over: budget > 0 && total > budget}, nil +} diff --git a/internal/store/platform_test.go b/internal/store/platform_test.go new file mode 100644 index 0000000..c6b3bea --- /dev/null +++ b/internal/store/platform_test.go @@ -0,0 +1,97 @@ +package store + +import ( + "path/filepath" + "testing" + "time" + + core "agent-system-core" +) + +func TestPlatformTables(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "p.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + + if err := st.SetPluginEnabled("acme", "rdm", false); err != nil { + t.Fatal(err) + } + on, err := st.PluginEnabled("acme", "rdm") + if err != nil || on { + t.Fatalf("disabled grant: on=%v err=%v", on, err) + } + on, err = st.PluginEnabled("acme", "other") + if err != nil || !on { + t.Fatalf("missing grant defaults to enabled: on=%v err=%v", on, err) + } + + if err := st.SetPluginSchemaVersion("rdm", 2); err != nil { + t.Fatal(err) + } + if v, err := st.PluginSchemaVersion("rdm"); err != nil || v != 2 { + t.Fatalf("schema: %d %v", v, err) + } + if err := st.PutPluginKV("rdm", "acme", "slice", "s1"); err != nil { + t.Fatal(err) + } + got, ok, err := st.GetPluginKV("rdm", "acme", "slice") + if err != nil || !ok || got != "s1" { + t.Fatalf("kv: %q ok=%v err=%v", got, ok, err) + } + + task, err := st.CreateBackgroundTask("acme", "ingest", "入库", "") + if err != nil { + t.Fatal(err) + } + if task.ConversationID != "" || task.Status != "queued" { + t.Fatalf("task should not require a conversation: %+v", task) + } + if err := st.UpdateBackgroundTask("acme", task.ID, "done", 100, ""); err != nil { + t.Fatal(err) + } + task, err = st.GetBackgroundTask("acme", task.ID) + if err != nil || task.Status != "done" || task.Progress != 100 { + t.Fatalf("updated: %+v %v", task, err) + } + + exp := time.Now().UTC().Add(time.Hour) + mem, err := st.PutMemory("acme", core.MemoryEntry{Scope: "user", Type: "preference", Content: "用中文", Source: "manual", ExpiresAt: &exp}) + if err != nil || mem.ID == "" { + t.Fatal(err) + } + list, err := st.ListMemory("acme", "user", "") + if err != nil || len(list) != 1 || list[0].Content != "用中文" || list[0].ExpiresAt == nil { + t.Fatalf("memory: %+v %v", list, err) + } + + if err := st.AppendAuditEvent(AuditEvent{Owner: "acme", Action: "upload", Target: "f", Evidence: `{"ok":true}`, TraceID: "tr"}); err != nil { + t.Fatal(err) + } + evs, err := st.ListAuditEvents("acme", 10) + if err != nil || len(evs) != 1 || evs[0].Action != "upload" || evs[0].TraceID != "tr" { + t.Fatalf("audit: %+v %v", evs, err) + } + + if err := st.SavePendingConfirm("card1", "acme", `{"id":"x"}`); err != nil { + t.Fatal(err) + } + owner, payload, ok, err := st.TakePendingConfirm("card1") + if err != nil || !ok || owner != "acme" || payload != `{"id":"x"}` { + t.Fatalf("take: %s %s %v %v", owner, payload, ok, err) + } + _, _, ok, err = st.TakePendingConfirm("card1") + if err != nil || ok { + t.Fatalf("second take must miss: ok=%v err=%v", ok, err) + } + + st1, err := st.AccountTokens("acme", 30, 40) + if err != nil || st1.Total != 30 || st1.Over { + t.Fatalf("under budget: %+v %v", st1, err) + } + st2, err := st.AccountTokens("acme", 20, 40) + if err != nil || st2.Total != 50 || !st2.Over { + t.Fatalf("over budget: %+v %v", st2, err) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 8a630a1..18fb493 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 12 { - t.Fatalf("expected versions [1..12], got %v", got) + if len(got) != 13 { + t.Fatalf("expected versions [1..13], got %v", got) } // 老库形态(v2 前):events 全局自增、settings 单列主键。 if err := st.MigrateDown(1); err != nil { diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go new file mode 100644 index 0000000..6bf70f2 --- /dev/null +++ b/pkg/extension/extension.go @@ -0,0 +1,86 @@ +// Package extension 是基座对下游仓的公开扩展面。 +// 下游只能依赖本包(以及 agent-system-core 的类型),不能导入 internal。 +package extension + +import ( + "encoding/json" + "errors" + "sync" +) + +// SurfaceVersion 是本包导出面的实验版本。变更导出符号必须同时更新 surface.txt。 +const SurfaceVersion = "2.0.0-experimental" + +// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。 +var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") + +// Registry 是进程内扩展的注册口。实现在基座内部,对下游只暴露这些方法。 +type Registry interface { + AddSkill(name, markdown string) error + AddCommand(spec json.RawMessage) error + Note(kind, name string, payload json.RawMessage) error +} + +// InProc 是随下游二进制编译进来的扩展。 +type InProc struct { + ID string + Register func(reg Registry) error +} + +// Options 是下游自有二进制的启动参数,字段与 agent-system serve 对齐。 +type Options struct { + Addr string + DataDir string + PluginsDir string + Mock bool + StorageDSN string + ArtifactsDir string + AuthMode string +} + +var ( + mu sync.Mutex + compiled []InProc + runner func(Options) error +) + +// RegisterCompiled 在 init 中登记进程内扩展。 +func RegisterCompiled(e InProc) { + mu.Lock() + defer mu.Unlock() + compiled = append(compiled, e) +} + +// Compiled 返回已登记扩展的副本。 +func Compiled() []InProc { + mu.Lock() + defer mu.Unlock() + out := make([]InProc, len(compiled)) + copy(out, compiled) + return out +} + +// ResetCompiled 只给测试清状态。 +func ResetCompiled() { + mu.Lock() + compiled = nil + mu.Unlock() +} + +// SetRunner 由 link 包在 init 时注入。下游不要调用。 +func SetRunner(fn func(Options) error) { + mu.Lock() + runner = fn + mu.Unlock() +} + +// Main 启动带有已登记扩展的服务。 +func Main(opt Options) error { + mu.Lock() + fn := runner + mu.Unlock() + if fn == nil { + return ErrNotLinked + } + return fn(opt) +} diff --git a/pkg/extension/hostclient.go b/pkg/extension/hostclient.go new file mode 100644 index 0000000..ff6db6d --- /dev/null +++ b/pkg/extension/hostclient.go @@ -0,0 +1,92 @@ +package extension + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// HostClient 是 sidecar / 外部进程调用基座 Host API 的客户端。 +type HostClient struct { + BaseURL string + Token string + PluginID string + Owner string + HTTP *http.Client +} + +// NewHostClient 用基座注入的地址和令牌构造客户端。 +func NewHostClient(baseURL, token, pluginID, owner string) *HostClient { + return &HostClient{ + BaseURL: strings.TrimRight(baseURL, "/"), Token: token, PluginID: pluginID, Owner: owner, + HTTP: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (c *HostClient) PutKV(ctx context.Context, key, value string) error { + return c.do(ctx, http.MethodPut, "/host/v1/kv/"+url.PathEscape(key), strings.NewReader(value), nil) +} + +func (c *HostClient) GetKV(ctx context.Context, key string) (string, error) { + var out struct { + Value string `json:"value"` + } + if err := c.do(ctx, http.MethodGet, "/host/v1/kv/"+url.PathEscape(key), nil, &out); err != nil { + return "", err + } + return out.Value, nil +} + +func (c *HostClient) Audit(ctx context.Context, action, target string, evidence json.RawMessage) error { + body, _ := json.Marshal(map[string]any{"action": action, "target": target, "evidence": string(evidence)}) + return c.do(ctx, http.MethodPost, "/host/v1/audit", bytes.NewReader(body), nil) +} + +func (c *HostClient) Dispatch(ctx context.Context, commandID string, params map[string]any) error { + body, _ := json.Marshal(map[string]any{"params": params}) + return c.do(ctx, http.MethodPost, "/host/v1/commands/"+url.PathEscape(commandID), bytes.NewReader(body), nil) +} + +func (c *HostClient) do(ctx context.Context, method, path string, body io.Reader, dest any) error { + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, body) + if err != nil { + return err + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + if c.PluginID != "" { + req.Header.Set("X-Plugin-Id", c.PluginID) + } + if c.Owner != "" { + req.Header.Set("X-Owner", c.Owner) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := c.HTTP + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return fmt.Errorf("host api %s %s: %d %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw))) + } + if dest != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, dest); err != nil { + return err + } + } + return nil +} diff --git a/pkg/extension/link/link.go b/pkg/extension/link/link.go new file mode 100644 index 0000000..ebcc0b2 --- /dev/null +++ b/pkg/extension/link/link.go @@ -0,0 +1,17 @@ +// Package link 把 extension.Main 接到产品组合根。 +// 下游自有二进制空白导入本包;基座 internal 不反向依赖它。 +package link + +import ( + "agent-system-backend/internal/app" + "agent-system-backend/pkg/extension" +) + +func init() { + extension.SetRunner(func(opt extension.Options) error { + return app.Run(app.Options{ + Addr: opt.Addr, DataDir: opt.DataDir, PluginsDir: opt.PluginsDir, ForceMock: opt.Mock, + StorageDSN: opt.StorageDSN, ArtifactsDir: opt.ArtifactsDir, AuthMode: opt.AuthMode, + }) + }) +} diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt new file mode 100644 index 0000000..4381290 --- /dev/null +++ b/pkg/extension/surface.txt @@ -0,0 +1,12 @@ +Compiled +ErrNotLinked +HostClient +InProc +Main +NewHostClient +Options +RegisterCompiled +Registry +ResetCompiled +SetRunner +SurfaceVersion diff --git a/pkg/extension/surface_test.go b/pkg/extension/surface_test.go new file mode 100644 index 0000000..ee2ae47 --- /dev/null +++ b/pkg/extension/surface_test.go @@ -0,0 +1,69 @@ +package extension + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func TestSurfaceSnapshot(t *testing.T) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", func(info os.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatal(err) + } + pkg := pkgs["extension"] + if pkg == nil { + t.Fatal("package extension not parsed") + } + var names []string + for _, f := range pkg.Files { + for _, d := range f.Decls { + switch n := d.(type) { + case *ast.FuncDecl: + if n.Recv == nil && n.Name.IsExported() { + names = append(names, n.Name.Name) + } + case *ast.GenDecl: + for _, spec := range n.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + if s.Name.IsExported() { + names = append(names, s.Name.Name) + } + case *ast.ValueSpec: + for _, name := range s.Names { + if name.IsExported() { + names = append(names, name.Name) + } + } + } + } + } + } + } + sort.Strings(names) + got := strings.Join(names, "\n") + "\n" + path := filepath.Join("surface.txt") + want, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got != string(want) { + t.Fatalf("public surface drifted; update surface.txt if the change is intentional\n got:\n%s\nwant:\n%s", got, want) + } +} + +func TestMainWithoutLink(t *testing.T) { + SetRunner(nil) + if err := Main(Options{}); err != ErrNotLinked { + t.Fatalf("got %v", err) + } +}