mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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.
386 lines
11 KiB
Go
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"
|
|
}
|
|
}
|