first commit
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user