mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
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:
12 files changed
+293
-77
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)."))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user