Merge pull request 'Validate plugin agent declarations and wire the squad ask port (N1 WP1/WP4)' (#71) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 39s
ci / vet-and-test (push) Successful in 39s
This commit was merged in pull request #71.
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-native updated: c2befaf17e...c5b9dca446
Vendored
+1
-1
Submodule deps/agent-squad updated: a75d359ade...fc9620e05f
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user