diff --git a/adapters/native/backend.go b/adapters/native/backend.go index c26d69e..7a7fcd6 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -19,6 +19,7 @@ import ( "agent-native/runtime/promptx" "agent-native/runtime/session" "agent-native/runtime/skills" + "agent-native/runtime/subagent" "agent-native/runtime/tools" "logging-system/logsys" @@ -47,6 +48,8 @@ type Options struct { SkillsRoots []string // Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。 Compact *CompactConfig + // Subagent 可选:提供时注册 delegate_task(委派由宿主实现,内核只开端口)。 + Subagent subagent.Factory // Log 可选。nil 时丢弃日志。 Log *logsys.Logger // ExecutionID 写入日志,不参与执行控制。 @@ -164,6 +167,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, return &exec.Result{Success: false, ErrorReason: err.Error()}, nil } } + subagent.Register(reg, b.opts.Subagent) if err := reg.Restrict(req.ToolAllowlist); err != nil { return &exec.Result{Success: false, ErrorReason: err.Error()}, nil } diff --git a/adapters/native/exec_tools_test.go b/adapters/native/exec_tools_test.go index 5096e75..865b634 100644 --- a/adapters/native/exec_tools_test.go +++ b/adapters/native/exec_tools_test.go @@ -11,6 +11,7 @@ import ( "agent-native/exec" "agent-native/runtime/llm" "agent-native/runtime/mcp" + "agent-native/runtime/subagent" "agent-native/runtime/tools" ) @@ -295,3 +296,47 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) { t.Fatalf("workspace skills root must be merged into index:\n%s", c.system) } } + +func TestBackendSubagentWiredViaOptions(t *testing.T) { + 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{ + Model: "fake", + Stream: stream, + Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) { + delegated = &req + return subagent.Result{Success: true, Text: "结论已查明"}, nil + }, + }) + dir := t.TempDir() + res, err := b.Execute(context.Background(), exec.Request{ + WorkDir: dir, Prompt: "分工", Targets: []string{"output.md"}, + }) + if err != nil || !res.Success { + t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs) + } + 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") + } +} diff --git a/runtime/subagent/subagent.go b/runtime/subagent/subagent.go new file mode 100644 index 0000000..f7c2199 --- /dev/null +++ b/runtime/subagent/subagent.go @@ -0,0 +1,103 @@ +// Package subagent 定义委派端口:内核不内置嵌套执行(那是编排层职责), +// 只提供工具面与信息集契约;宿主(产品 SpawnSubagent、小队引擎等) +// 注入 Factory 实现真实派生执行。未注入则 delegate_task 不注册(fail-closed)。 +// +// 信息集对齐 agent-execution 标准的委派条款:目标、输入、写范围、非目标、 +// 完成判据、预算与回传格式——子任务不能从父任务目标推导额外权限。 +package subagent + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "agent-native/runtime/tools" +) + +// Request 是一次委派的完整信息集(模型经工具参数逐项填写)。 +type Request struct { + Goal string `json:"goal"` + Inputs string `json:"inputs,omitempty"` + WriteScope string `json:"write_scope,omitempty"` + NonGoals string `json:"non_goals,omitempty"` + DoneCriteria string `json:"done_criteria,omitempty"` + Deliverables []string `json:"deliverables,omitempty"` + // Deadline / MaxToolCalls 是宿主注入的预算余量(模型不可自行扩权)。 + Deadline time.Time `json:"-"` + MaxToolCalls int `json:"-"` +} + +// Result 是子代理的回传:至少包含结论文本;文件由宿主按写范围物化。 +type Result struct { + Success bool + Text string + Files map[string][]byte + Err string +} + +// Factory 由宿主注入;ctx 取消必须传播到子执行。 +type Factory func(ctx context.Context, req Request) (Result, error) + +type delegateTool struct{ factory Factory } + +// Register 把 delegate_task 工具注册进注册表(factory 为 nil 时不注册)。 +func Register(r *tools.Registry, factory Factory) { + if factory == nil { + return + } + r.Register(&delegateTool{factory: factory}) +} + +func (t *delegateTool) Name() string { return "delegate_task" } +func (t *delegateTool) Description() string { + return "Delegate a self-contained subtask to a fresh sub-agent. Fill every field: goal, inputs (with versions), write_scope (directory the sub-agent may write), non_goals, done_criteria, deliverables. The sub-agent cannot exceed your permissions; report back its conclusion and files." +} +func (t *delegateTool) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","required":["goal","done_criteria"],"properties":{ + "goal":{"type":"string"}, + "inputs":{"type":"string"}, + "write_scope":{"type":"string"}, + "non_goals":{"type":"string"}, + "done_criteria":{"type":"string"}, + "deliverables":{"type":"array","items":{"type":"string"}} + }}`) +} + +func (t *delegateTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) { + var req Request + if err := json.Unmarshal(args, &req); err != nil { + return tools.Result{}, err + } + if strings.TrimSpace(req.Goal) == "" { + return tools.Result{Content: "goal is empty", IsError: true}, nil + } + if ctx.Err() != nil { + return tools.Result{Content: "delegation canceled before start: " + ctx.Err().Error(), IsError: true}, nil + } + res, err := t.factory(ctx, req) + if err != nil { + return tools.Result{Content: fmt.Sprintf("sub-agent failed: %v", err), IsError: true}, nil + } + var b strings.Builder + if res.Success { + b.WriteString("[sub-agent succeeded]\n") + } else { + b.WriteString("[sub-agent failed]\n") + } + if res.Err != "" { + b.WriteString("error: " + res.Err + "\n") + } + b.WriteString(res.Text) + if len(res.Files) > 0 { + names := make([]string, 0, len(res.Files)) + for name := range res.Files { + names = append(names, name) + } + sort.Strings(names) + b.WriteString("\nfiles: " + strings.Join(names, ", ")) + } + return tools.Result{Content: b.String(), IsError: !res.Success}, nil +} diff --git a/runtime/subagent/subagent_test.go b/runtime/subagent/subagent_test.go new file mode 100644 index 0000000..1c58bfb --- /dev/null +++ b/runtime/subagent/subagent_test.go @@ -0,0 +1,86 @@ +package subagent + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "agent-native/runtime/tools" +) + +func TestRegisterNilFactoryNoop(t *testing.T) { + reg := tools.NewRegistry(t.TempDir()) + Register(reg, nil) + for _, d := range reg.Decls() { + if d.Name == "delegate_task" { + t.Fatal("nil factory must not register delegate_task") + } + } +} + +func TestDelegateToolRoundTrip(t *testing.T) { + var got Request + reg := tools.NewRegistry(t.TempDir()) + Register(reg, func(ctx context.Context, req Request) (Result, error) { + got = req + return Result{ + Success: true, + Text: "调研完成:结论 A。", + Files: map[string][]byte{"notes.md": []byte("n"), "data/table.csv": []byte("c")}, + }, nil + }) + res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{ + "goal":"调研主题 X", + "inputs":"docs/v2 规范", + "write_scope":"research/", + "non_goals":"不做实现", + "done_criteria":"产出三来源对照表", + "deliverables":["notes.md","data/table.csv"] + }`)) + if res.IsError { + t.Fatal(res.Content) + } + if got.Goal != "调研主题 X" || got.WriteScope != "research/" || got.DoneCriteria != "产出三来源对照表" { + t.Fatalf("delegation fields lost: %+v", got) + } + if !strings.Contains(res.Content, "[sub-agent succeeded]") || + !strings.Contains(res.Content, "调研完成") || + !strings.Contains(res.Content, "files: data/table.csv, notes.md") { + t.Fatalf("result text wrong:\n%s", res.Content) + } +} + +func TestDelegateToolFailureAndValidation(t *testing.T) { + reg := tools.NewRegistry(t.TempDir()) + Register(reg, func(ctx context.Context, req Request) (Result, error) { + return Result{Success: false, Err: "子任务超出预算"}, nil + }) + res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"g","done_criteria":"d"}`)) + if !res.IsError || !strings.Contains(res.Content, "[sub-agent failed]") || !strings.Contains(res.Content, "子任务超出预算") { + t.Fatalf("failure must propagate, got %+v", res) + } + // goal 必填(schema 校验拦截)。 + res = reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"done_criteria":"d"}`)) + if !res.IsError || !strings.Contains(res.Content, "required argument goal") { + t.Fatalf("missing goal must fail schema validation, got %+v", res) + } +} + +func TestDelegateToolFactoryErrorAndCancel(t *testing.T) { + reg := tools.NewRegistry(t.TempDir()) + Register(reg, func(ctx context.Context, req Request) (Result, error) { + return Result{}, context.Canceled + }) + res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"g","done_criteria":"d"}`)) + if !res.IsError || !strings.Contains(res.Content, "sub-agent failed") { + t.Fatalf("factory error must surface, got %+v", res) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + res = reg.Execute(ctx, "delegate_task", json.RawMessage(`{"goal":"g","done_criteria":"d"}`)) + if !res.IsError || !strings.Contains(res.Content, "canceled before start") { + t.Fatalf("pre-canceled delegation must not start, got %+v", res) + } +}