// This file implements the generic `task` tool (US-002, #454): a general-purpose // sub-agent the model can dispatch with a free-form prompt to fan out work in a // single assistant message. It is a thin specialization of SubAgentTool - it // reuses executeGoroutine's child-loop driving - configured with a generic // system prompt (the delegated task comes from the call arguments at runtime), // a shared concurrency semaphore, and a child tool set from which `task` itself // is excluded (the nesting guard, wired in internal/cli/run). package runtime import ( "encoding/json" "os" "strconv" "strings" "github.com/smallnest/pigo/internal/agentcore" ) // DefaultMaxSubagents is the concurrency cap applied when PIGO_MAX_SUBAGENTS is // unset or invalid. It bounds how many task sub-agents run at once so a fan-out // cannot overwhelm the provider rate limit. const DefaultMaxSubagents = 4 // taskDescription is advertised to the parent model so it knows when to delegate // work to a generic sub-agent. const taskDescription = "Dispatch a general-purpose sub-agent to autonomously complete a delegated task. " + "The sub-agent runs its own agent loop with a fresh context and the standard tool set, then returns its final report. " + "Provide a complete, self-contained prompt since the sub-agent shares none of this conversation's context. " + "Multiple task calls in one message run in parallel." // taskSystemPrompt seeds every generic sub-agent's context. It is intentionally // generic (the actual work arrives as the runtime prompt) and mirrors the // parent agent's operating posture so a delegated task is carried out the same // way the parent would. const taskSystemPrompt = "You are a focused sub-agent working on one delegated task. " + "You have your own fresh context and the standard tool set, but you cannot spawn further sub-agents. " + "Complete the task fully using the tools available, then respond with a concise final report of what you did and any key findings. " + "Your final message is returned verbatim to the agent that dispatched you, so make it self-contained." // taskSchema is the JSON Schema for a task invocation: a required self-contained // prompt plus an optional short description used for status display. var taskSchema = json.RawMessage(`{ "type": "object", "properties": { "description": { "type": "string", "description": "A short (3-5 word) description of the task, for status display." }, "prompt": { "type": "string", "description": "The full task for the sub-agent to perform. It must be self-contained since the sub-agent runs with a fresh context and shares none of this conversation." } }, "required": ["prompt"], "additionalProperties": false }`) // NewTaskTool builds the generic `task` sub-agent tool. factory produces a fresh // child RunConfig per spawn (reusing the parent's provider stream/model and a // child tool registry that must exclude `task` for the nesting guard); sem is a // shared buffered channel bounding concurrent task runs (nil disables limiting). // The child prompt comes from the call arguments at runtime, so a single generic // spec serves every delegated task. func NewTaskTool(factory func() RunConfig, sem chan struct{}) *SubAgentTool { return NewSubAgentTool(SubAgentSpec{ Name: "task", Description: taskDescription, SystemPrompt: taskSystemPrompt, Schema: taskSchema, NewRunConfig: factory, Sem: sem, }) } // MaxSubagents resolves the concurrency cap for task sub-agents from // PIGO_MAX_SUBAGENTS: absent or unparseable yields DefaultMaxSubagents (4), and // a parsed value below 1 is floored to 1 so the semaphore always admits at least // one runner. func MaxSubagents() int { v := strings.TrimSpace(os.Getenv("PIGO_MAX_SUBAGENTS")) if v == "" { return DefaultMaxSubagents } n, err := strconv.Atoi(v) if err != nil { return DefaultMaxSubagents } if n < 1 { return 1 } return n } // NewSubagentSemaphore builds the shared concurrency semaphore for task // sub-agents, sized by MaxSubagents. One instance per run is created and shared // across every task call so the cap is enforced run-wide. func NewSubagentSemaphore() chan struct{} { return make(chan struct{}, MaxSubagents()) } // activityOf maps a child sub-agent event to the display verb surfaced in a // SubAgentProgressEvent (D-8: tool name / phase, no argument summary). A child // ToolExecutionStartEvent maps by tool name; a TurnStartEvent (a fresh turn with // no tool in progress) maps to "Thinking". Every other event maps to "" so the // caller emits nothing — progress is reported only at these activity boundaries, // keeping event volume proportional to the child's tool calls rather than its // text deltas (D-7). func activityOf(ev agentcore.AgentEvent) string { switch e := ev.(type) { case agentcore.ToolExecutionStartEvent: switch e.ToolName { case "read": return "Reading" case "edit", "write": return "Editing" case "bash": return "Running bash" case "grep", "find", "ls": return "Searching" case "webfetch": return "Fetching" default: return "" } case agentcore.TurnStartEvent: return "Thinking" default: return "" } } // estimateTokens gives a coarse output-token estimate from a running character // count of the child's streamed text (~4 chars per token). It rides along on // each progress event as a rough "↓ tokens" figure; 0 means unknown (no text // streamed yet). func estimateTokens(chars int) int { if chars <= 0 { return 0 } return chars / 4 }