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