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:
Ettore Di Giacinto committed 2026-11-03 14:02:02 +00:00
1 parent 79109058d0
commit 367dbe76d8
5 files changed
+135 -8

No files matched your search

+11 -4
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+60 -3
View File
@@ -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"))
})
})
+4
View File
@@ -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.