Merge EXT-1 inproc plugin command handler into main #66

Merged
weichen merged 1 commits from feature/ext-1-inproc-handler into main 2026-10-06 01:25:37 +00:00
11 changed files with 581 additions and 15 deletions
+5 -7
View File
@@ -344,7 +344,9 @@ func Run(opt Options) error {
bus := cmdbus.New(st, cardSvc, func() time.Time { return time.Now().UTC() })
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
applyCompiled(skillsRoot, bus)
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
caps := hostcaps{st: st, pipe: pipe, set: setSvc}
applyCompiled(skillsRoot, bus, caps)
mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
status, aerr := st.AccountTokens(owner, tokens, budget)
@@ -365,14 +367,10 @@ func Run(opt Options) error {
return nil
}
hostSrv.Search = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{Text: text})
return caps.searchDefault(ctx, owner, text)
}
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
route := runtime.ResolveChatModel(core.AgentProfile{}, setSvc.Effective(owner, ""))
if route.Model == "" || route.Base == "" {
return "", fmt.Errorf("llm route not configured")
}
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
return caps.complete(ctx, owner, prompt)
}
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
+34 -2
View File
@@ -1,11 +1,14 @@
package app
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/pkg/extension"
core "agent-system-core"
@@ -16,6 +19,34 @@ type compiledRegistry struct {
bus *cmdbus.Bus
}
// pluginRegistry 携带插件身份与宿主能力,per-InProc 构造:
// AddCommandHandler 绑定的处理器在分发时按请求 Owner 构造 inprocHost(EXT-1)。
type pluginRegistry struct {
compiledRegistry
pluginID string
caps hostcaps
}
func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
if r.bus == nil {
return nil
}
if h == nil {
return fmt.Errorf("extension: %s: nil command handler", r.pluginID)
}
var s agentcommand.CommandSpec
if err := json.Unmarshal(spec, &s); err != nil {
return fmt.Errorf("extension: command spec: %w", err)
}
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
host := inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner}
return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID,
})
})
}
func (r compiledRegistry) AddSkill(name, markdown string) error {
if r.skillsRoot == "" || name == "" {
return nil
@@ -39,12 +70,13 @@ func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error
return nil
}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus) {
reg := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) {
base := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
for _, e := range extension.Compiled() {
if e.Register == nil {
continue
}
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
if err := e.Register(reg); err != nil {
log.Printf("extension: %s: %v", e.ID, err)
}
+116
View File
@@ -0,0 +1,116 @@
package app
import (
"context"
"fmt"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// hostcaps 聚合 owner 作用域的宿主能力(存储、检索管线、LLM 路由)。
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
st *store.Store
pipe *rag.Pipeline
set *settings.Service
}
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
return c.pipe.Retrieve(ctx, core.ID(owner), core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK})
}
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return c.search(ctx, owner, "", text, 0)
}
// complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。
func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) {
route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, ""))
if route.Model == "" || route.Base == "" {
return "", fmt.Errorf("llm route not configured")
}
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
}
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
type inprocHost struct {
caps hostcaps
pluginID string
owner string
}
func (h inprocHost) PluginID() string { return h.pluginID }
func (h inprocHost) Owner() string { return h.owner }
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
}
func (h inprocHost) KVGet(key string) (string, bool, error) {
return h.caps.st.GetPluginKV(h.pluginID, h.owner, key)
}
func (h inprocHost) Audit(action, target, evidence string) error {
return h.caps.st.AppendAuditEvent(store.AuditEvent{
Owner: h.owner, Actor: "plugin:" + h.pluginID,
Action: action, Target: target, Evidence: evidence,
})
}
func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) {
return h.caps.complete(ctx, h.owner, prompt)
}
func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
return h.caps.search(ctx, h.owner, projectID, text, topK)
}
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
//(embedding 失败降级纯全文,不阻断写入)。
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if len(docs) == 0 {
return nil
}
src := "plugin:" + h.pluginID
for i := range docs {
docs[i].Owner = core.ID(h.owner)
if docs[i].Source == "" {
docs[i].Source = src
}
}
if !embed {
return h.caps.st.AddKnowledge(docs...)
}
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
}
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
if err != nil {
return "", err
}
return t.ID, nil
}
func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error {
return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText)
}
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
return h.caps.st.GetArtifact(h.owner, artifactID)
}
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
return h.caps.st.ArtifactContent(h.owner, artifactID)
}
+195
View File
@@ -0,0 +1,195 @@
package app
import (
"context"
"encoding/json"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// newInprocTestApp 组合根测试版:走真实的 applyCompiled → cmdbus.Dispatch →
// inprocHost 链路(卡片端口 nil,仅普通危险级命令;embedder 非 nil 时为 hash 档)。
func newInprocTestApp(t *testing.T, embedder func() string) (*cmdbus.Bus, *store.Store) {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
set := settings.NewService(st)
pipe := rag.NewPipeline(st, set, nil, nil)
if embedder != nil {
pipe.SetEmbedder(rag.SwitchingEmbedder{Provider: embedder})
}
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
caps := hostcaps{st: st, pipe: pipe, set: set}
applyCompiled(filepath.Join(t.TempDir(), "skills"), bus, caps)
return bus, st
}
// TestInprocHostOwnerScope 是 D8/H4 烟测的单元级前置:两个 owner 各分发一次,
// 审计、知识、KV、任务台账必须各落自己 owner 名下(D8 烟测的 PG 版另行)。
func TestInprocHostOwnerScope(t *testing.T) {
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
extension.RegisterCompiled(extension.InProc{
ID: "tst",
Register: func(reg extension.Registry) error {
return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.record","title":"记录"}`),
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
ref, _ := req.Params["ref"].(string)
if err := h.Audit("tst.record", ref, "{}"); err != nil {
return nil, err
}
if err := h.KnowledgeWrite(ctx, []core.KnowledgeDoc{
{Title: "分块", Ref: ref, Chunk: string(h.Owner()) + " 的入库内容"},
}, true); err != nil {
return nil, err
}
if err := h.KVPut("last", string(h.Owner())); err != nil {
return nil, err
}
taskID, err := h.TaskCreate("tst.record", "记录 "+ref, req.ConversationID)
if err != nil {
return nil, err
}
if err := h.TaskUpdate(taskID, "succeeded", 100, ""); err != nil {
return nil, err
}
hits, err := h.KnowledgeSearch(ctx, "", string(h.Owner())+" 的入库内容", 5)
if err != nil {
return nil, err
}
return map[string]any{"owner": h.Owner(), "plugin": h.PluginID(), "hits": len(hits)}, nil
})
},
})
bus, st := newInprocTestApp(t, nil) // embedder nil:embed=true 仍只落全文(IndexChunks 降级路径)
for _, owner := range []string{"alice", "bob"} {
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.record", Params: map[string]any{"ref": owner + "-ref"}, Owner: owner, Actor: "ui", Entrance: "chat",
})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("%s dispatch: %+v", owner, resp.Receipt)
}
m, _ := resp.Payload.(map[string]any)
if m["owner"] != owner || m["plugin"] != "tst" || m["hits"] != 1 {
t.Fatalf("%s payload: %+v", owner, resp.Payload)
}
}
// 审计各归其主,Actor 标注插件身份。
for _, owner := range []string{"alice", "bob"} {
evs, err := st.ListAuditEvents(owner, 10)
if err != nil {
t.Fatal(err)
}
if len(evs) != 1 || evs[0].Owner != owner || evs[0].Actor != "plugin:tst" || evs[0].Action != "tst.record" {
t.Fatalf("%s audit: %+v", owner, evs)
}
}
// 知识各归其主:本人可见,他人检索不可见(visibility 过滤)。
aHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "alice 的入库内容"})
if err != nil {
t.Fatal(err)
}
if len(aHits) != 1 || string(aHits[0].Doc.Owner) != "alice" {
t.Fatalf("alice knowledge: %+v", aHits)
}
cross, err := st.SearchKnowledge("bob", core.RetrievalQuery{Text: "alice 的入库内容"})
if err != nil {
t.Fatal(err)
}
if len(cross) != 0 {
t.Fatalf("owner leak in knowledge: %+v", cross)
}
// KV 按 pluginID+owner 隔离。
for _, owner := range []string{"alice", "bob"} {
v, ok, err := st.GetPluginKV("tst", owner, "last")
if err != nil || !ok || v != owner {
t.Fatalf("%s kv: %q %v %v", owner, v, ok, err)
}
if _, ok, _ := st.GetPluginKV("other-plugin", owner, "last"); ok {
t.Fatalf("%s kv leaked across plugins", owner)
}
}
// 任务台账各归其主且状态已更新。
for _, owner := range []string{"alice", "bob"} {
tasks, err := st.ListBackgroundTasks(owner)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 || tasks[0].Kind != "tst.record" || tasks[0].Status != "succeeded" || tasks[0].Owner != owner {
t.Fatalf("%s tasks: %+v", owner, tasks)
}
}
}
// TestInprocHostKnowledgeWriteEmbedHash:provider=hash 档下 KnowledgeWrite(embed=true)
// 写出向量行,且 Owner/Source 由基座强制改写。
func TestInprocHostKnowledgeWriteEmbedHash(t *testing.T) {
extension.ResetCompiled()
t.Cleanup(extension.ResetCompiled)
extension.RegisterCompiled(extension.InProc{
ID: "tst",
Register: func(reg extension.Registry) error {
return reg.AddCommandHandler(json.RawMessage(`{"id":"tst.embed","title":"向量"}`),
func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
text, _ := req.Params["text"].(string)
// 故意不带 Owner/Source:验证基座强制改写。
return "ok", h.KnowledgeWrite(ctx, []core.KnowledgeDoc{{Title: "向量", Ref: "vec-1", Chunk: text}}, true)
})
},
})
bus, st := newInprocTestApp(t, func() string { return "hash" })
resp := bus.Dispatch(context.Background(), cmdbus.Request{
ID: "tst.embed", Params: map[string]any{"text": "Hash 向量内容"}, Owner: "alice", Entrance: "api",
})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("dispatch: %+v", resp.Receipt)
}
// Owner 强制 alice、Source 强制补 plugin:tst。
hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "向量内容"})
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || string(hits[0].Doc.Owner) != "alice" || hits[0].Doc.Source != "plugin:tst" {
t.Fatalf("doc attribution: %+v", hits)
}
// hash 档向量行存在且可被同文本向量召回。
want, err := rag.HashEmbedder{}.Embed(context.Background(), []string{"Hash 向量内容"})
if err != nil {
t.Fatal(err)
}
vhits, err := st.SearchKnowledgeVectors(context.Background(), "alice", want[0], 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(vhits) != 1 {
t.Fatalf("vector rows: %+v", vhits)
}
// bob 用同一向量检索不可见。
bhits, err := st.SearchKnowledgeVectors(context.Background(), "bob", want[0], 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(bhits) != 0 {
t.Fatalf("vector owner leak: %+v", bhits)
}
}
+12
View File
@@ -140,6 +140,18 @@ func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error {
return nil
}
// RegisterPluginCommand 注册进程内插件命令(EXT-1):登记规格并绑定宿主处理器。
// 仅登记规格的声明式路径仍走 RegisterPluginCommands(无 handler,分发即 rejected)。
func (b *Bus) RegisterPluginCommand(spec agentcommand.CommandSpec, h Handler) error {
if err := b.reg.Register(spec, agentcommand.SourcePlugin); err != nil {
return fmt.Errorf("cmdbus: register %s: %w", spec.ID, err)
}
if h != nil {
b.handlers[spec.ID] = h
}
return nil
}
// ResolveSlash `/` 入口解析:返回命令 id 与原始字段(参数由宿主处理器按命令语义解析)。
func (b *Bus) ResolveSlash(cmd string) (string, bool) {
id, ok := b.slash[strings.ToLower(strings.TrimSpace(cmd))]
+69
View File
@@ -211,3 +211,72 @@ func TestValidateParamsScalars(t *testing.T) {
t.Fatalf("nil schema must pass: %v", err)
}
}
// ---- EXT-1:进程内插件命令处理器 ----
func TestRegisterPluginCommandDispatch(t *testing.T) {
a := &memAudit{}
b := newTestBus(a, &memCards{})
err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "入库"}, func(ctx context.Context, req Request) (any, error) {
return map[string]any{"echo": req.Params["ref"], "owner": req.Owner}, nil
})
if err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "rdm.ingest", Params: map[string]any{"ref": "doc-1"}, Owner: "alice"})
if resp.Receipt.State != agentcommand.StateCommitted {
t.Fatalf("dispatch: %+v", resp.Receipt)
}
m, _ := resp.Payload.(map[string]any)
if m["echo"] != "doc-1" || m["owner"] != "alice" {
t.Fatalf("payload: %+v", resp.Payload)
}
if len(a.logs) != 1 || a.logs[0].Source != agentcommand.SourcePlugin {
t.Fatalf("audit: %+v", a.logs)
}
// 重复注册同一 ID:fail-closed。
if err := b.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.ingest", Title: "重复"}, nil); err == nil {
t.Fatal("duplicate plugin command must be rejected")
}
}
func TestPluginCommandWithoutHandlerRejected(t *testing.T) {
b := newTestBus(&memAudit{}, &memCards{})
// 声明式路径只登记规格(manifest provides.commands)。
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"plug.only","title":"仅规格"}`)}); err != nil {
t.Fatal(err)
}
resp := b.Dispatch(context.Background(), Request{ID: "plug.only"})
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "no host handler bound") {
t.Fatalf("unbound: %+v", resp.Receipt)
}
}
func TestHighDangerPluginCommandConfirmsFirst(t *testing.T) {
a := &memAudit{}
cards := &memCards{}
b := newTestBus(a, cards)
executed := 0
err := b.RegisterPluginCommand(agentcommand.CommandSpec{
ID: "rdm.purge", Title: "清库", Danger: agentcommand.DangerHigh,
}, func(ctx context.Context, req Request) (any, error) {
executed++
return "purged", nil
})
if err != nil {
t.Fatal(err)
}
// 未确认:先出内联确认卡,处理器不得执行。
resp := b.Dispatch(context.Background(), Request{ID: "rdm.purge", ConversationID: "cnv_9", Owner: "alice"})
if resp.Receipt.State != agentcommand.StateAccepted || resp.Card == nil || resp.Card.Kind != core.CardKindConfirm {
t.Fatalf("pre-confirm: %+v card=%+v", resp.Receipt, resp.Card)
}
if executed != 0 {
t.Fatal("handler must not run before confirm")
}
// 确认后执行 committed。
resp2 := b.Confirm("alice", "card_x1", "confirm")
if resp2.Receipt.State != agentcommand.StateCommitted || resp2.Payload != "purged" || executed != 1 {
t.Fatalf("confirm: %+v executed=%d", resp2.Receipt, executed)
}
}
+24 -6
View File
@@ -80,32 +80,50 @@ func (p *Pipeline) IndexDocument(owner, projectID core.ID, source, title, ref, p
for i, c := range chunks {
docs[i] = core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: source, Title: title, Ref: ref, Chunk: c.Text}
}
return p.IndexChunks(context.Background(), owner, docs)
}
// IndexChunks 写入已构造好的知识分块并建向量(EXT-1 KnowledgeWrite embed=true 的路径)。
// AddKnowledge 原地回填 docs[i].ID;embedding 失败降级为纯全文(记日志,不阻断写入)。
func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc) error {
if len(docs) == 0 {
return nil
}
if err := p.st.AddKnowledge(docs...); err != nil {
return err
}
if p.embedder == nil {
return nil
}
texts := make([]string, len(chunks))
for i, c := range chunks {
texts[i] = c.Text
texts := make([]string, len(docs))
for i, d := range docs {
texts[i] = d.Chunk
}
vecs, err := p.embedder.Embed(context.Background(), texts)
vecs, err := p.embedder.Embed(ctx, texts)
if err != nil {
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", ref, err)
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err)
return nil
}
for i, v := range vecs {
if len(v) == 0 {
continue
}
if err := p.st.UpsertKnowledgeVector(context.Background(), owner, docs[i].ID, ref, v); err != nil {
if err := p.st.UpsertKnowledgeVector(ctx, owner, docs[i].ID, docs[i].Ref, v); err != nil {
return err
}
}
return nil
}
func firstRef(docs []core.KnowledgeDoc) string {
for _, d := range docs {
if d.Ref != "" {
return d.Ref
}
}
return ""
}
// Retrieve 统一检索(core.Retriever)。
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
topK := q.TopK
+65
View File
@@ -228,3 +228,68 @@ func TestVectorDimMismatchSkipped(t *testing.T) {
t.Fatalf("dim mismatch must skip, got %+v", hits)
}
}
// ---- EXT-1:IndexChunks(KnowledgeWrite embed=true 的底层路径) ----
func TestIndexChunksWritesFulltextAndVectors(t *testing.T) {
p, st := newTestPipeline(t, fakeEmbedder{}, nil)
docs := []core.KnowledgeDoc{
{Owner: "alice", Source: "plugin:rdm", Title: "规格", Ref: "spec-1", Chunk: "Alpha 内容甲"},
{Owner: "alice", Source: "plugin:rdm", Title: "规格", Ref: "spec-1", Chunk: "beta 内容乙"},
}
if err := p.IndexChunks(context.Background(), "alice", docs); err != nil {
t.Fatal(err)
}
// ID 已被 AddKnowledge 回填,向量按回填 ID 关联。
for _, d := range docs {
if d.ID == "" {
t.Fatal("doc ID not backfilled")
}
}
// 全文行落库。
hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "内容甲"})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("fulltext row missing")
}
// 向量行落库(fakeEmbedder:非 A 前缀 → [0,1])。
vhits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{0, 1}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(vhits) == 0 {
t.Fatal("vector rows missing")
}
// owner 作用域:bob 检索不到 alice 的向量。
bhits, err := st.SearchKnowledgeVectors(context.Background(), "bob", []float32{0, 1}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(bhits) != 0 {
t.Fatalf("owner leak: %+v", bhits)
}
}
func TestIndexChunksEmbedFailureDegrades(t *testing.T) {
p, st := newTestPipeline(t, fakeEmbedder{fail: true}, nil)
docs := []core.KnowledgeDoc{{Owner: "alice", Source: "plugin:rdm", Ref: "spec-2", Chunk: "降级内容"}}
if err := p.IndexChunks(context.Background(), "alice", docs); err != nil {
t.Fatalf("embed failure must degrade to fulltext, got %v", err)
}
hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "降级"})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("fulltext row missing after degrade")
}
vhits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{0, 1}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(vhits) != 0 {
t.Fatalf("no vectors expected after degrade, got %+v", vhits)
}
}
+3
View File
@@ -18,6 +18,9 @@ var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-
type Registry interface {
AddSkill(name, markdown string) error
AddCommand(spec json.RawMessage) error
// AddCommandHandler 注册插件命令规格并绑定进程内处理器(EXT-1):
// 基座在分发时按请求 Owner 构造 Host 句柄调用 h。
AddCommandHandler(spec json.RawMessage, h Handler) error
Note(kind, name string, payload json.RawMessage) error
}
+55
View File
@@ -0,0 +1,55 @@
package extension
import (
"context"
core "agent-system-core"
)
// Request 是进程内命令处理器收到的一次分发请求(cmdbus.Request 的子集)。
type Request struct {
ID string
Params map[string]any
Entrance string // button | card | slash | chat | api(归一)
Actor string
Owner string
ConversationID string
}
// Handler 是下游注册的进程内命令处理器:每次分发由基座构造 owner 作用域的
// Host 句柄传入,处理器经句柄访问基座能力,不得自行越权持有 owner 凭证。
type Handler func(ctx context.Context, h Host, req Request) (any, error)
// Host 是基座在分发时构造的 owner 作用域宿主句柄(EXT-1 最小集)。
// 所有能力的可见范围都以本次分发的 Owner 为准;artifacts 只读
//(写入属完整集,另行评估)。实现由基座 internal 提供。
type Host interface {
// PluginID 返回注册处理器的插件标识(形如 rdm)。
PluginID() string
// Owner 返回本次分发的作用域 owner。
Owner() string
// KVPut/KVGet 插件 KV(按 pluginID+owner 隔离)。
KVPut(key, value string) error
KVGet(key string) (value string, ok bool, err error)
// Audit 写一条 owner 名下的审计事件。
Audit(action, target, evidence string) error
// Complete 经基座 LLM 路由补全(owner 的模型配置生效)。
Complete(ctx context.Context, prompt string) (string, error)
// KnowledgeSearch 混合检索(owner 可见范围)。
KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error)
// KnowledgeWrite 写知识分块;embed=true 时同步建向量(失败降级纯全文)。
// docs 各项的 Owner 由基座强制改写为本次 owner;Source 为空补 "plugin:<PluginID>"。
KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error
// TaskCreate/TaskUpdate 后台任务台账(进度/状态记账,执行体由下游自跑)。
TaskCreate(kind, title, conversationID string) (taskID string, err error)
TaskUpdate(taskID, status string, progress int, errText string) error
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件)。
ArtifactGet(artifactID string) (core.Artifact, error)
ArtifactContent(artifactID string) ([]byte, error)
}
+3
View File
@@ -1,5 +1,7 @@
Compiled
ErrNotLinked
Handler
Host
HostClient
InProc
Main
@@ -7,6 +9,7 @@ NewHostClient
Options
RegisterCompiled
Registry
Request
ResetCompiled
SetRunner
SurfaceVersion