260 lines
9.2 KiB
Go
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
|
|
}
|