From 0c72b2c96fd9cb93494dcdf9390e91e8c509ca75 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:49:42 +0000 Subject: [PATCH] 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 --- core/application/addon_gateway.go | 4 +- core/services/addons/tools/adapter.go | 27 ++++- .../services/addons/tools/definitions_test.go | 32 ++++++ core/services/studio/addonskills.go | 32 +++--- core/services/studio/addontools.go | 4 + core/services/studio/capabilities.go | 102 ++++++++++++++---- core/services/studio/capabilities_test.go | 59 ++++++++-- .../documenteligibility_internal_test.go | 83 +++++++++----- core/services/studio/loadskill.go | 16 +-- core/services/studio/session/harness.go | 5 + core/services/studio/shadow.go | 2 +- core/services/studio/shadow_test.go | 4 +- 12 files changed, 293 insertions(+), 77 deletions(-) diff --git a/core/application/addon_gateway.go b/core/application/addon_gateway.go index b08d40216..dc4864dd1 100644 --- a/core/application/addon_gateway.go +++ b/core/application/addon_gateway.go @@ -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 diff --git a/core/services/addons/tools/adapter.go b/core/services/addons/tools/adapter.go index 628aa873b..421f2eb0c 100644 --- a/core/services/addons/tools/adapter.go +++ b/core/services/addons/tools/adapter.go @@ -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) { diff --git a/core/services/addons/tools/definitions_test.go b/core/services/addons/tools/definitions_test.go index 2fc121f42..ec950778a 100644 --- a/core/services/addons/tools/definitions_test.go +++ b/core/services/addons/tools/definitions_test.go @@ -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}`) diff --git a/core/services/studio/addonskills.go b/core/services/studio/addonskills.go index e74a6a94c..2be60b878 100644 --- a/core/services/studio/addonskills.go +++ b/core/services/studio/addonskills.go @@ -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 == "" { diff --git a/core/services/studio/addontools.go b/core/services/studio/addontools.go index 1edfa140e..2ce347209 100644 --- a/core/services/studio/addontools.go +++ b/core/services/studio/addontools.go @@ -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 diff --git a/core/services/studio/capabilities.go b/core/services/studio/capabilities.go index 27bfd24a5..c32c85f6f 100644 --- a/core/services/studio/capabilities.go +++ b/core/services/studio/capabilities.go @@ -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 diff --git a/core/services/studio/capabilities_test.go b/core/services/studio/capabilities_test.go index 39162b35d..7fbe415b5 100644 --- a/core/services/studio/capabilities_test.go +++ b/core/services/studio/capabilities_test.go @@ -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.")) + }) +}) diff --git a/core/services/studio/documenteligibility_internal_test.go b/core/services/studio/documenteligibility_internal_test.go index 0cb4ae88d..4f61cdaac 100644 --- a/core/services/studio/documenteligibility_internal_test.go +++ b/core/services/studio/documenteligibility_internal_test.go @@ -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) diff --git a/core/services/studio/loadskill.go b/core/services/studio/loadskill.go index 1e7c3c991..44830a489 100644 --- a/core/services/studio/loadskill.go +++ b/core/services/studio/loadskill.go @@ -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 { diff --git a/core/services/studio/session/harness.go b/core/services/studio/session/harness.go index 93825e5af..95bddabd1 100644 --- a/core/services/studio/session/harness.go +++ b/core/services/studio/session/harness.go @@ -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 diff --git a/core/services/studio/shadow.go b/core/services/studio/shadow.go index a07288e2e..04e6bacd9 100644 --- a/core/services/studio/shadow.go +++ b/core/services/studio/shadow.go @@ -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 diff --git a/core/services/studio/shadow_test.go b/core/services/studio/shadow_test.go index 3d2839c65..e53a18101 100644 --- a/core/services/studio/shadow_test.go +++ b/core/services/studio/shadow_test.go @@ -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).")) }) })