mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
POST and PUT on /api/chats/agents take folders, a list of path and mode rows, up to eight. The old folder field still works and is read as a list with one entry. Responses carry folders, the effective mode of each row and a notice with the count of shared folders a chat does not mount. A folder inside another folder of the same agent takes the mode of the outer one, because the file store gives a node the highest mode among the roots above it and cannot hold a read only folder inside a write folder. The editor data shows that mode. An agent with one folder still exports as version 2. An agent with several exports as version 3 with folders. A server that reads version 2 refuses version 3. Import keeps a folder that is not found as a row that is not chosen. Assisted-by: Claude:claude-sonnet-5-5 go gofmt
449 lines
16 KiB
Go
449 lines
16 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/agents"
|
|
"github.com/mudler/LocalAI/core/services/studio/files"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/sharedfolders"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
)
|
|
|
|
// The access of a chat is resolved from the stores at every turn start: the
|
|
// chat record, its saved agent (or, for a sub-agent, the parent chat's agent)
|
|
// and the agent's folder node. Nothing is kept between turns, so an edit of
|
|
// the agent applies from the next turn on every frontend and chat worker
|
|
// (stateless per request, .agents/distributed-state.md).
|
|
//
|
|
// A chat whose agent has a folder gets a fenced actor: the store refuses
|
|
// every node outside the agent's folder, the chat's own folder, the shared
|
|
// folders and the skill layers, whoever calls it with that actor. A chat
|
|
// whose folder is missing or not chosen does not start
|
|
// (ErrAgentFolderMissing); it never falls back to the whole home.
|
|
|
|
// ErrAgentFolderMissing means the chat's agent has a folder that is deleted,
|
|
// that the user can no longer read, or that is not chosen yet, or that the
|
|
// chat's agent had a folder and is gone. The chat does not start.
|
|
var ErrAgentFolderMissing = errors.New("studio: the folder of this agent is missing")
|
|
|
|
// ErrAgentFolderLimit means the complete scoped access exceeds the fence budget.
|
|
// The caller may retry after the available shared folders change.
|
|
var ErrAgentFolderLimit = errors.New("studio: the file access of this agent exceeds the folder limit")
|
|
|
|
// scopedFolder is one folder of a saved agent as it is now, at the mode the
|
|
// agent may use it: the mode it was given, capped by every folder of the same
|
|
// agent that holds it.
|
|
type scopedFolder struct {
|
|
node int64
|
|
mode workspace.Mode
|
|
path string // workspace path
|
|
}
|
|
|
|
// folderScope is the folders of a saved agent as they are now, in the order
|
|
// the agent lists them. The first is the one the file tools call "home".
|
|
type folderScope struct {
|
|
folders []scopedFolder
|
|
}
|
|
|
|
// primary is the first folder of the scope.
|
|
func (sc *folderScope) primary() scopedFolder { return sc.folders[0] }
|
|
|
|
// extraPaths are the workspace paths of the folders after the first.
|
|
func (sc *folderScope) extraPaths() []string {
|
|
out := make([]string, 0, len(sc.folders)-1)
|
|
for _, f := range sc.folders[1:] {
|
|
out = append(out, f.path)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// roots are the fence roots of the scope.
|
|
func (sc *folderScope) roots() []workspace.FenceRoot {
|
|
out := make([]workspace.FenceRoot, 0, len(sc.folders))
|
|
for _, f := range sc.folders {
|
|
out = append(out, workspace.FenceRoot{Node: f.node, Mode: f.mode})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// userChatActor is the user's actor in a chat, without a fence: the actor of
|
|
// a chat whose agent has no folder, and the base that chatAccess and
|
|
// agentReader fence. A person's own action on a chat (a delete) uses it too.
|
|
// Every other actor of a chat comes from chatAccess.
|
|
func userChatActor(p, conv string) workspace.Actor {
|
|
a := files.UserActor(p)
|
|
a.ChatID = conv
|
|
return a
|
|
}
|
|
|
|
// ChatAccess returns the access of a chat for this turn, for code outside the
|
|
// package (command runners, add-on host operations). The chat of another user
|
|
// is session.ErrNotFound.
|
|
func (s *Service) ChatAccess(ctx context.Context, user, conv string) (session.Access, error) {
|
|
rec, err := s.d.Store.GetChat(ctx, principal(user), conv)
|
|
if err != nil {
|
|
return session.Access{}, err
|
|
}
|
|
return s.chatAccess(ctx, rec)
|
|
}
|
|
|
|
// resolveAccess is the session's Resolve hook: the access of the chat of a.
|
|
func (s *Service) resolveAccess(ctx context.Context, a workspace.Actor) (session.Access, error) {
|
|
return s.ChatAccess(ctx, a.Principal, a.ChatID)
|
|
}
|
|
|
|
// chatAccess is the access of a chat for this turn: its actor, fenced when the
|
|
// chat's agent (or, for a sub-agent, the parent's agent) has a folder, and the
|
|
// view of its folders. It makes the chat's folder before it fences, because a
|
|
// fenced actor cannot make a folder outside its fence. ErrAgentFolderMissing
|
|
// when the folder is missing or not chosen.
|
|
func (s *Service) chatAccess(ctx context.Context, rec session.ChatRecord) (session.Access, error) {
|
|
base := userChatActor(rec.Principal, rec.ID)
|
|
scopes, holder, err := s.chatScopes(ctx, rec, true)
|
|
if err != nil {
|
|
return session.Access{}, err
|
|
}
|
|
if len(scopes) == 0 {
|
|
return s.mgr.UserAccess(ctx, base)
|
|
}
|
|
readOnly, _, err := s.scopedRoots(ctx, base, scopes)
|
|
if err != nil {
|
|
return session.Access{}, err
|
|
}
|
|
owner := base
|
|
owner.ChatID = ""
|
|
chatDir, err := s.mgr.EnsureChatFolder(ctx, owner, holder)
|
|
if err != nil {
|
|
return session.Access{}, fmt.Errorf("studio: make the chat's folder: %w", err)
|
|
}
|
|
actor, err := fenceScopes(base, scopes, chatDir.ID, readOnly)
|
|
if err != nil {
|
|
return session.Access{}, err
|
|
}
|
|
// The innermost folder is the one the chat works in: a sub-agent's own
|
|
// folder inside its parent's. The store caps the mode by every layer.
|
|
// The view is the session's one view builder: the folder at home, the
|
|
// chat folder at chat, the shared folders read only.
|
|
work := scopes[len(scopes)-1]
|
|
x, err := s.mgr.ScopedAccessFolders(ctx, actor, work.primary().path, work.extraPaths(), chatDir.Path)
|
|
if errors.Is(err, session.ErrNoHome) || errors.Is(err, workspace.ErrNotFound) {
|
|
// The folder went away since it was checked, or a sub-agent's folder
|
|
// is outside its parent's.
|
|
return session.Access{}, ErrAgentFolderMissing
|
|
}
|
|
if err != nil {
|
|
return session.Access{}, err
|
|
}
|
|
return x, nil
|
|
}
|
|
|
|
// chatActorFor is the actor of a chat for the code that only lists what a
|
|
// chat could use (the tools panel): the same fence as chatAccess, without
|
|
// making the chat's folder. A chat whose folder is missing gets an actor that
|
|
// reaches nothing.
|
|
func (s *Service) chatActorFor(ctx context.Context, rec session.ChatRecord) workspace.Actor {
|
|
base := userChatActor(rec.Principal, rec.ID)
|
|
scopes, holder, err := s.chatScopes(ctx, rec, false)
|
|
if err != nil {
|
|
return base.Fenced()
|
|
}
|
|
if len(scopes) == 0 {
|
|
return base
|
|
}
|
|
readOnly, _, err := s.scopedRoots(ctx, base, scopes)
|
|
if err != nil {
|
|
return base.Fenced()
|
|
}
|
|
var chatNode int64
|
|
if hrec, herr := s.d.Store.GetChat(ctx, rec.Principal, holder); herr == nil && hrec.Folder != "" {
|
|
owner := base
|
|
owner.ChatID = ""
|
|
if n, serr := s.d.Files.Store().Stat(ctx, owner, session.ChatsPath(rec.Principal)+"/"+hrec.Folder); serr == nil {
|
|
chatNode = n.ID
|
|
}
|
|
}
|
|
actor, err := fenceScopes(base, scopes, chatNode, readOnly)
|
|
if err != nil {
|
|
return base.Fenced()
|
|
}
|
|
return actor
|
|
}
|
|
|
|
// fenceScopes fences base with one layer per scope. Each layer holds the
|
|
// scope's folder at its mode, the chat's folder (when chatNode is not 0) read
|
|
// and write, and the read-only roots; the layers intersect, so a sub-agent
|
|
// whose own agent has a folder reaches only what both folders allow.
|
|
func fenceScopes(base workspace.Actor, scopes []*folderScope, chatNode int64, readOnly []workspace.FenceRoot) (workspace.Actor, error) {
|
|
// Reserve the chat root even before its folder exists. Check every
|
|
// layer before building any actor; roots must never be truncated.
|
|
for _, sc := range scopes {
|
|
if 1+len(sc.folders)+len(readOnly) > workspace.MaxFenceRoots {
|
|
return workspace.Actor{}, ErrAgentFolderLimit
|
|
}
|
|
}
|
|
a := base
|
|
for _, sc := range scopes {
|
|
roots := sc.roots()
|
|
if chatNode > 0 {
|
|
roots = append(roots, workspace.FenceRoot{Node: chatNode, Mode: workspace.ModeWrite})
|
|
}
|
|
roots = append(roots, readOnly...)
|
|
a = a.Fenced(roots...)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// scopedRoots resolves the complete optional read set and validates the budget
|
|
// without making a chat folder. Creation and every turn use the same check.
|
|
// notMounted counts the shared folders left out to fit the budget.
|
|
func (s *Service) scopedRoots(ctx context.Context, base workspace.Actor, scopes []*folderScope) ([]workspace.FenceRoot, int, error) {
|
|
var mandatory []string
|
|
reserve := 0
|
|
for _, sc := range scopes {
|
|
reserve = max(reserve, len(sc.folders))
|
|
for _, f := range sc.folders {
|
|
mandatory = append(mandatory, f.path)
|
|
}
|
|
}
|
|
roots, dropped, err := s.readOnlyRoots(ctx, base, reserve, mandatory)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if _, err := fenceScopes(base, scopes, 0, roots); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return roots, dropped, nil
|
|
}
|
|
|
|
// readOnlyRoots lists the folders a scoped chat may read besides its own: the
|
|
// skill layers and the folders shared with the user, all read only (the
|
|
// shared folders' grants may allow more; a scoped chat never writes there).
|
|
// A folder that is missing or unreadable is left out. Enumeration failures
|
|
// refuse access rather than silently omitting shared folders.
|
|
//
|
|
// A fence layer holds at most workspace.MaxFenceRoots roots: the chat folder,
|
|
// reserve folders of the agent, the skill layers and the rest for shared
|
|
// folders. The skill layers and the agent's folders are always kept. A shared
|
|
// folder inside a root of the layer is left out (the root already reaches it).
|
|
// Of the others the ones with the lowest node IDs are kept until the budget is
|
|
// full, and dropped counts the rest. Dropping a root only removes access.
|
|
// mandatory are the workspace paths of the agent's folders.
|
|
func (s *Service) readOnlyRoots(ctx context.Context, base workspace.Actor, reserve int, mandatory []string) ([]workspace.FenceRoot, int, error) {
|
|
owner := base
|
|
owner.ChatID = ""
|
|
ws := s.d.Files.Store()
|
|
var out []workspace.FenceRoot
|
|
var held []string // paths of the roots already in the layer
|
|
held = append(held, mandatory...)
|
|
stat := func(p string) (workspace.Node, bool) {
|
|
n, err := ws.Stat(ctx, owner, p)
|
|
return n, err == nil && n.Kind == workspace.KindDir
|
|
}
|
|
for _, p := range []string{files.SharedSkillsPath, files.BuiltinSkillsPath, files.AddonSkillsPath} {
|
|
if n, ok := stat(p); ok {
|
|
out = append(out, workspace.FenceRoot{Node: n.ID, Mode: workspace.ModeRead})
|
|
held = append(held, n.Path)
|
|
}
|
|
}
|
|
if s.shared == nil {
|
|
return out, 0, nil
|
|
}
|
|
shared, err := s.shared.Roots(ctx, owner)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("studio: list shared folders for scoped access: %w", err)
|
|
}
|
|
var cand []workspace.Node
|
|
for _, r := range shared {
|
|
if n, ok := stat(r.Path); ok {
|
|
cand = append(cand, n)
|
|
}
|
|
}
|
|
sort.Slice(cand, func(i, j int) bool { return cand[i].ID < cand[j].ID })
|
|
room := workspace.MaxFenceRoots - 1 - reserve - len(out)
|
|
dropped := 0
|
|
for _, n := range cand {
|
|
if insideAny(n.Path, held) {
|
|
continue
|
|
}
|
|
if room <= 0 {
|
|
dropped++
|
|
continue
|
|
}
|
|
room--
|
|
out = append(out, workspace.FenceRoot{Node: n.ID, Mode: workspace.ModeRead})
|
|
held = append(held, n.Path)
|
|
}
|
|
return out, dropped, nil
|
|
}
|
|
|
|
// insideAny says whether p is one of roots or inside one.
|
|
func insideAny(p string, roots []string) bool {
|
|
for _, r := range roots {
|
|
if workspace.Within(p, r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// chatScopes returns the agent folders that apply to a chat, outermost first,
|
|
// and the chat whose folder is the chat's own folder. A chat applies its own
|
|
// agent's folder; a sub-agent applies its parent's agent's folder, and its
|
|
// own agent's folder inside it. No scope means the chat works on the whole
|
|
// home. With record, the chat's ScopedAgent flag is set or cleared to match
|
|
// what its agent has now.
|
|
func (s *Service) chatScopes(ctx context.Context, rec session.ChatRecord, record bool) ([]*folderScope, string, error) {
|
|
holder := rec.ID
|
|
var scopes []*folderScope
|
|
if rec.Kind == session.KindSubAgent && rec.ParentID != "" {
|
|
holder = rec.ParentID
|
|
parent, err := s.d.Store.GetChat(ctx, rec.Principal, rec.ParentID)
|
|
switch {
|
|
case errors.Is(err, session.ErrNotFound):
|
|
// The parent is gone: nothing is known of its folder.
|
|
if rec.ScopedAgent {
|
|
return nil, "", ErrAgentFolderMissing
|
|
}
|
|
holder = rec.ID
|
|
case err != nil:
|
|
return nil, "", err
|
|
default:
|
|
sc, err := s.agentFolderScope(ctx, parent, false)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if sc != nil {
|
|
scopes = append(scopes, sc)
|
|
}
|
|
}
|
|
}
|
|
// A sub-agent's flag follows both folders, not only its own agent's: a
|
|
// child of a scoped chat stays refused, never given the whole home, when
|
|
// its parent's folder can no longer be found.
|
|
sub := rec.Kind == session.KindSubAgent && rec.ParentID != ""
|
|
sc, err := s.agentFolderScope(ctx, rec, record && !sub)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if sc != nil {
|
|
scopes = append(scopes, sc)
|
|
}
|
|
if record && sub {
|
|
if err := s.markScoped(ctx, rec, len(scopes) > 0); err != nil {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
return scopes, holder, nil
|
|
}
|
|
|
|
// markScoped sets the chat's ScopedAgent flag to match what its folders are
|
|
// now. Failing to set it is an error only when the chat is scoped: without the
|
|
// flag a later delete of the agent would give the chat the whole home.
|
|
func (s *Service) markScoped(ctx context.Context, rec session.ChatRecord, scoped bool) error {
|
|
if rec.ScopedAgent == scoped {
|
|
return nil
|
|
}
|
|
err := s.d.Store.SetScopedAgent(ctx, rec.Principal, rec.ID, scoped)
|
|
switch {
|
|
case err != nil && scoped:
|
|
return fmt.Errorf("studio: mark the chat as scoped: %w", err)
|
|
case err != nil:
|
|
s.log.Warn("studio: could not clear the scoped mark of a chat", "chat", rec.ID, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// agentFolderScope returns the folder of the saved agent of rec, nil for a
|
|
// chat without an agent or whose agent has no folder.
|
|
func (s *Service) agentFolderScope(ctx context.Context, rec session.ChatRecord, record bool) (*folderScope, error) {
|
|
if rec.AgentID == "" {
|
|
return nil, nil
|
|
}
|
|
var ag agents.Agent
|
|
err := agents.ErrNotFound
|
|
if s.d.Agents != nil {
|
|
ag, err = s.d.Agents.Get(ctx, rec.Principal, rec.AgentID)
|
|
}
|
|
switch {
|
|
case errors.Is(err, agents.ErrNotFound):
|
|
// A deleted agent leaves no folder to read. A chat that knew its
|
|
// agent had one is refused; one that did not keeps today's rule.
|
|
if rec.ScopedAgent {
|
|
return nil, ErrAgentFolderMissing
|
|
}
|
|
return nil, nil
|
|
case err != nil:
|
|
// Without the agent nothing says whether it has a folder.
|
|
return nil, fmt.Errorf("studio: read the chat's agent: %w", err)
|
|
}
|
|
if record {
|
|
if err := s.markScoped(ctx, rec, ag.HasFolder()); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if !ag.HasFolder() {
|
|
return nil, nil
|
|
}
|
|
return s.liveFolder(ctx, rec.Principal, ag)
|
|
}
|
|
|
|
// liveFolder returns the folders of an agent that has some, or
|
|
// ErrAgentFolderMissing when one is not chosen, deleted or unreadable. The
|
|
// error names the row, so the person knows which folder to fix. The limits of
|
|
// the list are checked again here: a store may hold an older shape.
|
|
func (s *Service) liveFolder(ctx context.Context, p string, ag agents.Agent) (*folderScope, error) {
|
|
list := ag.FolderList()
|
|
if err := agents.ValidateFolders(list); err != nil {
|
|
return nil, err
|
|
}
|
|
sc := &folderScope{}
|
|
for i, f := range list {
|
|
if f.Node <= 0 {
|
|
return nil, fmt.Errorf("%w: folder %d is not chosen", ErrAgentFolderMissing, i+1)
|
|
}
|
|
path, ok := s.FolderResolver().PathOf(ctx, p, f.Node)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: folder %d was deleted or can no longer be read", ErrAgentFolderMissing, i+1)
|
|
}
|
|
mode := workspace.ModeRead
|
|
// A shared folder is read only, whatever the list says.
|
|
if f.Mode == agents.FolderWrite && !isSharedPath(path) {
|
|
mode = workspace.ModeWrite
|
|
}
|
|
sc.folders = append(sc.folders, scopedFolder{node: f.Node, mode: mode, path: path})
|
|
}
|
|
capModes(sc.folders)
|
|
return sc, nil
|
|
}
|
|
|
|
// isSharedPath says whether a workspace path is in the shared folders.
|
|
func isSharedPath(p string) bool { return workspace.Within(p, sharedfolders.Root) }
|
|
|
|
// capModes applies the rule of nested folders: where one folder holds
|
|
// another, the mode of the outer folder is the mode of the inner one. The
|
|
// store's fence gives a node the highest mode among the roots above it, so a
|
|
// read only folder inside a write folder cannot be enforced; the inner folder
|
|
// therefore takes the outer mode, in both directions, and the editor shows it.
|
|
func capModes(fs []scopedFolder) {
|
|
eff := make([]workspace.Mode, len(fs))
|
|
for i, f := range fs {
|
|
eff[i] = f.mode
|
|
outer := -1
|
|
for j, o := range fs {
|
|
if i != j && f.path != o.path && workspace.Within(f.path, o.path) && (outer < 0 || len(o.path) < len(fs[outer].path)) {
|
|
outer = j
|
|
}
|
|
}
|
|
if outer >= 0 {
|
|
eff[i] = fs[outer].mode
|
|
}
|
|
}
|
|
for i := range fs {
|
|
fs[i].mode = eff[i]
|
|
}
|
|
}
|