// This file implements the subprocess side of process-isolated sub-agents // (US-019, #135). Invoked as `pigo --subagent-rpc`, pigo speaks JSON-RPC 2.0 // over stdio: for each "subagent/run" request on stdin it runs a child agent // loop and writes the result (or an error) to stdout, exiting when stdin // closes. The parent (SubAgentTool in process mode, internal/runtime) drives it // via internal/jsonrpc. // // It reuses internal/jsonrpc's message types (Request/Response/ID/Version) for // (de)serialization so the wire format matches the client exactly; the agent // execution itself is runtime.RunSubAgentOnce. package headless import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "os" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/cli/run" "github.com/smallnest/pigo/internal/jsonrpc" "github.com/smallnest/pigo/internal/provider" "github.com/smallnest/pigo/internal/runtime" ) // RunSubAgentRPC is the `pigo --subagent-rpc` entry point. It reads // newline-delimited JSON-RPC requests from in, runs each sub-agent request, and // writes one response per request to out. It returns 0 (success) when stdin // closes; a per-request failure is an RPC error response, not a non-zero exit, // so the parent can distinguish "the child answered with an error" from "the // child crashed" (the latter is detected by the parent's transport when stdout // closes without a response). func RunSubAgentRPC(ctx context.Context, in io.Reader, out, errOut io.Writer) int { scanner := bufio.NewScanner(in) // A sub-agent prompt can be large; match the jsonrpc client's line cap. scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) enc := json.NewEncoder(out) for scanner.Scan() { line := bytes.TrimSpace(scanner.Bytes()) if len(line) == 0 { continue } var req jsonrpc.Request if err := json.Unmarshal(line, &req); err != nil { // A parse error carries no id, so the response id is null. The // jsonrpc client drops responses with a null id (it cannot correlate // them), so this is only observable on the child's stderr; in // practice the parent always sends well-formed requests. writeSubAgentError(enc, nil, -32700, "parse error: "+err.Error()) continue } handleSubAgentRequest(ctx, enc, &req) } // A scanner error (e.g. a request line exceeding the 16 MiB cap) ends the // stream abnormally: surface it on stderr and exit non-zero so the parent's // transport sees a diagnostic rather than a silent clean exit. if err := scanner.Err(); err != nil { fmt.Fprintf(errOut, "pigo: subagent-rpc stdin: %v\n", err) return 1 } return 0 } // handleSubAgentRequest dispatches one JSON-RPC request to the sub-agent runner // and writes the response. Unknown methods, bad params, provider-resolution // failures, and failed child runs are all RPC errors so the parent surfaces them // as tool errors; only a successful run yields a result with the child's text. func handleSubAgentRequest(ctx context.Context, enc *json.Encoder, req *jsonrpc.Request) { if req.Method != runtime.SubAgentRPCMethod { writeSubAgentError(enc, req.ID, -32601, "method not found: "+req.Method) return } var params runtime.SubAgentRunParams if len(req.Params) > 0 { if err := json.Unmarshal(req.Params, ¶ms); err != nil { writeSubAgentError(enc, req.ID, -32602, "invalid params: "+err.Error()) return } } if params.Prompt == "" || params.Model == "" { writeSubAgentError(enc, req.ID, -32602, "invalid params: prompt and model are required") return } // Resolve the provider the same way the CLI does, so the subprocess targets // the same gateway the parent's NewRunConfig encoded. Credentials come from // the inherited environment (the parent's env vars). prov, providerName, err := provider.ResolveProvider(params.Model, params.BaseURL, params.Protocol, "", os.Getenv) if err != nil { writeSubAgentError(enc, req.ID, -32603, "resolve provider: "+err.Error()) return } cwd, _ := os.Getwd() tools := filterBuiltinTools(run.BuiltinTools(cwd, false), params.Tools) reg := run.ToolRegistry(tools) creds := provider.NewCredentialStore(nil) // env-resolved runCfg := runtime.RunConfig{ LoopConfig: runtime.LoopConfig{ Model: params.Model, Provider: providerName, Stream: provider.StreamFnFromProvider(prov), GetAPIKey: creds.GetAPIKey, }, Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}}, } // Wire hooks uniformly with every other driver (#425): the child sub-agent runs // its own PreToolUse/PostToolUse (and Stop) hooks from the trust-gated hook set // rooted at its working directory. It has no backing session, so SessionID is // empty (omitted from HookInput). A malformed hook layer disables hooks with a // warning rather than failing the child run. if set, herr := run.ResolveHookSet(cwd, run.Trusted(cwd)); herr != nil { fmt.Fprintf(os.Stderr, "pigo: hooks disabled: %v\n", herr) } else { run.InstallHooks(&runCfg, set, run.HookDeps{ProjectDir: cwd, WarnLog: os.Stderr}) } text, err := runtime.RunSubAgentOnce(ctx, params.SystemPrompt, params.Prompt, tools, runCfg) if err != nil { // A failed child run is an RPC error so the parent's defaultProcessCall // returns a Go error and executeProcess marks the tool result IsError, // matching goroutine mode's "failed run -> tool error" behavior. writeSubAgentError(enc, req.ID, -32000, err.Error()) return } result, _ := json.Marshal(runtime.SubAgentRunResult{Text: text}) _ = enc.Encode(jsonrpc.Response{JSONRPC: jsonrpc.Version, ID: req.ID, Result: result}) } // writeSubAgentError writes a JSON-RPC error response with the given id (which // may be nil for a parse error on an unidentifiable request) and code/message. func writeSubAgentError(enc *json.Encoder, id *jsonrpc.ID, code int, msg string) { _ = enc.Encode(jsonrpc.Response{ JSONRPC: jsonrpc.Version, ID: id, Error: &jsonrpc.Error{Code: code, Message: msg}, }) } // filterBuiltinTools returns the subset of tools whose Name is in names. An // empty names list keeps all tools. It lets a process-isolated sub-agent // restrict the child to a subset (e.g. a read-only researcher), matching // goroutine mode's Tools filtering, without serializing in-process tool objects // across the process boundary. func filterBuiltinTools(tools []agentcore.AgentTool, names []string) []agentcore.AgentTool { if len(names) == 0 { return tools } want := make(map[string]bool, len(names)) for _, n := range names { want[n] = true } var out []agentcore.AgentTool for _, t := range tools { if want[t.Name()] { out = append(out, t) } } return out }