first commit
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user