473 lines
21 KiB
Go
473 lines
21 KiB
Go
// This file implements sub-agent orchestration (US-027, #45) with an optional
|
|
// process-isolation mode (US-019, #135).
|
|
//
|
|
// A sub-agent is a full agent loop with its own AgentContext (independent system
|
|
// prompt, message history and tool set), launched by the parent through a normal
|
|
// tool call. The child runs to completion and its final assistant text is fed
|
|
// back to the parent as the tool result - so from the parent loop's perspective
|
|
// a sub-agent is just another tool.
|
|
//
|
|
// Two isolation modes are supported, selected by SubAgentSpec.Isolation:
|
|
//
|
|
// - Goroutine (default): the child loop runs in-process in a goroutine sharing
|
|
// the parent process, matching the original "single-process goroutine" decision.
|
|
// - Process: the parent spawns a fresh pigo subprocess (pigo --subagent-rpc)
|
|
// and delegates the run over stdio JSON-RPC (reusing internal/jsonrpc). The
|
|
// child runs in a separate process, so a crash or resource leak in the child
|
|
// cannot affect the parent loop; a crash is surfaced as a tool error. The
|
|
// subprocess resolves its own provider from the model/provider passed in the
|
|
// request and inherits the parent environment for credentials.
|
|
//
|
|
// Because each Execute call spins up an independent run, multiple sub-agents can
|
|
// run concurrently (the batch executor already runs parallel tool calls in
|
|
// separate goroutines/processes).
|
|
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/jsonrpc"
|
|
)
|
|
|
|
// SubAgentIsolation selects how a sub-agent runs relative to its parent.
|
|
type SubAgentIsolation int
|
|
|
|
const (
|
|
// SubAgentIsolationGoroutine runs the child agent loop in-process in a
|
|
// goroutine. This is the default and the original behavior; it changes
|
|
// nothing about how sub-agents previously ran.
|
|
SubAgentIsolationGoroutine SubAgentIsolation = iota
|
|
// SubAgentIsolationProcess runs the child in a fresh pigo subprocess,
|
|
// delegating the run over stdio JSON-RPC. A subprocess crash is surfaced to
|
|
// the parent as a tool error and never affects the parent loop.
|
|
SubAgentIsolationProcess
|
|
)
|
|
|
|
// SubAgentProcessConfig configures process-isolated sub-agent execution. It
|
|
// carries the serializable provider config the subprocess needs to reconstruct
|
|
// the run: the in-process Stream/GetAPIKey functions a goroutine-mode
|
|
// NewRunConfig returns cannot cross a process boundary, so the parent forwards
|
|
// the model (and optional base URL/protocol) and the subprocess resolves the
|
|
// provider itself, inheriting the parent environment for API keys.
|
|
type SubAgentProcessConfig struct {
|
|
// Command is the executable to spawn. When empty, os.Executable() (the pigo
|
|
// binary itself) is used so a pigo process spawns another pigo.
|
|
Command string
|
|
// Args are appended to the command after the subagent-rpc flag. Rarely
|
|
// needed; reserved for test doubles or non-standard layouts.
|
|
Args []string
|
|
// Model is the model id the subprocess runs against. Required. A preset id
|
|
// (e.g. "openrouter/free", "anthropic/claude-...") or ollama/nvidia-prefixed
|
|
// id resolves its own provider; a custom gateway needs BaseURL/Protocol.
|
|
Model string
|
|
// BaseURL and Protocol override the provider endpoint and wire protocol for
|
|
// custom gateways (Protocol "anthropic"/"openai" forces that wire format).
|
|
// Empty falls back to the same resolution the CLI uses.
|
|
BaseURL string
|
|
Protocol string
|
|
// ToolNames restricts the subprocess's builtin tool set to the named tools
|
|
// (e.g. a read-only researcher). Empty keeps all builtins. Non-builtin names
|
|
// are ignored: custom/plugin tools cannot cross a process boundary, so a
|
|
// process-isolated child runs with builtins only.
|
|
ToolNames []string
|
|
// Env is the child's environment (os/exec form). When nil the child inherits
|
|
// the parent environment, which is how it picks up provider API keys.
|
|
Env []string
|
|
// Dir is the child's working directory; empty means the parent's.
|
|
Dir string
|
|
// Stderr optionally receives the child's stderr. When nil it is discarded.
|
|
Stderr io.Writer
|
|
}
|
|
|
|
// SubAgentRunParams is the JSON-RPC request payload for a process-isolated
|
|
// sub-agent run (the "subagent/run" method). It is the wire contract between
|
|
// the parent (SubAgentTool in process mode) and the pigo subprocess
|
|
// (cmd/pigo --subagent-rpc).
|
|
type SubAgentRunParams struct {
|
|
Prompt string `json:"prompt"`
|
|
SystemPrompt string `json:"systemPrompt,omitempty"`
|
|
Model string `json:"model"`
|
|
BaseURL string `json:"baseUrl,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
Tools []string `json:"tools,omitempty"`
|
|
}
|
|
|
|
// SubAgentRunResult is the JSON-RPC response payload carrying the child's final
|
|
// assistant text.
|
|
type SubAgentRunResult struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// SubAgentRPCMethod is the JSON-RPC method name the parent calls on the
|
|
// subprocess: "subagent/run".
|
|
const SubAgentRPCMethod = "subagent/run"
|
|
|
|
// SubAgentRPCFlag is the command-line flag the parent launches the pigo
|
|
// subprocess with so it enters the sub-agent RPC server mode: "--subagent-rpc".
|
|
const SubAgentRPCFlag = "--subagent-rpc"
|
|
|
|
// SubAgentSpec declares a spawnable sub-agent: its identity (surfaced to the
|
|
// model as a tool), the system prompt and tools its child context runs with,
|
|
// and a factory for the child's run configuration (provider stream, batch
|
|
// registry, hooks). The factory is called once per spawn so each child gets an
|
|
// independent RunConfig; NewRunConfig must wire a ToolRegistry consistent with
|
|
// Tools. It is used by goroutine mode; process mode uses Process instead (the
|
|
// subprocess builds its own RunConfig from the serializable provider config).
|
|
type SubAgentSpec struct {
|
|
// Name is the tool name the parent invokes to spawn this sub-agent.
|
|
Name string
|
|
// Description is injected into the parent's tool list / capability list so
|
|
// the model knows when to delegate.
|
|
Description string
|
|
// SystemPrompt seeds the child context's system prompt. When empty the child
|
|
// runs with no system prompt.
|
|
SystemPrompt string
|
|
// Tools is the child's independent tool set. It may differ from the parent's
|
|
// (e.g. a read-only researcher sub-agent) and may be empty. In goroutine
|
|
// mode these exact tools run in-process; in process mode only the tools'
|
|
// NAMES are forwarded (the subprocess rebuilds builtins by name).
|
|
Tools []agentcore.AgentTool
|
|
// NewRunConfig builds the loop configuration for one child run in goroutine
|
|
// mode. It is called per spawn; the returned config's Batch registry should
|
|
// contain Tools. Ignored in process mode (the subprocess resolves its own).
|
|
NewRunConfig func() RunConfig
|
|
// Isolation selects goroutine (default) vs process execution. Zero value is
|
|
// goroutine, preserving the original behavior.
|
|
Isolation SubAgentIsolation
|
|
// Process configures process-isolated execution. Required when Isolation is
|
|
// SubAgentIsolationProcess; ignored otherwise.
|
|
Process SubAgentProcessConfig
|
|
// Schema, when non-empty, overrides the default single-prompt argument schema
|
|
// advertised to the model. The generic task tool uses this to also accept an
|
|
// optional description; a nil/empty Schema keeps the original prompt-only
|
|
// schema so existing specs are unaffected.
|
|
Schema json.RawMessage
|
|
// Sem, when non-nil, is a shared buffered channel used as a concurrency
|
|
// semaphore for goroutine-mode runs: executeGoroutine acquires a slot before
|
|
// spawning the child and releases it when the child settles. A full channel
|
|
// blocks (queues) the acquire rather than erroring. nil disables limiting, so
|
|
// existing sub-agent specs run unbounded exactly as before.
|
|
Sem chan struct{}
|
|
}
|
|
|
|
// subAgentArgs is the JSON argument shape for a sub-agent tool call: a
|
|
// free-form prompt describing the delegated task, plus an optional short
|
|
// description used for status display (accepted by the generic task tool;
|
|
// ignored by prompt-only specs).
|
|
type subAgentArgs struct {
|
|
Prompt string `json:"prompt"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// subAgentSchema is the JSON Schema validating a sub-agent invocation.
|
|
var subAgentSchema = json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"prompt": {
|
|
"type": "string",
|
|
"description": "The task for the sub-agent to perform, described in full since the sub-agent runs with a fresh context."
|
|
}
|
|
},
|
|
"required": ["prompt"],
|
|
"additionalProperties": false
|
|
}`)
|
|
|
|
// SubAgentTool adapts a SubAgentSpec into an AgentTool. Executing it spawns a
|
|
// child agent run (in a goroutine or a subprocess, per Isolation) and returns
|
|
// the child's final text.
|
|
type SubAgentTool struct {
|
|
spec SubAgentSpec
|
|
// processCall, when non-nil, overrides the default subprocess transport for
|
|
// process-isolated mode. Tests inject a fake to exercise the process-mode
|
|
// logic (params shaping, crash-as-error, result forwarding) without building
|
|
// a real binary; production leaves it nil so Execute uses defaultProcessCall.
|
|
processCall func(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error)
|
|
}
|
|
|
|
// NewSubAgentTool builds a sub-agent tool from a spec. In goroutine mode
|
|
// NewRunConfig is required (it supplies the provider stream that drives the
|
|
// child); in process mode Process.Model is required instead.
|
|
func NewSubAgentTool(spec SubAgentSpec) *SubAgentTool {
|
|
return &SubAgentTool{spec: spec}
|
|
}
|
|
|
|
func (t *SubAgentTool) Name() string { return t.spec.Name }
|
|
|
|
func (t *SubAgentTool) Description() string { return t.spec.Description }
|
|
|
|
func (t *SubAgentTool) Schema() json.RawMessage {
|
|
if len(t.spec.Schema) > 0 {
|
|
return t.spec.Schema
|
|
}
|
|
return subAgentSchema
|
|
}
|
|
|
|
// ExecutionMode is parallel: independent sub-agents may run concurrently, since
|
|
// each spawns its own context and run (goroutine or process) with no shared
|
|
// mutable state.
|
|
func (t *SubAgentTool) ExecutionMode() agentcore.ToolExecutionMode {
|
|
return agentcore.ToolExecutionParallel
|
|
}
|
|
|
|
// Execute spawns the child agent run and blocks until it settles, then returns
|
|
// the child's final assistant text as the tool result. The parent's ctx governs
|
|
// the child, so cancelling the parent run cancels in-flight sub-agents (in
|
|
// goroutine mode via ctx; in process mode via ctx cancelling the JSON-RPC call
|
|
// and Close killing the child).
|
|
func (t *SubAgentTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
|
// Goroutine mode requires NewRunConfig (it supplies the in-process provider
|
|
// stream). Process mode does not - the subprocess resolves its own provider
|
|
// from Process.Model - so the check is guarded to goroutine mode. This
|
|
// preserves the original precedence (nil NewRunConfig reported before an
|
|
// empty prompt) for the unchanged goroutine path.
|
|
if t.spec.Isolation != SubAgentIsolationProcess && t.spec.NewRunConfig == nil {
|
|
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: no run configuration", t.spec.Name)
|
|
}
|
|
var a subAgentArgs
|
|
if len(args) > 0 {
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: decode args: %w", t.spec.Name, err)
|
|
}
|
|
}
|
|
if a.Prompt == "" {
|
|
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: empty prompt", t.spec.Name)
|
|
}
|
|
|
|
if t.spec.Isolation == SubAgentIsolationProcess {
|
|
// Process mode returns only the child's final text (the JSON-RPC protocol
|
|
// does not stream partial updates), so onUpdate is intentionally not
|
|
// forwarded here; a caller supplying a sink gets no deltas in this mode.
|
|
return t.executeProcess(ctx, a.Prompt)
|
|
}
|
|
return t.executeGoroutine(ctx, id, a.Prompt, a.Description, onUpdate)
|
|
}
|
|
|
|
// executeGoroutine runs the child agent loop in-process and returns its final
|
|
// text. This is the default mode and the original sub-agent behavior.
|
|
//
|
|
// id is the parent tool call's id and description is the (optional) task
|
|
// description; both are threaded onto any SubAgentProgressEvent emitted for this
|
|
// run so a consumer can key status by the parent task call. When the parent loop
|
|
// injected a run-level progress emitter into ctx (WithProgressEmitter), the
|
|
// child's tool-execution / turn boundaries are translated into
|
|
// SubAgentProgressEvent and surfaced up the parent stream; when no emitter is
|
|
// present (e.g. the tool is called directly in a unit test) progress reporting is
|
|
// silently skipped.
|
|
func (t *SubAgentTool) executeGoroutine(ctx context.Context, id, prompt, description string, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
|
// Concurrency guard: when a shared semaphore is configured, acquire a slot
|
|
// before spawning the child and release it via defer so a panic or error
|
|
// still frees the slot. A full channel blocks (queues) the acquire; a
|
|
// cancelled parent ctx abandons the wait instead of blocking forever.
|
|
if t.spec.Sem != nil {
|
|
select {
|
|
case t.spec.Sem <- struct{}{}:
|
|
defer func() { <-t.spec.Sem }()
|
|
case <-ctx.Done():
|
|
return agentcore.AgentToolResult{}, ctx.Err()
|
|
}
|
|
}
|
|
runCfg := t.spec.NewRunConfig()
|
|
// Advertise the child's tools to the model. A spec may pin an explicit set
|
|
// (spec.Tools); otherwise fall back to the run config's registry — the tools
|
|
// the executor can actually run — so a factory that wires only the registry
|
|
// (like the generic task tool) still tells the child what it can call.
|
|
// Without this the model is handed an empty tool list, can only reply with
|
|
// text, and a delegated task that needs tools comes back empty.
|
|
tools := t.spec.Tools
|
|
if len(tools) == 0 && runCfg.Batch.ToolExecutorConfig.Registry != nil {
|
|
tools = runCfg.Batch.ToolExecutorConfig.Registry.List()
|
|
}
|
|
childCtx := &agentcore.AgentContext{
|
|
SystemPrompt: t.spec.SystemPrompt,
|
|
Messages: agentcore.MessageList{
|
|
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(prompt)}},
|
|
},
|
|
Tools: tools,
|
|
}
|
|
|
|
stream := StartRun(ctx, childCtx, runCfg)
|
|
// Drain events (DrainStream never returns early, so the producer goroutine is
|
|
// never blocked on back-pressure); forward streamed child text as
|
|
// tool-execution updates when a sink is set.
|
|
var h StreamHandler
|
|
if onUpdate != nil {
|
|
h.OnText = func(delta string) {
|
|
onUpdate(agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(delta)}})
|
|
}
|
|
}
|
|
// Progress reporting: when the parent loop injected a run-level emitter into
|
|
// ctx, translate the child's tool-execution / turn boundaries into
|
|
// SubAgentProgressEvent and emit them up the parent stream. Reporting is at
|
|
// activity granularity (per child tool start / turn boundary), NOT per text
|
|
// delta, so event volume stays proportional to the child's tool calls. When
|
|
// no emitter is present the OnEvent hook is left nil and progress is skipped.
|
|
if parentEmit := agentcore.ProgressEmitterFromContext(ctx); parentEmit != nil {
|
|
// chars accumulates the child's streamed text length so a coarse output
|
|
// token estimate can ride along on each progress event (0 = unknown).
|
|
chars := 0
|
|
if prev := h.OnText; prev != nil {
|
|
h.OnText = func(delta string) {
|
|
chars += len(delta)
|
|
prev(delta)
|
|
}
|
|
} else {
|
|
h.OnText = func(delta string) { chars += len(delta) }
|
|
}
|
|
h.OnEvent = func(ev agentcore.AgentEvent) {
|
|
act := activityOf(ev)
|
|
if act == "" {
|
|
return
|
|
}
|
|
_ = parentEmit(ctx, agentcore.SubAgentProgressEvent{
|
|
ToolCallID: id,
|
|
Description: description,
|
|
Activity: act,
|
|
Tokens: estimateTokens(chars),
|
|
})
|
|
}
|
|
}
|
|
final, err := DrainStream(ctx, stream, h)
|
|
if err != nil {
|
|
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: %w", t.spec.Name, err)
|
|
}
|
|
text := ""
|
|
if final != nil {
|
|
text = agentcore.ContentToText(final.Content)
|
|
}
|
|
if text == "" {
|
|
text = fmt.Sprintf("(sub-agent %q produced no text output)", t.spec.Name)
|
|
}
|
|
// Surface a failed child run as a tool error so the parent model gets a
|
|
// signal the delegation failed (the tool executor marks the result
|
|
// IsError). A child whose final turn stopped on error/aborted otherwise
|
|
// looks like a successful delegation carrying error text.
|
|
if final != nil && (final.StopReason == agentcore.StopReasonError || final.StopReason == agentcore.StopReasonAborted) {
|
|
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q failed (%s): %s", t.spec.Name, final.StopReason, text)
|
|
}
|
|
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
|
|
}
|
|
|
|
// executeProcess runs the child agent loop in a fresh pigo subprocess over stdio
|
|
// JSON-RPC and returns its final text. A subprocess crash, transport error, or
|
|
// failed child run is surfaced as a tool error; the parent loop is unaffected.
|
|
// Streamed child text is not forwarded (the process protocol returns only the
|
|
// final result); the parent sees the complete result when the child settles.
|
|
func (t *SubAgentTool) executeProcess(ctx context.Context, prompt string) (agentcore.AgentToolResult, error) {
|
|
cfg := t.spec.Process
|
|
if cfg.Model == "" {
|
|
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q: process mode requires Process.Model", t.spec.Name)
|
|
}
|
|
// Forward the child's tool names so the subprocess can rebuild a matching
|
|
// builtin set; an explicit ToolNames list wins over deriving from Tools.
|
|
toolNames := cfg.ToolNames
|
|
if len(toolNames) == 0 {
|
|
for _, tl := range t.spec.Tools {
|
|
toolNames = append(toolNames, tl.Name())
|
|
}
|
|
}
|
|
params := SubAgentRunParams{
|
|
Prompt: prompt,
|
|
SystemPrompt: t.spec.SystemPrompt,
|
|
Model: cfg.Model,
|
|
BaseURL: cfg.BaseURL,
|
|
Protocol: cfg.Protocol,
|
|
Tools: toolNames,
|
|
}
|
|
call := t.processCall
|
|
if call == nil {
|
|
call = defaultProcessCall
|
|
}
|
|
text, err := call(ctx, cfg, params)
|
|
if err != nil {
|
|
return agentcore.AgentToolResult{}, fmt.Errorf("sub-agent %q (process): %w", t.spec.Name, err)
|
|
}
|
|
if text == "" {
|
|
text = fmt.Sprintf("(sub-agent %q produced no text output)", t.spec.Name)
|
|
}
|
|
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(text)}}, nil
|
|
}
|
|
|
|
// defaultProcessCall is the production subprocess transport: it launches the
|
|
// pigo binary (or cfg.Command) with the subagent-rpc flag, sends a single
|
|
// "subagent/run" JSON-RPC request over the child's stdin, and returns the
|
|
// child's final text from the response. The child is closed (killed if it does
|
|
// not exit on its own) before returning. A crash, transport error, or RPC error
|
|
// is returned as a Go error so executeProcess surfaces it as a tool error.
|
|
func defaultProcessCall(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
|
|
command := cfg.Command
|
|
if command == "" {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve pigo executable: %w", err)
|
|
}
|
|
command = exe
|
|
}
|
|
args := append([]string{SubAgentRPCFlag}, cfg.Args...)
|
|
client, err := jsonrpc.NewClient(jsonrpc.Config{
|
|
Command: command,
|
|
Args: args,
|
|
Env: cfg.Env,
|
|
Dir: cfg.Dir,
|
|
Stderr: cfg.Stderr,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer client.Close()
|
|
raw, err := client.Call(ctx, SubAgentRPCMethod, params)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var res SubAgentRunResult
|
|
if err := json.Unmarshal(raw, &res); err != nil {
|
|
return "", fmt.Errorf("decode sub-agent result: %w", err)
|
|
}
|
|
return res.Text, nil
|
|
}
|
|
|
|
// RunSubAgentOnce runs one sub-agent loop to completion and returns the child's
|
|
// final assistant text. It is the execution core shared by the process-isolated
|
|
// subprocess (cmd/pigo --subagent-rpc): given a resolved RunConfig (provider
|
|
// stream, tool registry) and the prompt/system prompt, it builds a fresh child
|
|
// context and drains the run. A run whose final turn stopped on error/aborted
|
|
// is reported as an error so the subprocess surfaces failure (as an RPC error)
|
|
// rather than returning empty text. It does not stream partial updates: the
|
|
// process protocol returns only the final result.
|
|
func RunSubAgentOnce(ctx context.Context, systemPrompt, prompt string, tools []agentcore.AgentTool, runCfg RunConfig) (string, error) {
|
|
childCtx := &agentcore.AgentContext{
|
|
SystemPrompt: systemPrompt,
|
|
Messages: agentcore.MessageList{
|
|
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(prompt)}},
|
|
},
|
|
Tools: tools,
|
|
}
|
|
stream := StartRun(ctx, childCtx, runCfg)
|
|
final, err := DrainStream(ctx, stream, StreamHandler{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
text := ""
|
|
if final != nil {
|
|
text = agentcore.ContentToText(final.Content)
|
|
}
|
|
if final != nil && (final.StopReason == agentcore.StopReasonError || final.StopReason == agentcore.StopReasonAborted) {
|
|
// When the loop synthesizes an error turn (e.g. a provider connection
|
|
// failure) the diagnostic lands in ErrorMessage, not Content; fall back
|
|
// to it so the subprocess surfaces the real cause rather than a bare
|
|
// "error" stop reason.
|
|
if text == "" && final.ErrorMessage != "" {
|
|
text = final.ErrorMessage
|
|
}
|
|
if text == "" {
|
|
text = string(final.StopReason)
|
|
}
|
|
return text, fmt.Errorf("sub-agent failed (%s): %s", final.StopReason, text)
|
|
}
|
|
return text, nil
|
|
}
|