Files
LocalAI/pkg/addons/contract/contextfiles.go
T
Ettore Di Giacinto 1834d4fd72 refactor(addons): make manifest api 2 the only version
No release ever shipped add-ons, so there is no installed base on api 1.
The manifest must now declare api {"min": 2, "max": 2}; a manifest with
api 1, no api or a wider range is refused with a message that names the
range.

Every field that was gated on api 2 is now part of the contract, so the
Min*API constants and their gates are gone, and so are HostAPIMin and
the apiMin host field. Three older-manifest shims go with them: the
resource listing the host implied for one add-on, the legacyArgs hint
for arguments an older version of a tool took, and the tests that
checked the gates. All shipped manifests and the test fixtures declare
api 2.

Assisted-by: Claude:claude-sonnet-5-5 go test
2026-11-03 16:15:07 +00:00

119 lines
3.9 KiB
Go

// SPDX-License-Identifier: MIT
package contract
import (
"fmt"
"regexp"
"strings"
"unicode"
)
// Limits of the context files an add-on declares.
const (
// MaxContextFiles is the most files one add-on declares: one for each
// scope.
MaxContextFiles = 3
// MaxContextFileBytes is the most bytes of one index the prompt carries.
// It is the cap of the host; a smaller Cap lowers it.
MaxContextFileBytes = 2 << 10
// ContextFilesRoot is the folder of the user's home that every declared
// file lies in. An add-on cannot name any other part of the home.
ContextFilesRoot = "Memory/"
// maxContextPath bounds the path of a declared file.
maxContextPath = 200
)
// ContextFile names one small index file that the host loads into the prompt
// of a chat, under a fixed header that asks the model to read the notes the
// index points to. The add-on declares where its files are; the host reads
// them as the user, caps them and builds the prompt. The add-on is not asked
// at each turn and holds no authority over the files: it names a path under
// ContextFilesRoot and nothing else.
//
// A user file applies to every chat of the person. An agent file holds the
// placeholder {agent} once and applies to the chats of one saved agent. A
// channel file holds {channel} once and applies to the chats that one
// connector conversation started. The host fills a placeholder from the chat,
// never from the model.
type ContextFile struct {
Scope ChangeScope `json:"scope"`
// Path is relative to the user's home, under ContextFilesRoot, and ends
// in .md.
Path string `json:"path"`
// Cap is the most bytes of the index the prompt carries. Zero means
// MaxContextFileBytes.
Cap int `json:"cap,omitempty"`
}
var contextPlaceholder = regexp.MustCompile(`\{[^{}]*\}`)
// placeholderOf is the one placeholder a scope's path holds, or "".
func placeholderOf(s ChangeScope) string {
switch s {
case ScopeAgent:
return "{agent}"
case ScopeChannel:
return "{channel}"
}
return ""
}
func (f ContextFile) validate() error {
bad := func(format string, a ...any) error {
return fmt.Errorf("context file %q: %s", f.Path, fmt.Sprintf(format, a...))
}
if !ValidChangeScope(string(f.Scope)) {
return bad("the scope must be user, agent or channel")
}
if f.Cap < 0 || f.Cap > MaxContextFileBytes {
return bad("the cap must be 0 to %d bytes", MaxContextFileBytes)
}
p := f.Path
if len(p) > maxContextPath || !strings.HasPrefix(p, ContextFilesRoot) || !strings.HasSuffix(p, ".md") {
return bad("the path must lie under %s, end in .md and have at most %d bytes", ContextFilesRoot, maxContextPath)
}
found := contextPlaceholder.FindAllString(p, -1)
if want := placeholderOf(f.Scope); (want == "" && len(found) > 0) || (want != "" && (len(found) != 1 || found[0] != want)) {
return bad("a %s path holds %s once and no other placeholder", f.Scope, placeholderOrNone(want))
}
rest := contextPlaceholder.ReplaceAllString(p, "")
if strings.ContainsAny(rest, `{}\`) || strings.IndexFunc(rest, unicode.IsControl) >= 0 {
return bad("the path holds a character that is not allowed")
}
for seg := range strings.SplitSeq(p, "/") {
if seg == "" || seg == "." || seg == ".." {
return bad("the path holds an empty or parent segment")
}
}
return nil
}
func placeholderOrNone(p string) string {
if p == "" {
return "no placeholder"
}
return p
}
// validateContextFiles checks the declaration against the manifest.
func validateContextFiles(m Manifest) error {
files := m.Capabilities.ContextFiles
if len(files) == 0 {
return nil
}
if len(files) > MaxContextFiles {
return fmt.Errorf("too many context files: at most %d", MaxContextFiles)
}
seen := map[ChangeScope]bool{}
for _, f := range files {
if err := f.validate(); err != nil {
return err
}
if seen[f.Scope] {
return fmt.Errorf("context files: a second file for the %s scope", f.Scope)
}
seen[f.Scope] = true
}
return nil
}