Files
LocalAI/core/services/studio/capabilities.go
T
Ettore Di Giacinto 76efd6b26b fix(studio): keep the commands state and skills of a draft chat
A chat that does not exist yet has no execution status to read, so its
isolation is unknown by construction. Three places treated that as a
failure:

- Switching an add-on in a draft reset the commands state and never
  refilled it, so the "isolation is unknown" notice stayed. The draft
  now keeps the state its list gave it.
- The skills list of a draft hid the document skills of the selected
  commands provider, because they need isolated commands. A draft list
  now offers them; the turn of the real chat still gates them on the
  status it reads. The draft add-on switch also reads the skills again.
- The shell row of the Tools menu printed the generic unknown text for
  the draft state. It now says nothing about isolation there.

In a real chat, switching an add-on or a tool group now reads the
chat's network view again, so the network controls appear once the
provider serves commands.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt playwright
2026-11-03 14:00:45 +00:00

570 lines
23 KiB
Go

package studio
import (
"context"
"errors"
"regexp"
"slices"
"strings"
"time"
addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/skills"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// A chat that has no tool to run commands must say so. Without that fact a
// model writes a script, believes it ran, sends a sub-agent with the same
// tools to "really run it" and in the end prints a shell command as if it ran.
// So every turn gets one short block that says whether a tool runs commands,
// skills that need one are listed as unavailable, and a sub-agent task that
// asks to run something is refused at once.
// CommandIsolation is implemented by a broker that can tell how the tool that
// runs commands is isolated. It is optional.
type CommandIsolation interface {
// Isolation is "isolated" when commands run in an isolated environment, "host" when they
// run directly on the server with no isolation, and "" when the broker
// cannot tell.
Isolation(ctx context.Context, user, addonID string) string
}
// AddonControls is implemented by a broker that knows which host controls an
// add-on is entitled to (see contract.EntitledControls). It is optional.
type AddonControls interface {
// Controls returns the host controls the host draws for the add-on, as
// the apps list the user can read gives them, or nil.
Controls(ctx context.Context, user, addonID string) []string
}
// AddonNetwork is the network an add-on declares for the commands it runs,
// as the host shows it (see contract.NetworkCapability).
type AddonNetwork struct {
// Title names the network in text: the declared title, else the add-on's.
Title string
// Presets are the titles the add-on gives the host's network presets.
Presets []contract.NetworkPreset
}
// AddonNetworks is implemented by a broker that knows the network each
// add-on declares. It is optional: an add-on it does not answer for gets the
// title of its commands tool.
type AddonNetworks interface {
// Network returns the network of the add-on while the host draws its
// network-policy control.
Network(ctx context.Context, user, addonID string) (AddonNetwork, bool)
}
// CommandState says whether a chat can run commands.
type CommandState string
// The states. Only CommandsAvailable gives the model a tool that runs commands.
const (
// CommandsAvailable: a tool of the chat runs commands.
CommandsAvailable CommandState = "available"
// CommandsOff: an add-on that runs commands is installed for the user, but
// this chat switched it off, or the agent of the chat does not name it.
CommandsOff CommandState = "off"
// CommandsUnavailable: an add-on that runs commands is installed but cannot
// run them on this server now (no backend works).
CommandsUnavailable CommandState = "unavailable"
// CommandsNone: no add-on that runs commands is installed for the user.
CommandsNone CommandState = "none"
// CommandsSwitchedOff: the chat switched the shell group off, so no tool
// runs commands, whoever would serve them.
CommandsSwitchedOff CommandState = "switched_off"
)
// Commands is what a chat knows about running commands at a turn.
type Commands struct {
ExecutionStatus *ExecutionStatusView
State CommandState
// Tool is the name of the tool that runs commands, when State is available.
Tool string
// Isolation is "isolated", "host" or "" (unknown), when State is available.
Isolation string
// Reason is why the add-on cannot run commands, when State is unavailable.
Reason string
// Addon is the add-on that runs commands, or that would (off,
// unavailable).
Addon string
// Title names the add-on in the text the model and the person read: the
// title its commands tool declares, or the add-on's own.
Title string
// Note is the sentence the add-on declares for the model when its commands
// are isolated, or "".
Note string
// Provider serves the tool when State is available: the add-on's ID, or
// BuiltinProvider for the built-in bash on this machine.
Provider string
// Consent is "ask" when each command of the built-in bash waits for the
// person's yes.
Consent string
// Fallback is set when the built-in bash serves although an add-on that
// runs commands is installed for the user: the add-on's ID, with
// FallbackReason "off" (switched off for the chat or not named by its
// agent) or why it cannot run.
Fallback, FallbackReason string
// Draft is set when the chat does not exist yet. No execution status can be
// read for it, so Isolation is unknown by construction, not by a failure.
Draft bool
}
// withHostBash is the state of a chat whose tools may hold the built-in bash:
// c is what the add-on tools alone gave.
func (s *Service) withHostBash(c Commands, tools []session.Tool) Commands {
if c.State == CommandsAvailable {
c.Provider = c.Addon
return c
}
if providerOf(tools, "bash") != BuiltinProvider {
return c
}
out := Commands{State: CommandsAvailable, Tool: "bash", Isolation: "host", Provider: BuiltinProvider}
if s.hostExecMode() == "ask" {
out.Consent = "ask"
}
switch c.State {
case CommandsOff:
out.Fallback, out.FallbackReason = c.Addon, "off"
case CommandsUnavailable:
out.Fallback, out.FallbackReason = c.Addon, c.Reason
if out.FallbackReason == "" {
out.FallbackReason = "it cannot run commands on this server now"
}
}
return out
}
// skillNeedsExec is the reason Index gives for a skill that needs a tool that
// runs commands when the chat has none.
const skillNeedsExec = "needs command execution"
// commandTool is the tool name of the commands: the built-in bash, or the
// add-on tool that replaces it (contract.ToolDefinition shadows "bash").
const commandTool = "bash"
// addonRunner returns the add-on that serves the commands in the tool list:
// the provider of a bash that an add-on serves, or "". The built-in bash is
// not an add-on's: see providerOf.
func addonRunner(tools []session.Tool) string {
for _, t := range tools {
if t.Name == commandTool && t.Provider != "" && t.Provider != BuiltinProvider {
return t.Provider
}
}
return ""
}
// commandsDeclared is what the add-on that serves the commands in tools
// declares about its commands tool.
func commandsDeclared(tools []session.Tool, addon string) *contract.ExecutionDescriptor {
for _, t := range tools {
if t.Provider == addon && t.Execution != nil {
return t.Execution
}
}
return nil
}
// standInRunner returns the add-on whose stand-in tool (the add-on cannot
// work) is in the list, when that add-on replaces bash.
func standInRunner(tools []session.Tool) string {
for _, t := range tools {
if !slices.Contains(t.StandInFor, commandTool) {
continue
}
id, tool, ok := addontools.ParseQualifiedToolName(t.Name)
if ok && tool == "unavailable" {
return id
}
}
return ""
}
// commandAddons lists the installed add-ons that replace bash and that the
// user can see, oldest install first. With a broker that lists add-ons in any
// state it is every such add-on; else the running ones. It reads the broker
// each time, so every frontend answers the same, also after a restart.
func (s *Service) commandAddons(ctx context.Context, user string) []string {
if s.d.AddonTools == nil {
return nil
}
if _, ok := s.d.AddonTools.(ShadowLister); ok {
sh := s.shadowersFor(ctx, user)
return OrderProviders(sh.byName[commandTool], sh.when)
}
defs, err := s.d.AddonTools.Tools(ctx, user)
if err != nil {
return nil
}
var ids []string
installed := map[string]string{}
for _, d := range defs {
if d.Shadows == commandTool && !slices.Contains(ids, d.AddonID) {
ids = append(ids, d.AddonID)
installed[d.AddonID] = d.InstalledAt
}
}
return OrderProviders(ids, installed)
}
// commandTitle is the name of an add-on that is not serving commands now: the
// title of its manifest when the broker lists add-ons in any state, else the
// title its commands tool declares, else its ID.
func (s *Service) commandTitle(ctx context.Context, user, id string) string {
if _, ok := s.d.AddonTools.(ShadowLister); ok {
if t := s.shadowersFor(ctx, user).title[id]; t != "" {
return t
}
}
if s.d.AddonTools == nil {
return id
}
if defs, err := s.d.AddonTools.Tools(ctx, user); err == nil {
for _, d := range defs {
if d.AddonID == id && d.Execution != nil && d.Execution.Title != "" {
return d.Execution.Title
}
}
}
return id
}
// commandsOf says whether the tools of a chat can run commands. addonsOff are
// the add-ons the chat switched off and sc the agent's scope, for a chat that
// cannot: they tell an add-on that is installed but off from none at all.
func (s *Service) commandsOf(ctx context.Context, user string, tools []session.Tool, addonsOff []string, sc *scope) Commands {
if addon := addonRunner(tools); addon != "" {
c := Commands{State: CommandsAvailable, Tool: commandTool, Addon: addon, Title: addon}
if e := commandsDeclared(tools, addon); e != nil {
c.Note = e.Note
if e.Title != "" {
c.Title = e.Title
}
}
if iso, ok := s.d.AddonTools.(CommandIsolation); ok {
c.Isolation = iso.Isolation(ctx, user, addon)
}
return c
}
if addon := standInRunner(tools); addon != "" {
c := Commands{State: CommandsUnavailable, Addon: addon, Title: s.commandTitle(ctx, user, addon)}
if gate, ok := s.d.AddonTools.(AddonGate); ok {
c.Reason = gate.Unavailable(ctx, user, addon, s.d.Files.Available())
}
return c
}
off := set(addonsOff)
for _, id := range s.commandAddons(ctx, user) {
if off[id] || (sc != nil && !sc.addons[id]) {
return Commands{State: CommandsOff, Addon: id, Title: s.commandTitle(ctx, user, id)}
}
}
return Commands{State: CommandsNone}
}
// CommandRunner says whether a chat can run commands at its next turn, as the
// Tools menu shows it. It uses the same tools the model would get. The chat of
// another user is session.ErrNotFound.
func (s *Service) CommandRunner(ctx context.Context, user, conv string) (Commands, error) {
ctx, closeScope := s.executionScope(ctx, principal(user), conv)
defer closeScope()
p := principal(user)
rec, err := s.d.Store.GetChat(ctx, p, conv)
if err != nil {
return Commands{}, err
}
return s.commandsFor(ctx, p, rec)
}
// commandsFor says whether the chat of rec can run commands at its next turn.
func (s *Service) commandsFor(ctx context.Context, p string, rec session.ChatRecord) (Commands, error) {
conv := rec.ID
ctx, closeScope := s.executionScope(ctx, p, conv)
defer closeScope()
a := s.chatActorFor(ctx, rec)
if rec.Kind == session.KindSubAgent && rec.AgentID == "" {
s.mu.Lock()
tools := s.allowed[key(p, conv)]
s.mu.Unlock()
return s.commandsOf(ctx, p, tools, rec.AddonsOff, nil), nil
}
sc := s.scopeOf(ctx, rec)
var tools []session.Tool
if sc == nil || !sc.deleted {
var err error
tools, _, err = s.addonTools(ctx, a, rec, sc)
if err != nil {
return Commands{}, err
}
}
c := s.commandsOf(ctx, p, tools, rec.AddonsOff, sc)
c.Draft = rec.ID == ""
if _, ok := s.d.AddonTools.(ExecutionObserver); ok {
c.ExecutionStatus = executionStatusView(ctx, p, c.Addon)
}
tools = s.applyBuiltins(tools, s.builtinsFor(ctx, a, rec, false))
return shellSwitched(s.withHostBash(c, tools), s.hiddenFor(ctx, rec)), nil
}
// commandsWhere is where a command of an add-on starts and what it keeps.
// fileTools says whether the chat has the file tools, which see the same
// files: a chat without them must not hear of them. A scoped chat (its agent
// works in one folder) has that folder at /workspace/home, at the agent's
// mode, and its chat folder at /workspace/chat; its shared folders are read
// only.
func commandsWhere(fileTools bool, place session.Place) string {
same := ""
if fileTools {
same = ", the same files as the file tools"
}
if place.Scoped() {
kept := "/workspace/chat"
if place.PrimaryWritable() {
kept = "/workspace/home or /workspace/chat"
}
return "It starts in /workspace/home, the agent's folder" + same + "; the chat's own folder is /workspace/chat, and shared folders are read only. Only what it saves under " + kept + " is kept."
}
return "It starts in /workspace/home, the user's home" + same + "; only what it saves under /workspace/home or a writable shared folder is kept."
}
// skillsNotInProvider is the sentence that keeps a model from looking for a
// skill folder in an isolated environment, which sees the user's home only, and
// points it at the tool that loads a skill by its name.
const skillsNotInProvider = "Skill folders are not visible in the isolated environment: load skills with load_skill."
// commandsTitle is the name the prompt gives the add-on that runs commands.
func commandsTitle(c Commands) string {
if c.Title != "" {
return c.Title
}
return "the add-on"
}
// commandsPhrase names the add-on that runs commands in a sentence: the
// declared title when there is one, else a generic description. The title comes
// from the add-on's declaration, never from this file.
func commandsPhrase(c Commands) string {
if c.Title != "" {
return "the " + c.Title + " add-on"
}
return "the add-on that runs commands"
}
// commandsBlock is the block of the system prompt that states whether the chat
// can run commands. writeFile says whether the chat has write_file,
// fileTools whether it has the file tools to read them, and skillsOff
// whether the skill index lists a skill as unavailable for want of a way to
// run commands, and place the chat's folders. It is short on purpose: it
// goes into every turn.
func commandsBlock(c Commands, writeFile, fileTools, skillsOff bool, place session.Place) string {
if c.State == CommandsAvailable {
home := "the user's home files"
if fileTools {
home = "the files of /workspace/home of the file tools"
}
switch {
case c.Provider == BuiltinProvider:
b := "Commands: " + c.Tool + " runs commands directly on this machine, with no isolation. It starts in the user's home inside a scratch folder (" + home + "; a shared folder is ../shared-<name>), so use relative paths there. " +
"It can read what the server can read and reach the internet, the local network and the LocalAI API. " +
"Be careful: never delete or change anything outside the task, and never send the user's data anywhere, unless they asked."
if c.Consent == "ask" {
b += " Each command waits for the user's yes in the chat; if bash says it was not run, stop and ask them."
}
return b
case c.Isolation == "isolated":
// The turn's note is cut at 400 characters and the tool description
// is capped, so the list of programs goes here, in the prompt.
b := "Commands: " + c.Tool + " runs commands and code in an isolated environment (" + commandsTitle(c) + "). " + commandsWhere(fileTools, place) + " "
if c.Note != "" {
b += c.Note + " "
}
return b + skillsNotInProvider
case c.Isolation == "host":
return "Commands: " + c.Tool + " runs commands directly on the server, with no isolation. Ask before you delete or change anything outside the task. " + commandsWhere(fileTools, place)
}
return "Commands: " + c.Tool + " runs commands and code. " + commandsWhere(fileTools, place)
}
var b strings.Builder
b.WriteString("Commands: nothing in this chat runs code or commands. Never claim or imply that you ran anything, and do not ask a sub-agent to run it: it has the same tools. Instead ")
if writeFile {
b.WriteString("write the output directly with write_file (text, CSV, Markdown, HTML, JSON) or ")
}
b.WriteString("give the user the script to run, and tell them ")
switch c.State {
case CommandsSwitchedOff:
b.WriteString("they switched running commands off for this chat and can switch Run commands on in its Tools menu.")
case CommandsOff:
b.WriteString("they can switch " + commandsPhrase(c) + " on in the Tools menu of this chat.")
case CommandsUnavailable:
b.WriteString(commandsPhrase(c) + " cannot run commands on this server; an administrator can fix that.")
default:
b.WriteString("an administrator can enable running code by installing an add-on that runs commands.")
}
if skillsOff {
b.WriteString(" A skill marked unavailable here needs this: say so instead of starting it.")
}
return b.String()
}
// imagesBlock tells the model whether it sees the images the user attaches.
// A model that sees them must not send them to describe_image, which costs a
// second model call; a model that cannot must say so instead of guessing what
// a picture shows. describer is the model that describe_image would use, ""
// when there is none, and describe says whether the chat has describe_image:
// a chat that switched the image helpers off must not hear of the tool.
func imagesBlock(sees bool, describer string, describe bool) string {
switch {
case sees && describe:
return "Images: you see the images attached to the user's message, with the message. " +
"Answer about them directly. Use describe_image only for an image in the user's files that is not in the message."
case sees:
return "Images: you see the images attached to the user's message, with the message. Answer about them directly."
case describer != "" && describe:
return "Images: you cannot see images. " +
"To look at an image the user attached or has in their files, call describe_image with its path."
case describer != "":
return "Images: you cannot see images, and the image helper is switched off for this chat. " +
"When the user shares an image, tell them plainly; they can switch Images and audio on in the Tools menu of this chat."
}
return "Images: you cannot see images, and no model here can describe them. " +
"When the user shares an image, tell them plainly and suggest that they choose a model that can look at images for this chat."
}
// subAgentCommandsBlock is the block of a sub-agent that cannot run commands.
// Its parent decides what to tell the user.
const subAgentCommandsBlock = "Commands: nothing here runs code or commands. Never claim or imply that you ran anything; say plainly what you could not do."
// execGate returns the skill gate for a chat: a skill that requires a way to
// run commands is unavailable when the chat has none. n counts the skills it
// marked.
func execGate(c Commands, n *int) func(skills.Skill) string {
return func(sk skills.Skill) string {
if c.State == CommandsAvailable {
return ""
}
if slices.Contains(sk.Requires, skills.RequireExec) {
*n++
return skillNeedsExec
}
return ""
}
}
// Sub-agent refusals for tasks that cannot work, returned to the model as the
// result of the tool call.
var (
// ErrSubAgentCannotRun means the task asks to run something and nothing in
// the chat runs commands: a sub-agent gets the same tools.
ErrSubAgentCannotRun = errors.New("the sub-agent has no way to run commands either: nothing in this chat runs code or commands, and a sub-agent gets the same tools. " +
"Do not try again. Write the output directly with a file tool, or give the user the script to run")
// ErrSubAgentRepeat means a sub-agent already got the same task and no
// file of the chat changed since, so it would answer the same.
ErrSubAgentRepeat = errors.New("a sub-agent already got this exact task and no file of this chat changed since, so it would answer the same. " +
"Use its answer, change the task, or tell the user what is missing")
)
// execTaskRE matches a task that asks to run something: "execute", "run the
// script", "run make_deck.py", "pip install". It is narrow on purpose: a
// research task that only names a language must still start.
var execTaskRE = regexp.MustCompile(`(?i)\bexecut(?:e|es|ed|ing|ion)\b|\brun(?:s|ning)?\s+it\b|\brun(?:s|ning)?\b[^.\n]{0,40}?(?:\b(?:script|command|commands|code|program|python3?|bash|shell|terminal)\b|\.(?:py|sh|js)\b)|\bpip3?\s+install\b|\bnpm\s+(?:install|run)\b`)
// asksToRun says whether a sub-agent task asks to run code or commands.
func asksToRun(task string) bool { return execTaskRE.MatchString(task) }
// normTask is a task as the repeat guard compares it: words, any case.
func normTask(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) }
// repeatedTask says whether an earlier sub-agent of the chat got the same task
// and no file of the chat was created or changed since. It reads the store, so
// it holds when the turns of the chat run on different frontends.
func (s *Service) repeatedTask(ctx context.Context, a workspace.Actor, task string) (bool, error) {
kids, err := s.d.Store.ListChildren(ctx, a.Principal, a.ChatID)
if err != nil {
return false, err
}
want := normTask(task)
var last time.Time
for _, k := range kids {
es, err := s.d.Store.Entries(ctx, k.ID, 0, 1)
if err != nil || len(es) == 0 {
continue
}
if es[0].Role == session.RoleUser && normTask(es[0].Text) == want && es[0].At.After(last) {
last = es[0].At
}
}
if last.IsZero() {
return false, nil
}
if !s.d.Files.Available() {
return true, nil
}
cf, err := s.mgr.Files(ctx, a.Principal, a.ChatID)
if err != nil {
// Without the list nobody can tell: let the task run.
return false, nil
}
for _, f := range cf.Files {
if f.At.After(last) {
return false, nil
}
}
return true, nil
}
// isolatedProvider reports whether the add-on serves the chat's commands and
// the typed execution status says they are isolated. It is what the skills of
// that add-on that need isolation require: the selected command provider, not
// just an installed guest or generic command authority. Unknown isolation
// fails closed.
func isolatedProvider(c Commands, addon string) bool {
return c.State == CommandsAvailable && c.Isolation == "isolated" && c.Addon == addon && c.Provider == addon
}
// addonCommandDescriptor describes existing add-on authority without building
// built-ins (which build load_skill) or invoking any tool callbacks.
func (s *Service) addonCommandDescriptor(ctx context.Context, p string, rec session.ChatRecord, sc *scope) (Commands, error) {
access, err := s.AddonAccess(ctx, p, rec.ID)
if err != nil {
return Commands{State: CommandsNone}, err
}
return s.addonCommandDescriptorForActor(ctx, access.Actor(), rec, sc)
}
// addonCommandDescriptorForActor reuses the actor already authorized by turn
// setup (or the tools panel). It must not resolve session access: tool building
// can run while Manager.Open holds its open lock.
func (s *Service) addonCommandDescriptorForActor(ctx context.Context, a workspace.Actor, rec session.ChatRecord, sc *scope) (Commands, error) {
p := a.Principal
if sc != nil && sc.deleted {
return Commands{State: CommandsNone}, nil
}
ctx, closeScope := s.executionScope(ctx, p, rec.ID)
defer closeScope()
var tools []session.Tool
// Only ad-hoc children inherit a pruned tool set. Saved-agent children
// resolve current grants through their scope, just like their turn tools.
if rec.Kind == session.KindSubAgent && rec.AgentID == "" {
s.mu.Lock()
tools = slices.Clone(s.allowed[key(p, rec.ID)])
s.mu.Unlock()
} else {
var err error
tools, _, err = s.addonTools(ctx, a, rec, sc)
if err != nil {
return Commands{}, err
}
}
c := s.commandsOf(ctx, p, tools, rec.AddonsOff, sc)
c.Draft = rec.ID == ""
if c.State == CommandsAvailable {
c.Provider = c.Addon
}
return shellSwitched(c, s.hiddenFor(ctx, rec)), nil
}