Files
2026-08-14 23:41:57 +08:00

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
}