Files
LocalAI/core/services/addons/tools/adapter.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

962 lines
40 KiB
Go

// SPDX-License-Identifier: MIT
// Package tools adapts the tools of installed add-ons to model tool calls.
//
// The adapter is the one place where a model-facing consumer (chat, an
// agent, a scheduled job) meets add-on tools. It never decides authority:
//
// - discovery shows only what the owner lists for the authenticated actor:
// enabled, running add-ons with verified manifest tools, plus the narrow
// host-owned Tables database listing;
// - a read goes to the gateway read path with that actor;
// - a change, a schema change or a creation goes to the proposal broker and
// returns a reference to the stored proposal. The adapter has no way to
// confirm, cancel or resolve a proposal: Backend does not name those
// operations, so a tool call can never apply a change;
// - the actor is a value the consumer took from its authenticated request
// or job principal. Nothing in the arguments can change it, and any
// argument that names identity, role or approval is refused.
package tools
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/mudler/LocalAI/core/services/addons/apps"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/hostops"
addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime"
"github.com/mudler/LocalAI/core/services/addons/toolargs"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
const (
// Prefix starts the name of every add-on tool. Names have the form
// addon__<addonId>__<tool>: letters, digits, hyphen and underscore, so
// they are valid function names for the OpenAI tool format. An add-on ID
// and a tool name never contain an underscore.
Prefix = "addon__"
// MaxNameLength is the longest function name providers accept.
MaxNameLength = 64
// MaxArgumentBytes bounds the arguments of one call.
MaxArgumentBytes = toolargs.MaxArgumentBytes
// MaxResultBytes bounds a read result handed to a model. A larger result
// is refused: a truncated JSON document would read as a complete one.
MaxResultBytes = 128 << 10
// MaxAppliedBytes bounds each excerpt copied into the result of a change
// made at once. The chat keeps a tool result capped at 2 KiB, so the whole
// document must stay well under it to remain valid JSON there.
MaxAppliedBytes = 512
// MaxTools bounds the tools one discovery returns.
MaxTools = 64
// MaxSummaryBytes bounds the effect sample copied into a proposal
// reference. The approval card loads the full preview from the host.
MaxSummaryBytes = 4 << 10
)
var (
// ErrUnknownTool means the name is not a tool this actor may call now:
// not in the namespace, not defined by the manifest, or its add-on is
// disabled, stopped or not visible to the actor. Approval, sharing,
// deletion and purge names land here: no such tool exists.
ErrUnknownTool = errors.New("addon tool not available")
// ErrForbiddenArgument means the arguments carry a field that stands for
// identity, authority or approval.
ErrForbiddenArgument = toolargs.ErrForbiddenArgument
// ErrInvalidArguments means the arguments are not what the tool schema
// declares.
ErrInvalidArguments = toolargs.ErrInvalid
// ErrResultTooLarge means a read result is too large for a model.
ErrResultTooLarge = errors.New("addon tool result is too large; narrow the request")
)
// Backend is what the adapter needs from the owner. It deliberately names no
// way to confirm, cancel, reconcile or resolve a proposal.
type Backend interface {
// Apps lists the add-ons the actor may see.
Apps(ctx context.Context, actor contract.Actor) (apps.List, error)
// Dispatch runs a typed read.
Dispatch(ctx context.Context, actor contract.Actor, request contract.Request) (contract.Result, error)
// Propose stores a change for a person to confirm. It never applies.
Propose(ctx context.Context, actor contract.Actor, request contract.Request) (gateway.Proposal, error)
}
var _ Backend = (*gateway.Gateway)(nil)
// Selector resolves the resource a conversation selected. The owner
// authorizes the stored reference for the actor on every call, so the answer
// is never a credential. A Backend that does not implement it has no
// conversation selection: a call that omits the resource is invalid.
type Selector interface {
ResolveSelection(ctx context.Context, actor contract.Actor, conversationID, addonID string) (string, error)
}
var _ Selector = (*gateway.Gateway)(nil)
// ToolDefinition is one tool as a model sees it.
type ToolDefinition struct {
// Name is the namespaced function name.
Name string `json:"name"`
AddonID string `json:"addonId"`
Tool string `json:"tool"`
Kind string `json:"kind"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
// Shadows is the built-in chat tool this tool replaces in a chat (see
// contract.ToolDefinition.Shadows), or empty. Only the chat consumer acts
// on it; every other consumer keeps the namespaced Name.
Shadows string `json:"shadows,omitempty"`
// Execution is what the add-on declares about a tool that shadows bash. It
// is never nil on such a tool: a manifest without a descriptor gets one
// with the add-on's title.
Execution *contract.ExecutionDescriptor `json:"execution,omitempty"`
// InstalledAt is when the add-on was first installed (RFC 3339, UTC), or
// empty. The chat consumer ranks the add-ons that shadow one name by it.
InstalledAt string `json:"installedAt,omitempty"`
// Permission is the capability (see contract.ToolPermission) that a
// sender needs to use the tool in a connector chat, or empty. The chat
// consumer holds the tool to it; another consumer has no sender.
Permission string `json:"permission,omitempty"`
}
// ResultKind says what a ToolResult carries.
type ResultKind string
const (
// ResultData is a read result.
ResultData ResultKind = "result"
// ResultProposal is a reference to a stored proposal. Nothing is applied.
ResultProposal ResultKind = "proposal"
// ResultApplied says a change tool ran for a chat and the change is made.
ResultApplied ResultKind = "applied"
// ResultArtifact is a reference to a private file the add-on produced.
ResultArtifact ResultKind = "artifact"
// ResultError is a typed answer that asks the person to act: no resource
// is selected for the conversation. It is a result, not a transport
// failure, so the model can tell the person what to do.
ResultError ResultKind = "error"
)
// CodeSelectResource is the Code of a ResultError for a tool call that named
// no resource in a conversation that has none selected.
const CodeSelectResource = "select_resource"
// CodeInvalidRequest is the Code of a ResultError for a call the add-on
// refused as invalid. Nothing was changed, so the model corrects the call
// instead of reporting an outage or retrying it unchanged.
const CodeInvalidRequest = "invalid_request"
// InvalidNote is what the model is told when the add-on refused a call.
const InvalidNote = "The add-on refused this request as invalid. Nothing was changed. Check the arguments against the tool description and correct them; do not repeat the same call and do not report an outage."
// ReasonedNote is what the model is told when the add-on refused and said why.
const ReasonedNote = "The add-on cannot do this now, and says why in message. Nothing was changed. Tell the person what it said in plain words; do not repeat the same call and do not report an outage."
// CodeNotRunning is the Code of a ResultError for a call that found the add-on
// not running. Nothing was sent.
const CodeNotRunning = "not_running"
// NotRunningNote is what the model is told when the add-on is not running.
const NotRunningNote = "The add-on is starting or stopped, so nothing was sent and nothing was changed. Tell the person to try again in a moment; you may try the same call once more later."
// CodeNotPermitted is the Code of a ResultError for a call the person is not
// allowed to make, for example without the fine-tuning permission.
const CodeNotPermitted = "not_permitted"
// NotPermittedNote is what the model is told when the person lacks a permission.
const NotPermittedNote = "The person does not have the permission this tool needs. Nothing was changed. Tell them an administrator must grant it; do not repeat the call and do not report an outage."
// CodeRateLimited is the Code of a ResultError for a call that the service
// behind the add-on turned away because it was called too often. RetryAfter says
// how long to wait.
const CodeRateLimited = "rate_limited"
// RateLimitedNote is what the model is told when the service asks it to wait.
const RateLimitedNote = "The service is limiting how often it is called. Nothing was changed. Do not repeat this call, or any call to the same add-on, before retryAfter seconds have passed; tell the person if the wait is long."
// CodeResourceNotAllowed is the Code of a ResultError for a call that names a
// resource the person did not allow for the add-on. The add-on was not called.
const CodeResourceNotAllowed = gateway.CodeResourceNotAllowed
// ResourceNotAllowedNote is what the model is told for a resource that is not on the list.
const ResourceNotAllowedNote = "That resource is not on the list the person allowed for this add-on, so nothing was sent to it. Use one of the resources in allowed, or tell the person to add it to the list on the add-on page. Do not repeat the call and do not report an outage."
// CodeNoCredential is the Code of a ResultError for an add-on that needs a
// credential the person has not given.
const CodeNoCredential = gateway.CodeNoCredential
// NoCredentialNote is what the model is told when the person has no credential for the add-on.
const NoCredentialNote = "The person has not connected this add-on yet, so nothing was sent. Tell them to add a connection on the add-on page. Do not repeat the call and do not report an outage."
// CodeWriteNotAllowed is the Code of a ResultError for a write tool of an add-on
// whose binding has its write mode off. The add-on was not called.
const CodeWriteNotAllowed = gateway.CodeWriteNotAllowed
// WriteNotAllowedNote is what the model is told when writes are off.
const WriteNotAllowedNote = "The person has not allowed this add-on to change anything, so nothing was sent. Tell them they can turn on writes on the add-on page, choosing to confirm each change or to allow them. Do not repeat the call and do not report an outage."
// CodeResourceUnreachable is the Code of a ResultError for a resource the person cannot reach.
const CodeResourceUnreachable = "resource_unreachable"
// ResourceUnreachableNote is what the model is told when a resource cannot be used.
const ResourceUnreachableNote = "That resource does not exist or the person has no access to it. Nothing was changed. Ask which resource to use, or ask them to choose one; do not guess an ID and do not repeat the call."
// SelectNote is what the model is told when no resource is selected.
const SelectNote = "No resource is selected for this conversation. Ask the person to choose one with the Select action of the add-on in the chat, or to give its ID. Do not guess an ID."
// ProposalRef is what a model and a chat learn of a stored proposal. The
// approval card reads the authoritative record from the host by ID; model
// text is never the preview.
type ProposalRef struct {
ID string `json:"id"`
AddonID string `json:"addonId"`
ResourceID string `json:"resourceId"`
Action string `json:"action"`
Digest string `json:"digest"`
State string `json:"state"`
Count uint64 `json:"count"`
Summary json.RawMessage `json:"summary,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
// AppliedRef is what a chat learns of a change it made. The person enabled the
// add-on for the chat or the agent, which is the consent, so the change is made
// at once. The safety net is undo: when the add-on kept a change set for the
// change, Undoable is true and ChangeID names it; the person undoes it from the
// chat with a button, and the model cannot, because the undo route accepts the
// person's own browser session only. When it kept none, Reason says why.
type AppliedRef struct {
// ID is the operation ID of the change.
ID string `json:"id"`
AddonID string `json:"addonId"`
ResourceID string `json:"resourceId,omitempty"`
Action string `json:"action"`
Count uint64 `json:"count"`
Summary json.RawMessage `json:"summary,omitempty"`
// Result is what the add-on's receipt reported.
Result json.RawMessage `json:"result,omitempty"`
// Undoable says whether the person can roll the change back.
Undoable bool `json:"undoable"`
// ChangeID is the change set to undo, when Undoable.
ChangeID string `json:"changeId,omitempty"`
// Undo says in one line what the undo will do, when Undoable.
Undo string `json:"undo,omitempty"`
// Reason says why the change cannot be undone, when it cannot.
Reason string `json:"reason,omitempty"`
}
// ArtifactRef points at a private artifact. The bytes are fetched by the
// person's authenticated session, never through a tool result.
type ArtifactRef struct {
ID string `json:"id"`
Name string `json:"name"`
MediaType string `json:"mediaType"`
Size int64 `json:"size"`
}
// ToolResult discriminates a read result, a host proposal reference and
// private artifact references.
type ToolResult struct {
Kind ResultKind `json:"kind"`
Tool string `json:"tool"`
Result json.RawMessage `json:"result,omitempty"`
Proposal *ProposalRef `json:"proposal,omitempty"`
Applied *AppliedRef `json:"applied,omitempty"`
Artifacts []ArtifactRef `json:"artifacts,omitempty"`
// Code says what the person is asked to do, for a ResultError.
Code string `json:"code,omitempty"`
// Message is the reason the add-on gave for refusing, in a short fixed
// sentence such as a missing engine. It is empty when the add-on gave none.
Message string `json:"message,omitempty"`
// Note is host text for the model: a proposal is not applied.
Note string `json:"note,omitempty"`
// Allowed lists the resources the person's bindings allow, for the code
// resource_not_allowed.
Allowed []string `json:"allowed,omitempty"`
// RetryAfter is how many seconds to wait, for the code rate_limited.
RetryAfter int `json:"retryAfter,omitempty"`
}
// ProposalNote is what the model is told after a proposal.
const ProposalNote = "Nothing has been changed. A person must review and confirm this proposal in the approval card. Do not say the change was made."
// AppliedNote is what the model is told after a change it made in a chat that
// cannot be undone. The reason follows it in the result.
const AppliedNote = "The change was made. It cannot be undone: see the reason. Tell the person plainly what changed and that it cannot be undone."
// UndoableNote is what the model is told after a change it made in a chat that
// the person can undo. Only the person can: the button is theirs.
const UndoableNote = "The change was made. The person can undo it with the Undo button on this change in the chat; you cannot undo it. Tell the person plainly what changed and that they can undo it."
// noUndoReason is the reason when the add-on kept no change set and said
// nothing more.
const noUndoReason = "the add-on keeps no change set for it"
// Applier is the owner side of a change the chats consumer makes at once. It is
// separate from Backend so that no consumer that holds only a Backend can apply
// a change, and the adapter has no method that confirms anything: the chats
// adapter confirms only the proposal it has just stored for the same actor.
type Applier interface {
// ConfirmProposal applies the stored proposal once.
ConfirmProposal(ctx context.Context, actor contract.Actor, id, digest string) (gateway.Proposal, error)
// CancelProposal withdraws a pending proposal.
CancelProposal(ctx context.Context, actor contract.Actor, id string) (gateway.Proposal, error)
}
var _ Applier = (*gateway.Gateway)(nil)
// ToolAdapter exposes add-on tools to model consumers.
type ToolAdapter struct {
// actions says that this adapter serves action tools. Only the adapter of
// the chats consumer is built with it; every other consumer neither lists
// nor runs an action tool.
actions bool
apply Applier
backend Backend
newID func() (string, error)
}
// NewToolAdapter returns an adapter over the owner.
func NewToolAdapter(b Backend) *ToolAdapter {
return &ToolAdapter{backend: b, newID: randomOperationID}
}
// ActBackend is the owner side of action tools. It is separate from Backend so
// that no consumer that holds only a Backend can run one.
type ActBackend interface {
// Act runs an action tool for the actor and returns its answer. It never
// proposes and never repeats a call.
Act(ctx context.Context, actor contract.Actor, request contract.Request) (contract.Result, error)
}
var _ ActBackend = (*gateway.Gateway)(nil)
// NewChatToolAdapter returns an adapter for the chats consumer: besides what
// NewToolAdapter serves, it lists and runs action tools, as the chat's user,
// with no confirmation. The owner it is given must be an ActBackend. A write,
// schema or create tool is made at once, not left as a proposal, when the owner
// is also an Applier: enabling the add-on for the chat is the consent, and the
// same preview, revision and receipt rules apply.
func NewChatToolAdapter(b Backend) *ToolAdapter {
t := &ToolAdapter{backend: b, newID: randomOperationID, actions: true}
t.apply, _ = b.(Applier)
return t
}
func randomOperationID() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
return "t" + hex.EncodeToString(raw[:]), nil
}
// ToolName formats a qualified identity. Callers validate components and, for
// model-callable tools, the total length with ParseToolName.
func ToolName(addonID, tool string) string { return Prefix + addonID + "__" + tool }
// ParseQualifiedToolName splits an add-on tool identity with valid contract
// components. It does not apply the model's total name limit: Studio stand-ins
// identify unavailable add-ons, including long IDs, rather than dispatch tools.
func ParseQualifiedToolName(name string) (addonID, tool string, ok bool) {
qualified, ok := strings.CutPrefix(name, Prefix)
if !ok {
return "", "", false
}
addonID, tool, ok = strings.Cut(qualified, "__")
if !ok || !contract.ValidName(addonID) || !contract.ValidName(tool) {
return "", "", false
}
return addonID, tool, true
}
// ParseToolName splits a model-callable name, enforcing its total length limit.
func ParseToolName(name string) (addonID, tool string, ok bool) {
if len(name) > MaxNameLength {
return "", "", false
}
return ParseQualifiedToolName(name)
}
// IsToolName reports whether name is in the namespace reserved for add-on
// tools. Other tool sources never use it.
func IsToolName(name string) bool { return strings.HasPrefix(name, Prefix) }
func checkActor(a contract.Actor) error {
if strings.TrimSpace(a.UserID) == "" || strings.TrimSpace(a.CredentialID) == "" || (a.Mode != "named-user" && a.Mode != "local-owner") {
return gateway.ErrForbidden
}
return nil
}
// visible lists the apps whose tools the actor can use now.
func (t *ToolAdapter) visible(ctx context.Context, actor contract.Actor) ([]apps.App, error) {
if err := checkActor(actor); err != nil {
return nil, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return nil, gateway.ClassifyCheck(err)
}
var out []apps.App
for _, a := range list.Apps {
if a.Enabled && a.State == "running" && a.Generation > 0 && (len(a.ToolDefinitions) > 0 || t.hasListing(a)) {
if err := validateListing(a); err != nil {
return nil, err
}
out = append(out, a)
}
}
return out, nil
}
// Controls returns the host controls the apps list draws for one add-on the
// actor can see (see contract.EntitledControls), or nil when it is not
// listed, has no UI or is not entitled to any.
func (t *ToolAdapter) Controls(ctx context.Context, actor contract.Actor, addonID string) ([]string, error) {
if err := checkActor(actor); err != nil {
return nil, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return nil, gateway.ClassifyCheck(err)
}
for _, a := range list.Apps {
if a.ID == addonID && a.UI != nil {
return slices.Clone(a.UI.Controls), nil
}
}
return nil, nil
}
// Network returns the network an add-on declares, when the actor can see the
// add-on and the apps list draws its network-policy control.
func (t *ToolAdapter) Network(ctx context.Context, actor contract.Actor, addonID string) (apps.NetworkView, bool, error) {
if err := checkActor(actor); err != nil {
return apps.NetworkView{}, false, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return apps.NetworkView{}, false, gateway.ClassifyCheck(err)
}
for _, a := range list.Apps {
if a.ID == addonID && a.Network != nil {
return *a.Network, true, nil
}
}
return apps.NetworkView{}, false, nil
}
// AddonSkills is the skills of one add-on the actor can see.
type AddonSkills struct {
AddonID, Title string
// InstalledAt is when the add-on was first installed, or empty.
InstalledAt string
Skills []contract.SkillDefinition
// Isolated are the skills that need the add-on's own commands tool to
// report isolated execution (see contract.IsolatedSkills).
Isolated []string
// Slots are the slots the add-on's skills serve (see contract.SlotsOf).
Slots []contract.Slot
// Running is true when the add-on may serve now: enabled and running, as
// Discover requires for tools.
Running bool
}
// Skills lists the installed add-ons that ship skills and that the actor can
// see, running or not. A chat offers the skills of a running one from the copy
// in its skill layer, and says when a built-in skill serves because the
// add-on that replaces it is not running.
func (t *ToolAdapter) Skills(ctx context.Context, actor contract.Actor) ([]AddonSkills, error) {
if err := checkActor(actor); err != nil {
return nil, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return nil, gateway.ClassifyCheck(err)
}
var out []AddonSkills
for _, a := range list.Apps {
if len(a.Skills) == 0 {
continue
}
running := a.Enabled && a.State == "running" && a.Generation > 0
out = append(out, AddonSkills{AddonID: a.ID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: slices.Clone(a.Skills),
Isolated: contract.IsolatedSkills(a.ToolDefinitions, a.Skills), Slots: contract.SlotsOf(a.Skills), Running: running})
}
slices.SortFunc(out, func(x, y AddonSkills) int { return strings.Compare(x.AddonID, y.AddonID) })
return out, nil
}
// AddonContextFiles is the context files one add-on declares.
type AddonContextFiles struct {
AddonID string
Files []contract.ContextFile
// Running is true when the add-on may serve now: enabled and running, as
// Discover requires for tools.
Running bool
}
// ContextFiles lists the installed add-ons that declare context files and that
// the actor can see, running or not. The host reads the files itself; a chat
// loads those of a running add-on, and the Memory page lists them for any.
func (t *ToolAdapter) ContextFiles(ctx context.Context, actor contract.Actor) ([]AddonContextFiles, error) {
if err := checkActor(actor); err != nil {
return nil, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return nil, gateway.ClassifyCheck(err)
}
var out []AddonContextFiles
for _, a := range list.Apps {
if len(a.ContextFiles) == 0 {
continue
}
running := a.Enabled && a.State == "running" && a.Generation > 0
out = append(out, AddonContextFiles{AddonID: a.ID, Files: slices.Clone(a.ContextFiles), Running: running})
}
slices.SortFunc(out, func(x, y AddonContextFiles) int { return strings.Compare(x.AddonID, y.AddonID) })
return out, nil
}
// AddonListing is the title and description an add-on declares for people.
type AddonListing struct {
AddonID, Title, Description string
}
// Listings returns the title and description of every add-on the actor can
// see. An add-on that declares neither has its ID as title.
func (t *ToolAdapter) Listings(ctx context.Context, actor contract.Actor) ([]AddonListing, error) {
if err := checkActor(actor); err != nil {
return nil, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return nil, gateway.ClassifyCheck(err)
}
out := make([]AddonListing, 0, len(list.Apps))
for _, a := range list.Apps {
out = append(out, AddonListing{AddonID: a.ID, Title: a.Title, Description: a.Description})
}
return out, nil
}
// AddonShadows is one installed add-on that replaces built-in chat tools, as
// the actor sees it, running or not.
type AddonShadows struct {
AddonID, Title string
// InstalledAt is when the add-on was first installed, or empty.
InstalledAt string
// Tools are the built-in chat tools it replaces (bash, web_fetch).
Tools []string
// Running is true when the add-on may serve now: enabled and running, as
// Discover requires.
Running bool
}
// Shadowers lists the installed add-ons that replace a built-in chat tool and
// that the actor can see, in any state. A chat uses it to tell a pinned add-on
// that is stopped (the built-in serves, with a note) from a pin to an add-on
// that is not installed (the pin is ignored), and Settings lists every add-on
// an administrator can choose.
func (t *ToolAdapter) Shadowers(ctx context.Context, actor contract.Actor) ([]AddonShadows, error) {
if err := checkActor(actor); err != nil {
return nil, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return nil, gateway.ClassifyCheck(err)
}
var out []AddonShadows
for _, a := range list.Apps {
var names []string
for _, d := range a.ToolDefinitions {
if d.Shadows != "" && slices.Contains(a.Tools, d.Name) && contract.ValidateShadow(d) == nil && !slices.Contains(names, d.Shadows) {
names = append(names, d.Shadows)
}
}
if len(names) == 0 {
continue
}
running := a.Enabled && a.State == "running" && a.Generation > 0
out = append(out, AddonShadows{AddonID: a.ID, Title: a.Title, InstalledAt: a.InstalledAt, Tools: names, Running: running})
}
slices.SortFunc(out, func(x, y AddonShadows) int { return strings.Compare(x.AddonID, y.AddonID) })
return out, nil
}
func definition(a apps.App, d contract.ToolDefinition) (ToolDefinition, bool) {
if d.UIOnly {
return ToolDefinition{}, false // for the add-on's own page, never for a model
}
name := ToolName(a.ID, d.Name)
if _, _, ok := ParseToolName(name); !ok || !slices.Contains(a.Tools, d.Name) {
return ToolDefinition{}, false
}
// The owner validated the manifest at install; it is checked again here
// because a schema is what a model is told to obey.
if contract.ValidateDefinitionSchema(d) != nil {
return ToolDefinition{}, false
}
var params map[string]any
if json.Unmarshal(d.InputSchema, &params) != nil {
return ToolDefinition{}, false
}
def := ToolDefinition{Name: name, AddonID: a.ID, Tool: d.Name, Kind: d.Kind, Description: d.Description, Parameters: params, InstalledAt: a.InstalledAt, Permission: contract.ToolPermission(a.Credentialed, d)}
// Checked again for the same reason as the schema: a manifest that names a
// tool outside the list, or a shadow of the wrong kind or schema, never
// shadows it.
if d.Shadows != "" && contract.ValidateShadow(d) == nil {
def.Shadows = d.Shadows
if d.Shadows == "bash" {
def.Execution = executionOf(a, d)
}
}
return def, true
}
// executionOf is the descriptor of a commands tool as the chat consumer sees
// it. A manifest that declares none behaves as a provider of commands under the
// add-on's title.
func executionOf(a apps.App, d contract.ToolDefinition) *contract.ExecutionDescriptor {
e := contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands}
if d.Execution != nil {
e = *d.Execution
}
if e.Title == "" {
e.Title = a.Title
}
return &e
}
// Discover returns the tools the actor may offer a model now, in a stable
// order. A model is never told about a tool it could not call.
func (t *ToolAdapter) Discover(ctx context.Context, actor contract.Actor) ([]ToolDefinition, error) {
list, err := t.visible(ctx, actor)
if err != nil {
return nil, err
}
var out []ToolDefinition
for _, a := range list {
if l, ok := t.listingOf(a); ok && len(out) < MaxTools {
out = append(out, resourceListingDefinition(a, l))
}
for _, d := range a.ToolDefinitions {
if d.Kind == contract.ToolKindAction && !t.actions {
continue
}
if def, ok := definition(a, d); ok && len(out) < MaxTools {
out = append(out, def)
}
}
}
slices.SortFunc(out, func(x, y ToolDefinition) int { return strings.Compare(x.Name, y.Name) })
return out, nil
}
// Execute runs one tool call as actor. The actor is the authenticated
// principal of the consumer's request or job; arguments cannot change it.
func (t *ToolAdapter) Execute(ctx context.Context, actor contract.Actor, name string, arguments json.RawMessage) (ToolResult, error) {
return t.ExecuteIn(ctx, actor, "", name, arguments)
}
// ChatCall is the chat a tool call comes from: the user whose files the chat
// uses, as the chats service names them, and the chat's ID. The chat turn
// builds it from its own record of the chat, never from the model's arguments.
type ChatCall struct {
principal, conversation string
}
// NewChatCall returns the chat of a tool call.
func NewChatCall(principal, conversation string) ChatCall {
return ChatCall{principal: principal, conversation: conversation}
}
// ExecuteChat runs one tool call of a chat, of any kind. The call is bound to
// the chat on the host side (hostops.WithBinding): the owner records the chat
// next to the envelope, so the add-on's workspace operations act with the
// chat's access, also for a read tool that names no resource. The envelope the
// add-on receives does not change. The conversation is the chat's ID, used as
// ExecuteIn uses it.
func (t *ToolAdapter) ExecuteChat(ctx context.Context, actor contract.Actor, call ChatCall, name string, arguments json.RawMessage) (ToolResult, error) {
b := hostops.Binding{Principal: call.principal, ChatID: call.conversation}
if !b.Valid() || !ValidConversationID(call.conversation) {
return ToolResult{}, ErrInvalidArguments
}
return t.execute(hostops.WithBinding(ctx, b), actor, call.conversation, name, arguments)
}
// ExecuteIn runs one tool call of a conversation. The conversation ID comes
// from the consumer's authenticated request, never from the arguments: it is
// used only when the call names no resource, to resolve the conversation's
// selection at the owner. An empty ID means the consumer has no conversation.
// The chats adapter refuses a conversation here: a chat's call goes through
// ExecuteChat, so it is never made without its binding.
func (t *ToolAdapter) ExecuteIn(ctx context.Context, actor contract.Actor, conversationID, name string, arguments json.RawMessage) (ToolResult, error) {
if t.actions && conversationID != "" {
return ToolResult{}, ErrInvalidArguments
}
return t.execute(ctx, actor, conversationID, name, arguments)
}
func (t *ToolAdapter) execute(ctx context.Context, actor contract.Actor, conversationID, name string, arguments json.RawMessage) (ToolResult, error) {
if conversationID != "" && !ValidConversationID(conversationID) {
return ToolResult{}, ErrInvalidArguments
}
addonID, tool, ok := ParseToolName(name)
if !ok {
return ToolResult{}, ErrUnknownTool
}
list, err := t.visible(ctx, actor)
if err != nil {
return ToolResult{}, err
}
var app apps.App
var def ToolDefinition
var found bool
var declared contract.ToolDefinition
for _, a := range list {
if a.ID != addonID {
continue
}
if l, ok := t.listingOf(a); ok && l.Tool == tool {
return t.listResources(ctx, actor, a, l, name, arguments)
}
for _, d := range a.ToolDefinitions {
if d.Name != tool {
continue
}
if d.Kind == contract.ToolKindAction && !t.actions {
continue // not offered to this consumer, so not there
}
if x, ok := definition(a, d); ok {
app, def, found, declared = a, x, true, d
}
}
}
if !found {
return ToolResult{}, ErrUnknownTool
}
resource, body, err := toolargs.Bind(def.Tool, def.Parameters, arguments, conversationID != "")
if err != nil {
if errors.Is(err, ErrInvalidArguments) {
if message := contract.LegacyMessage(declared, arguments); message != "" {
return ToolResult{Kind: ResultError, Tool: name, Code: CodeInvalidRequest, Message: message}, nil
}
}
return ToolResult{}, err
}
if resource == "" && conversationID != "" && requiresResource(def) {
sel, ok := t.backend.(Selector)
if !ok {
return ToolResult{}, ErrInvalidArguments
}
id, err := sel.ResolveSelection(ctx, actor, conversationID, app.ID)
switch {
case errors.Is(err, gateway.ErrNotSelected):
return ToolResult{Kind: ResultError, Tool: name, Code: CodeSelectResource, Note: SelectNote}, nil
case err != nil:
// A revoked, deleted or foreign reference is the denial of an
// unknown resource, so the model reads the same words as for a
// resource it named that does not exist. An outage stays an
// outage.
return refusedResult(name, err)
case !toolargs.ValidResourceID(id):
return ToolResult{}, gateway.ErrForbidden
}
resource = id
}
op, err := t.newID()
if err != nil {
return ToolResult{}, gateway.ErrUnavailable
}
request := contract.Request{AddonID: app.ID, ResourceID: resource, OperationID: op, Generation: app.Generation, Arguments: body}
if def.Kind == contract.ToolKindAction {
ab, ok := t.backend.(ActBackend)
if !ok {
return ToolResult{}, gateway.ErrUnavailable
}
request.Action = "act"
// An action works for one chat. The conversation comes from the chat's
// own authenticated turn and rides in ResourceID, mapped to a valid resource ID; the model cannot name
// it, because no action schema has that property. It scopes the
// add-on's state and is never authority.
if conversationID == "" {
return ToolResult{}, ErrInvalidArguments
}
request.ResourceID = toolargs.ActionResourceID(conversationID)
out, err := ab.Act(ctx, actor, request)
if err != nil {
return refusedResult(name, err)
}
return readResult(name, out)
}
if def.Kind == contract.ToolKindRead {
request.Action = "read"
out, err := t.backend.Dispatch(ctx, actor, request)
if err != nil {
return refusedResult(name, err)
}
return readResult(name, out)
}
request.Action = map[string]string{contract.ToolKindWrite: "write", contract.ToolKindSchema: "schema", contract.ToolKindCreate: "create"}[def.Kind]
p, err := t.backend.Propose(ctx, actor, request)
if err != nil {
return refusedResult(name, err)
}
if t.apply != nil && !p.AwaitsPerson {
return t.applyNow(ctx, actor, name, p)
}
ref := ProposalRef{ID: p.ID, AddonID: p.AddonID, ResourceID: p.ResourceID, Action: p.Action, Digest: p.Digest,
State: string(p.State), Count: p.Effects.Count, ExpiresAt: p.ExpiresAt}
if len(p.Effects.Sample) <= MaxSummaryBytes {
ref.Summary = p.Effects.Sample
}
return ToolResult{Kind: ResultProposal, Tool: name, Proposal: &ref, Note: ProposalNote}, nil
}
// applyNow confirms the proposal the adapter has just stored for the actor. A
// failure that is certain withdraws the proposal, so nothing waits for a
// browser; an unknown outcome is left alone and reported, never repeated.
func (t *ToolAdapter) applyNow(ctx context.Context, actor contract.Actor, name string, p gateway.Proposal) (ToolResult, error) {
// The mark keeps the owner from treating this confirmation as a person's
// when the chat and the browser share one credential (local-owner mode).
done, err := t.apply.ConfirmProposal(gateway.WithChatTurn(ctx), actor, p.ID, p.Digest)
if err != nil {
var unknown *gateway.OutcomeUnknown
var unavailable *gateway.ProposalUnavailable
if !errors.As(err, &unknown) && !errors.As(err, &unavailable) {
// The caller's context may be over; the withdrawal must still run.
_, _ = t.apply.CancelProposal(context.WithoutCancel(ctx), actor, p.ID)
}
return refusedResult(name, err)
}
if done.State != gateway.ProposalApplied {
// The add-on rejected it at the apply: the data moved or it cannot do
// this. Nothing changed, so the model corrects or asks again.
return ToolResult{}, gateway.ErrStale
}
ref := AppliedRef{ID: p.ID, AddonID: p.AddonID, ResourceID: p.ResourceID, Action: p.Action, Count: p.Effects.Count}
if len(p.Effects.Sample) <= MaxAppliedBytes {
ref.Summary = p.Effects.Sample
}
if done.Receipt != nil && len(done.Receipt.Result.Payload) <= MaxAppliedBytes {
ref.Result = done.Receipt.Result.Payload
}
note := AppliedNote
n, _ := contract.ParseUndoNote(receiptPayload(done))
switch {
case n.Available:
ref.Undoable, ref.ChangeID, ref.Undo = true, n.ID, n.Summary
note = UndoableNote
case n.Reason != "":
ref.Reason = n.Reason
default:
ref.Reason = noUndoReason
}
return ToolResult{Kind: ResultApplied, Tool: name, Applied: &ref, Note: note}, nil
}
func receiptPayload(p gateway.Proposal) json.RawMessage {
if p.Receipt == nil {
return nil
}
return p.Receipt.Result.Payload
}
// refusedResult turns the add-on's own refusal, a missing permission and an
// unreachable resource into a result the model can act
// on. Every other error, an outage and an unknown outcome included, passes
// through unchanged.
func refusedResult(name string, err error) (ToolResult, error) {
var limited *contract.ErrRateLimited
var refusal *gateway.CredentialRefusal
switch {
case errors.As(err, &limited):
return ToolResult{Kind: ResultError, Tool: name, Code: CodeRateLimited, Note: RateLimitedNote, RetryAfter: limited.Seconds()}, nil
case errors.As(err, &refusal) && refusal.Code == gateway.CodeResourceNotAllowed:
return ToolResult{Kind: ResultError, Tool: name, Code: CodeResourceNotAllowed, Note: ResourceNotAllowedNote, Allowed: refusal.Allowed}, nil
case errors.As(err, &refusal) && refusal.Code == gateway.CodeWriteNotAllowed:
return ToolResult{Kind: ResultError, Tool: name, Code: CodeWriteNotAllowed, Note: WriteNotAllowedNote}, nil
case errors.As(err, &refusal) && refusal.Code == gateway.CodeNoCredential:
return ToolResult{Kind: ResultError, Tool: name, Code: CodeNoCredential, Note: NoCredentialNote}, nil
case errors.Is(err, gateway.ErrNotPermitted):
return ToolResult{Kind: ResultError, Tool: name, Code: CodeNotPermitted, Note: NotPermittedNote}, nil
case errors.Is(err, gateway.ErrNotSent):
return ToolResult{Kind: ResultError, Tool: name, Code: CodeNotRunning, Note: NotRunningNote}, nil
case errors.Is(err, gateway.ErrResourceUnreachable):
return ToolResult{Kind: ResultError, Tool: name, Code: CodeResourceUnreachable, Note: ResourceUnreachableNote}, nil
}
if errors.Is(err, gateway.ErrInvalidRequest) || errors.Is(err, contract.ErrRefused) {
res := ToolResult{Kind: ResultError, Tool: name, Code: CodeInvalidRequest, Note: InvalidNote, Message: refusalMessage(err)}
if res.Message != "" {
res.Note = ReasonedNote
}
return res, nil
}
return ToolResult{}, err
}
// refusalMessage is the reason in the add-on's refusal, unless it is only the
// class every refusal has.
func refusalMessage(err error) string {
var r *addonruntime.Refusal
if errors.As(err, &r) && r.Reason != "" && r.Reason != "invalid request" {
return r.Reason
}
return ""
}
// requiresResource reports a tool whose schema requires the resource.
func requiresResource(def ToolDefinition) bool {
props, _ := def.Parameters["properties"].(map[string]any)
if _, ok := props[contract.ResourceArgument]; !ok {
return false
}
req, _ := def.Parameters["required"].([]any)
for _, r := range req {
if s, _ := r.(string); s == contract.ResourceArgument {
return true
}
}
return false
}
// ValidConversationID reports whether id has the shape of a conversation
// identifier: the namespace of one user's selections.
func ValidConversationID(id string) bool { return toolargs.ValidConversationID(id) }
func readResult(name string, out contract.Result) (ToolResult, error) {
if len(out.Outputs) > 0 {
refs := make([]ArtifactRef, 0, len(out.Outputs))
for _, o := range out.Outputs {
refs = append(refs, ArtifactRef{ID: o.Handle, Name: o.Name, MediaType: o.MediaType, Size: o.Size})
}
return ToolResult{Kind: ResultArtifact, Tool: name, Artifacts: refs}, nil
}
if len(out.Payload) > MaxResultBytes {
return ToolResult{}, ErrResultTooLarge
}
return ToolResult{Kind: ResultData, Tool: name, Result: out.Payload}, nil
}
// JSON is the form of a result in a tool message and in chat.
func (r ToolResult) JSON() string {
b, err := json.Marshal(r)
if err != nil {
return `{"kind":"error"}`
}
return string(b)
}
// String describes a definition for logs.
func (d ToolDefinition) String() string { return fmt.Sprintf("%s (%s)", d.Name, d.Kind) }