Files
LocalAI/core/services/studio/addontools.go
T
Ettore Di Giacinto 2b588a7350 fix(connectors): hold add-on write tools to tools.write in connector chats
A write tool of an add-on that holds a credential was protected only by
the write mode of the connection and the confirmation of the proposal.
In a connector chat it now also needs the capability tools.write for the
sender of the turn, through the same Authorize path as the other
capabilities: the tool is not offered without it, a call is checked again
and every decision is audited. The default is no.

The rule is by shape, not by name. The manifest says an add-on holds a
credential, and contract.ToolPermission maps its non-read tools to
tools.write. The chat reads the permission from the tool definition, so
the core names no add-on.

Also close a hole in local-owner mode, where a chat turn and the browser
share one credential: a proposal that waits for a person could be
confirmed from a chat turn. The call of a chat tool now carries a mark,
and the owner refuses to confirm such a proposal under it. A remote owner
does not see the mark; local-owner mode has no remote owner.

Assisted-by: Claude:claude-sonnet-5-5 go test
2026-11-03 15:41:45 +00:00

314 lines
11 KiB
Go

package studio
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"sort"
addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// AddonTool is one tool of an installed add-on, as a chat offers it to the model.
type AddonTool struct {
Name string // the function name the model calls
AddonID string
Description string
Schema json.RawMessage
// Action is true for a tool that acts at once as the user (runs code, edits
// files, changes data) instead of only reading.
Action bool
// Shadows is the built-in chat tool this tool replaces (see shadow.go), or
// empty: the tool keeps its add-on name. A tool that shadows bash is the
// add-on's tool that runs shell commands and code.
Shadows string
// Execution is what the add-on declares about a tool that shadows bash
// (its provider title and note), or nil.
Execution *contract.ExecutionDescriptor
// InstalledAt is when the add-on was first installed (RFC 3339, UTC), or
// empty. It decides the default provider of a shadowed name.
InstalledAt string
// Permission is the capability (contract.ToolPermission) that the sender
// of a connector turn needs to use the tool, or empty. The host sets it
// from the manifest; the core does not know which add-on declares it.
Permission string
}
// AddonTools is the add-on broker as the chats see it. Both calls run as the
// user; the host decides what that user may see and do, and turns a result into
// text for the model (a change the chat made says what was done and whether the
// user can undo it, with the change ID; one that stays a proposal asks the user
// to confirm it).
type AddonTools interface {
// Tools lists the tools the user may use now.
Tools(ctx context.Context, user string) ([]AddonTool, error)
// Call runs one tool of a conversation as the user and returns what the
// model is told.
Call(ctx context.Context, user, conv, name string, args json.RawMessage) (string, error)
}
// AddonGate is implemented by a broker that knows when an add-on cannot work
// for a user now: its backend is missing, or it needs the workspace and the
// workspace is off. It is optional; a broker without it offers every tool.
type AddonGate interface {
// Unavailable returns "" when the tools of the add-on can work, or a short
// plain reason when they cannot. workspace says whether the Files
// workspace is on, for an add-on that needs it. It must not fail a chat:
// when the broker cannot tell, it answers "".
Unavailable(ctx context.Context, user, addonID string, workspace bool) string
}
// unavailableToolName is the name of the stand-in tool of an add-on that
// cannot work. It has the shape of the broker's tool names.
func unavailableToolName(addonID string) string { return addontools.ToolName(addonID, "unavailable") }
// ErrBadAddonID means a switch names something that is not an add-on ID, or
// there are too many switches.
var ErrBadAddonID = errors.New("studio: an add-on ID is not valid")
// maxAddonSwitches bounds the switches of one chat.
const maxAddonSwitches = 200
var addonIDShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
// AddonInfo is one add-on a chat can use, with its switch.
type AddonInfo struct {
ID string
Tools int
Off bool
// Title and Description are what the add-on declares for people, when its
// broker can say (see AddonLister). Empty otherwise.
Title, Description string
}
// AddonListing is the title and the description an add-on declares in its
// manifest.
type AddonListing struct {
AddonID, Title, Description string
}
// AddonLister is implemented by a broker that knows what each add-on declares
// for people. It is optional: without it the lists show the add-on IDs only.
type AddonLister interface {
AddonListings(ctx context.Context, user string) ([]AddonListing, error)
}
// SetAddonsOff sets the add-ons a chat has switched off, replacing the earlier
// list. An ID appears once. The chat of another user is session.ErrNotFound.
func (s *Service) SetAddonsOff(ctx context.Context, user, conv string, off []string) error {
clean, err := cleanAddonSwitches(off)
if err != nil {
return err
}
return s.d.Store.SetAddonsOff(ctx, principal(user), conv, clean)
}
// cleanAddonSwitches checks and dedups a list of add-on IDs.
func cleanAddonSwitches(off []string) ([]string, error) {
if len(off) > maxAddonSwitches {
return nil, fmt.Errorf("%w: at most %d switches", ErrBadAddonID, maxAddonSwitches)
}
seen := make(map[string]bool, len(off))
clean := make([]string, 0, len(off))
for _, id := range off {
if !addonIDShape.MatchString(id) {
return nil, fmt.Errorf("%w: %q", ErrBadAddonID, id)
}
if !seen[id] {
seen[id] = true
clean = append(clean, id)
}
}
return clean, nil
}
// Addons lists the add-ons whose tools the user may use, with the chat's
// switch for each. Without the add-on host it lists none.
func (s *Service) Addons(ctx context.Context, user, conv string) ([]AddonInfo, error) {
rec, err := s.d.Store.GetChat(ctx, principal(user), conv)
if err != nil {
return nil, err
}
return s.addonsFor(ctx, user, rec)
}
// addonsFor lists the add-ons of Addons with the switches of rec.
func (s *Service) addonsFor(ctx context.Context, user string, rec session.ChatRecord) ([]AddonInfo, error) {
if s.d.AddonTools == nil {
return nil, nil
}
tools, err := s.d.AddonTools.Tools(ctx, principal(user))
if err != nil {
return nil, err
}
off := map[string]bool{}
for _, id := range rec.AddonsOff {
off[id] = true
}
n := map[string]int{}
for _, t := range tools {
n[t.AddonID]++
}
listed := map[string]AddonListing{}
if l, ok := s.d.AddonTools.(AddonLister); ok {
if list, err := l.AddonListings(ctx, principal(user)); err != nil {
s.log.Warn("studio: add-on titles unavailable", "user", user, "error", err)
} else {
for _, a := range list {
listed[a.AddonID] = a
}
}
}
out := make([]AddonInfo, 0, len(n))
for id, c := range n {
out = append(out, AddonInfo{ID: id, Tools: c, Off: off[id], Title: listed[id].Title, Description: listed[id].Description})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
// addonTools turns the add-on tools the chat's user may use, less the add-ons
// the chat switched off and, for a chat with an agent, those the agent does not
// name, into chat tools. A host that cannot answer gives no tools and a warning;
// it never fails the chat. The second result names the add-ons the agent grants
// that the user cannot use.
func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session.ChatRecord, sc *scope) ([]session.Tool, []string, error) {
pins, err := s.toolPins(ctx)
if err != nil {
return nil, nil, err
}
if s.d.AddonTools == nil {
if sc != nil {
return nil, prefixed("add-on", sc.addonNames), nil
}
return nil, nil, nil
}
defs, err := s.d.AddonTools.Tools(ctx, a.Principal)
if err != nil {
s.log.Warn("studio: add-on tools unavailable", "chat", a.ChatID, "error", err)
return nil, nil, nil
}
off := make(map[string]bool, len(rec.AddonsOff))
for _, id := range rec.AddonsOff {
off[id] = true
}
have := map[string]bool{}
// replaces maps an add-on to the built-in tools it replaces, for the
// stand-in of one that cannot work.
replaces := map[string][]string{}
for _, d := range defs {
if d.Shadows != "" && !slices.Contains(replaces[d.AddonID], d.Shadows) {
replaces[d.AddonID] = append(replaces[d.AddonID], d.Shadows)
}
}
var tools []session.Tool
gate, _ := s.d.AddonTools.(AddonGate)
reasons := map[string]string{}
for _, d := range defs {
have[d.AddonID] = true
if gate != nil && !off[d.AddonID] && (sc == nil || sc.addons[d.AddonID]) {
reason, known := reasons[d.AddonID]
if !known {
reason = gate.Unavailable(ctx, a.Principal, d.AddonID, s.d.Files.Available())
reasons[d.AddonID] = reason
if reason != "" {
stand := unavailableTool(d.AddonID, reason)
stand.StandInFor = replaces[d.AddonID]
tools = append(tools, stand)
}
}
if reason != "" {
continue
}
}
var grants []string
if sc != nil {
grants = sc.addonNames
}
if !AddonToolEligible(d, rec, grants) {
continue
}
name := d.Name
schema := d.Schema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object"}`)
}
if t, ok := s.shadowTool(a, d, schema); ok {
tools = append(tools, t)
continue
}
tool := session.Tool{
Name: name, Description: d.Description, Schema: schema,
// The user and the conversation come from the chat, never from the
// arguments the model wrote.
Run: func(ctx context.Context, args json.RawMessage) (string, error) {
return s.d.AddonTools.Call(ctx, a.Principal, a.ChatID, name, args)
},
}
if d.Permission != "" && rec.Origin == session.OriginConnector {
var ok bool
if tool, ok = s.gateAddonTool(tool, d.Permission, a.ChatID); !ok {
continue
}
}
tools = append(tools, tool)
}
installed := map[string]string{}
for _, d := range defs {
installed[d.AddonID] = d.InstalledAt
}
tools = resolveShadows(tools, pins, installed, s.shadowersFor(ctx, a.Principal))
var missing []string
if sc != nil {
for _, id := range sc.addonNames {
if !have[id] {
missing = append(missing, "add-on "+id)
}
}
}
return tools, missing, nil
}
// unavailableTool is the one tool a chat gets for an add-on that cannot work:
// it tells the model why, so the model does not try the others and the user
// gets a plain answer.
func unavailableTool(addonID, reason string) session.Tool {
msg := "The " + addonID + " add-on cannot be used in this chat: " + reason + ". Tell the user this instead of trying."
return session.Tool{
Name: unavailableToolName(addonID),
Description: msg,
Schema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
Run: func(context.Context, json.RawMessage) (string, error) { return msg, nil },
}
}
// gateAddonTool holds a tool of an add-on to the capability it declares (see
// AddonTool.Permission) in a connector chat. The tool is offered only when the
// sender of the turn holds the capability, and a call checks again and records
// the decision, because the grant of a turn can shrink after the tools were
// offered. The second result is false when the tool must not be offered. A chat
// that no connector started has no sender and is not gated here: the write
// mode of the binding and the confirmation of the proposal govern it.
func (s *Service) gateAddonTool(t session.Tool, permission, chat string) (session.Tool, bool) {
allow, audit := s.turnAllow(chat)
c := builtins.Chat{Allow: allow, Audit: audit}
if held, _ := c.Holds(permission); !held {
return t, false
}
run := t.Run
t.Run = func(ctx context.Context, args json.RawMessage) (string, error) {
if err := c.Require(permission); err != nil {
return "", err
}
return run(ctx, args)
}
return t, true
}