mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(studio): mount the extra folders of an agent in a chat view
A view can now mount further folders next to the first one, at folder-<name> with a numeric suffix when two names collide. The store still caps each mount by the fence. A fenced view counts the shared folders it leaves out, and the prompt says how many are not mounted. Assisted-by: Claude:claude-sonnet-5-5 go gofmt
This commit is contained in:
5 files changed
+135
-8
No files matched your search
@@ -57,7 +57,7 @@ func sameView(a, b workspace.View) bool {
|
||||
// is the one place a chat's view is built: UserAccess (the home) and
|
||||
// ScopedAccess (the agent's folder and the chat folder) both call it, so an
|
||||
// unscoped and a scoped chat get the same layout rules.
|
||||
func (m *Manager) buildView(ctx context.Context, a workspace.Actor, primary, chat string) (workspace.View, error) {
|
||||
func (m *Manager) buildView(ctx context.Context, a workspace.Actor, primary string, extra []string, chat string) (workspace.View, error) {
|
||||
if m.o.Workspace == nil {
|
||||
return workspace.View{Actor: a}, nil
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func (m *Manager) buildView(ctx context.Context, a workspace.Actor, primary, cha
|
||||
return workspace.View{}, fmt.Errorf("session: list shared folders: %w", err)
|
||||
}
|
||||
}
|
||||
return BuildView(ctx, m.o.Workspace, a, primary, chat, shared, m.log)
|
||||
return BuildViewFolders(ctx, m.o.Workspace, a, primary, extra, chat, shared, m.log)
|
||||
}
|
||||
|
||||
// UserAccess is the access of a chat that works on the whole home: the
|
||||
@@ -78,7 +78,7 @@ func (m *Manager) UserAccess(ctx context.Context, a workspace.Actor) (Access, er
|
||||
if _, fenced := a.Fence(); fenced {
|
||||
return Access{}, fmt.Errorf("%w: a fenced actor has no view of the home", ErrInvalid)
|
||||
}
|
||||
v, err := m.buildView(ctx, a, HomePath(a.Principal), "")
|
||||
v, err := m.buildView(ctx, a, HomePath(a.Principal), nil, "")
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
@@ -93,6 +93,13 @@ func (m *Manager) UserAccess(ctx context.Context, a workspace.Actor) (Access, er
|
||||
// primary) or an error that wraps workspace.ErrNotFound (the chat folder),
|
||||
// never a view of the home.
|
||||
func (m *Manager) ScopedAccess(ctx context.Context, a workspace.Actor, primary, chat string) (Access, error) {
|
||||
return m.ScopedAccessFolders(ctx, a, primary, nil, chat)
|
||||
}
|
||||
|
||||
// ScopedAccessFolders is ScopedAccess for an agent with several folders:
|
||||
// primary is the first one, at "home", and extra are the others, at
|
||||
// "folder-<name>" (see FolderMountNames).
|
||||
func (m *Manager) ScopedAccessFolders(ctx context.Context, a workspace.Actor, primary string, extra []string, chat string) (Access, error) {
|
||||
if !workspace.HasFence(a) {
|
||||
return Access{}, fmt.Errorf("%w: a scoped access needs a fenced actor", ErrInvalid)
|
||||
}
|
||||
@@ -102,7 +109,7 @@ func (m *Manager) ScopedAccess(ctx context.Context, a workspace.Actor, primary,
|
||||
if m.o.Workspace == nil {
|
||||
return Access{}, ErrNoWorkspace
|
||||
}
|
||||
v, err := m.buildView(ctx, a, primary, chat)
|
||||
v, err := m.buildView(ctx, a, primary, extra, chat)
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (m *Manager) PersonAccess(ctx context.Context, a workspace.Actor) (Access,
|
||||
case workspace.HasFence(a):
|
||||
return Access{}, fmt.Errorf("%w: a fenced actor has no view of the home", ErrInvalid)
|
||||
}
|
||||
v, err := m.buildView(ctx, a, HomePath(a.Principal), "")
|
||||
v, err := m.buildView(ctx, a, HomePath(a.Principal), nil, "")
|
||||
if err != nil {
|
||||
return Access{}, err
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ const (
|
||||
// GuestSharedPrefix starts the mount name of a shared folder:
|
||||
// /workspace/shared-<name>. A mount name is one path segment.
|
||||
GuestSharedPrefix = "shared-"
|
||||
// GuestFolderPrefix starts the mount name of an agent's folder after the
|
||||
// first: /workspace/folder-<name>. The first folder is "home".
|
||||
GuestFolderPrefix = "folder-"
|
||||
// DefaultTitle is the title of a new chat.
|
||||
DefaultTitle = "New chat"
|
||||
// MaxTitleRunes is the longest title of a chat, in characters.
|
||||
@@ -188,6 +191,43 @@ type SharedRoot struct {
|
||||
// folder that cannot be mounted is an error too, since the resolver makes it
|
||||
// before it fences.
|
||||
func BuildView(ctx context.Context, st workspace.Store, a workspace.Actor, primary string, chat string, shared []SharedRoot, log *slog.Logger) (workspace.View, error) {
|
||||
return BuildViewFolders(ctx, st, a, primary, nil, chat, shared, log)
|
||||
}
|
||||
|
||||
// reservedMountNames are the names no folder may take: the home, the chat
|
||||
// folder and the skill layers.
|
||||
func reservedMountNames() map[string]bool {
|
||||
used := map[string]bool{workspace.FoldName(GuestHome): true, workspace.FoldName(GuestChat): true}
|
||||
for _, l := range SkillLayers {
|
||||
used[workspace.FoldName(l.At)] = true
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// FolderMountNames returns the mount name of each further folder of an agent,
|
||||
// in order: "folder-<name>", with " 2", " 3" and so on when two folders have
|
||||
// the same name. The same call gives the same names, so the code that builds
|
||||
// the view and the code that tells the model about it agree.
|
||||
func FolderMountNames(paths []string) []string {
|
||||
used := reservedMountNames()
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
base := strings.TrimRight(fitBytes(GuestFolderPrefix+path.Base(p), maxNameBytes-len(" 999")), " ")
|
||||
at, err := uniqueName(base, func(n string) (bool, error) { return used[workspace.FoldName(n)], nil })
|
||||
if err != nil {
|
||||
at = fmt.Sprintf("%s%d", GuestFolderPrefix, len(out)+1)
|
||||
}
|
||||
used[workspace.FoldName(at)] = true
|
||||
out = append(out, at)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildViewFolders is BuildView for an agent with several folders: primary is
|
||||
// the first and extra are the others, mounted at "folder-<name>". The store
|
||||
// caps each mount at the fence's mode. An extra folder the store refuses is
|
||||
// skipped and logged.
|
||||
func BuildViewFolders(ctx context.Context, st workspace.Store, a workspace.Actor, primary string, extra []string, chat string, shared []SharedRoot, log *slog.Logger) (workspace.View, error) {
|
||||
home := HomePath(a.Principal)
|
||||
reqs := []workspace.MountRequest{{Path: primary, At: GuestHome, Mode: workspace.ModeWrite}}
|
||||
v, err := st.View(ctx, a, reqs)
|
||||
@@ -208,9 +248,18 @@ func BuildView(ctx context.Context, st workspace.Store, a workspace.Actor, prima
|
||||
// The names of the skill layers and of the chat folder are reserved: the
|
||||
// file tools read them there, so a shared folder named skills gets
|
||||
// "shared-skills 2".
|
||||
used := map[string]bool{workspace.FoldName(GuestHome): true, workspace.FoldName(GuestChat): true}
|
||||
for _, l := range SkillLayers {
|
||||
used[workspace.FoldName(l.At)] = true
|
||||
used := reservedMountNames()
|
||||
for i, at := range FolderMountNames(extra) {
|
||||
ev, err := st.View(ctx, a, []workspace.MountRequest{{Path: extra[i], At: at, Mode: workspace.ModeWrite}})
|
||||
if err != nil {
|
||||
if missingFolder(err) {
|
||||
log.Warn("session: skipping a folder of the agent", "path", extra[i], "error", err)
|
||||
continue
|
||||
}
|
||||
return workspace.View{}, err
|
||||
}
|
||||
used[workspace.FoldName(at)] = true
|
||||
v.Mounts = append(v.Mounts, ev.Mounts...)
|
||||
}
|
||||
for _, s := range shared {
|
||||
p := path.Clean("/" + strings.Trim(s.Path, "/"))
|
||||
@@ -236,6 +285,9 @@ func BuildView(ctx context.Context, st workspace.Store, a workspace.Actor, prima
|
||||
sv, err := st.View(ctx, a, []workspace.MountRequest{{Path: p, At: at, Mode: mode}})
|
||||
if err != nil {
|
||||
if missingFolder(err) {
|
||||
if fenced {
|
||||
v.NotMounted++
|
||||
}
|
||||
log.Warn("session: skipping a shared folder", "path", p, "error", err)
|
||||
continue
|
||||
}
|
||||
@@ -312,12 +364,17 @@ func scopedLayoutPrompt(v workspace.View) string {
|
||||
fmt.Fprintf(&b, "- %s/%s: the user's home folder (%s).\n", GuestRoot, m.At, access)
|
||||
case m.At == GuestHome:
|
||||
fmt.Fprintf(&b, "- %s/%s: the folder %q of the user (%s). You cannot see the user's other files.\n", GuestRoot, m.At, path.Base(m.Path), access)
|
||||
case strings.HasPrefix(m.At, GuestFolderPrefix):
|
||||
fmt.Fprintf(&b, "- %s/%s: the folder %q of the user (%s). You cannot see the user's other files.\n", GuestRoot, m.At, path.Base(m.Path), access)
|
||||
case m.At == GuestChat:
|
||||
fmt.Fprintf(&b, "- %s/%s: this chat's own folder (%s). Files the user attaches are here; save working files in %s/%s/%s/.\n", GuestRoot, m.At, access, GuestRoot, m.At, workfiles.Dir)
|
||||
default:
|
||||
fmt.Fprintf(&b, "- %s/%s: a folder shared with the user (%s).\n", GuestRoot, m.At, access)
|
||||
}
|
||||
}
|
||||
if v.NotMounted > 0 {
|
||||
fmt.Fprintf(&b, "%d shared folders are not mounted: they did not fit the access limit of this chat.\n", v.NotMounted)
|
||||
}
|
||||
b.WriteString("There is nothing else under " + GuestRoot + ". Every change is saved, and the user can undo it.")
|
||||
return b.String()
|
||||
}
|
||||
@@ -183,3 +183,62 @@ var _ = Describe("the layout of a chat's view", func() {
|
||||
Expect(strings.Count(p, "\n- ")).To(Equal(3))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("the view of an agent with several folders", func() {
|
||||
It("mounts the first at home and the others by name, each capped by the fence", func() {
|
||||
r := newLayoutRig()
|
||||
other := session.HomePath("alice") + "/Other"
|
||||
on, err := r.ws.Stat(context.Background(), r.admin, other)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
a := workspace.Actor{Principal: "alice", ChatID: "c1"}.Fenced(
|
||||
workspace.FenceRoot{Node: r.acme.ID, Mode: workspace.ModeWrite},
|
||||
workspace.FenceRoot{Node: on.ID, Mode: workspace.ModeRead},
|
||||
workspace.FenceRoot{Node: r.chat.ID, Mode: workspace.ModeWrite},
|
||||
)
|
||||
v, err := session.BuildViewFolders(context.Background(), r.ws, a, r.acme.Path, []string{other}, r.chat.Path, nil, quiet)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
home, ok := mountAt(v, session.GuestHome)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(home.Path).To(Equal(r.acme.Path))
|
||||
Expect(home.Mode).To(Equal(workspace.ModeWrite))
|
||||
o, ok := mountAt(v, "folder-Other")
|
||||
Expect(ok).To(BeTrue(), "%+v", v.Mounts)
|
||||
Expect(o.Mode).To(Equal(workspace.ModeRead), "the fence caps the mount")
|
||||
Expect(session.LayoutPrompt(v, session.PromptScope(v))).To(ContainSubstring("/workspace/folder-Other"))
|
||||
})
|
||||
|
||||
It("skips a folder outside the fence instead of widening it", func() {
|
||||
r := newLayoutRig()
|
||||
other := session.HomePath("alice") + "/Other"
|
||||
v, err := session.BuildViewFolders(context.Background(), r.ws, r.scoped(workspace.ModeWrite), r.acme.Path, []string{other}, r.chat.Path, nil, quiet)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, ok := mountAt(v, "folder-Other")
|
||||
Expect(ok).To(BeFalse(), "the folder is not a root of the fence")
|
||||
})
|
||||
|
||||
It("names two folders with the same name apart and never takes a reserved name", func() {
|
||||
names := session.FolderMountNames([]string{"/users/a/X/Docs", "/users/a/Y/Docs", "/users/a/Z/Docs"})
|
||||
Expect(names).To(Equal([]string{"folder-Docs", "folder-Docs 2", "folder-Docs 3"}))
|
||||
})
|
||||
|
||||
It("refuses a path that climbs out of a mounted folder", func() {
|
||||
r := newLayoutRig()
|
||||
other := session.HomePath("alice") + "/Other"
|
||||
on, _ := r.ws.Stat(context.Background(), r.admin, other)
|
||||
a := workspace.Actor{Principal: "alice", ChatID: "c1"}.Fenced(
|
||||
workspace.FenceRoot{Node: r.acme.ID, Mode: workspace.ModeWrite},
|
||||
workspace.FenceRoot{Node: on.ID, Mode: workspace.ModeRead},
|
||||
workspace.FenceRoot{Node: r.chat.ID, Mode: workspace.ModeWrite})
|
||||
v, err := session.BuildViewFolders(context.Background(), r.ws, a, r.acme.Path, []string{other}, r.chat.Path, nil, quiet)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
pl := session.NewPlace(v)
|
||||
for _, p := range []string{"/workspace/folder-Other/../home/x", "/workspace/folder-Other/../../alice/secret", "folder-Other/.."} {
|
||||
_, err := pl.Resolve(p)
|
||||
Expect(err).To(HaveOccurred(), p)
|
||||
}
|
||||
t, err := pl.Resolve("/workspace/folder-Other/notes.md")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(t.Write).To(BeFalse())
|
||||
Expect(t.Abs).To(Equal(other + "/notes.md"))
|
||||
})
|
||||
})
|
||||
@@ -292,6 +292,10 @@ type Mount struct {
|
||||
type View struct {
|
||||
Actor Actor
|
||||
Mounts []Mount
|
||||
// NotMounted counts the shared folders a fenced view leaves out because
|
||||
// the fence does not reach them (the budget of a fence layer is limited).
|
||||
// It is for the prompt and the editor; it grants nothing.
|
||||
NotMounted int
|
||||
}
|
||||
|
||||
// HistoryQuery selects change sets for History.
|
||||
|
||||
Reference in new issue
Block a user