refactor(studio): decide command eligibility from the execution status

Document skills, the Commands block of the prompt and load_skill no
longer compare an add-on ID or provider with "sandbox". The host asks
whether the add-on that ships a skill serves the chat's commands and
whether the typed execution status reports them isolated. Skills that
need that are listed by the broker from the add-on's manifest, and the
text names the provider by the title its manifest declares.

An add-on whose commands tool declares no descriptor behaves as a
provider of commands under its own title and keeps the isolation check
for its document skills.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 10:07:46 +00:00
1 parent 0c08e17a30
commit 0c72b2c96f
12 files changed
+293 -77

No files matched your search

+2 -2
View File
@@ -751,7 +751,7 @@ func (s *studioAddons) Tools(ctx context.Context, user string) ([]studio.AddonTo
continue
}
out = append(out, studio.AddonTool{Name: d.Name, AddonID: d.AddonID, Description: d.Description, Schema: raw, Action: d.Kind != contract.ToolKindRead,
Shadows: d.Shadows, InstalledAt: d.InstalledAt})
Shadows: d.Shadows, Execution: d.Execution, InstalledAt: d.InstalledAt})
}
return filterByFeature(out, s.hostOps, s.has, actor), nil
}
@@ -777,7 +777,7 @@ func (s *studioAddons) AddonSkills(ctx context.Context, user string) ([]studio.A
if denied[a.AddonID] {
continue
}
out = append(out, studio.AddonSkillSet{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: contract.SkillNames(a.Skills),
out = append(out, studio.AddonSkillSet{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: contract.SkillNames(a.Skills), Isolated: a.Isolated,
Shadows: contract.ShadowedSkills(a.Skills), Running: a.Running})
}
return out, nil
+26 -1
View File
@@ -113,6 +113,10 @@ type ToolDefinition struct {
// 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"`
@@ -400,6 +404,9 @@ type AddonSkills struct {
// 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
// Running is true when the add-on may serve now: enabled and running, as
// Discover requires for tools.
Running bool
@@ -423,7 +430,8 @@ func (t *ToolAdapter) Skills(ctx context.Context, actor contract.Actor) ([]Addon
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), Running: running})
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), Running: running})
}
slices.SortFunc(out, func(x, y AddonSkills) int { return strings.Compare(x.AddonID, y.AddonID) })
return out, nil
@@ -496,10 +504,27 @@ func definition(a apps.App, d contract.ToolDefinition) (ToolDefinition, bool) {
// 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) {
@@ -187,6 +187,38 @@ var _ = Describe("A tool that shadows a built-in chat tool", func() {
})
})
var _ = Describe("The execution descriptor of a commands tool", func() {
ctx := context.Background()
schema := json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)
bashTool := func(e *contract.ExecutionDescriptor) contract.ToolDefinition {
return contract.ToolDefinition{Name: "bash", Kind: contract.ToolKindAction, Description: "Run.", InputSchema: schema, Shadows: "bash", Execution: e}
}
discover := func(app apps.App) tools.ToolDefinition {
defs, err := tools.NewChatToolAdapter(&fake{list: apps.List{Apps: []apps.App{app}}}).Discover(ctx, member)
Expect(err).NotTo(HaveOccurred())
Expect(defs).To(HaveLen(1))
return defs[0]
}
It("reaches the chat as the add-on declared it", func() {
e := &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox", Note: "It has jq."}
d := discover(apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{bashTool(e)}})
Expect(d.Execution).To(Equal(e))
})
It("falls back to the add-on's title for a manifest that declares none", func() {
d := discover(apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{bashTool(nil)}})
Expect(d.Execution).To(Equal(&contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Run box"}))
})
It("lists the skills that need isolation for the add-on that owns the commands tool", func() {
skills := []contract.SkillDefinition{{Name: "docx", Shadows: "docx"}, {Name: "runbox-tips"}}
app := apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, Skills: skills,
ToolDefinitions: []contract.ToolDefinition{bashTool(nil)}}
list, err := tools.NewChatToolAdapter(&fake{list: apps.List{Apps: []apps.App{app}}}).Skills(ctx, member)
Expect(err).NotTo(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Isolated).To(Equal([]string{"docx"}))
})
})
var _ = Describe("The add-ons that replace a built-in chat tool", func() {
ctx := context.Background()
bash := json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)
+19 -13
View File
@@ -38,6 +38,9 @@ type AddonSkillSet struct {
// InstalledAt is when the add-on was first installed, or empty.
InstalledAt string
Skills []string
// Isolated are the skills that run only when the add-on's own commands tool
// reports isolated execution (a subset of Skills).
Isolated []string
// Shadows are the built-in skills it replaces (a subset of Skills).
Shadows []string
// Running is true when the add-on can serve now: installed, enabled and
@@ -61,14 +64,15 @@ type addonSkills struct {
known []AddonSkillSet
// pins are the administrator's choices, by name.
pins map[string]string
// hiddenDocuments are the document skills of the Sandbox left out because
// it runs the chat's commands without isolation.
// hiddenDocuments are the document skills left out because the add-on
// that ships them runs the chat's commands without isolation.
hiddenDocuments []string
// documentsWhy is why the Sandbox is not known to be isolated.
documentsWhy string
// documentsWhy is why that add-on is not known to be isolated, and
// documentsBy its title.
documentsWhy, documentsBy string
}
// withNotes adds to notes the line that says why the Sandbox document skills
// withNotes adds to notes the line that says why the document skills
// are missing, so the person is not left to guess.
// documentNotePrefix starts the note that explains the missing document skills.
const documentNotePrefix = "the document skills "
@@ -77,7 +81,7 @@ func (a addonSkills) withNotes(notes []string) []string {
if len(a.hiddenDocuments) == 0 {
return notes
}
return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need an isolated Sandbox; "+a.documentsWhy)
return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need isolated command execution by "+a.documentsBy+"; "+a.documentsWhy)
}
// addonSkillsFor lists the add-ons of the user that ship skills and decides
@@ -118,13 +122,15 @@ func (s *Service) addonSkillsFor(ctx context.Context, p string, addonsOff []stri
if gate != nil && gate.Unavailable(ctx, p, set.AddonID, s.d.Files.Available()) != "" {
continue
}
if set.AddonID == "sandbox" && !sandboxDocumentEligible(command) {
if command.State == CommandsAvailable && command.Provider == "sandbox" {
if len(set.Isolated) > 0 && !isolatedProvider(command, set.AddonID) {
needs := func(n string) bool { return slices.Contains(set.Isolated, n) }
if command.State == CommandsAvailable && command.Provider == set.AddonID {
out.documentsWhy = s.documentsWhy(ctx, p, command)
out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !contract.DocumentSkill(n) })...)
out.documentsBy = command.Title
out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !needs(n) })...)
}
set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), contract.DocumentSkill)
set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), contract.DocumentSkill)
set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), needs)
set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), needs)
}
out.usable = append(out.usable, set)
}
@@ -239,7 +245,7 @@ func (a addonSkills) markFallbacks(list []skills.Skill) string {
return "The built-in skills " + strings.Join(parts, ", ") + " serve because the add-on's own skill is off or not running in this chat."
}
// documentsWhy says why the Sandbox that runs the chat's commands is not known
// documentsWhy says why the add-on that runs the chat's commands is not known
// to be isolated: it runs them on the host, or its status could not be read.
func (s *Service) documentsWhy(ctx context.Context, p string, command Commands) string {
if command.Isolation == "host" {
@@ -247,7 +253,7 @@ func (s *Service) documentsWhy(ctx context.Context, p string, command Commands)
}
value, d := ObserveExecution(ctx, p, command.Addon)
if value.Readiness == "unavailable" {
return "the Sandbox reports that it cannot run commands"
return commandsTitle(command) + " reports that it cannot run commands"
}
why := d.Failure.Text()
if d.Failure == "" {
+4
View File
@@ -12,6 +12,7 @@ import (
addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"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.
@@ -27,6 +28,9 @@ type AddonTool struct {
// 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
+81 -21
View File
@@ -9,10 +9,10 @@ import (
"time"
addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/core/services/studio/sandbox"
"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
@@ -25,7 +25,7 @@ import (
// 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 a sandbox, "host" when they
// 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
@@ -73,6 +73,12 @@ type Commands struct {
// 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
@@ -132,6 +138,17 @@ func addonRunner(tools []session.Tool) string {
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 {
@@ -174,19 +191,47 @@ func (s *Service) commandAddons(ctx context.Context, user string) []string {
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}
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}
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())
}
@@ -195,7 +240,7 @@ func (s *Service) commandsOf(ctx context.Context, user string, tools []session.T
off := set(addonsOff)
for _, id := range s.commandAddons(ctx, user) {
if off[id] || (sc != nil && !sc.addons[id]) {
return Commands{State: CommandsOff, Addon: id}
return Commands{State: CommandsOff, Addon: id, Title: s.commandTitle(ctx, user, id)}
}
}
return Commands{State: CommandsNone}
@@ -242,13 +287,13 @@ func (s *Service) commandsFor(ctx context.Context, p string, rec session.ChatRec
return shellSwitched(s.withHostBash(c, tools), s.hiddenFor(ctx, rec)), nil
}
// sandboxWhere is where a command of the Sandbox starts and what it keeps.
// 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 sandboxWhere(fileTools bool, place session.Place) string {
func commandsWhere(fileTools bool, place session.Place) string {
same := ""
if fileTools {
same = ", the same files as the file tools"
@@ -263,10 +308,18 @@ func sandboxWhere(fileTools bool, place session.Place) string {
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."
}
// skillsNotInSandbox is the sentence that keeps a model from looking for a
// skill folder in the Sandbox, which sees the user's home only, and points it
// at the tool that loads a skill by its name.
const skillsNotInSandbox = "Skill folders are not visible in the Sandbox: load skills with load_skill."
// 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"
}
// commandsBlock is the block of the system prompt that states whether the chat
// can run commands. writeFile says whether the chat has write_file,
@@ -292,11 +345,15 @@ func commandsBlock(c Commands, writeFile, fileTools, skillsOff bool, place sessi
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.
return "Commands: " + c.Tool + " runs commands and code in an isolated sandbox. " + sandboxWhere(fileTools, place) + " " + sandbox.GuestToolsNote + " " + skillsNotInSandbox
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. " + sandboxWhere(fileTools, place)
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. " + sandboxWhere(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 ")
@@ -308,11 +365,11 @@ func commandsBlock(c Commands, writeFile, fileTools, skillsOff bool, place sessi
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 the Sandbox add-on on in the Tools menu of this chat.")
b.WriteString("they can switch the " + commandsTitle(c) + " add-on on in the Tools menu of this chat.")
case CommandsUnavailable:
b.WriteString("the Sandbox add-on cannot run commands on this server; an administrator can fix that.")
b.WriteString("the " + commandsTitle(c) + " add-on cannot run commands on this server; an administrator can fix that.")
default:
b.WriteString("an administrator can enable running code by installing the Sandbox add-on.")
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.")
@@ -426,10 +483,13 @@ func (s *Service) repeatedTask(ctx context.Context, a workspace.Actor, task stri
return true, nil
}
// sandboxDocumentEligible requires the selected command provider, not just an
// installed guest or generic command authority. Unknown isolation fails closed.
func sandboxDocumentEligible(c Commands) bool {
return c.State == CommandsAvailable && c.Isolation == "isolated" && c.Addon == "sandbox" && c.Provider == "sandbox"
// 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
+52 -7
View File
@@ -15,12 +15,16 @@ import (
"github.com/mudler/LocalAI/core/services/studio/sandbox"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// sandboxExecution is how the Sandbox add-on declares its commands tool.
var sandboxExecution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Sandbox", Note: sandbox.GuestToolsNote}
// runAddon is the Sandbox add-on as the broker lists it: bash runs commands.
func runAddon() []studio.AddonTool {
bash := addonTool("sandbox", "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Action, bash.Shadows, bash.Execution = true, "bash", sandboxExecution
read := addonTool("sandbox", "read")
read.Action = true
return []studio.AddonTool{bash, read}
@@ -76,7 +80,7 @@ var _ = Describe("capabilities", func() {
Expect(prompt).To(ContainSubstring("do not ask a sub-agent to run it"))
Expect(prompt).To(ContainSubstring("write the output directly with write_file"))
Expect(prompt).To(ContainSubstring("give the user the script to run"))
Expect(prompt).To(ContainSubstring("an administrator can enable running code by installing the Sandbox add-on"))
Expect(prompt).To(ContainSubstring("an administrator can enable running code by installing an add-on that runs commands"))
Expect(prompt).To(ContainSubstring("A skill marked unavailable here needs this"))
Expect(prompt).To(ContainSubstring("- xlsx: Work with xlsx files. (unavailable here: needs command execution)"))
Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "an unavailable skill must not give the path to start it")
@@ -106,14 +110,14 @@ var _ = Describe("capabilities", func() {
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated sandbox."))
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Sandbox)."))
Expect(prompt).To(ContainSubstring(sandbox.GuestToolsNote), "the isolated sandbox lists its programs")
Expect(prompt).To(ContainSubstring("Skill folders are not visible in the Sandbox: load skills with load_skill."))
Expect(prompt).To(ContainSubstring("Skill folders are not visible in the isolated environment: load skills with load_skill."))
Expect(prompt).NotTo(ContainSubstring("nothing in this chat runs code"))
Expect(prompt).To(ContainSubstring("\n- xlsx: "))
Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "the index names no path")
Expect(prompt).NotTo(ContainSubstring("unavailable here"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "sandbox", Provider: "sandbox"}))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "sandbox", Provider: "sandbox", Title: "Sandbox", Note: sandbox.GuestToolsNote}))
})
It("warns when commands run on the server with no isolation", func() {
@@ -133,7 +137,7 @@ var _ = Describe("capabilities", func() {
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("nothing in this chat runs code or commands"))
Expect(prompt).To(ContainSubstring("they can switch the Sandbox add-on on in the Tools menu of this chat"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsOff, Addon: "sandbox"}))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsOff, Addon: "sandbox", Title: "Sandbox"}))
})
It("says the Sandbox cannot run when no backend works", func() {
@@ -143,7 +147,7 @@ var _ = Describe("capabilities", func() {
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("the Sandbox add-on cannot run commands on this server"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsUnavailable, Reason: "no sandbox backend works on this host", Addon: "sandbox"}))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsUnavailable, Reason: "no sandbox backend works on this host", Addon: "sandbox", Title: "Sandbox"}))
})
It("does not call the add-on off for a user who cannot use it", func() {
@@ -278,3 +282,44 @@ var _ = Describe("execution presentation DTO", func() {
}
})
})
var _ = Describe("a second provider of commands", func() {
runbox := func() []studio.AddonTool {
bash := addonTool("runbox", "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Execution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox", Note: "Runbox has python3 and jq."}
return []studio.AddonTool{bash}
}
It("is described by the title and the note it declares, not by a name known to the host", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "isolated"}, nil)
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Runbox)."))
Expect(prompt).To(ContainSubstring("Runbox has python3 and jq."))
Expect(prompt).NotTo(ContainSubstring("Sandbox"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "runbox", Provider: "runbox", Title: "Runbox", Note: "Runbox has python3 and jq."}))
})
It("is named when the chat switched it off and when it cannot run", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}}
svc, h, _ := newCapsChat(fa, nil)
id := create(svc, alice)
Expect(svc.SetAddonsOff(context.Background(), alice, id, []string{"runbox"})).To(Succeed())
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("they can switch the Runbox add-on on in the Tools menu of this chat"))
gate := gatedAddons{fakeAddons: fa, reasons: map[string]string{"runbox": "no backend works"}}
svc, h, _ = newCapsChat(gate, nil)
id = create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("the Runbox add-on cannot run commands on this server"))
Expect(prompt).NotTo(ContainSubstring("Sandbox"))
})
It("behaves as an unnamed provider when its manifest declares no descriptor", func() {
bash := addonTool("runbox", "bash")
bash.Action, bash.Shadows = true, "bash"
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: {bash}}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: ""}, nil)
id := create(svc, alice)
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code."))
})
})
@@ -13,6 +13,7 @@ import (
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/skills"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -23,7 +24,7 @@ var _ = Describe("document command eligibility", func() {
b := &skillBroker{}
svc, f := newSkillService(b)
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: md("docx", "Sandbox document.")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
list, _, err := svc.Skills(ctx, "alice", "")
Expect(err).NotTo(HaveOccurred())
Expect(skills.Names(skills.Usable(list))).NotTo(ContainElement("docx"))
@@ -37,21 +38,44 @@ var _ = Describe("document command eligibility", func() {
"pdf/SKILL.md": {Data: md("pdf", "Sandbox pdf.")},
"plot/SKILL.md": {Data: md("plot", "Sandbox plots.")},
})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx", "pdf", "plot"}, Shadows: []string{"docx", "pdf"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx", "pdf", "plot"}, Shadows: []string{"docx", "pdf"}, Isolated: []string{"docx", "pdf"}, Running: true})
_, notes, err := chatSkillNotes(svc, ctx, off, command)
Expect(err).NotTo(HaveOccurred())
if wantNote {
Expect(notes).To(ContainElement(HavePrefix("the document skills docx, pdf need an isolated Sandbox; ")))
Expect(notes).To(ContainElement(HavePrefix("the document skills docx, pdf need isolated command execution by Sandbox; ")))
} else {
Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated Sandbox"))
Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated command execution"))
}
},
Entry("commands run on the host", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, nil, true),
Entry("isolation unknown", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox"}, nil, true),
Entry("commands run on the host", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Title: "Sandbox", Isolation: "host"}, nil, true),
Entry("isolation unknown", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Title: "Sandbox"}, nil, true),
Entry("isolated", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, false),
Entry("another provider runs the commands", Commands{State: CommandsAvailable, Addon: "other", Provider: "other", Isolation: "host"}, nil, false),
Entry("the Sandbox is off for the chat", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, []string{"sandbox"}, false),
)
It("names the second provider by the title it declares, in the note and in the commands", func() {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "host", defs: []AddonTool{{AddonID: "runbox", Name: "addon__runbox__exec", Shadows: "bash",
Execution: &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox"}}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
Expect(f.SyncAddonSkills(ctx, "runbox", fstest.MapFS{"docx/SKILL.md": {Data: md("docx", "Runbox document.")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "runbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
command := documentCommands(svc, ctx, "alice", rec, nil)
Expect(command.Title).To(Equal("Runbox"))
Expect(isolatedProvider(command, "runbox")).To(BeFalse(), "the status says host")
_, notes, err := chatSkillNotes(svc, ctx, nil, command)
Expect(err).NotTo(HaveOccurred())
Expect(notes).To(ContainElement(ContainSubstring("need isolated command execution by Runbox; its commands run on the server without isolation")))
b.isolation = "isolated"
command = documentCommands(svc, ctx, "alice", rec, nil)
Expect(isolatedProvider(command, "runbox")).To(BeTrue())
list, _, err := chatSkillNotes(svc, ctx, nil, command)
Expect(err).NotTo(HaveOccurred())
Expect(skills.Names(skills.Usable(list))).To(ContainElement("docx"))
})
It("does not switch a missing pinned document provider to Office", func() {
a := addonSkills{pins: map[string]string{"docx": "sandbox"}, usable: []AddonSkillSet{{AddonID: "office", Shadows: []string{"docx"}}}}
id, chosen := a.provider("docx")
@@ -60,6 +84,9 @@ var _ = Describe("document command eligibility", func() {
})
})
// sandboxExecution is how the Sandbox add-on declares its commands tool.
var sandboxExecution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Sandbox"}
// This broker lists metadata but fails immediately if descriptor construction
// invokes a command. Keeping it separate avoids changing older fixtures.
type documentBroker struct {
@@ -88,7 +115,11 @@ var _ = Describe("document provider matrix", func() {
svc, f := newSkillService(b, map[string]string{"docx": pin})
for _, id := range ids {
Expect(f.SyncAddonSkills(ctx, id, fstest.MapFS{"docx/SKILL.md": {Data: md("docx", id+" document.")}})).To(Succeed())
b.sets = append(b.sets, AddonSkillSet{AddonID: id, Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
set := AddonSkillSet{AddonID: id, Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true}
if id == "sandbox" || id == "runbox" { // office ships no commands tool, so its document skill needs no isolation
set.Isolated = []string{"docx"}
}
b.sets = append(b.sets, set)
}
list, _, _, err := svc.chatSkillSet(ctx, "alice", nil, off, sc, command)
Expect(err).NotTo(HaveOccurred())
@@ -114,34 +145,38 @@ var _ = Describe("document provider matrix", func() {
Entry("unavailable pin", []string{"sandbox", "office"}, Commands{}, nil, nil, "sandbox", ""),
Entry("missing pin", []string{"office"}, Commands{}, nil, nil, "sandbox", ""),
Entry("eligible pin", []string{"sandbox", "office"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "sandbox", "sandbox"),
Entry("a second provider selected", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox", Isolation: "isolated"}, nil, nil, "", "runbox"),
Entry("a second provider on the host", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox", Isolation: "host"}, nil, nil, "", ""),
Entry("a second provider with unknown isolation", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox"}, nil, nil, "", ""),
Entry("the other provider runs the commands", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "", ""),
Entry("existing default tie", []string{"sandbox", "office"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "", "office"),
)
It("describes selected commands without recursive tool building or execution, including subagent final pruning", func() {
ctx := context.Background()
svc, _ := newSkillService(&skillBroker{})
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Schema: json.RawMessage(`{"type":"object"}`)}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution, Schema: json.RawMessage(`{"type":"object"}`)}}}
svc.d.AddonTools = b
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
c := documentCommands(svc, ctx, "alice", rec, nil)
Expect(sandboxDocumentEligible(c)).To(BeTrue())
Expect(isolatedProvider(c, "sandbox")).To(BeTrue())
Expect(b.calls).To(BeNumerically("<=", 3))
rec.Kind = session.KindSubAgent
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, nil))).To(BeFalse())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, nil), "sandbox")).To(BeFalse())
svc.allowed[key("alice", rec.ID)] = []session.Tool{{Name: "bash", Provider: "sandbox"}}
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, nil))).To(BeTrue())
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, &scope{deleted: true}))).To(BeFalse())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, nil), "sandbox")).To(BeTrue())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, &scope{deleted: true}), "sandbox")).To(BeFalse())
})
DescribeTable("uses saved-agent subagent grants for the index and loader", func(granted bool) {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
agentsStore, err := agents.NewJSON("")
Expect(err).NotTo(HaveOccurred())
svc.d.Agents = agentsStore
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: skillMD("docx", "Sandbox document.", "Sandbox body.", "requires: [exec]")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
grants := []string{}
if granted {
grants = []string{"sandbox"}
@@ -200,14 +235,14 @@ var _ = Describe("document provider matrix", func() {
It("says why a document skill is missing when the status cannot be read after the tool was offered", func() {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
agentsStore, err := agents.NewJSON("")
Expect(err).NotTo(HaveOccurred())
svc.d.Agents = agentsStore
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"pptx/SKILL.md": {Data: skillMD("pptx", "Sandbox slides.", "Slides body.", "requires: [exec]")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Isolated: []string{"pptx"}, Running: true})
agent, err := svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Slides", Model: "m", Addons: []string{"sandbox"}, Skills: []string{"pptx"}})
Expect(err).NotTo(HaveOccurred())
parent, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
@@ -230,7 +265,7 @@ var _ = Describe("document provider matrix", func() {
_, err = loader.Run(ctx, json.RawMessage(`{"name":"pptx"}`))
Expect(err).To(HaveOccurred())
Expect(err.Error()).NotTo(ContainSubstring("has no skills now"))
Expect(err.Error()).To(ContainSubstring("need an isolated Sandbox; its status could not be read"))
Expect(err.Error()).To(ContainSubstring("need isolated command execution by Sandbox; its status could not be read"))
})
It("opens a scoped chat with load_skill without reentering session access resolution", func() {
ctx := context.Background()
@@ -276,30 +311,30 @@ var _ = Describe("document provider matrix", func() {
It("refuses another user's chat and access errors rather than using the whole home", func() {
ctx := context.Background()
svc, _ := newSkillService(&skillBroker{})
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc.d.AddonTools = b
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
c, err := svc.addonCommandDescriptor(ctx, "bob", rec, nil)
Expect(err).To(HaveOccurred())
Expect(sandboxDocumentEligible(c)).To(BeFalse())
Expect(isolatedProvider(c, "sandbox")).To(BeFalse())
Expect(b.calls).To(BeZero())
rec.ID = "missing"
c, err = svc.addonCommandDescriptor(ctx, "alice", rec, nil)
Expect(err).To(HaveOccurred())
Expect(sandboxDocumentEligible(c)).To(BeFalse())
Expect(isolatedProvider(c, "sandbox")).To(BeFalse())
Expect(b.calls).To(BeZero())
// Only the genuinely chatless picker may describe person capabilities.
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", session.ChatRecord{}, nil))).To(BeTrue())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", session.ChatRecord{}, nil), "sandbox")).To(BeTrue())
})
It("keeps picker, slash selection, index and fresh loads aligned across readiness and final pruning", func() {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: skillMD("docx", "Sandbox document.", "Sandbox body.", "requires: [exec]")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
a := userChatActor("alice", rec.ID)
@@ -341,7 +376,7 @@ var _ = Describe("document provider matrix", func() {
svc.d.ToolPins = func(context.Context) (map[string]string, error) { return nil, unavailable }
command, err := svc.addonCommandDescriptor(ctx, "alice", rec, nil)
Expect(errors.Is(err, unavailable)).To(BeTrue())
Expect(sandboxDocumentEligible(command)).To(BeFalse())
Expect(isolatedProvider(command, "sandbox")).To(BeFalse())
_, err = svc.CommandRunner(ctx, "alice", rec.ID)
Expect(errors.Is(err, unavailable)).To(BeTrue())
_, _, err = svc.Skills(ctx, "alice", rec.ID)
+10 -6
View File
@@ -43,8 +43,12 @@ type loadOpts struct {
files bool
// exec is true when the chat can run commands. A skill that requires
// them is refused with the reason when it cannot.
exec bool
sandbox *bool
exec bool
// provided is true when an add-on, not the built-in bash, serves the
// chat's commands in the final tool list. Without it the commands count as
// absent when the skills resolve, so a skill that needs an add-on's
// isolation is not loaded on the strength of a stale descriptor.
provided *bool
}
// registerLoadSkill adds load_skill to the registry of built-in tools. It has
@@ -96,8 +100,8 @@ func (s *Service) fitLoadSkill(a workspace.Actor, tools []session.Tool) []sessio
return tools
}
// Final tools may only narrow the freshly checked descriptor at call time.
sandbox := addonRunner(tools) == "sandbox"
tools[i].Run = s.loadSkillRun(a, loadOpts{files: hasTool(tools, "read_file"), exec: s.runsCommands(tools), sandbox: &sandbox})
provided := addonRunner(tools) != ""
tools[i].Run = s.loadSkillRun(a, loadOpts{files: hasTool(tools, "read_file"), exec: s.runsCommands(tools), provided: &provided})
return tools
}
@@ -160,12 +164,12 @@ func (s *Service) loadSkillRun(a workspace.Actor, o loadOpts) func(context.Conte
if err != nil {
return "", err
}
list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.sandbox)
list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.provided)
if err != nil {
return "", fmt.Errorf("the skills cannot be read now: %w", err)
}
// The tool was offered because the chat had this skill; a document
// skill is missing now only because the Sandbox is not known to be
// skill is missing now only because its add-on is not known to be
// isolated, and the model must say so rather than "no skills".
if _, ok := skills.Find(list, in.Name); !ok && contract.DocumentSkill(skills.Clean(in.Name)) {
if i := slices.IndexFunc(notes, func(n string) bool { return strings.HasPrefix(n, documentNotePrefix) }); i >= 0 {
+5
View File
@@ -3,7 +3,9 @@ package session
import (
"context"
"encoding/json"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Harness starts agent sessions.
@@ -78,6 +80,9 @@ type Tool struct {
// say): "builtin" or the add-on's ID. It is empty for every other tool.
// The harness does not read it.
Provider string
// Execution is what the add-on that serves a command tool declares about
// itself, or nil. The harness does not read it.
Execution *contract.ExecutionDescriptor
// StandInFor is set on the stand-in tool of an add-on that cannot work
// now: the built-in tools that add-on replaces (bash, say), so the chat
// can tell that the built-in serves in its place. The harness does not
+1 -1
View File
@@ -56,7 +56,7 @@ func (s *Service) shadowTool(a workspace.Actor, d AddonTool, schema json.RawMess
// What the command saved in the user's files goes on its card.
run = s.withCommandFiles(a, run)
}
return session.Tool{Name: d.Shadows, Description: d.Description, Schema: schema, Provider: addon, Run: run}, true
return session.Tool{Name: d.Shadows, Description: d.Description, Schema: schema, Provider: addon, Execution: d.Execution, Run: run}, true
}
// withBuiltins adds the built-in tools that no add-on shadows. A built-in whose
+2 -2
View File
@@ -15,7 +15,7 @@ import (
// bash, which shadows the built-in bash, and status under its add-on name.
func shadowingSandbox() []studio.AddonTool {
bash := addonTool("sandbox", "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Action, bash.Shadows, bash.Execution = true, "bash", sandboxExecution
return []studio.AddonTool{bash, addonTool("sandbox", "status")}
}
@@ -105,6 +105,6 @@ var _ = Describe("an add-on tool that shadows a built-in", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: shadowingSandbox()}}
svc, h, _ := newCapsChat(isolatedAddons{fa, "isolated"}, nil)
id := create(svc, alice)
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code in an isolated sandbox."))
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Sandbox)."))
})
})