Files
BlackBean/pigo/internal/runtime/headless.go
T
2026-08-14 23:41:57 +08:00

260 lines
9.2 KiB
Go

// This file implements the headless / stdio run modes (US-020, FR-18): a
// non-interactive driver that runs the agent loop over a single prompt for
// scripting and CI. Two output modes are supported, mirroring pi's print-mode
// and rpc/stream-json protocols:
//
// - PrintMode: run the loop to completion and write only the final assistant
// text to the output (the "-p / --print" mode).
// - StreamJSONMode: serialize every AgentEvent as a line-delimited JSON object
// as it is emitted (the "--output-format stream-json" mode), so a parent
// process can consume the run incrementally.
//
// The run's success/failure is reported as a returned error so the CLI can map
// it to a process exit code: a run whose final assistant message carries
// stopReason error/aborted, or whose stream result errors, is a failure.
package runtime
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// HeadlessMode selects how a headless run reports its progress and result.
type HeadlessMode int
const (
// PrintMode runs the loop to completion and writes only the final assistant
// text to the output writer.
PrintMode HeadlessMode = iota
// StreamJSONMode writes each AgentEvent as a line-delimited JSON object as it
// is emitted.
StreamJSONMode
)
// HeadlessConfig configures a headless run.
type HeadlessConfig struct {
// Run is the loop configuration (provider stream, tools, hooks).
Run RunConfig
// Mode selects print vs stream-json output. Defaults to PrintMode.
Mode HeadlessMode
// Out receives the run output (final text or JSON lines). Required.
Out io.Writer
// OnEvent, when non-nil, is invoked for every AgentEvent before output
// handling. It is the seam plugin lifecycle-event delivery (US-017, #133)
// hooks into, independent of the output mode. It must not block.
OnEvent func(ev agentcore.AgentEvent)
// Progress receives human-readable sub-agent progress lines
// (SubAgentProgressEvent). Defaults to os.Stderr when nil. Progress is
// stderr-only by contract: it is never serialised onto Out (stdout), so it
// cannot pollute the final result text or the stream-json envelope stream.
Progress io.Writer
}
// ErrRunFailed is the sentinel returned by RunHeadless when the agent run ended
// in a failure state (stopReason error/aborted). The CLI maps a non-nil error
// to a non-zero exit code.
type ErrRunFailed struct {
// Reason is the stopReason (or message) that marked the run as failed.
Reason string
}
func (e *ErrRunFailed) Error() string {
if e.Reason == "" {
return "agent run failed"
}
return "agent run failed: " + e.Reason
}
// RunHeadless runs the agent loop for the already-assembled agentCtx and drives
// output per cfg.Mode. It blocks until the run ends and returns nil on success
// or an error describing the failure (for exit-code mapping). It never returns
// before the stream is fully drained, so no goroutine is leaked.
func RunHeadless(ctx context.Context, agentCtx *agentcore.AgentContext, cfg HeadlessConfig) error {
if cfg.Out == nil {
return fmt.Errorf("headless: nil output writer")
}
stream := agentLoop(ctx, agentCtx, cfg.Run)
// writeErr holds the first stream-json write failure. We keep draining after
// it (DrainStream never returns early) so the loop's producer goroutine never
// blocks on a synchronous Emit — honoring the no-leak contract on a broken
// pipe. In stream-json mode every event is serialised; print mode only needs
// the final message, which DrainStream returns.
var writeErr error
h := StreamHandler{}
// Compose the output-mode serialiser (stream-json only) with the optional
// external OnEvent (plugin lifecycle delivery, US-017). Both observe every
// event; the serialiser runs first so a write failure is recorded even when a
// plugin observer is also wired.
//
// SubAgentProgressEvent (D-9) is special-cased: it is written as a
// human-readable line to the progress writer (stderr) and is deliberately
// excluded from the stream-json stdout path so it never pollutes the result
// output or the machine-readable envelope stream (progress is stderr-only).
progress := cfg.Progress
if progress == nil {
progress = os.Stderr
}
streamJSON := cfg.Mode == StreamJSONMode
h.OnEvent = func(ev agentcore.AgentEvent) {
if pe, ok := ev.(agentcore.SubAgentProgressEvent); ok {
writeProgressLine(progress, pe)
if cfg.OnEvent != nil {
cfg.OnEvent(ev)
}
return
}
if streamJSON && writeErr == nil {
if err := writeEventJSON(cfg.Out, ev); err != nil {
writeErr = err
}
}
if cfg.OnEvent != nil {
cfg.OnEvent(ev)
}
}
lastAssistant, resErr := DrainStream(ctx, stream, h)
if writeErr != nil {
return writeErr
}
if resErr != nil {
return resErr
}
if cfg.Mode == PrintMode {
text := ""
if lastAssistant != nil {
text = agentcore.ContentToText(lastAssistant.Content)
}
if _, err := io.WriteString(cfg.Out, text); err != nil {
return err
}
if text != "" && !strings.HasSuffix(text, "\n") {
if _, err := io.WriteString(cfg.Out, "\n"); err != nil {
return err
}
}
}
if lastAssistant != nil {
switch lastAssistant.StopReason {
case agentcore.StopReasonError:
reason := lastAssistant.ErrorMessage
if reason == "" {
reason = "error"
}
return &ErrRunFailed{Reason: reason}
case agentcore.StopReasonAborted:
return &ErrRunFailed{Reason: "aborted"}
}
}
return nil
}
// writeProgressLine renders one SubAgentProgressEvent as a human-readable line
// on w (stderr by contract). When the task supplied a description it is shown
// alongside the activity; otherwise the line degrades to the activity alone
// (Description MAY be empty, Activity never is). Write errors are ignored:
// progress is a non-critical, best-effort side channel.
func writeProgressLine(w io.Writer, ev agentcore.SubAgentProgressEvent) {
if ev.Description != "" {
fmt.Fprintf(w, " ⏺ %s · %s\n", ev.Description, ev.Activity)
return
}
fmt.Fprintf(w, " ⏺ %s\n", ev.Activity)
}
// writeEventJSON serializes one AgentEvent as a single line of JSON, terminated
// by a newline, onto w. The envelope always carries a "type" discriminant so a
// consumer can dispatch without positional knowledge.
func writeEventJSON(w io.Writer, ev agentcore.AgentEvent) error {
env := eventEnvelope(ev)
b, err := json.Marshal(env)
if err != nil {
return fmt.Errorf("headless: marshal event: %w", err)
}
b = append(b, '\n')
_, err = w.Write(b)
return err
}
// eventEnvelope maps an AgentEvent onto a JSON-serializable object with a
// "type" discriminant plus the event's observable payload. Only fields that are
// safe and useful over the wire are included (assistant text, tool ids/names,
// stop reasons) — never secrets.
func eventEnvelope(ev agentcore.AgentEvent) map[string]any {
env := map[string]any{"type": ev.EventType()}
switch e := ev.(type) {
case agentcore.AgentStartEvent:
// The first event carries the backing session id (mirrors pi/Claude Code),
// so a consumer can associate the run's output with a session and resume
// it later. Omitted only when the run has no backing session (SessionID
// unset), which the envelope treats as "not resumable".
if e.SessionID != "" {
env["sessionId"] = e.SessionID
}
case agentcore.AgentEndEvent:
env["messageCount"] = len(e.Messages)
case agentcore.TurnEndEvent:
env["stopReason"] = e.Message.StopReason
if text := agentcore.ContentToText(e.Message.Content); text != "" {
env["text"] = text
}
if calls := e.Message.ToolCalls(); len(calls) > 0 {
names := make([]string, len(calls))
for i, c := range calls {
names[i] = c.Name
}
env["toolCalls"] = names
}
case agentcore.MessageUpdateEvent:
if a, ok := e.Message.(agentcore.AssistantMessage); ok {
if text := agentcore.ContentToText(a.Content); text != "" {
env["text"] = text
}
}
case agentcore.ToolExecutionStartEvent:
env["toolCallId"] = e.ToolCallID
env["toolName"] = e.ToolName
case agentcore.ToolExecutionEndEvent:
env["toolCallId"] = e.ToolCallID
env["toolName"] = e.ToolName
env["isError"] = e.IsError
case agentcore.CompactionStartEvent:
env["reason"] = e.Reason
env["tokensBefore"] = e.TokensBefore
case agentcore.CompactionEvent:
env["reason"] = e.Reason
env["tokensBefore"] = e.TokensBefore
env["tokensAfter"] = e.TokensAfter
env["summarizedCount"] = e.SummarizedCount
env["keptCount"] = e.KeptCount
if e.ErrorMessage != "" {
env["error"] = e.ErrorMessage
}
case agentcore.TelemetryEvent:
// The run-end telemetry summary: structured metrics a script can read
// directly from the stream-json output (observability -- structured telemetry collection). Per-tool
// timings are flattened into a name→{count,totalMs} object so a JSON
// consumer can index by tool name.
env["turns"] = e.Turns
env["truncationCount"] = e.TruncationCount
env["compactionCount"] = e.CompactionCount
env["contextUtilization"] = e.ContextUtilization
env["contextTokens"] = e.ContextTokens
env["contextWindow"] = e.ContextWindow
tools := make(map[string]map[string]any, len(e.ToolDurationsMs))
for name, t := range e.ToolDurationsMs {
tools[name] = map[string]any{"count": t.Count, "totalMs": t.TotalMs}
}
env["toolDurationsMs"] = tools
}
return env
}