mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
Keep built-in models visible when merging models.json, accept custom provider API keys through the login flow, preserve model-level base URLs, and route custom clients through the scoped HTTP wrapper. Also register providers from model-level baseUrl metadata, warn on unknown api values, dedupe login picker entries, and cover the custom-provider behavior with regression tests. Co-authored-by: pulyankote <4314305+pulyankote@users.noreply.github.com>
1308 lines
42 KiB
Go
1308 lines
42 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/patriceckhart/zot/packages/agent/extensions"
|
|
"github.com/patriceckhart/zot/packages/agent/extproto"
|
|
"github.com/patriceckhart/zot/packages/agent/modes"
|
|
"github.com/patriceckhart/zot/packages/agent/skills"
|
|
"github.com/patriceckhart/zot/packages/agent/swarm"
|
|
"github.com/patriceckhart/zot/packages/agent/tools"
|
|
"github.com/patriceckhart/zot/packages/core"
|
|
"github.com/patriceckhart/zot/packages/provider"
|
|
"github.com/patriceckhart/zot/packages/provider/auth"
|
|
"github.com/patriceckhart/zot/packages/tui"
|
|
)
|
|
|
|
// interactiveExtHooks is a tiny adapter that lets the extension
|
|
// manager call back into the Interactive instance built later in
|
|
// runInteractive. The forward-declared *modes.Interactive is filled
|
|
// in immediately after manager construction.
|
|
type interactiveExtHooks struct {
|
|
ivPtr **modes.Interactive
|
|
}
|
|
|
|
func (h *interactiveExtHooks) iv() *modes.Interactive {
|
|
if h == nil || h.ivPtr == nil {
|
|
return nil
|
|
}
|
|
return *h.ivPtr
|
|
}
|
|
|
|
func (h *interactiveExtHooks) Notify(extName, level, message string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.Notify(extName, level, message)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) Submit(text string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.Submit(text)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) SubmitSlash(text string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.SubmitSlash(text)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) Insert(text string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.Insert(text)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) Display(extName, text string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.Display(extName, text)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) ClearNotes(extName string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.ClearNotes(extName)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.OpenPanel(extName, spec)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.UpdatePanel(extName, panelID, title, lines, footer)
|
|
}
|
|
}
|
|
func (h *interactiveExtHooks) ClosePanel(extName, panelID string) {
|
|
if iv := h.iv(); iv != nil {
|
|
iv.ClosePanel(extName, panelID)
|
|
}
|
|
}
|
|
|
|
// extToolAdapter bridges *extensions.Manager to the
|
|
// ExtensionToolSource interface declared in build.go (kept narrow to
|
|
// avoid a build->extensions import cycle). One adapter instance per
|
|
// run; used at every Resolve point so re-built agents pick up the
|
|
// same set of extension tools.
|
|
type extToolAdapter struct {
|
|
mgr *extensions.Manager
|
|
}
|
|
|
|
func (a *extToolAdapter) Tools() []ExtensionToolInfo {
|
|
infos := a.mgr.Tools()
|
|
out := make([]ExtensionToolInfo, len(infos))
|
|
for i, t := range infos {
|
|
out[i] = ExtensionToolInfo{
|
|
Extension: t.Extension,
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Schema: t.Schema,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
|
|
return extensions.NewTool(a.mgr, extensions.ToolInfo{
|
|
Extension: info.Extension,
|
|
Name: info.Name,
|
|
Description: info.Description,
|
|
Schema: info.Schema,
|
|
})
|
|
}
|
|
|
|
// fanoutAgentEvent translates a core.AgentEvent into the wire-format
|
|
// EventFromHost and pushes it through the extension manager. Only
|
|
// the events that have a clear extension-facing meaning are
|
|
// forwarded; internal-only ones (text_delta, tool_progress) are
|
|
// dropped to keep the per-extension stream sane.
|
|
func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
|
|
if keepTail <= 0 || len(msgs) <= keepTail {
|
|
return provider.RepairOrphanedToolResults(msgs)
|
|
}
|
|
var out []provider.Message
|
|
start := len(msgs) - keepTail
|
|
// Preserve the synthetic compaction summary when present so an
|
|
// already-compacted session stays compacted after resume.
|
|
if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" && start > 1 {
|
|
out = append(out, msgs[0])
|
|
}
|
|
// Avoid hydrating a tail that starts with orphan tool_result rows;
|
|
// provider APIs require those to be paired with an earlier tool_use.
|
|
for start < len(msgs) && msgs[start].Role == provider.RoleTool {
|
|
start++
|
|
}
|
|
out = append(out, msgs[start:]...)
|
|
return provider.RepairOrphanedToolResults(out)
|
|
}
|
|
|
|
func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
|
|
if mgr == nil {
|
|
return
|
|
}
|
|
switch e := ev.(type) {
|
|
case core.EvTurnStart:
|
|
mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
|
|
case core.EvToolCall:
|
|
mgr.EmitEvent(extproto.EventFromHost{
|
|
Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
|
|
})
|
|
case core.EvAssistantMessage:
|
|
// Concat the visible text portions of the message; binary
|
|
// blocks (tool_use, etc.) are skipped because subscribers
|
|
// usually want a string they can grep / display.
|
|
var text string
|
|
for _, c := range e.Message.Content {
|
|
if tb, ok := c.(provider.TextBlock); ok {
|
|
text += tb.Text
|
|
}
|
|
}
|
|
mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
|
|
case core.EvTurnEnd:
|
|
ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
|
|
if e.Err != nil {
|
|
ev.Error = e.Err.Error()
|
|
}
|
|
mgr.EmitEvent(ev)
|
|
}
|
|
}
|
|
|
|
// Run is the top-level entrypoint for the zot binary.
|
|
func Run(rawArgs []string, version string) error {
|
|
// Subcommand router: `zot bot ...` is handled separately so the
|
|
// generic flag parser doesn't reject "bot" as a positional arg.
|
|
if handled, err := runBotCommand(rawArgs, version); handled {
|
|
return err
|
|
}
|
|
if handled, err := runExtCommand(rawArgs); handled {
|
|
return err
|
|
}
|
|
if handled, err := runUpdateCommand(rawArgs, version); handled {
|
|
return err
|
|
}
|
|
// `zot rpc` is shorthand for `zot --rpc` so third-party apps can
|
|
// spawn the binary with a clean argv. Strip the leading 'rpc'
|
|
// token and let the rest flow through the normal arg parser.
|
|
if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
|
|
rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
|
|
}
|
|
|
|
args, err := ParseArgs(rawArgs)
|
|
if err != nil {
|
|
PrintHelp(version)
|
|
return err
|
|
}
|
|
if args.Help {
|
|
PrintHelp(version)
|
|
return nil
|
|
}
|
|
if args.Version {
|
|
fmt.Println("zot", version)
|
|
return nil
|
|
}
|
|
// Model catalog: load any cached discovery data before we inspect
|
|
// the model list (list-models, print/json, interactive).
|
|
LoadCachedModels()
|
|
LoadUserModels()
|
|
|
|
// Register custom provider names with the auth package so they
|
|
// participate in /login API-key flow and provider pickers.
|
|
if cps := provider.CustomProviders(); len(cps) > 0 {
|
|
names := make([]string, 0, len(cps))
|
|
for name := range cps {
|
|
if !isBuiltinProvider(name) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
auth.SetExtraAPIKeyProviders(names)
|
|
}
|
|
|
|
// Repair config.json so a stale (provider, model) pair from an
|
|
// interrupted /model switch can't strand the user with an
|
|
// "unknown model" error on the first turn. Runs before any UI
|
|
// renders so the status bar shows the post-repair pair, not the
|
|
// broken one. Silent on success.
|
|
ValidateAndRepairConfig()
|
|
|
|
if args.ListModels {
|
|
printModels()
|
|
return nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Kick an async refresh of the live model catalog. The first run of
|
|
// zot hits the network; subsequent runs within CacheTTL do nothing.
|
|
RefreshModelsAsync()
|
|
|
|
switch args.Mode {
|
|
case ModePrint:
|
|
return runPrintMode(ctx, args, version)
|
|
case ModeJSON:
|
|
return runJSONMode(ctx, args, version)
|
|
case ModeRPC:
|
|
return runRPCMode(ctx, args, version)
|
|
case ModeSwarmAgent:
|
|
return runSwarmAgentMode(ctx, args, version)
|
|
default:
|
|
return runInteractive(ctx, args, version)
|
|
}
|
|
}
|
|
|
|
// ---- print / json modes: require credentials, run single-shot ----
|
|
|
|
// nonInteractiveExtHooks is the HostHooks impl used by print / json
|
|
// modes. They have no TUI, so notify / display go to stderr and
|
|
// submit / insert are no-ops (the extension can't steer a
|
|
// single-shot run once it's in flight anyway).
|
|
type nonInteractiveExtHooks struct{}
|
|
|
|
func (nonInteractiveExtHooks) Notify(ext, level, message string) {
|
|
fmt.Fprintf(os.Stderr, "[%s] %s: %s\n", ext, level, message)
|
|
}
|
|
func (nonInteractiveExtHooks) Submit(string) {}
|
|
func (nonInteractiveExtHooks) SubmitSlash(string) {}
|
|
func (nonInteractiveExtHooks) Insert(string) {}
|
|
func (nonInteractiveExtHooks) Display(string, string) {}
|
|
func (nonInteractiveExtHooks) ClearNotes(string) {}
|
|
func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
|
|
func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
|
|
func (nonInteractiveExtHooks) ClosePanel(string, string) {}
|
|
|
|
// setupNonInteractiveExtensions loads --ext paths and (unless
|
|
// --no-ext) runs discovery. Returns the manager so the caller can
|
|
// wire tools into the resolved registry, and a cleanup closure to
|
|
// defer. Mirrors the interactive-mode setup minus the TUI hooks.
|
|
func setupNonInteractiveExtensions(ctx context.Context, args Args, r *Resolved, version string) (*extensions.Manager, func()) {
|
|
extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, nonInteractiveExtHooks{})
|
|
for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
|
|
fmt.Fprintln(os.Stderr, "extension load:", e)
|
|
}
|
|
if !args.NoExt {
|
|
for _, e := range extMgr.Discover(ctx) {
|
|
fmt.Fprintln(os.Stderr, "extension load:", e)
|
|
}
|
|
}
|
|
extMgr.WaitForReady(3 * time.Second)
|
|
r.MergeExtensionTools(&extToolAdapter{mgr: extMgr})
|
|
extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
|
|
return extMgr, func() { extMgr.Stop(2 * time.Second) }
|
|
}
|
|
|
|
// wireNonInteractiveAgentExtHooks installs the same BeforeToolExecute
|
|
// / BeforeTurn / BeforeAssistantMessage / OnEvent hooks the
|
|
// interactive path wires up, so extensions get their normal
|
|
// event-intercept surface in print / json / rpc flows too.
|
|
func wireNonInteractiveAgentExtHooks(ctx context.Context, ag *core.Agent, extMgr *extensions.Manager) {
|
|
if ag == nil || extMgr == nil {
|
|
return
|
|
}
|
|
ag.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
|
|
res := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
|
|
if res.Block {
|
|
return false, res.Reason, nil
|
|
}
|
|
return true, "", res.ModifiedArgs
|
|
}
|
|
ag.BeforeTurn = func(step int) (bool, string) {
|
|
res := extMgr.InterceptTurnStart(ctx, step)
|
|
return !res.Block, res.Reason
|
|
}
|
|
ag.BeforeAssistantMessage = func(text string) (bool, string, string) {
|
|
res := extMgr.InterceptAssistantMessage(ctx, text)
|
|
if res.Block {
|
|
return false, res.Reason, ""
|
|
}
|
|
return true, "", res.ReplaceText
|
|
}
|
|
ag.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
|
|
}
|
|
|
|
func runPrintMode(ctx context.Context, args Args, version string) error {
|
|
if args.NoYolo {
|
|
fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
|
|
}
|
|
r, err := Resolve(args, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
|
|
defer stopExt()
|
|
|
|
ag := r.NewAgent()
|
|
wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
|
|
sess, _ := openOrCreateSession(args, r, ag, version)
|
|
defer sess.Close()
|
|
|
|
prompt := args.Prompt
|
|
if prompt == "" {
|
|
piped, _ := readAllStdin()
|
|
prompt = strings.TrimSpace(piped)
|
|
}
|
|
if prompt == "" {
|
|
return fmt.Errorf("print mode requires a prompt (arg or stdin)")
|
|
}
|
|
|
|
start := len(ag.Messages())
|
|
err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
|
|
WriteNewTranscript(ag, sess, start)
|
|
return err
|
|
}
|
|
|
|
func runJSONMode(ctx context.Context, args Args, version string) error {
|
|
if args.NoYolo {
|
|
fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
|
|
}
|
|
r, err := Resolve(args, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version)
|
|
defer stopExt()
|
|
|
|
ag := r.NewAgent()
|
|
wireNonInteractiveAgentExtHooks(ctx, ag, extMgr)
|
|
sess, _ := openOrCreateSession(args, r, ag, version)
|
|
defer sess.Close()
|
|
|
|
prompt := args.Prompt
|
|
if prompt == "" {
|
|
piped, _ := readAllStdin()
|
|
prompt = strings.TrimSpace(piped)
|
|
}
|
|
if prompt == "" {
|
|
return fmt.Errorf("json mode requires a prompt (arg or stdin)")
|
|
}
|
|
|
|
start := len(ag.Messages())
|
|
err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
|
|
WriteNewTranscript(ag, sess, start)
|
|
return err
|
|
}
|
|
|
|
// ---- interactive mode: opens the TUI even without credentials ----
|
|
|
|
func runInteractive(ctx context.Context, args Args, version string) error {
|
|
// Resolve WITHOUT requiring credentials.
|
|
r, err := Resolve(args, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
authStore := AuthStoreFor()
|
|
mgr := auth.NewManager(authStore)
|
|
defer mgr.Close()
|
|
|
|
// Keep the sandbox pointer stable across agent rebuilds (login / model
|
|
// switch). The Interactive UI toggles the lock via this pointer, and
|
|
// rebuilt tool instances must share the same one so the lock sticks.
|
|
sharedSandbox := r.Sandbox
|
|
|
|
// Build the extension manager BEFORE the agent so we can fold
|
|
// extension-defined tools into the registry. Forward-declare iv so
|
|
// the host hooks adapter can dereference it after construction.
|
|
var iv *modes.Interactive
|
|
extHooks := &interactiveExtHooks{ivPtr: &iv}
|
|
extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
|
|
// --ext paths first so they win against installed extensions of
|
|
// the same name (loadOne's first-write-wins semantics).
|
|
for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
|
|
fmt.Fprintln(os.Stderr, "extension load:", e)
|
|
}
|
|
// --no-ext skips the global + project-local discovery scan;
|
|
// explicit --ext paths above are still honoured so you can run
|
|
// "only this extension" with --no-ext --ext ./x.
|
|
if !args.NoExt {
|
|
for _, e := range extMgr.Discover(ctx) {
|
|
fmt.Fprintln(os.Stderr, "extension load:", e)
|
|
}
|
|
}
|
|
// Wait briefly for extensions to flush their initial register_tool
|
|
// frames before we build the agent's tool registry. Half a second
|
|
// is plenty for any extension that's actually well-behaved; ones
|
|
// that don't send a ready frame eat the full grace and proceed.
|
|
// 3s is the per-extension grace period for the ready frame.
|
|
// Native binaries are instant; runtimes like `npx tsx` take ~1.5s
|
|
// from cold cache. The wait is tight only for extensions that
|
|
// haven't sent ready by then; ones that signalled earlier release
|
|
// the wait immediately.
|
|
extMgr.WaitForReady(3 * time.Second)
|
|
defer extMgr.Stop(2 * time.Second)
|
|
|
|
extToolAdapter := &extToolAdapter{mgr: extMgr}
|
|
r.MergeExtensionTools(extToolAdapter)
|
|
|
|
// Build the swarm supervisor BEFORE the agent so the auto-swarm
|
|
// tool can reference it during tool-registry construction. State
|
|
// lives under ZotHome/swarm so per-agent meta/events survive
|
|
// restarts; the user can hunt orphaned agents down with
|
|
// `git worktree list` if anything misbehaves.
|
|
//
|
|
// swarmMgr is also captured by loadSession / changeCWD closures
|
|
// further down the function, which is why we keep the variable
|
|
// in this outer scope rather than scoping it tighter.
|
|
var swarmMgr *swarm.Swarm
|
|
swarmMgr = swarm.New(swarm.Config{
|
|
Root: filepath.Join(ZotHome(), "swarm"),
|
|
RepoRoot: r.CWD,
|
|
})
|
|
// Pull any previously-spawned agents off disk so the dashboard
|
|
// shows them as detached and the user can resume / remove them.
|
|
_, _ = swarmMgr.Reload()
|
|
|
|
// onSpawnedSwarm is the OnSpawned callback the swarm_spawn tool
|
|
// fires after every successful spawn. It hands the agent off to
|
|
// the running Interactive so the watcher can flush a summary back
|
|
// into chat when all sub-agents finish. Reads `iv` lazily because
|
|
// the Interactive is constructed after the agent.
|
|
onSpawnedSwarm := func(a *swarm.Agent, task string) {
|
|
if iv != nil {
|
|
iv.TrackSwarmAgent(a, task)
|
|
}
|
|
}
|
|
|
|
// Inject the swarm_spawn auto-swarm tool only when /settings ->
|
|
// auto-swarm is currently enabled. Registering it unconditionally
|
|
// leaves the model trying to call it (and getting a polite error)
|
|
// even when the user has switched the feature off. The /settings
|
|
// toggle live-mutates the running agent's registry separately so
|
|
// flipping the flag mid-session takes effect on the next turn.
|
|
injectSwarmSpawn := func(reg core.Registry) core.Registry {
|
|
if reg == nil {
|
|
return reg
|
|
}
|
|
if !AutoSwarmEnabled() {
|
|
return reg
|
|
}
|
|
reg["swarm_spawn"] = &tools.SwarmSpawnTool{
|
|
Swarm: swarmMgr,
|
|
Enabled: AutoSwarmEnabled,
|
|
OnSpawned: onSpawnedSwarm,
|
|
}
|
|
return reg
|
|
}
|
|
injectSwarmSpawn(r.ToolRegistry)
|
|
|
|
// Confirmation gate: when --no-yolo is on, the agent must ask
|
|
// the user before every tool call. In interactive mode the TUI
|
|
// provides the Confirmer; in print/json/rpc modes there's no
|
|
// way to prompt, so the gate is constructed with a nil inner
|
|
// which auto-refuses every call with a helpful reason.
|
|
var confirmGate *core.ConfirmGate
|
|
if args.NoYolo {
|
|
confirmGate = core.NewConfirmGate(nil) // set below for interactive
|
|
}
|
|
|
|
// Capture current args in a closure so BuildAgent can re-resolve
|
|
// after a successful login (picks up the newly stored credential).
|
|
wireAgentExt := func(a *core.Agent) *core.Agent {
|
|
if a == nil {
|
|
return a
|
|
}
|
|
a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
|
|
// Confirm gate runs FIRST: if the user refused, we don't
|
|
// waste extension-intercept time or let guards see the call.
|
|
if confirmGate != nil {
|
|
ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
|
|
if !ok {
|
|
return false, reason, nil
|
|
}
|
|
}
|
|
r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
|
|
if r.Block {
|
|
return false, r.Reason, nil
|
|
}
|
|
return true, "", r.ModifiedArgs
|
|
}
|
|
a.BeforeTurn = func(step int) (bool, string) {
|
|
r := extMgr.InterceptTurnStart(ctx, step)
|
|
return !r.Block, r.Reason
|
|
}
|
|
a.BeforeAssistantMessage = func(text string) (bool, string, string) {
|
|
r := extMgr.InterceptAssistantMessage(ctx, text)
|
|
if r.Block {
|
|
return false, r.Reason, ""
|
|
}
|
|
return true, "", r.ReplaceText
|
|
}
|
|
a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
|
|
return a
|
|
}
|
|
|
|
buildAgent := func() (*core.Agent, string, string, error) {
|
|
resolved, err := Resolve(args, true)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
resolved.UseSandbox(sharedSandbox)
|
|
resolved.MergeExtensionTools(extToolAdapter)
|
|
injectSwarmSpawn(resolved.ToolRegistry)
|
|
return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
|
|
}
|
|
|
|
// Rebuild agent with an explicit provider/model override.
|
|
buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
|
|
next := args
|
|
if providerOverride != "" {
|
|
next.Provider = providerOverride
|
|
}
|
|
if modelOverride != "" {
|
|
next.Model = modelOverride
|
|
}
|
|
resolved, err := Resolve(next, true)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
resolved.UseSandbox(sharedSandbox)
|
|
resolved.MergeExtensionTools(extToolAdapter)
|
|
injectSwarmSpawn(resolved.ToolRegistry)
|
|
return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
|
|
}
|
|
|
|
// Rebuild agent for the rescue picker after a recoverable failure.
|
|
// Unlike buildAgentFor, this drops launch-time --api-key and
|
|
// --base-url overrides because those are typically the cause of the
|
|
// rescue (a bad key, a typo'd base URL, or a corporate gateway that
|
|
// only the originally-picked provider needed). Re-resolving without
|
|
// them lets the rescue retry use env vars / auth.json / provider
|
|
// defaults the way zot would have without the overrides.
|
|
buildAgentForRescue := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
|
|
next := args
|
|
next.APIKey = ""
|
|
next.BaseURL = ""
|
|
if providerOverride != "" {
|
|
next.Provider = providerOverride
|
|
}
|
|
if modelOverride != "" {
|
|
next.Model = modelOverride
|
|
}
|
|
resolved, err := Resolve(next, true)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
resolved.UseSandbox(sharedSandbox)
|
|
resolved.MergeExtensionTools(extToolAdapter)
|
|
injectSwarmSpawn(resolved.ToolRegistry)
|
|
return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
|
|
}
|
|
|
|
var ag *core.Agent
|
|
if r.HasCredential() {
|
|
ag = wireAgentExt(r.NewAgent())
|
|
}
|
|
|
|
// /reload-ext callback: after the manager has respawned every
|
|
// extension, re-resolve the tool registry (built-ins + freshly-
|
|
// registered extension tools) and swap it onto the current
|
|
// agent in-place. The current agent may have been replaced by a
|
|
// /model swap since spawn, so re-read the live `ag` on each
|
|
// invocation.
|
|
extMgr.SetOnReload(func() {
|
|
current := ag
|
|
if current == nil {
|
|
return
|
|
}
|
|
resolved, err := Resolve(args, true)
|
|
if err != nil {
|
|
return
|
|
}
|
|
resolved.UseSandbox(sharedSandbox)
|
|
resolved.MergeExtensionTools(extToolAdapter)
|
|
injectSwarmSpawn(resolved.ToolRegistry)
|
|
current.SetTools(resolved.ToolRegistry)
|
|
})
|
|
|
|
// Fire session_start once we know the manager's running.
|
|
extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
|
|
|
|
var sess *core.Session
|
|
var sessBaselineMsgs int // messages already on disk when current session opened
|
|
// persistMu guards sess + sessBaselineMsgs against concurrent access
|
|
// from the agent loop's per-message persistence hook (runs on the
|
|
// agent goroutine) and the TUI's session swap / flush callbacks
|
|
// (run on the TUI goroutine). Without this, a /sessions swap that
|
|
// races with a finishing turn could double-write or lose messages.
|
|
var persistMu sync.Mutex
|
|
if !args.NoSess && ag != nil {
|
|
sess, _ = openOrCreateSession(args, r, ag, version)
|
|
if ag != nil {
|
|
sessBaselineMsgs = len(ag.Messages())
|
|
}
|
|
}
|
|
defer func() {
|
|
persistMu.Lock()
|
|
defer persistMu.Unlock()
|
|
if sess != nil {
|
|
sess.Close()
|
|
}
|
|
}()
|
|
|
|
// persistMessage is the per-message hook bound to the agent. It
|
|
// appends each new transcript message to the live session as soon
|
|
// as it lands, so a kill / closed terminal / OS crash costs at
|
|
// most the in-flight turn instead of the whole session. The
|
|
// baseline counter advances in lock-step so the exit-time flush
|
|
// doesn't double-write rows already on disk.
|
|
persistMessage := func(m provider.Message) {
|
|
persistMu.Lock()
|
|
defer persistMu.Unlock()
|
|
if sess == nil {
|
|
return
|
|
}
|
|
if err := sess.AppendMessage(m); err == nil {
|
|
sessBaselineMsgs++
|
|
}
|
|
}
|
|
persistUsage := func(cum provider.Usage) {
|
|
persistMu.Lock()
|
|
defer persistMu.Unlock()
|
|
if sess == nil {
|
|
return
|
|
}
|
|
_ = sess.AppendUsage(cum, cum)
|
|
}
|
|
persistCompaction := func(messages []provider.Message) {
|
|
persistMu.Lock()
|
|
defer persistMu.Unlock()
|
|
if sess == nil {
|
|
return
|
|
}
|
|
if err := sess.AppendCompaction(messages); err == nil {
|
|
sessBaselineMsgs = len(messages)
|
|
}
|
|
}
|
|
wireAgentPersist := func(a *core.Agent) *core.Agent {
|
|
if a == nil {
|
|
return a
|
|
}
|
|
a.OnMessageAppended = persistMessage
|
|
a.OnUsage = persistUsage
|
|
a.OnTranscriptCompacted = persistCompaction
|
|
return a
|
|
}
|
|
wireAgentPersist(ag)
|
|
|
|
// Re-wrap the build closures so any agent constructed by the TUI
|
|
// (login, /model swap to a different provider) also gets the
|
|
// persistence hooks. Without this, switching provider would
|
|
// silently revert to the old in-memory-only behaviour.
|
|
baseBuildAgent := buildAgent
|
|
buildAgent = func() (*core.Agent, string, string, error) {
|
|
a, p, m, err := baseBuildAgent()
|
|
return wireAgentPersist(a), p, m, err
|
|
}
|
|
baseBuildAgentFor := buildAgentFor
|
|
buildAgentFor = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
|
|
a, p, m, err := baseBuildAgentFor(providerOverride, modelOverride)
|
|
return wireAgentPersist(a), p, m, err
|
|
}
|
|
baseBuildAgentForRescue := buildAgentForRescue
|
|
buildAgentForRescue = func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
|
|
a, p, m, err := baseBuildAgentForRescue(providerOverride, modelOverride)
|
|
return wireAgentPersist(a), p, m, err
|
|
}
|
|
|
|
// loadSession replaces the current session with the one at path and
|
|
// hands its messages to the agent. Used by the /sessions picker.
|
|
loadSession := func(path string) error {
|
|
currentAg := ag // captured
|
|
if currentAg == nil {
|
|
return fmt.Errorf("no agent running; log in first")
|
|
}
|
|
newSess, msgs, err := core.OpenSession(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fullMsgCount := len(msgs)
|
|
msgs = trimMessagesForResume(msgs, 100)
|
|
persistMu.Lock()
|
|
// Flush any unsaved messages to the old session before swapping.
|
|
// Per-message persistence keeps sessBaselineMsgs current, so
|
|
// this is a defensive no-op in the common case; it still
|
|
// matters for the rare race where a turn just finished and
|
|
// hadn't fired its hook yet.
|
|
if sess != nil {
|
|
writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
|
|
_ = sess.Close()
|
|
}
|
|
sess = newSess
|
|
currentAg.SetMessages(msgs)
|
|
if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
|
|
currentAg.SeedCost(cum)
|
|
currentAg.SeedLastTurnUsage(last)
|
|
}
|
|
// The live agent only receives a compact resume window, but
|
|
// the session file remains intact. Keep the persistence
|
|
// baseline at the original on-disk message count so future
|
|
// turns append after the full session instead of duplicating
|
|
// the hydrated tail.
|
|
sessBaselineMsgs = fullMsgCount
|
|
persistMu.Unlock()
|
|
// Re-scope the swarm dashboard to the new session so /swarm
|
|
// only shows agents this session spawned. swarmMgr may be nil
|
|
// here if we haven't reached the construction site yet (it
|
|
// shouldn't be, since the interactive loop is what triggers
|
|
// loadSession, but the nil check is cheap insurance).
|
|
if swarmMgr != nil && newSess != nil {
|
|
swarmMgr.SetActiveSession(newSess.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// changeCWD switches the running session to a new working directory.
|
|
// Wired into InteractiveConfig.ChangeCWD and invoked by the hidden
|
|
// /cd slash command (which itself is only fired by the workspaces
|
|
// extension's panel-key Enter handler today; the user can type /cd
|
|
// directly but it's not in autocomplete / help / the README).
|
|
//
|
|
// Steps, in order:
|
|
// 1. resolve + validate the new path (~ expansion, abs/rel)
|
|
// 2. close the current session, flushing pending messages
|
|
// 3. mutate captured args.CWD + r.CWD so future buildAgent
|
|
// calls bind to the new cwd
|
|
// 4. re-root the shared sandbox («·«) so /jail follows the
|
|
// session into the new cwd instead of widening or silently
|
|
// dropping
|
|
// 5. rebuild the agent via buildAgent() so tools, AGENTS.md
|
|
// addendum, system prompt, sessions dir all bind correctly
|
|
// 6. open a fresh session in the new cwd's bucket
|
|
// 7. push the new state into the running Interactive
|
|
// 8. re-scope the swarm dashboard to the freshly-opened session
|
|
//
|
|
// The /jail state is preserved verbatim: if the sandbox was locked
|
|
// to the old cwd, it stays locked, just re-pointed at the new one.
|
|
changeCWD := func(path string) error {
|
|
if path == "" {
|
|
return fmt.Errorf("empty path")
|
|
}
|
|
// ~ expansion.
|
|
if path == "~" || strings.HasPrefix(path, "~/") {
|
|
home, herr := os.UserHomeDir()
|
|
if herr != nil || home == "" {
|
|
return fmt.Errorf("cannot expand ~: %v", herr)
|
|
}
|
|
if path == "~" {
|
|
path = home
|
|
} else {
|
|
path = filepath.Join(home, path[2:])
|
|
}
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
path = filepath.Join(args.CWD, path)
|
|
}
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("not a directory: %s", absPath)
|
|
}
|
|
|
|
currentAg := ag
|
|
if currentAg == nil {
|
|
return fmt.Errorf("no agent running; log in first")
|
|
}
|
|
|
|
// Close the current session before we drop the reference.
|
|
// Per-message persistence keeps it current already; this is
|
|
// a defensive flush + final fsync via Close.
|
|
persistMu.Lock()
|
|
if sess != nil {
|
|
writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
|
|
_ = sess.Close()
|
|
sess = nil
|
|
}
|
|
sessBaselineMsgs = 0
|
|
persistMu.Unlock()
|
|
|
|
// Mutate captured state so subsequent agent rebuilds and
|
|
// session opens see the new cwd.
|
|
wasJailed := sharedSandbox != nil && sharedSandbox.Locked()
|
|
args.CWD = absPath
|
|
r.CWD = absPath
|
|
if sharedSandbox != nil {
|
|
sharedSandbox.Root = absPath
|
|
if wasJailed {
|
|
sharedSandbox.Lock()
|
|
} else {
|
|
sharedSandbox.Unlock()
|
|
}
|
|
}
|
|
|
|
// Rebuild the agent so tools / AGENTS.md / system prompt
|
|
// re-bind to the new cwd. buildAgent() reads from args + r.
|
|
newAg, newProvider, newModel, berr := buildAgent()
|
|
if berr != nil {
|
|
return fmt.Errorf("rebuild agent: %v", berr)
|
|
}
|
|
ag = newAg
|
|
|
|
// Fresh session in the new cwd's bucket. We bypass
|
|
// openOrCreateSession's --continue / --resume branches
|
|
// because /cd's semantics are "start fresh here", matching
|
|
// what relaunching `zot --cwd <path>` would do today.
|
|
if !args.NoSess {
|
|
core.PruneEmptySessions(ZotHome(), absPath)
|
|
newSess, serr := core.NewSession(ZotHome(), absPath, newProvider, newModel, version)
|
|
if serr != nil {
|
|
return fmt.Errorf("open session in %s: %v", absPath, serr)
|
|
}
|
|
persistMu.Lock()
|
|
sess = newSess
|
|
sessBaselineMsgs = 0
|
|
persistMu.Unlock()
|
|
}
|
|
|
|
// Push the new state into the running Interactive.
|
|
if iv != nil {
|
|
iv.ApplyChangedCWD(newAg, newProvider, newModel, absPath)
|
|
}
|
|
|
|
// Re-scope the swarm dashboard to the new session.
|
|
if swarmMgr != nil && sess != nil {
|
|
swarmMgr.SetActiveSession(sess.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
term := tui.NewProcTerm()
|
|
|
|
// Kick off the async update check so the banner can appear when the
|
|
// http response eventually arrives (usually <1s on cached DNS). Map
|
|
// agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
|
|
updateCh := make(chan modes.UpdateInfo, 1)
|
|
go func() {
|
|
defer close(updateCh)
|
|
src := <-CheckForUpdateAsync(ZotHome(), version)
|
|
updateCh <- modes.UpdateInfo{
|
|
Current: src.Current,
|
|
Latest: src.Latest,
|
|
Available: src.Available,
|
|
URL: src.URL,
|
|
}
|
|
}()
|
|
|
|
// Changelog: when the running version differs from the last
|
|
// version whose release notes the user dismissed, fetch the
|
|
// release body from GitHub and have the TUI show it once. On
|
|
// first-ever launch (no prior LastChangelogShown), seed the
|
|
// stored version silently — don't dump release notes at someone
|
|
// who just installed.
|
|
changelogCh := make(chan modes.ChangelogPayload, 1)
|
|
go func() {
|
|
defer close(changelogCh)
|
|
cfg, _ := LoadConfig()
|
|
if cfg.LastChangelogShown == "" {
|
|
SeedChangelogVersion(version)
|
|
return
|
|
}
|
|
if !ShouldShowChangelog(version, cfg) {
|
|
return
|
|
}
|
|
info := <-FetchChangelogAsync(version)
|
|
if info.Body == "" {
|
|
return
|
|
}
|
|
// For dev builds (0.0.0), skip if the latest release was
|
|
// already shown (stored by the dismiss callback).
|
|
if version == "0.0.0" && info.Version == cfg.LastChangelogShown {
|
|
return
|
|
}
|
|
changelogCh <- modes.ChangelogPayload{
|
|
Version: info.Version,
|
|
Body: info.Body,
|
|
URL: info.URL,
|
|
}
|
|
}()
|
|
|
|
initialCfg, _ := LoadConfig()
|
|
theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond)
|
|
if themeErr != nil {
|
|
fmt.Fprintln(os.Stderr, "theme load:", themeErr)
|
|
if initialCfg.Theme != "" && !tui.ThemeExists(ZotHome(), initialCfg.Theme) {
|
|
initialCfg.Theme = ""
|
|
_ = SaveConfig(initialCfg)
|
|
}
|
|
}
|
|
|
|
// swarmMgr was constructed and reloaded earlier (before the agent
|
|
// build, so the auto-swarm tool could capture it). Here we just
|
|
// scope the dashboard to the active host session so /swarm only
|
|
// shows agents this session spawned (and any pre-upgrade unscoped
|
|
// agents — see SnapshotAll docs). Updated again whenever the
|
|
// user swaps sessions via loadSession below.
|
|
if sess != nil {
|
|
swarmMgr.SetActiveSession(sess.ID)
|
|
}
|
|
// Best-effort shutdown on interactive exit: stop all running
|
|
// agents so they don't outlive their parent zot.
|
|
defer swarmMgr.StopAll()
|
|
|
|
iv = modes.NewInteractive(modes.InteractiveConfig{
|
|
Terminal: term,
|
|
Theme: theme,
|
|
InlineImagesEnabled: initialCfg.InlineImagesEnabled,
|
|
AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
|
|
RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
|
|
RespectGitignore: initialCfg.RespectGitignore,
|
|
ThemeName: initialCfg.Theme,
|
|
ExtensionThemes: extMgr.ThemeOptions,
|
|
AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
|
|
SettingsStore: configSettingsStore{},
|
|
Model: r.Model,
|
|
Provider: r.Provider,
|
|
AuthMethod: r.AuthMethod,
|
|
BaseURL: r.BaseURL,
|
|
Reasoning: r.Reasoning,
|
|
SystemPrompt: r.SystemPrompt,
|
|
Tools: r.ToolRegistry,
|
|
MaxSteps: r.MaxSteps,
|
|
CWD: r.CWD,
|
|
ZotHome: ZotHome(),
|
|
Version: version,
|
|
UpdateInfoChan: updateCh,
|
|
Sandbox: sharedSandbox,
|
|
Agent: ag,
|
|
InitialInput: args.Prompt,
|
|
AuthManager: mgr,
|
|
BuildAgent: buildAgent,
|
|
SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
|
|
BuildAgentFor: buildAgentFor,
|
|
BuildAgentForRescue: buildAgentForRescue,
|
|
LoggedInProviders: func() []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, p := range knownProviders {
|
|
if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
|
|
out = append(out, p)
|
|
seen[p] = true
|
|
}
|
|
}
|
|
// Include custom providers that have credentials stored.
|
|
for p := range provider.CustomProviders() {
|
|
if _, _, err := ResolveCredential(p, ""); err == nil && !seen[p] {
|
|
out = append(out, p)
|
|
seen[p] = true
|
|
}
|
|
}
|
|
// Ollama models are always available (no auth needed).
|
|
if !seen["ollama"] {
|
|
out = append(out, "ollama")
|
|
}
|
|
return out
|
|
},
|
|
LoadSession: loadSession,
|
|
ChangeCWD: changeCWD,
|
|
CurrentSessionPath: func() string {
|
|
if sess == nil {
|
|
return ""
|
|
}
|
|
return sess.Path
|
|
},
|
|
FlushSession: func() {
|
|
// Append any not-yet-persisted agent messages to the
|
|
// current session file, then advance the baseline so
|
|
// the final WriteNewTranscript at exit doesn't write
|
|
// duplicates. Per-message persistence keeps the on-
|
|
// disk file current already, so this is mostly a
|
|
// defensive flush — still needed for /session export
|
|
// to guarantee the exported bytes include the very
|
|
// last in-flight turn.
|
|
currentAg := iv.Agent()
|
|
if currentAg == nil {
|
|
return
|
|
}
|
|
persistMu.Lock()
|
|
defer persistMu.Unlock()
|
|
if sess == nil {
|
|
return
|
|
}
|
|
writeNewTranscriptLocked(currentAg, sess, sessBaselineMsgs)
|
|
sessBaselineMsgs = len(currentAg.Messages())
|
|
},
|
|
Extensions: extMgr,
|
|
Swarm: swarmMgr,
|
|
ChangelogChan: changelogCh,
|
|
OnChangelogDismiss: func() {
|
|
// For dev builds (0.0.0) store the actual release version
|
|
// so the same changelog doesn't show again next launch.
|
|
// For real builds, store the binary version.
|
|
v := version
|
|
if v == "0.0.0" {
|
|
if iv != nil && iv.ChangelogVersion() != "" {
|
|
v = iv.ChangelogVersion()
|
|
}
|
|
}
|
|
_ = MarkChangelogShown(v)
|
|
},
|
|
SkillSnapshot: func() []*skills.Skill {
|
|
if args.NoSkill {
|
|
// --no-skill: nothing for the picker to show.
|
|
return nil
|
|
}
|
|
// Re-discover so the picker reflects edits made during
|
|
// the session. Cheap; SKILL.md files are small. Filter
|
|
// out built-in skills — they're hidden from user-facing
|
|
// surfaces because they're implementation detail; the
|
|
// model still sees them through the system-prompt
|
|
// manifest and the skill tool.
|
|
userHome, _ := os.UserHomeDir()
|
|
list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
|
|
return skills.VisibleSkills(list)
|
|
},
|
|
NoYolo: args.NoYolo,
|
|
ConfirmGate: confirmGate,
|
|
PersistModel: func(providerName, model string) {
|
|
// Update config.json so next launch uses the same pick.
|
|
cfg, _ := LoadConfig()
|
|
cfg.Provider = providerName
|
|
cfg.Model = model
|
|
_ = SaveConfig(cfg)
|
|
// Update the active session's meta so resume picks this up.
|
|
if sess != nil {
|
|
_ = sess.UpdateModel(providerName, model)
|
|
}
|
|
},
|
|
})
|
|
|
|
// Bind the interactive TUI as the Confirmer. We deferred this
|
|
// until now because the gate is constructed before the TUI
|
|
// (the BeforeToolExecute closure captures it). SetConfirmer
|
|
// is mutex-guarded on the gate so this is safe.
|
|
if confirmGate != nil {
|
|
confirmGate.SetConfirmer(iv)
|
|
}
|
|
|
|
// Signal-driven flush: a SIGTERM / SIGHUP to the zot process
|
|
// (closed terminal window, system shutdown, kill) used to lose
|
|
// the entire in-memory transcript because the deferred post-Run
|
|
// flush below never ran. Per-message persistence above covers
|
|
// most of it; this handler writes any in-flight remainder and
|
|
// then exits the process so we don't double-paint over a
|
|
// broken terminal that the TUI's restore deferreds can no
|
|
// longer fix from a signal context.
|
|
//
|
|
// SIGINT is intentionally NOT handled here — the TUI consumes
|
|
// Ctrl+C as a regular key event for cancel/clear semantics, and
|
|
// installing a SIGINT notifier here would swallow it.
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
|
|
defer signal.Stop(sigCh)
|
|
go func() {
|
|
_, ok := <-sigCh
|
|
if !ok {
|
|
return
|
|
}
|
|
if finalAg := iv.Agent(); finalAg != nil {
|
|
persistMu.Lock()
|
|
if sess != nil {
|
|
writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
|
|
sessBaselineMsgs = len(finalAg.Messages())
|
|
_ = sess.Close()
|
|
sess = nil
|
|
}
|
|
persistMu.Unlock()
|
|
}
|
|
// Exit cleanly. Re-raising the signal would skip os.Exit's
|
|
// at-exit hooks; explicit exit is fine because we've already
|
|
// flushed the only at-risk state (the session file).
|
|
os.Exit(0)
|
|
}()
|
|
|
|
runErr := iv.Run(ctx)
|
|
|
|
// Flush final transcript to session (only if we had / ended up with an agent).
|
|
if finalAg := iv.Agent(); finalAg != nil {
|
|
persistMu.Lock()
|
|
if sess != nil {
|
|
writeNewTranscriptLocked(finalAg, sess, sessBaselineMsgs)
|
|
sessBaselineMsgs = len(finalAg.Messages())
|
|
}
|
|
persistMu.Unlock()
|
|
}
|
|
return runErr
|
|
}
|
|
|
|
// openOrCreateSession returns a session for the run. sess may be nil
|
|
// with a nil error if session persistence is disabled.
|
|
func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
|
|
if args.NoSess {
|
|
return nil, nil
|
|
}
|
|
// Sweep meta-only files left over from older zot versions (and from
|
|
// any session that crashed before its first AppendMessage). Cheap;
|
|
// reads the first few bytes of each file in the cwd's session dir.
|
|
core.PruneEmptySessions(ZotHome(), args.CWD)
|
|
var (
|
|
s *core.Session
|
|
msgs []provider.Message
|
|
err error
|
|
)
|
|
switch {
|
|
case args.Session != "":
|
|
s, msgs, err = core.OpenSession(args.Session)
|
|
// The swarm-agent child passes a fixed --session path that
|
|
// may not exist yet on first Spawn. Treat ENOENT as "create
|
|
// a fresh session AT THIS PATH" so the conversation actually
|
|
// gets persisted; without this fallback the swarm child runs
|
|
// with sess==nil and every Resume re-starts with no memory
|
|
// of the prior turns. Other openers (--continue / --resume /
|
|
// the picker) never see ENOENT here because they only choose
|
|
// paths that already exist on disk.
|
|
if err != nil && errors.Is(err, os.ErrNotExist) {
|
|
s, err = core.NewSessionAtPath(args.Session, args.CWD, r.Provider, r.Model, version)
|
|
msgs = nil
|
|
}
|
|
case args.Continue:
|
|
latest := core.LatestSession(ZotHome(), args.CWD)
|
|
if latest != "" {
|
|
s, msgs, err = core.OpenSession(latest)
|
|
}
|
|
case args.Resume:
|
|
picked, perr := pickSession(args.CWD)
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if picked != "" {
|
|
s, msgs, err = core.OpenSession(picked)
|
|
}
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s != nil {
|
|
ag.SetMessages(msgs)
|
|
if cum, last, uerr := core.SessionUsageDetail(s.Path); uerr == nil {
|
|
ag.SeedCost(cum)
|
|
ag.SeedLastTurnUsage(last)
|
|
}
|
|
return s, nil
|
|
}
|
|
return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
|
|
}
|
|
|
|
func pickSession(cwd string) (string, error) {
|
|
files := core.ListSessions(ZotHome(), cwd)
|
|
if len(files) == 0 {
|
|
fmt.Fprintln(os.Stderr, "no sessions for", cwd)
|
|
return "", nil
|
|
}
|
|
for i, f := range files {
|
|
fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
|
|
}
|
|
fmt.Fprint(os.Stderr, "pick #: ")
|
|
rd := bufio.NewReader(os.Stdin)
|
|
line, _ := rd.ReadString('\n')
|
|
line = strings.TrimSpace(line)
|
|
var n int
|
|
if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
|
|
return "", fmt.Errorf("invalid selection")
|
|
}
|
|
return files[n-1], nil
|
|
}
|
|
|
|
// WriteNewTranscript appends only messages after index `from` from the
|
|
// agent's transcript to the session. Used by callers that don't hold
|
|
// the persistMu (non-interactive print/json modes which run a single
|
|
// turn under their own goroutine).
|
|
func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
|
|
writeNewTranscriptLocked(ag, sess, from)
|
|
}
|
|
|
|
// writeNewTranscriptLocked is the same as WriteNewTranscript. The
|
|
// suffix marks that interactive callers must hold persistMu when
|
|
// invoking it so concurrent appends from the agent loop don't race
|
|
// with this catch-up flush.
|
|
func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) {
|
|
if sess == nil || ag == nil {
|
|
return
|
|
}
|
|
msgs := ag.Messages()
|
|
for i := from; i < len(msgs); i++ {
|
|
_ = sess.AppendMessage(msgs[i])
|
|
}
|
|
cum := ag.Cost()
|
|
_ = sess.AppendUsage(cum, cum)
|
|
}
|
|
|
|
func readAllStdin() (string, error) {
|
|
fi, err := os.Stdin.Stat()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if (fi.Mode() & os.ModeCharDevice) != 0 {
|
|
return "", nil
|
|
}
|
|
b, err := io.ReadAll(os.Stdin)
|
|
return string(b), err
|
|
}
|
|
|
|
func printModels() {
|
|
models := provider.Active()
|
|
|
|
// Compute column widths from actual data so wide providers (e.g.
|
|
// xiaomi-token-plan-sgp) and long bedrock model ids don't force the
|
|
// `name` column off-screen. Floors mirror the historical layout so
|
|
// short catalogs look the same as before.
|
|
provW, idW, srcW := len("provider"), len("model id"), len("source")
|
|
for _, m := range models {
|
|
if w := len(m.Provider); w > provW {
|
|
provW = w
|
|
}
|
|
if w := len(m.ID); w > idW {
|
|
idW = w
|
|
}
|
|
source := m.Source
|
|
if source == "" {
|
|
source = "catalog"
|
|
}
|
|
if m.Speculative {
|
|
source = "speculative"
|
|
}
|
|
if w := len(source); w > srcW {
|
|
srcW = w
|
|
}
|
|
}
|
|
|
|
header := fmt.Sprintf("%-*s %-*s %8s %8s %s %-*s %s",
|
|
provW, "provider",
|
|
idW, "model id",
|
|
"context", "max-out", "reasoning",
|
|
srcW, "source",
|
|
"name")
|
|
fmt.Println(header)
|
|
|
|
for _, m := range models {
|
|
reason := " "
|
|
if m.Reasoning {
|
|
reason = "✓"
|
|
}
|
|
source := m.Source
|
|
if source == "" {
|
|
source = "catalog"
|
|
}
|
|
if m.Speculative {
|
|
source = "speculative"
|
|
}
|
|
fmt.Printf("%-*s %-*s %8d %8d %s %-*s %s\n",
|
|
provW, m.Provider,
|
|
idW, m.ID,
|
|
m.ContextWindow, m.MaxOutput,
|
|
reason,
|
|
srcW, source,
|
|
m.DisplayName)
|
|
}
|
|
}
|