zot/internal/agent/build.go
patriceckhart b6fc3fd886 rename: /lock -> /jail, /unlock -> /unjail
User-facing slash commands renamed to /jail and /unjail. The
internal Sandbox type (Lock/Unlock/Locked methods, atomic.Bool
field) keeps its mutex-style names because those describe the
implementation, not the feature. Everything the user sees swaps:

- slashCatalog: /jail + /unjail entries and descriptions.
- runSlash handlers: case "/jail" / case "/unjail"; status line
  reports "jailed to <cwd>" / "unjailed".
- Status bar tag: "· jailed · ~/cwd" (was "· locked ·").
- Sandbox error messages: "jailed: path X is outside sandbox
  root Y (use /unjail to disable)" etc.
- README: table rows, section heading, body text, busy-mode
  section all updated.
- Website (/Users/pat/Sites/zot): Tools section prose updated.
- SDK doc comment in pkg/zotcore refers to /jail.

Internal identifiers (Sandbox, Lock(), Unlock(), Locked(),
CheckPath, CheckCommand, slashCancelsTurn switch) unchanged.

Verified: go vet clean, go test -race ./... clean, bun
typecheck + lint + build clean on the site.
2026-04-20 08:57:40 +02:00

386 lines
11 KiB
Go

package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/patriceckhart/zot/internal/agent/tools"
"github.com/patriceckhart/zot/internal/core"
"github.com/patriceckhart/zot/internal/provider"
"github.com/patriceckhart/zot/internal/skills"
)
// Resolved is the effective configuration after merging CLI, config, defaults.
type Resolved struct {
Provider string
Model string
Credential string // api key or oauth access token
AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
AccountID string // ChatGPT account id (for openai oauth), "" otherwise
BaseURL string
CWD string
Reasoning string
ToolRegistry core.Registry
ToolSummary []ToolSummary
SystemPrompt string
MaxSteps int
Sandbox *tools.Sandbox
// SkillTool is the on-demand skill loader registered with the
// agent's tool registry, or nil if no SKILL.md files were
// discovered. Exposed so the tui can list / preview skills.
SkillTool *skills.Tool
// Bookkeeping for MergeExtensionTools. Captured at Resolve time
// so the system prompt can be rebuilt later without re-running
// resolve.
systemAppend []string
systemCustom string
toolDescriptions map[string]string
}
// HasCredential reports whether a credential was resolved.
func (r Resolved) HasCredential() bool { return r.Credential != "" }
// MergeExtensionTools folds every tool registered by an extension
// into r's ToolRegistry and re-renders the system prompt's tool
// summary so the model sees both built-in and extension tools.
//
// Idempotent: calling twice with the same manager state has no
// effect on the second pass (existing names are preserved). Built-in
// tools always win on conflict.
func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
if mgr == nil {
return
}
infos := mgr.Tools()
if len(infos) == 0 {
return
}
changed := false
for _, info := range infos {
if _, exists := r.ToolRegistry[info.Name]; exists {
continue
}
r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
changed = true
}
if !changed {
return
}
// Re-render the system prompt with the merged tool list. Skill
// addendum is preserved by walking the existing append slice.
append_ := r.systemAppend
r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
CWD: r.CWD,
Tools: toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
Custom: r.systemCustom,
Append: append_,
})
}
// ExtensionToolSource is the slice of the extension manager that
// MergeExtensionTools needs. Lives here as an interface so the
// build package doesn't import internal/agent/extensions (which
// imports core, which imports... avoid the cycle).
type ExtensionToolSource interface {
Tools() []ExtensionToolInfo
NewExtensionTool(info ExtensionToolInfo) core.Tool
}
// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
// ExtensionToolSource here without importing the extensions
// package. The cli wires a tiny adapter to bridge them.
type ExtensionToolInfo struct {
Extension string
Name string
Description string
Schema []byte
}
// toolSummariesFromRegistry rebuilds the system-prompt tool list
// from a (possibly extended) registry, using cached descriptions for
// the human-readable summary text.
func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
out := make([]ToolSummary, 0, len(reg))
for name, t := range reg {
desc := t.Description()
if d, ok := cached[name]; ok && d != "" {
desc = d
}
out = append(out, ToolSummary{Name: name, Description: desc})
}
return out
}
// Resolve merges args, config, and env into a Resolved set.
//
// Unlike the earlier version, Resolve NEVER returns an error for
// missing credentials: the TUI can start without them and launch a
// login flow. requireCred controls whether missing credentials are a
// hard error (used by print/json modes).
func Resolve(args Args, requireCred bool) (Resolved, error) {
cfg, _ := LoadConfig()
// User-requested provider (explicit > config > default).
provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
if provName != "anthropic" && provName != "openai" {
return Resolved{}, fmt.Errorf("provider must be anthropic or openai (got %q)", provName)
}
// Try the requested provider first.
cred, method, accountID, credErr := ResolveCredentialFull(provName, args.APIKey)
// If the user did NOT explicitly pick a provider and the default one
// has no credentials, auto-fall-back to whichever provider is actually
// logged in. That way running plain `zot` after `/login` (any provider)
// never shows a "not logged in" banner.
userPickedProvider := args.Provider != ""
if credErr != nil && !userPickedProvider {
other := "openai"
if provName == "openai" {
other = "anthropic"
}
if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
provName = other
cred, method, accountID, credErr = c, m, a, err
}
}
model := firstNonEmpty(args.Model, cfg.Model)
if model == "" {
if provName == "openai" {
model = "gpt-5"
} else {
model = provider.DefaultModel.ID
}
}
// If the resolved model belongs to a different provider (e.g. config
// says gpt-5 but we fell back to anthropic), pick that provider's default.
if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
if provName == "openai" {
model = "gpt-5"
} else {
model = provider.DefaultModel.ID
}
}
if _, err := provider.FindModel(provName, model); err != nil {
return Resolved{}, err
}
if credErr != nil && requireCred {
return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
credErr, envVarName(provName))
}
sandbox := tools.NewSandbox(args.CWD)
reg := buildToolRegistry(args, args.CWD, sandbox)
// Skill discovery: scan project + global locations + built-in
// skills shipped with the binary. If any are found, register
// the on-demand `skill` loader tool plus a system-prompt
// manifest so the model knows what's available.
//
// --no-skill bypasses the entire mechanism: no manifest in the
// system prompt, no `skill` tool in the registry. Useful for a
// clean-room run with zero extra context biasing the model.
var (
discovered []*skills.Skill
skillTool *skills.Tool
skillAddendum string
)
if !args.NoSkill {
homeDir, _ := os.UserHomeDir()
discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
if len(discovered) > 0 {
skillTool = skills.NewTool(discovered)
reg[skillTool.Name()] = skillTool
skillAddendum = skills.SystemPromptAddendum(discovered)
}
}
_ = skillTool
summaries := toolSummaries(reg, args)
append_ := append([]string(nil), args.AppendSystemPrompt...)
if skillAddendum != "" {
append_ = append(append_, skillAddendum)
}
// Custom system prompt resolution order:
// 1. --system-prompt flag (highest priority; ad-hoc per run)
// 2. $ZOT_HOME/SYSTEM.md (persistent user override)
// 3. built-in default (defaultIdentity + defaultGuidelines)
custom := args.SystemPrompt
if custom == "" {
custom = readUserSystemPrompt(ZotHome())
}
sys := BuildSystemPrompt(SystemPromptOpts{
CWD: args.CWD,
Tools: summaries,
Custom: custom,
Append: append_,
})
reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
max := args.MaxSteps
if max <= 0 {
max = 50
}
return Resolved{
Provider: provName,
Model: model,
Credential: cred,
AuthMethod: method,
AccountID: accountID,
BaseURL: args.BaseURL,
CWD: args.CWD,
Reasoning: reasoning,
ToolRegistry: reg,
ToolSummary: summaries,
SystemPrompt: sys,
MaxSteps: max,
Sandbox: sandbox,
SkillTool: skillTool,
systemAppend: append_,
systemCustom: custom,
toolDescriptions: descMapFromSummaries(summaries),
}, nil
}
// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
// trimmed contents, or "" when the file is missing / unreadable /
// empty. Errors are intentionally swallowed: the file is optional,
// and any failure to read it should fall back to the built-in
// default system prompt rather than crash the run.
func readUserSystemPrompt(zotHome string) string {
if zotHome == "" {
return ""
}
path := filepath.Join(zotHome, "SYSTEM.md")
raw, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(raw))
}
// descMapFromSummaries indexes the human-readable descriptions for
// the renderToolsSection rebuild path.
func descMapFromSummaries(summaries []ToolSummary) map[string]string {
out := make(map[string]string, len(summaries))
for _, s := range summaries {
out[s.Name] = s.Description
}
return out
}
// NewClient returns a provider.Client for r, choosing the auth mode
// based on r.AuthMethod. Panics if no credential is present; callers
// must check HasCredential() first.
func (r Resolved) NewClient() provider.Client {
if !r.HasCredential() {
panic("NewClient called without credential; check HasCredential first")
}
if r.Provider == "openai" {
if r.AuthMethod == "oauth" {
return provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
}
return provider.NewOpenAI(r.Credential, r.BaseURL)
}
if r.AuthMethod == "oauth" {
return provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
}
return provider.NewAnthropic(r.Credential, r.BaseURL)
}
// UseSandbox replaces the sandbox pointer that every tool in r's
// registry references. Used to keep the /jail state stable across
// agent rebuilds (e.g. /login, /model switching providers).
func (r *Resolved) UseSandbox(s *tools.Sandbox) {
if s == nil || r == nil {
return
}
r.Sandbox = s
for name, t := range r.ToolRegistry {
switch v := t.(type) {
case *tools.ReadTool:
v.Sandbox = s
case *tools.WriteTool:
v.Sandbox = s
case *tools.EditTool:
v.Sandbox = s
case *tools.BashTool:
v.Sandbox = s
}
_ = name
}
}
// NewAgent constructs a core.Agent from r. Requires a credential.
func (r Resolved) NewAgent() *core.Agent {
a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
a.MaxSteps = r.MaxSteps
a.Reasoning = r.Reasoning
return a
}
func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
if args.NoTools {
return core.Registry{}
}
all := map[string]core.Tool{
"read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
"edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox},
"bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
}
reg := core.Registry{}
if len(args.Tools) == 0 {
for _, t := range all {
reg[t.Name()] = t
}
return reg
}
for _, name := range args.Tools {
if t, ok := all[name]; ok {
reg[name] = t
}
}
return reg
}
func toolSummaries(reg core.Registry, args Args) []ToolSummary {
order := []string{"read", "write", "edit", "bash"}
var out []ToolSummary
for _, name := range order {
if t, ok := reg[name]; ok {
out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
}
}
return out
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func envVarName(provider string) string {
switch provider {
case "openai":
return "OPENAI"
default:
return "ANTHROPIC"
}
}