diff --git a/adapters/native/backend.go b/adapters/native/backend.go index 1a2122f..0bbdf02 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -212,8 +212,9 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, } writable := writableFrom(ctx) - // F02: a non-empty Writable cannot be enforced for exec/MCP/host tools — - // reject in preflight rather than pretend the write-set holds. + // F02: a non-empty Writable cannot be enforced for exec/MCP/host tools or + // subagent delegation — reject in preflight rather than pretend the + // write-set holds. if len(writable) > 0 && profile == exec.ToolProfileWorkspace { if b.opts.Exec != nil { return preflightFail("writable write-set cannot be enforced when exec tools are enabled"), nil @@ -224,6 +225,9 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, if len(b.opts.HostTools) > 0 { return preflightFail("writable write-set cannot be enforced when host tools are enabled"), nil } + if b.opts.Subagent != nil { + return preflightFail("writable write-set cannot be enforced when subagent delegation is enabled"), nil + } } var reg *tools.Registry if profile == exec.ToolProfileWorkspace { diff --git a/adapters/native/subagent_writable_test.go b/adapters/native/subagent_writable_test.go new file mode 100644 index 0000000..9df9ef3 --- /dev/null +++ b/adapters/native/subagent_writable_test.go @@ -0,0 +1,112 @@ +package native + +import ( + "context" + "os" + "path/filepath" + "testing" + + "agent-native/exec" + "agent-native/runtime/llm" + "agent-native/runtime/subagent" +) + +func TestContractWritableRejectsSubagentInPreflight(t *testing.T) { + dir := t.TempDir() + streamCalls := 0 + inner := script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}) + factoryCalls := 0 + b := New(Options{ + ModelOptions: ModelOptions{ + Model: "fake", + Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + streamCalls++ + return inner(ctx, model, messages, decls, opts) + }, + }, + ToolOptions: ToolOptions{ + Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) { + factoryCalls++ + return subagent.Result{Success: true}, nil + }, + }, + }).Contract() + res, err := b.Execute(context.Background(), exec.ContractRequest{ + ExecutionID: "e-sub", + Prompt: "x", + Workspace: exec.WorkspaceHandle{ + Root: dir, + Writable: []string{"a.md"}, + }, + ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace}, + Targets: []string{"a.md"}, + }) + if err != nil { + t.Fatal(err) + } + if res.Outcome == exec.OutcomeSucceeded { + t.Fatal("Writable + subagent delegation enabled must be rejected in preflight") + } + const want = "writable write-set cannot be enforced when subagent delegation is enabled" + if res.Err == nil || res.Err.Reason != want { + t.Fatalf("want reason %q, got %+v", want, res.Err) + } + if streamCalls != 0 || factoryCalls != 0 { + t.Fatalf("preflight must stop before model/factory: stream=%d factory=%d", streamCalls, factoryCalls) + } + if _, err := os.Stat(filepath.Join(dir, "a.md")); !os.IsNotExist(err) { + t.Fatal("deliverable a.md must not be created when preflight rejects") + } + if _, err := os.Stat(filepath.Join(dir, ".agentx")); !os.IsNotExist(err) { + t.Fatal("no session state must be written when preflight rejects") + } +} + +func TestContractSubagentWithoutWritableStillDelegates(t *testing.T) { + dir := t.TempDir() + var delegated *subagent.Request + stream, c := captureStream( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "delegate_task", + Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c2", Name: "write_file", + Arguments: `{"path":"output.md","content":"done"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}, + ) + b := New(Options{ + ModelOptions: ModelOptions{Model: "fake", Stream: stream}, + ToolOptions: ToolOptions{ + Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) { + delegated = &req + return subagent.Result{Success: true, Text: "结论已查明"}, nil + }, + }, + }).Contract() + res, err := b.Execute(context.Background(), exec.ContractRequest{ + ExecutionID: "e-sub-ok", + Prompt: "分工", + Workspace: exec.WorkspaceHandle{Root: dir}, + ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace}, + Targets: []string{"output.md"}, + }) + if err != nil || res.Outcome != exec.OutcomeSucceeded { + t.Fatalf("execute: err=%v res=%+v", err, res) + } + if delegated == nil || delegated.Goal != "查证数据" { + t.Fatalf("factory not invoked with fields: %+v", delegated) + } + sawDelegate := false + for _, names := range c.toolDecls { + for _, n := range names { + if n == "delegate_task" { + sawDelegate = true + } + } + } + if !sawDelegate { + t.Fatal("delegate_task must be declared when Subagent factory is set and Writable is empty") + } +}