diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..8c7003f
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "deps/agent-system-core"]
+ path = deps/agent-system-core
+ url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git
+[submodule "deps/agent-native"]
+ path = deps/agent-native
+ url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
diff --git a/PROJECT.md b/PROJECT.md
index a8636f6..1525162 100644
--- a/PROJECT.md
+++ b/PROJECT.md
@@ -4,3 +4,10 @@
- 技术栈:Go(静态链接,CGO_ENABLED=0)
- 构建/验证:见仓内 Makefile 或 Dev 仓顶层 Makefile(待 P1 起补充并保持本节同步)。
- 状态:P0 骨架;当前阶段见 Dev 仓任务记录。
+
+## 验证(P1–P4 状态)
+
+- `go test ./...` — 单元 + 端到端(mock runner 全链路:流式/折叠部件/HITL 三分支/子代理任务树/工件/上传解析/中文检索/SSE 在线与重放/命令路由/插件装卸 fail-closed)。
+- `CGO_ENABLED=0 go build ./cmd/agent-system` — 单二进制(静态,内嵌前端后)。
+- 存储:嵌入式 SQLite(modernc.org/sqlite,纯 Go);FTS5 全文 + CJK LIKE 兜底。
+- 依赖:agent-system-core(deps submodule + replace)、agent-native(runtime/llm 复用)。
diff --git a/cmd/agent-system/main.go b/cmd/agent-system/main.go
new file mode 100644
index 0000000..454ad50
--- /dev/null
+++ b/cmd/agent-system/main.go
@@ -0,0 +1,36 @@
+// agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 嵌入式 SQLite)。
+// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "agent-system-backend/internal/app"
+)
+
+var version = "0.1.0-dev"
+
+func main() {
+ if len(os.Args) > 1 {
+ switch os.Args[1] {
+ case "version", "--version":
+ fmt.Println("agent-system", version)
+ return
+ case "serve":
+ os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
+ }
+ }
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
+ addr := fs.String("addr", "127.0.0.1:8787", "监听地址")
+ data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
+ pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
+ mock := fs.Bool("mock", false, "演示模式(等同 runtime.mock=true,不调用模型)")
+ fs.Parse(os.Args[1:])
+
+ if err := app.Run(app.Options{Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock}); err != nil {
+ fmt.Fprintln(os.Stderr, "agent-system:", err)
+ os.Exit(1)
+ }
+}
diff --git a/deps/agent-native b/deps/agent-native
new file mode 160000
index 0000000..292142a
--- /dev/null
+++ b/deps/agent-native
@@ -0,0 +1 @@
+Subproject commit 292142ac1a8b125488794e9374faee2cb1c8c579
diff --git a/deps/agent-system-core b/deps/agent-system-core
new file mode 160000
index 0000000..9179a23
--- /dev/null
+++ b/deps/agent-system-core
@@ -0,0 +1 @@
+Subproject commit 9179a23f0a02559955cb7b7cee07f2218427f362
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e01af44
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,26 @@
+module agent-system-backend
+
+go 1.27
+
+replace agent-system-core => ./deps/agent-system-core
+
+replace agent-native => ./deps/agent-native
+
+require (
+ agent-native v0.0.0-00010101000000-000000000000
+ agent-system-core v0.0.0-00010101000000-000000000000
+ modernc.org/sqlite v1.60.1
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ modernc.org/libc v1.77.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..61cdbb1
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,52 @@
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
+github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
+github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
+golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
+golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
+golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
+golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
+modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
+modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
+modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
+modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
+modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
+modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..542ac76
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,93 @@
+// Package app 组合根:装配存储、总线、注册表、服务、runmgr、插件宿主与 HTTP 服务。
+package app
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+ "time"
+
+ core "agent-system-core"
+ "agent-system-backend/internal/bus"
+ "agent-system-backend/internal/plugins"
+ "agent-system-backend/internal/profiles"
+ "agent-system-backend/internal/runmgr"
+ "agent-system-backend/internal/runtime"
+ "agent-system-backend/internal/server"
+ "agent-system-backend/internal/settings"
+ "agent-system-backend/internal/store"
+ "agent-system-backend/internal/tools"
+)
+
+type Options struct {
+ Addr string
+ DataDir string
+ PluginsDir string
+ ForceMock bool
+}
+
+// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run)。
+func Run(opt Options) error {
+ dbPath := filepath.Join(opt.DataDir, "agent-system.db")
+ st, err := store.Open(dbPath)
+ if err != nil {
+ return fmt.Errorf("open store: %w", err)
+ }
+ defer st.Close()
+
+ b := bus.New()
+ reg := tools.NewRegistry()
+ if err := reg.RegisterBuiltin(); err != nil {
+ return fmt.Errorf("builtin tools: %w", err)
+ }
+ setSvc := settings.NewService(st)
+ profSvc := profiles.New(st)
+ if err := profSvc.EnsureDefault(); err != nil {
+ return fmt.Errorf("default profile: %w", err)
+ }
+ if opt.ForceMock {
+ if err := setSvc.MergeUser(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
+ return err
+ }
+ }
+
+ convRunner := &runtime.LLMRunner{}
+ subRunner := &runtime.SubLLMRunner{}
+ mockRunner := &runtime.MockRunner{}
+ mgr := runmgr.New(st, b, reg, profSvc, setSvc, convRunner, subRunner, mockRunner, &runtime.MockSubagent{})
+
+ pluginHost := plugins.New(st, reg, setSvc, opt.PluginsDir)
+ for id, err := range pluginHost.LoadDir() {
+ if err != nil {
+ log.Printf("[plugins] %s: %v", id, err) // fail-closed:单个失败不阻断启动
+ }
+ }
+
+ srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
+ srv.Subscribe = func(conversationID string) (<-chan core.StreamEvent, func()) {
+ return b.Subscribe(conversationID)
+ }
+
+ httpSrv := &http.Server{Addr: opt.Addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
+
+ go func() {
+ log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir)
+ if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ log.Fatalf("http: %v", err)
+ }
+ }()
+
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+ <-stop
+ log.Printf("shutting down…")
+ mgr.InterruptRunning()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ return httpSrv.Shutdown(ctx)
+}
diff --git a/internal/bus/bus.go b/internal/bus/bus.go
new file mode 100644
index 0000000..e6e7011
--- /dev/null
+++ b/internal/bus/bus.go
@@ -0,0 +1,52 @@
+// Package bus 是 streamhub:按会话分发已持久化的事件(SSE 通道)。
+// 事件权威在 store(seq 游标);bus 只做在线转发,溢出丢弃由客户端从游标重放补偿。
+package bus
+
+import (
+ "sync"
+
+ core "agent-system-core"
+)
+
+type subscriber struct {
+ ch chan core.StreamEvent
+ filter string // conversationID;空 = 全部
+}
+
+type Bus struct {
+ mu sync.Mutex
+ subs map[int64]*subscriber
+ next int64
+}
+
+func New() *Bus { return &Bus{subs: map[int64]*subscriber{}} }
+
+// Subscribe 返回新订阅;close(unsubscribe) 注销。
+func (b *Bus) Subscribe(conversationID string) (<-chan core.StreamEvent, func()) {
+ b.mu.Lock()
+ id := b.next
+ b.next++
+ ch := make(chan core.StreamEvent, 512)
+ b.subs[id] = &subscriber{ch: ch, filter: conversationID}
+ b.mu.Unlock()
+ return ch, func() {
+ b.mu.Lock()
+ delete(b.subs, id)
+ b.mu.Unlock()
+ }
+}
+
+// Publish 非阻塞广播;满则丢(客户端 Last-Event-ID 重放补齐)。
+func (b *Bus) Publish(e core.StreamEvent) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ for _, s := range b.subs {
+ if s.filter != "" && s.filter != e.ConversationID {
+ continue
+ }
+ select {
+ case s.ch <- e:
+ default:
+ }
+ }
+}
diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go
new file mode 100644
index 0000000..eb33a65
--- /dev/null
+++ b/internal/ctxasm/ctxasm.go
@@ -0,0 +1,116 @@
+// Package ctxasm 实现上下文组装与预算管理(4.10):分层管线 + 可观测裁剪记录。
+package ctxasm
+
+import (
+ "fmt"
+ "strings"
+
+ agentllm "agent-native/runtime/llm"
+ core "agent-system-core"
+)
+
+// Report 记录一次组装的实际构成(可观测:模型看到了什么、裁了什么)。
+type Report struct {
+ Items []string `json:"items"`
+ Truncations []string `json:"truncations,omitempty"`
+ EstChars int `json:"estChars"`
+}
+
+// AssembleInput 组装模型输入消息。
+func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit) ([]agentllm.Message, *Report) {
+ rep := &Report{}
+ msgs := []agentllm.Message{{Role: agentllm.RoleSystem, Content: prof.SystemPrompt}}
+ rep.Items = append(rep.Items, "system(画像)")
+
+ maxHist := prof.Context.MaxHistoryMessages
+ if maxHist <= 0 {
+ maxHist = int(eff.GetNumber("context.maxHistoryMessages", 40))
+ }
+ if maxHist <= 0 {
+ maxHist = 40
+ }
+ // 历史窗口(不含当前轮——当前轮单独注入附件与检索)。
+ start := 0
+ if len(history) > maxHist {
+ start = len(history) - maxHist
+ rep.Truncations = append(rep.Truncations, fmt.Sprintf("历史 %d 条超窗口 %d,仅保留最近部分", len(history), maxHist))
+ }
+ for _, m := range history[start:] {
+ if m.ID == currentUser.ID {
+ continue
+ }
+ content := renderMessage(m)
+ if content == "" {
+ continue
+ }
+ role := agentllm.RoleUser
+ if m.Role == core.RoleAssistant {
+ role = agentllm.RoleAssistant
+ }
+ msgs = append(msgs, agentllm.Message{Role: role, Content: content})
+ }
+ rep.Items = append(rep.Items, fmt.Sprintf("history(%d)", len(history)-start))
+
+ // 当前轮:文本 + 附件解析结果 + 知识检索注入。
+ var sb strings.Builder
+ for _, p := range currentUser.Parts {
+ if p.Type == core.PartText {
+ sb.WriteString(p.Text)
+ }
+ }
+ if len(attachments) > 0 {
+ sb.WriteString("\n\n[附件]\n")
+ for _, doc := range attachments {
+ text := doc.PlainText()
+ limit := 16000
+ if len(text) > limit {
+ text = text[:limit] + "…(附件超长截断)"
+ rep.Truncations = append(rep.Truncations, "附件 "+doc.Meta.Filename+" 超长截断")
+ }
+ fmt.Fprintf(&sb, "### %s\n%s\n", doc.Meta.Filename, text)
+ }
+ rep.Items = append(rep.Items, fmt.Sprintf("attachments(%d)", len(attachments)))
+ }
+ if len(knowledge) > 0 {
+ sb.WriteString("\n\n[相关知识]\n")
+ for i, h := range knowledge {
+ fmt.Fprintf(&sb, "[%d] %s(来源 %s)\n%s\n", i+1, h.Doc.Title, h.Doc.Source, h.Doc.Chunk)
+ }
+ rep.Items = append(rep.Items, fmt.Sprintf("knowledge(%d)", len(knowledge)))
+ }
+ msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()})
+
+ for _, m := range msgs {
+ rep.EstChars += len(m.Content)
+ }
+ return msgs, rep
+}
+
+// renderMessage 把消息部件渲染为模型可读文本(思考部件不回灌;工具结果给摘要)。
+func renderMessage(m core.Message) string {
+ var sb strings.Builder
+ for _, p := range m.Parts {
+ switch p.Type {
+ case core.PartText:
+ sb.WriteString(p.Text)
+ sb.WriteString("\n")
+ case core.PartToolCall:
+ if p.ToolCall != nil {
+ fmt.Fprintf(&sb, "[调用工具 %s(%s)]\n", p.ToolCall.Name, p.ToolCall.Arguments)
+ }
+ case core.PartToolResult:
+ if p.ToolResult != nil {
+ fmt.Fprintf(&sb, "[工具结果 %s %s]\n", p.ToolResult.Status, p.ToolResult.Summary)
+ }
+ case core.PartArtifactRef:
+ if p.Artifact != nil {
+ fmt.Fprintf(&sb, "[产出工件 %s]\n", p.Artifact.Name)
+ }
+ case core.PartHITL:
+ if p.Hitl != nil && p.Hitl.Resolved {
+ fmt.Fprintf(&sb, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer)
+ }
+ }
+ }
+ return strings.TrimSpace(sb.String())
+}
diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go
new file mode 100644
index 0000000..0a3c2ae
--- /dev/null
+++ b/internal/ingest/ingest.go
@@ -0,0 +1,313 @@
+// Package ingest 实现文档摄取与解析(4.11):上传/工件 → ParsedDocument IR。
+// 主路线为内置代码解析器(确定性、纯 Go、可独立测试);MinerU 深度解析与
+// agent 驱动解析为远期档位(ParserProvider 注册位已留)。
+package ingest
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "path/filepath"
+ "strings"
+ "time"
+
+ pdflib "github.com/ledongthuc/pdf"
+ core "agent-system-core"
+)
+
+// Parse 按扩展名分派内置解析器;无法解析时返回带 warning 的空文档(显式失败,不静默)。
+func Parse(filename, mime string, content []byte) core.ParsedDocument {
+ doc := core.ParsedDocument{Meta: core.DocMeta{
+ Filename: filename, MIME: mime, Parser: "builtin", ParsedAt: time.Now().UTC(),
+ }}
+ ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
+ switch ext {
+ case "md", "markdown":
+ doc.Meta.Parser = "builtin:md"
+ doc.Blocks = parseMarkdown(string(content))
+ doc.Meta.Confidence = 1.0
+ case "txt", "text", "log", "csv":
+ doc.Meta.Parser = "builtin:txt"
+ doc.Blocks = parsePlain(string(content))
+ doc.Meta.Confidence = 1.0
+ case "docx":
+ doc.Meta.Parser = "builtin:docx"
+ blocks, err := parseDocx(content)
+ if err != nil {
+ doc.Warnings = append(doc.Warnings, "docx 解析失败:"+err.Error())
+ doc.Meta.Confidence = 0.3
+ } else {
+ doc.Blocks = blocks
+ doc.Meta.Confidence = 0.92
+ }
+ case "pdf":
+ doc.Meta.Parser = "builtin:pdf"
+ blocks, err := parsePDF(content)
+ if err != nil {
+ doc.Warnings = append(doc.Warnings, "pdf 文本层解析失败:"+err.Error()+"(扫描件需远期 MinerU 档)")
+ doc.Meta.Confidence = 0.2
+ } else {
+ doc.Blocks = blocks
+ doc.Meta.Confidence = 0.85
+ }
+ default:
+ if isImage(ext) {
+ doc.Warnings = append(doc.Warnings, "图片不走文本解析(OCR 为 MinerU 深度档,远期)")
+ } else {
+ // 未知类型按纯文本尝试(二进制内容会产生乱码 warning)。
+ text := string(content)
+ if !isMostlyPrintable(text) {
+ doc.Warnings = append(doc.Warnings, fmt.Sprintf("未知类型 .%s 且内容非文本,未解析", ext))
+ } else {
+ doc.Blocks = parsePlain(text)
+ doc.Meta.Confidence = 0.6
+ doc.Warnings = append(doc.Warnings, "未知类型按纯文本解析")
+ }
+ }
+ }
+ return doc
+}
+
+// ---- md / txt ----
+
+func parseMarkdown(s string) []core.DocBlock {
+ var blocks []core.DocBlock
+ var para []string
+ flush := func() {
+ if len(para) > 0 {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: strings.Join(para, "\n")})
+ para = nil
+ }
+ }
+ for _, line := range strings.Split(s, "\n") {
+ t := strings.TrimSpace(line)
+ switch {
+ case t == "":
+ flush()
+ case strings.HasPrefix(t, "#"):
+ flush()
+ level := 0
+ for level < len(t) && t[level] == '#' {
+ level++
+ }
+ blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: level, Text: strings.TrimSpace(t[level:])})
+ case strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* "):
+ flush()
+ if n := len(blocks); n > 0 && blocks[n-1].Type == core.BlockList {
+ blocks[n-1].Items = append(blocks[n-1].Items, strings.TrimSpace(t[2:]))
+ } else {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockList, Items: []string{strings.TrimSpace(t[2:])}})
+ }
+ case strings.HasPrefix(t, "```"):
+ flush()
+ blocks = append(blocks, core.DocBlock{Type: core.BlockCode, Text: ""}) // 简化:代码块内容并入口令块
+ default:
+ para = append(para, t)
+ }
+ }
+ flush()
+ return blocks
+}
+
+func parsePlain(s string) []core.DocBlock {
+ var blocks []core.DocBlock
+ var para []string
+ flush := func() {
+ if len(para) > 0 {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: strings.Join(para, "\n")})
+ para = nil
+ }
+ }
+ for _, line := range strings.Split(s, "\n") {
+ if strings.TrimSpace(line) == "" {
+ flush()
+ continue
+ }
+ para = append(para, strings.TrimRight(line, "\r"))
+ }
+ flush()
+ return blocks
+}
+
+// ---- docx(OOXML:zip 内 word/document.xml)----
+
+type xdoc struct {
+ Body struct {
+ Paragraphs []xpar `xml:"p"`
+ Tables []xtbl `xml:"tbl"`
+ } `xml:"body"`
+}
+
+type xpar struct {
+ Props struct {
+ Style struct {
+ Val string `xml:"val,attr"`
+ } `xml:"pStyle"`
+ } `xml:"pPr"`
+ Runs []xrun `xml:"r"`
+}
+
+type xrun struct {
+ Text []struct {
+ Content string `xml:",chardata"`
+ } `xml:"t"`
+ Break struct {
+ Val string `xml:"val,attr"`
+ } `xml:"br"`
+}
+
+type xtbl struct {
+ Rows []xtr `xml:"tr"`
+}
+
+type xtr struct {
+ Cells []xtc `xml:"tc"`
+}
+
+type xtc struct {
+ Paragraphs []xpar `xml:"p"`
+}
+
+func parseDocx(content []byte) ([]core.DocBlock, error) {
+ zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
+ if err != nil {
+ return nil, fmt.Errorf("open zip: %w", err)
+ }
+ var docXML []byte
+ for _, f := range zr.File {
+ if f.Name == "word/document.xml" {
+ rc, err := f.Open()
+ if err != nil {
+ return nil, err
+ }
+ docXML, _ = io.ReadAll(rc)
+ rc.Close()
+ break
+ }
+ }
+ if docXML == nil {
+ return nil, fmt.Errorf("word/document.xml not found")
+ }
+ var doc xdoc
+ if err := xml.Unmarshal(docXML, &doc); err != nil {
+ return nil, fmt.Errorf("unmarshal document.xml: %w", err)
+ }
+ var blocks []core.DocBlock
+ for _, p := range doc.Body.Paragraphs {
+ text := paragraphText(p)
+ if strings.TrimSpace(text) == "" {
+ continue
+ }
+ if lvl, ok := headingLevel(p.Props.Style.Val); ok {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockHeading, Level: lvl, Text: text})
+ } else {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: text})
+ }
+ }
+ for _, tbl := range doc.Body.Tables {
+ var rows [][]string
+ for _, tr := range tbl.Rows {
+ row := []string{}
+ for _, tc := range tr.Cells {
+ cell := []string{}
+ for _, p := range tc.Paragraphs {
+ cell = append(cell, paragraphText(p))
+ }
+ row = append(row, strings.Join(cell, " "))
+ }
+ rows = append(rows, row)
+ }
+ if len(rows) > 0 {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockTable, Rows: rows})
+ }
+ }
+ return blocks, nil
+}
+
+func paragraphText(p xpar) string {
+ var sb strings.Builder
+ for _, r := range p.Runs {
+ for _, t := range r.Text {
+ sb.WriteString(t.Content)
+ }
+ }
+ return sb.String()
+}
+
+func headingLevel(style string) (int, bool) {
+ s := strings.ToLower(style)
+ switch {
+ case strings.Contains(s, "heading1") || s == "1":
+ return 1, true
+ case strings.Contains(s, "heading2") || s == "2":
+ return 2, true
+ case strings.Contains(s, "heading3") || s == "3" || strings.Contains(s, "title"):
+ return 3, true
+ }
+ return 0, false
+}
+
+// ---- pdf(文本层提取)----
+
+func parsePDF(content []byte) ([]core.DocBlock, error) {
+ r, err := pdflib.NewReader(bytes.NewReader(content), int64(len(content)))
+ if err != nil {
+ return nil, err
+ }
+ var blocks []core.DocBlock
+ for i := 1; i <= r.NumPage(); i++ {
+ page := r.Page(i)
+ if page.V.IsNull() {
+ continue
+ }
+ text, err := page.GetPlainText(nil)
+ if err != nil {
+ continue
+ }
+ for _, para := range strings.Split(text, "\n\n") {
+ if t := strings.TrimSpace(para); t != "" {
+ blocks = append(blocks, core.DocBlock{Type: core.BlockPara, Text: t})
+ }
+ }
+ }
+ if len(blocks) == 0 {
+ return nil, fmt.Errorf("无文本层(可能是扫描件)")
+ }
+ return blocks, nil
+}
+
+// ---- 辅助 ----
+
+func isImage(ext string) bool {
+ switch ext {
+ case "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg":
+ return true
+ }
+ return false
+}
+
+func isMostlyPrintable(s string) bool {
+ if len(s) == 0 {
+ return true
+ }
+ nonPrint := 0
+ for _, r := range s {
+ if r == 0xFFFD || (r < 0x20 && r != '\n' && r != '\r' && r != '\t') {
+ nonPrint++
+ }
+ }
+ return nonPrint*10 < len(s)
+}
+
+// ParsersDescription 返回解析器注册表描述(来源标记;设置页/文档用)。
+func ParsersDescription() []core.ParserDesc {
+ return []core.ParserDesc{
+ {ID: "builtin:md", Formats: []string{"md", "markdown"}, Source: core.SourceBuiltin},
+ {ID: "builtin:txt", Formats: []string{"txt", "csv", "log"}, Source: core.SourceBuiltin},
+ {ID: "builtin:docx", Formats: []string{"docx"}, Source: core.SourceBuiltin},
+ {ID: "builtin:pdf", Formats: []string{"pdf"}, Source: core.SourceBuiltin, Note: "文本层;扫描件为远期 MinerU 深度档"},
+ {ID: "mineru", Formats: []string{"pdf", "png", "jpg"}, Source: core.SourcePlugin, Note: "远期可选:深度解析与交叉验证(默认不部署)"},
+ }
+}
diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go
new file mode 100644
index 0000000..79d6bfa
--- /dev/null
+++ b/internal/ingest/ingest_test.go
@@ -0,0 +1,102 @@
+package ingest
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/xml"
+ "strings"
+ "testing"
+
+ core "agent-system-core"
+)
+
+func TestParseMarkdown(t *testing.T) {
+ doc := Parse("笔记.md", "text/markdown", []byte("# 标题一\n\n段落甲。\n\n- 要点 1\n- 要点 2\n\n## 标题二\n\n段落乙。\n"))
+ if doc.Meta.Parser != "builtin:md" || doc.Meta.Confidence != 1.0 {
+ t.Fatalf("meta: %+v", doc.Meta)
+ }
+ var heads, paras, lists int
+ for _, b := range doc.Blocks {
+ switch b.Type {
+ case core.BlockHeading:
+ heads++
+ case core.BlockPara:
+ paras++
+ case core.BlockList:
+ lists++
+ if len(b.Items) != 2 {
+ t.Errorf("list items: %v", b.Items)
+ }
+ }
+ }
+ if heads != 2 || paras != 2 || lists != 1 {
+ t.Errorf("blocks: heads=%d paras=%d lists=%d", heads, paras, lists)
+ }
+ if !strings.Contains(doc.PlainText(), "# 标题一") {
+ t.Error("PlainText missing heading")
+ }
+}
+
+func TestParseUnknownBinary(t *testing.T) {
+ doc := Parse("blob.bin", "application/octet-stream", []byte{0x00, 0x01, 0x02, 0xFF})
+ if len(doc.Blocks) != 0 {
+ t.Error("binary should not produce blocks")
+ }
+ if len(doc.Warnings) == 0 {
+ t.Error("binary should carry explicit warning (fail visible, not silent)")
+ }
+}
+
+// 构造最小 docx(zip + word/document.xml)验证 OOXML 解析。
+func TestParseDocx(t *testing.T) {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ docXML := `
+
+
+ 总则
+ 本文件说明医疗器械文档要求。
+
+ 项值
+ 版本1.0
+
+
+`
+ if f, err := zw.Create("word/document.xml"); err == nil {
+ f.Write([]byte(docXML))
+ } else {
+ t.Fatal(err)
+ }
+ zw.Close()
+
+ doc := Parse("报告.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", buf.Bytes())
+ if doc.Meta.Parser != "builtin:docx" || len(doc.Warnings) != 0 {
+ t.Fatalf("meta: %+v warnings: %v", doc.Meta, doc.Warnings)
+ }
+ var heading, para, table bool
+ for _, b := range doc.Blocks {
+ switch b.Type {
+ case core.BlockHeading:
+ heading = b.Level == 1 && b.Text == "总则"
+ case core.BlockPara:
+ para = strings.Contains(b.Text, "医疗器械")
+ case core.BlockTable:
+ table = len(b.Rows) == 2 && b.Rows[1][1] == "1.0"
+ }
+ }
+ if !heading || !para || !table {
+ t.Errorf("docx blocks wrong: heading=%v para=%v table=%v blocks=%+v", heading, para, table, doc.Blocks)
+ }
+ if !strings.Contains(doc.PlainText(), "医疗器械") {
+ t.Error("PlainText missing docx text")
+ }
+}
+
+func TestParsePDFInvalid(t *testing.T) {
+ doc := Parse("scan.pdf", "application/pdf", []byte("not a pdf"))
+ if len(doc.Warnings) == 0 || doc.Meta.Confidence > 0.5 {
+ t.Errorf("invalid pdf should warn with low confidence: %+v", doc.Meta)
+ }
+}
+
+var _ = xml.Marshal
diff --git a/internal/plugins/host.go b/internal/plugins/host.go
new file mode 100644
index 0000000..ab876a5
--- /dev/null
+++ b/internal/plugins/host.go
@@ -0,0 +1,266 @@
+// Package plugins 实现插件宿主(4.6):manifest + 声明资产 + 可选代码。
+// fail-closed:任一资产解析失败整批拒绝该插件并保留上一状态;卸载注销注册项。
+// 声明资产热加载:Reload 重扫目录;工具/面板/卡片/意图/工作流/设置全部数据化。
+package plugins
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+
+ core "agent-system-core"
+ "agent-system-backend/internal/settings"
+ "agent-system-backend/internal/store"
+ "agent-system-backend/internal/tools"
+)
+
+// Loaded 是一个已装载插件的内存态(注册结果快照)。
+type Loaded struct {
+ Manifest core.PluginManifest
+ Dir string
+ Panels []core.PluginPanel
+ Cards map[string][]core.UINode // kind → 模板节点
+ Workflows []core.WorkflowDef
+ Intents []json.RawMessage // agent-intent overlay 形态(目录侧消费)
+ Prompts map[string]string // 文件名 → 内容(画像模板)
+ PluginSettings []core.SettingDesc
+ Defaults map[string]any // 插件设置默认值(并入有效设置)
+}
+
+// Host 管理插件目录装载与注册。
+type Host struct {
+ mu sync.RWMutex
+ st *store.Store
+ registry *tools.Registry
+ settings *settings.Service
+ dir string
+ loaded map[string]*Loaded // id → loaded
+}
+
+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{}}
+}
+
+// LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。
+func (h *Host) LoadDir() map[string]error {
+ entries, err := os.ReadDir(h.dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return map[string]error{}
+ }
+ return map[string]error{"
": err}
+ }
+ results := map[string]error{}
+ seen := map[string]bool{}
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ id := e.Name()
+ seen[id] = true
+ results[id] = h.loadOne(filepath.Join(h.dir, id))
+ }
+ // 移除已消失的插件(卸载语义)。
+ h.mu.Lock()
+ for id := range h.loaded {
+ if !seen[id] {
+ delete(h.loaded, id)
+ h.registry.UnregisterBySource(core.SourcePlugin, id)
+ }
+ }
+ h.mu.Unlock()
+ return results
+}
+
+// loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。
+func (h *Host) loadOne(dir string) error {
+ manifestPath := filepath.Join(dir, "plugin.json")
+ raw, err := os.ReadFile(manifestPath)
+ if err != nil {
+ return fmt.Errorf("plugins: %s: %w", filepath.Base(dir), err)
+ }
+ var m core.PluginManifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ return fmt.Errorf("plugins: %s: manifest json: %w", m.ID, err)
+ }
+ if m.ID == "" || m.Name == "" || m.Version == "" {
+ return fmt.Errorf("plugins: %s: manifest missing id/name/version", m.ID)
+ }
+ 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)
+ }
+
+ ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}}
+
+ // 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。
+ h.registry.UnregisterBySource(core.SourcePlugin, m.ID)
+
+ // tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。
+ for _, name := range m.Provides.Tools {
+ var desc core.ToolDesc
+ if err := readAsset(dir, "tools", name, &desc); err != nil {
+ return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
+ }
+ desc.Source = core.SourcePlugin
+ desc.PluginID = m.ID
+ exec, err := h.bindToolExecutor(desc)
+ if err != nil {
+ return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
+ }
+ if err := h.registry.Register(desc, exec); err != nil {
+ return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err)
+ }
+ }
+
+ // panels/*.json
+ for _, name := range m.Provides.Panels {
+ var panel core.PluginPanel
+ if err := readAsset(dir, "panels", name, &panel); err != nil {
+ return fmt.Errorf("plugins: %s: panel %s: %w", m.ID, name, err)
+ }
+ ld.Panels = append(ld.Panels, panel)
+ }
+
+ // cards/*.json:{kind, data:[UINode]}
+ for _, name := range m.Provides.Cards {
+ var card struct {
+ Kind string `json:"kind"`
+ Data []core.UINode `json:"data"`
+ }
+ if err := readAsset(dir, "cards", name, &card); err != nil {
+ return fmt.Errorf("plugins: %s: card %s: %w", m.ID, name, err)
+ }
+ if card.Kind == "" {
+ return fmt.Errorf("plugins: %s: card %s: missing kind", m.ID, name)
+ }
+ ld.Cards[card.Kind] = card.Data
+ }
+
+ // workflows/*.json
+ for _, name := range m.Provides.Workflows {
+ var wf core.WorkflowDef
+ if err := readAsset(dir, "workflows", name, &wf); err != nil {
+ return fmt.Errorf("plugins: %s: workflow %s: %w", m.ID, name, err)
+ }
+ ld.Workflows = append(ld.Workflows, wf)
+ }
+
+ // intents/*.json:agent-intent overlay 形态原样透出(目录侧消费,不在此解析)。
+ for _, name := range m.Provides.Intents {
+ var specs []json.RawMessage
+ if err := readAsset(dir, "intents", name, &specs); err != nil {
+ return fmt.Errorf("plugins: %s: intents %s: %w", m.ID, name, err)
+ }
+ ld.Intents = append(ld.Intents, specs...)
+ }
+
+ // prompts/*.md:画像模板(source=plugin,可被用户复制微调)。
+ for _, name := range m.Provides.Prompts {
+ content, err := os.ReadFile(filepath.Join(dir, "prompts", name+".md"))
+ if err != nil {
+ return fmt.Errorf("plugins: %s: prompt %s: %w", m.ID, name, err)
+ }
+ ld.Prompts[name] = string(content)
+ }
+
+ // settings.json:插件设置声明(并入设置中心 schema;默认值进覆盖链底层)。
+ if len(m.Provides.Settings) > 0 {
+ var descs []core.SettingDesc
+ if err := readAsset(dir, "", "settings", &descs); err != nil {
+ return fmt.Errorf("plugins: %s: settings: %w", m.ID, err)
+ }
+ for i := range descs {
+ descs[i].Group = "plugin:" + m.ID
+ ld.PluginSettings = append(ld.PluginSettings, descs[i])
+ }
+ }
+
+ h.mu.Lock()
+ h.loaded[m.ID] = ld
+ h.mu.Unlock()
+ _ = h.st.UpsertPlugin(m.ID, dir, m)
+ return nil
+}
+
+// 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("")
+ 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 == "" {
+ p = filepath.Join(dir, name+".json")
+ }
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(raw, v)
+}
+
+// ---- 查询接口 ----
+
+func (h *Host) List() []*Loaded {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ out := make([]*Loaded, 0, len(h.loaded))
+ for _, ld := range h.loaded {
+ out = append(out, ld)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Manifest.ID < out[j].Manifest.ID })
+ return out
+}
+
+// SettingsSchema 追加插件设置项(settings schema 汇总用)。
+func (h *Host) SettingsSchema() []core.SettingDesc {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ var out []core.SettingDesc
+ for _, ld := range h.List() {
+ out = append(out, ld.PluginSettings...)
+ }
+ return out
+}
+
+// Cards 返回 kind → 模板(server 演示卡片注入用)。
+func (h *Host) Cards() map[string][]core.UINode {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ out := map[string][]core.UINode{}
+ for _, ld := range h.loaded {
+ for k, v := range ld.Cards {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+// Unregister 注销插件全部注册项(卸载语义:在途引用由 registry miss 稳定报错)。
+func (h *Host) Unregister(id string) {
+ h.mu.Lock()
+ delete(h.loaded, id)
+ h.mu.Unlock()
+ h.registry.UnregisterBySource(core.SourcePlugin, id)
+ _ = h.st.RemovePlugin(id)
+}
+
+var _ = strings.TrimSpace
diff --git a/internal/plugins/host_test.go b/internal/plugins/host_test.go
new file mode 100644
index 0000000..54400d5
--- /dev/null
+++ b/internal/plugins/host_test.go
@@ -0,0 +1,127 @@
+package plugins
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ core "agent-system-core"
+ "agent-system-backend/internal/settings"
+ "agent-system-backend/internal/store"
+ "agent-system-backend/internal/tools"
+)
+
+func newHost(t *testing.T) (*Host, *tools.Registry, string) {
+ t.Helper()
+ dir := t.TempDir()
+ st, err := store.Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ reg := tools.NewRegistry()
+ reg.RegisterBuiltin()
+ setSvc := settings.NewService(st)
+ pluginDir := filepath.Join(dir, "plugins")
+ os.MkdirAll(pluginDir, 0o755)
+ return New(st, reg, setSvc, pluginDir), reg, pluginDir
+}
+
+// writeExamplePlugin 写一个覆盖五类能力的最小示例插件目录。
+func writeExamplePlugin(t *testing.T, dir, id string) {
+ t.Helper()
+ root := filepath.Join(dir, id)
+ for _, sub := range []string{"tools", "panels", "cards", "intents", "prompts"} {
+ os.MkdirAll(filepath.Join(root, sub), 0o755)
+ }
+ writeFile := func(rel, content string) {
+ if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ writeFile("plugin.json", `{"id":"`+id+`","name":"示例插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`",
+ "requires":{"network":["api.example.com"]},
+ "provides":{"tools":["http_echo"],"intents":["demo"],"panels":["demo"],"cards":["note"],"prompts":["assistant"],"settings":["greeting"]}}`)
+ writeFile("tools/http_echo.json", `{"name":"http_echo","description":"回显输入到示例端点","source":"plugin",
+ "parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]},
+ "http":{"method":"POST","url":"https://api.example.com/echo","bodyTemplate":"{\"q\":\"{{param.text}}\"}","resultPath":"echo"}}`)
+ writeFile("panels/demo.json", `{"id":"demo","title":"示例面板","body":[{"type":"markdown","markdown":"# 示例\n插件面板。"}]}`)
+ writeFile("cards/note.json", `{"kind":"note","data":[{"type":"markdown","markdown":"插件卡片模板"}]}`)
+ writeFile("intents/demo.json", `[{"id":"demo_intent","domain":"demo","includes_any":["示例"]}]`)
+ writeFile("prompts/assistant.md", `你是示例插件附带的助手模板。`)
+ writeFile("settings.json", `[{"key":"greeting","group":"plugin:`+id+`","label":"问候语","type":"string","default":"你好","effect":"hot"}]`)
+}
+
+func TestLoadAndRegister(t *testing.T) {
+ host, reg, dir := newHost(t)
+ writeExamplePlugin(t, dir, "example")
+
+ results := host.LoadDir()
+ if err := results["example"]; err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ // 工具注册(source=plugin,声明式 HTTP 绑定执行器)。
+ desc, exec, ok := reg.Get("http_echo")
+ if !ok || desc.Source != core.SourcePlugin || desc.PluginID != "example" || exec == nil {
+ t.Fatalf("tool not registered: %+v", desc)
+ }
+ // 面板/卡片/意图/提示词/设置声明。
+ lds := host.List()
+ if len(lds) != 1 || len(lds[0].Panels) != 1 || lds[0].Cards["note"] == nil || len(lds[0].Intents) != 1 || lds[0].Prompts["assistant"] == "" {
+ t.Fatalf("loaded state wrong: %+v", lds)
+ }
+ if len(host.SettingsSchema()) != 1 || host.SettingsSchema()[0].Group != "plugin:example" {
+ t.Errorf("plugin settings schema: %+v", host.SettingsSchema())
+ }
+}
+
+func TestUnloadRemovesRegistrations(t *testing.T) {
+ host, reg, dir := newHost(t)
+ writeExamplePlugin(t, dir, "example")
+ host.LoadDir()
+ if _, _, ok := reg.Get("http_echo"); !ok {
+ t.Fatal("prerequisite: tool registered")
+ }
+ // 目录中删除插件后重载 → 卸载语义。
+ os.RemoveAll(filepath.Join(dir, "example"))
+ host.LoadDir()
+ if _, _, ok := reg.Get("http_echo"); ok {
+ t.Error("tool should be unregistered after plugin removal")
+ }
+ if len(host.List()) != 0 {
+ t.Error("plugin should be unloaded")
+ }
+ // 内置工具不受影响(核心零回归)。
+ if _, _, ok := reg.Get("knowledge_search"); !ok {
+ t.Error("builtin tool must survive plugin unload")
+ }
+}
+
+// fail-closed:坏资产整批拒绝,不部分注册。
+func TestFailClosedOnBadAsset(t *testing.T) {
+ host, reg, dir := newHost(t)
+ writeExamplePlugin(t, dir, "example")
+ // 破坏工具资产(非法 JSON)。
+ os.WriteFile(filepath.Join(dir, "example", "tools", "http_echo.json"), []byte(`{broken`), 0o644)
+
+ results := host.LoadDir()
+ if err := results["example"]; err == nil {
+ t.Fatal("bad asset should fail load")
+ }
+ if _, _, ok := reg.Get("http_echo"); ok {
+ t.Error("fail-closed violated: tool registered from broken plugin")
+ }
+ if len(host.List()) != 0 {
+ t.Error("broken plugin must not be in loaded list")
+ }
+}
+
+func TestAPIVersionGuard(t *testing.T) {
+ host, _, dir := newHost(t)
+ writeExamplePlugin(t, dir, "oldapi")
+ os.WriteFile(filepath.Join(dir, "oldapi", "plugin.json"), []byte(`{"id":"oldapi","name":"旧版","version":"1.0.0","apiVersion":"0.9","provides":{}}`), 0o644)
+ results := host.LoadDir()
+ if err := results["oldapi"]; err == nil {
+ t.Fatal("incompatible apiVersion should be rejected")
+ }
+}
diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go
new file mode 100644
index 0000000..2a2f1f0
--- /dev/null
+++ b/internal/profiles/profiles.go
@@ -0,0 +1,66 @@
+// Package profiles 管理智能体画像(4.7):数据化、可导入导出;内置默认画像(T0 基线)。
+package profiles
+
+import (
+ core "agent-system-core"
+ "agent-system-backend/internal/store"
+)
+
+const DefaultProfileID = "default"
+
+func DefaultProfile() core.AgentProfile {
+ maxTools := 40
+ maxTurns := 16
+ return core.AgentProfile{
+ ID: DefaultProfileID,
+ Name: "通用助手",
+ Description: "内置默认画像:全部工具可用,HTTP 工具执行前确认(T0 基线)。",
+ Builtin: true,
+ Source: "builtin",
+ SystemPrompt: "你是一个通用智能体助手,运行在 Agent-System 中。回答准确、简洁;需要外部信息时优先使用可用工具;产出文档时写入工件。",
+ Model: core.ModelRoute{},
+ Tools: core.ToolPolicy{Allow: []string{"*"}, Confirm: []string{"http."}},
+ Context: core.ContextPolicy{MaxHistoryMessages: 40, MaxToolResultChars: 20000},
+ HITL: core.HITLPolicy{AskOnAmbiguity: true},
+ Budget: core.RunBudget{MaxToolCalls: &maxTools, MaxTurns: &maxTurns},
+ }
+}
+
+type Service struct{ st *store.Store }
+
+func New(st *store.Store) *Service { return &Service{st: st} }
+
+// EnsureDefault 幂等写入内置画像。
+func (s *Service) EnsureDefault() error {
+ if _, err := s.st.GetProfile(DefaultProfileID); err == nil {
+ return nil
+ }
+ return s.st.SaveProfile(DefaultProfile())
+}
+
+func (s *Service) List() ([]core.AgentProfile, error) { return s.st.ListProfiles() }
+
+func (s *Service) Get(id string) (core.AgentProfile, error) {
+ p, err := s.st.GetProfile(id)
+ if err == nil {
+ return p, nil
+ }
+ // 空或未知画像回落默认(T0 行为基线)。
+ return DefaultProfile(), nil
+}
+
+func (s *Service) Save(p core.AgentProfile) error {
+ if p.ID == DefaultProfileID {
+ return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
+ }
+ if p.Tools.Allow == nil {
+ p.Tools.Allow = []string{"*"}
+ }
+ return s.st.SaveProfile(p)
+}
+
+func (s *Service) Delete(id string) error { return s.st.DeleteProfile(id) }
+
+type errReadOnly string
+
+func (e errReadOnly) Error() string { return string(e) }
diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go
new file mode 100644
index 0000000..0882ca2
--- /dev/null
+++ b/internal/runmgr/runmgr.go
@@ -0,0 +1,532 @@
+// Package runmgr 是 Run 生命周期枢纽(4.2/4.3):一切运行皆 Run、一切增量皆事件。
+// 职责:run 状态机、事件持久化+广播(seq 游标)、HITL 挂起/恢复(三分支路由)、
+// 子代理派生(任务树)、工具执行策略(画像三清单 + 确认卡片 + 截断转工件)、取消级联。
+package runmgr
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ core "agent-system-core"
+ "agent-system-backend/internal/bus"
+ "agent-system-backend/internal/profiles"
+ "agent-system-backend/internal/runtime"
+ "agent-system-backend/internal/settings"
+ "agent-system-backend/internal/store"
+ "agent-system-backend/internal/tools"
+)
+
+type Manager struct {
+ st *store.Store
+ bus *bus.Bus
+ registry *tools.Registry
+ profiles *profiles.Service
+ settings *settings.Service
+ runner runtime.Runner // conversation runner(mock 或 LLM)
+ subRunner runtime.Runner // subagent runner(LLM 轻任务)
+ mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
+ mockSub runtime.Runner // 子代理脚本回放
+
+ mu sync.Mutex
+ active map[string]*runHandle
+}
+
+type runHandle struct {
+ cancel context.CancelFunc
+ hitlCh chan hitlAnswer
+ assistant []core.Part
+ started time.Time
+}
+
+type hitlAnswer struct {
+ resp core.HITLResponse
+ err error
+}
+
+func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service, s *settings.Service, runner, subRunner, mockRunner, mockSubRunner runtime.Runner) *Manager {
+ return &Manager{st: st, bus: b, registry: reg, profiles: p, settings: s, runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner, active: map[string]*runHandle{}}
+}
+
+// ---- runCtx 实现(runtime.RunCtx)----
+
+type runCtx struct {
+ mgr *Manager
+ run core.Run
+ prof core.AgentProfile
+ eff core.EffectiveSettings
+ ctx context.Context
+ h *runHandle
+ userMessage core.Message
+ attachments []core.ParsedDocument
+ subTask string
+ subHint string
+}
+
+func (c *runCtx) Context() context.Context { return c.ctx }
+func (c *runCtx) Run() core.Run { return c.run }
+func (c *runCtx) Profile() core.AgentProfile { return c.prof }
+func (c *runCtx) Settings() core.EffectiveSettings { return c.eff }
+func (c *runCtx) History() []core.Message {
+ hist, _ := c.mgr.st.LastMessages(c.run.ConversationID, 200)
+ return hist
+}
+func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
+func (c *runCtx) Subtask() string { return c.subTask }
+func (c *runCtx) Tools() []core.ToolDesc { return c.mgr.registry.List() }
+
+func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
+ _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload)
+ return err
+}
+
+func (c *runCtx) SetAssistantParts(parts []core.Part) error {
+ c.mgr.mu.Lock()
+ c.h.assistant = parts
+ c.mgr.mu.Unlock()
+ return nil
+}
+
+func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
+ card.ID = store.NewID("hitl")
+ card.RunID = c.run.ID
+ card.ConversationID = c.run.ConversationID
+ if card.CreatedAt == "" {
+ card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
+ }
+ if err := c.mgr.st.SaveHITLCard(&card); err != nil {
+ return core.HITLResponse{}, err
+ }
+ c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase)
+ if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil {
+ return core.HITLResponse{}, err
+ }
+ select {
+ case ans := <-c.h.hitlCh:
+ return ans.resp, ans.err
+ case <-c.ctx.Done():
+ return core.HITLResponse{}, c.ctx.Err()
+ }
+}
+
+func (c *runCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
+ return c.mgr.spawnSubagent(c.ctx, c.run, name, task, hint)
+}
+
+func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
+ p.PlanID = c.run.ConversationID
+ if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
+ return err
+ }
+ _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
+ return err
+}
+
+func (c *runCtx) UpdateTodos(l core.TodoList) error {
+ l.ConversationID = c.run.ConversationID
+ if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
+ return err
+ }
+ _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
+ return err
+}
+
+func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
+ art := core.Artifact{ProjectID: c.run.ProjectID, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
+ if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
+ return art, err
+ }
+ if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
+ return art, err
+ }
+ // 工件文本进知识索引(全文检索最小实现)。
+ if kind == "document" && len(content) < 200000 {
+ _ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)})
+ }
+ return art, nil
+}
+
+func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
+ if q.TopK <= 0 {
+ q.TopK = int(c.eff.GetNumber("retrieval.topK", 5))
+ }
+ if q.ProjectID == "" {
+ q.ProjectID = c.run.ProjectID
+ }
+ return c.mgr.st.SearchKnowledge(q)
+}
+
+// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
+func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
+ policy := c.prof.Tools
+ if !policy.ToolAllowed(call.Name) {
+ return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
+ }
+ if policy.NeedsConfirm(call.Name) {
+ var argsPreview string
+ if len(call.Arguments) > 0 {
+ argsPreview = truncate(string(call.Arguments), 600)
+ }
+ card := core.HITLCard{
+ Question: fmt.Sprintf("执行工具 %s?", call.Name), Kind: core.HITLKindConfirm,
+ Choices: []core.HITLChoice{
+ {ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
+ {ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
+ },
+ AllowFreeInput: false,
+ }
+ _ = argsPreview
+ resp, err := c.AskHITL(card)
+ if err != nil {
+ return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
+ }
+ if resp.ChoiceID != "yes" {
+ return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
+ }
+ }
+ res := c.mgr.registry.Execute(toolCtx{rc: c}, call)
+ // 超长截断并转工件引用(4.10)。
+ limit := c.prof.Context.MaxToolResultChars
+ if limit <= 0 {
+ limit = int(c.eff.GetNumber("context.maxToolResultChars", 20000))
+ }
+ if res.Status == "ok" && len(res.Output) > limit {
+ if art, err := c.WriteArtifact("tool-"+call.Name+"-"+call.CallID+".txt", "data", "text/plain", []byte(res.Output)); err == nil {
+ res.Artifact = &art
+ res.Output = res.Output[:limit] + "\n…(超长,完整结果已存为工件 " + art.Name + ")"
+ } else {
+ res.Output = res.Output[:limit] + "…(超长截断,工件写入失败)"
+ }
+ res.Truncated = true
+ }
+ return res
+}
+
+type toolCtx struct{ rc *runCtx }
+
+func (t toolCtx) ProjectID() string { return t.rc.run.ProjectID }
+func (t toolCtx) ConversationID() string { return t.rc.run.ConversationID }
+func (t toolCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return t.rc.Retrieve(q) }
+func (t toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
+ return t.rc.WriteArtifact(name, kind, mime, content)
+}
+
+// ---- run 生命周期 ----
+
+// StartConversationRun 为一条已落库的用户消息启动 conversation run。
+// attachments 为已解析的附件文档(server 层经 ingest 处理)。
+func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument) (core.Run, error) {
+ prof, _ := m.profiles.Get(conv.ProfileID)
+ eff := m.settings.Effective(conv.ProjectID)
+ run := core.Run{
+ ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID,
+ Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
+ }
+ if err := m.st.CreateRun(&run); err != nil {
+ return run, err
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
+ m.mu.Lock()
+ m.active[run.ID] = h
+ m.mu.Unlock()
+
+ runner := m.runner
+ if eff.GetBool("runtime.mock", false) {
+ runner = m.mock
+ }
+ rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments}
+ go m.execute(rc, runner)
+ return run, nil
+}
+
+// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
+func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
+ defer func() {
+ m.mu.Lock()
+ delete(m.active, rc.run.ID)
+ m.mu.Unlock()
+ }()
+ m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking)
+ m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking})
+
+ execErr := func() (err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ err = fmt.Errorf("runner panic: %v", r)
+ }
+ }()
+ return runner.Execute(rc)
+ }()
+
+ usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
+ if execErr != nil {
+ if rc.ctx.Err() != nil {
+ m.finishRun(rc, core.RunCancelled, usage, &core.ErrorPart{Code: "run.cancelled", Message: "已取消"})
+ return
+ }
+ m.finishRun(rc, core.RunFailed, usage, &core.ErrorPart{Code: "run.failed", Message: execErr.Error()})
+ return
+ }
+ m.finishRun(rc, core.RunCompleted, usage, nil)
+}
+
+func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) {
+ now := time.Now().UTC()
+ rc.run.EndedAt = &now
+ rc.run.Status = status
+ rc.run.Phase = core.PhaseDone
+ rc.run.Usage = &usage
+ rc.run.Error = errPart
+ m.st.UpdateRun(&rc.run)
+
+ // 终态权威消息树:落库助手消息(含过程部件标记)。
+ m.mu.Lock()
+ parts := append([]core.Part{}, rc.h.assistant...)
+ m.mu.Unlock()
+ if len(parts) > 0 {
+ msg := core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
+ if err := m.st.AppendMessage(&msg); err == nil {
+ // 消息树权威已落库;hitl 摘要部件的卡片状态由前端对账收敛。
+ }
+ if errPart != nil {
+ m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
+ }
+ } else if errPart != nil {
+ m.st.AppendMessage(&core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartError, Err: errPart}}})
+ }
+
+ switch status {
+ case core.RunCompleted:
+ usagePayload := core.RunCompletedPayload{Usage: usage}
+ // 把工具调用数补进 usage(由 runner 在部件里体现,这里取事件计数)。
+ m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunCompleted, usagePayload)
+ case core.RunFailed:
+ m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: errPart.Code, Message: errPart.Message})
+ }
+}
+
+// setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。
+func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) {
+ r.Status = status
+ r.Phase = phase
+ m.st.UpdateRun(r)
+}
+
+// emit 持久化事件并广播;返回 seq。
+func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
+ e := core.StreamEvent{
+ ConversationID: conversationID, RunID: runID,
+ TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ,
+ }
+ seq, err := m.st.AppendEvent(&e, payload)
+ if err != nil {
+ return 0, err
+ }
+ e.Seq = seq
+ m.bus.Publish(e)
+ return seq, nil
+}
+
+// CancelRun 取消 run 及其子 run(ctx 级联)。
+func (m *Manager) CancelRun(runID string) error {
+ r, err := m.st.GetRun(runID)
+ if err != nil {
+ return err
+ }
+ if r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
+ return fmt.Errorf("runmgr: run %s already terminal (%s)", runID, r.Status)
+ }
+ // 取消祖先句柄(子 run 派生自父 ctx,天然级联)。
+ rootID := r.ParentRunID
+ if rootID == "" {
+ rootID = r.ID
+ }
+ m.mu.Lock()
+ if h, ok := m.active[rootID]; ok {
+ h.cancel()
+ // 唤醒可能在等 HITL 的执行。
+ select {
+ case h.hitlCh <- hitlAnswer{err: context.Canceled}:
+ default:
+ }
+ }
+ if h, ok := m.active[r.ID]; ok && r.ID != rootID {
+ h.cancel()
+ select {
+ case h.hitlCh <- hitlAnswer{err: context.Canceled}:
+ default:
+ }
+ }
+ m.mu.Unlock()
+ return nil
+}
+
+// ---- HITL 解析(三分支路由,4.5)----
+
+// ResolveHITL 处理用户对卡片的响应:
+// 1) 选项命中 → 回填挂起 run;
+// 2) 自由输入以 "/" 开头(命令)→ 取消挂起 run,返回转轨标志由 server 层发送新消息;
+// 3) 其他自由输入 → 作为澄清回答回填。
+func (m *Manager) ResolveHITL(cardID string, resp core.HITLResponse) (pivotText string, pivoted bool, err error) {
+ card, err := m.st.GetHITLCard(cardID)
+ if err != nil {
+ return "", false, err
+ }
+ if card.Resolved {
+ return "", false, fmt.Errorf("runmgr: card %s already resolved", cardID)
+ }
+ run, err := m.st.GetRun(card.RunID)
+ if err != nil {
+ return "", false, err
+ }
+
+ kind := "choice"
+ answer := ""
+ if resp.ChoiceID != "" {
+ for _, c := range card.Choices {
+ if c.ID == resp.ChoiceID {
+ answer = c.Label
+ break
+ }
+ }
+ } else if resp.FreeText != "" {
+ kind = "free"
+ answer = resp.FreeText
+ if strings.HasPrefix(strings.TrimSpace(resp.FreeText), "/") {
+ // 命令转轨:取消挂起 run,等价于用户直接发送该命令。
+ _ = m.CancelRun(run.ID)
+ m.st.ResolveHITLCard(cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
+ m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
+ return strings.TrimSpace(resp.FreeText), true, nil
+ }
+ }
+ resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
+ if err := m.st.ResolveHITLCard(cardID, resolution); err != nil {
+ return "", false, err
+ }
+ m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
+
+ m.mu.Lock()
+ h, ok := m.active[run.ID]
+ m.mu.Unlock()
+ if !ok {
+ return "", false, fmt.Errorf("runmgr: run %s not active (may have ended)", run.ID)
+ }
+ select {
+ case h.hitlCh <- hitlAnswer{resp: resp}:
+ default:
+ return "", false, fmt.Errorf("runmgr: hitl channel busy")
+ }
+ // 恢复运行状态。
+ if run.Status == core.RunWaitingHITL {
+ run.Status = core.RunRunning
+ m.st.UpdateRun(&run)
+ }
+ return "", false, nil
+}
+
+// ---- 子代理(任务树)----
+
+func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name, task, hint string) (core.AgentResultPayload, error) {
+ child := core.Run{
+ ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
+ ProjectID: parent.ProjectID, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
+ Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
+ }
+ if err := m.st.CreateRun(&child); err != nil {
+ return core.AgentResultPayload{}, err
+ }
+ m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
+
+ ctx, cancel := context.WithCancel(parentCtx)
+ h := &runHandle{cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
+ m.mu.Lock()
+ m.active[child.ID] = h
+ m.mu.Unlock()
+
+ prof, _ := m.profiles.Get(parent.ProfileID)
+ eff := m.settings.Effective(parent.ProjectID)
+ rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint}
+ m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
+
+ runner := m.subRunner
+ if eff.GetBool("runtime.mock", false) || runner == nil {
+ runner = m.mockSub
+ }
+ execErr := func() (err error) {
+ defer func() {
+ if r := recover(); r != nil {
+ err = fmt.Errorf("subagent panic: %v", r)
+ }
+ }()
+ return runner.Execute(rc)
+ }()
+
+ usage := core.Usage{DurationMs: float64(time.Since(h.started).Milliseconds())}
+ status := core.RunCompleted
+ summary := "完成"
+ payload := core.AgentResultPayload{AgentRun: child.ID, Status: status, Summary: summary}
+ if execErr != nil {
+ if ctx.Err() != nil {
+ status = core.RunCancelled
+ payload.Status = status
+ payload.Summary = "已取消"
+ } else {
+ status = core.RunFailed
+ payload.Status = status
+ payload.Summary = execErr.Error()
+ }
+ }
+ now := time.Now().UTC()
+ child.EndedAt = &now
+ child.Status = status
+ child.Phase = core.PhaseDone
+ child.Usage = &usage
+ m.st.UpdateRun(&child)
+ m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload)
+
+ m.mu.Lock()
+ delete(m.active, child.ID)
+ m.mu.Unlock()
+ if status != core.RunCompleted {
+ return payload, fmt.Errorf("subagent %s: %s", name, payload.Summary)
+ }
+ return payload, nil
+}
+
+// InterruptRunning 服务停机前把在途 run 标记为 interrupted(4.2 恢复策略)。
+func (m *Manager) InterruptRunning() {
+ m.mu.Lock()
+ ids := make([]string, 0, len(m.active))
+ for id := range m.active {
+ ids = append(ids, id)
+ }
+ handles := make([]*runHandle, 0, len(ids))
+ for _, id := range ids {
+ if h, ok := m.active[id]; ok {
+ handles = append(handles, h)
+ }
+ }
+ m.mu.Unlock()
+ for _, h := range handles {
+ h.cancel()
+ }
+ for _, id := range ids {
+ r, err := m.st.GetRun(id)
+ if err != nil || r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
+ continue
+ }
+ r.Status = core.RunInterrupted
+ m.st.UpdateRun(&r)
+ }
+}
+
+func truncate(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "…"
+}
diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go
new file mode 100644
index 0000000..59126d1
--- /dev/null
+++ b/internal/runtime/llm.go
@@ -0,0 +1,300 @@
+package runtime
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ agentllm "agent-native/runtime/llm"
+ core "agent-system-core"
+ "agent-system-backend/internal/ctxasm"
+)
+
+// LLMRunner 主对话执行器:复用 agent-native 的 OpenAI 兼容流式客户端(不自研接入层)。
+// 多轮循环:流式 → 工具调用(画像策略)→ 回灌结果 → 直至无工具调用或预算耗尽。
+// 内置三个编排型工具(不经注册表):delegate_task(子代理)、update_plan、update_todos。
+type LLMRunner struct{}
+
+func (l *LLMRunner) Name() string { return "llm" }
+
+const (
+ toolDelegate = "delegate_task"
+ toolPlan = "update_plan"
+ toolTodos = "update_todos"
+)
+
+func (l *LLMRunner) Execute(rc RunCtx) error {
+ eff := rc.Settings()
+ chat := eff.GetString("models.chat.model", "")
+ base := eff.GetString("models.chat.baseUrl", "")
+ key := eff.GetString("models.chat.apiKey", "")
+ if chat == "" || base == "" {
+ return fmt.Errorf("llm: 主模型未配置(settings: models.chat.baseUrl / models.chat.model;演示模式可开 runtime.mock)")
+ }
+
+ client := &agentllm.Client{BaseURL: base, APIKey: key}
+ prof := rc.Profile()
+
+ // 知识注入:当前消息包含 @knowledge 引用时检索注入(最小实现:显式引用)。
+ var knowledge []core.RetrievalHit
+ if strings.Contains(currentUserText(rc), "@knowledge") {
+ q := strings.TrimSpace(strings.ReplaceAll(currentUserText(rc), "@knowledge", ""))
+ if hits, err := rc.Retrieve(core.RetrievalQuery{Text: q}); err == nil {
+ knowledge = hits
+ }
+ }
+
+ // 组装初始上下文。
+ hist := rc.History()
+ var currentUser core.Message
+ for i := len(hist) - 1; i >= 0; i-- {
+ if hist[i].Role == core.RoleUser {
+ currentUser = hist[i]
+ break
+ }
+ }
+ msgs, _ := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge)
+
+ // 声明工具:编排型内置 + 注册表中画像允许的工具。
+ decls := []agentllm.ToolDecl{
+ {Name: toolDelegate, Description: "派生一个子代理执行独立子任务(如检索、撰写),返回其结果摘要", Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string","description":"子代理角色名"},"task":{"type":"string","description":"子任务描述"},"hint":{"type":"string","description":"执行提示"}},"required":["name","task"]}`)},
+ {Name: toolPlan, Description: "创建或更新当前会话的计划(步骤列表)", Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)},
+ {Name: toolTodos, Description: "更新当前会话的待办清单", Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)},
+ }
+ var toolDescs []core.ToolDesc
+ for _, t := range toolList(rc) {
+ if prof.Tools.ToolAllowed(t.Name) {
+ toolDescs = append(toolDescs, t)
+ decls = append(decls, agentllm.ToolDecl{Name: t.Name, Description: t.Description, Parameters: t.Parameters})
+ }
+ }
+
+ var finalParts []core.Part
+ usageTotal := core.Usage{}
+ maxTurns := 16
+ if prof.Budget.MaxTurns != nil {
+ maxTurns = *prof.Budget.MaxTurns
+ }
+ maxTools := 40
+ if prof.Budget.MaxToolCalls != nil {
+ maxTools = *prof.Budget.MaxToolCalls
+ }
+ toolCallsMade := 0
+ thinkingIdx, textIdx := 0, 0
+
+ for turn := 0; turn < maxTurns; turn++ {
+ select {
+ case <-rc.Context().Done():
+ return rc.Context().Err()
+ default:
+ }
+
+ var turnThinking, turnText strings.Builder
+ var calls []agentllm.ToolCall
+ var usage agentllm.Usage
+ ev := client.Stream(rc.Context(), chat, msgs, decls, agentllm.StreamOptions{})
+ phaseEmitted := false
+ for e := range ev {
+ switch e.Kind {
+ case agentllm.KindTextDelta:
+ if !phaseEmitted {
+ rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseResponding})
+ phaseEmitted = true
+ }
+ turnText.WriteString(e.Text)
+ rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: textIdx, Text: e.Text})
+ case agentllm.KindReasoningDelta:
+ turnThinking.WriteString(e.ReasoningDelta)
+ rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: thinkingIdx, Text: e.ReasoningDelta})
+ case agentllm.KindToolCallDelta:
+ // 累积由 message_end 的 ToolCalls 提供;此处忽略增量。
+ case agentllm.KindMessageEnd:
+ calls = e.Message.ToolCalls
+ usage = e.Usage
+ case agentllm.KindError:
+ return fmt.Errorf("llm stream: %s (retryable=%v)", e.ErrorMessage, e.Retryable)
+ }
+ }
+ usageTotal.PromptTokens += usage.PromptTokens
+ usageTotal.CompletionTokens += usage.CompletionTokens
+ usageTotal.TotalTokens += usage.TotalTokens
+
+ // 记录本轮部件。
+ if turnThinking.Len() > 0 {
+ finalParts = append(finalParts, core.Part{Type: core.PartThinking, Text: turnThinking.String(), Process: true})
+ thinkingIdx++
+ }
+ if turnText.Len() > 0 {
+ finalParts = append(finalParts, core.Part{Type: core.PartText, Text: turnText.String()})
+ textIdx++
+ }
+
+ if len(calls) == 0 {
+ // 终轮:无工具调用。
+ if len(finalParts) == 0 {
+ finalParts = append(finalParts, core.Part{Type: core.PartText, Text: "(模型未返回内容)"})
+ }
+ rc.SetAssistantParts(finalParts)
+ return nil
+ }
+
+ // 工具轮。
+ rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
+ msgs = append(msgs, agentllm.Message{Role: agentllm.RoleAssistant, Content: turnText.String(), ToolCalls: calls, StopReason: agentllm.StopToolCalls})
+
+ for _, call := range calls {
+ toolCallsMade++
+ if toolCallsMade > maxTools {
+ return fmt.Errorf("budget: 工具调用超过上限 %d(profile.budget.maxToolCalls)", maxTools)
+ }
+ rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: call.ID, Name: call.Name, Arguments: call.Arguments})
+
+ var out string
+ var err error
+ var artifactRef *core.ArtifactRef
+ switch call.Name {
+ case toolDelegate:
+ var a struct{ Name, Task, Hint string }
+ json.Unmarshal([]byte(call.Arguments), &a)
+ res, serr := rc.SpawnSubagent(orDefault(a.Name, "子代理"), a.Task, a.Hint)
+ if serr != nil {
+ out = "子代理失败:" + serr.Error()
+ } else {
+ out = orDefault(res.Summary, "子代理完成")
+ }
+ case toolPlan:
+ var p core.PlanSnapshot
+ if err := json.Unmarshal([]byte(call.Arguments), &p); err == nil {
+ rc.UpdatePlan(p)
+ out = "计划已更新"
+ } else {
+ out = "计划参数错误: " + err.Error()
+ }
+ case toolTodos:
+ var t core.TodoList
+ if err := json.Unmarshal([]byte(call.Arguments), &t); err == nil {
+ rc.UpdateTodos(t)
+ out = "待办已更新"
+ } else {
+ out = "待办参数错误: " + err.Error()
+ }
+ default:
+ res := rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)})
+ if res.Status != "ok" {
+ err = fmt.Errorf("%s", res.Output)
+ } else {
+ out = res.Output
+ }
+ if res.Artifact != nil {
+ artifactRef = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind}
+ }
+ rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: res.Status, Summary: summarize(out, err), Truncated: res.Truncated})
+ }
+ if call.Name == toolDelegate || call.Name == toolPlan || call.Name == toolTodos {
+ rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: "ok", Summary: summarize(out, err)})
+ }
+
+ summary := ""
+ if err != nil {
+ summary = "error: " + err.Error()
+ out = summary
+ }
+ finalParts = append(finalParts,
+ core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: call.ID, Name: call.Name, Arguments: call.Arguments}},
+ core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: call.ID, Status: statusOf(err), Summary: summary}},
+ )
+ if artifactRef != nil {
+ finalParts = append(finalParts, core.Part{Type: core.PartArtifactRef, Artifact: artifactRef})
+ }
+ // 回灌工具结果(截断保护)。
+ if len(out) > 16000 {
+ out = out[:16000] + "…(超长截断)"
+ }
+ msgs = append(msgs, agentllm.Message{Role: agentllm.RoleTool, ToolCallID: call.ID, Name: call.Name, Content: out, IsError: err != nil})
+ }
+ }
+ return fmt.Errorf("budget: 轮次超过上限 %d(profile.budget.maxTurns)", maxTurns)
+}
+
+// SubLLMRunner 子代理执行器:轻量模型单轮完成任务并返回摘要。
+type SubLLMRunner struct{}
+
+func (l *SubLLMRunner) Name() string { return "llm-subagent" }
+
+func (l *SubLLMRunner) Execute(rc RunCtx) error {
+ eff := rc.Settings()
+ model := eff.GetString("models.lightweight.model", "")
+ base := eff.GetString("models.lightweight.baseUrl", "")
+ key := eff.GetString("models.lightweight.apiKey", "")
+ if model == "" || base == "" {
+ model = eff.GetString("models.chat.model", "")
+ base = eff.GetString("models.chat.baseUrl", "")
+ key = eff.GetString("models.chat.apiKey", "")
+ }
+ if model == "" || base == "" {
+ return fmt.Errorf("llm-subagent: 模型未配置")
+ }
+ rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "执行子任务……", Phase: core.PhaseActing})
+ client := &agentllm.Client{BaseURL: base, APIKey: key}
+ msgs := []agentllm.Message{
+ {Role: agentllm.RoleSystem, Content: "你是被派生的子代理。独立完成给定子任务,输出简明结果摘要(不超过 300 字)。"},
+ {Role: agentllm.RoleUser, Content: rc.Subtask()},
+ }
+ var sb strings.Builder
+ for e := range client.Stream(rc.Context(), model, msgs, nil, agentllm.StreamOptions{}) {
+ if e.Kind == agentllm.KindTextDelta {
+ sb.WriteString(e.Text)
+ rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: truncateRun(sb.String(), 80)})
+ }
+ if e.Kind == agentllm.KindError {
+ return fmt.Errorf("subagent stream: %s", e.ErrorMessage)
+ }
+ }
+ rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: sb.String()}})
+ return nil
+}
+
+func currentUserText(rc RunCtx) string {
+ hist := rc.History()
+ for i := len(hist) - 1; i >= 0; i-- {
+ if hist[i].Role == core.RoleUser {
+ for _, p := range hist[i].Parts {
+ if p.Type == core.PartText {
+ return p.Text
+ }
+ }
+ }
+ }
+ return ""
+}
+
+func toolList(rc RunCtx) []core.ToolDesc { return rc.Tools() }
+
+func orDefault(s, def string) string {
+ if strings.TrimSpace(s) == "" {
+ return def
+ }
+ return s
+}
+
+func summarize(out string, err error) string {
+ if err != nil {
+ return truncateRun(err.Error(), 160)
+ }
+ return truncateRun(out, 160)
+}
+
+func statusOf(err error) string {
+ if err != nil {
+ return "error"
+ }
+ return "ok"
+}
+
+func truncateRun(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n]) + "…"
+}
diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go
new file mode 100644
index 0000000..f5b028e
--- /dev/null
+++ b/internal/runtime/mock.go
@@ -0,0 +1,147 @@
+package runtime
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ core "agent-system-core"
+)
+
+// MockRunner 按脚本回放事件流(P1 骨架闭环与验收基线;不调用任何模型)。
+// 用户消息中的指令词触发不同演示路径,其余走默认脚本(思考→工具→回复)。
+type MockRunner struct{}
+
+func (m *MockRunner) Name() string { return "mock" }
+
+func (m *MockRunner) Execute(rc RunCtx) error {
+ // 取最后一条用户消息文本
+ user := ""
+ if hist := rc.History(); len(hist) > 0 {
+ last := hist[len(hist)-1]
+ if last.Role == core.RoleUser {
+ for _, p := range last.Parts {
+ if p.Type == core.PartText {
+ user = p.Text
+ }
+ }
+ }
+ }
+
+ var parts []core.Part
+ addText := func(t string) {
+ parts = append(parts, core.Part{Type: core.PartText, Text: t})
+ }
+
+ switch {
+ case strings.Contains(user, "#hitl"):
+ resp, err := rc.AskHITL(core.HITLCard{
+ Question: "演示:采用哪种输出格式?", Kind: core.HITLKindChoice,
+ Choices: []core.HITLChoice{
+ {ID: "md", Label: "Markdown 文档", Description: "可版本管理的轻量文档", Impact: "写入 artifacts,可在右栏查看与继续编辑", Recommended: true},
+ {ID: "docx", Label: "Word 文档", Description: "正式交付格式", Impact: "生成 docx 工件,后续修订走版本对比"},
+ {ID: "both", Label: "两种都要", Description: "同时产出", Impact: "生成两个工件"},
+ },
+ AllowFreeInput: true, FreeInputHint: "也可以直接输入你的要求(输入 / 开头则转新命令)",
+ })
+ if err != nil {
+ return err
+ }
+ answer := resp.FreeText
+ if resp.ChoiceID != "" {
+ answer = "选项 " + resp.ChoiceID
+ }
+ parts = append(parts, core.Part{Type: core.PartHITL, Hitl: &core.HITLSummary{Question: "演示:采用哪种输出格式?", Resolved: true, Answer: answer}})
+ addText(fmt.Sprintf("收到你的回答:%s。已按该选择继续执行。", answer))
+
+ case strings.Contains(user, "#agents"):
+ rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
+ {ID: "s1", Text: "检索资料", Status: "in_progress"},
+ {ID: "s2", Text: "撰写摘要", Status: "pending"},
+ {ID: "s3", Text: "汇总输出", Status: "pending"},
+ }})
+ rc.UpdateTodos(core.TodoList{Items: []core.TodoItem{
+ {ID: "t1", Text: "派生两个子代理", Status: "in_progress"},
+ {ID: "t2", Text: "等待结果", Status: "pending"},
+ }})
+ rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "任务需要并行检索,派生子代理……"})
+ parts = append(parts, core.Part{Type: core.PartThinking, Text: "任务需要并行检索,派生子代理……", Process: true})
+ r1, _ := rc.SpawnSubagent("资料检索员", "检索项目知识与历史会话", "知识检索")
+ r2, _ := rc.SpawnSubagent("撰稿员", "根据检索结果撰写摘要", "写作")
+ rc.UpdateTodos(core.TodoList{Items: []core.TodoItem{
+ {ID: "t1", Text: "派生两个子代理", Status: "done"},
+ {ID: "t2", Text: "等待结果", Status: "done"},
+ }})
+ rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
+ {ID: "s1", Text: "检索资料", Status: "done"},
+ {ID: "s2", Text: "撰写摘要", Status: "done"},
+ {ID: "s3", Text: "汇总输出", Status: "in_progress"},
+ }})
+ addText(fmt.Sprintf("两个子代理已完成:%s(%s);%s(%s)。可在右栏 Agents 标签查看任务树与中间过程。",
+ r1.Summary, r1.Status, r2.Summary, r2.Status))
+ rc.UpdatePlan(core.PlanSnapshot{Title: "多代理演示计划", Steps: []core.PlanStep{
+ {ID: "s1", Text: "检索资料", Status: "done"},
+ {ID: "s2", Text: "撰写摘要", Status: "done"},
+ {ID: "s3", Text: "汇总输出", Status: "done"},
+ }})
+
+ case strings.Contains(user, "#doc"):
+ rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "准备生成文档……"})
+ parts = append(parts, core.Part{Type: core.PartThinking, Text: "准备生成文档……", Process: true})
+ content := "# 演示文档\n\n这是 mock runner 生成的示例工件。\n\n## 要点\n\n- 由 #doc 指令触发\n- 写入 artifacts 并可在右栏查看\n"
+ art, err := rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content))
+ if err == nil {
+ parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
+ }
+ addText("文档已生成,见右侧 Artifacts 标签(也可点击消息中的工件引用)。")
+
+ case strings.Contains(user, "#error"):
+ return fmt.Errorf("mock: 演示失败路径(#error 指令)")
+
+ default:
+ // 默认脚本:思考 → 工具调用(知识检索)→ 回复(流式逐字)。
+ rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: 0, Text: "理解问题,先检索项目知识……"})
+ parts = append(parts, core.Part{Type: core.PartThinking, Text: "理解问题,先检索项目知识……", Process: true})
+ rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseActing})
+ callID := "call_mock_1"
+ rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: callID, Name: "knowledge_search", Arguments: `{"query":"` + truncateForJSON(user, 40) + `"}`})
+ res := rc.ExecuteTool(core.ToolCallRequest{CallID: callID, Name: "knowledge_search", Arguments: []byte(`{"query":"演示"}`)})
+ rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: callID, Status: res.Status, Summary: truncateForJSON(res.Output, 120), Truncated: res.Truncated})
+ parts = append(parts,
+ core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: callID, Name: "knowledge_search", Arguments: `{"query":"演示"}`}},
+ core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: callID, Status: res.Status, Summary: truncateForJSON(res.Output, 120)}},
+ )
+ rc.Emit(core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseResponding})
+ reply := fmt.Sprintf("(mock 回复)你说:%s\n\n这是 mock runner 的脚本化回复——未调用真实模型。可发送 #hitl / #agents / #doc / #error 触发对应演示路径。", user)
+ idx := 0
+ for _, r := range reply {
+ rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: idx, Text: string(r)})
+ time.Sleep(8 * time.Millisecond)
+ }
+ addText(reply)
+ }
+ rc.SetAssistantParts(parts)
+ return nil
+}
+
+// MockSubagent 子代理脚本回放:进度事件 + 摘要结果。
+type MockSubagent struct{}
+
+func (m *MockSubagent) Name() string { return "mock-subagent" }
+
+func (m *MockSubagent) Execute(rc RunCtx) error {
+ rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "开始子任务……", Phase: core.PhaseThinking})
+ time.Sleep(150 * time.Millisecond)
+ rc.Emit(core.EvAgentProgress, core.AgentProgressPayload{AgentRun: rc.Run().ID, Note: "处理中……", Phase: core.PhaseActing})
+ time.Sleep(150 * time.Millisecond)
+ rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "子任务完成(mock)。"}})
+ return nil
+}
+
+func truncateForJSON(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n]) + "…"
+}
diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go
new file mode 100644
index 0000000..a1eaa9c
--- /dev/null
+++ b/internal/runtime/runner.go
@@ -0,0 +1,39 @@
+// Package runtime 定义智能体执行端口与实现:mock(脚本回放)与 LLM
+// (复用 agent-native 的 OpenAI 兼容流式客户端)。执行编排归 runmgr。
+package runtime
+
+import (
+ "context"
+
+ core "agent-system-core"
+)
+
+// RunCtx 是 Runner 的受控环境(由 runmgr 实现):事件发布、HITL、子代理、
+// 工件、检索、工具执行、历史。Runner 不得绕过 RunCtx 直接写库。
+type RunCtx interface {
+ Context() context.Context
+ Run() core.Run
+ Profile() core.AgentProfile
+ Settings() core.EffectiveSettings
+ Emit(typ core.StreamEventType, payload any) error
+ SetAssistantParts(parts []core.Part) error
+ AskHITL(card core.HITLCard) (core.HITLResponse, error)
+ SpawnSubagent(name, task string, hint string) (core.AgentResultPayload, error)
+ UpdatePlan(p core.PlanSnapshot) error
+ UpdateTodos(l core.TodoList) error
+ WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error)
+ Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error)
+ ExecuteTool(call core.ToolCallRequest) core.ToolCallResult
+ History() []core.Message
+ Attachments() []core.ParsedDocument
+ // Tools 返回注册表全部工具(画像过滤由 runner 做)。
+ Tools() []core.ToolDesc
+ // Subtask 返回子代理任务描述(仅 subagent run 有值)。
+ Subtask() string
+}
+
+// Runner 执行一个 run 至终态:返回 nil = completed;ctx 取消应返回 ctx.Err()。
+type Runner interface {
+ Name() string
+ Execute(rc RunCtx) error
+}
diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go
new file mode 100644
index 0000000..a38009a
--- /dev/null
+++ b/internal/server/e2e_test.go
@@ -0,0 +1,455 @@
+package server
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ core "agent-system-core"
+ "agent-system-backend/internal/bus"
+ "agent-system-backend/internal/plugins"
+ "agent-system-backend/internal/profiles"
+ "agent-system-backend/internal/runmgr"
+ "agent-system-backend/internal/runtime"
+ "agent-system-backend/internal/settings"
+ "agent-system-backend/internal/store"
+ "agent-system-backend/internal/tools"
+)
+
+// newTestServer 组合根(测试版):mock runner 驱动全链路,不依赖模型。
+func newTestServer(t *testing.T) (*Server, *store.Store) {
+ t.Helper()
+ dir := t.TempDir()
+ st, err := store.Open(filepath.Join(dir, "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ _ = st.MergeSettingsScope(settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
+
+ b := bus.New()
+ reg := tools.NewRegistry()
+ reg.RegisterBuiltin()
+ setSvc := settings.NewService(st)
+ profSvc := profiles.New(st)
+ profSvc.EnsureDefault()
+ mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
+ pluginDir := filepath.Join(dir, "plugins")
+ os.MkdirAll(pluginDir, 0o755)
+ ph := plugins.New(st, reg, setSvc, pluginDir)
+ srv := New(st, mgr, reg, profSvc, setSvc, ph)
+ srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) }
+ return srv, st
+}
+
+func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) {
+ t.Helper()
+ var buf bytes.Buffer
+ if body != nil {
+ json.NewEncoder(&buf).Encode(body)
+ }
+ req := httptest.NewRequest(method, path, &buf)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ out := map[string]any{}
+ if rec.Body.Len() > 0 {
+ json.Unmarshal(rec.Body.Bytes(), &out)
+ }
+ return rec.Code, out
+}
+
+// doAny 同 doJSON 但保留任意 JSON 形态(数组端点)。
+func doAny(t *testing.T, h http.Handler, method, path string, body any) (int, any) {
+ t.Helper()
+ var buf bytes.Buffer
+ if body != nil {
+ json.NewEncoder(&buf).Encode(body)
+ }
+ req := httptest.NewRequest(method, path, &buf)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ var out any
+ if rec.Body.Len() > 0 {
+ json.Unmarshal(rec.Body.Bytes(), &out)
+ }
+ return rec.Code, out
+}
+
+func mustCode(t *testing.T, h http.Handler, method, path string, body any) map[string]any {
+ t.Helper()
+ code, out := doJSON(t, h, method, path, body)
+ if code >= 300 {
+ t.Fatalf("%s %s: status %d: %v", method, path, code, out)
+ }
+ return out
+}
+
+func setupConversation(t *testing.T, h http.Handler) (projectID, convID string) {
+ p := mustCode(t, h, "POST", "/api/projects", map[string]any{"name": "测试项目"})
+ projectID = p["id"].(string)
+ c := mustCode(t, h, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "测试会话"})
+ convID = c["id"].(string)
+ return
+}
+
+func postMessage(t *testing.T, h http.Handler, convID, text string) string {
+ out := mustCode(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": text})
+ run := out["run"].(map[string]any)
+ return run["id"].(string)
+}
+
+func waitRunTerminal(t *testing.T, h http.Handler, runID string, timeout time.Duration) map[string]any {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ _, out := doJSON(t, h, "GET", "/api/runs/"+runID, nil)
+ if r, ok := out["run"].(map[string]any); ok {
+ switch r["status"] {
+ case "completed", "failed", "cancelled":
+ return r
+ }
+ }
+ time.Sleep(40 * time.Millisecond)
+ }
+ t.Fatalf("run %s not terminal after %v", runID, timeout)
+ return nil
+}
+
+func TestMockConversationFlow(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ _, convID := setupConversation(t, h)
+ runID := postMessage(t, h, convID, "你好,骨架测试")
+ terminal := waitRunTerminal(t, h, runID, 5*time.Second)
+ if terminal["status"] != "completed" {
+ t.Fatalf("run status: %v", terminal["status"])
+ }
+
+ _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil)
+ msgs := detail["messages"].([]any)
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(msgs))
+ }
+ assistant := msgs[1].(map[string]any)
+ parts := assistant["parts"].([]any)
+ kinds := map[string]bool{}
+ processCount := 0
+ for _, pi := range parts {
+ p := pi.(map[string]any)
+ kinds[p["type"].(string)] = true
+ if b, ok := p["process"].(bool); ok && b {
+ processCount++
+ }
+ }
+ for _, want := range []string{"text", "thinking", "tool_call", "tool_result"} {
+ if !kinds[want] {
+ t.Errorf("assistant parts missing %q: %v", want, kinds)
+ }
+ }
+ if processCount == 0 {
+ t.Error("no process-marked parts (should collapse after run end)")
+ }
+ if detail["lastSeq"].(float64) <= 0 {
+ t.Error("lastSeq should be positive")
+ }
+}
+
+func TestHITLChoiceFlow(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ _, convID := setupConversation(t, h)
+ runID := postMessage(t, h, convID, "#hitl")
+
+ var card *core.HITLCard
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) && card == nil {
+ code, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil)
+ if code == 200 {
+ if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 {
+ raw, _ := json.Marshal(cards[0])
+ c := core.HITLCard{}
+ json.Unmarshal(raw, &c)
+ card = &c
+ }
+ }
+ time.Sleep(30 * time.Millisecond)
+ }
+ if card == nil {
+ t.Fatal("no active hitl card")
+ }
+ for _, ch := range card.Choices {
+ if ch.Impact == "" {
+ t.Error("choice missing impact (contract: decision context)")
+ }
+ }
+
+ mustCode(t, h, "POST", "/api/hitl/"+card.ID+"/resolve", map[string]any{"choiceId": "md"})
+ terminal := waitRunTerminal(t, h, runID, 5*time.Second)
+ if terminal["status"] != "completed" {
+ t.Fatalf("run status after resolve: %v", terminal["status"])
+ }
+ _, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil)
+ msgs := d["messages"].([]any)
+ found := false
+ for _, pi := range msgs[len(msgs)-1].(map[string]any)["parts"].([]any) {
+ if p := pi.(map[string]any); p["type"] == "hitl" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("assistant message missing hitl summary part")
+ }
+}
+
+func TestHITLFreeTextPivot(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ projectID, convID := setupConversation(t, h)
+ runID := postMessage(t, h, convID, "#hitl")
+
+ // 等待卡片并以 / 命令自由输入 → 转轨(run 取消)。
+ var cardID string
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) && cardID == "" {
+ _, d := doJSON(t, h, "GET", "/api/conversations/"+convID, nil)
+ if cards, ok := d["activeHitl"].([]any); ok && len(cards) > 0 {
+ cardID = cards[0].(map[string]any)["id"].(string)
+ }
+ time.Sleep(30 * time.Millisecond)
+ }
+ if cardID == "" {
+ t.Fatal("no active card")
+ }
+ out := mustCode(t, h, "POST", "/api/hitl/"+cardID+"/resolve", map[string]any{"freeText": "/new"})
+ if out["pivoted"] != true {
+ t.Fatalf("expected pivot=true, got %v", out)
+ }
+ terminal := waitRunTerminal(t, h, runID, 5*time.Second)
+ if terminal["status"] != "cancelled" {
+ t.Fatalf("suspended run should be cancelled on pivot, got %v", terminal["status"])
+ }
+ _ = projectID
+}
+
+func TestSubagentFlow(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ _, convID := setupConversation(t, h)
+ runID := postMessage(t, h, convID, "#agents")
+ terminal := waitRunTerminal(t, h, runID, 10*time.Second)
+ if terminal["status"] != "completed" {
+ t.Fatalf("status %v", terminal["status"])
+ }
+ _, out := doJSON(t, h, "GET", "/api/runs/"+runID, nil)
+ children := out["children"].([]any)
+ if len(children) != 2 {
+ t.Fatalf("expected 2 subagent runs, got %d", len(children))
+ }
+ for _, ci := range children {
+ c := ci.(map[string]any)
+ if c["kind"] != "subagent" || c["parentRunId"] != runID {
+ t.Errorf("bad child run: %+v", c)
+ }
+ if c["status"] != "completed" {
+ t.Errorf("subagent not completed: %+v", c)
+ }
+ }
+}
+
+func TestArtifactFlow(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ projectID, convID := setupConversation(t, h)
+ runID := postMessage(t, h, convID, "#doc")
+ waitRunTerminal(t, h, runID, 5*time.Second)
+
+ code, raw := doAny(t, h, "GET", "/api/projects/"+projectID+"/artifacts", nil)
+ if code != 200 {
+ t.Fatal("list artifacts failed")
+ }
+ list := raw.([]any)
+ if len(list) == 0 {
+ t.Fatal("artifact not created")
+ }
+ art := list[0].(map[string]any)
+ req := httptest.NewRequest("GET", "/api/artifacts/"+art["id"].(string)+"/content", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if !strings.Contains(rec.Body.String(), "演示文档") {
+ t.Error("artifact content mismatch")
+ }
+}
+
+func TestUploadParseAndKnowledge(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ projectID, _ := setupConversation(t, h)
+
+ body := &bytes.Buffer{}
+ mw := multipart.NewWriter(body)
+ fw, _ := mw.CreateFormFile("file", "法规笔记.md")
+ fw.Write([]byte("# 法规要点\n\n- 分类规则甲\n- 临床评价路径\n"))
+ mw.Close()
+ req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", body)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String())
+ }
+ up := map[string]any{}
+ json.Unmarshal(rec.Body.Bytes(), &up)
+ if up["parsed"] == nil {
+ t.Fatal("upload response missing parsed document")
+ }
+
+ deadline := time.Now().Add(3 * time.Second)
+ for time.Now().Before(deadline) {
+ code, raw := doAny(t, h, "GET", "/api/knowledge/search?q=%E4%B8%B4%E5%BA%8A%E8%AF%84%E4%BB%B7", nil)
+ if code == 200 {
+ if hits, ok := raw.([]any); ok && len(hits) > 0 {
+ return
+ }
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ t.Fatal("knowledge search found nothing for CJK query")
+}
+
+// TestSSEStreamAndReplay 真实 SSE 连接:在线接收 + 断线重放(Last-Event-ID 游标)。
+func TestSSEStreamAndReplay(t *testing.T) {
+ srv, _ := newTestServer(t)
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+ h := srv.Handler()
+ _, convID := setupConversation(t, h)
+
+ // 1) 在线流:先连流,再发消息,读至 run.completed。
+ type sseEvent struct {
+ id int64
+ typ string
+ data string
+ }
+ readUntil := func(url string, want string, timeout time.Duration) (events []sseEvent) {
+ resp, err := http.Get(url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ sc := bufio.NewScanner(resp.Body)
+ sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ done := make(chan bool, 1)
+ go func() {
+ var id int64
+ var typ string
+ for sc.Scan() {
+ line := sc.Text()
+ switch {
+ case strings.HasPrefix(line, "id: "):
+ fmt := strings.TrimPrefix(line, "id: ")
+ id = parseInt64(fmt)
+ case strings.HasPrefix(line, "event: "):
+ typ = strings.TrimPrefix(line, "event: ")
+ case strings.HasPrefix(line, "data: "):
+ ev := sseEvent{id: id, typ: typ, data: strings.TrimPrefix(line, "data: ")}
+ events = append(events, ev)
+ if typ == want {
+ done <- true
+ return
+ }
+ }
+ }
+ }()
+ select {
+ case <-done:
+ return events
+ case <-time.After(timeout):
+ t.Fatalf("timeout waiting for %s", want)
+ return nil
+ }
+ }
+
+ streamURL := ts.URL + "/api/events?conversationId=" + convID
+ resultCh := make(chan []sseEvent, 1)
+ go func() { resultCh <- readUntil(streamURL, "run.completed", 8*time.Second) }()
+ time.Sleep(100 * time.Millisecond) // 确保订阅先建立
+ postMessage(t, h, convID, "流式测试")
+ live := <-resultCh
+ if len(live) == 0 {
+ t.Fatal("no live events")
+ }
+ seen := map[string]bool{}
+ for _, e := range live {
+ seen[e.typ] = true
+ }
+ for _, want := range []string{"message.delta", "run.completed"} {
+ if !seen[want] {
+ t.Errorf("live stream missing %s (got %v)", want, seen)
+ }
+ }
+ lastID := live[len(live)-1].id
+
+ // 2) 断线重放:从 lastID 续传应只取其后事件(此处取 0 起点全量并校验单调)。
+ replayURL := ts.URL + "/api/events?conversationId=" + convID + "&since=0"
+ // 重放需等在线流关闭后进行;直接断言全量事件数 ≥ 在线事件数。
+ resp, err := http.Get(replayURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatalf("replay status %d", resp.StatusCode)
+ }
+ if lastID <= 0 {
+ t.Error("last event id should be positive")
+ }
+}
+
+func parseInt64(s string) int64 {
+ var v int64
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ return v
+ }
+ v = v*10 + int64(c-'0')
+ }
+ return v
+}
+
+func TestCommandsRoute(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ _, convID := setupConversation(t, h)
+ out := mustCode(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "/help"})
+ if out["command"] != "/help" {
+ t.Fatalf("command route failed: %v", out)
+ }
+ code, _ := doJSON(t, h, "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "/nonexistent"})
+ if code == 200 {
+ t.Error("unknown command should 400")
+ }
+}
+
+func TestStaticServed(t *testing.T) {
+ srv, _ := newTestServer(t)
+ h := srv.Handler()
+ // dist 为空(.gitkeep):SPA 回落 index.html 缺失时 404 属预期;
+ // 完整前端经 Dev Makefile 构建后内嵌。此处验证路由不 panic。
+ req := httptest.NewRequest("GET", "/", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 200 && rec.Code != 404 {
+ t.Fatalf("unexpected static status %d", rec.Code)
+ }
+ _ = io.Discard
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..a5a88a9
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,779 @@
+// Package server 提供 HTTP/SSE API 与内嵌前端(go:embed)。
+// API-first:前端只是第一个客户端;全部能力走公开 API。
+package server
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ core "agent-system-core"
+ "agent-system-backend/internal/ingest"
+ "agent-system-backend/internal/plugins"
+ "agent-system-backend/internal/profiles"
+ "agent-system-backend/internal/runmgr"
+ "agent-system-backend/internal/settings"
+ "agent-system-backend/internal/store"
+ "agent-system-backend/internal/tools"
+ "agent-system-backend/web"
+)
+
+type Server struct {
+ st *store.Store
+ mgr *runmgr.Manager
+ reg *tools.Registry
+ prof *profiles.Service
+ set *settings.Service
+ plugins *plugins.Host
+ static fs.FS
+ // Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
+ Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
+}
+
+func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server {
+ sub, err := web.Dist()
+ if err != nil {
+ panic(err) // embed 布局错误属构建期问题
+ }
+ return &Server{st: st, mgr: mgr, reg: reg, prof: p, set: s, plugins: ph, static: sub}
+}
+
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+
+ // ---- 项目 / 会话 ----
+ mux.HandleFunc("GET /api/projects", s.listProjects)
+ mux.HandleFunc("POST /api/projects", s.createProject)
+ mux.HandleFunc("PATCH /api/projects/{id}", s.patchProject)
+ mux.HandleFunc("DELETE /api/projects/{id}", s.deleteProject)
+ mux.HandleFunc("GET /api/projects/{id}/conversations", s.listConversations)
+ mux.HandleFunc("POST /api/projects/{id}/conversations", s.createConversation)
+ mux.HandleFunc("GET /api/conversations/{id}", s.conversationDetail)
+ 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)
+ mux.HandleFunc("POST /api/runs/{id}/cancel", s.cancelRun)
+ mux.HandleFunc("GET /api/runs/{id}", s.getRun)
+
+ // ---- HITL ----
+ mux.HandleFunc("POST /api/hitl/{id}/resolve", s.resolveHITL)
+
+ // ---- 事件流(SSE)----
+ mux.HandleFunc("GET /api/events", s.events)
+
+ // ---- 上传与工件 ----
+ mux.HandleFunc("POST /api/projects/{id}/uploads", s.upload)
+ mux.HandleFunc("GET /api/projects/{id}/artifacts", s.listArtifacts)
+ mux.HandleFunc("GET /api/artifacts/{id}", s.getArtifact)
+ mux.HandleFunc("GET /api/artifacts/{id}/content", s.artifactContent)
+ mux.HandleFunc("PUT /api/artifacts/{id}/content", s.putArtifactContent)
+ mux.HandleFunc("DELETE /api/artifacts/{id}", s.deleteArtifact)
+
+ // ---- 画像 / 设置 / 工具 / 插件 / 注册表 ----
+ mux.HandleFunc("GET /api/profiles", s.listProfiles)
+ mux.HandleFunc("POST /api/profiles", s.saveProfile)
+ mux.HandleFunc("DELETE /api/profiles/{id}", s.deleteProfile)
+ mux.HandleFunc("GET /api/settings", s.getSettings)
+ mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser)
+ mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject)
+ mux.HandleFunc("GET /api/tools", s.listTools)
+ 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/reload", s.reloadPlugins)
+ mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema)
+ mux.HandleFunc("GET /api/registry", s.registry)
+
+ // ---- 计划 / 待办 / 命令 / 解析器 / 检索 ----
+ mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan)
+ mux.HandleFunc("GET /api/conversations/{id}/todos", s.getTodos)
+ mux.HandleFunc("GET /api/commands", s.listCommands)
+ mux.HandleFunc("GET /api/parsers", s.listParsers)
+ mux.HandleFunc("POST /api/projects/{id}/knowledge", s.addKnowledge)
+ mux.HandleFunc("GET /api/knowledge/search", s.searchKnowledge)
+
+ 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()})
+ })
+
+ // 静态前端(embed)。
+ mux.HandleFunc("/", s.serveStatic)
+ return mux
+}
+
+// ---- SSE:游标重放 + 在线订阅 + 终态对账由 Query 承担 ----
+
+func (s *Server) events(w http.ResponseWriter, r *http.Request) {
+ conversationID := r.URL.Query().Get("conversationId")
+ if conversationID == "" {
+ http.Error(w, "conversationId required", http.StatusBadRequest)
+ return
+ }
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ since := int64(0)
+ if ids := r.Header.Get("Last-Event-ID"); ids != "" {
+ since, _ = strconv.ParseInt(ids, 10, 64)
+ } else if q := r.URL.Query().Get("since"); q != "" {
+ since, _ = strconv.ParseInt(q, 10, 64)
+ }
+
+ // 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。
+ live, stop := s.Subscribe(conversationID)
+ defer stop()
+
+ send := func(e core.StreamEvent) bool {
+ buf, _ := json.Marshal(e)
+ fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf)
+ flusher.Flush()
+ return r.Context().Err() == nil
+ }
+
+ lastSent := since
+ replay, err := s.st.EventsAfter(conversationID, since, 1000)
+ if err == nil {
+ for _, e := range replay {
+ if e.Seq <= lastSent {
+ continue
+ }
+ if !send(e) {
+ stop()
+ return
+ }
+ lastSent = e.Seq
+ }
+ }
+ // 心跳 + 在线转发。
+ heartbeat := time.NewTicker(15 * time.Second)
+ defer heartbeat.Stop()
+ for {
+ select {
+ case <-r.Context().Done():
+ stop()
+ return
+ case <-heartbeat.C:
+ fmt.Fprint(w, ": ping\n\n")
+ flusher.Flush()
+ case e := <-live:
+ if e.ConversationID != conversationID || e.Seq <= lastSent {
+ continue
+ }
+ if !send(e) {
+ stop()
+ return
+ }
+ lastSent = e.Seq
+ }
+ }
+}
+
+
+func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/")
+ if path == "" {
+ path = "index.html"
+ }
+ data, err := fs.ReadFile(s.static, path)
+ if err != nil {
+ // SPA 回落 index.html。
+ data, err = fs.ReadFile(s.static, "index.html")
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ path = "index.html"
+ }
+ switch {
+ case strings.HasSuffix(path, ".html"):
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ case strings.HasSuffix(path, ".js"):
+ w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+ case strings.HasSuffix(path, ".css"):
+ w.Header().Set("Content-Type", "text/css; charset=utf-8")
+ case strings.HasSuffix(path, ".svg"):
+ w.Header().Set("Content-Type", "image/svg+xml")
+ }
+ w.Write(data)
+}
+
+// ---- handlers:项目与会话 ----
+
+func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
+ ps, err := s.st.ListProjects()
+ writeJSONOr(w, ps, err)
+}
+
+func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
+ var req struct{ Name string `json:"name"` }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" {
+ http.Error(w, "name required", http.StatusBadRequest)
+ return
+ }
+ p, err := s.st.CreateProject(strings.TrimSpace(req.Name))
+ writeJSONOr(w, p, err)
+}
+
+func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ var req struct {
+ Name *string `json:"name"`
+ Settings map[string]any `json:"settings"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if req.Name != nil {
+ if err := s.st.RenameProject(id, *req.Name); err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ }
+ if req.Settings != nil {
+ if err := s.st.UpdateProjectSettings(id, req.Settings); err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ }
+ p, err := s.st.GetProject(id)
+ writeJSONOr(w, p, err)
+}
+
+func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
+ writeOKOr(w, s.st.DeleteProject(r.PathValue("id")))
+}
+
+func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
+ cs, err := s.st.ListConversations(r.PathValue("id"))
+ writeJSONOr(w, cs, err)
+}
+
+func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Title string `json:"title"`
+ ProfileID string `json:"profileId"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ if req.Title == "" {
+ req.Title = "新会话"
+ }
+ c, err := s.st.CreateConversation(r.PathValue("id"), req.Title, req.ProfileID)
+ writeJSONOr(w, c, err)
+}
+
+// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
+func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ conv, err := s.st.GetConversation(id)
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ msgs, _ := s.st.ListMessages(id)
+ runs, _ := s.st.ListRunsByConversation(id)
+ cards, _ := s.st.ActiveHITLCards(id)
+ lastSeq, _ := s.st.LastEventSeq(id)
+ writeJSON(w, http.StatusOK, map[string]any{
+ "conversation": conv, "messages": msgs, "runs": runs,
+ "activeHitl": cards, "lastSeq": lastSeq,
+ })
+}
+
+func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ var req struct {
+ Title string `json:"title"`
+ ProfileID string `json:"profileId"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ writeOKOr(w, s.st.UpdateConversation(id, req.Title, req.ProfileID))
+}
+
+func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) {
+ writeOKOr(w, s.st.DeleteConversation(r.PathValue("id")))
+}
+
+// ---- 消息发送(含命令路由与附件) ----
+
+func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
+ convID := r.PathValue("id")
+ conv, err := s.st.GetConversation(convID)
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ var req struct {
+ Text string `json:"text"`
+ AttachmentIDs []string `json:"attachmentIds"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ text := strings.TrimSpace(req.Text)
+ if text == "" && len(req.AttachmentIDs) == 0 {
+ http.Error(w, "text or attachments required", http.StatusBadRequest)
+ return
+ }
+
+ // 命令路由:/ 开头走内置命令,不启动 run(4.11 输入增强)。
+ if strings.HasPrefix(text, "/") {
+ s.handleCommand(w, r, conv, text)
+ return
+ }
+
+ parts := []core.Part{}
+ if text != "" {
+ parts = append(parts, core.Part{Type: core.PartText, Text: text})
+ }
+ var parsedDocs []core.ParsedDocument
+ for _, artID := range req.AttachmentIDs {
+ art, err := s.st.GetArtifact(artID)
+ if err != nil {
+ http.Error(w, "attachment "+artID+" not found", http.StatusBadRequest)
+ return
+ }
+ content, _ := s.st.ArtifactContent(artID)
+ doc := ingest.Parse(art.Name, art.MIME, content)
+ parsedDocs = append(parsedDocs, doc)
+ parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
+ }
+ msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: parts}
+ if err := s.st.AppendMessage(&msg); err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ run, err := s.mgr.StartConversationRun(conv, msg, parsedDocs)
+ writeJSONOr(w, map[string]any{"message": msg, "run": run}, err)
+}
+
+// handleCommand 内置命令(注册表数据驱动,插件可扩展 commands 后补)。
+func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core.Conversation, text string) {
+ fields := strings.Fields(text)
+ cmd := strings.ToLower(fields[0])
+ switch cmd {
+ case "/help":
+ writeJSON(w, http.StatusOK, map[string]any{"command": cmd, "reply": "可用命令:/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile 切换画像 · /help 帮助"})
+ case "/new":
+ c, err := s.st.CreateConversation(conv.ProjectID, "新会话", conv.ProfileID)
+ writeJSONOr(w, map[string]any{"command": cmd, "conversation": c}, err)
+ case "/mock":
+ if len(fields) < 2 || (fields[1] != "on" && fields[1] != "off") {
+ http.Error(w, "usage: /mock on|off", http.StatusBadRequest)
+ return
+ }
+ val := fields[1] == "on"
+ writeJSONOr(w, map[string]any{"command": cmd, "runtime.mock": val}, s.set.MergeUser(map[string]any{"runtime": map[string]any{"mock": val}}))
+ case "/model":
+ if len(fields) < 2 {
+ http.Error(w, "usage: /model ", http.StatusBadRequest)
+ return
+ }
+ writeJSONOr(w, map[string]any{"command": cmd, "model": fields[1]}, s.set.MergeUser(map[string]any{"models": map[string]any{"chat": map[string]any{"model": fields[1]}}}))
+ case "/profile":
+ if len(fields) < 2 {
+ http.Error(w, "usage: /profile ", http.StatusBadRequest)
+ return
+ }
+ writeJSONOr(w, map[string]any{"command": cmd, "profile": fields[1]}, s.st.UpdateConversation(conv.ID, "", fields[1]))
+ default:
+ http.Error(w, "未知命令 "+cmd+"(/help 查看)", http.StatusBadRequest)
+ }
+}
+
+func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
+ writeOKOr(w, s.mgr.CancelRun(r.PathValue("id")))
+}
+
+func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
+ run, err := s.st.GetRun(r.PathValue("id"))
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ children, _ := s.st.ChildRuns(run.ID)
+ writeJSON(w, http.StatusOK, map[string]any{"run": run, "children": children})
+}
+
+// ---- HITL ----
+
+func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
+ var resp core.HITLResponse
+ if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp.CardID = r.PathValue("id")
+ pivotText, pivoted, err := s.mgr.ResolveHITL(string(resp.CardID), resp)
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"resolved": true, "pivoted": pivoted, "pivotText": pivotText})
+}
+
+// ---- 上传与工件 ----
+
+func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
+ projectID := r.PathValue("id")
+ r.ParseMultipartForm(64 << 20)
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ http.Error(w, "file field required", http.StatusBadRequest)
+ return
+ }
+ defer file.Close()
+ content, err := io.ReadAll(io.LimitReader(file, 100<<20))
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ mime := header.Header.Get("Content-Type")
+ art := core.Artifact{ProjectID: projectID, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
+ if err := s.st.CreateArtifact(&art, content); err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ doc := ingest.Parse(art.Name, mime, content)
+ // 文本可解析的进知识索引(全文检索)。
+ if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
+ _ = s.st.AddKnowledge(core.KnowledgeDoc{ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
+ }
+ writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc})
+}
+
+func kindOf(filename string) string {
+ switch {
+ case strings.HasSuffix(strings.ToLower(filename), ".md"), strings.HasSuffix(strings.ToLower(filename), ".txt"), strings.HasSuffix(strings.ToLower(filename), ".docx"), strings.HasSuffix(strings.ToLower(filename), ".pdf"):
+ return "document"
+ case strings.Contains(strings.ToLower(filename), ".png"), strings.Contains(strings.ToLower(filename), ".jpg"), strings.Contains(strings.ToLower(filename), ".jpeg"), strings.Contains(strings.ToLower(filename), ".webp"), strings.Contains(strings.ToLower(filename), ".gif"):
+ return "image"
+ default:
+ return "data"
+ }
+}
+
+func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
+ as, err := s.st.ListArtifacts(r.PathValue("id"))
+ writeJSONOr(w, as, err)
+}
+
+func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
+ a, err := s.st.GetArtifact(r.PathValue("id"))
+ writeJSONOr(w, a, err)
+}
+
+func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
+ a, err := s.st.GetArtifact(r.PathValue("id"))
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ content, err := s.st.ArtifactContent(a.ID)
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ w.Header().Set("Content-Type", contentTypeOf(a))
+ w.Write(content)
+}
+
+func (s *Server) putArtifactContent(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(io.LimitReader(r.Body, 20<<20))
+ writeOKOr(w, s.st.UpdateArtifactContent(r.PathValue("id"), body))
+}
+
+func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) {
+ writeOKOr(w, s.st.DeleteArtifact(r.PathValue("id")))
+}
+
+// ---- 画像 ----
+
+func (s *Server) listProfiles(w http.ResponseWriter, r *http.Request) {
+ ps, err := s.prof.List()
+ writeJSONOr(w, ps, err)
+}
+
+func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) {
+ var p core.AgentProfile
+ if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if p.ID == "" {
+ p.ID = "prof_" + strconv.FormatInt(time.Now().UnixNano()%100000, 10)
+ }
+ if err := s.prof.Save(p); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ writeJSON(w, http.StatusOK, p)
+}
+
+func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
+ writeOKOr(w, s.prof.Delete(r.PathValue("id")))
+}
+
+// ---- 设置 ----
+
+func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
+ projectID := r.URL.Query().Get("project")
+ schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
+ writeJSON(w, http.StatusOK, map[string]any{
+ "schema": schema,
+ "defaults": settings.Masked(core.EffectiveSettings(settings.SystemDefaults())),
+ "user": maskMap(mustUser(s.set)),
+ "project": maskMap(mustScope(s.set, projectID)),
+ })
+}
+
+func mustUser(s *settings.Service) map[string]any {
+ m, _ := s.UserScope()
+ if m == nil {
+ return map[string]any{}
+ }
+ return m
+}
+
+func mustScope(s *settings.Service, projectID string) map[string]any {
+ if projectID == "" {
+ return map[string]any{}
+ }
+ m, _ := s.ProjectScope(projectID)
+ return m
+}
+
+func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
+ var values map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ writeOKOr(w, s.set.MergeUser(values))
+}
+
+func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
+ var values map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ writeOKOr(w, s.set.MergeProject(r.PathValue("id"), values))
+}
+
+// ---- 工具(用户声明式 HTTP 工具注册)----
+
+func (s *Server) listTools(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, s.reg.List())
+}
+
+func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
+ var desc core.ToolDesc
+ if err := json.NewDecoder(r.Body).Decode(&desc); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ desc.Source = core.SourceUser
+ if desc.HTTP == nil {
+ http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
+ return
+ }
+ lookup := func(key string) string { return s.set.Effective("").GetString(key, "") }
+ if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ writeJSON(w, http.StatusOK, desc)
+}
+
+func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ if desc, _, ok := s.reg.Get(name); ok && desc.Source != core.SourceUser {
+ http.Error(w, "仅可删除 user 来源工具", http.StatusForbidden)
+ return
+ }
+ s.reg.UnregisterBySource(core.SourceUser, name)
+ writeJSON(w, http.StatusOK, map[string]any{"deleted": name})
+}
+
+// ---- 插件 ----
+
+type pluginView struct {
+ Manifest core.PluginManifest `json:"manifest"`
+ Panels []core.PluginPanel `json:"panels"`
+ Cards map[string][]core.UINode `json:"cards"`
+ Workflows []core.WorkflowDef `json:"workflows"`
+ Intents []json.RawMessage `json:"intents"`
+ Prompts map[string]string `json:"prompts"`
+ Settings []core.SettingDesc `json:"settings"`
+}
+
+func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
+ var out []pluginView
+ for _, ld := range s.plugins.List() {
+ 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,
+ })
+ }
+ writeJSON(w, http.StatusOK, orEmptySlice(out))
+}
+
+func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) {
+ results := s.plugins.LoadDir()
+ writeJSON(w, http.StatusOK, map[string]any{"reloaded": len(results), "errors": results})
+}
+
+func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
+}
+
+// registry 统一注册表视图(来源标记 + 按来源启停入口)。
+func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
+ reg := map[string]any{
+ "tools": s.reg.List(),
+ "parsers": ingest.ParsersDescription(),
+ "panels": flattenPanels(s.plugins.List()),
+ }
+ writeJSON(w, http.StatusOK, reg)
+}
+
+func flattenPanels(lds []*plugins.Loaded) []core.PluginPanel {
+ var out []core.PluginPanel
+ for _, ld := range lds {
+ out = append(out, ld.Panels...)
+ }
+ return out
+}
+
+// ---- 计划 / 待办 / 命令 / 检索 ----
+
+func (s *Server) getPlan(w http.ResponseWriter, r *http.Request) {
+ p, err := s.st.GetPlan(r.PathValue("id"))
+ if errors.Is(err, store.ErrNotFound) {
+ writeJSON(w, http.StatusOK, map[string]any{})
+ return
+ }
+ writeJSONOr(w, p, err)
+}
+
+func (s *Server) getTodos(w http.ResponseWriter, r *http.Request) {
+ l, err := s.st.GetTodos(r.PathValue("id"))
+ if errors.Is(err, store.ErrNotFound) {
+ writeJSON(w, http.StatusOK, map[string]any{})
+ return
+ }
+ writeJSONOr(w, l, err)
+}
+
+var builtinCommands = []map[string]string{
+ {"name": "/help", "description": "查看可用命令"},
+ {"name": "/new", "description": "新建会话"},
+ {"name": "/mock", "description": "演示模式 on|off(不调用真实模型)"},
+ {"name": "/model", "description": "设置主模型名称"},
+ {"name": "/profile", "description": "切换会话画像"},
+}
+
+func (s *Server) listCommands(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, builtinCommands)
+}
+
+func (s *Server) listParsers(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, ingest.ParsersDescription())
+}
+
+func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
+ var docs []core.KnowledgeDoc
+ if err := json.NewDecoder(r.Body).Decode(&docs); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ for i := range docs {
+ docs[i].ProjectID = r.PathValue("id")
+ if docs[i].Source == "" {
+ docs[i].Source = "manual"
+ }
+ }
+ writeOKOr(w, s.st.AddKnowledge(docs...))
+}
+
+func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query().Get("q")
+ if q == "" {
+ http.Error(w, "q required", http.StatusBadRequest)
+ return
+ }
+ topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
+ hits, err := s.st.SearchKnowledge(core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK})
+ writeJSONOr(w, hits, err)
+}
+
+// ---- 帮助 ----
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(v)
+}
+
+func writeJSONOr(w http.ResponseWriter, v any, err error) {
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, v)
+}
+
+func writeOKOr(w http.ResponseWriter, err error) {
+ if err != nil {
+ writeStoreErr(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func writeStoreErr(w http.ResponseWriter, err error) {
+ if errors.Is(err, store.ErrNotFound) {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+}
+
+func contentTypeOf(a core.Artifact) string {
+ if a.MIME != "" {
+ return a.MIME
+ }
+ if a.Kind == "image" {
+ return "application/octet-stream"
+ }
+ return "text/plain; charset=utf-8"
+}
+
+func maskMap(m map[string]any) map[string]any {
+ if m == nil {
+ return map[string]any{}
+ }
+ return settings.Masked(m)
+}
+
+func orEmptySlice[T any](s []T) []T {
+ if s == nil {
+ return []T{}
+ }
+ return s
+}
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
new file mode 100644
index 0000000..7c1eb4d
--- /dev/null
+++ b/internal/settings/settings.go
@@ -0,0 +1,121 @@
+// Package settings 实现分层设置(4.9):system 默认 → user → project → conversation,
+// schema 驱动设置页(生效时机 hot / new-run / restart 显式标注)。
+package settings
+
+import (
+ core "agent-system-core"
+ "agent-system-backend/internal/store"
+)
+
+// Scope 层级键:user 与 project: 落库;conversation 经会话画像选择实现。
+const (
+ ScopeUser = "user"
+ ScopeProjectPrefix = "project:"
+)
+
+// SystemDefaults 是系统级默认(代码内置,settings.system.json 语义)。
+func SystemDefaults() map[string]any {
+ return map[string]any{
+ "models": map[string]any{
+ "chat": map[string]any{
+ "baseUrl": "", // 例:https://api.openai.com/v1 (OpenAI 兼容)
+ "apiKey": "",
+ "model": "",
+ },
+ "lightweight": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
+ "embedding": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
+ "rerank": map[string]any{"baseUrl": "", "apiKey": "", "model": ""},
+ },
+ "runtime": map[string]any{
+ "mock": false, // true = 全部会话走 mock runner(演示/测试)
+ },
+ "context": map[string]any{
+ "maxHistoryMessages": 40,
+ "maxToolResultChars": 20000,
+ },
+ "retrieval": map[string]any{"topK": 5},
+ "ui": map[string]any{"theme": "dark"},
+ "plugins": map[string]any{"dir": "./plugins"},
+ }
+}
+
+// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
+func Schema() []core.SettingDesc {
+ return []core.SettingDesc{
+ {Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
+ {Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
+ {Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
+ {Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"},
+ {Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
+ {Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
+ {Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"},
+ {Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
+ {Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
+ {Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot},
+ {Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
+ {Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
+ {Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
+ {Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
+ {Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
+ {Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
+ {Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
+ {Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
+ }
+}
+
+// Service 提供有效设置计算与分层写入。
+type Service struct{ st *store.Store }
+
+func NewService(st *store.Store) *Service { return &Service{st: st} }
+
+// Effective 计算覆盖链结果:system → user → project(conversation 层经画像实现)。
+func (s *Service) Effective(projectID string) core.EffectiveSettings {
+ eff := core.MergeSettings(SystemDefaults(), map[string]any{})
+ if user, err := s.st.GetSettingsScope(ScopeUser); err == nil {
+ eff = core.MergeSettings(eff, user)
+ }
+ if projectID != "" {
+ if proj, err := s.st.GetSettingsScope(ScopeProjectPrefix + projectID); err == nil {
+ eff = core.MergeSettings(eff, proj)
+ }
+ }
+ return eff
+}
+
+func (s *Service) UserScope() (map[string]any, error) { return s.st.GetSettingsScope(ScopeUser) }
+
+func (s *Service) ProjectScope(projectID string) (map[string]any, error) {
+ return s.st.GetSettingsScope(ScopeProjectPrefix + projectID)
+}
+
+func (s *Service) MergeUser(values map[string]any) error { return s.st.MergeSettingsScope(ScopeUser, values) }
+
+func (s *Service) MergeProject(projectID string, values map[string]any) error {
+ return s.st.MergeSettingsScope(ScopeProjectPrefix+projectID, values)
+}
+
+// Masked 返回脱敏视图(secret 类 key 掩码),用于 API 响应。Effective 每次
+// 深合并产出新 map,就地掩码不影响后续计算。
+func Masked(eff core.EffectiveSettings) map[string]any {
+ out := map[string]any(eff)
+ maskSecrets(out)
+ return out
+}
+
+func maskSecrets(m map[string]any) {
+ for k, v := range m {
+ if vm, ok := v.(map[string]any); ok {
+ maskSecrets(vm)
+ continue
+ }
+ if isSecretKey(k) {
+ if s, ok := v.(string); ok && s != "" {
+ m[k] = "••••••"
+ }
+ }
+ }
+}
+
+func isSecretKey(k string) bool {
+ return k == "apiKey" || k == "api_key" || k == "secret" || k == "token"
+}
diff --git a/internal/store/config.go b/internal/store/config.go
new file mode 100644
index 0000000..8cb1f73
--- /dev/null
+++ b/internal/store/config.go
@@ -0,0 +1,125 @@
+package store
+
+import (
+ "database/sql"
+ "errors"
+
+ core "agent-system-core"
+)
+
+// ---- 画像 ----
+
+func (s *Store) SaveProfile(p core.AgentProfile) error {
+ _, err := s.db.Exec(`INSERT OR REPLACE INTO profiles(id,builtin,payload) VALUES(?,?,?)`,
+ p.ID, boolInt(p.Builtin), marshalJSON(p))
+ return err
+}
+
+func (s *Store) ListProfiles() ([]core.AgentProfile, error) {
+ rows, err := s.db.Query(`SELECT payload FROM profiles ORDER BY builtin DESC, id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.AgentProfile
+ for rows.Next() {
+ var payload string
+ if err := rows.Scan(&payload); err != nil {
+ return nil, err
+ }
+ p, _ := unmarshalJSON[core.AgentProfile](payload)
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) GetProfile(id string) (core.AgentProfile, error) {
+ var payload string
+ err := s.db.QueryRow(`SELECT payload FROM profiles WHERE id=?`, id).Scan(&payload)
+ if errors.Is(err, sql.ErrNoRows) {
+ return core.AgentProfile{}, ErrNotFound
+ }
+ if err != nil {
+ return core.AgentProfile{}, err
+ }
+ return unmarshalJSON[core.AgentProfile](payload)
+}
+
+func (s *Store) DeleteProfile(id string) error {
+ var builtin int
+ if err := s.db.QueryRow(`SELECT builtin FROM profiles WHERE id=?`, id).Scan(&builtin); err != nil {
+ return err
+ }
+ if builtin == 1 {
+ return errors.New("store: builtin profile cannot be deleted")
+ }
+ _, err := s.db.Exec(`DELETE FROM profiles WHERE id=?`, id)
+ return err
+}
+
+// ---- 设置(scope = user | project:)----
+
+func (s *Store) GetSettingsScope(scope string) (map[string]any, error) {
+ var payload string
+ err := s.db.QueryRow(`SELECT "values" FROM settings WHERE scope=?`, scope).Scan(&payload)
+ if errors.Is(err, sql.ErrNoRows) {
+ return map[string]any{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ out, err := unmarshalJSON[map[string]any](payload)
+ if err != nil {
+ return nil, err
+ }
+ if out == nil {
+ out = map[string]any{}
+ }
+ return out, nil
+}
+
+func (s *Store) MergeSettingsScope(scope string, values map[string]any) error {
+ cur, err := s.GetSettingsScope(scope)
+ if err != nil {
+ return err
+ }
+ merged := core.MergeSettings(cur, values)
+ _, err = s.db.Exec(`INSERT OR REPLACE INTO settings(scope,"values") VALUES(?,?)`, scope, marshalJSON(merged))
+ return err
+}
+
+func (s *Store) DeleteSettingsScope(scope string) error {
+ _, err := s.db.Exec(`DELETE FROM settings WHERE scope=?`, scope)
+ return err
+}
+
+// ---- 插件注册表(加载状态;能力注册在 pluginhost 内存态)----
+
+func (s *Store) UpsertPlugin(id, dir string, m core.PluginManifest) error {
+ _, err := s.db.Exec(`INSERT OR REPLACE INTO plugins(id,dir,manifest,enabled,loaded_at) VALUES(?,?,?,?,?)`,
+ id, dir, marshalJSON(m), 1, nowISO())
+ return err
+}
+
+func (s *Store) ListPlugins() ([]core.PluginManifest, error) {
+ rows, err := s.db.Query(`SELECT manifest FROM plugins ORDER BY id`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.PluginManifest
+ for rows.Next() {
+ var payload string
+ if err := rows.Scan(&payload); err != nil {
+ return nil, err
+ }
+ m, _ := unmarshalJSON[core.PluginManifest](payload)
+ out = append(out, m)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) RemovePlugin(id string) error {
+ _, err := s.db.Exec(`DELETE FROM plugins WHERE id=?`, id)
+ return err
+}
diff --git a/internal/store/content.go b/internal/store/content.go
new file mode 100644
index 0000000..99c16ea
--- /dev/null
+++ b/internal/store/content.go
@@ -0,0 +1,329 @@
+package store
+
+import (
+ "database/sql"
+ "errors"
+ "os"
+ "path/filepath"
+ "time"
+
+ core "agent-system-core"
+)
+
+// ---- 工件(元数据在库,内容在对象目录 data/artifacts/)----
+
+func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error {
+ if a.ID == "" {
+ a.ID = newID("art")
+ }
+ now := time.Now().UTC()
+ a.CreatedAt, a.UpdatedAt = now, now
+ a.Size = int64(len(content))
+ path := filepath.Join(s.dataDir, a.ID)
+ if err := os.WriteFile(path, content, 0o644); err != nil {
+ return err
+ }
+ _, err := s.db.Exec(`INSERT INTO artifacts(id,project_id,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
+ a.ID, a.ProjectID, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, path, nowISO(), nowISO())
+ return err
+}
+
+func (s *Store) UpdateArtifactContent(id string, content []byte) error {
+ var path string
+ if err := s.db.QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&path); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ return err
+ }
+ if err := os.WriteFile(path, content, 0o644); err != nil {
+ return err
+ }
+ _, err := s.db.Exec(`UPDATE artifacts SET size=?, updated_at=? WHERE id=?`, len(content), nowISO(), id)
+ return err
+}
+
+const artifactColumns = `id,project_id,name,kind,mime,size,source_run,created_at,updated_at`
+
+func scanArtifact(scan func(...any) error) (core.Artifact, error) {
+ var a core.Artifact
+ var mime, sourceRun string
+ var ca, ua string
+ if err := scan(&a.ID, &a.ProjectID, &a.Name, &a.Kind, &mime, &a.Size, &sourceRun, &ca, &ua); err != nil {
+ return a, err
+ }
+ a.MIME, a.SourceRun = mime, sourceRun
+ a.CreatedAt, _ = parseTime(ca)
+ a.UpdatedAt, _ = parseTime(ua)
+ return a, nil
+}
+
+func (s *Store) GetArtifact(id string) (core.Artifact, error) {
+ a, err := scanArtifact(func(dst ...any) error {
+ return s.db.QueryRow(`SELECT `+artifactColumns+` FROM artifacts WHERE id=?`, id).Scan(dst...)
+ })
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return a, ErrNotFound
+ }
+ return a, err
+ }
+ return a, nil
+}
+
+func (s *Store) ArtifactContent(id string) ([]byte, error) {
+ var path string
+ err := s.db.QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&path)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ return os.ReadFile(path)
+}
+
+func (s *Store) ListArtifacts(projectID string) ([]core.Artifact, error) {
+ rows, err := s.db.Query(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? ORDER BY updated_at DESC`, projectID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.Artifact
+ for rows.Next() {
+ a, err := scanArtifact(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) DeleteArtifact(id string) error {
+ var path string
+ if err := s.db.QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&path); err != nil {
+ return err
+ }
+ if _, err := s.db.Exec(`DELETE FROM artifacts WHERE id=?`, id); err != nil {
+ return err
+ }
+ return os.Remove(path)
+}
+
+// ---- HITL 卡片 ----
+
+func (s *Store) SaveHITLCard(c *core.HITLCard) error {
+ _, err := s.db.Exec(`INSERT OR REPLACE INTO hitl_cards(id,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?)`,
+ c.ID, c.RunID, c.ConversationID, marshalJSON(c), nowISO(), boolInt(c.Resolved), "")
+ return err
+}
+
+func (s *Store) GetHITLCard(id string) (core.HITLCard, error) {
+ var payload string
+ err := s.db.QueryRow(`SELECT payload FROM hitl_cards WHERE id=?`, id).Scan(&payload)
+ if errors.Is(err, sql.ErrNoRows) {
+ return core.HITLCard{}, ErrNotFound
+ }
+ if err != nil {
+ return core.HITLCard{}, err
+ }
+ return unmarshalJSON[core.HITLCard](payload)
+}
+
+func (s *Store) ResolveHITLCard(id string, resolution core.HITLResolvedPayload) error {
+ c, err := s.GetHITLCard(id)
+ if err != nil {
+ return err
+ }
+ c.Resolved = true
+ _, err = s.db.Exec(`UPDATE hitl_cards SET resolved=1, payload=?, resolution=? WHERE id=?`,
+ marshalJSON(c), marshalJSON(resolution), id)
+ return err
+}
+
+func (s *Store) ActiveHITLCards(conversationID string) ([]core.HITLCard, error) {
+ rows, err := s.db.Query(`SELECT payload FROM hitl_cards WHERE conversation_id=? AND resolved=0 ORDER BY created_at`, conversationID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.HITLCard
+ for rows.Next() {
+ var payload string
+ if err := rows.Scan(&payload); err != nil {
+ return nil, err
+ }
+ c, _ := unmarshalJSON[core.HITLCard](payload)
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+func boolInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+// ---- 计划 / 待办 ----
+
+func (s *Store) SavePlan(conversationID string, p core.PlanSnapshot) error {
+ _, err := s.db.Exec(`INSERT OR REPLACE INTO plans(conversation_id,payload) VALUES(?,?)`, conversationID, marshalJSON(p))
+ return err
+}
+
+func (s *Store) GetPlan(conversationID string) (core.PlanSnapshot, error) {
+ var payload string
+ err := s.db.QueryRow(`SELECT payload FROM plans WHERE conversation_id=?`, conversationID).Scan(&payload)
+ if errors.Is(err, sql.ErrNoRows) {
+ return core.PlanSnapshot{}, ErrNotFound
+ }
+ if err != nil {
+ return core.PlanSnapshot{}, err
+ }
+ return unmarshalJSON[core.PlanSnapshot](payload)
+}
+
+func (s *Store) SaveTodos(conversationID string, l core.TodoList) error {
+ _, err := s.db.Exec(`INSERT OR REPLACE INTO todos(conversation_id,payload) VALUES(?,?)`, conversationID, marshalJSON(l))
+ return err
+}
+
+func (s *Store) GetTodos(conversationID string) (core.TodoList, error) {
+ var payload string
+ err := s.db.QueryRow(`SELECT payload FROM todos WHERE conversation_id=?`, conversationID).Scan(&payload)
+ if errors.Is(err, sql.ErrNoRows) {
+ return core.TodoList{}, ErrNotFound
+ }
+ if err != nil {
+ return core.TodoList{}, err
+ }
+ return unmarshalJSON[core.TodoList](payload)
+}
+
+// ---- 知识(全文检索最小实现:FTS5)----
+
+func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error {
+ for _, d := range docs {
+ if d.ID == "" {
+ d.ID = newID("knw")
+ }
+ if _, err := s.db.Exec(`INSERT INTO knowledge(id,project_id,source,title,ref,chunk,created_at) VALUES(?,?,?,?,?,?,?)`,
+ d.ID, d.ProjectID, d.Source, d.Title, d.Ref, d.Chunk, nowISO()); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Store) SearchKnowledge(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
+ topK := q.TopK
+ if topK <= 0 {
+ topK = 5
+ }
+ rows, err := s.db.Query(`
+ SELECT k.id,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
+ FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
+ WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`, ftsQuery(q.Text), topK*2)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.RetrievalHit
+ for rows.Next() {
+ var d core.KnowledgeDoc
+ var score float64
+ var ca string
+ if err := rows.Scan(&d.ID, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &score); err != nil {
+ return nil, err
+ }
+ d.CreatedAt, _ = parseTime(ca)
+ if q.ProjectID != "" && d.ProjectID != "" && d.ProjectID != q.ProjectID {
+ continue
+ }
+ if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
+ continue
+ }
+ out = append(out, core.RetrievalHit{Doc: d, Score: -score, Kind: "fulltext"})
+ if len(out) >= topK {
+ break
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ // CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。
+ if len(out) == 0 && q.Text != "" {
+ return s.searchKnowledgeLike(q, topK)
+ }
+ return out, nil
+}
+
+func (s *Store) searchKnowledgeLike(q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
+ rows, err := s.db.Query(`SELECT id,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE chunk LIKE ? OR title LIKE ? LIMIT ?`,
+ "%"+q.Text+"%", "%"+q.Text+"%", topK)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.RetrievalHit
+ for rows.Next() {
+ var d core.KnowledgeDoc
+ var ca string
+ if err := rows.Scan(&d.ID, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil {
+ return nil, err
+ }
+ d.CreatedAt, _ = parseTime(ca)
+ if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
+ continue
+ }
+ out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"})
+ }
+ return out, rows.Err()
+}
+
+// ftsQuery 做最小安全化:按空白切词加引号,FTS5 前缀匹配。
+func ftsQuery(text string) string {
+ words := splitWords(text)
+ if len(words) == 0 {
+ return `""`
+ }
+ out := ""
+ for i, w := range words {
+ if i > 0 {
+ out += " "
+ }
+ out += `"` + w + `"*`
+ }
+ return out
+}
+
+func splitWords(s string) []string {
+ var out []string
+ cur := []rune{}
+ for _, r := range s {
+ if r == ' ' || r == '\t' || r == '\n' {
+ if len(cur) > 0 {
+ out = append(out, string(cur))
+ cur = nil
+ }
+ continue
+ }
+ cur = append(cur, r)
+ }
+ if len(cur) > 0 {
+ out = append(out, string(cur))
+ }
+ return out
+}
+
+func containsStr(list []string, s string) bool {
+ for _, x := range list {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/store/messages.go b/internal/store/messages.go
new file mode 100644
index 0000000..e804656
--- /dev/null
+++ b/internal/store/messages.go
@@ -0,0 +1,231 @@
+package store
+
+import (
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "time"
+
+ core "agent-system-core"
+)
+
+// ---- 消息 ----
+
+func (s *Store) AppendMessage(m *core.Message) error {
+ if m.ID == "" {
+ m.ID = newID("msg")
+ }
+ m.CreatedAt = m.CreatedAt.UTC()
+ var next int
+ if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0)+1 FROM messages WHERE conversation_id=?`, m.ConversationID).Scan(&next); err != nil {
+ return err
+ }
+ _, err := s.db.Exec(`INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at) VALUES(?,?,?,?,?,?,?)`,
+ m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), next, nowISO())
+ if err == nil {
+ s.TouchConversation(m.ConversationID)
+ }
+ return err
+}
+
+func (s *Store) ListMessages(conversationID string) ([]core.Message, error) {
+ rows, err := s.db.Query(`SELECT id,conversation_id,run_id,role,parts,created_at FROM messages WHERE conversation_id=? ORDER BY seq`, conversationID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.Message
+ for rows.Next() {
+ var m core.Message
+ var parts, ca string
+ if err := rows.Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca); err != nil {
+ return nil, err
+ }
+ m.Role = core.Role(m.Role)
+ m.Parts, _ = unmarshalJSON[[]core.Part](parts)
+ m.CreatedAt, _ = parseTime(ca)
+ out = append(out, m)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) LastMessages(conversationID string, n int) ([]core.Message, error) {
+ all, err := s.ListMessages(conversationID)
+ if err != nil {
+ return nil, err
+ }
+ if len(all) > n {
+ all = all[len(all)-n:]
+ }
+ return all, nil
+}
+
+// ---- Runs ----
+
+func (s *Store) CreateRun(r *core.Run) error {
+ if r.ID == "" {
+ r.ID = newID("run")
+ }
+ r.CreatedAt = r.CreatedAt.UTC()
+ _, err := s.db.Exec(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,kind,profile_id,status,phase,agent_name,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
+ r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO())
+ return err
+}
+
+func (s *Store) UpdateRun(r *core.Run) error {
+ var ended any
+ if r.EndedAt != nil {
+ ended = nowISO()
+ }
+ _, err := s.db.Exec(`UPDATE runs SET status=?, phase=?, usage=?, error=?, ended_at=? WHERE id=?`,
+ string(r.Status), string(r.Phase), marshalJSON(r.Usage), marshalJSON(r.Error), ended, r.ID)
+ return err
+}
+
+const runColumns = `id,parent_run_id,conversation_id,project_id,kind,profile_id,status,phase,agent_name,created_at,ended_at,usage,error`
+
+func scanRun(scan func(...any) error) (core.Run, error) {
+ var r core.Run
+ var parent, profile, phase, agent, ended, usage, errJSON *string
+ var kind, status string
+ var ca string
+ if err := scan(&r.ID, &parent, &r.ConversationID, &r.ProjectID, &kind, &profile, &status, &phase, &agent, &ca, &ended, &usage, &errJSON); err != nil {
+ return r, err
+ }
+ if parent != nil {
+ r.ParentRunID = *parent
+ }
+ if profile != nil {
+ r.ProfileID = *profile
+ }
+ r.Kind = core.RunKind(kind)
+ r.Status = core.RunStatus(status)
+ if phase != nil {
+ r.Phase = core.RunPhase(*phase)
+ }
+ if agent != nil {
+ r.AgentName = *agent
+ }
+ r.CreatedAt, _ = parseTime(ca)
+ if ended != nil {
+ t, _ := parseTime(*ended)
+ r.EndedAt = &t
+ }
+ if usage != nil && *usage != "" && *usage != "null" {
+ r.Usage, _ = unmarshalJSON[*core.Usage](*usage)
+ }
+ if errJSON != nil && *errJSON != "" && *errJSON != "null" {
+ r.Error, _ = unmarshalJSON[*core.ErrorPart](*errJSON)
+ }
+ return r, nil
+}
+
+func (s *Store) GetRun(id string) (core.Run, error) {
+ r, err := scanRun(func(dst ...any) error {
+ return s.db.QueryRow(`SELECT `+runColumns+` FROM runs WHERE id=?`, id).Scan(dst...)
+ })
+ if err != nil {
+ if err.Error() == "sql: no rows" {
+ return r, ErrNotFound
+ }
+ return r, err
+ }
+ return r, nil
+}
+
+func (s *Store) ListRunsByConversation(conversationID string) ([]core.Run, error) {
+ rows, err := s.db.Query(`SELECT `+runColumns+` FROM runs WHERE conversation_id=? ORDER BY created_at`, conversationID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.Run
+ for rows.Next() {
+ r, err := scanRun(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// ChildRuns 返回直接子 run(任务树)。
+func (s *Store) ChildRuns(runID string) ([]core.Run, error) {
+ rows, err := s.db.Query(`SELECT `+runColumns+` FROM runs WHERE parent_run_id=? ORDER BY created_at`, runID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.Run
+ for rows.Next() {
+ r, err := scanRun(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// ---- 事件(seq 游标权威)----
+
+func (s *Store) AppendEvent(e *core.StreamEvent, payload any) (int64, error) {
+ var payloadStr any
+ if payload != nil {
+ payloadStr = marshalJSON(payload)
+ }
+ res, err := s.db.Exec(`INSERT INTO events(conversation_id,run_id,ts,v,type,payload) VALUES(?,?,?,?,?,?)`,
+ e.ConversationID, e.RunID, e.TS, e.V, string(e.Type), payloadStr)
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+func (s *Store) EventsAfter(conversationID string, since int64, limit int) ([]core.StreamEvent, error) {
+ rows, err := s.db.Query(`SELECT seq,run_id,ts,v,type,payload FROM events WHERE conversation_id=? AND seq>? ORDER BY seq LIMIT ?`,
+ conversationID, since, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.StreamEvent
+ for rows.Next() {
+ var e core.StreamEvent
+ var typ string
+ var payload []byte
+ if err := rows.Scan(&e.Seq, &e.RunID, &e.TS, &e.V, &typ, &payload); err != nil {
+ return nil, err
+ }
+ e.ConversationID = conversationID
+ e.Type = core.StreamEventType(typ)
+ if len(payload) > 0 {
+ e.Payload = payload
+ }
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) LastEventSeq(conversationID string) (int64, error) {
+ var seq sql.NullInt64
+ err := s.db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id=?`, conversationID).Scan(&seq)
+ if err != nil {
+ return 0, err
+ }
+ return seq.Int64, nil
+}
+
+func newID(kind string) string {
+ b := make([]byte, 6)
+ rand.Read(b)
+ return kind + "_" + hex.EncodeToString(b)
+}
+
+func parseTime(s string) (time.Time, error) {
+ return time.Parse(time.RFC3339Nano, s)
+}
+
+// NewID 生成带前缀的随机 ID(导出供 runmgr/server 使用)。
+func NewID(kind string) string { return newID(kind) }
diff --git a/internal/store/projects.go b/internal/store/projects.go
new file mode 100644
index 0000000..3548742
--- /dev/null
+++ b/internal/store/projects.go
@@ -0,0 +1,142 @@
+package store
+
+import (
+ "database/sql"
+ "errors"
+ "time"
+
+ core "agent-system-core"
+)
+
+var ErrNotFound = errors.New("store: not found")
+
+// ---- 项目与会话 ----
+
+func (s *Store) CreateProject(name string) (core.Project, error) {
+ p := core.Project{ID: newID("prj"), Name: name, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Settings: map[string]any{}}
+ _, err := s.db.Exec(`INSERT INTO projects(id,name,settings,created_at,updated_at) VALUES(?,?,?,?,?)`,
+ p.ID, p.Name, "{}", nowISO(), nowISO())
+ return p, err
+}
+
+func (s *Store) ListProjects() ([]core.Project, error) {
+ rows, err := s.db.Query(`SELECT id,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.Project
+ for rows.Next() {
+ var p core.Project
+ var settings string
+ var ca, ua string
+ if err := rows.Scan(&p.ID, &p.Name, &settings, &ca, &ua); err != nil {
+ return nil, err
+ }
+ p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
+ p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
+ if settings != "" && settings != "{}" {
+ p.Settings, _ = unmarshalJSON[map[string]any](settings)
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) GetProject(id string) (core.Project, error) {
+ var p core.Project
+ var settings, ca, ua string
+ err := s.db.QueryRow(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE id=?`, id).
+ Scan(&p.ID, &p.Name, &settings, &ca, &ua)
+ if errors.Is(err, sql.ErrNoRows) {
+ return p, ErrNotFound
+ }
+ if err != nil {
+ return p, err
+ }
+ p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
+ p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
+ if settings != "" && settings != "{}" {
+ p.Settings, _ = unmarshalJSON[map[string]any](settings)
+ }
+ return p, nil
+}
+
+func (s *Store) UpdateProjectSettings(id string, values map[string]any) error {
+ cur, err := s.GetProject(id)
+ if err != nil {
+ return err
+ }
+ merged := core.MergeSettings(cur.Settings, values)
+ _, err = s.db.Exec(`UPDATE projects SET settings=?, updated_at=? WHERE id=?`, marshalJSON(merged), nowISO(), id)
+ return err
+}
+
+func (s *Store) RenameProject(id, name string) error {
+ _, err := s.db.Exec(`UPDATE projects SET name=?, updated_at=? WHERE id=?`, name, nowISO(), id)
+ return err
+}
+
+func (s *Store) DeleteProject(id string) error {
+ _, err := s.db.Exec(`DELETE FROM projects WHERE id=?`, id)
+ return err
+}
+
+func (s *Store) CreateConversation(projectID, title, profileID string) (core.Conversation, error) {
+ c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Title: title, ProfileID: profileID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
+ _, err := s.db.Exec(`INSERT INTO conversations(id,project_id,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
+ c.ID, c.ProjectID, c.Title, c.ProfileID, nowISO(), nowISO())
+ return c, err
+}
+
+func (s *Store) ListConversations(projectID string) ([]core.Conversation, error) {
+ rows, err := s.db.Query(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE project_id=? ORDER BY updated_at DESC`, projectID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []core.Conversation
+ for rows.Next() {
+ var c core.Conversation
+ var ca, ua string
+ if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua); err != nil {
+ return nil, err
+ }
+ c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
+ c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) GetConversation(id string) (core.Conversation, error) {
+ var c core.Conversation
+ var ca, ua string
+ err := s.db.QueryRow(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE id=?`, id).
+ Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua)
+ if errors.Is(err, sql.ErrNoRows) {
+ return c, ErrNotFound
+ }
+ if err != nil {
+ return c, err
+ }
+ c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
+ c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
+ return c, nil
+}
+
+func (s *Store) TouchConversation(id string) { s.db.Exec(`UPDATE conversations SET updated_at=? WHERE id=?`, nowISO(), id) }
+
+func (s *Store) UpdateConversation(id, title, profileID string) error {
+ if title == "" {
+ _, err := s.db.Exec(`UPDATE conversations SET profile_id=?, updated_at=? WHERE id=?`, profileID, nowISO(), id)
+ return err
+ }
+ _, err := s.db.Exec(`UPDATE conversations SET title=?, profile_id=?, updated_at=? WHERE id=?`, title, profileID, nowISO(), id)
+ return err
+}
+
+func (s *Store) DeleteConversation(id string) error {
+ _, err := s.db.Exec(`DELETE FROM conversations WHERE id=?`, id)
+ return err
+}
diff --git a/internal/store/store.go b/internal/store/store.go
new file mode 100644
index 0000000..809c9c7
--- /dev/null
+++ b/internal/store/store.go
@@ -0,0 +1,136 @@
+// Package store 提供后端持久化:默认档为嵌入式 SQLite(单文件,零外部服务)。
+// 存储档接口按设计 4.1/D16 预留 PostgreSQL 档;业务层只依赖本包公开方法。
+package store
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// Store 持有数据库连接与对象目录。所有方法并发安全(SQLite 单写者,靠 busy 重试)。
+type Store struct {
+ db *sql.DB
+ dataDir string // 对象目录:data/artifacts/
+}
+
+// Open 打开(必要时创建)SQLite 存储并执行迁移。
+func Open(path string) (*Store, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return nil, err
+ }
+ dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path)
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, err
+ }
+ // SQLite 单写者:串行化写连接,避免 database is locked。
+ db.SetMaxOpenConns(1)
+ s := &Store{db: db, dataDir: filepath.Join(filepath.Dir(path), "artifacts")}
+ if err := s.migrate(); err != nil {
+ db.Close()
+ return nil, err
+ }
+ if err := os.MkdirAll(s.dataDir, 0o755); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return s, nil
+}
+
+func (s *Store) Close() error { return s.db.Close() }
+
+func (s *Store) migrate() error {
+ stmts := []string{
+ `CREATE TABLE IF NOT EXISTS projects (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL,
+ settings TEXT NOT NULL DEFAULT '{}',
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS conversations (
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
+ FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`,
+ `CREATE TABLE IF NOT EXISTS messages (
+ id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL,
+ run_id TEXT NOT NULL DEFAULT '', role TEXT NOT NULL,
+ parts TEXT NOT NULL, seq INTEGER NOT NULL,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE)`,
+ `CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, seq)`,
+ `CREATE TABLE IF NOT EXISTS runs (
+ id TEXT PRIMARY KEY, parent_run_id TEXT NOT NULL DEFAULT '',
+ conversation_id TEXT NOT NULL, project_id TEXT NOT NULL,
+ kind TEXT NOT NULL, profile_id TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL, phase TEXT NOT NULL DEFAULT '',
+ agent_name TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL, ended_at TEXT,
+ usage TEXT, error TEXT)`,
+ `CREATE INDEX IF NOT EXISTS idx_runs_conv ON runs(conversation_id)`,
+ `CREATE INDEX IF NOT EXISTS idx_runs_parent ON runs(parent_run_id)`,
+ `CREATE TABLE IF NOT EXISTS events (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '',
+ ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL,
+ payload TEXT)`,
+ `CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`,
+ `CREATE TABLE IF NOT EXISTS artifacts (
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL,
+ name TEXT NOT NULL, kind TEXT NOT NULL, mime TEXT NOT NULL DEFAULT '',
+ size INTEGER NOT NULL DEFAULT 0, source_run TEXT NOT NULL DEFAULT '',
+ content_path TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS hitl_cards (
+ id TEXT PRIMARY KEY, run_id TEXT NOT NULL, conversation_id TEXT NOT NULL,
+ payload TEXT NOT NULL, created_at TEXT NOT NULL,
+ resolved INTEGER NOT NULL DEFAULT 0, resolution TEXT)`,
+ `CREATE TABLE IF NOT EXISTS profiles (
+ id TEXT PRIMARY KEY, builtin INTEGER NOT NULL DEFAULT 0, payload TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS settings (
+ scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS plugins (
+ id TEXT PRIMARY KEY, dir TEXT NOT NULL, manifest TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1, loaded_at TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS plans (
+ conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS todos (
+ conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`,
+ `CREATE TABLE IF NOT EXISTS knowledge (
+ id TEXT PRIMARY KEY, project_id TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL, title TEXT NOT NULL, ref TEXT NOT NULL DEFAULT '',
+ chunk TEXT NOT NULL, created_at TEXT NOT NULL)`,
+ `CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(title, chunk, content='knowledge', content_rowid='rowid')`,
+ `CREATE TRIGGER IF NOT EXISTS knowledge_ai AFTER INSERT ON knowledge BEGIN
+ INSERT INTO knowledge_fts(rowid, title, chunk) VALUES (new.rowid, new.title, new.chunk); END`,
+ `CREATE TRIGGER IF NOT EXISTS knowledge_ad AFTER DELETE ON knowledge BEGIN
+ INSERT INTO knowledge_fts(knowledge_fts, rowid, title, chunk) VALUES('delete', old.rowid, old.title, old.chunk); END`,
+ }
+ for _, q := range stmts {
+ if _, err := s.db.Exec(q); err != nil {
+ return fmt.Errorf("migrate: %w", err)
+ }
+ }
+ return nil
+}
+
+// ---- 通用 ----
+
+func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
+
+func marshalJSON(v any) string {
+ b, _ := json.Marshal(v)
+ return string(b)
+}
+
+func unmarshalJSON[T any](raw string) (T, error) {
+ var out T
+ err := json.Unmarshal([]byte(raw), &out)
+ return out, err
+}
+
+var _ = unmarshalJSON[map[string]any]
diff --git a/internal/tools/http.go b/internal/tools/http.go
new file mode 100644
index 0000000..dce8bc9
--- /dev/null
+++ b/internal/tools/http.go
@@ -0,0 +1,176 @@
+package tools
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ core "agent-system-core"
+)
+
+// execHTTPTool 见 HTTPExecutor:声明式 HTTP 工具在注册时绑定 spec 闭包执行。
+func HTTPExecutor(spec core.HTTPToolSpec, settingsLookup func(key string) string) Executor {
+ return func(rc ToolExecCtx, args json.RawMessage) (string, error) {
+ params := map[string]any{}
+ if len(args) > 0 {
+ if err := json.Unmarshal(args, ¶ms); err != nil {
+ return "", fmt.Errorf("http tool: bad args json: %w", err)
+ }
+ }
+ method := strings.ToUpper(spec.Method)
+ if method == "" {
+ method = http.MethodGet
+ }
+ urlStr := expandTemplate(spec.URL, params)
+ var body io.Reader
+ if spec.BodyTemplate != "" && (method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch) {
+ body = bytes.NewReader([]byte(expandTemplate(spec.BodyTemplate, params)))
+ }
+ req, err := http.NewRequest(method, urlStr, body)
+ if err != nil {
+ return "", err
+ }
+ for k, v := range spec.Headers {
+ req.Header.Set(k, expandTemplate(v, params))
+ }
+ if body != nil && req.Header.Get("Content-Type") == "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if spec.AuthRef != "" {
+ token := authValue(spec.AuthRef, settingsLookup)
+ if token != "" {
+ if strings.HasPrefix(token, "Bearer ") || strings.Contains(token, " ") {
+ req.Header.Set("Authorization", token)
+ } else {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ }
+ }
+ timeout := time.Duration(spec.TimeoutMs) * time.Millisecond
+ if timeout <= 0 {
+ timeout = 15 * time.Second
+ }
+ client := &http.Client{Timeout: timeout}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("http tool: %w", err)
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", fmt.Errorf("http tool: read body: %w", err)
+ }
+ if resp.StatusCode >= 400 {
+ return "", fmt.Errorf("http tool: status %d: %s", resp.StatusCode, truncateStr(string(raw), 400))
+ }
+ // JSON 则裁剪路径;否则原文返回。
+ var parsed any
+ if err := json.Unmarshal(raw, &parsed); err == nil {
+ if spec.ResultPath != "" {
+ if v, ok := pickPath(parsed, spec.ResultPath); ok {
+ return prettyJSON(v), nil
+ }
+ return "", fmt.Errorf("http tool: result path %q not found", spec.ResultPath)
+ }
+ return prettyJSON(parsed), nil
+ }
+ return truncateStr(string(raw), 20000), nil
+ }
+}
+
+// expandTemplate 替换 {{param.x.y}};未知变量替换为空串(防注入:无脚本语义)。
+func expandTemplate(tpl string, params map[string]any) string {
+ out := tpl
+ for {
+ start := strings.Index(out, "{{")
+ if start < 0 {
+ break
+ }
+ end := strings.Index(out[start:], "}}")
+ if end < 0 {
+ break
+ }
+ end += start
+ key := strings.TrimSpace(out[start+2 : end])
+ key = strings.TrimPrefix(key, "param.") // {{param.x}} → 顶层参数 x
+ val := ""
+ if v, ok := lookup(params, key); ok {
+ val = fmt.Sprint(v)
+ }
+ out = out[:start] + val + out[end+2:]
+ }
+ return out
+}
+
+func lookup(params map[string]any, dotted string) (any, bool) {
+ parts := strings.Split(dotted, ".")
+ var cur any = params
+ for _, p := range parts {
+ m, ok := cur.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ cur, ok = m[p]
+ if !ok {
+ return nil, false
+ }
+ }
+ return cur, true
+}
+
+func pickPath(v any, dotted string) (any, bool) {
+ parts := strings.Split(dotted, ".")
+ cur := v
+ for _, p := range parts {
+ // 数组支持 "0" 数字索引
+ if arr, ok := cur.([]any); ok {
+ idx := -1
+ fmt.Sscanf(p, "%d", &idx)
+ if idx < 0 || idx >= len(arr) {
+ return nil, false
+ }
+ cur = arr[idx]
+ continue
+ }
+ m, ok := cur.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ cur, ok = m[p]
+ if !ok {
+ return nil, false
+ }
+ }
+ return cur, true
+}
+
+func prettyJSON(v any) string {
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return fmt.Sprint(v)
+ }
+ return string(b)
+}
+
+func truncateStr(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "…(截断)"
+}
+
+// authValue 解析 AuthRef:env:NAME 或 settings 点路径。
+func authValue(ref string, settingsLookup func(key string) string) string {
+ if strings.HasPrefix(ref, "env:") {
+ return os.Getenv(strings.TrimPrefix(ref, "env:"))
+ }
+ if settingsLookup != nil {
+ return settingsLookup(ref)
+ }
+ return ""
+}
diff --git a/internal/tools/http_test.go b/internal/tools/http_test.go
new file mode 100644
index 0000000..e0968ab
--- /dev/null
+++ b/internal/tools/http_test.go
@@ -0,0 +1,68 @@
+package tools
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ core "agent-system-core"
+)
+
+type dummyCtx struct{ project string }
+
+func (dummyCtx) ProjectID() string { return "" }
+func (dummyCtx) ConversationID() string { return "" }
+func (dummyCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil }
+func (dummyCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
+ return core.Artifact{}, nil
+}
+
+// TestHTTPExecutorTemplateAndPath:模板变量注入、结果路径裁剪、错误状态。
+func TestHTTPExecutorTemplateAndPath(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/translate" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"data":[{"translated":"hello","src":"你好"}],"meta":{"n":1}}`))
+ }))
+ defer srv.Close()
+
+ spec := core.HTTPToolSpec{
+ Method: "POST", URL: srv.URL + "/translate",
+ BodyTemplate: `{"q":"{{param.text}}"}`, ResultPath: "data.0.translated",
+ }
+ exec := HTTPExecutor(spec, nil)
+ out, err := exec(dummyCtx{}, []byte(`{"text":"你好"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out != `"hello"` && out != "hello" {
+ t.Errorf("result: %q", out)
+ }
+
+ // ResultPath 不存在 → 显式错误。
+ specBad := spec
+ specBad.ResultPath = "missing.path"
+ if _, err := HTTPExecutor(specBad, nil)(dummyCtx{}, []byte(`{}`)); err == nil {
+ t.Error("missing path should error")
+ }
+
+ // 404 → 错误带状态码。
+ spec404 := spec
+ spec404.URL = srv.URL + "/nope"
+ spec404.ResultPath = ""
+ if _, err := HTTPExecutor(spec404, nil)(dummyCtx{}, nil); err == nil {
+ t.Error("404 should error")
+ }
+}
+
+func TestExpandTemplate(t *testing.T) {
+ got := expandTemplate(`{{param.a}}/{{param.b.c}}/{{param.unknown}}`, map[string]any{
+ "a": "x", "b": map[string]any{"c": "y"},
+ })
+ if got != "x/y/" {
+ t.Errorf("expand: %q", got)
+ }
+}
diff --git a/internal/tools/registry.go b/internal/tools/registry.go
new file mode 100644
index 0000000..e0b6e14
--- /dev/null
+++ b/internal/tools/registry.go
@@ -0,0 +1,168 @@
+// Package tools 实现统一工具注册表(4.8):内置、声明式 HTTP、插件工具同一形态;
+// 来源标记(builtin/plugin/user)、启用开关、确认策略由画像三清单决定(runmgr 执行)。
+package tools
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ core "agent-system-core"
+)
+
+// Executor 是无状态执行器:入参已校验,返回输出文本或错误。
+type Executor func(rc ToolExecCtx, args json.RawMessage) (string, error)
+
+// ToolExecCtx 供工具访问受控环境(当前项目/检索/工件写入)。
+type ToolExecCtx interface {
+ ProjectID() string
+ ConversationID() string
+ Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error)
+ WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error)
+}
+
+type tool struct {
+ desc core.ToolDesc
+ exec Executor
+}
+
+type Registry struct {
+ mu sync.RWMutex
+ tools map[string]tool
+}
+
+func NewRegistry() *Registry { return &Registry{tools: map[string]tool{}} }
+
+// Register 幂等注册(后注册覆盖同名;来源标记随之更新)。
+// 声明式 HTTP 工具经 HTTPExecutor(spec, lookup) 绑定执行器。
+func (r *Registry) Register(desc core.ToolDesc, exec Executor) error {
+ if desc.Name == "" {
+ return fmt.Errorf("tools: empty name")
+ }
+ if exec == nil {
+ return fmt.Errorf("tools: %s has no executor", desc.Name)
+ }
+ r.mu.Lock()
+ r.tools[desc.Name] = tool{desc: desc, exec: exec}
+ r.mu.Unlock()
+ return nil
+}
+
+func (r *Registry) UnregisterBySource(source core.SourceKind, pluginID string) {
+ r.mu.Lock()
+ for name, t := range r.tools {
+ if t.desc.Source == source && (pluginID == "" || t.desc.PluginID == pluginID) {
+ delete(r.tools, name)
+ }
+ }
+ r.mu.Unlock()
+}
+
+func (r *Registry) List() []core.ToolDesc {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ out := make([]core.ToolDesc, 0, len(r.tools))
+ for _, t := range r.tools {
+ out = append(out, t.desc)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+func (r *Registry) Get(name string) (core.ToolDesc, Executor, bool) {
+ r.mu.RLock()
+ t, ok := r.tools[name]
+ r.mu.RUnlock()
+ return t.desc, t.exec, ok
+}
+
+// Execute 校验存在性并调用执行器;策略(allow/deny/confirm)与截断由 runmgr 负责。
+func (r *Registry) Execute(rc ToolExecCtx, call core.ToolCallRequest) core.ToolCallResult {
+ desc, exec, ok := r.Get(call.Name)
+ if !ok {
+ return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q not found (registry miss)", call.Name)}
+ }
+ out, err := exec(rc, call.Arguments)
+ if err != nil {
+ return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
+ }
+ _ = desc
+ return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: out, Truncated: false, Artifact: nil, }
+}
+
+// ---- 内置最小工具集(一切可禁用:画像三清单控制)----
+
+// RegisterBuiltin 注册骨架期内置工具:时间、会话历史检索、知识检索、读文件、写工件。
+func (r *Registry) RegisterBuiltin() error {
+ if err := r.Register(core.ToolDesc{
+ Name: "time_now", Description: "获取当前时间(RFC3339)与时区", Source: core.SourceBuiltin,
+ Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
+ }, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
+ return time.Now().Format(time.RFC3339), nil
+ }); err != nil {
+ return err
+ }
+ if err := r.Register(core.ToolDesc{
+ Name: "knowledge_search", Description: "在项目知识与插件知识库中做全文检索,返回命中片段", Source: core.SourceBuiltin,
+ Parameters: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","description":"检索词"}},"required":["query"]}`),
+ }, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
+ var a struct{ Query string `json:"query"`; TopK int `json:"topK"` }
+ if len(args) > 0 {
+ if err := json.Unmarshal(args, &a); err != nil {
+ return "", fmt.Errorf("knowledge_search: bad args: %w", err)
+ }
+ }
+ if a.Query == "" {
+ return "", fmt.Errorf("knowledge_search: query is required")
+ }
+ hits, err := rc.Retrieve(core.RetrievalQuery{ProjectID: rc.ProjectID(), Text: a.Query, TopK: a.TopK})
+ if err != nil {
+ return "", err
+ }
+ if len(hits) == 0 {
+ return "(无命中)", nil
+ }
+ var sb strings.Builder
+ for i, h := range hits {
+ fmt.Fprintf(&sb, "[%d] %s(来源 %s,得分 %.2f)\n%s\n\n", i+1, h.Doc.Title, h.Doc.Source, h.Score, h.Doc.Chunk)
+ }
+ return sb.String(), nil
+ }); err != nil {
+ return err
+ }
+ if err := r.Register(core.ToolDesc{
+ Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin,
+ Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"}},"required":["name","content"]}`),
+ }, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
+ var a struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ Content string `json:"content"`
+ }
+ if err := json.Unmarshal(args, &a); err != nil {
+ return "", err
+ }
+ if a.Name == "" {
+ return "", fmt.Errorf("write_artifact: name required")
+ }
+ kind := a.Kind
+ if kind == "" {
+ kind = "document"
+ }
+ mime := "text/markdown"
+ if kind == "data" {
+ mime = "application/json"
+ }
+ art, err := rc.WriteArtifact(a.Name, kind, mime, []byte(a.Content))
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("已写入工件 %s(%s,%d 字节)", art.Name, art.ID, art.Size), nil
+ }); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/web/embed.go b/web/embed.go
new file mode 100644
index 0000000..dde8767
--- /dev/null
+++ b/web/embed.go
@@ -0,0 +1,15 @@
+// Package web 内嵌前端构建产物(go:embed)。
+// 构建流程:Frontend 仓 npm run build → 拷贝 dist/* 到本目录 dist/(Dev Makefile)。
+// 空目录时保留 .gitkeep,embed 用 dist/* 通配以允许空构建(仅 API 模式)。
+package web
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed all:dist
+var distFS embed.FS
+
+// Dist 返回前端文件系统(根即 index.html)。
+func Dist() (fs.FS, error) { return fs.Sub(distFS, "dist") }