Validate plugin agent declarations and wire the squad ask port (N1 WP1/WP4) #71

Merged
weichen merged 1 commits from feature/agent-stack-n1 into main 2026-10-07 05:15:37 +00:00
10 changed files with 134 additions and 12 deletions
+3
View File
@@ -255,6 +255,9 @@ func Run(opt Options) error {
}
}
cfg.Trace = traceFn
// N1 WP4:小队成员的内核 ask 决策经 GateOptions.PermissionAsk 到达
// runCtx 的 HITL 确认卡(宿主无实现则 nil,内核保持 ask=拒绝旧行为)。
cfg.PermissionAsk = runtime.PermissionAskOf(rc)
if host, ok := rc.(interface {
OpenCheckpoints() (*checkpoint.Log, error)
CheckpointAnchor() string
+41
View File
@@ -0,0 +1,41 @@
package plugins
import (
"bytes"
"encoding/json"
"fmt"
core "agent-system-core"
)
// agentBannedKeys 是插件代理声明禁带的键:钩子、MCP 与权限模式归宿主与
// 基座,插件画像只能声明身份与工具面(N1 WP1 验收条款)。
var agentBannedKeys = map[string]bool{
"hooks": true,
"mcp": true,
"permissionMode": true,
}
// parseAgentAsset 解析 agents/ 资产:禁带键定位到字段、未知键拒绝、
// id 必填。任一违规由调用方整批拒绝该插件(fail-closed)。
func parseAgentAsset(raw []byte) (core.PluginAgentDecl, error) {
var probe map[string]json.RawMessage
if err := json.Unmarshal(raw, &probe); err != nil {
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
}
for key := range probe {
if agentBannedKeys[key] {
return core.PluginAgentDecl{}, fmt.Errorf("插件代理不得声明 %s", key)
}
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var item core.PluginAgentDecl
if err := dec.Decode(&item); err != nil {
return core.PluginAgentDecl{}, fmt.Errorf("agent json: %w", err)
}
if item.ID == "" {
return core.PluginAgentDecl{}, fmt.Errorf("agent missing id")
}
return item, nil
}
+72
View File
@@ -0,0 +1,72 @@
package plugins
import (
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
// writeAgentPlugin 写一个带 agents/ 资产的插件目录。
func writeAgentPlugin(t *testing.T, dir, id, agentJSON string) {
t.Helper()
root := filepath.Join(dir, id)
os.MkdirAll(filepath.Join(root, "agents"), 0o755)
write := func(rel, content string) {
if err := os.WriteFile(filepath.Join(root, rel), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("plugin.json", `{"id":"`+id+`","name":"代理插件","version":"0.1.0","apiVersion":"`+core.PluginAPIVersion+`",
"provides":{"agents":["reviewer"]}}`)
write("agents/reviewer.json", agentJSON)
}
func TestAgentAssetLoads(t *testing.T) {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "agentplug", `{"id":"rdm-reviewer","title":"章节评审","instructions":"逐条核对来源","tools":["read_file","grep"]}`)
if err := h.LoadDir()["agentplug"]; err != nil {
t.Fatal(err)
}
for _, ld := range h.List() {
if ld.Manifest.ID != "agentplug" {
continue
}
if len(ld.Agents) != 1 || ld.Agents[0].ID != "rdm-reviewer" || len(ld.Agents[0].Tools) != 2 {
t.Fatalf("agents = %+v", ld.Agents)
}
return
}
t.Fatal("plugin not loaded")
}
// TestAgentAssetBansHostOnlyKeys:插件代理声明 hooks/mcp/permissionMode 时
// 整批拒绝且错误定位到字段(N1 WP1 验收条款)。
func TestAgentAssetBansHostOnlyKeys(t *testing.T) {
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "badplug", `{"id":"x","`+key+`":{}}`)
err := h.LoadDir()["badplug"]
if err == nil {
t.Fatalf("%s accepted", key)
}
if !strings.Contains(err.Error(), "不得声明 "+key) {
t.Fatalf("%s error 未定位到字段: %v", key, err)
}
}
}
func TestAgentAssetRejectsUnknownKeysAndMissingID(t *testing.T) {
h, _, dir := newHost(t)
writeAgentPlugin(t, dir, "unknownkey", `{"id":"x","bogus":1}`)
if err := h.LoadDir()["unknownkey"]; err == nil {
t.Fatal("unknown key accepted")
}
h2, _, dir2 := newHost(t)
writeAgentPlugin(t, dir2, "noid", `{"title":"无 id"}`)
if err := h2.LoadDir()["noid"]; err == nil {
t.Fatal("missing id accepted")
}
}
+6 -2
View File
@@ -98,8 +98,12 @@ func (h *Host) loadDeclared(dir string, m core.PluginManifest, ld *Loaded) error
ld.Parsers = append(ld.Parsers, item)
}
for _, name := range m.Provides.Agents {
var item core.PluginAgentDecl
if err := readAsset(dir, "agents", name, &item); err != nil {
raw, err := readPluginFile(dir, filepath.Join("agents", name+".json"))
if err != nil {
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
}
item, err := parseAgentAsset(raw)
if err != nil {
return fmt.Errorf("plugins: %s: agent %s: %w", m.ID, name, err)
}
ld.Agents = append(ld.Agents, item)
+1 -1
View File
@@ -186,7 +186,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin
OnEvent: st.onEvent,
Hooks: bus,
Reminders: st.reminders,
PermissionAsk: permissionAskOf(rc),
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
UnknownTool: func(call agentllm.ToolCall) agenttools.Result {
return st.runTool(call.Name, call.Arguments)
+3 -3
View File
@@ -8,18 +8,18 @@ import (
agentllm "agent-native/runtime/llm"
)
// permissionAskOf 只在宿主实现内核级询问时接线;否则返回 nil,
// PermissionAskOf 只在宿主实现内核级询问时接线;否则返回 nil,
// 内核保持「ask 按拒绝处理」的旧行为。
func TestPermissionAskOfOptional(t *testing.T) {
type plain struct{ RunCtx }
if got := permissionAskOf(plain{}); got != nil {
if got := PermissionAskOf(plain{}); got != nil {
t.Fatal("plain rc returned a non-nil ask port, want nil")
}
}
func TestPermissionAskOfWired(t *testing.T) {
type asker struct{ RunCtx }
wired := permissionAskOf(askStub{})
wired := PermissionAskOf(askStub{})
if wired == nil {
t.Fatal("wired rc returned nil")
}
+5 -3
View File
@@ -60,10 +60,12 @@ type Runner interface {
Execute(rc RunCtx) error
}
// permissionAskOf 把宿主的内核级权限询问(runmgr 的 HITL 确认卡)接成
// PermissionAskOf 把宿主的内核级权限询问(runmgr 的 HITL 确认卡)接成
// nruntime.Config.PermissionAsk 端口;宿主未实现时返回 nil,内核保持
// 「ask 按拒绝处理」的旧行为(N1 WP2 装配)。
func permissionAskOf(rc RunCtx) func(context.Context, agentllm.ToolCall) nruntime.AskVerdict {
// 「ask 按拒绝处理」的旧行为(N1 WP2 装配;直连/子代理/小队共用)。
// 入参为 any:直连/子代理传 RunCtx,小队装配传 squadadapter.Ctx——
// 判定只依赖可选的 PermissionAsk 方法,不要求实现整个 RunCtx。
func PermissionAskOf(rc any) func(context.Context, agentllm.ToolCall) nruntime.AskVerdict {
asker, ok := rc.(interface {
PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict
})
+1 -1
View File
@@ -108,7 +108,7 @@ func (l *SubLLMRunner) Execute(rc RunCtx) error {
OutputReserve: reserve,
ContextBuffer: buffer,
OnEvent: st.onEvent,
PermissionAsk: permissionAskOf(rc),
PermissionAsk: PermissionAskOf(rc),
Trace: l.Trace,
}, user)
if len(st.parts) == 0 {