v0.1.68 9b4f4da

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"strings"
10
11	"github.com/patriceckhart/zot/internal/agent/tools"
12	"github.com/patriceckhart/zot/internal/core"
13	"github.com/patriceckhart/zot/internal/provider"
14	"github.com/patriceckhart/zot/internal/skills"
15)
16
17// Resolved is the effective configuration after merging CLI, config, defaults.
18type Resolved struct {
19	Provider   string
20	Model      string
21	Credential string // api key or oauth access token
22	AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
23	AccountID  string // ChatGPT account id (for openai oauth), "" otherwise
24	BaseURL    string
25	CWD        string
26	Reasoning  string
27
28	ToolRegistry core.Registry
29	ToolSummary  []ToolSummary
30	SystemPrompt string
31	MaxSteps     int
32	Sandbox      *tools.Sandbox
33
34	// SkillTool is the on-demand skill loader registered with the
35	// agent's tool registry, or nil if no SKILL.md files were
36	// discovered. Exposed so the tui can list / preview skills.
37	SkillTool *skills.Tool
38
39	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
40	// so the system prompt can be rebuilt later without re-running
41	// resolve.
42	systemAppend     []string
43	systemCustom     string
44	toolDescriptions map[string]string
45}
46
47// HasCredential reports whether a credential was resolved.
48func (r Resolved) HasCredential() bool { return r.Credential != "" }
49
50// MergeExtensionTools folds every tool registered by an extension
51// into r's ToolRegistry and re-renders the system prompt's tool
52// summary so the model sees both built-in and extension tools.
53//
54// Idempotent: calling twice with the same manager state has no
55// effect on the second pass (existing names are preserved). Built-in
56// tools always win on conflict.
57func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
58	if mgr == nil {
59		return
60	}
61	infos := mgr.Tools()
62	if len(infos) == 0 {
63		return
64	}
65	changed := false
66	for _, info := range infos {
67		if _, exists := r.ToolRegistry[info.Name]; exists {
68			continue
69		}
70		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
71		changed = true
72	}
73	if !changed {
74		return
75	}
76	// Re-render the system prompt with the merged tool list. Skill
77	// addendum is preserved by walking the existing append slice.
78	append_ := r.systemAppend
79	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
80		CWD:    r.CWD,
81		Tools:  toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
82		Custom: r.systemCustom,
83		Append: append_,
84	})
85}
86
87// ExtensionToolSource is the slice of the extension manager that
88// MergeExtensionTools needs. Lives here as an interface so the
89// build package doesn't import internal/agent/extensions (which
90// imports core, which imports... avoid the cycle).
91type ExtensionToolSource interface {
92	Tools() []ExtensionToolInfo
93	NewExtensionTool(info ExtensionToolInfo) core.Tool
94}
95
96// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
97// ExtensionToolSource here without importing the extensions
98// package. The cli wires a tiny adapter to bridge them.
99type ExtensionToolInfo struct {
100	Extension   string
101	Name        string
102	Description string
103	Schema      []byte
104}
105
106// toolSummariesFromRegistry rebuilds the system-prompt tool list
107// from a (possibly extended) registry, using cached descriptions for
108// the human-readable summary text.
109func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
110	out := make([]ToolSummary, 0, len(reg))
111	for name, t := range reg {
112		desc := t.Description()
113		if d, ok := cached[name]; ok && d != "" {
114			desc = d
115		}
116		out = append(out, ToolSummary{Name: name, Description: desc})
117	}
118	return out
119}
120
121// Resolve merges args, config, and env into a Resolved set.
122//
123// Unlike the earlier version, Resolve NEVER returns an error for
124// missing credentials: the TUI can start without them and launch a
125// login flow. requireCred controls whether missing credentials are a
126// hard error (used by print/json modes).
127func Resolve(args Args, requireCred bool) (Resolved, error) {
128	cfg, _ := LoadConfig()
129
130	// User-requested provider (explicit > config > default).
131	provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
132	if provName != "anthropic" && provName != "openai" && provName != "kimi" && provName != "google" && provName != "ollama" {
133		// Unknown provider (maybe removed or renamed). Fall back to
134		// the first provider that has credentials, or anthropic.
135		provName = "anthropic"
136		if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
137			provName = "openai"
138		}
139		if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
140			provName = "kimi"
141		}
142		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
143			provName = "anthropic"
144		}
145		// Reset the saved config so this doesn't keep happening.
146		cfg.Provider = provName
147		cfg.Model = ""
148		_ = SaveConfig(cfg)
149	}
150
151	var (
152		cred      string
153		method    string
154		accountID string
155		credErr   error
156	)
157	if provName == "ollama" {
158		if args.BaseURL == "" {
159			args.BaseURL = "http://localhost:11434"
160		}
161		cred = firstNonEmpty(args.APIKey, "ollama")
162		method = "apikey"
163	} else {
164		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
165	}
166
167	// If the user did NOT explicitly pick a provider and the default one
168	// has no credentials, auto-fall-back to whichever provider is actually
169	// logged in. That way running plain `zot` after `/login` (any provider)
170	// never shows a "not logged in" banner.
171	userPickedProvider := args.Provider != ""
172	if credErr != nil && !userPickedProvider && provName != "ollama" {
173		for _, other := range []string{"anthropic", "openai", "kimi", "google"} {
174			if other == provName {
175				continue
176			}
177			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
178				provName = other
179				cred, method, accountID, credErr = c, m, a, err
180				break
181			}
182		}
183	}
184
185	model := firstNonEmpty(args.Model, cfg.Model)
186	if model == "" {
187		switch provName {
188		case "openai":
189			model = "gpt-5"
190		case "kimi":
191			model = "kimi-for-coding"
192		case "google":
193			model = "gemini-2.5-pro"
194		case "ollama":
195			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
196		default:
197			model = provider.DefaultModel.ID
198		}
199	}
200	// If the resolved model belongs to a different provider (e.g. config
201	// says gpt-5 but we fell back to anthropic), pick that provider's default.
202	if provName != "ollama" {
203		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
204			switch provName {
205			case "openai":
206				model = "gpt-5"
207			case "kimi":
208				model = "kimi-for-coding"
209			case "google":
210				model = "gemini-2.5-pro"
211			default:
212				model = provider.DefaultModel.ID
213			}
214		}
215	}
216	resolvedModel, err := provider.FindModel(provName, model)
217	if err != nil && provName != "ollama" {
218		return Resolved{}, err
219	}
220	if err != nil && provName == "ollama" {
221		resolvedModel = provider.Model{
222			Provider:      "ollama",
223			ID:            model,
224			DisplayName:   model,
225			ContextWindow: 32768,
226			MaxOutput:     8192,
227			BaseURL:       args.BaseURL,
228			Source:        "ollama",
229		}
230	}
231
232	// If the model defines a base URL (e.g. local ollama) and the
233	// user didn't pass --base-url, use the model's URL.
234	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
235		args.BaseURL = resolvedModel.BaseURL
236	}
237
238	// If the model has a base URL, credentials are optional (local
239	// models like ollama don't need real API keys).
240	if resolvedModel.BaseURL != "" && credErr != nil {
241		cred = "ollama"
242		credErr = nil
243		requireCred = false
244	}
245
246	if credErr != nil && requireCred {
247		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
248			credErr, envVarName(provName))
249	}
250
251	sandbox := tools.NewSandbox(args.CWD)
252	reg := buildToolRegistry(args, args.CWD, sandbox)
253
254	// Skill discovery: scan project + global locations + built-in
255	// skills shipped with the binary. If any are found, register
256	// the on-demand `skill` loader tool plus a system-prompt
257	// manifest so the model knows what's available.
258	//
259	// --no-skill bypasses the entire mechanism: no manifest in the
260	// system prompt, no `skill` tool in the registry. Useful for a
261	// clean-room run with zero extra context biasing the model.
262	var (
263		discovered    []*skills.Skill
264		skillTool     *skills.Tool
265		skillAddendum string
266	)
267	if !args.NoSkill {
268		homeDir, _ := os.UserHomeDir()
269		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
270		if len(discovered) > 0 {
271			skillTool = skills.NewTool(discovered)
272			reg[skillTool.Name()] = skillTool
273			skillAddendum = skills.SystemPromptAddendum(discovered)
274		}
275	}
276	_ = skillTool
277
278	summaries := toolSummaries(reg, args)
279
280	append_ := append([]string(nil), args.AppendSystemPrompt...)
281	if skillAddendum != "" {
282		append_ = append(append_, skillAddendum)
283	}
284
285	// Custom system prompt resolution order:
286	//   1. --system-prompt flag (highest priority; ad-hoc per run)
287	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
288	//   3. built-in default (defaultIdentity + defaultGuidelines)
289	custom := args.SystemPrompt
290	if custom == "" {
291		custom = readUserSystemPrompt(ZotHome())
292	}
293
294	sys := BuildSystemPrompt(SystemPromptOpts{
295		CWD:    args.CWD,
296		Tools:  summaries,
297		Custom: custom,
298		Append: append_,
299	})
300
301	reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
302
303	max := args.MaxSteps // 0 = unlimited
304
305	return Resolved{
306		Provider:         provName,
307		Model:            model,
308		Credential:       cred,
309		AuthMethod:       method,
310		AccountID:        accountID,
311		BaseURL:          args.BaseURL,
312		CWD:              args.CWD,
313		Reasoning:        reasoning,
314		ToolRegistry:     reg,
315		ToolSummary:      summaries,
316		SystemPrompt:     sys,
317		MaxSteps:         max,
318		Sandbox:          sandbox,
319		SkillTool:        skillTool,
320		systemAppend:     append_,
321		systemCustom:     custom,
322		toolDescriptions: descMapFromSummaries(summaries),
323	}, nil
324}
325
326// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
327// trimmed contents, or "" when the file is missing / unreadable /
328// empty. Errors are intentionally swallowed: the file is optional,
329// and any failure to read it should fall back to the built-in
330// default system prompt rather than crash the run.
331func readUserSystemPrompt(zotHome string) string {
332	if zotHome == "" {
333		return ""
334	}
335	path := filepath.Join(zotHome, "SYSTEM.md")
336	raw, err := os.ReadFile(path)
337	if err != nil {
338		return ""
339	}
340	return strings.TrimSpace(string(raw))
341}
342
343// descMapFromSummaries indexes the human-readable descriptions for
344// the renderToolsSection rebuild path.
345func descMapFromSummaries(summaries []ToolSummary) map[string]string {
346	out := make(map[string]string, len(summaries))
347	for _, s := range summaries {
348		out[s.Name] = s.Description
349	}
350	return out
351}
352
353// NewClient returns a provider.Client for r, choosing the auth mode
354// based on r.AuthMethod. Panics if no credential is present; callers
355// must check HasCredential() first.
356func (r Resolved) NewClient() provider.Client {
357	if !r.HasCredential() {
358		panic("NewClient called without credential; check HasCredential first")
359	}
360	switch r.Provider {
361	case "ollama":
362		return provider.NewOpenAI(r.Credential, r.BaseURL)
363	case "kimi":
364		inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
365		if r.AuthMethod == "oauth" {
366			return r.wrapWithRefresh(inner)
367		}
368		return inner
369	case "openai":
370		if r.AuthMethod == "oauth" {
371			inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
372			return r.wrapWithRefresh(inner)
373		}
374		return provider.NewOpenAI(r.Credential, r.BaseURL)
375	case "google":
376		// API-key only path. Gemini Generative Language API.
377		return provider.NewGemini(r.Credential, r.BaseURL)
378	default:
379		if r.AuthMethod == "oauth" {
380			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
381			return r.wrapWithRefresh(inner)
382		}
383		return provider.NewAnthropic(r.Credential, r.BaseURL)
384	}
385}
386
387// wrapWithRefresh wraps an OAuth client so the access token is
388// refreshed automatically before each API call. Without this, long
389// sessions (hours) silently fail when the 1-hour token expires.
390func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
391	provName := r.Provider
392	baseURL := r.BaseURL
393	accountID := r.AccountID
394
395	refreshFn := func(ctx context.Context) (string, error) {
396		tok, err := refreshIfExpired(provName, loadOAuthToken(provName))
397		if err != nil {
398			return "", err
399		}
400		return tok.AccessToken, nil
401	}
402
403	factory := func(token string) provider.Client {
404		switch provName {
405		case "openai":
406			return provider.NewOpenAICodex(token, accountID, baseURL)
407		case "kimi":
408			return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
409		default:
410			return provider.NewAnthropicOAuth(token, baseURL)
411		}
412	}
413
414	return provider.NewRefreshingClient(inner, refreshFn, factory)
415}
416
417// UseSandbox replaces the sandbox pointer that every tool in r's
418// registry references. Used to keep the /jail state stable across
419// agent rebuilds (e.g. /login, /model switching providers).
420func (r *Resolved) UseSandbox(s *tools.Sandbox) {
421	if s == nil || r == nil {
422		return
423	}
424	r.Sandbox = s
425	for name, t := range r.ToolRegistry {
426		switch v := t.(type) {
427		case *tools.ReadTool:
428			v.Sandbox = s
429		case *tools.WriteTool:
430			v.Sandbox = s
431		case *tools.EditTool:
432			v.Sandbox = s
433		case *tools.BashTool:
434			v.Sandbox = s
435		}
436		_ = name
437	}
438}
439
440// NewAgent constructs a core.Agent from r. Requires a credential.
441func (r Resolved) NewAgent() *core.Agent {
442	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
443	a.MaxSteps = r.MaxSteps
444	a.Reasoning = r.Reasoning
445	return a
446}
447
448func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
449	if args.NoTools {
450		return core.Registry{}
451	}
452	all := map[string]core.Tool{
453		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
454		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
455		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
456		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
457	}
458	reg := core.Registry{}
459	if len(args.Tools) == 0 {
460		for _, t := range all {
461			reg[t.Name()] = t
462		}
463		return reg
464	}
465	for _, name := range args.Tools {
466		if t, ok := all[name]; ok {
467			reg[name] = t
468		}
469	}
470	return reg
471}
472
473func toolSummaries(reg core.Registry, args Args) []ToolSummary {
474	order := []string{"read", "write", "edit", "bash"}
475	var out []ToolSummary
476	for _, name := range order {
477		if t, ok := reg[name]; ok {
478			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
479		}
480	}
481	return out
482}
483
484func firstNonEmpty(vals ...string) string {
485	for _, v := range vals {
486		if v != "" {
487			return v
488		}
489	}
490	return ""
491}
492
493func kimiCodeHeaders() map[string]string {
494	host, _ := os.Hostname()
495	if host == "" {
496		host = "unknown"
497	}
498	deviceID := ""
499	if home, err := os.UserHomeDir(); err == nil {
500		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
501			deviceID = strings.TrimSpace(string(b))
502		}
503	}
504	if deviceID == "" {
505		deviceID = "zot"
506	}
507	return map[string]string{
508		"User-Agent":         "KimiCLI/1.41.0",
509		"X-Msh-Platform":     "kimi_cli",
510		"X-Msh-Version":      "1.41.0",
511		"X-Msh-Device-Name":  host,
512		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
513		"X-Msh-Os-Version":   runtime.GOOS,
514		"X-Msh-Device-Id":    deviceID,
515	}
516}
517
518func envVarName(provider string) string {
519	switch provider {
520	case "openai":
521		return "OPENAI"
522	case "kimi":
523		return "KIMI"
524	case "google":
525		return "GEMINI"
526	case "ollama":
527		return "OLLAMA"
528	default:
529		return "ANTHROPIC"
530	}
531}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close