1package agent
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "strings"
11 "time"
12
13 "github.com/patriceckhart/zot/internal/agent/extensions"
14 "github.com/patriceckhart/zot/internal/agent/modes"
15 "github.com/patriceckhart/zot/internal/auth"
16 "github.com/patriceckhart/zot/internal/core"
17 "github.com/patriceckhart/zot/internal/extproto"
18 "github.com/patriceckhart/zot/internal/provider"
19 "github.com/patriceckhart/zot/internal/skills"
20 "github.com/patriceckhart/zot/internal/tui"
21)
22
23// interactiveExtHooks is a tiny adapter that lets the extension
24// manager call back into the Interactive instance built later in
25// runInteractive. The forward-declared *modes.Interactive is filled
26// in immediately after manager construction.
27type interactiveExtHooks struct {
28 ivPtr **modes.Interactive
29}
30
31func (h *interactiveExtHooks) iv() *modes.Interactive {
32 if h == nil || h.ivPtr == nil {
33 return nil
34 }
35 return *h.ivPtr
36}
37
38func (h *interactiveExtHooks) Notify(extName, level, message string) {
39 if iv := h.iv(); iv != nil {
40 iv.Notify(extName, level, message)
41 }
42}
43func (h *interactiveExtHooks) Submit(text string) {
44 if iv := h.iv(); iv != nil {
45 iv.Submit(text)
46 }
47}
48func (h *interactiveExtHooks) Insert(text string) {
49 if iv := h.iv(); iv != nil {
50 iv.Insert(text)
51 }
52}
53func (h *interactiveExtHooks) Display(extName, text string) {
54 if iv := h.iv(); iv != nil {
55 iv.Display(extName, text)
56 }
57}
58
59// extToolAdapter bridges *extensions.Manager to the
60// ExtensionToolSource interface declared in build.go (kept narrow to
61// avoid a build->extensions import cycle). One adapter instance per
62// run; used at every Resolve point so re-built agents pick up the
63// same set of extension tools.
64type extToolAdapter struct {
65 mgr *extensions.Manager
66}
67
68func (a *extToolAdapter) Tools() []ExtensionToolInfo {
69 infos := a.mgr.Tools()
70 out := make([]ExtensionToolInfo, len(infos))
71 for i, t := range infos {
72 out[i] = ExtensionToolInfo{
73 Extension: t.Extension,
74 Name: t.Name,
75 Description: t.Description,
76 Schema: t.Schema,
77 }
78 }
79 return out
80}
81
82func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
83 return extensions.NewTool(a.mgr, extensions.ToolInfo{
84 Extension: info.Extension,
85 Name: info.Name,
86 Description: info.Description,
87 Schema: info.Schema,
88 })
89}
90
91// fanoutAgentEvent translates a core.AgentEvent into the wire-format
92// EventFromHost and pushes it through the extension manager. Only
93// the events that have a clear extension-facing meaning are
94// forwarded; internal-only ones (text_delta, tool_progress) are
95// dropped to keep the per-extension stream sane.
96func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
97 if mgr == nil {
98 return
99 }
100 switch e := ev.(type) {
101 case core.EvTurnStart:
102 mgr.EmitEvent(extproto.EventFromHost{Event: "turn_start", Step: e.Step})
103 case core.EvToolCall:
104 mgr.EmitEvent(extproto.EventFromHost{
105 Event: "tool_call", ToolID: e.ID, ToolName: e.Name, ToolArgs: e.Args,
106 })
107 case core.EvAssistantMessage:
108 // Concat the visible text portions of the message; binary
109 // blocks (tool_use, etc.) are skipped because subscribers
110 // usually want a string they can grep / display.
111 var text string
112 for _, c := range e.Message.Content {
113 if tb, ok := c.(provider.TextBlock); ok {
114 text += tb.Text
115 }
116 }
117 mgr.EmitEvent(extproto.EventFromHost{Event: "assistant_message", Text: text})
118 case core.EvTurnEnd:
119 ev := extproto.EventFromHost{Event: "turn_end", Stop: string(e.Stop)}
120 if e.Err != nil {
121 ev.Error = e.Err.Error()
122 }
123 mgr.EmitEvent(ev)
124 }
125}
126
127// Run is the top-level entrypoint for the zot binary.
128func Run(rawArgs []string, version string) error {
129 // Subcommand router: `zot bot ...` is handled separately so the
130 // generic flag parser doesn't reject "bot" as a positional arg.
131 if handled, err := runBotCommand(rawArgs, version); handled {
132 return err
133 }
134 if handled, err := runExtCommand(rawArgs); handled {
135 return err
136 }
137 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
138 // spawn the binary with a clean argv. Strip the leading 'rpc'
139 // token and let the rest flow through the normal arg parser.
140 if len(rawArgs) > 0 && rawArgs[0] == "rpc" {
141 rawArgs = append([]string{"--rpc"}, rawArgs[1:]...)
142 }
143
144 args, err := ParseArgs(rawArgs)
145 if err != nil {
146 PrintHelp(version)
147 return err
148 }
149 if args.Help {
150 PrintHelp(version)
151 return nil
152 }
153 if args.Version {
154 fmt.Println("zot", version)
155 return nil
156 }
157 // Model catalog: load any cached discovery data before we inspect
158 // the model list (list-models, print/json, interactive).
159 LoadCachedModels()
160
161 if args.ListModels {
162 printModels()
163 return nil
164 }
165
166 ctx := context.Background()
167
168 // Kick an async refresh of the live model catalog. The first run of
169 // zot hits the network; subsequent runs within CacheTTL do nothing.
170 RefreshModelsAsync()
171
172 switch args.Mode {
173 case ModePrint:
174 return runPrintMode(ctx, args, version)
175 case ModeJSON:
176 return runJSONMode(ctx, args, version)
177 case ModeRPC:
178 return runRPCMode(ctx, args, version)
179 default:
180 return runInteractive(ctx, args, version)
181 }
182}
183
184// ---- print / json modes: require credentials, run single-shot ----
185
186func runPrintMode(ctx context.Context, args Args, version string) error {
187 if args.NoYolo {
188 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in print mode (no interactive prompt available); tools will run without confirmation")
189 }
190 r, err := Resolve(args, true)
191 if err != nil {
192 return err
193 }
194 ag := r.NewAgent()
195 sess, _ := openOrCreateSession(args, r, ag, version)
196 defer sess.Close()
197
198 prompt := args.Prompt
199 if prompt == "" {
200 piped, _ := readAllStdin()
201 prompt = strings.TrimSpace(piped)
202 }
203 if prompt == "" {
204 return fmt.Errorf("print mode requires a prompt (arg or stdin)")
205 }
206
207 start := len(ag.Messages())
208 err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
209 WriteNewTranscript(ag, sess, start)
210 return err
211}
212
213func runJSONMode(ctx context.Context, args Args, version string) error {
214 if args.NoYolo {
215 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
216 }
217 r, err := Resolve(args, true)
218 if err != nil {
219 return err
220 }
221 ag := r.NewAgent()
222 sess, _ := openOrCreateSession(args, r, ag, version)
223 defer sess.Close()
224
225 prompt := args.Prompt
226 if prompt == "" {
227 piped, _ := readAllStdin()
228 prompt = strings.TrimSpace(piped)
229 }
230 if prompt == "" {
231 return fmt.Errorf("json mode requires a prompt (arg or stdin)")
232 }
233
234 start := len(ag.Messages())
235 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
236 WriteNewTranscript(ag, sess, start)
237 return err
238}
239
240// ---- interactive mode: opens the TUI even without credentials ----
241
242func runInteractive(ctx context.Context, args Args, version string) error {
243 // Resolve WITHOUT requiring credentials.
244 r, err := Resolve(args, false)
245 if err != nil {
246 return err
247 }
248
249 authStore := AuthStoreFor()
250 mgr := auth.NewManager(authStore)
251 defer mgr.Close()
252
253 // Keep the sandbox pointer stable across agent rebuilds (login / model
254 // switch). The Interactive UI toggles the lock via this pointer, and
255 // rebuilt tool instances must share the same one so the lock sticks.
256 sharedSandbox := r.Sandbox
257
258 // Build the extension manager BEFORE the agent so we can fold
259 // extension-defined tools into the registry. Forward-declare iv so
260 // the host hooks adapter can dereference it after construction.
261 var iv *modes.Interactive
262 extHooks := &interactiveExtHooks{ivPtr: &iv}
263 extMgr := extensions.New(ZotHome(), r.CWD, version, r.Provider, r.Model, extHooks)
264 // --ext paths first so they win against installed extensions of
265 // the same name (loadOne's first-write-wins semantics).
266 for _, e := range extMgr.LoadExplicit(ctx, args.Exts) {
267 fmt.Fprintln(os.Stderr, "extension load:", e)
268 }
269 // --no-ext skips the global + project-local discovery scan;
270 // explicit --ext paths above are still honoured so you can run
271 // "only this extension" with --no-ext --ext ./x.
272 if !args.NoExt {
273 for _, e := range extMgr.Discover(ctx) {
274 fmt.Fprintln(os.Stderr, "extension load:", e)
275 }
276 }
277 // Wait briefly for extensions to flush their initial register_tool
278 // frames before we build the agent's tool registry. Half a second
279 // is plenty for any extension that's actually well-behaved; ones
280 // that don't send a ready frame eat the full grace and proceed.
281 // 3s is the per-extension grace period for the ready frame.
282 // Native binaries are instant; runtimes like `npx tsx` take ~1.5s
283 // from cold cache. The wait is tight only for extensions that
284 // haven't sent ready by then; ones that signalled earlier release
285 // the wait immediately.
286 extMgr.WaitForReady(3 * time.Second)
287 defer extMgr.Stop(2 * time.Second)
288
289 extToolAdapter := &extToolAdapter{mgr: extMgr}
290 r.MergeExtensionTools(extToolAdapter)
291
292 // Confirmation gate: when --no-yolo is on, the agent must ask
293 // the user before every tool call. In interactive mode the TUI
294 // provides the Confirmer; in print/json/rpc modes there's no
295 // way to prompt, so the gate is constructed with a nil inner
296 // which auto-refuses every call with a helpful reason.
297 var confirmGate *core.ConfirmGate
298 if args.NoYolo {
299 confirmGate = core.NewConfirmGate(nil) // set below for interactive
300 }
301
302 // Capture current args in a closure so BuildAgent can re-resolve
303 // after a successful login (picks up the newly stored credential).
304 wireAgentExt := func(a *core.Agent) *core.Agent {
305 if a == nil {
306 return a
307 }
308 a.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
309 // Confirm gate runs FIRST: if the user refused, we don't
310 // waste extension-intercept time or let guards see the call.
311 if confirmGate != nil {
312 ok, reason, _ := confirmGate.Check(call.Name, core.BuildPreview(call.Arguments, 120))
313 if !ok {
314 return false, reason, nil
315 }
316 }
317 r := extMgr.InterceptToolCall(ctx, call.ID, call.Name, call.Arguments)
318 if r.Block {
319 return false, r.Reason, nil
320 }
321 return true, "", r.ModifiedArgs
322 }
323 a.BeforeTurn = func(step int) (bool, string) {
324 r := extMgr.InterceptTurnStart(ctx, step)
325 return !r.Block, r.Reason
326 }
327 a.BeforeAssistantMessage = func(text string) (bool, string, string) {
328 r := extMgr.InterceptAssistantMessage(ctx, text)
329 if r.Block {
330 return false, r.Reason, ""
331 }
332 return true, "", r.ReplaceText
333 }
334 a.OnEvent = func(ev core.AgentEvent) { fanoutAgentEvent(extMgr, ev) }
335 return a
336 }
337
338 buildAgent := func() (*core.Agent, string, string, error) {
339 resolved, err := Resolve(args, true)
340 if err != nil {
341 return nil, "", "", err
342 }
343 resolved.UseSandbox(sharedSandbox)
344 resolved.MergeExtensionTools(extToolAdapter)
345 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
346 }
347
348 // Rebuild agent with an explicit provider/model override.
349 buildAgentFor := func(providerOverride, modelOverride string) (*core.Agent, string, string, error) {
350 next := args
351 if providerOverride != "" {
352 next.Provider = providerOverride
353 }
354 if modelOverride != "" {
355 next.Model = modelOverride
356 }
357 resolved, err := Resolve(next, true)
358 if err != nil {
359 return nil, "", "", err
360 }
361 resolved.UseSandbox(sharedSandbox)
362 resolved.MergeExtensionTools(extToolAdapter)
363 return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil
364 }
365
366 var ag *core.Agent
367 if r.HasCredential() {
368 ag = wireAgentExt(r.NewAgent())
369 }
370
371 // /reload-ext callback: after the manager has respawned every
372 // extension, re-resolve the tool registry (built-ins + freshly-
373 // registered extension tools) and swap it onto the current
374 // agent in-place. The current agent may have been replaced by a
375 // /model swap since spawn, so re-read the live `ag` on each
376 // invocation.
377 extMgr.SetOnReload(func() {
378 current := ag
379 if current == nil {
380 return
381 }
382 resolved, err := Resolve(args, true)
383 if err != nil {
384 return
385 }
386 resolved.UseSandbox(sharedSandbox)
387 resolved.MergeExtensionTools(extToolAdapter)
388 current.SetTools(resolved.ToolRegistry)
389 })
390
391 // Fire session_start once we know the manager's running.
392 extMgr.EmitEvent(extproto.EventFromHost{Event: "session_start"})
393
394 var sess *core.Session
395 var sessBaselineMsgs int // messages already on disk when current session opened
396 if !args.NoSess && ag != nil {
397 sess, _ = openOrCreateSession(args, r, ag, version)
398 if ag != nil {
399 sessBaselineMsgs = len(ag.Messages())
400 }
401 }
402 defer func() {
403 if sess != nil {
404 sess.Close()
405 }
406 }()
407
408 // loadSession replaces the current session with the one at path and
409 // hands its messages to the agent. Used by the /sessions picker.
410 loadSession := func(path string) error {
411 currentAg := ag // captured
412 if currentAg == nil {
413 return fmt.Errorf("no agent running; log in first")
414 }
415 newSess, msgs, err := core.OpenSession(path)
416 if err != nil {
417 return err
418 }
419 // Flush any unsaved messages to the old session before swapping.
420 if sess != nil {
421 WriteNewTranscript(currentAg, sess, sessBaselineMsgs)
422 _ = sess.Close()
423 }
424 sess = newSess
425 currentAg.SetMessages(msgs)
426 sessBaselineMsgs = len(msgs)
427 return nil
428 }
429
430 term := tui.NewProcTerm()
431
432 // Kick off the async update check so the banner can appear when the
433 // http response eventually arrives (usually <1s on cached DNS). Map
434 // agent.UpdateInfo -> modes.UpdateInfo here to avoid a cyclic import.
435 updateCh := make(chan modes.UpdateInfo, 1)
436 go func() {
437 defer close(updateCh)
438 src := <-CheckForUpdateAsync(ZotHome(), version)
439 updateCh <- modes.UpdateInfo{
440 Current: src.Current,
441 Latest: src.Latest,
442 Available: src.Available,
443 URL: src.URL,
444 }
445 }()
446
447 // Changelog: when the running version differs from the last
448 // version whose release notes the user dismissed, fetch the
449 // release body from GitHub and have the TUI show it once. On
450 // first-ever launch (no prior LastChangelogShown), seed the
451 // stored version silently — don't dump release notes at someone
452 // who just installed.
453 changelogCh := make(chan modes.ChangelogPayload, 1)
454 go func() {
455 defer close(changelogCh)
456 cfg, _ := LoadConfig()
457 if cfg.LastChangelogShown == "" {
458 SeedChangelogVersion(version)
459 return
460 }
461 if !ShouldShowChangelog(version, cfg) {
462 return
463 }
464 info := <-FetchChangelogAsync(version)
465 if info.Body == "" {
466 return
467 }
468 changelogCh <- modes.ChangelogPayload{
469 Version: info.Version,
470 Body: info.Body,
471 URL: info.URL,
472 }
473 }()
474
475 iv = modes.NewInteractive(modes.InteractiveConfig{
476 Terminal: term,
477 Theme: tui.Dark,
478 Model: r.Model,
479 Provider: r.Provider,
480 AuthMethod: r.AuthMethod,
481 BaseURL: r.BaseURL,
482 Reasoning: r.Reasoning,
483 SystemPrompt: r.SystemPrompt,
484 Tools: r.ToolRegistry,
485 MaxSteps: r.MaxSteps,
486 CWD: r.CWD,
487 ZotHome: ZotHome(),
488 Version: version,
489 UpdateInfoChan: updateCh,
490 Sandbox: sharedSandbox,
491 Agent: ag,
492 InitialInput: args.Prompt,
493 AuthManager: mgr,
494 BuildAgent: buildAgent,
495 BuildAgentFor: buildAgentFor,
496 LoadSession: loadSession,
497 Extensions: extMgr,
498 ChangelogChan: changelogCh,
499 OnChangelogDismiss: func() {
500 _ = MarkChangelogShown(version)
501 },
502 SkillSnapshot: func() []*skills.Skill {
503 if args.NoSkill {
504 // --no-skill: nothing for the picker to show.
505 return nil
506 }
507 // Re-discover so the picker reflects edits made during
508 // the session. Cheap; SKILL.md files are small. Filter
509 // out built-in skills — they're hidden from user-facing
510 // surfaces because they're implementation detail; the
511 // model still sees them through the system-prompt
512 // manifest and the skill tool. User skills only appear
513 // when --with-skills is set; without it the picker shows
514 // nothing but the model still has the built-ins.
515 userHome, _ := os.UserHomeDir()
516 list, _ := skills.Discover(ZotHome(), r.CWD, userHome, args.WithSkills)
517 return skills.VisibleSkills(list)
518 },
519 NoYolo: args.NoYolo,
520 ConfirmGate: confirmGate,
521 PersistModel: func(providerName, model string) {
522 // Update config.json so next launch uses the same pick.
523 cfg, _ := LoadConfig()
524 cfg.Provider = providerName
525 cfg.Model = model
526 _ = SaveConfig(cfg)
527 // Update the active session's meta so resume picks this up.
528 if sess != nil {
529 _ = sess.UpdateModel(providerName, model)
530 }
531 },
532 })
533
534 // Bind the interactive TUI as the Confirmer. We deferred this
535 // until now because the gate is constructed before the TUI
536 // (the BeforeToolExecute closure captures it). SetConfirmer
537 // is mutex-guarded on the gate so this is safe.
538 if confirmGate != nil {
539 confirmGate.SetConfirmer(iv)
540 }
541
542 runErr := iv.Run(ctx)
543
544 // Flush final transcript to session (only if we had / ended up with an agent).
545 if finalAg := iv.Agent(); finalAg != nil && sess != nil {
546 WriteNewTranscript(finalAg, sess, sessBaselineMsgs)
547 }
548 return runErr
549}
550
551// openOrCreateSession returns a session for the run. sess may be nil
552// with a nil error if session persistence is disabled.
553func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
554 if args.NoSess {
555 return nil, nil
556 }
557 // Sweep meta-only files left over from older zot versions (and from
558 // any session that crashed before its first AppendMessage). Cheap;
559 // reads the first few bytes of each file in the cwd's session dir.
560 core.PruneEmptySessions(ZotHome(), args.CWD)
561 var (
562 s *core.Session
563 msgs []provider.Message
564 err error
565 )
566 switch {
567 case args.Session != "":
568 s, msgs, err = core.OpenSession(args.Session)
569 case args.Continue:
570 latest := core.LatestSession(ZotHome(), args.CWD)
571 if latest != "" {
572 s, msgs, err = core.OpenSession(latest)
573 }
574 case args.Resume:
575 picked, perr := pickSession(args.CWD)
576 if perr != nil {
577 return nil, perr
578 }
579 if picked != "" {
580 s, msgs, err = core.OpenSession(picked)
581 }
582 }
583 if err != nil {
584 return nil, err
585 }
586 if s != nil {
587 ag.SetMessages(msgs)
588 return s, nil
589 }
590 return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
591}
592
593func pickSession(cwd string) (string, error) {
594 files := core.ListSessions(ZotHome(), cwd)
595 if len(files) == 0 {
596 fmt.Fprintln(os.Stderr, "no sessions for", cwd)
597 return "", nil
598 }
599 for i, f := range files {
600 fmt.Fprintf(os.Stderr, " %2d) %s\n", i+1, f)
601 }
602 fmt.Fprint(os.Stderr, "pick #: ")
603 rd := bufio.NewReader(os.Stdin)
604 line, _ := rd.ReadString('\n')
605 line = strings.TrimSpace(line)
606 var n int
607 if _, err := fmt.Sscanf(line, "%d", &n); err != nil || n < 1 || n > len(files) {
608 return "", fmt.Errorf("invalid selection")
609 }
610 return files[n-1], nil
611}
612
613// WriteNewTranscript appends only messages after index `from` from the
614// agent's transcript to the session.
615func WriteNewTranscript(ag *core.Agent, sess *core.Session, from int) {
616 if sess == nil || ag == nil {
617 return
618 }
619 msgs := ag.Messages()
620 for i := from; i < len(msgs); i++ {
621 _ = sess.AppendMessage(msgs[i])
622 }
623 cum := ag.Cost()
624 _ = sess.AppendUsage(cum, cum)
625}
626
627func readAllStdin() (string, error) {
628 fi, err := os.Stdin.Stat()
629 if err != nil {
630 return "", err
631 }
632 if (fi.Mode() & os.ModeCharDevice) != 0 {
633 return "", nil
634 }
635 b, err := io.ReadAll(os.Stdin)
636 return string(b), err
637}
638
639func printModels() {
640 fmt.Println("provider model id context max-out reasoning source name")
641 for _, m := range provider.Active() {
642 reason := " "
643 if m.Reasoning {
644 reason = "✓"
645 }
646 source := m.Source
647 if source == "" {
648 source = "catalog"
649 }
650 if m.Speculative {
651 source = "speculative"
652 }
653 fmt.Printf("%-10s %-30s %8d %8d %s %-11s %s\n",
654 m.Provider, m.ID, m.ContextWindow, m.MaxOutput, reason, source, m.DisplayName)
655 }
656}