first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
// This file implements the /btw command (mirrors Claude Code's /btw and the pi
// agent extension @narumitw/pi-btw): a throwaway "side thread" for asking the
// model a quick side question that must NOT pollute the main conversation.
//
// /btw is intercepted in the REPL loop rather than routed through a slash Action
// closure because it must run an agent stream and read the live main context —
// none of which a pure string→string Action can do, exactly like /compact and
// /goal. It reaches the session's collaborators and mutable state through the
// cli.Host contract and reads follow-up lines through cli.Editor, so it need not
// import the concrete replDeps aggregate that assembles them.
//
// Isolation contract (the whole point of the feature): a side thread runs on a
// COPY of the main conversation as background, and its question/answer are only
// ever appended to that copy — never to host.AgentCtx().Messages. Nothing is
// persisted: no store.Save, no change to the persisted cursor / current leaf /
// header timestamp. Closing the side thread, switching sessions or restarting
// pigo discards everything.
//
// Scope: /btw is intercepted in the REPL loop and runs a side question against a
// copy of the main context (#279); it supports multi-turn follow-ups in the same
// ephemeral thread (#280), bare-/btw reopen of the most recent side thread this
// process (#281), and an optional model/thinking override config (#282, see
// btw_config.go) that affects only the side thread.
package btw
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/trust"
)
// btwHeader is the fixed banner shown when entering a side thread, so the user
// always knows the current input is a throwaway side question, not the main
// conversation (mirrors pi-btw's "btw · side thread" header).
const btwHeader = "btw · side thread"
// BtwHeader exposes the side-thread banner text so callers (and tests) can
// recognize it in output.
const BtwHeader = btwHeader
// btwPrompt is the input prompt shown for follow-up questions inside a side
// thread, distinguishing it from the main "pigo(model)>" prompt.
const btwPrompt = "btw> "
// RunBtw handles a /btw invocation. With an argument it starts a fresh side
// thread, asks that question, then enters a follow-up loop so the user can keep
// asking in the same ephemeral thread. Bare "/btw" reopens the most recent side
// thread from this process — replaying its Q&A history — and drops back into the
// follow-up loop; if none exists yet it guides the user to supply a question
// (US-004, #281). setCancel publishes the active run's cancel func so the REPL's
// SIGINT handler can interrupt the side run, reusing the same plumbing as a
// normal turn.
//
// The main context is never mutated: RunBtw builds a private side AgentContext
// seeded with a copy of the main messages, runs every turn against that copy,
// and returns without touching host.AgentCtx() or persisting anything. The side
// thread is retained in-process (host.LastBtw()) so a later bare /btw can reopen
// it, but it is never written to disk — restarting pigo discards it.
func RunBtw(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, editor cli.Editor, line string) {
question := strings.TrimSpace(strings.TrimPrefix(line, "/btw"))
// Resolve the side thread's model/thinking once per invocation from the
// session defaults overlaid with btw.json (#282). Re-read each call so an
// edit takes effect next time with no restart.
settings := ResolveBtwSettings(out, host)
if question == "" {
// Bare /btw: reopen the most recent side thread if one exists this process,
// replaying its history; otherwise guide the user to supply a question.
if host.LastBtw() == nil {
fmt.Fprintln(out, "usage: /btw <question> — ask a quick side question without touching the main conversation")
return
}
printBtwHeader(out)
replaySideHistory(out, host.LastBtw(), host.LastBtwBase())
if editor != nil {
btwFollowUpLoop(setCancel, out, host, editor, host.LastBtw(), settings)
}
return
}
side := NewSideContext(host.AgentCtx())
// Remember this thread so a later bare /btw can reopen it. LastBtwBase marks
// where the copied background ends and the side Q&A begins, so a reopen only
// replays the side turns, not the whole main transcript.
host.SetLastBtw(side)
host.SetLastBtwBase(len(side.Messages))
printBtwHeader(out)
AskSide(setCancel, out, host, side, settings, question)
// Follow-up loop: keep answering in the same ephemeral thread until the user
// exits. A nil editor (direct test callers that only ask one question) skips
// the loop entirely, so a single /btw asks exactly one question and returns.
if editor != nil {
btwFollowUpLoop(setCancel, out, host, editor, side, settings)
}
}
// replaySideHistory prints the side thread's own Q&A (everything after the
// copied main-conversation background at index base) when a bare /btw reopens a
// prior thread, so the user can browse earlier answers before continuing. Only
// user questions and assistant text are shown; tool activity is omitted to keep
// the recap compact.
func replaySideHistory(out io.Writer, side *agentcore.AgentContext, base int) {
if base > len(side.Messages) {
base = len(side.Messages)
}
for _, msg := range side.Messages[base:] {
switch m := msg.(type) {
case agentcore.UserMessage:
fmt.Fprintf(out, "%s %s\n", ui.Colorize(ui.Enabled(), ui.Dim, "you:"), agentcore.ContentToText(m.Content))
case agentcore.AssistantMessage:
if text := agentcore.ContentToText(m.Content); text != "" {
rendered := ui.RenderMarkdown(text)
fmt.Fprint(out, rendered)
if !strings.HasSuffix(rendered, "\n") {
fmt.Fprintln(out)
}
}
}
}
}
// btwFollowUpLoop reads follow-up questions and answers them in the same side
// context, so each answer sees the prior side Q&A (FR-4). It exits on /exit,
// /quit, EOF, or an idle Ctrl+C (errLineInterrupted) — the same exit affordances
// as the main REPL, but confined to the side thread (FR-5). A blank line is
// ignored (stays in the thread). Nothing here touches the main context.
func btwFollowUpLoop(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, editor cli.Editor, side *agentcore.AgentContext, settings BtwRunSettings) {
for {
raw, err := editor.ReadLine(btwPrompt)
if errors.Is(err, cli.ErrLineInterrupted) {
// Idle Ctrl+C at the side prompt leaves the thread (a Ctrl+C during a
// run is handled inside askSide via the SIGINT cancel plumbing).
fmt.Fprintln(out, "left side thread")
return
}
q := strings.TrimSpace(raw)
if err != nil && q == "" {
// EOF or read error with no partial line: leave the thread.
fmt.Fprintln(out, "left side thread")
return
}
if q == "/exit" || q == "/quit" {
fmt.Fprintln(out, "left side thread")
return
}
if q == "" {
continue
}
AskSide(setCancel, out, host, side, settings, q)
}
}
// NewSideContext builds the side thread's private AgentContext. Its Messages are
// a fresh slice seeded with a shallow COPY of the main messages (the elements
// are immutable value/interface messages, so a copied slice header is enough to
// guarantee appends to the side thread never reach the main context's Messages).
// The system prompt and tools are shared by value; only Messages diverges.
func NewSideContext(main *agentcore.AgentContext) *agentcore.AgentContext {
msgs := make(agentcore.MessageList, len(main.Messages))
copy(msgs, main.Messages)
return &agentcore.AgentContext{
SystemPrompt: main.SystemPrompt,
Messages: msgs,
Tools: main.Tools,
}
}
// printBtwHeader prints the side-thread banner.
func printBtwHeader(out io.Writer) {
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, btwHeader))
}
// AskSide appends the question to the side context and streams one answer,
// mirroring streamRun's rendering but targeting the side context so nothing is
// written back to the main conversation or to disk. It reuses the REPL's SIGINT
// cancel plumbing via setCancel. The model/provider/thinking come from settings
// (session defaults overlaid with btw.json, #282), never from host.Live(), so a
// /btw override cannot leak into the main session.
func AskSide(setCancel func(context.CancelFunc), out io.Writer, host cli.Host, side *agentcore.AgentContext, settings BtwRunSettings, question string) {
content, err := ui.BuildUserContent(question)
if err != nil {
fmt.Fprintf(out, "pigo: %v\n", err)
return
}
side.Messages = append(side.Messages, agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: content,
})
runCtx, cancel := context.WithCancel(context.Background())
setCancel(cancel)
defer func() {
cancel()
setCancel(nil)
}()
// Show a transient status while the model works (FR-9). It is printed on its
// own line; the streamed answer follows below it.
fmt.Fprintln(out, ui.Colorize(ui.Enabled(), ui.Dim, "Answering…"))
cfg := runtime.RunConfig{
LoopConfig: runtime.LoopConfig{
Model: settings.Model,
Provider: settings.ProviderName,
ThinkingLevel: settings.ThinkingLevel,
Stream: provider.StreamFnFromProvider(settings.Provider),
GetAPIKey: host.Creds().GetAPIKey,
ContextWindow: host.Live().ContextWindow,
Compaction: compaction.DefaultCompactionSettings,
},
Batch: agenttool.BatchConfig{
ToolExecutorConfig: agenttool.ToolExecutorConfig{
Registry: host.Registry(),
BeforeToolCall: trust.BeforeToolCall(host.Trust(), host.Cwd(), host.Input(), out, host.ConfirmMu()),
},
},
Reminders: host.Reminders(),
}
// Wire the per-turn hook seams onto the side run's cfg; nil dispatcher is a
// no-op (FR-18).
if d := host.Dispatcher(); d != nil {
run.InstallSeams(&cfg, d, host.HookDeps())
}
stream := runtime.StartRun(runCtx, side, cfg)
drainSideStream(runCtx, out, host, stream)
}
// chainBtwEvent returns the OnEvent observer for a /btw side run: the plugin
// notifier, with the SessionEnd/PreCompact hook notifier chained after it when
// hooks are configured, mirroring the REPL's OnEvent composition.
func chainBtwEvent(host cli.Host) func(agentcore.AgentEvent) {
notifier := host.NotifierHandle()
d := host.Dispatcher()
if d == nil {
return notifier
}
deps := host.HookDeps()
hookEvent := hooks.NewHookNotifier(d, deps.SessionID, deps.ProjectDir).Handle
if notifier == nil {
return hookEvent
}
return func(ev agentcore.AgentEvent) {
notifier(ev)
hookEvent(ev)
}
}
// drainSideStream prints the streamed assistant text and tool activity of a side
// run, mirroring streamRun/drainGoalStream. It blocks until the run ends. Unlike
// the main loop it persists nothing.
func drainSideStream(ctx context.Context, out io.Writer, host cli.Host, stream *runtime.LoopEventStream) {
var reply strings.Builder
flushReply := func() {
if reply.Len() == 0 {
return
}
rendered := ui.RenderMarkdown(reply.String())
fmt.Fprint(out, rendered)
if !strings.HasSuffix(rendered, "\n") {
fmt.Fprintln(out)
}
reply.Reset()
}
_, err := runtime.DrainStream(ctx, stream, runtime.StreamHandler{
OnEvent: chainBtwEvent(host),
OnText: func(delta string) {
reply.WriteString(delta)
},
OnTurnEnd: func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage) {
flushReply()
for _, c := range msg.ToolCalls() {
fmt.Fprintf(out, " %s %s\n", ui.Colorize(ui.Enabled(), ui.Green, "→ tool:"), ui.ToolCallLabel(c))
}
for _, tr := range results {
ui.RenderToolResult(out, tr)
}
},
})
flushReply()
if err != nil {
if ctx.Err() != nil {
// A Ctrl+C during the run cancels just this answer; the follow-up loop
// then returns to the btw prompt so the user can ask again or exit with
// another Ctrl+C (FR-5).
fmt.Fprintln(out, "^C interrupted — answer cancelled")
} else {
fmt.Fprintf(out, "error: %v\n", err)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
// This file implements the /btw model/thinking override config (US-005, #282):
// an optional per-command config that lets a side thread use a different model
// and/or reasoning effort than the main session, without touching the main
// session's settings (mirrors pi-btw's pi-btw.json).
//
// The config lives at $PIGO_HOME/btw.json (or ~/.pigo/btw.json). It is read
// fresh on every /btw invocation, so editing it takes effect on the next call
// with no restart. A missing file, an empty object, or an absent field all mean
// "inherit the session default" silently — only a malformed file or an
// unusable model override produces a (non-fatal) warning.
package btw
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/provider"
)
// btwConfig is the on-disk shape of ~/.pigo/btw.json. Both fields are optional;
// an absent field (nil / empty) inherits the session default. Pointers/empty
// strings distinguish "not set" from a real value so a partial file still falls
// back per-field.
type btwConfig struct {
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty"`
}
// BtwRunSettings is the resolved model/provider/thinking a side run uses. It is
// computed once per /btw invocation from the session defaults overlaid with
// btw.json, and passed down to AskSide so every turn of that invocation uses
// the same settings.
type BtwRunSettings struct {
Model string
ProviderName string
Provider provider.Provider
ThinkingLevel agentcore.ThinkingLevel
}
// btwConfigPath returns the path to the /btw override config, or "" when the
// config directory cannot be resolved (then the config is treated as absent).
func btwConfigPath() string {
dir := run.ConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, "btw.json")
}
// loadBtwConfig reads and parses btw.json. A missing file returns a zero config
// with no error (inherit everything). A malformed file returns an error so the
// caller can warn and fall back. An empty object parses to a zero config.
func loadBtwConfig(path string) (btwConfig, error) {
if path == "" {
return btwConfig{}, nil
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return btwConfig{}, nil
}
return btwConfig{}, err
}
var cfg btwConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return btwConfig{}, fmt.Errorf("parse %s: %w", path, err)
}
return cfg, nil
}
// ResolveBtwSettings computes the model/provider/thinking a side run should use.
// It starts from the session defaults (host.Live()) and overlays btw.json:
//
// - No config / empty object / absent fields → inherit the session values.
// - thinkingLevel set → validate and override (invalid value warns, falls back).
// - model set → resolve its provider (reusing resolveProvider like /model);
// if the model cannot be resolved/authenticated, warn on one line and fall
// back to the session model+provider.
//
// A malformed config file warns once and inherits everything. Nothing here
// mutates the session live config, so the override is confined to the side
// thread (FR-8).
func ResolveBtwSettings(out io.Writer, host cli.Host) BtwRunSettings {
live := host.Live()
s := BtwRunSettings{
Model: live.Model,
ProviderName: live.ProviderName,
Provider: live.Provider,
ThinkingLevel: live.ThinkingLevel,
}
cfg, err := loadBtwConfig(btwConfigPath())
if err != nil {
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, "btw: ignoring invalid btw.json: "+err.Error()))
return s
}
if lvl := strings.TrimSpace(cfg.ThinkingLevel); lvl != "" {
if v, ok := validThinkingLevel(lvl); ok {
s.ThinkingLevel = v
} else {
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, fmt.Sprintf("btw: ignoring invalid thinkingLevel %q, using %q", lvl, s.ThinkingLevel)))
}
}
if model := strings.TrimSpace(cfg.Model); model != "" && model != s.Model {
prov, providerName, perr := provider.ResolveProvider(model, live.BaseURL, live.Protocol, "", os.Getenv)
if perr != nil {
fmt.Fprintf(out, "%s\n", ui.Colorize(ui.Enabled(), ui.Dim, fmt.Sprintf("btw: cannot use model %q (%v), falling back to %q", model, perr, s.Model)))
} else {
s.Model = model
s.ProviderName = providerName
s.Provider = prov
}
}
return s
}
// validThinkingLevel reports whether s is one of the known reasoning-effort
// levels and returns the typed value. It mirrors the enum in agentcore so an
// invalid btw.json value can be rejected without importing the config layer.
func validThinkingLevel(s string) (agentcore.ThinkingLevel, bool) {
switch agentcore.ThinkingLevel(s) {
case agentcore.ThinkingOff, agentcore.ThinkingMinimal, agentcore.ThinkingLow,
agentcore.ThinkingMedium, agentcore.ThinkingHigh, agentcore.ThinkingXHigh, agentcore.ThinkingMax:
return agentcore.ThinkingLevel(s), true
default:
return "", false
}
}
+146
View File
@@ -0,0 +1,146 @@
package btw
// Tests for the /btw model/thinking override config (#282, US-005): btw.json
// overlays the session defaults for the side thread only, is read fresh each
// call, and falls back silently on missing/empty/partial config.
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli"
)
// fakeHost satisfies cli.Host by embedding the interface (so every method is
// present) while overriding only Live(), the sole accessor ResolveBtwSettings
// reads. The embedded nil interface would panic if any other method were
// called, which these tests never do.
type fakeHost struct {
cli.Host
live *cli.LiveConfig
}
func (f fakeHost) Live() *cli.LiveConfig { return f.live }
// withBtwConfig points PIGO_HOME at a temp dir and writes btw.json with the
// given contents (or removes it when contents is ""), returning nothing — the
// temp dir is cleaned up by t.TempDir. It restores PIGO_HOME after the test.
func withBtwConfig(t *testing.T, contents string) {
t.Helper()
dir := t.TempDir()
t.Setenv("PIGO_HOME", dir)
if contents != "" {
if err := os.WriteFile(filepath.Join(dir, "btw.json"), []byte(contents), 0o644); err != nil {
t.Fatalf("write btw.json: %v", err)
}
}
}
// TestBtwConfigAbsentInherits verifies that with no btw.json the side settings
// equal the session defaults.
func TestBtwConfigAbsentInherits(t *testing.T) {
withBtwConfig(t, "") // no file
live := &cli.LiveConfig{Model: "sess-model", ProviderName: "sess-prov", ThinkingLevel: agentcore.ThinkingMedium}
host := fakeHost{live: live}
var warn bytes.Buffer
s := ResolveBtwSettings(&warn, host)
if s.Model != live.Model || s.ProviderName != live.ProviderName {
t.Errorf("absent config must inherit model/provider, got %q/%q", s.Model, s.ProviderName)
}
if s.ThinkingLevel != agentcore.ThinkingMedium {
t.Errorf("absent config must inherit thinkingLevel, got %q", s.ThinkingLevel)
}
if warn.Len() != 0 {
t.Errorf("absent config must not warn, got %q", warn.String())
}
}
// TestBtwConfigEmptyObjectInherits verifies that an empty JSON object inherits
// everything without warning.
func TestBtwConfigEmptyObjectInherits(t *testing.T) {
withBtwConfig(t, "{}")
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
host := fakeHost{live: live}
var warn bytes.Buffer
s := ResolveBtwSettings(&warn, host)
if s.Model != live.Model || s.ThinkingLevel != agentcore.ThinkingLow {
t.Errorf("empty object must inherit, got model=%q thinking=%q", s.Model, s.ThinkingLevel)
}
if warn.Len() != 0 {
t.Errorf("empty object must not warn, got %q", warn.String())
}
}
// TestBtwConfigThinkingOverride verifies a valid thinkingLevel is applied while
// the model still inherits (partial config falls back per-field).
func TestBtwConfigThinkingOverride(t *testing.T) {
withBtwConfig(t, `{"thinkingLevel":"high"}`)
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
host := fakeHost{live: live}
var warn bytes.Buffer
s := ResolveBtwSettings(&warn, host)
if s.ThinkingLevel != agentcore.ThinkingHigh {
t.Errorf("expected thinkingLevel override 'high', got %q", s.ThinkingLevel)
}
if s.Model != live.Model {
t.Errorf("model must still inherit when only thinkingLevel is set, got %q", s.Model)
}
if warn.Len() != 0 {
t.Errorf("valid override must not warn, got %q", warn.String())
}
}
// TestBtwConfigInvalidThinkingWarnsAndFallsBack verifies an invalid thinkingLevel
// warns on one line and keeps the session value.
func TestBtwConfigInvalidThinkingWarnsAndFallsBack(t *testing.T) {
withBtwConfig(t, `{"thinkingLevel":"bogus"}`)
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingMedium}
host := fakeHost{live: live}
var warn bytes.Buffer
s := ResolveBtwSettings(&warn, host)
if s.ThinkingLevel != agentcore.ThinkingMedium {
t.Errorf("invalid thinkingLevel must fall back to session value, got %q", s.ThinkingLevel)
}
if !strings.Contains(warn.String(), "thinkingLevel") {
t.Errorf("expected a warning about the invalid thinkingLevel, got %q", warn.String())
}
}
// TestBtwConfigMalformedWarnsAndInherits verifies a malformed JSON file warns
// once and inherits every field (never crashes /btw).
func TestBtwConfigMalformedWarnsAndInherits(t *testing.T) {
withBtwConfig(t, `{not json`)
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
host := fakeHost{live: live}
var warn bytes.Buffer
s := ResolveBtwSettings(&warn, host)
if s.Model != live.Model || s.ThinkingLevel != agentcore.ThinkingLow {
t.Errorf("malformed config must inherit, got model=%q thinking=%q", s.Model, s.ThinkingLevel)
}
if !strings.Contains(warn.String(), "invalid btw.json") {
t.Errorf("expected a malformed-config warning, got %q", warn.String())
}
}
// TestBtwConfigDoesNotMutateSession verifies ResolveBtwSettings never mutates
// the session live config, so a /btw override cannot leak into the main session
// (FR-8).
func TestBtwConfigDoesNotMutateSession(t *testing.T) {
withBtwConfig(t, `{"thinkingLevel":"xhigh"}`)
live := &cli.LiveConfig{Model: "sess-model", ThinkingLevel: agentcore.ThinkingLow}
host := fakeHost{live: live}
_ = ResolveBtwSettings(&bytes.Buffer{}, host)
if live.ThinkingLevel != agentcore.ThinkingLow {
t.Errorf("session thinkingLevel must be unchanged by /btw config, got %q", live.ThinkingLevel)
}
}