Note

Agent 委派:Fork 与预定义子 Agent 如何分工

How PseudoClaude routes Agent tool calls into conversation forks or predefined roles with scoped models, tools, permissions, and execution contexts.

多 Agent 的第一步不是并发,而是决定要把什么上下文和能力交给谁。代码检索适合一个从空白上下文开始的只读角色,延续当前推理则需要继承完整 Conversation;两者如果都叫“启动子 Agent”,很容易掩盖其状态边界。

PseudoClaude 用同一个 Agent Tool 承担委派入口,但内部区分 Fork、预定义子 Agent 和 Team Member。本篇只分析前两种:Fork 复制父会话后在后台继续,预定义角色则按 Markdown Definition 创建受限 Runner。

Agent Tool 先做三路分发

模型提交的参数同时覆盖三种协作方式:

type AgentToolInput struct {
    Prompt           string `json:"prompt"`
    Description      string `json:"description"`
    SubagentType     string `json:"subagent_type,omitempty"`
    Model            string `json:"model,omitempty"`
    Isolation        string `json:"isolation,omitempty"`
    RunInBackground  bool   `json:"run_in_background,omitempty"`
    Name             string `json:"name,omitempty"`
    TeamName         string `json:"team_name,omitempty"`
    PlanModeRequired bool   `json:"plan_mode_required,omitempty"`
}

Execute 在 Trim 和基础校验后读取 RunnerHandle 快照,并按固定顺序路由:

if parentRunnerIsSubagent(parent) {
    if args.TeamName != "" {
        return tools.Failure(
            "Agent",
            "nested_team_member_forbidden",
            "team members and sub Agents cannot start another team member",
            nil,
        )
    }
    if parentRunnerIsFork(parent) {
        return tools.Failure(
            "Agent",
            "nested_agent_forbidden",
            "Fork sub Agents cannot start another Agent",
            nil,
        )
    }
    return tools.Failure(
        "Agent",
        "nested_agent_forbidden",
        "Sub Agents cannot start another Agent",
        nil,
    )
}
if args.TeamName != "" {
    return t.executeTeam(ctx, args, parent)
}
if args.SubagentType == "" {
    return t.executeFork(ctx, args, parent)
}
def, ok := t.Catalog.Resolve(args.SubagentType)
if !ok {
    return tools.Failure(
        "Agent",
        "unknown_subagent_type",
        "unknown subagent_type: "+args.SubagentType,
        map[string]any{"subagent_type": args.SubagentType},
    )
}
return t.executeDefined(ctx, args, parent, def)

team_name 的优先级高于 subagent_type,因为 Team Member 可以同时选择一个预定义角色;没有 Team 且没有类型才表示 Fork。所有子 Agent 和 Team Member 都不能再次调用 Agent,避免无界递归委派和无法归属的后台树。

Description 缺失时取 Prompt 的前 80 个字节作为短描述。这里按 byte slice 截取,长中文恰好跨 UTF-8 边界时可能生成无效字符串;现有输入校验没有做 rune 截断。

预定义角色是一份 Markdown 运行策略

角色文件由 YAML Frontmatter 和正文组成。解析结果不仅是 Prompt,还包括运行能力:

type Definition struct {
    Name            string
    Description     string
    Tools           []string
    DisallowedTools []string
    Model           ModelRef
    MaxTurns        int
    Permission      PermissionRef
    Background      bool
    Isolation       Isolation
    SystemPrompt    string
    Source          Source
    Path            string
    Warnings        []Warning
}

内置 explore 使用 Haiku、最多 8 轮,明确禁用 Write/Edit;plan 使用 Sonnet 和 Plan Permission;general-purpose 继承父模型和权限。项目也可以在 .PseudoClaude/agents/*.md 定义角色,用户级目录是 ~/.PseudoClaude/agents/*.md。

Catalog 同时加载 Plugin、Builtin、User 和 Project,优先级依次为 10、20、30、40:

func (s Source) Priority() int {
    switch s {
    case SourceProject:
        return 40
    case SourceUser:
        return 30
    case SourceBuiltin:
        return 20
    case SourcePlugin:
        return 10
    default:
        return 0
    }
}

func (c *Catalog) reload(opts LoadOptions) {
    active := map[string]Definition{}
    all := map[string][]Definition{}
    add := func(def Definition) {
        all[def.Name] = append(all[def.Name], cloneDefinition(def))
        if cur, ok := active[def.Name];
            !ok || def.Source.Priority() >= cur.Source.Priority() {
            active[def.Name] = cloneDefinition(def)
        }
    }
    // plugin -> builtin -> user -> project
}

Project 可以覆盖同名 User/Builtin,User 可以覆盖 Builtin。解析错误不会阻止整个 Catalog,合法角色继续加载,问题进入 Warnings。Tool schema 本身不为角色生成 enum,动态角色只追加到 Tool Description,因此 Reload 不会改变 Provider 缓存所见的 JSON Schema。

Plugin 加载函数当前返回 nil,所以它只是预留优先级;同一 Source 内出现同名文件时,后加载项会因 >= 覆盖 active,代码没有单独报告重名冲突。

预定义子 Agent 从空 Conversation 开始

Defined 路径不会复制父消息,只共享 Provider、Registry、配置和受控运行依赖:

func (t *AgentTool) executeDefined(
    ctx context.Context,
    args AgentToolInput,
    parent RunnerSnapshot,
    def subagent.Definition,
) tools.Result {
    background := args.RunInBackground || def.Background
    runner := t.childRunner(parent, def, false, background)
    childConv := &conversation.Conversation{}

    if effectiveIsolation(args, def) == subagent.IsolationWorktree {
        // 创建或准备独立 worktree
    }
    if background {
        return t.launch(
            ctx, args, def, runner, childConv, false, "async_launched",
        )
    }
    result := runForeground(
        ctx, runner, childConv, args.Prompt,
        t.Background.ForegroundTimeout,
    )
    return completionToolResult(def.Name, result)
}

父 Agent 必须把完成任务所需事实写进 Prompt,子 Agent 看不到父 Conversation 的隐含推理。这减少无关历史和 Token,也让角色结果更容易复现;代价是 Prompt 不完整时,子 Agent 无法自行回看父对话补齐需求。

前台是 Defined 的默认值。调用参数或 Definition 的 background: true 任一成立,就改由 Task Manager 异步运行。调用参数的 isolation: worktree 也可以覆盖一个未声明隔离的角色,但只接受 worktree,container 等值直接返回 invalid_arguments。

Fork 复制父会话并强制后台

Fork 的目的不是切换角色,而是从当前会话状态分出一条异步支线:

func (t *AgentTool) executeFork(
    ctx context.Context,
    args AgentToolInput,
    parent RunnerSnapshot,
) tools.Result {
    def := subagent.ForkDefinition()
    messages := subagent.BuildForkMessages(
        parent.Conversation.Messages(),
        args.Prompt,
    )
    childConv := conversation.NewFromMessages(
        messages,
        conversation.Hooks{},
    )
    runner := t.childRunner(parent, def, true, true)
    return t.launch(
        ctx, args, def, runner, childConv, true, "async_launched",
    )
}

即使请求写了 run_in_background: false,Fork 仍传入 background=true 并立即返回 task ID。Fork Definition 使用继承模型、继承权限和专用约束 Prompt,要求只处理分配任务、不询问用户、不继续委派。

BuildForkMessages 会复制消息与 ToolCalls 切片,并为 ToolResult 建立新的结构体值,再附加 Fork 标记和新任务:

func BuildForkMessages(parent []llm.Message, task string) []llm.Message {
    messages := cloneMessages(parent)
    messages = ensureNoDanglingToolCalls(messages)
    content := strings.TrimSpace(ForkBoilerplate) +
        "\n\nTask:\n" + strings.TrimSpace(task)
    messages = append(messages, llm.Message{
        Role: "user", Content: content,
    })
    return messages
}

父消息若以带 ToolCalls 的 Assistant 消息结束,Fork 会为每个调用补一个 tool result unavailable in forked context 错误结果,保证 Provider 不收到悬空调用协议。这不是重放真实工具结果,而是为了让复制后的消息序列结构合法。

Fork 不读取 isolation 并直接进入 executeFork,因此当前不能用同一个 Agent 调用为 Fork 创建 worktree;隔离参数只在 Defined 路径生效。

这里的复制不是任意嵌套数据的递归深拷贝。ToolCall.Arguments 是 json.RawMessage,复制 ToolCall struct 后仍可能共享底层 byte slice;当前流程把历史消息视为不可变值。测试验证了修改子级 ToolCalls 元素不会回写父切片,并验证悬空调用得到补全,但没有修改 Arguments byte 来验证完全隔离。

Child Runner 收窄能力而不是复制完整父 Runner

Runner 装配会解析角色模型,并按 Definition、后台策略和 Plan Permission 过滤 Tool:

allowed := tools.FilterSubAgentTools(parent.Registry, tools.FilterPolicy{
    DefinitionTools:      def.Tools,
    DefinitionDisallowed: def.DisallowedTools,
    Background:           background,
    Fork:                 fork,
})
if def.Permission == subagent.PermissionPlan {
    allowed = readOnlyToolNames(parent.Registry, allowed)
}

return Runner{
    Provider:     provider,
    Registry:     parent.Registry,
    AllowedTools: allowed,
    Sub: SubRunOptions{
        SystemPrompt:   def.SystemPrompt,
        MaxTurns:       def.MaxTurns,
        PermissionMode: permissionModeFromRef(
            def.Permission, parent.PermissionMode,
        ),
        DontAsk:     true,
        IsSubAgent:  true,
        IsFork:      fork,
        ParentLabel: def.Name,
    },
}

普通子 Agent 看不到 Team 协作 Tool 和 Agent Tool;后台任务还会与固定异步允许列表取交集。Definition 的 allow list 最后再取交集,deny list 则先删除。MaxTurns 覆盖 Runner 的最大迭代数,Model 可通过 ProviderResolver 选择,解析失败会安静回退父 Provider。

所有子运行都设置 DontAsk: true,具体权限边界已在第 17 篇披露:硬 Deny 仍生效,但 Permission Engine 的 Ask 会直接执行。它属于委派信任模型的一部分,不能因为角色有 Permission 字段就理解为每个副作用都重新询问用户。

完整流程

Provider 返回 Agent ToolCall
  -> 解析、Trim、校验 prompt / isolation / parent snapshot
  -> 父 Runner 是子 Agent:拒绝嵌套委派
  -> team_name 非空:交给 Team Manager
  -> subagent_type 为空:Fork
       -> 结构化复制父 Conversation
       -> 修复悬空 ToolCall
       -> 加入 Fork 约束与任务
       -> 强制后台 Task
  -> subagent_type 非空:Catalog Resolve
       -> 空 Conversation
       -> 解析 Model / Tools / Permission / MaxTurns
       -> 前台运行,或按参数/Definition 转后台

测试验证了什么

AgentTool 测试确认 Defined 从空会话启动、Explore 不暴露写工具、Fork 保留标记并强制后台、未知角色和嵌套委派被拒绝、调用级 worktree 可覆盖角色设置。Catalog/Parser 测试覆盖来源优先级、热 Reload、完整 Frontmatter、非法值 Warning;Fork 测试覆盖 ToolCalls 切片隔离、Fork 标记和悬空 ToolCall 修复。

现有测试没有覆盖同一来源的同名角色冲突、ProviderResolver 返回错误的可观测告警、中文 Description 的 byte 截断,或为 Fork 提供 worktree isolation。它们都是当前实现不能额外承诺的行为。

小结

PseudoClaude 将“继承当前思路”和“调用一个专业角色”做成两种明确委派模型。Fork 用父 Conversation 换取上下文连续性,并固定后台运行;Defined 用空 Conversation 和 Definition 换取更小、更受控的执行环境。

下一篇继续追踪二者的运行载体:前台子 Agent 如何同步返回,超时怎样转入后台,以及 Task Manager 怎样保存状态并把完成结果交还 TUI。