diff --git a/deps/agent-native b/deps/agent-native index c2befaf..c5b9dca 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit c2befaf17ebd0d22486d00c8248658171264e41d +Subproject commit c5b9dca44622eb10b11f89929e107c3aacaacbfd diff --git a/deps/agent-squad b/deps/agent-squad index a75d359..fc9620e 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit a75d359ade28ab0b20108b4a1b09934329cb94cd +Subproject commit fc9620e05f9f982b45fc596c1b46bdebc4d699e0 diff --git a/internal/app/app.go b/internal/app/app.go index 246aa47..b983b4f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/plugins/agents.go b/internal/plugins/agents.go new file mode 100644 index 0000000..d86bab8 --- /dev/null +++ b/internal/plugins/agents.go @@ -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 +} diff --git a/internal/plugins/agents_test.go b/internal/plugins/agents_test.go new file mode 100644 index 0000000..d7818b1 --- /dev/null +++ b/internal/plugins/agents_test.go @@ -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") + } +} diff --git a/internal/plugins/host_v2.go b/internal/plugins/host_v2.go index 6b4fc57..6b5c02e 100644 --- a/internal/plugins/host_v2.go +++ b/internal/plugins/host_v2.go @@ -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) diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index c219a5a..a3503bc 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -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) diff --git a/internal/runtime/permission_ask_test.go b/internal/runtime/permission_ask_test.go index 021f940..0a46a9a 100644 --- a/internal/runtime/permission_ask_test.go +++ b/internal/runtime/permission_ask_test.go @@ -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") } diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go index 2cc3a29..9a7257d 100644 --- a/internal/runtime/runner.go +++ b/internal/runtime/runner.go @@ -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 }) diff --git a/internal/runtime/sub_loop.go b/internal/runtime/sub_loop.go index dde1099..7098b82 100644 --- a/internal/runtime/sub_loop.go +++ b/internal/runtime/sub_loop.go @@ -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 {