merge: bring master into the proposal UI branch

Union of both case lists in the add-on suite inventory; the swagger
files are regenerated from the merged code.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 10:21:41 +00:00
commit 0cd265e86e
32 files changed
+73914 -451

No files matched your search

+14
View File
@@ -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)
}
@@ -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)
}
+12 -2
View File
@@ -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",
+2 -2
View File
@@ -751,7 +751,7 @@ func (s *studioAddons) Tools(ctx context.Context, user string) ([]studio.AddonTo
continue
}
out = append(out, studio.AddonTool{Name: d.Name, AddonID: d.AddonID, Description: d.Description, Schema: raw, Action: d.Kind != contract.ToolKindRead,
Shadows: d.Shadows, InstalledAt: d.InstalledAt})
Shadows: d.Shadows, Execution: d.Execution, InstalledAt: d.InstalledAt})
}
return filterByFeature(out, s.hostOps, s.has, actor), nil
}
@@ -777,7 +777,7 @@ func (s *studioAddons) AddonSkills(ctx context.Context, user string) ([]studio.A
if denied[a.AddonID] {
continue
}
out = append(out, studio.AddonSkillSet{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: contract.SkillNames(a.Skills),
out = append(out, studio.AddonSkillSet{AddonID: a.AddonID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: contract.SkillNames(a.Skills), Isolated: a.Isolated,
Shadows: contract.ShadowedSkills(a.Skills), Running: a.Running})
}
return out, nil
+1 -1
View File
@@ -979,7 +979,7 @@ func (a *Application) startAddons() error {
}
host := c.Host
if host == (contract.HostCompatibility{}) {
host = contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH}
host = contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, APIMin: contract.HostAPIMin, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH}
}
cat = catalog.Catalog{Sources: sources, TargetOS: host.OS, TargetArch: host.Arch}
installer = &packages.Installer{Root: filepath.Join(c.Root, "packages"), Catalog: cat, Host: host}
+2 -2
View File
@@ -388,8 +388,8 @@ var _ = Describe("apps routes outcomes and documentation", Label("apps"), func()
Expect(json.Unmarshal(b, &m)).To(Succeed())
return m
}
full := apps.App{ID: "a", Name: "a", Title: "t", Titles: map[string]string{"en": "t"}, Description: "d", Descriptions: map[string]string{"en": "d"}, Icon: "i", Version: "1", InstalledAt: "2026-10-30T09:15:00Z", Generation: 1, Enabled: true, State: "running", Update: "update-pending", MainPage: "/m", HasUI: true, LaunchMode: "always", Tools: []string{"x"}, ToolCount: 1, Shadows: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{{Name: "x", Description: "d", Kind: "read", InputSchema: json.RawMessage(`{"type":"object"}`), UIOnly: true, Shadows: "bash"}}, Skills: []contract.SkillDefinition{{Name: "xlsx", Shadows: "xlsx"}}, SkillShadows: []string{"xlsx"}, UI: &apps.UIView{Entry: "/e", CSS: []string{"/c"}, SDK: contract.VersionRange{Min: 1, Max: 1}, Actions: []apps.ChatActionView{{ID: "a", Label: "A", Labels: map[string]string{"en": "A"}, Icon: "star", PackagedIcon: true, Placement: "composer", Tool: "t", Component: "C"}}, Slots: []apps.SlotView{{ID: "s", Slot: "files.preview", Component: "C", Extensions: []string{"docx"}}}, Renderers: []contract.Renderer{{Kind: "approval-card", Tool: "t", Component: "C"}}, Pages: []string{"p"}, Navigation: []string{"p"}, ChatActions: []string{"a"}, Controls: []string{"network-policy"}}, Admin: &apps.AdminView{CanStart: true, CanStop: true, ManagementURL: "/x"}}
for name, v := range map[string]any{"apps.App": full, "apps.AdminView": *full.Admin, "apps.UIView": *full.UI, "apps.ChatActionView": full.UI.Actions[0], "apps.SlotView": full.UI.Slots[0], "contract.Renderer": full.UI.Renderers[0], "contract.VersionRange": full.UI.SDK, "contract.ToolDefinition": full.ToolDefinitions[0], "contract.SkillDefinition": full.Skills[0], "apps.List": apps.List{Apps: []apps.App{full}}} {
full := apps.App{ID: "a", Name: "a", Title: "t", Titles: map[string]string{"en": "t"}, Description: "d", Descriptions: map[string]string{"en": "d"}, Icon: "i", Version: "1", InstalledAt: "2026-10-30T09:15:00Z", Generation: 1, Enabled: true, State: "running", Update: "update-pending", MainPage: "/m", HasUI: true, LaunchMode: "always", Tools: []string{"x"}, ToolCount: 1, Shadows: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{{Name: "x", Description: "d", Kind: "read", InputSchema: json.RawMessage(`{"type":"object"}`), UIOnly: true, Shadows: "bash", Execution: &contract.ExecutionDescriptor{Class: "commands", Title: "T", Note: "n", Skills: &contract.ExecutionSkills{ProvidesRuntimeFor: []string{"documents"}}}}}, Skills: []contract.SkillDefinition{{Name: "xlsx", Shadows: "xlsx"}}, SkillShadows: []string{"xlsx"}, UI: &apps.UIView{Entry: "/e", CSS: []string{"/c"}, SDK: contract.VersionRange{Min: 1, Max: 1}, Actions: []apps.ChatActionView{{ID: "a", Label: "A", Labels: map[string]string{"en": "A"}, Icon: "star", PackagedIcon: true, Placement: "composer", Tool: "t", Component: "C"}}, Slots: []apps.SlotView{{ID: "s", Slot: "files.preview", Component: "C", Extensions: []string{"docx"}}}, Renderers: []contract.Renderer{{Kind: "approval-card", Tool: "t", Component: "C"}}, Pages: []string{"p"}, Navigation: []string{"p"}, ChatActions: []string{"a"}, Controls: []string{"network-policy"}}, Admin: &apps.AdminView{CanStart: true, CanStop: true, ManagementURL: "/x"}}
for name, v := range map[string]any{"apps.App": full, "apps.AdminView": *full.Admin, "apps.UIView": *full.UI, "apps.ChatActionView": full.UI.Actions[0], "apps.SlotView": full.UI.Slots[0], "contract.Renderer": full.UI.Renderers[0], "contract.VersionRange": full.UI.SDK, "contract.ToolDefinition": full.ToolDefinitions[0], "contract.ExecutionDescriptor": *full.ToolDefinitions[0].Execution, "contract.ExecutionSkills": *full.ToolDefinitions[0].Execution.Skills, "contract.SkillDefinition": full.Skills[0], "apps.List": apps.List{Apps: []apps.App{full}}} {
def, ok := doc.Definitions[name]
Expect(ok).To(BeTrue(), name)
shape := emitted(v)
+1 -1
View File
@@ -284,7 +284,7 @@ func (s *Supervisor) start(ctx context.Context, pkg packages.PackageReceipt, gen
func (s *Supervisor) launch(ctx context.Context, pkg packages.PackageReceipt, generation uint64) (*process, error) {
ctx, cancel := context.WithTimeout(ctx, s.config.ReadyTimeout)
defer cancel()
host := contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH}
host := contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, APIMin: contract.HostAPIMin, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: goruntime.GOOS, Arch: goruntime.GOARCH}
if contract.ValidateManifest(pkg.Manifest, host) != nil || !filepath.IsAbs(pkg.Directory) {
return nil, ErrRuntime
}
+26 -1
View File
@@ -113,6 +113,10 @@ type ToolDefinition struct {
// contract.ToolDefinition.Shadows), or empty. Only the chat consumer acts
// on it; every other consumer keeps the namespaced Name.
Shadows string `json:"shadows,omitempty"`
// Execution is what the add-on declares about a tool that shadows bash. It
// is never nil on such a tool: a manifest without a descriptor gets one
// with the add-on's title.
Execution *contract.ExecutionDescriptor `json:"execution,omitempty"`
// InstalledAt is when the add-on was first installed (RFC 3339, UTC), or
// empty. The chat consumer ranks the add-ons that shadow one name by it.
InstalledAt string `json:"installedAt,omitempty"`
@@ -400,6 +404,9 @@ type AddonSkills struct {
// InstalledAt is when the add-on was first installed, or empty.
InstalledAt string
Skills []contract.SkillDefinition
// Isolated are the skills that need the add-on's own commands tool to
// report isolated execution (see contract.IsolatedSkills).
Isolated []string
// Running is true when the add-on may serve now: enabled and running, as
// Discover requires for tools.
Running bool
@@ -423,7 +430,8 @@ func (t *ToolAdapter) Skills(ctx context.Context, actor contract.Actor) ([]Addon
continue
}
running := a.Enabled && a.State == "running" && a.Generation > 0
out = append(out, AddonSkills{AddonID: a.ID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: slices.Clone(a.Skills), Running: running})
out = append(out, AddonSkills{AddonID: a.ID, Title: a.Title, InstalledAt: a.InstalledAt, Skills: slices.Clone(a.Skills),
Isolated: contract.IsolatedSkills(a.ToolDefinitions, a.Skills), Running: running})
}
slices.SortFunc(out, func(x, y AddonSkills) int { return strings.Compare(x.AddonID, y.AddonID) })
return out, nil
@@ -496,10 +504,27 @@ func definition(a apps.App, d contract.ToolDefinition) (ToolDefinition, bool) {
// shadows it.
if d.Shadows != "" && contract.ValidateShadow(d) == nil {
def.Shadows = d.Shadows
if d.Shadows == "bash" {
def.Execution = executionOf(a, d)
}
}
return def, true
}
// executionOf is the descriptor of a commands tool as the chat consumer sees
// it. A manifest that declares none behaves as a provider of commands under the
// add-on's title.
func executionOf(a apps.App, d contract.ToolDefinition) *contract.ExecutionDescriptor {
e := contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands}
if d.Execution != nil {
e = *d.Execution
}
if e.Title == "" {
e.Title = a.Title
}
return &e
}
// Discover returns the tools the actor may offer a model now, in a stable
// order. A model is never told about a tool it could not call.
func (t *ToolAdapter) Discover(ctx context.Context, actor contract.Actor) ([]ToolDefinition, error) {
@@ -187,6 +187,38 @@ var _ = Describe("A tool that shadows a built-in chat tool", func() {
})
})
var _ = Describe("The execution descriptor of a commands tool", func() {
ctx := context.Background()
schema := json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)
bashTool := func(e *contract.ExecutionDescriptor) contract.ToolDefinition {
return contract.ToolDefinition{Name: "bash", Kind: contract.ToolKindAction, Description: "Run.", InputSchema: schema, Shadows: "bash", Execution: e}
}
discover := func(app apps.App) tools.ToolDefinition {
defs, err := tools.NewChatToolAdapter(&fake{list: apps.List{Apps: []apps.App{app}}}).Discover(ctx, member)
Expect(err).NotTo(HaveOccurred())
Expect(defs).To(HaveLen(1))
return defs[0]
}
It("reaches the chat as the add-on declared it", func() {
e := &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox", Note: "It has jq."}
d := discover(apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{bashTool(e)}})
Expect(d.Execution).To(Equal(e))
})
It("falls back to the add-on's title for a manifest that declares none", func() {
d := discover(apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, ToolDefinitions: []contract.ToolDefinition{bashTool(nil)}})
Expect(d.Execution).To(Equal(&contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Run box"}))
})
It("lists the skills that need isolation for the add-on that owns the commands tool", func() {
skills := []contract.SkillDefinition{{Name: "docx", Shadows: "docx"}, {Name: "runbox-tips"}}
app := apps.App{ID: "runbox", Title: "Run box", Generation: 1, Enabled: true, State: "running", Tools: []string{"bash"}, Skills: skills,
ToolDefinitions: []contract.ToolDefinition{bashTool(nil)}}
list, err := tools.NewChatToolAdapter(&fake{list: apps.List{Apps: []apps.App{app}}}).Skills(ctx, member)
Expect(err).NotTo(HaveOccurred())
Expect(list).To(HaveLen(1))
Expect(list[0].Isolated).To(Equal([]string{"docx"}))
})
})
var _ = Describe("The add-ons that replace a built-in chat tool", func() {
ctx := context.Background()
bash := json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)
+19 -13
View File
@@ -38,6 +38,9 @@ type AddonSkillSet struct {
// InstalledAt is when the add-on was first installed, or empty.
InstalledAt string
Skills []string
// Isolated are the skills that run only when the add-on's own commands tool
// reports isolated execution (a subset of Skills).
Isolated []string
// Shadows are the built-in skills it replaces (a subset of Skills).
Shadows []string
// Running is true when the add-on can serve now: installed, enabled and
@@ -61,14 +64,15 @@ type addonSkills struct {
known []AddonSkillSet
// pins are the administrator's choices, by name.
pins map[string]string
// hiddenDocuments are the document skills of the Sandbox left out because
// it runs the chat's commands without isolation.
// hiddenDocuments are the document skills left out because the add-on
// that ships them runs the chat's commands without isolation.
hiddenDocuments []string
// documentsWhy is why the Sandbox is not known to be isolated.
documentsWhy string
// documentsWhy is why that add-on is not known to be isolated, and
// documentsBy its title.
documentsWhy, documentsBy string
}
// withNotes adds to notes the line that says why the Sandbox document skills
// withNotes adds to notes the line that says why the document skills
// are missing, so the person is not left to guess.
// documentNotePrefix starts the note that explains the missing document skills.
const documentNotePrefix = "the document skills "
@@ -77,7 +81,7 @@ func (a addonSkills) withNotes(notes []string) []string {
if len(a.hiddenDocuments) == 0 {
return notes
}
return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need an isolated Sandbox; "+a.documentsWhy)
return append(notes, documentNotePrefix+strings.Join(a.hiddenDocuments, ", ")+" need isolated command execution by "+a.documentsBy+"; "+a.documentsWhy)
}
// addonSkillsFor lists the add-ons of the user that ship skills and decides
@@ -118,13 +122,15 @@ func (s *Service) addonSkillsFor(ctx context.Context, p string, addonsOff []stri
if gate != nil && gate.Unavailable(ctx, p, set.AddonID, s.d.Files.Available()) != "" {
continue
}
if set.AddonID == "sandbox" && !sandboxDocumentEligible(command) {
if command.State == CommandsAvailable && command.Provider == "sandbox" {
if len(set.Isolated) > 0 && !isolatedProvider(command, set.AddonID) {
needs := func(n string) bool { return slices.Contains(set.Isolated, n) }
if command.State == CommandsAvailable && command.Provider == set.AddonID {
out.documentsWhy = s.documentsWhy(ctx, p, command)
out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !contract.DocumentSkill(n) })...)
out.documentsBy = command.Title
out.hiddenDocuments = append(out.hiddenDocuments, slices.DeleteFunc(slices.Clone(set.Skills), func(n string) bool { return !needs(n) })...)
}
set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), contract.DocumentSkill)
set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), contract.DocumentSkill)
set.Skills = slices.DeleteFunc(slices.Clone(set.Skills), needs)
set.Shadows = slices.DeleteFunc(slices.Clone(set.Shadows), needs)
}
out.usable = append(out.usable, set)
}
@@ -239,7 +245,7 @@ func (a addonSkills) markFallbacks(list []skills.Skill) string {
return "The built-in skills " + strings.Join(parts, ", ") + " serve because the add-on's own skill is off or not running in this chat."
}
// documentsWhy says why the Sandbox that runs the chat's commands is not known
// documentsWhy says why the add-on that runs the chat's commands is not known
// to be isolated: it runs them on the host, or its status could not be read.
func (s *Service) documentsWhy(ctx context.Context, p string, command Commands) string {
if command.Isolation == "host" {
@@ -247,7 +253,7 @@ func (s *Service) documentsWhy(ctx context.Context, p string, command Commands)
}
value, d := ObserveExecution(ctx, p, command.Addon)
if value.Readiness == "unavailable" {
return "the Sandbox reports that it cannot run commands"
return commandsTitle(command) + " reports that it cannot run commands"
}
why := d.Failure.Text()
if d.Failure == "" {
+4
View File
@@ -12,6 +12,7 @@ import (
addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// AddonTool is one tool of an installed add-on, as a chat offers it to the model.
@@ -27,6 +28,9 @@ type AddonTool struct {
// empty: the tool keeps its add-on name. A tool that shadows bash is the
// add-on's tool that runs shell commands and code.
Shadows string
// Execution is what the add-on declares about a tool that shadows bash
// (its provider title and note), or nil.
Execution *contract.ExecutionDescriptor
// InstalledAt is when the add-on was first installed (RFC 3339, UTC), or
// empty. It decides the default provider of a shadowed name.
InstalledAt string
+81 -21
View File
@@ -9,10 +9,10 @@ import (
"time"
addontools "github.com/mudler/LocalAI/core/services/addons/tools"
"github.com/mudler/LocalAI/core/services/studio/sandbox"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/skills"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// A chat that has no tool to run commands must say so. Without that fact a
@@ -25,7 +25,7 @@ import (
// CommandIsolation is implemented by a broker that can tell how the tool that
// runs commands is isolated. It is optional.
type CommandIsolation interface {
// Isolation is "isolated" when commands run in a sandbox, "host" when they
// Isolation is "isolated" when commands run in an isolated environment, "host" when they
// run directly on the server with no isolation, and "" when the broker
// cannot tell.
Isolation(ctx context.Context, user, addonID string) string
@@ -73,6 +73,12 @@ type Commands struct {
// Addon is the add-on that runs commands, or that would (off,
// unavailable).
Addon string
// Title names the add-on in the text the model and the person read: the
// title its commands tool declares, or the add-on's own.
Title string
// Note is the sentence the add-on declares for the model when its commands
// are isolated, or "".
Note string
// Provider serves the tool when State is available: the add-on's ID, or
// BuiltinProvider for the built-in bash on this machine.
Provider string
@@ -132,6 +138,17 @@ func addonRunner(tools []session.Tool) string {
return ""
}
// commandsDeclared is what the add-on that serves the commands in tools
// declares about its commands tool.
func commandsDeclared(tools []session.Tool, addon string) *contract.ExecutionDescriptor {
for _, t := range tools {
if t.Provider == addon && t.Execution != nil {
return t.Execution
}
}
return nil
}
// standInRunner returns the add-on whose stand-in tool (the add-on cannot
// work) is in the list, when that add-on replaces bash.
func standInRunner(tools []session.Tool) string {
@@ -174,19 +191,47 @@ func (s *Service) commandAddons(ctx context.Context, user string) []string {
return OrderProviders(ids, installed)
}
// commandTitle is the name of an add-on that is not serving commands now: the
// title of its manifest when the broker lists add-ons in any state, else the
// title its commands tool declares, else its ID.
func (s *Service) commandTitle(ctx context.Context, user, id string) string {
if _, ok := s.d.AddonTools.(ShadowLister); ok {
if t := s.shadowersFor(ctx, user).title[id]; t != "" {
return t
}
}
if s.d.AddonTools == nil {
return id
}
if defs, err := s.d.AddonTools.Tools(ctx, user); err == nil {
for _, d := range defs {
if d.AddonID == id && d.Execution != nil && d.Execution.Title != "" {
return d.Execution.Title
}
}
}
return id
}
// commandsOf says whether the tools of a chat can run commands. addonsOff are
// the add-ons the chat switched off and sc the agent's scope, for a chat that
// cannot: they tell an add-on that is installed but off from none at all.
func (s *Service) commandsOf(ctx context.Context, user string, tools []session.Tool, addonsOff []string, sc *scope) Commands {
if addon := addonRunner(tools); addon != "" {
c := Commands{State: CommandsAvailable, Tool: commandTool, Addon: addon}
c := Commands{State: CommandsAvailable, Tool: commandTool, Addon: addon, Title: addon}
if e := commandsDeclared(tools, addon); e != nil {
c.Note = e.Note
if e.Title != "" {
c.Title = e.Title
}
}
if iso, ok := s.d.AddonTools.(CommandIsolation); ok {
c.Isolation = iso.Isolation(ctx, user, addon)
}
return c
}
if addon := standInRunner(tools); addon != "" {
c := Commands{State: CommandsUnavailable, Addon: addon}
c := Commands{State: CommandsUnavailable, Addon: addon, Title: s.commandTitle(ctx, user, addon)}
if gate, ok := s.d.AddonTools.(AddonGate); ok {
c.Reason = gate.Unavailable(ctx, user, addon, s.d.Files.Available())
}
@@ -195,7 +240,7 @@ func (s *Service) commandsOf(ctx context.Context, user string, tools []session.T
off := set(addonsOff)
for _, id := range s.commandAddons(ctx, user) {
if off[id] || (sc != nil && !sc.addons[id]) {
return Commands{State: CommandsOff, Addon: id}
return Commands{State: CommandsOff, Addon: id, Title: s.commandTitle(ctx, user, id)}
}
}
return Commands{State: CommandsNone}
@@ -242,13 +287,13 @@ func (s *Service) commandsFor(ctx context.Context, p string, rec session.ChatRec
return shellSwitched(s.withHostBash(c, tools), s.hiddenFor(ctx, rec)), nil
}
// sandboxWhere is where a command of the Sandbox starts and what it keeps.
// commandsWhere is where a command of an add-on starts and what it keeps.
// fileTools says whether the chat has the file tools, which see the same
// files: a chat without them must not hear of them. A scoped chat (its agent
// works in one folder) has that folder at /workspace/home, at the agent's
// mode, and its chat folder at /workspace/chat; its shared folders are read
// only.
func sandboxWhere(fileTools bool, place session.Place) string {
func commandsWhere(fileTools bool, place session.Place) string {
same := ""
if fileTools {
same = ", the same files as the file tools"
@@ -263,10 +308,18 @@ func sandboxWhere(fileTools bool, place session.Place) string {
return "It starts in /workspace/home, the user's home" + same + "; only what it saves under /workspace/home or a writable shared folder is kept."
}
// skillsNotInSandbox is the sentence that keeps a model from looking for a
// skill folder in the Sandbox, which sees the user's home only, and points it
// at the tool that loads a skill by its name.
const skillsNotInSandbox = "Skill folders are not visible in the Sandbox: load skills with load_skill."
// skillsNotInProvider is the sentence that keeps a model from looking for a
// skill folder in an isolated environment, which sees the user's home only, and
// points it at the tool that loads a skill by its name.
const skillsNotInProvider = "Skill folders are not visible in the isolated environment: load skills with load_skill."
// commandsTitle is the name the prompt gives the add-on that runs commands.
func commandsTitle(c Commands) string {
if c.Title != "" {
return c.Title
}
return "the add-on"
}
// commandsBlock is the block of the system prompt that states whether the chat
// can run commands. writeFile says whether the chat has write_file,
@@ -292,11 +345,15 @@ func commandsBlock(c Commands, writeFile, fileTools, skillsOff bool, place sessi
case c.Isolation == "isolated":
// The turn's note is cut at 400 characters and the tool description
// is capped, so the list of programs goes here, in the prompt.
return "Commands: " + c.Tool + " runs commands and code in an isolated sandbox. " + sandboxWhere(fileTools, place) + " " + sandbox.GuestToolsNote + " " + skillsNotInSandbox
b := "Commands: " + c.Tool + " runs commands and code in an isolated environment (" + commandsTitle(c) + "). " + commandsWhere(fileTools, place) + " "
if c.Note != "" {
b += c.Note + " "
}
return b + skillsNotInProvider
case c.Isolation == "host":
return "Commands: " + c.Tool + " runs commands directly on the server, with no isolation. Ask before you delete or change anything outside the task. " + sandboxWhere(fileTools, place)
return "Commands: " + c.Tool + " runs commands directly on the server, with no isolation. Ask before you delete or change anything outside the task. " + commandsWhere(fileTools, place)
}
return "Commands: " + c.Tool + " runs commands and code. " + sandboxWhere(fileTools, place)
return "Commands: " + c.Tool + " runs commands and code. " + commandsWhere(fileTools, place)
}
var b strings.Builder
b.WriteString("Commands: nothing in this chat runs code or commands. Never claim or imply that you ran anything, and do not ask a sub-agent to run it: it has the same tools. Instead ")
@@ -308,11 +365,11 @@ func commandsBlock(c Commands, writeFile, fileTools, skillsOff bool, place sessi
case CommandsSwitchedOff:
b.WriteString("they switched running commands off for this chat and can switch Run commands on in its Tools menu.")
case CommandsOff:
b.WriteString("they can switch the Sandbox add-on on in the Tools menu of this chat.")
b.WriteString("they can switch the " + commandsTitle(c) + " add-on on in the Tools menu of this chat.")
case CommandsUnavailable:
b.WriteString("the Sandbox add-on cannot run commands on this server; an administrator can fix that.")
b.WriteString("the " + commandsTitle(c) + " add-on cannot run commands on this server; an administrator can fix that.")
default:
b.WriteString("an administrator can enable running code by installing the Sandbox add-on.")
b.WriteString("an administrator can enable running code by installing an add-on that runs commands.")
}
if skillsOff {
b.WriteString(" A skill marked unavailable here needs this: say so instead of starting it.")
@@ -426,10 +483,13 @@ func (s *Service) repeatedTask(ctx context.Context, a workspace.Actor, task stri
return true, nil
}
// sandboxDocumentEligible requires the selected command provider, not just an
// installed guest or generic command authority. Unknown isolation fails closed.
func sandboxDocumentEligible(c Commands) bool {
return c.State == CommandsAvailable && c.Isolation == "isolated" && c.Addon == "sandbox" && c.Provider == "sandbox"
// isolatedProvider reports whether the add-on serves the chat's commands and
// the typed execution status says they are isolated. It is what the skills of
// that add-on that need isolation require: the selected command provider, not
// just an installed guest or generic command authority. Unknown isolation
// fails closed.
func isolatedProvider(c Commands, addon string) bool {
return c.State == CommandsAvailable && c.Isolation == "isolated" && c.Addon == addon && c.Provider == addon
}
// addonCommandDescriptor describes existing add-on authority without building
+52 -7
View File
@@ -15,12 +15,16 @@ import (
"github.com/mudler/LocalAI/core/services/studio/sandbox"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// sandboxExecution is how the Sandbox add-on declares its commands tool.
var sandboxExecution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Sandbox", Note: sandbox.GuestToolsNote}
// runAddon is the Sandbox add-on as the broker lists it: bash runs commands.
func runAddon() []studio.AddonTool {
bash := addonTool("sandbox", "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Action, bash.Shadows, bash.Execution = true, "bash", sandboxExecution
read := addonTool("sandbox", "read")
read.Action = true
return []studio.AddonTool{bash, read}
@@ -76,7 +80,7 @@ var _ = Describe("capabilities", func() {
Expect(prompt).To(ContainSubstring("do not ask a sub-agent to run it"))
Expect(prompt).To(ContainSubstring("write the output directly with write_file"))
Expect(prompt).To(ContainSubstring("give the user the script to run"))
Expect(prompt).To(ContainSubstring("an administrator can enable running code by installing the Sandbox add-on"))
Expect(prompt).To(ContainSubstring("an administrator can enable running code by installing an add-on that runs commands"))
Expect(prompt).To(ContainSubstring("A skill marked unavailable here needs this"))
Expect(prompt).To(ContainSubstring("- xlsx: Work with xlsx files. (unavailable here: needs command execution)"))
Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "an unavailable skill must not give the path to start it")
@@ -106,14 +110,14 @@ var _ = Describe("capabilities", func() {
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated sandbox."))
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Sandbox)."))
Expect(prompt).To(ContainSubstring(sandbox.GuestToolsNote), "the isolated sandbox lists its programs")
Expect(prompt).To(ContainSubstring("Skill folders are not visible in the Sandbox: load skills with load_skill."))
Expect(prompt).To(ContainSubstring("Skill folders are not visible in the isolated environment: load skills with load_skill."))
Expect(prompt).NotTo(ContainSubstring("nothing in this chat runs code"))
Expect(prompt).To(ContainSubstring("\n- xlsx: "))
Expect(prompt).NotTo(ContainSubstring("/workspace/shared-skills/xlsx/SKILL.md"), "the index names no path")
Expect(prompt).NotTo(ContainSubstring("unavailable here"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "sandbox", Provider: "sandbox"}))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "sandbox", Provider: "sandbox", Title: "Sandbox", Note: sandbox.GuestToolsNote}))
})
It("warns when commands run on the server with no isolation", func() {
@@ -133,7 +137,7 @@ var _ = Describe("capabilities", func() {
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("nothing in this chat runs code or commands"))
Expect(prompt).To(ContainSubstring("they can switch the Sandbox add-on on in the Tools menu of this chat"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsOff, Addon: "sandbox"}))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsOff, Addon: "sandbox", Title: "Sandbox"}))
})
It("says the Sandbox cannot run when no backend works", func() {
@@ -143,7 +147,7 @@ var _ = Describe("capabilities", func() {
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("the Sandbox add-on cannot run commands on this server"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsUnavailable, Reason: "no sandbox backend works on this host", Addon: "sandbox"}))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsUnavailable, Reason: "no sandbox backend works on this host", Addon: "sandbox", Title: "Sandbox"}))
})
It("does not call the add-on off for a user who cannot use it", func() {
@@ -278,3 +282,44 @@ var _ = Describe("execution presentation DTO", func() {
}
})
})
var _ = Describe("a second provider of commands", func() {
runbox := func() []studio.AddonTool {
bash := addonTool("runbox", "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Execution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox", Note: "Runbox has python3 and jq."}
return []studio.AddonTool{bash}
}
It("is described by the title and the note it declares, not by a name known to the host", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: "isolated"}, nil)
id := create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Runbox)."))
Expect(prompt).To(ContainSubstring("Runbox has python3 and jq."))
Expect(prompt).NotTo(ContainSubstring("Sandbox"))
Expect(svc.CommandRunner(context.Background(), alice, id)).To(Equal(studio.Commands{State: studio.CommandsAvailable, Tool: "bash", Isolation: "isolated", Addon: "runbox", Provider: "runbox", Title: "Runbox", Note: "Runbox has python3 and jq."}))
})
It("is named when the chat switched it off and when it cannot run", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runbox()}}
svc, h, _ := newCapsChat(fa, nil)
id := create(svc, alice)
Expect(svc.SetAddonsOff(context.Background(), alice, id, []string{"runbox"})).To(Succeed())
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("they can switch the Runbox add-on on in the Tools menu of this chat"))
gate := gatedAddons{fakeAddons: fa, reasons: map[string]string{"runbox": "no backend works"}}
svc, h, _ = newCapsChat(gate, nil)
id = create(svc, alice)
prompt := turnPrompt(svc, h, id)
Expect(prompt).To(ContainSubstring("the Runbox add-on cannot run commands on this server"))
Expect(prompt).NotTo(ContainSubstring("Sandbox"))
})
It("behaves as an unnamed provider when its manifest declares no descriptor", func() {
bash := addonTool("runbox", "bash")
bash.Action, bash.Shadows = true, "bash"
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: {bash}}}
svc, h, _ := newCapsChat(isolatedAddons{fakeAddons: fa, mode: ""}, nil)
id := create(svc, alice)
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code."))
})
})
@@ -13,6 +13,7 @@ import (
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/skills"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -23,7 +24,7 @@ var _ = Describe("document command eligibility", func() {
b := &skillBroker{}
svc, f := newSkillService(b)
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: md("docx", "Sandbox document.")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
list, _, err := svc.Skills(ctx, "alice", "")
Expect(err).NotTo(HaveOccurred())
Expect(skills.Names(skills.Usable(list))).NotTo(ContainElement("docx"))
@@ -37,21 +38,44 @@ var _ = Describe("document command eligibility", func() {
"pdf/SKILL.md": {Data: md("pdf", "Sandbox pdf.")},
"plot/SKILL.md": {Data: md("plot", "Sandbox plots.")},
})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx", "pdf", "plot"}, Shadows: []string{"docx", "pdf"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx", "pdf", "plot"}, Shadows: []string{"docx", "pdf"}, Isolated: []string{"docx", "pdf"}, Running: true})
_, notes, err := chatSkillNotes(svc, ctx, off, command)
Expect(err).NotTo(HaveOccurred())
if wantNote {
Expect(notes).To(ContainElement(HavePrefix("the document skills docx, pdf need an isolated Sandbox; ")))
Expect(notes).To(ContainElement(HavePrefix("the document skills docx, pdf need isolated command execution by Sandbox; ")))
} else {
Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated Sandbox"))
Expect(strings.Join(notes, ";")).NotTo(ContainSubstring("isolated command execution"))
}
},
Entry("commands run on the host", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, nil, true),
Entry("isolation unknown", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox"}, nil, true),
Entry("commands run on the host", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Title: "Sandbox", Isolation: "host"}, nil, true),
Entry("isolation unknown", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Title: "Sandbox"}, nil, true),
Entry("isolated", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, false),
Entry("another provider runs the commands", Commands{State: CommandsAvailable, Addon: "other", Provider: "other", Isolation: "host"}, nil, false),
Entry("the Sandbox is off for the chat", Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "host"}, []string{"sandbox"}, false),
)
It("names the second provider by the title it declares, in the note and in the commands", func() {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "host", defs: []AddonTool{{AddonID: "runbox", Name: "addon__runbox__exec", Shadows: "bash",
Execution: &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Runbox"}}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
Expect(f.SyncAddonSkills(ctx, "runbox", fstest.MapFS{"docx/SKILL.md": {Data: md("docx", "Runbox document.")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "runbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
command := documentCommands(svc, ctx, "alice", rec, nil)
Expect(command.Title).To(Equal("Runbox"))
Expect(isolatedProvider(command, "runbox")).To(BeFalse(), "the status says host")
_, notes, err := chatSkillNotes(svc, ctx, nil, command)
Expect(err).NotTo(HaveOccurred())
Expect(notes).To(ContainElement(ContainSubstring("need isolated command execution by Runbox; its commands run on the server without isolation")))
b.isolation = "isolated"
command = documentCommands(svc, ctx, "alice", rec, nil)
Expect(isolatedProvider(command, "runbox")).To(BeTrue())
list, _, err := chatSkillNotes(svc, ctx, nil, command)
Expect(err).NotTo(HaveOccurred())
Expect(skills.Names(skills.Usable(list))).To(ContainElement("docx"))
})
It("does not switch a missing pinned document provider to Office", func() {
a := addonSkills{pins: map[string]string{"docx": "sandbox"}, usable: []AddonSkillSet{{AddonID: "office", Shadows: []string{"docx"}}}}
id, chosen := a.provider("docx")
@@ -60,6 +84,9 @@ var _ = Describe("document command eligibility", func() {
})
})
// sandboxExecution is how the Sandbox add-on declares its commands tool.
var sandboxExecution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: "Sandbox"}
// This broker lists metadata but fails immediately if descriptor construction
// invokes a command. Keeping it separate avoids changing older fixtures.
type documentBroker struct {
@@ -88,7 +115,11 @@ var _ = Describe("document provider matrix", func() {
svc, f := newSkillService(b, map[string]string{"docx": pin})
for _, id := range ids {
Expect(f.SyncAddonSkills(ctx, id, fstest.MapFS{"docx/SKILL.md": {Data: md("docx", id+" document.")}})).To(Succeed())
b.sets = append(b.sets, AddonSkillSet{AddonID: id, Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
set := AddonSkillSet{AddonID: id, Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true}
if id == "sandbox" || id == "runbox" { // office ships no commands tool, so its document skill needs no isolation
set.Isolated = []string{"docx"}
}
b.sets = append(b.sets, set)
}
list, _, _, err := svc.chatSkillSet(ctx, "alice", nil, off, sc, command)
Expect(err).NotTo(HaveOccurred())
@@ -114,34 +145,38 @@ var _ = Describe("document provider matrix", func() {
Entry("unavailable pin", []string{"sandbox", "office"}, Commands{}, nil, nil, "sandbox", ""),
Entry("missing pin", []string{"office"}, Commands{}, nil, nil, "sandbox", ""),
Entry("eligible pin", []string{"sandbox", "office"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "sandbox", "sandbox"),
Entry("a second provider selected", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox", Isolation: "isolated"}, nil, nil, "", "runbox"),
Entry("a second provider on the host", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox", Isolation: "host"}, nil, nil, "", ""),
Entry("a second provider with unknown isolation", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "runbox", Provider: "runbox"}, nil, nil, "", ""),
Entry("the other provider runs the commands", []string{"runbox"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "", ""),
Entry("existing default tie", []string{"sandbox", "office"}, Commands{State: CommandsAvailable, Addon: "sandbox", Provider: "sandbox", Isolation: "isolated"}, nil, nil, "", "office"),
)
It("describes selected commands without recursive tool building or execution, including subagent final pruning", func() {
ctx := context.Background()
svc, _ := newSkillService(&skillBroker{})
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Schema: json.RawMessage(`{"type":"object"}`)}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution, Schema: json.RawMessage(`{"type":"object"}`)}}}
svc.d.AddonTools = b
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
c := documentCommands(svc, ctx, "alice", rec, nil)
Expect(sandboxDocumentEligible(c)).To(BeTrue())
Expect(isolatedProvider(c, "sandbox")).To(BeTrue())
Expect(b.calls).To(BeNumerically("<=", 3))
rec.Kind = session.KindSubAgent
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, nil))).To(BeFalse())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, nil), "sandbox")).To(BeFalse())
svc.allowed[key("alice", rec.ID)] = []session.Tool{{Name: "bash", Provider: "sandbox"}}
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, nil))).To(BeTrue())
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", rec, &scope{deleted: true}))).To(BeFalse())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, nil), "sandbox")).To(BeTrue())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", rec, &scope{deleted: true}), "sandbox")).To(BeFalse())
})
DescribeTable("uses saved-agent subagent grants for the index and loader", func(granted bool) {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
agentsStore, err := agents.NewJSON("")
Expect(err).NotTo(HaveOccurred())
svc.d.Agents = agentsStore
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: skillMD("docx", "Sandbox document.", "Sandbox body.", "requires: [exec]")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
grants := []string{}
if granted {
grants = []string{"sandbox"}
@@ -200,14 +235,14 @@ var _ = Describe("document provider matrix", func() {
It("says why a document skill is missing when the status cannot be read after the tool was offered", func() {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
agentsStore, err := agents.NewJSON("")
Expect(err).NotTo(HaveOccurred())
svc.d.Agents = agentsStore
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"pptx/SKILL.md": {Data: skillMD("pptx", "Sandbox slides.", "Slides body.", "requires: [exec]")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"pptx"}, Shadows: []string{"pptx"}, Isolated: []string{"pptx"}, Running: true})
agent, err := svc.SaveAgent(ctx, "alice", agents.Agent{Name: "Slides", Model: "m", Addons: []string{"sandbox"}, Skills: []string{"pptx"}})
Expect(err).NotTo(HaveOccurred())
parent, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
@@ -230,7 +265,7 @@ var _ = Describe("document provider matrix", func() {
_, err = loader.Run(ctx, json.RawMessage(`{"name":"pptx"}`))
Expect(err).To(HaveOccurred())
Expect(err.Error()).NotTo(ContainSubstring("has no skills now"))
Expect(err.Error()).To(ContainSubstring("need an isolated Sandbox; its status could not be read"))
Expect(err.Error()).To(ContainSubstring("need isolated command execution by Sandbox; its status could not be read"))
})
It("opens a scoped chat with load_skill without reentering session access resolution", func() {
ctx := context.Background()
@@ -276,30 +311,30 @@ var _ = Describe("document provider matrix", func() {
It("refuses another user's chat and access errors rather than using the whole home", func() {
ctx := context.Background()
svc, _ := newSkillService(&skillBroker{})
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc.d.AddonTools = b
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
c, err := svc.addonCommandDescriptor(ctx, "bob", rec, nil)
Expect(err).To(HaveOccurred())
Expect(sandboxDocumentEligible(c)).To(BeFalse())
Expect(isolatedProvider(c, "sandbox")).To(BeFalse())
Expect(b.calls).To(BeZero())
rec.ID = "missing"
c, err = svc.addonCommandDescriptor(ctx, "alice", rec, nil)
Expect(err).To(HaveOccurred())
Expect(sandboxDocumentEligible(c)).To(BeFalse())
Expect(isolatedProvider(c, "sandbox")).To(BeFalse())
Expect(b.calls).To(BeZero())
// Only the genuinely chatless picker may describe person capabilities.
Expect(sandboxDocumentEligible(documentCommands(svc, ctx, "alice", session.ChatRecord{}, nil))).To(BeTrue())
Expect(isolatedProvider(documentCommands(svc, ctx, "alice", session.ChatRecord{}, nil), "sandbox")).To(BeTrue())
})
It("keeps picker, slash selection, index and fresh loads aligned across readiness and final pruning", func() {
ctx := context.Background()
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash"}}}
b := &documentBroker{skillBroker: &skillBroker{}, isolation: "isolated", defs: []AddonTool{{AddonID: "sandbox", Name: "addon__sandbox__exec", Shadows: "bash", Execution: sandboxExecution}}}
svc, f := newSkillService(b.skillBroker)
svc.d.AddonTools = b
Expect(f.SyncAddonSkills(ctx, "sandbox", fstest.MapFS{"docx/SKILL.md": {Data: skillMD("docx", "Sandbox document.", "Sandbox body.", "requires: [exec]")}})).To(Succeed())
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Running: true})
b.set(AddonSkillSet{AddonID: "sandbox", Skills: []string{"docx"}, Shadows: []string{"docx"}, Isolated: []string{"docx"}, Running: true})
rec, err := svc.Create(ctx, "alice", CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
a := userChatActor("alice", rec.ID)
@@ -341,7 +376,7 @@ var _ = Describe("document provider matrix", func() {
svc.d.ToolPins = func(context.Context) (map[string]string, error) { return nil, unavailable }
command, err := svc.addonCommandDescriptor(ctx, "alice", rec, nil)
Expect(errors.Is(err, unavailable)).To(BeTrue())
Expect(sandboxDocumentEligible(command)).To(BeFalse())
Expect(isolatedProvider(command, "sandbox")).To(BeFalse())
_, err = svc.CommandRunner(ctx, "alice", rec.ID)
Expect(errors.Is(err, unavailable)).To(BeTrue())
_, _, err = svc.Skills(ctx, "alice", rec.ID)
+10 -6
View File
@@ -43,8 +43,12 @@ type loadOpts struct {
files bool
// exec is true when the chat can run commands. A skill that requires
// them is refused with the reason when it cannot.
exec bool
sandbox *bool
exec bool
// provided is true when an add-on, not the built-in bash, serves the
// chat's commands in the final tool list. Without it the commands count as
// absent when the skills resolve, so a skill that needs an add-on's
// isolation is not loaded on the strength of a stale descriptor.
provided *bool
}
// registerLoadSkill adds load_skill to the registry of built-in tools. It has
@@ -96,8 +100,8 @@ func (s *Service) fitLoadSkill(a workspace.Actor, tools []session.Tool) []sessio
return tools
}
// Final tools may only narrow the freshly checked descriptor at call time.
sandbox := addonRunner(tools) == "sandbox"
tools[i].Run = s.loadSkillRun(a, loadOpts{files: hasTool(tools, "read_file"), exec: s.runsCommands(tools), sandbox: &sandbox})
provided := addonRunner(tools) != ""
tools[i].Run = s.loadSkillRun(a, loadOpts{files: hasTool(tools, "read_file"), exec: s.runsCommands(tools), provided: &provided})
return tools
}
@@ -160,12 +164,12 @@ func (s *Service) loadSkillRun(a workspace.Actor, o loadOpts) func(context.Conte
if err != nil {
return "", err
}
list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.sandbox)
list, notes, err := s.loadableSkillsAndNotes(ctx, a, o.provided)
if err != nil {
return "", fmt.Errorf("the skills cannot be read now: %w", err)
}
// The tool was offered because the chat had this skill; a document
// skill is missing now only because the Sandbox is not known to be
// skill is missing now only because its add-on is not known to be
// isolated, and the model must say so rather than "no skills".
if _, ok := skills.Find(list, in.Name); !ok && contract.DocumentSkill(skills.Clean(in.Name)) {
if i := slices.IndexFunc(notes, func(n string) bool { return strings.HasPrefix(n, documentNotePrefix) }); i >= 0 {
+70
View File
@@ -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)
}
}
})
})
+5
View File
@@ -3,7 +3,9 @@ package session
import (
"context"
"encoding/json"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Harness starts agent sessions.
@@ -78,6 +80,9 @@ type Tool struct {
// say): "builtin" or the add-on's ID. It is empty for every other tool.
// The harness does not read it.
Provider string
// Execution is what the add-on that serves a command tool declares about
// itself, or nil. The harness does not read it.
Execution *contract.ExecutionDescriptor
// StandInFor is set on the stand-in tool of an add-on that cannot work
// now: the built-in tools that add-on replaces (bash, say), so the chat
// can tell that the built-in serves in its place. The harness does not
+1 -1
View File
@@ -56,7 +56,7 @@ func (s *Service) shadowTool(a workspace.Actor, d AddonTool, schema json.RawMess
// What the command saved in the user's files goes on its card.
run = s.withCommandFiles(a, run)
}
return session.Tool{Name: d.Shadows, Description: d.Description, Schema: schema, Provider: addon, Run: run}, true
return session.Tool{Name: d.Shadows, Description: d.Description, Schema: schema, Provider: addon, Execution: d.Execution, Run: run}, true
}
// withBuiltins adds the built-in tools that no add-on shadows. A built-in whose
+2 -2
View File
@@ -15,7 +15,7 @@ import (
// bash, which shadows the built-in bash, and status under its add-on name.
func shadowingSandbox() []studio.AddonTool {
bash := addonTool("sandbox", "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Action, bash.Shadows, bash.Execution = true, "bash", sandboxExecution
return []studio.AddonTool{bash, addonTool("sandbox", "status")}
}
@@ -105,6 +105,6 @@ var _ = Describe("an add-on tool that shadows a built-in", func() {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: shadowingSandbox()}}
svc, h, _ := newCapsChat(isolatedAddons{fa, "isolated"}, nil)
id := create(svc, alice)
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code in an isolated sandbox."))
Expect(turnPrompt(svc, h, id)).To(ContainSubstring("Commands: bash runs commands and code in an isolated environment (Sandbox)."))
})
})
+49 -1
View File
@@ -139,7 +139,8 @@ and reconciling the operation before further use.
## Optional typed manifest extensions
These declarations extend the strict manifest contract. They keep
`schemaVersion: 1`, host/API/protocol/data-format level 1 and UI SDK 2.
`schemaVersion: 1`, host/protocol/data-format level 1 and UI SDK 2. The manifest
`api` range is 1 to 2: only the execution descriptor below needs 2.
The Go contract parser is authoritative; there is no separate JSON Schema.
The fragments below merge into an otherwise valid manifest, not a complete
installable package. Existing executable, identity, compatibility and UI
@@ -287,6 +288,53 @@ list and rejects its pending responses, even before effect cleanup. A new list
request can populate the refreshed session. These in-memory scopes contain no
credentials.
### Execution descriptor of a commands tool
Needs manifest `api` 2. On the definition of a tool that shadows `bash`:
```json
{
"name": "bash",
"kind": "action",
"shadows": "bash",
"description": "...",
"inputSchema": { "...": "..." },
"execution": {
"class": "commands",
"title": "Runbox",
"note": "The environment has python3, jq and sqlite3.",
"skills": { "providesRuntimeFor": ["documents"] }
}
}
```
The descriptor says what the add-on is, so that the host can write about it
without knowing its ID. It grants no authority. Whether commands are ready and
isolated stays what the provider reports in its execution status (see above);
the host decides from that report.
| Field | Meaning |
|---|---|
| `class` | Required. `commands`: the tool runs shell commands and code. |
| `title` | Optional, at most 40 characters of plain text. The host calls the provider by this name in the prompt and in the Tools menu text ("Runbox runs commands in an isolated environment", "switch the Runbox add-on on"). Without it, the host uses the add-on's own title. |
| `note` | Optional, at most 600 characters of plain text. One sentence for the model, such as the programs the environment has. The host adds it to the Commands block only while the provider reports `isolated`. |
| `skills.providesRuntimeFor` | Optional list of skill kinds. `documents` means the add-on's document skills (`xlsx`, `docx`, `pptx`, `pdf`) run on this provider. |
The host offers the document skills of an add-on only while that add-on serves
the chat's commands and its status reports `isolated`. When the status is
`host` or unknown, or another provider serves the commands, the skills are left
out and the chat says why. A tool descriptor may leave `providesRuntimeFor`
out to opt out of that check, because its skills do not need its commands.
The field is optional and does not change the protocol. A manifest that
declares a descriptor must accept API 2 (`"api": {"min": 1, "max": 2}`). The
host speaks API 2 and still accepts add-ons that declare `{1,1}`. An add-on
whose commands tool declares no descriptor works as before: the host treats it
as a provider of commands under the add-on's own title, with no note, and keeps
the isolation check for its document skills. The host never compares the add-on
ID with a fixed name for any of this, so a second add-on that runs commands gets
the same behavior with its own title.
### Requested host operations are not grants
Inside `capabilities`:
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: MIT
package contract
import (
"fmt"
"slices"
)
// MinExecutionAPI is the first manifest API version that knows the execution
// descriptor. A manifest that declares one must accept it.
const MinExecutionAPI uint64 = 2
// ExecutionClassCommands is the class of a tool that runs shell commands and
// code: the tool that shadows bash.
const ExecutionClassCommands = "commands"
// Kinds of skills a commands provider can supply the runtime for.
const (
// SkillKindDocuments are the skills of the document slots (see
// DocumentSkill). They need a provider that reports isolated execution.
SkillKindDocuments = "documents"
)
// Limits of the text the host shows for an execution provider.
const (
MaxExecutionTitleChars = 40
MaxExecutionNoteChars = 600
)
// ExecutionDescriptor is what an add-on declares about the tool that runs
// commands. It carries no authority. Whether commands are ready and isolated
// is what the provider reports at run time (ExecutionStatus); the host decides
// from that report and never from the add-on's ID.
type ExecutionDescriptor struct {
// Class is ExecutionClassCommands.
Class string `json:"class"`
// Title names the provider in the text the host shows the person and the
// model, for example "Sandbox". It falls back to the add-on's title.
Title string `json:"title,omitempty"`
// Note is one plain sentence for the model when the provider reports
// isolated execution, such as the programs its environment has.
Note string `json:"note,omitempty"`
// Skills says which skills run on this provider.
Skills *ExecutionSkills `json:"skills,omitempty"`
}
// ExecutionSkills declares the skills whose runtime the provider supplies.
type ExecutionSkills struct {
// ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host
// offers the add-on's skills of those kinds only while the provider
// reports isolated execution.
ProvidesRuntimeFor []string `json:"providesRuntimeFor,omitempty"`
}
// validate checks the descriptor of tool d. A nil descriptor is valid.
func (e *ExecutionDescriptor) validate(d ToolDefinition) error {
if e == nil {
return nil
}
if e.Class != ExecutionClassCommands || d.Shadows != "bash" {
return fmt.Errorf("tool %s: an execution descriptor has class %q and belongs to a tool that shadows bash", d.Name, ExecutionClassCommands)
}
if e.Title != "" && !plainText(e.Title, MaxExecutionTitleChars) {
return fmt.Errorf("tool %s: invalid execution title", d.Name)
}
if e.Note != "" && !plainText(e.Note, MaxExecutionNoteChars) {
return fmt.Errorf("tool %s: invalid execution note", d.Name)
}
if e.Skills != nil {
for _, k := range e.Skills.ProvidesRuntimeFor {
if k != SkillKindDocuments {
return fmt.Errorf("tool %s: unknown skill kind %q", d.Name, k)
}
}
}
return nil
}
// CommandsTool returns the tool of the definitions that shadows bash.
func CommandsTool(defs []ToolDefinition) (ToolDefinition, bool) {
i := slices.IndexFunc(defs, func(d ToolDefinition) bool { return d.Shadows == "bash" && ValidateShadow(d) == nil })
if i < 0 {
return ToolDefinition{}, false
}
return defs[i], true
}
// IsolatedSkills lists the skills of an add-on that need its own commands
// provider to report isolated execution: the document-slot skills of an
// add-on with a commands tool, unless its descriptor says it does not provide
// the runtime for documents. A commands tool without a descriptor (a manifest
// of API 1) counts as providing it, so such an add-on keeps the check.
func IsolatedSkills(defs []ToolDefinition, skills []SkillDefinition) []string {
tool, ok := CommandsTool(defs)
if !ok {
return nil
}
if e := tool.Execution; e != nil && (e.Skills == nil || !slices.Contains(e.Skills.ProvidesRuntimeFor, SkillKindDocuments)) {
return nil
}
return ShadowedSkills(skills)
}
+77
View File
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: MIT
package contract
import (
"encoding/json"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("The execution descriptor of a commands tool", func() {
const schema = `{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`
manifestWith := func(e *ExecutionDescriptor, api VersionRange) Manifest {
m, err := ParseManifest(fixture())
Expect(err).NotTo(HaveOccurred())
m.API = api
m.Capabilities = Capabilities{
Tools: []string{"run"},
Actions: true,
ToolDefinitions: []ToolDefinition{{Name: "run", Description: "Run a command.", Kind: ToolKindAction,
InputSchema: json.RawMessage(schema), Shadows: "bash", Execution: e}},
}
return m
}
host := func() HostCompatibility {
h := compatible()
h.SDK = 0
h.API, h.APIMin = HostAPIVersion, HostAPIMin
return h
}
commands := &ExecutionDescriptor{Class: ExecutionClassCommands, Title: "Runbox", Note: "It has python3 and jq."}
It("is valid for a tool that shadows bash when the manifest accepts API 2", func() {
Expect(ValidateManifest(manifestWith(commands, VersionRange{Min: 1, Max: 2}), host())).To(Succeed())
})
It("needs the manifest to accept API 2", func() {
Expect(ValidateManifest(manifestWith(commands, VersionRange{Min: 1, Max: 1}), host())).NotTo(Succeed())
})
It("is refused on a tool that does not shadow bash, with another class or with bad text", func() {
m := manifestWith(commands, VersionRange{Min: 1, Max: 2})
m.Capabilities.ToolDefinitions[0].Shadows = ""
Expect(ValidateManifest(m, host())).NotTo(Succeed())
for _, bad := range []*ExecutionDescriptor{
{Class: "pages"},
{Class: ExecutionClassCommands, Title: "<b>Runbox</b>"},
{Class: ExecutionClassCommands, Note: string(make([]rune, MaxExecutionNoteChars+1))},
{Class: ExecutionClassCommands, Skills: &ExecutionSkills{ProvidesRuntimeFor: []string{"games"}}},
} {
Expect(ValidateManifest(manifestWith(bad, VersionRange{Min: 1, Max: 2}), host())).NotTo(Succeed())
}
})
It("keeps a manifest of API 1 valid on a host that speaks API 2, and refuses one that needs API 3", func() {
Expect(ValidateManifest(manifestWith(nil, VersionRange{Min: 1, Max: 1}), host())).To(Succeed())
Expect(ValidateManifest(manifestWith(nil, VersionRange{Min: 3, Max: 3}), host())).NotTo(Succeed())
})
Describe("IsolatedSkills", func() {
skills := []SkillDefinition{{Name: "docx", Shadows: "docx"}, {Name: "runbox-tips"}}
defs := func(e *ExecutionDescriptor) []ToolDefinition {
return manifestWith(e, VersionRange{Min: 1, Max: 2}).Capabilities.ToolDefinitions
}
It("names the document skills of a provider that declares it supplies their runtime", func() {
e := &ExecutionDescriptor{Class: ExecutionClassCommands, Skills: &ExecutionSkills{ProvidesRuntimeFor: []string{SkillKindDocuments}}}
Expect(IsolatedSkills(defs(e), skills)).To(Equal([]string{"docx"}))
})
It("fails closed for a commands tool without a descriptor", func() {
Expect(IsolatedSkills(defs(nil), skills)).To(Equal([]string{"docx"}))
})
It("names nothing for a descriptor that does not supply the runtime of documents", func() {
Expect(IsolatedSkills(defs(commands), skills)).To(BeEmpty())
})
It("names nothing for an add-on without a commands tool", func() {
Expect(IsolatedSkills(nil, skills)).To(BeEmpty())
})
})
})
+26 -3
View File
@@ -58,9 +58,25 @@ func (r VersionRange) accepts(v uint64) bool {
return r.Min > 0 && r.Max >= r.Min && v >= r.Min && v <= r.Max
}
// HostAPIVersion is the manifest API version this host speaks. Version 2 adds
// the execution descriptor of a tool. The host still accepts add-ons that
// declare only version 1 (HostAPIMin), because every change since is optional.
const (
HostAPIVersion uint64 = 2
HostAPIMin uint64 = 1
)
// overlaps reports whether the range holds a version from lo to hi.
func (r VersionRange) overlaps(lo, hi uint64) bool {
return r.Min > 0 && r.Max >= r.Min && r.Min <= hi && r.Max >= lo
}
type HostCompatibility struct {
Host uint64 `json:"host"`
API uint64 `json:"api"`
Host uint64 `json:"host"`
API uint64 `json:"api"`
// APIMin is the oldest manifest API the host still accepts. Zero means API
// only.
APIMin uint64 `json:"apiMin,omitempty"`
Protocol uint64 `json:"protocol"`
SDK uint64 `json:"sdk"`
DataFormat uint64 `json:"dataFormat"`
@@ -347,7 +363,11 @@ func ValidateManifest(m Manifest, h HostCompatibility) error {
if !namespace.MatchString(m.OS) || !namespace.MatchString(m.Arch) || m.OS != h.OS || m.Arch != h.Arch {
return fmt.Errorf("incompatible platform")
}
if !m.Host.accepts(h.Host) || !m.API.accepts(h.API) || !m.Protocol.accepts(h.Protocol) || !m.DataFormat.accepts(h.DataFormat) {
apiMin := h.APIMin
if apiMin == 0 || apiMin > h.API {
apiMin = h.API
}
if !m.Host.accepts(h.Host) || !m.API.overlaps(apiMin, h.API) || !m.Protocol.accepts(h.Protocol) || !m.DataFormat.accepts(h.DataFormat) {
return fmt.Errorf("incompatible host, API, protocol or data format")
}
c := m.Capabilities
@@ -355,6 +375,9 @@ func ValidateManifest(m Manifest, h HostCompatibility) error {
return fmt.Errorf("invalid capabilities")
}
for _, d := range c.ToolDefinitions {
if d.Execution != nil && m.API.Max < MinExecutionAPI {
return fmt.Errorf("tool %s declares an execution descriptor, which needs api %d", d.Name, MinExecutionAPI)
}
if d.Kind == ToolKindAction && !c.Actions {
return fmt.Errorf("tool %s is an action tool but the add-on does not declare actions", d.Name)
}
+7
View File
@@ -73,6 +73,10 @@ type ToolDefinition struct {
// Only a name in ReplaceableTools may be shadowed. A tool without it keeps
// its add-on name (addon__<id>__<tool>).
Shadows string `json:"shadows,omitempty"`
// Execution declares that this tool runs commands and what the host may
// say about the provider. Only a tool that shadows bash may declare it, and
// the manifest must then accept API 2 (see MinExecutionAPI).
Execution *ExecutionDescriptor `json:"execution,omitempty"`
}
// ReplaceableTool says what a tool that shadows a built-in chat tool must look
@@ -222,6 +226,9 @@ func validateToolDefinitions(defs []ToolDefinition, tools []string) error {
if d.UIOnly && !IsResourceless(d) {
return fmt.Errorf("tool %s: a page-only tool must be a read tool without a resource", d.Name)
}
if err := d.Execution.validate(d); err != nil {
return err
}
if d.Shadows != "" {
if err := ValidateShadow(d); err != nil {
return err
+1 -1
View File
@@ -32,7 +32,7 @@ import (
// does not contain them is not installable and must not be published.
func hostFor(platform string) contract.HostCompatibility {
os_, arch, _ := strings.Cut(platform, "/")
return contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: os_, Arch: arch}
return contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, APIMin: contract.HostAPIMin, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: os_, Arch: arch}
}
type fileEntry struct {
+11 -1
View File
@@ -387,7 +387,17 @@
"names every kind and status once",
"tells a settled status from a pending one",
"keeps the wire version and omits empty fields",
"has the limits the owner decided"
"has the limits the owner decided",
"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"
]
},
{
+43 -127
View File
@@ -3708,7 +3708,7 @@ const docTemplate = `{
}
},
"409": {
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -4690,7 +4690,7 @@ const docTemplate = `{
},
"/api/chats/agents/{id}/export": {
"get": {
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's path in your files now (empty when the folder is missing or not chosen); a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"tags": [
"chats"
],
@@ -9579,7 +9579,7 @@ const docTemplate = `{
}
},
"409": {
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -14816,9 +14816,6 @@ const docTemplate = `{
"canStop": {
"type": "boolean"
},
"hostOperations": {
"$ref": "#/definitions/hostops.RequestSummary"
},
"managementUrl": {
"description": "ManagementURL opens the management view of the addon.",
"type": "string",
@@ -15018,32 +15015,6 @@ const docTemplate = `{
}
}
},
"apps.HomeCommandView": {
"type": "object",
"properties": {
"alias": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"page": {
"type": "string"
}
}
},
"apps.List": {
"type": "object",
"properties": {
@@ -15147,20 +15118,6 @@ const docTemplate = `{
"type": "string",
"example": "/api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js"
},
"homeCommands": {
"description": "HomeCommands contains only resolved navigation metadata for this app.",
"type": "array",
"items": {
"$ref": "#/definitions/apps.HomeCommandView"
}
},
"modelActions": {
"description": "ModelActions contains only declared destinations for this installed UI.",
"type": "array",
"items": {
"$ref": "#/definitions/contract.ModelAction"
}
},
"navigation": {
"type": "array",
"items": {
@@ -15523,20 +15480,43 @@ const docTemplate = `{
}
}
},
"contract.ModelAction": {
"contract.ExecutionDescriptor": {
"type": "object",
"properties": {
"capability": {
"class": {
"description": "Class is ExecutionClassCommands.",
"type": "string"
},
"page": {
"note": {
"description": "Note is one plain sentence for the model when the provider reports\nisolated execution, such as the programs its environment has.",
"type": "string"
},
"path": {
"skills": {
"description": "Skills says which skills run on this provider.",
"allOf": [
{
"$ref": "#/definitions/contract.ExecutionSkills"
}
]
},
"title": {
"description": "Title names the provider in the text the host shows the person and the\nmodel, for example \"Sandbox\". It falls back to the add-on's title.",
"type": "string"
}
}
},
"contract.ExecutionSkills": {
"type": "object",
"properties": {
"providesRuntimeFor": {
"description": "ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host\noffers the add-on's skills of those kinds only while the provider\nreports isolated execution.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"contract.OutputDescription": {
"type": "object",
"properties": {
@@ -15671,11 +15651,11 @@ const docTemplate = `{
"type": "object",
"properties": {
"name": {
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nfor a reserved document slot has the slot's name (see Shadows).",
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nthat replaces a built-in one has the built-in's name (see Shadows).",
"type": "string"
},
"shadows": {
"description": "Shadows names the document-provider slot this skill supplies. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"description": "Shadows names the built-in skill this skill replaces. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"type": "string"
}
}
@@ -15687,6 +15667,14 @@ const docTemplate = `{
"description": "Description is plain text for the model.",
"type": "string"
},
"execution": {
"description": "Execution declares that this tool runs commands and what the host may\nsay about the provider. Only a tool that shadows bash may declare it, and\nthe manifest must then accept API 2 (see MinExecutionAPI).",
"allOf": [
{
"$ref": "#/definitions/contract.ExecutionDescriptor"
}
]
},
"inputSchema": {
"description": "InputSchema is the JSON schema of the arguments object. It is an\nobject schema with explicit properties, additionalProperties false at\nthe top level and the keywords the host allows only.",
"type": "object"
@@ -16982,50 +16970,6 @@ const docTemplate = `{
}
}
},
"hostops.RequestSummary": {
"type": "object",
"properties": {
"granted": {
"type": "array",
"items": {
"type": "string"
}
},
"grantedCount": {
"type": "integer"
},
"missingOptional": {
"type": "array",
"items": {
"type": "string"
}
},
"missingRequired": {
"type": "array",
"items": {
"type": "string"
}
},
"optional": {
"type": "array",
"items": {
"type": "string"
}
},
"optionalCount": {
"type": "integer"
},
"required": {
"type": "array",
"items": {
"type": "string"
}
},
"requiredCount": {
"type": "integer"
}
}
},
"library.Info": {
"type": "object",
"properties": {
@@ -18248,9 +18192,6 @@ const docTemplate = `{
"commands_reason": {
"description": "CommandsReason is why commands cannot run, when Commands is unavailable.",
"type": "string"
},
"execution_status": {
"$ref": "#/definitions/schema.ChatsExecutionStatus"
}
}
},
@@ -18586,7 +18527,7 @@ const docTemplate = `{
"type": "string"
},
"path": {
"description": "Path is home-relative (\"/\" is the whole home), or /shared/\u003cname\u003e/...\nfor a followed readable shared folder. It is absent when missing,\nnot chosen, or readable outside these public locations; a readable\nfolder without a public path still has state ok.",
"description": "Path is the folder's path in your files now (\"/\" is all of them). It\nis absent when the folder is missing or not chosen.",
"type": "string"
},
"state": {
@@ -18745,7 +18686,7 @@ const docTemplate = `{
"type": "string"
},
"folder": {
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the home-relative path and mode when the file was\nmade. A missing, unchosen or non-home folder has an empty export path;\nimport preserves its mode as unset and warns instead of clearing scope.",
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the folder's path and mode when the file was made.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsAgentFolderWrite"
@@ -19643,7 +19584,7 @@ const docTemplate = `{
}
},
"applies": {
"description": "Applies is true when the add-on that runs the commands reports that it\nenforces the network policy. It is false when commands run on this\nmachine (applies_reason host), or no Sandbox serves bash or the add-on\ndoes not report enforcement (none).",
"description": "Applies is false when commands run on this machine (applies_reason\nhost) or no Sandbox serves bash (none).",
"type": "boolean"
},
"applies_reason": {
@@ -19955,31 +19896,6 @@ const docTemplate = `{
}
}
},
"schema.ChatsExecutionStatus": {
"type": "object",
"properties": {
"explanation": {
"type": "string"
},
"generation": {
"type": "string"
},
"incarnation": {
"type": "string"
},
"isolation": {
"type": "string"
},
"readiness": {
"type": "string"
},
"unknown_reason": {
"description": "UnknownReason says why the status is unknown: owner_unreachable,\nnot_reported, stale, invalid_status, not_selected, no_answer or\nreported_unknown. Absent when the status is known.",
"type": "string",
"example": "owner_unreachable"
}
}
},
"schema.ChatsExport": {
"type": "object",
"properties": {
+26756
View File
File diff suppressed because it is too large. Load diff
+43 -127
View File
@@ -3705,7 +3705,7 @@
}
},
"409": {
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -4687,7 +4687,7 @@
},
"/api/chats/agents/{id}/export": {
"get": {
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's path in your files now (empty when the folder is missing or not chosen); a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"tags": [
"chats"
],
@@ -9576,7 +9576,7 @@
}
},
"409": {
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -14813,9 +14813,6 @@
"canStop": {
"type": "boolean"
},
"hostOperations": {
"$ref": "#/definitions/hostops.RequestSummary"
},
"managementUrl": {
"description": "ManagementURL opens the management view of the addon.",
"type": "string",
@@ -15015,32 +15012,6 @@
}
}
},
"apps.HomeCommandView": {
"type": "object",
"properties": {
"alias": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"page": {
"type": "string"
}
}
},
"apps.List": {
"type": "object",
"properties": {
@@ -15144,20 +15115,6 @@
"type": "string",
"example": "/api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js"
},
"homeCommands": {
"description": "HomeCommands contains only resolved navigation metadata for this app.",
"type": "array",
"items": {
"$ref": "#/definitions/apps.HomeCommandView"
}
},
"modelActions": {
"description": "ModelActions contains only declared destinations for this installed UI.",
"type": "array",
"items": {
"$ref": "#/definitions/contract.ModelAction"
}
},
"navigation": {
"type": "array",
"items": {
@@ -15520,20 +15477,43 @@
}
}
},
"contract.ModelAction": {
"contract.ExecutionDescriptor": {
"type": "object",
"properties": {
"capability": {
"class": {
"description": "Class is ExecutionClassCommands.",
"type": "string"
},
"page": {
"note": {
"description": "Note is one plain sentence for the model when the provider reports\nisolated execution, such as the programs its environment has.",
"type": "string"
},
"path": {
"skills": {
"description": "Skills says which skills run on this provider.",
"allOf": [
{
"$ref": "#/definitions/contract.ExecutionSkills"
}
]
},
"title": {
"description": "Title names the provider in the text the host shows the person and the\nmodel, for example \"Sandbox\". It falls back to the add-on's title.",
"type": "string"
}
}
},
"contract.ExecutionSkills": {
"type": "object",
"properties": {
"providesRuntimeFor": {
"description": "ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host\noffers the add-on's skills of those kinds only while the provider\nreports isolated execution.",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"contract.OutputDescription": {
"type": "object",
"properties": {
@@ -15668,11 +15648,11 @@
"type": "object",
"properties": {
"name": {
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nfor a reserved document slot has the slot's name (see Shadows).",
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nthat replaces a built-in one has the built-in's name (see Shadows).",
"type": "string"
},
"shadows": {
"description": "Shadows names the document-provider slot this skill supplies. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"description": "Shadows names the built-in skill this skill replaces. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"type": "string"
}
}
@@ -15684,6 +15664,14 @@
"description": "Description is plain text for the model.",
"type": "string"
},
"execution": {
"description": "Execution declares that this tool runs commands and what the host may\nsay about the provider. Only a tool that shadows bash may declare it, and\nthe manifest must then accept API 2 (see MinExecutionAPI).",
"allOf": [
{
"$ref": "#/definitions/contract.ExecutionDescriptor"
}
]
},
"inputSchema": {
"description": "InputSchema is the JSON schema of the arguments object. It is an\nobject schema with explicit properties, additionalProperties false at\nthe top level and the keywords the host allows only.",
"type": "object"
@@ -16979,50 +16967,6 @@
}
}
},
"hostops.RequestSummary": {
"type": "object",
"properties": {
"granted": {
"type": "array",
"items": {
"type": "string"
}
},
"grantedCount": {
"type": "integer"
},
"missingOptional": {
"type": "array",
"items": {
"type": "string"
}
},
"missingRequired": {
"type": "array",
"items": {
"type": "string"
}
},
"optional": {
"type": "array",
"items": {
"type": "string"
}
},
"optionalCount": {
"type": "integer"
},
"required": {
"type": "array",
"items": {
"type": "string"
}
},
"requiredCount": {
"type": "integer"
}
}
},
"library.Info": {
"type": "object",
"properties": {
@@ -18245,9 +18189,6 @@
"commands_reason": {
"description": "CommandsReason is why commands cannot run, when Commands is unavailable.",
"type": "string"
},
"execution_status": {
"$ref": "#/definitions/schema.ChatsExecutionStatus"
}
}
},
@@ -18583,7 +18524,7 @@
"type": "string"
},
"path": {
"description": "Path is home-relative (\"/\" is the whole home), or /shared/\u003cname\u003e/...\nfor a followed readable shared folder. It is absent when missing,\nnot chosen, or readable outside these public locations; a readable\nfolder without a public path still has state ok.",
"description": "Path is the folder's path in your files now (\"/\" is all of them). It\nis absent when the folder is missing or not chosen.",
"type": "string"
},
"state": {
@@ -18742,7 +18683,7 @@
"type": "string"
},
"folder": {
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the home-relative path and mode when the file was\nmade. A missing, unchosen or non-home folder has an empty export path;\nimport preserves its mode as unset and warns instead of clearing scope.",
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the folder's path and mode when the file was made.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsAgentFolderWrite"
@@ -19640,7 +19581,7 @@
}
},
"applies": {
"description": "Applies is true when the add-on that runs the commands reports that it\nenforces the network policy. It is false when commands run on this\nmachine (applies_reason host), or no Sandbox serves bash or the add-on\ndoes not report enforcement (none).",
"description": "Applies is false when commands run on this machine (applies_reason\nhost) or no Sandbox serves bash (none).",
"type": "boolean"
},
"applies_reason": {
@@ -19952,31 +19893,6 @@
}
}
},
"schema.ChatsExecutionStatus": {
"type": "object",
"properties": {
"explanation": {
"type": "string"
},
"generation": {
"type": "string"
},
"incarnation": {
"type": "string"
},
"isolation": {
"type": "string"
},
"readiness": {
"type": "string"
},
"unknown_reason": {
"description": "UnknownReason says why the status is unknown: owner_unreachable,\nnot_reported, stale, invalid_status, not_selected, no_answer or\nreported_unknown. Absent when the status is known.",
"type": "string",
"example": "owner_unreachable"
}
}
},
"schema.ChatsExport": {
"type": "object",
"properties": {
File diff suppressed because it is too large. Load diff
+46 -108
View File
@@ -166,8 +166,6 @@ definitions:
type: boolean
canStop:
type: boolean
hostOperations:
$ref: '#/definitions/hostops.RequestSummary'
managementUrl:
description: ManagementURL opens the management view of the addon.
example: /app/addons?manage=notes
@@ -353,23 +351,6 @@ definitions:
example: summarize
type: string
type: object
apps.HomeCommandView:
properties:
alias:
type: string
icon:
type: string
id:
type: string
label:
type: string
labels:
additionalProperties:
type: string
type: object
page:
type: string
type: object
apps.List:
properties:
apps:
@@ -460,18 +441,6 @@ definitions:
package digest, so a new package is a new URL.
example: /api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js
type: string
homeCommands:
description: HomeCommands contains only resolved navigation metadata for this
app.
items:
$ref: '#/definitions/apps.HomeCommandView'
type: array
modelActions:
description: ModelActions contains only declared destinations for this installed
UI.
items:
$ref: '#/definitions/contract.ModelAction'
type: array
navigation:
items:
type: string
@@ -743,15 +712,37 @@ definitions:
thread:
type: string
type: object
contract.ModelAction:
contract.ExecutionDescriptor:
properties:
capability:
class:
description: Class is ExecutionClassCommands.
type: string
page:
note:
description: |-
Note is one plain sentence for the model when the provider reports
isolated execution, such as the programs its environment has.
type: string
path:
skills:
allOf:
- $ref: '#/definitions/contract.ExecutionSkills'
description: Skills says which skills run on this provider.
title:
description: |-
Title names the provider in the text the host shows the person and the
model, for example "Sandbox". It falls back to the add-on's title.
type: string
type: object
contract.ExecutionSkills:
properties:
providesRuntimeFor:
description: |-
ProvidesRuntimeFor lists skill kinds (SkillKindDocuments). The host
offers the add-on's skills of those kinds only while the provider
reports isolated execution.
items:
type: string
type: array
type: object
contract.OutputDescription:
properties:
handle:
@@ -849,11 +840,11 @@ definitions:
Name is the skill's name and the name of its folder. A plain skill is
named after the add-on: the add-on ID, or the ID and a dash and more
(office-quickstart), so two add-ons never ship one plain name. A skill
for a reserved document slot has the slot's name (see Shadows).
that replaces a built-in one has the built-in's name (see Shadows).
type: string
shadows:
description: |-
Shadows names the document-provider slot this skill supplies. It must equal Name
Shadows names the built-in skill this skill replaces. It must equal Name
and be one of ReplaceableSkills. It repeats the name on purpose: the
replacement is an explicit act, which the install warning and the check
read from the manifest without opening the package. Empty: a plain
@@ -865,6 +856,13 @@ definitions:
description:
description: Description is plain text for the model.
type: string
execution:
allOf:
- $ref: '#/definitions/contract.ExecutionDescriptor'
description: |-
Execution declares that this tool runs commands and what the host may
say about the provider. Only a tool that shadows bash may declare it, and
the manifest must then accept API 2 (see MinExecutionAPI).
inputSchema:
description: |-
InputSchema is the JSON schema of the arguments object. It is an
@@ -1870,35 +1868,6 @@ definitions:
example: 4f1c
type: string
type: object
hostops.RequestSummary:
properties:
granted:
items:
type: string
type: array
grantedCount:
type: integer
missingOptional:
items:
type: string
type: array
missingRequired:
items:
type: string
type: array
optional:
items:
type: string
type: array
optionalCount:
type: integer
required:
items:
type: string
type: array
requiredCount:
type: integer
type: object
library.Info:
properties:
authReference:
@@ -2784,8 +2753,6 @@ definitions:
commands_reason:
description: CommandsReason is why commands cannot run, when Commands is unavailable.
type: string
execution_status:
$ref: '#/definitions/schema.ChatsExecutionStatus'
type: object
schema.ChatsAddonSwitches:
properties:
@@ -3033,10 +3000,8 @@ definitions:
type: string
path:
description: |-
Path is home-relative ("/" is the whole home), or /shared/<name>/...
for a followed readable shared folder. It is absent when missing,
not chosen, or readable outside these public locations; a readable
folder without a public path still has state ok.
Path is the folder's path in your files now ("/" is all of them). It
is absent when the folder is missing or not chosen.
type: string
state:
description: |-
@@ -3163,9 +3128,7 @@ definitions:
description: |-
Folder chooses the one folder the agent's chats work in. A write
without it keeps the folder the agent has; {"path": ""} clears it. In
an export file it is the home-relative path and mode when the file was
made. A missing, unchosen or non-home folder has an empty export path;
import preserves its mode as unset and warns instead of clearing scope.
an export file it is the folder's path and mode when the file was made.
instructions:
type: string
mcp_servers:
@@ -3883,10 +3846,8 @@ definitions:
type: array
applies:
description: |-
Applies is true when the add-on that runs the commands reports that it
enforces the network policy. It is false when commands run on this
machine (applies_reason host), or no Sandbox serves bash or the add-on
does not report enforcement (none).
Applies is false when commands run on this machine (applies_reason
host) or no Sandbox serves bash (none).
type: boolean
applies_reason:
type: string
@@ -4104,26 +4065,6 @@ definitions:
- $ref: '#/definitions/schema.ChatsUsage'
description: 'Usage is set on usage events: what one model request used.'
type: object
schema.ChatsExecutionStatus:
properties:
explanation:
type: string
generation:
type: string
incarnation:
type: string
isolation:
type: string
readiness:
type: string
unknown_reason:
description: |-
UnknownReason says why the status is unknown: owner_unreachable,
not_reported, stale, invalid_status, not_selected, no_answer or
reported_unknown. Absent when the status is known.
example: owner_unreachable
type: string
type: object
schema.ChatsExport:
properties:
created:
@@ -12306,8 +12247,7 @@ paths:
schema:
$ref: '#/definitions/schema.ErrorResponse'
"409":
description: The saved agent's folder is missing or not chosen (agent_folder_missing),
or its complete file access exceeds the folder limit (agent_folder_limit)
description: The saved agent's folder is missing or not chosen (agent_folder_missing)
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Start a chat.
@@ -13445,8 +13385,7 @@ paths:
$ref: '#/definitions/schema.ErrorResponse'
"409":
description: A turn is running for a command, or the saved agent's folder
is missing (agent_folder_missing), or its complete file access exceeds
the folder limit (agent_folder_limit)
is missing (agent_folder_missing)
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Send a message to a chat.
@@ -14309,11 +14248,10 @@ paths:
get:
description: Returns the definition as a JSON file (version, kind localai-agent,
agent). It holds no ID, owner or time. An agent with a folder is version 2
and carries folder {path, mode}, the folder's home-relative path now (empty
when the folder is missing, not chosen, or outside your home, even if readable);
importing an empty path preserves the scope as unset with a warning; a server
that knows only version 1 refuses it. An agent without a folder is version
1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.
and carries folder {path, mode}, the folder's path in your files now (empty
when the folder is missing or not chosen); a server that knows only version
1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import
reads it. This replaces the old /api/agents/{name}/export.
parameters:
- description: the agent's id
in: path
File diff suppressed because it is too large. Load diff