Add the delegation port and delegate_task tool backed by host factories
Why: complete agents delegate; the kernel must not nest executions itself (orchestration stays with the host per the agent-execution standard), so the missing piece is a port plus a tool face. Evidence: go vet ./... clean; go test ./... 14 packages ok. Cases: nil factory registers nothing, full delegation field round-trip (goal/inputs/write_scope/non_goals/done_criteria/deliverables) with sorted file list in the result text, sub-agent failure and factory error propagation, schema-required goal rejection, pre-canceled delegations never starting, and adapter end-to-end wiring through Options.Subagent. Impact: new runtime/subagent package (Request/Result/Factory); the delegation message set matches the agent-execution delegation clause (sub-tasks derive no extra permissions; budget fields are host-set); native.Options gains Subagent. Refs: plan B8; standards/agent-execution.md delegation section.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user