ollama/cmd/agent_tui.go
2026-06-26 17:26:38 -07:00

885 lines
26 KiB
Go

package cmd
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
coreagent "github.com/ollama/ollama/agent"
"github.com/ollama/ollama/agent/skills"
agentstore "github.com/ollama/ollama/agent/store"
agenttools "github.com/ollama/ollama/agent/tools"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/filedata"
"github.com/ollama/ollama/cmd/launch"
agentchat "github.com/ollama/ollama/cmd/tui/chat"
"github.com/ollama/ollama/format"
internalcloud "github.com/ollama/ollama/internal/cloud"
"github.com/ollama/ollama/internal/modelref"
"github.com/ollama/ollama/types/model"
)
type AgentTUIOptions struct {
Model string
Prompt string
Messages []api.Message
System string
Images []api.ImageData
Format string
Options map[string]any
Think *api.ThinkValue
KeepAlive *api.Duration
ContextWindowTokens int
Resume bool
AutoApproveTools bool
Policy coreagent.RunPolicy
Verbose bool
MultiModal bool
Skill string
Skills *skills.Catalog
}
func agentOptionsFromRunOptions(opts runOptions) AgentTUIOptions {
return AgentTUIOptions{
Model: opts.Model,
Prompt: opts.Prompt,
Messages: opts.Messages,
System: opts.System,
Images: opts.Images,
Format: opts.Format,
Options: opts.Options,
Think: opts.Think,
KeepAlive: opts.KeepAlive,
ContextWindowTokens: opts.ContextWindowTokens,
Resume: opts.Resume,
AutoApproveTools: opts.AutoApproveTools,
Verbose: opts.Verbose,
MultiModal: opts.MultiModal,
}
}
type agentSurface int
const (
agentSurfaceTUI agentSurface = iota
agentSurfaceHeadless
)
func resolveAgentRunPolicy(opts AgentTUIOptions, surface agentSurface) coreagent.RunPolicy {
policy := opts.Policy
if surface == agentSurfaceHeadless {
policy.ToolMode = coreagent.ToolModeDisabled
}
if opts.AutoApproveTools {
policy.ToolMode = coreagent.ToolModeFullAccess
}
return policy
}
type agentRunSetup struct {
opts AgentTUIOptions
client *api.Client
cwd string
store *agentstore.Store
newChatID func(context.Context) (string, error)
chatID string
messages []api.Message
skills *skills.Catalog
registry *coreagent.Registry
approval coreagent.ApprovalHandler
}
func (s *agentRunSetup) close() {
if s != nil && s.store != nil {
_ = s.store.Close()
s.store = nil
}
}
func newAgentRunSetup(cmd *cobra.Command, opts AgentTUIOptions, resumeLatestWithoutModel bool) (*agentRunSetup, error) {
client, err := api.ClientFromEnvironment()
if err != nil {
return nil, err
}
cwd, err := os.Getwd()
if err != nil {
cwd = ""
}
var store *agentstore.Store
if openedStore, err := agentstore.New(""); err != nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m chat persistence unavailable: %v\n", err)
} else {
store = openedStore
}
newChatID := func(ctx context.Context) (string, error) {
u, err := uuid.NewV7()
if err != nil {
u = uuid.Must(uuid.NewRandom())
}
chatID := u.String()
if store != nil {
if err := store.EnsureChat(ctx, chatID, ""); err != nil {
return "", err
}
}
return chatID, nil
}
chatID := ""
var resumedMessages []api.Message
if opts.Resume {
if store == nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m chat resume unavailable: persistence is disabled\n")
} else {
chat, err := resumeAgentChat(cmd.Context(), store, opts.Model, resumeLatestWithoutModel)
if err == nil {
chatID = chat.ID
if opts.Model == "" {
opts.Model = chat.Model
}
resumedMessages = chat.Messages
} else if errors.Is(err, sql.ErrNoRows) {
if resumeLatestWithoutModel && opts.Model == "" {
if store != nil {
_ = store.Close()
}
return nil, errors.New("no saved chat to resume; pass a model to start a new chat")
}
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m no saved chat for %s; starting a new chat\n", opts.Model)
} else if resumeLatestWithoutModel {
if store != nil {
_ = store.Close()
}
return nil, fmt.Errorf("could not resume chat: %w", err)
} else {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not resume chat: %v\n", err)
}
}
}
if strings.TrimSpace(opts.Model) == "" {
if store != nil {
_ = store.Close()
}
return nil, errors.New("model is required")
}
if chatID == "" {
var err error
chatID, err = newChatID(cmd.Context())
if err != nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not create persistent chat: %v\n", err)
if store != nil {
_ = store.Close()
}
store = nil
chatID, _ = newChatID(cmd.Context())
}
}
if store != nil {
if err := store.SetChatModel(cmd.Context(), chatID, opts.Model); err != nil {
_ = store.Close()
return nil, err
}
}
skillCatalog := opts.Skills
if skillCatalog == nil {
skillCatalog = loadAgentSkills()
}
registry := agentToolsRegistry(cmd.Context(), client, opts.Model, skillCatalog)
opts.ContextWindowTokens = contextWindowTokensForRun(cmd.Context(), client, opts.Model, opts.ContextWindowTokens)
approval := opts.Policy.ReviewApprovalHandler(nil)
messages := slices.Clone(resumedMessages)
messages = append(messages, opts.Messages...)
return &agentRunSetup{
opts: opts,
client: client,
cwd: cwd,
store: store,
newChatID: newChatID,
chatID: chatID,
messages: messages,
skills: skillCatalog,
registry: registry,
approval: approval,
}, nil
}
func resumeAgentChat(ctx context.Context, store *agentstore.Store, modelName string, latestWithoutModel bool) (*agentstore.AgentChat, error) {
if latestWithoutModel && modelName == "" {
return store.LatestChat(ctx)
}
return store.LatestChatForModel(ctx, modelName)
}
func GenerateAgentTUI(cmd *cobra.Command, opts AgentTUIOptions) error {
opts.Policy = resolveAgentRunPolicy(opts, agentSurfaceTUI)
setup, err := newAgentRunSetup(cmd, opts, false)
if err != nil {
return err
}
defer setup.close()
opts = setup.opts
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
Model: opts.Model,
ChatID: setup.chatID,
Messages: setup.messages,
Client: setup.client,
Store: setup.store,
Tools: setup.registry,
ToolRegistryForModel: func(ctx context.Context, model string) *coreagent.Registry {
return agentToolsRegistry(ctx, setup.client, model, setup.skills)
},
MultiModalForModel: func(ctx context.Context, model string) bool {
return agentModelSupportsMultimodal(ctx, setup.client, model)
},
ModelOptions: func(ctx context.Context) ([]agentchat.ModelOption, error) {
return agentModelOptions(ctx, setup.client)
},
OnModelSelected: func(_ context.Context, model string) error {
return config.SetLastModel(model)
},
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry) string {
modelSystem := opts.System
if strings.TrimSpace(model) != strings.TrimSpace(opts.Model) {
modelSystem = agentSystemFromShow(ctx, setup.client, model)
}
return agentSystemPrompt(model, setup.skills, registry != nil && registry.Has("skill"), modelSystem, "")
},
Approval: setup.approval,
Policy: opts.Policy,
Skills: setup.skills,
SystemPrompt: agentSystemPrompt(opts.Model, setup.skills, setup.registry != nil && setup.registry.Has("skill"), opts.System, ""),
WorkingDir: setup.cwd,
Format: opts.Format,
Options: opts.Options,
Think: opts.Think,
KeepAlive: opts.KeepAlive,
Images: slices.Clone(opts.Images),
MultiModal: opts.MultiModal,
Verbose: opts.Verbose,
Compactor: coreagent.NewSimpleCompactor(setup.client, setup.store, coreagent.CompactionOptions{
ContextWindowTokens: opts.ContextWindowTokens,
}),
ContextWindowTokens: opts.ContextWindowTokens,
ContextWindowTokensForModel: func(ctx context.Context, model string, fallback int) int {
return contextWindowTokensForRun(ctx, setup.client, model, fallback)
},
PreloadModel: func(ctx context.Context, model string, think *api.ThinkValue) error {
preloadOpts := opts
preloadOpts.Think = think
return preloadAgentModelIfLocal(ctx, setup.client, preloadOpts, model)
},
CheckCloudModel: func(ctx context.Context, model, requiredPlan string) error {
return ensureCloudModelAccess(ctx, setup.client, model, requiredPlan)
},
OpenBrowser: func(url string) {
launch.OpenBrowser(url)
},
PollCloudAuth: func(ctx context.Context) (string, bool) {
user, err := setup.client.Whoami(ctx)
if err != nil || user == nil || user.Name == "" {
return "", false
}
return user.Name, true
},
NewChat: setup.newChatID,
})
if err != nil {
return err
}
return nil
}
func GenerateAgentHeadless(cmd *cobra.Command, opts AgentTUIOptions) error {
if strings.TrimSpace(opts.Prompt) == "" {
return errors.New("agent headless mode requires a prompt or stdin")
}
opts.Policy = resolveAgentRunPolicy(opts, agentSurfaceHeadless)
setup, err := newAgentRunSetup(cmd, opts, true)
if err != nil {
return err
}
defer setup.close()
opts = setup.opts
if opts.Model == "" {
return errors.New("model is required")
}
prompt := opts.Prompt
images := slices.Clone(opts.Images)
if opts.MultiModal {
var files []filedata.File
prompt, files, err = filedata.ExtractWithFiles(prompt)
if err != nil {
fmt.Fprintf(os.Stderr, "Couldn't process file: %q\n", err)
return err
}
imgs := make([]api.ImageData, 0, len(files))
for _, file := range files {
switch filedata.Kind(file.Path) {
case "audio":
fmt.Fprintf(os.Stderr, "Added audio '%s'\n", file.Path)
default:
fmt.Fprintf(os.Stderr, "Added image '%s'\n", file.Path)
}
imgs = append(imgs, file.Data)
}
images = imgs
}
tools := opts.Policy.Tools(setup.registry)
toolPrompt := ""
if tools == nil {
toolPrompt = "Tools are unavailable in this headless run because --auto-approve-tools was not passed. Answer directly without tool calls."
}
systemPrompt := agentSystemPrompt(opts.Model, setup.skills, tools != nil && tools.Has("skill"), opts.System, toolPrompt)
newMessages := []api.Message{{Role: "user", Content: prompt, Images: images}}
if strings.TrimSpace(opts.Skill) == "" {
if skill, request, ok := skillFromPrompt(setup.skills, prompt); ok {
opts.Skill = skill.Name
prompt = request
}
}
if strings.TrimSpace(opts.Skill) != "" {
skill, ok := setup.skills.Find(opts.Skill)
if !ok {
return fmt.Errorf("unknown skill: %s", opts.Skill)
}
manualMessages, err := agenttools.ManualSkillMessages(skill, prompt, len(setup.messages)+1)
if err != nil {
return err
}
manualMessages[0].Images = images
newMessages = manualMessages
}
runCtx, cancel := context.WithCancel(cmd.Context())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT)
defer signal.Stop(sigChan)
go func() {
select {
case <-sigChan:
cancel()
case <-runCtx.Done():
}
}()
headlessSink := &agentHeadlessEventSink{}
eventSink := coreagent.EventSink(headlessSink)
session := &coreagent.Session{
Client: setup.client,
Store: setup.store,
Events: eventSink,
Tools: tools,
Approval: opts.Policy.ApprovalHandler(nil),
WorkingDir: setup.cwd,
Compactor: coreagent.NewSimpleCompactor(setup.client, setup.store, coreagent.CompactionOptions{
ContextWindowTokens: opts.ContextWindowTokens,
}),
}
result, err := session.Run(runCtx, coreagent.RunOptions{
ChatID: setup.chatID,
Model: opts.Model,
SystemPrompt: systemPrompt,
Messages: setup.messages,
NewMessages: newMessages,
Format: opts.Format,
Options: opts.Options,
Think: opts.Think,
KeepAlive: opts.KeepAlive,
Policy: opts.Policy,
})
if err != nil {
return err
}
headlessSink.Finish()
if headlessSink.denied {
return errors.New("tool execution denied")
}
verbose := opts.Verbose
if cmd != nil && cmd.Flags().Lookup("verbose") != nil {
flagVerbose, err := cmd.Flags().GetBool("verbose")
if err != nil {
return err
}
verbose = verbose || flagVerbose
}
if verbose {
result.Latest.Summary()
}
return nil
}
func skillFromPrompt(catalog *skills.Catalog, prompt string) (skills.Skill, string, bool) {
if catalog == nil || catalog.Empty() {
return skills.Skill{}, "", false
}
prompt = strings.TrimSpace(prompt)
if !strings.HasPrefix(prompt, "/") {
return skills.Skill{}, "", false
}
command, rest, _ := strings.Cut(prompt, " ")
skill, ok := catalog.Find(command)
return skill, strings.TrimSpace(rest), ok
}
type agentHeadlessEventSink struct {
wroteContent bool
contentEndedWithNewline bool
denied bool
}
func (s *agentHeadlessEventSink) Emit(event coreagent.Event) error {
switch event.Type {
case coreagent.EventThinkingDelta:
case coreagent.EventMessageDelta:
if event.Content != "" {
fmt.Fprint(os.Stdout, event.Content)
s.wroteContent = true
s.contentEndedWithNewline = strings.HasSuffix(event.Content, "\n")
}
case coreagent.EventToolFinished:
s.ensureContentNewline()
status := "done"
if event.Status != "done" || event.Error != "" {
status = "failed"
}
fmt.Fprintf(os.Stderr, "• %s %s\n", coreagent.ToolInvocationLabel(event.ToolName, event.Args), status)
case coreagent.EventToolsUnavailable:
fmt.Fprintln(os.Stderr, "Tools are unavailable for this model.")
case coreagent.EventRunFinished:
if event.Status == "denied" {
s.denied = true
}
case coreagent.EventCompactionSkipped:
if event.Content != "" {
fmt.Fprintf(os.Stderr, "%s\n", event.Content)
}
case coreagent.EventError:
if event.Error != "" {
fmt.Fprintf(os.Stderr, "error: %s\n", event.Error)
}
}
return nil
}
func (s *agentHeadlessEventSink) Finish() {
s.ensureContentNewline()
}
func (s *agentHeadlessEventSink) ensureContentNewline() {
if s.wroteContent && !s.contentEndedWithNewline {
fmt.Fprintln(os.Stdout)
s.contentEndedWithNewline = true
}
}
func loadAgentSkills() *skills.Catalog {
catalog, err := skills.LoadDefault()
if err != nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not load skills: %v\n", err)
return nil
}
for _, warning := range catalog.Warnings {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m skill ignored: %s\n", warning)
}
return catalog
}
func agentSystemPrompt(modelName string, catalog *skills.Catalog, skillToolAvailable bool, modelSystem string, extra string) string {
return agentSystemPromptAt(time.Now(), modelName, catalog, skillToolAvailable, modelSystem, extra)
}
func agentSystemPromptAt(now time.Time, modelName string, catalog *skills.Catalog, skillToolAvailable bool, modelSystem string, extra string) string {
var parts []string
parts = append(parts, agentDefaultSystemPrompt(now, modelName))
if strings.TrimSpace(modelSystem) != "" {
parts = append(parts, strings.TrimSpace(modelSystem))
}
if catalogPrompt := catalog.SystemPrompt(skillToolAvailable); strings.TrimSpace(catalogPrompt) != "" {
parts = append(parts, catalogPrompt)
}
if strings.TrimSpace(extra) != "" {
parts = append(parts, strings.TrimSpace(extra))
}
return strings.Join(parts, "\n\n")
}
func agentSystemFromShow(ctx context.Context, client *api.Client, modelName string) string {
if client == nil || strings.TrimSpace(modelName) == "" {
return ""
}
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
if err != nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not load model system prompt: %v\n", err)
return ""
}
return resp.System
}
func agentDefaultSystemPrompt(now time.Time, modelName string) string {
date := now.Format("Monday, January 2, 2006")
shellName := "bash"
if runtime.GOOS == "windows" {
shellName = "PowerShell"
}
return strings.Join([]string{
"You are running in Ollama, in a harness to help the user accomplish tasks, and the model is " + modelName + ".",
"",
"Current date: " + date + ".",
"",
"Be concise, practical, and action-oriented. Use tools when they materially help. Verify current or fast-changing facts with web tools when available; otherwise state uncertainty.",
"",
"Use " + shellName + " carefully. Prefer read-only inspection first. Stay within the current working directory unless explicitly asked. Surface intent before risky actions such as writes, deletes, moves, installs, git state changes, service changes, sudo, secrets access, network scripts, or commands outside the working directory. Request approval when required and do not work around denied approvals.",
"",
"Tell the user about meaningful changes, verification, failures, blockers, assumptions, and risks. Summarize routine tool output instead of dumping it.",
}, "\n")
}
func agentModelOptions(ctx context.Context, client *api.Client) ([]agentchat.ModelOption, error) {
if client == nil {
return nil, errors.New("model picker requires an API client")
}
list, err := client.List(ctx)
if err != nil {
return nil, err
}
seen := make(map[string]struct{})
var options []agentchat.ModelOption
add := func(name, description string, recommended bool, requiredPlan string, cloud bool) {
name = strings.TrimSpace(name)
if name == "" {
return
}
key := strings.ToLower(name)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
options = append(options, agentchat.ModelOption{
Name: name,
Description: strings.TrimSpace(description),
Recommended: recommended,
RequiredPlan: requiredPlan,
Cloud: cloud,
})
}
if disabled, known := agentCloudStatusDisabled(ctx, client); !known || !disabled {
if recs, err := client.ModelRecommendationsExperimental(ctx); err == nil {
for _, rec := range recs.Recommendations {
name := strings.TrimSpace(rec.Model)
if !modelref.HasExplicitCloudSource(name) {
continue
}
add(name, agentRecommendationDescription(rec), true, strings.TrimSpace(rec.RequiredPlan), true)
}
}
}
local := slices.Clone(list.Models)
slices.SortStableFunc(local, func(a, b api.ListModelResponse) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
for _, model := range local {
name := strings.TrimSpace(model.Name)
if name == "" {
name = strings.TrimSpace(model.Model)
}
name = strings.TrimSuffix(name, ":latest")
if modelref.HasExplicitCloudSource(name) {
// Cloud-sourced models in the local tags list keep their ":cloud"
// suffix in the name and show arch details without a "cloud" marker.
// If a curated rec already added this model, the dedup above keeps
// the rec entry.
add(name, agentCloudModelDescription(model), false, "", true)
continue
}
add(name, agentLocalModelDescription(model), false, "", false)
}
// Compute availability badges for cloud models based on account state.
badges, signInURLs := cloudAvailabilityBadges(ctx, client, options)
for i := range options {
options[i].AvailabilityBadge = badges[options[i].Name]
options[i].SignInURL = signInURLs[options[i].Name]
}
return options, nil
}
// cloudAvailabilityBadges returns a map of model name → availability badge
// for cloud models that require sign-in or a plan upgrade. It also returns
// a map of model name → sign-in URL for models that require sign-in.
func cloudAvailabilityBadges(ctx context.Context, client *api.Client, options []agentchat.ModelOption) (map[string]string, map[string]string) {
badges := make(map[string]string)
signInURLs := make(map[string]string)
hasCloud := false
for _, opt := range options {
if opt.Cloud {
hasCloud = true
break
}
}
if !hasCloud {
return badges, signInURLs
}
if disabled, known := agentCloudStatusDisabled(ctx, client); known && disabled {
return badges, signInURLs
}
whoamiCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
user, err := client.Whoami(whoamiCtx)
if err != nil {
// Whoami failed — likely not signed in. Extract the sign-in URL
// from the authorization error so we can show it immediately.
var authErr api.AuthorizationError
signInURL := ""
if errors.As(err, &authErr) && authErr.SigninURL != "" {
signInURL = authErr.SigninURL
}
for _, opt := range options {
if opt.Cloud {
badges[opt.Name] = "Sign in required"
if signInURL != "" {
signInURLs[opt.Name] = signInURL
}
}
}
return badges, signInURLs
}
signedIn := user != nil && user.Name != ""
for _, opt := range options {
if !opt.Cloud {
continue
}
if !signedIn {
badges[opt.Name] = "Sign in required"
} else if opt.RequiredPlan != "" && !launch.PlanSatisfies(user.Plan, opt.RequiredPlan) {
badges[opt.Name] = "Upgrade required"
}
}
return badges, signInURLs
}
func agentRecommendationDescription(rec api.ModelRecommendation) string {
var parts []string
if description := strings.TrimSpace(rec.Description); description != "" {
parts = append(parts, description)
} else {
parts = append(parts, "cloud")
}
if rec.ContextLength > 0 {
parts = append(parts, format.HumanNumber(uint64(rec.ContextLength))+" ctx")
}
return strings.Join(parts, " · ")
}
// agentModelArchDescription builds the shared arch-details + context segment
// used by both local and cloud-sourced tag entries: "<family> <params> <quant>
// · N ctx>". Parameter sizes that are raw numbers (common for cloud stubs) are
// humanized, e.g. "27000000000" -> "27B".
func agentModelArchDescription(model api.ListModelResponse) string {
var details []string
if model.Details.Family != "" {
details = append(details, model.Details.Family)
}
if ps := humanizedParameterSize(model.Details.ParameterSize); ps != "" {
details = append(details, ps)
}
if model.Details.QuantizationLevel != "" {
details = append(details, model.Details.QuantizationLevel)
}
var parts []string
if len(details) > 0 {
parts = append(parts, strings.Join(details, " "))
}
if model.Details.ContextLength > 0 {
parts = append(parts, format.HumanNumber(uint64(model.Details.ContextLength))+" ctx")
}
return strings.Join(parts, " · ")
}
func agentLocalModelDescription(model api.ListModelResponse) string {
desc := agentModelArchDescription(model)
if desc == "" {
return "local"
}
return "local · " + desc
}
// agentCloudModelDescription describes a cloud-sourced model present in the
// local tags list. It omits a "cloud" marker (the model name keeps its
// ":cloud"/"-cloud" suffix) and shows the same arch details as local models.
func agentCloudModelDescription(model api.ListModelResponse) string {
return agentModelArchDescription(model)
}
func humanizedParameterSize(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return format.HumanNumber(uint64(f))
}
return s
}
func agentToolsRegistry(ctx context.Context, client *api.Client, modelName string, catalog *skills.Catalog) *coreagent.Registry {
supportsTools, err := agentModelSupportsTools(ctx, client, modelName)
if err != nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not check model capabilities: %v\n", err)
}
if !supportsTools {
return nil
}
registry := coreagent.NewRegistry()
if os.Getenv("OLLAMA_AGENT_DISABLE_SHELL") == "" {
registry.Register(agenttools.NewBash())
}
registry.Register(agenttools.NewRead())
registry.Register(agenttools.NewEdit())
if !catalog.Empty() {
registry.Register(agenttools.NewSkill(catalog))
}
if os.Getenv("OLLAMA_AGENT_DISABLE_WEBSEARCH") == "" {
if disabled, known := agentCloudStatusDisabled(ctx, client); !known || !disabled {
registry.Register(agenttools.NewWebSearch())
registry.Register(agenttools.NewWebFetch())
} else {
fmt.Fprintf(os.Stderr, "%s\n", internalcloud.DisabledError("web search is unavailable"))
}
}
return registry
}
func preloadAgentModelIfLocal(ctx context.Context, client *api.Client, opts AgentTUIOptions, modelName string) error {
modelName = strings.TrimSpace(modelName)
if client == nil || modelName == "" {
return nil
}
info, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
if err != nil {
return err
}
if info.RemoteHost != "" || modelref.HasExplicitCloudSource(modelName) {
return nil
}
return preloadLocalModel(ctx, client, runOptions{
Model: modelName,
KeepAlive: opts.KeepAlive,
Think: opts.Think,
})
}
func agentModelSupportsTools(ctx context.Context, client *api.Client, modelName string) (bool, error) {
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
if err != nil {
return false, err
}
return slices.Contains(resp.Capabilities, model.CapabilityTools), nil
}
func agentModelSupportsMultimodal(ctx context.Context, client *api.Client, modelName string) bool {
if client == nil || strings.TrimSpace(modelName) == "" {
return false
}
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
if err != nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not check model capabilities: %v\n", err)
return false
}
if slices.Contains(resp.Capabilities, model.CapabilityVision) || slices.Contains(resp.Capabilities, model.CapabilityAudio) {
return true
}
if len(resp.ProjectorInfo) != 0 {
return true
}
for key := range resp.ModelInfo {
if strings.Contains(key, ".vision.") {
return true
}
}
return false
}
func agentCloudStatusDisabled(ctx context.Context, client *api.Client) (disabled bool, known bool) {
if internalcloud.Disabled() {
return true, true
}
status, err := client.CloudStatusExperimental(ctx)
if err != nil {
var statusErr api.StatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound {
return false, false
}
return false, false
}
return status.Cloud.Disabled, true
}
// ensureCloudModelAccess checks whether the user is signed in and has a
// sufficient plan to use a cloud model. It returns an AuthorizationError
// (with SigninURL) when sign-in is needed, or a plan error when upgrade is
// needed.
func ensureCloudModelAccess(ctx context.Context, client *api.Client, model, requiredPlan string) error {
if client == nil {
return errors.New("no API client available")
}
if disabled, known := agentCloudStatusDisabled(ctx, client); known && disabled {
return errors.New("remote inference is unavailable")
}
user, err := client.Whoami(ctx)
if err != nil {
return err
}
if user != nil && user.Name != "" {
if requiredPlan != "" && !launch.PlanSatisfies(user.Plan, requiredPlan) {
return fmt.Errorf("plan upgrade required: %s needs plan %s, you have %s", model, requiredPlan, user.Plan)
}
return nil
}
return fmt.Errorf("%s requires sign in", model)
}