feat(addons): add an execution descriptor to the tool contract

A tool that shadows bash can now declare an execution descriptor: its
class, a title and a note for the model, and the skill kinds whose
runtime it supplies. The host reads it to build its text and to decide
which skills need isolated execution, instead of knowing add-on IDs.

The field is optional and needs manifest API 2. The host now speaks
API 2 and still accepts add-ons that declare only API 1, so manifests
that declare nothing new keep working unchanged.

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

No files matched your search

+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}
+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
}
+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 {