diff --git a/addons/sandbox/internal/tools/tools.go b/addons/sandbox/internal/tools/tools.go index c763eab1b..9955e86d0 100644 --- a/addons/sandbox/internal/tools/tools.go +++ b/addons/sandbox/internal/tools/tools.go @@ -19,6 +19,7 @@ import ( "github.com/mudler/LocalAI/addons/internal/protocol" "github.com/mudler/LocalAI/addons/sandbox/internal/backends" + "github.com/mudler/LocalAI/core/services/studio/sandbox" "github.com/mudler/LocalAI/core/services/studio/sandbox/runner" "github.com/mudler/LocalAI/core/services/studio/workspace/workfiles" "github.com/mudler/LocalAI/pkg/addons/contract" @@ -115,6 +116,18 @@ func (r *runtime) Close(ctx context.Context) error { var errNoTool = errors.New("this tool is not served by the sandbox add-on") +// Execution is what the add-on declares about its bash tool: the host names the +// provider by this title, tells the model the note when commands are isolated, +// and offers the document skills of this package only while the status says so. +func Execution() *contract.ExecutionDescriptor { + return &contract.ExecutionDescriptor{ + Class: contract.ExecutionClassCommands, + Title: "Sandbox", + Note: sandbox.GuestToolsNote, + Skills: &contract.ExecutionSkills{ProvidesRuntimeFor: []string{contract.SkillKindDocuments}}, + } +} + // Definitions returns what a model sees of each tool. They come from the code // tools themselves, with the strictness the contract asks of an arguments // schema. @@ -133,6 +146,7 @@ func Definitions() []contract.ToolDefinition { def := contract.ToolDefinition{Name: t.Name, Kind: contract.ToolKindAction, Description: t.Description, InputSchema: raw} if t.Name == ShadowedTool { def.Shadows = ShadowedTool + def.Execution = Execution() } out = append(out, def) } diff --git a/addons/sandbox/internal/tools/tools_test.go b/addons/sandbox/internal/tools/tools_test.go index f191f62e1..a51cd3ff5 100644 --- a/addons/sandbox/internal/tools/tools_test.go +++ b/addons/sandbox/internal/tools/tools_test.go @@ -261,6 +261,7 @@ var _ = Describe("Sandbox tools", func() { // add-on's own page, with the default grant of egress.check. Expect(m.Capabilities.UI.Controls).To(Equal([]string{contract.ControlNetworkPolicy, contract.ControlToolProviders})) Expect(contract.EntitledControls(m, []string{"workspace.", "egress."})).To(Equal(m.Capabilities.UI.Controls)) + Expect(m.API.Max).To(BeNumerically(">=", contract.MinExecutionAPI), "the execution descriptor needs api 2") want := tools.Definitions() Expect(m.Capabilities.ToolDefinitions).To(HaveLen(len(want))) for i, d := range m.Capabilities.ToolDefinitions { @@ -272,6 +273,7 @@ var _ = Describe("Sandbox tools", func() { Expect(d.Description).To(Equal(want[i].Description), "definition %s differs from the runtime", d.Name) Expect(d.UIOnly).To(Equal(want[i].UIOnly), "definition %s differs from the runtime", d.Name) Expect(d.Shadows).To(Equal(want[i].Shadows), "definition %s differs from the runtime", d.Name) + Expect(d.Execution).To(Equal(want[i].Execution), "definition %s differs from the runtime", d.Name) Expect(a).To(Equal(b), "definition %s differs from the runtime", d.Name) Expect(contract.ValidateToolSchema(d.InputSchema, d.Kind)).To(Succeed(), "schema of %s", d.Name) } diff --git a/addons/sandbox/manifest.json b/addons/sandbox/manifest.json index b698af0ec..4059cfe41 100644 --- a/addons/sandbox/manifest.json +++ b/addons/sandbox/manifest.json @@ -1,6 +1,6 @@ { "api": { - "max": 1, + "max": 2, "min": 1 }, "arch": "amd64", @@ -41,7 +41,17 @@ ], "type": "object" }, - "shadows": "bash" + "shadows": "bash", + "execution": { + "class": "commands", + "title": "Sandbox", + "note": "The sandbox has: python3 (pandas, openpyxl, docx, pptx, pypdf, Pillow), pip, jq, ripgrep (rg), sqlite3, zip, unzip, file, tree, poppler (pdftotext, pdftoppm), git, curl. It has no Node, ImageMagick, ffmpeg or OCR, and apt cannot install anything.", + "skills": { + "providesRuntimeFor": [ + "documents" + ] + } + } }, { "name": "execution-status", diff --git a/core/services/studio/noaddonid_test.go b/core/services/studio/noaddonid_test.go new file mode 100644 index 000000000..9128fdd80 --- /dev/null +++ b/core/services/studio/noaddonid_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +package studio_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Core decides what an add-on may do from what the add-on declares and what it +// reports at run time. These specs fail when a file that sets the rules for +// command execution names one add-on again. Tests, comments, documentation and +// the add-on's own data are the places that may. +var _ = Describe("the command execution code of the host", func() { + // literals returns the string literals of the non-test Go files of dir. + literals := func(dir string, only ...string) map[string][]string { + out := map[string][]string{} + entries, err := os.ReadDir(dir) + Expect(err).NotTo(HaveOccurred()) + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + if len(only) > 0 && !slices.Contains(only, name) { + continue + } + path := filepath.Join(dir, name) + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + Expect(err).NotTo(HaveOccurred()) + ast.Inspect(file, func(n ast.Node) bool { + if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING { + v, err := strconv.Unquote(lit.Value) + Expect(err).NotTo(HaveOccurred()) + out[path] = append(out[path], v) + } + return true + }) + } + return out + } + + It("compares no string with the ID of the add-on that runs commands", func() { + // Other add-on IDs join this list as their special cases are removed. + ids := []string{"sandbox"} + for _, dir := range []string{".", "../addons/tools"} { + for path, values := range literals(dir) { + for _, v := range values { + Expect(ids).NotTo(ContainElement(v), "%s compares with the ID of an add-on", path) + } + } + } + }) + + It("names no add-on in the text it builds for the commands of a chat", func() { + for path, values := range literals(".", "capabilities.go", "addonskills.go", "loadskill.go", "shadow.go") { + for _, v := range values { + Expect(strings.ToLower(v)).NotTo(ContainSubstring("sandbox"), "%s writes a provider name into a sentence; take it from the manifest", path) + } + } + }) +}) diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json index e74c3959a..4171ebfcf 100644 --- a/scripts/addons/suites.json +++ b/scripts/addons/suites.json @@ -383,7 +383,17 @@ "an unknown delivery", "a page the UI does not declare", "a preview interval too short", - "a preview interval too long" + "a preview interval too long", + "is valid for a tool that shadows bash when the manifest accepts API 2", + "needs the manifest to accept API 2", + "is refused on a tool that does not shadow bash, with another class or with bad text", + "keeps a manifest of API 1 valid on a host that speaks API 2, and refuses one that needs API 3", + "names the document skills of a provider that declares it supplies their runtime", + "fails closed for a commands tool without a descriptor", + "names nothing for a descriptor that does not supply the runtime of documents", + "names nothing for an add-on without a commands tool", + "reports network-policy enforcement only for an available isolated provider", + "refuses a non-boolean enforcement claim" ] }, {