package tools import ( "bytes" "context" "encoding/json" "fmt" "io" "strings" "github.com/bnema/zut/packages/agent/subagents" "github.com/bnema/zut/packages/core" "github.com/bnema/zut/packages/provider" ) // SubagentSpawnTool lets the main agent delegate work through the resident // manager. Every spawn returns immediately and runs in the background. Required work records a durable obligation that must be // resolved before the parent can produce its terminal response. // // Available when the host's launch-time tool policy permits delegation. The // primary-agent prompt decides whether delegation is proactive or only on an // explicit user request. const ( SubagentSpawnToolName = "subagent_spawn" SubagentStatusToolName = "subagent_status" SubagentStopToolName = "subagent_stop" SubagentResumeToolName = "subagent_resume" ) type SubagentSpawnTool struct { ResidentManager *subagents.ResidentManager BuildResidentSpec func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) OnResidentSpawned func(subagents.ResidentChildSpec, string) // Enabled reports whether the host currently exposes this tool. When nil, // the tool is treated as disabled. Enabled func() bool // DefaultModel and DefaultProvider return the host agent's resolved // model and provider. They are used when the tool call omits both // fields and does not select a named profile, so auto-subagents follows // the same auth route as the user sees in the parent session. DefaultModel func() string DefaultProvider func() string DefaultReasoning func() string // ResolveSubagent validates and resolves a named markdown profile. ResolveSubagent func(name string) (*subagents.Profile, error) } // ResidentSpawnRequest is the resolved tool input the host uses to construct // a complete non-secret resident ChildSpec. type ResidentSpawnRequest struct { Task string Profile *subagents.Profile Model string Provider string Reasoning string FastMode *bool Required bool WorkspaceMode subagents.WorkspaceMode } type subagentSpawnArgs struct { Task string `json:"task"` Agent string `json:"agent,omitempty"` Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` Reasoning string `json:"reasoning,omitempty"` FastMode *bool `json:"fast_mode,omitempty"` Required bool `json:"required,omitempty"` Isolation string `json:"isolation,omitempty"` } const subagentSpawnSchemaTemplate = `{ "type": "object", "properties": { "task": { "type": "string", "description": "The full task description for the sub-agent. Be specific: the child normally has the main agent's built-in tools, including lsp when enabled, but a selected profile can restrict its tools; it shares this working directory and starts with NO context from this conversation." }, "agent": { "type": "string", "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child." }, "model": { "type": "string", "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route, or omit them when using an agent profile. Do not infer provider from model name. If you override this, also provide provider." }, "provider": { "type": "string", "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth." }, "reasoning": { "type": "string", "enum": ["off", "minimum", "low", "medium", "high", "xhigh", "max"], "description": "Optional reasoning level for the child. Overrides the selected profile's thinking level when provided." }, "fast_mode": { "type": "boolean", "description": "Optional fast-mode override for the child. Omit to inherit the selected profile or host setting." }, "required": { "type": "boolean", "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation." }, "isolation": { "type": "string", "enum": ["shared", "worktree"], "description": "Workspace mode. Shared preserves existing behavior; worktree captures a patch without merging it." } }, "required": ["task"] }` func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName } func (t *SubagentSpawnTool) Description() string { return "Delegate work to a resident sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed." } func (t *SubagentSpawnTool) Schema() json.RawMessage { return json.RawMessage(subagentSpawnSchemaTemplate) } func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { prefix := t.Name() if t.ResidentManager == nil { return protocolToolError(prefix + ": subagent runtime not available in this mode") } if t.Enabled == nil || !t.Enabled() { return protocolToolError(prefix + ": subagent delegation is unavailable in this mode") } var a subagentSpawnArgs if err := decodeSubagentArgs(raw, &a); err != nil { return core.ToolResult{}, err } task := strings.TrimSpace(a.Task) if task == "" { return protocolToolError(prefix + ": task is required") } workspaceMode := subagents.WorkspaceShared if value := strings.TrimSpace(a.Isolation); value != "" { workspaceMode = subagents.WorkspaceMode(value) if workspaceMode != subagents.WorkspaceShared && workspaceMode != subagents.WorkspaceWorktree { return protocolToolError(prefix + ": isolation must be shared or worktree") } } agentName := strings.TrimSpace(a.Agent) var profile *subagents.Profile var fastModeOverride *bool if agentName != "" { if t.ResolveSubagent == nil { return protocolToolError(prefix + ": named subagent profiles are unavailable") } var err error profile, err = t.ResolveSubagent(agentName) if err != nil { return protocolToolError(prefix + ": " + err.Error()) } if profile == nil { return protocolToolError(prefix + ": unknown subagent profile " + agentName) } fastModeOverride = profile.FastMode } if a.FastMode != nil { fastModeOverride = a.FastMode } model := strings.TrimSpace(a.Model) providerID := strings.TrimSpace(a.Provider) if (model == "") != (providerID == "") { return protocolToolError(prefix + ": omit both model/provider to inherit the host or profile, or provide both explicitly") } if profile != nil { // A profile may specify a qualified model, a bare model, a provider, // or neither. Fill only missing pieces so explicit spawn options win; // the parent session supplies whatever the profile leaves unspecified. profileProvider, profileModel := profile.ModelSelection() if model == "" { model = profileModel } if providerID == "" { providerID = profileProvider } } if model == "" && t.DefaultModel != nil { model = strings.TrimSpace(t.DefaultModel()) } if providerID == "" && t.DefaultProvider != nil { providerID = strings.TrimSpace(t.DefaultProvider()) } reasoning, err := normalizeReasoning(a.Reasoning) if err != nil { return protocolToolError(prefix + ": " + err.Error()) } if reasoning == "" && profile != nil && strings.TrimSpace(profile.Thinking) != "" { reasoning, err = normalizeReasoning(profile.Thinking) if err != nil { return protocolToolError(prefix + ": profile " + profile.Name + ": " + err.Error()) } } if reasoning == "" && t.DefaultReasoning != nil { reasoning, err = normalizeReasoning(t.DefaultReasoning()) if err != nil { return protocolToolError(prefix + ": host " + err.Error()) } } if t.BuildResidentSpec == nil { return protocolToolError(prefix + ": resident child factory is unavailable") } spec, err := t.BuildResidentSpec(ctx, ResidentSpawnRequest{ Task: task, Profile: profile, Model: model, Provider: providerID, Reasoning: reasoning, FastMode: fastModeOverride, Required: a.Required, WorkspaceMode: workspaceMode, }) if err != nil { return protocolToolError(prefix + ": " + err.Error()) } if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil { return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err) } var sb strings.Builder fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: queued\nworkspace: %s\ntask: %s\n", spec.ID, spec.WorkspaceMode, truncateTask(task, 200)) if spec.Profile != "" { fmt.Fprintf(&sb, "agent: %s\n", spec.Profile) } fmt.Fprintf(&sb, "model: %s\nprovider: %s\n", spec.Model, spec.Provider) if spec.Reasoning != "" { fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning) } if spec.Required { sb.WriteString("required: pending\n") } sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].") return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil } func decodeSubagentArgs(raw json.RawMessage, target any) error { decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return fmt.Errorf("invalid args: %w", err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { return fmt.Errorf("invalid args: trailing JSON value") } return nil } func normalizeReasoning(value string) (string, error) { value = strings.TrimSpace(value) if value == "" { return "", nil } normalized := provider.NormalizeReasoning(value) switch normalized { case "": return "off", nil case "minimum", "low", "medium", "high", "xhigh", "max": return normalized, nil default: return "", fmt.Errorf("reasoning must be off|minimum|low|medium|high|xhigh|max") } } // protocolToolError keeps model-visible validation failures in the // ToolResult channel rather than treating them as host execution errors. func protocolToolError(msg string) (core.ToolResult, error) { //nolint:nilerr // ToolResult.IsError is the established tool protocol. return toolErr(msg), nil } func toolErr(msg string) core.ToolResult { return core.ToolResult{ Content: []provider.Content{provider.TextBlock{Text: msg}}, IsError: true, } } func truncateTask(s string, n int) string { if len(s) <= n { return s } return s[:n-3] + "..." }