206 lines
8.1 KiB
Go
206 lines
8.1 KiB
Go
// This file is the headless run driver: the print / stream-json run path
|
|
// (US-020) extracted from the CLI dispatch seam (#363). dispatch resolves the
|
|
// output mode and the run environment, then hands off to Run, which wires the
|
|
// session, prompt, thinking level, and provider credentials into a
|
|
// runtime.HeadlessConfig and executes one run. Plugin slash commands and output
|
|
// mode parsing live here because they are specific to the headless path.
|
|
package headless
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/cli/run"
|
|
"github.com/smallnest/pigo/internal/cli/ui"
|
|
"github.com/smallnest/pigo/internal/plugin"
|
|
"github.com/smallnest/pigo/internal/provider"
|
|
"github.com/smallnest/pigo/internal/runtime"
|
|
)
|
|
|
|
// RunParams carries the resolved inputs for one headless run. Mode and Env are
|
|
// resolved by the caller (dispatch) — Mode via ParseOutputMode, Env via
|
|
// run.SetupEnv — so their distinct exit codes stay at the call site; Run owns
|
|
// the rest of the run lifecycle.
|
|
type RunParams struct {
|
|
Mode runtime.HeadlessMode
|
|
Env run.Env
|
|
Prompt string
|
|
Model string
|
|
APIKey string
|
|
ThinkingLevel string
|
|
ResumeID string
|
|
}
|
|
|
|
// Run executes one headless run over p.Prompt, writing agent output to out and
|
|
// diagnostics to errOut, and returns a process exit code (0 = success). The run
|
|
// is backed by a session so its id appears in the first stream-json event and it
|
|
// can be resumed with --resume/--continue; a resumed session seeds its prior
|
|
// messages ahead of the new prompt.
|
|
func Run(ctx context.Context, p RunParams, out, errOut io.Writer) int {
|
|
env := p.Env
|
|
// Best-effort plugin slash-command support in headless mode: if the prompt is
|
|
// a "/cmd ..." naming a plugin command, invoke it, print its notifications to
|
|
// errOut, and use the returned prompt for this run (appending the raw args if
|
|
// the command produced no prompt). Headless has no turn injection, so
|
|
// appending the returned prompt is the accepted behavior. A non-plugin prompt
|
|
// or unknown command is left untouched.
|
|
headlessPrompt := resolveHeadlessPluginCommand(p.Prompt, env.Plugins, errOut)
|
|
promptContent, err := ui.BuildUserContent(headlessPrompt)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "pigo: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
// Back the headless run with a session so its id appears in the first
|
|
// stream-json event and the run can be resumed with --resume/--continue,
|
|
// matching the interactive REPL and pi/Claude Code. A resumed session seeds
|
|
// its prior messages ahead of the new prompt.
|
|
priorMsgs, hs, err := openHeadlessSession(p.ResumeID, p.Model, env.ProviderName, env.SysPrompt)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "pigo: %v\n", err)
|
|
return 1
|
|
}
|
|
messages := append(priorMsgs, agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: promptContent})
|
|
agentCtx := &agentcore.AgentContext{
|
|
SystemPrompt: hs.header.SystemPrompt,
|
|
Messages: messages,
|
|
Tools: env.Tools,
|
|
}
|
|
|
|
// Resolve the effective reasoning-effort level through the layered config
|
|
// chain (default < global < project < env < --thinking-level flag).
|
|
thinking, err := run.ResolveThinkingLevel(p.ThinkingLevel)
|
|
if err != nil {
|
|
fmt.Fprintf(errOut, "pigo: %v\n", err)
|
|
return 2
|
|
}
|
|
|
|
// Resolve the API key by provider name from the environment (never logged).
|
|
// An explicit --api-key overrides env/config for the resolved provider.
|
|
creds := provider.NewCredentialStore(nil)
|
|
creds.SetOverride(env.ProviderName, p.APIKey)
|
|
runCfg := run.NewConfig(p.Model, env.ProviderName, thinking, env.Provider, creds, run.ToolRegistry(env.Tools), run.TodoReminders(env.Tools))
|
|
runCfg.SessionID = hs.header.ID
|
|
// Route auto-compaction checkpoints to the shared memory root so a rebuild can
|
|
// recover the pre-watermark prefix (no-op when memory is disabled → empty root).
|
|
runCfg.MemoryRoot = run.MemoryRootFromTools(env.Tools)
|
|
|
|
// Wire hooks uniformly with every other driver (#425): resolve the trust-gated
|
|
// hook set, install the tool-execution + Stop seams, dispatch SessionStart, and
|
|
// chain the SessionEnd/PreCompact observer onto the plugin event notifier. A
|
|
// malformed hook layer is a config error (exit 2), matching thinking-level.
|
|
source := "startup"
|
|
if p.ResumeID != "" {
|
|
source = "resume"
|
|
}
|
|
set, herr := run.ResolveHookSet(env.Cwd, run.Trusted(env.Cwd))
|
|
if herr != nil {
|
|
fmt.Fprintf(errOut, "pigo: %v\n", herr)
|
|
return 2
|
|
}
|
|
hookDeps := run.HookDeps{SessionID: hs.header.ID, ProjectDir: env.Cwd, WarnLog: errOut}
|
|
// Deliver agent lifecycle events to any subscribed plugin (US-017, #133).
|
|
// NewEventNotifier returns nil when no plugin subscribes, so the base handler
|
|
// stays nil in the common no-plugin case.
|
|
var baseOnEvent func(agentcore.AgentEvent)
|
|
if n := plugin.NewEventNotifier(env.Plugins, errOut); n != nil {
|
|
baseOnEvent = n.Handle
|
|
}
|
|
d, onEvent := run.InstallDriverHooks(ctx, &runCfg, set, hookDeps, source, baseOnEvent)
|
|
// UserPromptSubmit runs before the prompt is handed to the loop: a block aborts
|
|
// the headless run non-zero; additionalContext is injected into this run only.
|
|
if d != nil {
|
|
if block, reason := run.DispatchUserPromptSubmit(ctx, d, &runCfg, hookDeps, headlessPrompt); block {
|
|
fmt.Fprintf(errOut, "pigo: prompt blocked by hook: %s\n", reason)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
cfg := runtime.HeadlessConfig{
|
|
Mode: p.Mode,
|
|
Out: out,
|
|
Run: runCfg,
|
|
}
|
|
cfg.OnEvent = onEvent
|
|
runErr := runtime.RunHeadless(ctx, agentCtx, cfg)
|
|
// Persist the run's messages regardless of run outcome so a partial run is
|
|
// still resumable; a persistence failure is reported but does not mask a run
|
|
// error.
|
|
if perr := hs.persist(agentCtx); perr != nil {
|
|
fmt.Fprintf(errOut, "pigo: warning: could not persist session %s: %v\n", hs.header.ID, perr)
|
|
}
|
|
if runErr != nil {
|
|
fmt.Fprintf(errOut, "pigo: %v\n", runErr)
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// resolveHeadlessPluginCommand gives the headless / print path best-effort
|
|
// support for plugin slash commands. When prompt is a "/cmd ..." naming a
|
|
// plugin command (from mgr.Commands()), it invokes the command, prints each
|
|
// returned notification to notifyOut, and returns the command's returned Prompt
|
|
// as the run's prompt. If the command returns no prompt, the raw argument text
|
|
// is used instead (so a bare "/cmd" with only notifications still runs
|
|
// something sensible rather than an empty prompt). Any other input — a
|
|
// non-command, an unknown command, or a call error — leaves prompt unchanged so
|
|
// the normal headless run proceeds. mgr may be nil (no plugins).
|
|
//
|
|
// Headless has no turn-injection loop, so "inject the returned prompt" degrades
|
|
// to "use the returned prompt for this run", which the acceptance criteria
|
|
// permit.
|
|
func resolveHeadlessPluginCommand(prompt string, mgr *plugin.Manager, notifyOut io.Writer) string {
|
|
if mgr == nil || !strings.HasPrefix(strings.TrimLeft(prompt, " \t"), "/") {
|
|
return prompt
|
|
}
|
|
trimmed := strings.TrimLeft(prompt, " \t")[1:]
|
|
name := trimmed
|
|
args := ""
|
|
if i := strings.IndexAny(trimmed, " \t"); i >= 0 {
|
|
name = trimmed[:i]
|
|
args = strings.TrimSpace(trimmed[i+1:])
|
|
}
|
|
for _, pc := range mgr.Commands() {
|
|
if pc.Spec.Name != name {
|
|
continue
|
|
}
|
|
// Encode the raw arg text as a JSON string (never null), matching the
|
|
// host's CommandCallParams.Args contract.
|
|
raw, _ := json.Marshal(args)
|
|
res, err := pc.Plugin.CallCommand(context.Background(), name, json.RawMessage(raw))
|
|
if err != nil {
|
|
fmt.Fprintf(notifyOut, "pigo: plugin command %q failed: %v\n", name, err)
|
|
return prompt
|
|
}
|
|
for _, n := range res.Notifications {
|
|
if n.Type != "" {
|
|
fmt.Fprintf(notifyOut, "[%s] %s\n", n.Type, n.Message)
|
|
} else {
|
|
fmt.Fprintln(notifyOut, n.Message)
|
|
}
|
|
}
|
|
if res.Prompt != "" {
|
|
return res.Prompt
|
|
}
|
|
return args
|
|
}
|
|
return prompt
|
|
}
|
|
|
|
// ParseOutputMode maps the --output-format flag onto a HeadlessMode, erroring on
|
|
// an unknown value.
|
|
func ParseOutputMode(outputFmt string) (runtime.HeadlessMode, error) {
|
|
switch outputFmt {
|
|
case "text", "":
|
|
return runtime.PrintMode, nil
|
|
case "stream-json":
|
|
return runtime.StreamJSONMode, nil
|
|
default:
|
|
return 0, fmt.Errorf("unknown --output-format %q (want text|stream-json)", outputFmt)
|
|
}
|
|
}
|