diff --git a/core/application/addon_gateway.go b/core/application/addon_gateway.go index f782b2e156..fe96a83399 100644 --- a/core/application/addon_gateway.go +++ b/core/application/addon_gateway.go @@ -602,6 +602,14 @@ func (l lateStudioAddons) AddonContextFiles(ctx context.Context, user string) ([ return nil, nil } +// AddonListings forwards to the broker when it knows what the add-ons declare. +func (l lateStudioAddons) AddonListings(ctx context.Context, user string) ([]studio.AddonListing, error) { + if g, ok := l.a.StudioAddonTools().(studio.AddonLister); ok { + return g.AddonListings(ctx, user) + } + return nil, nil +} + // Isolation forwards to the broker when it can say how commands are isolated. func (l lateStudioAddons) Isolation(ctx context.Context, user, addonID string) string { if g, ok := l.a.StudioAddonTools().(studio.CommandIsolation); ok { @@ -842,6 +850,27 @@ func (s *studioAddons) AddonContextFiles(ctx context.Context, user string) ([]st return out, nil } +// AddonListings gives the title and description each add-on declares, for the +// lists of what a chat can switch. +func (s *studioAddons) AddonListings(ctx context.Context, user string) ([]studio.AddonListing, error) { + actor, err := s.actor(user) + if err != nil { + return nil, nil + } + list, err := s.adapter.Listings(ctx, actor) + if errors.Is(err, gateway.ErrForbidden) { + return nil, nil + } + if err != nil { + return nil, err + } + out := make([]studio.AddonListing, 0, len(list)) + for _, a := range list { + out = append(out, studio.AddonListing{AddonID: a.AddonID, Title: a.Title, Description: a.Description}) + } + return out, nil +} + // AddonShadowers lists the add-ons that replace a built-in chat tool and that // the user can see, in any state, less those whose host operations need a // feature the user lacks: the same rule as their tools. diff --git a/core/http/endpoints/studio/addons.go b/core/http/endpoints/studio/addons.go index e1c4e19b3f..b085c57f24 100644 --- a/core/http/endpoints/studio/addons.go +++ b/core/http/endpoints/studio/addons.go @@ -28,7 +28,7 @@ func writeAddonList(c echo.Context, list []studiosvc.AddonInfo, cmds studiosvc.C out.ExecutionStatus = &schema.ChatsExecutionStatus{Readiness: v.Readiness, Isolation: v.Isolation, Explanation: v.Explanation, UnknownReason: v.UnknownReason, Generation: v.Generation, Incarnation: v.Incarnation} } for _, a := range list { - out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off}) + out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off, Title: a.Title, Description: a.Description}) } return c.JSON(http.StatusOK, out) } diff --git a/core/http/endpoints/studio/agents.go b/core/http/endpoints/studio/agents.go index 7d5bec5b7f..8c6958d302 100644 --- a/core/http/endpoints/studio/agents.go +++ b/core/http/endpoints/studio/agents.go @@ -77,7 +77,7 @@ func agentOut(a agents.Agent) schema.ChatsAgent { } return schema.ChatsAgent{ID: a.ID, Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model, MCPServers: nz(a.MCPServers), Addons: nz(a.Addons), Collections: nz(a.Collections), Skills: nz(a.Skills), - Network: networkOf(a.Network), Created: a.Created, Updated: a.Updated} + Network: networkOf(a.Network), BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Created: a.Created, Updated: a.Updated} } // agentIn reads the body. A model the user may not use is refused here, so a @@ -100,7 +100,8 @@ func (h *Handlers) agentIn(c echo.Context) (a agents.Agent, folders *[]studiosvc } } return agents.Agent{Name: req.Name, Description: req.Description, Instructions: req.Instructions, Model: req.Model, - MCPServers: req.MCPServers, Addons: req.Addons, Collections: req.Collections, Skills: req.Skills, Network: req.Network}, + MCPServers: req.MCPServers, Addons: req.Addons, Collections: req.Collections, Skills: req.Skills, Network: req.Network, + BuiltinsOff: req.BuiltinsOff, BuiltinsOn: req.BuiltinsOn}, foldersIn(req), true, nil } diff --git a/core/http/endpoints/studio/agentsfile.go b/core/http/endpoints/studio/agentsfile.go index 71a495634c..6e024ba51c 100644 --- a/core/http/endpoints/studio/agentsfile.go +++ b/core/http/endpoints/studio/agentsfile.go @@ -24,14 +24,19 @@ const ( // reads. Version 2 is an agent with a folder, version 3 an agent with // several: each got its own version so that an older server refuses the // file instead of importing the agent with more access than it had. - maxAgentFileVersion = 3 + // Version 4 is an agent with switches of built-in tool groups, for the + // same reason: an older server would drop them and give the agent every + // tool. + maxAgentFileVersion = 4 ) // agentFileVersion is the version an export writes: 1 for an agent without a -// folder, so older servers keep importing it, 2 for one with one folder and 3 -// for one with several. -func agentFileVersion(folders int) int { +// folder or tool switches, so older servers keep importing it, 2 for one with +// one folder, 3 for one with several, and 4 for one with tool switches. +func agentFileVersion(folders int, hasSwitches bool) int { switch { + case hasSwitches: + return 4 case folders > 1: return 3 case folders == 1: @@ -42,7 +47,7 @@ func agentFileVersion(folders int) int { // ExportAgent returns a saved agent as a file. // @Summary Export a saved agent. -// @Description Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export. +// @Description Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export. // @Tags chats // @Param id path string true "the agent's id" // @Success 200 {object} schema.ChatsAgentExport "The agent file" @@ -53,7 +58,8 @@ func (h *Handlers) ExportAgent(c echo.Context) error { return failFor(c, err) } w := schema.ChatsAgentWrite{Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model, - MCPServers: a.MCPServers, Addons: a.Addons, Collections: a.Collections, Skills: a.Skills, Network: a.Network} + MCPServers: a.MCPServers, Addons: a.Addons, Collections: a.Collections, Skills: a.Skills, Network: a.Network, + BuiltinsOff: a.BuiltinsOff, BuiltinsOn: a.BuiltinsOn} rows := h.svc.AgentFolderRows(c.Request().Context(), userID(c), []agents.Agent{a})[a.ID] for i, f := range a.FolderList() { // Imports resolve paths in the home only. A followed root outside @@ -73,7 +79,7 @@ func (h *Handlers) ExportAgent(c echo.Context) error { w.Folder, w.Folders = &w.Folders[0], nil } c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%q", fileSafe(a.Name)+".json")) - return c.JSON(http.StatusOK, schema.ChatsAgentExport{Version: agentFileVersion(len(a.FolderList())), Kind: agentExportKind, ExportedAt: time.Now().UTC(), Agent: w}) + return c.JSON(http.StatusOK, schema.ChatsAgentExport{Version: agentFileVersion(len(a.FolderList()), len(a.BuiltinsOff)+len(a.BuiltinsOn) > 0), Kind: agentExportKind, ExportedAt: time.Now().UTC(), Agent: w}) } // fileSafe keeps a name usable as a file name in a header. @@ -141,7 +147,8 @@ func (h *Handlers) ImportAgent(c echo.Context) error { } } a := agents.Agent{Name: def.Name, Description: def.Description, Instructions: def.Instructions, Model: def.Model, - MCPServers: def.MCPServers, Addons: def.Addons, Collections: def.Collections, Skills: def.Skills, Network: def.Network} + MCPServers: def.MCPServers, Addons: def.Addons, Collections: def.Collections, Skills: def.Skills, Network: def.Network, + BuiltinsOff: def.BuiltinsOff, BuiltinsOn: def.BuiltinsOn} var rowsIn []studiosvc.AgentFolderWrite switch { case def.Folders != nil: diff --git a/core/http/endpoints/studio/agentsfile_test.go b/core/http/endpoints/studio/agentsfile_test.go index 7c0ef661f1..22ebc07e07 100644 --- a/core/http/endpoints/studio/agentsfile_test.go +++ b/core/http/endpoints/studio/agentsfile_test.go @@ -35,7 +35,7 @@ var _ = Describe("agent files", func() { It("refuses a version it does not know", func() { for _, raw := range []string{ - `{"version":4,"kind":"localai-agent","agent":{"name":"x"}}`, + `{"version":5,"kind":"localai-agent","agent":{"name":"x"}}`, `{"version":0,"kind":"localai-agent","agent":{"name":"x"}}`, } { _, err := readAgentFile([]byte(raw)) @@ -44,9 +44,21 @@ var _ = Describe("agent files", func() { }) It("exports version 2 for one folder and version 3 for several", func() { - Expect(agentFileVersion(0)).To(Equal(1)) - Expect(agentFileVersion(1)).To(Equal(2)) - Expect(agentFileVersion(2)).To(Equal(3)) + Expect(agentFileVersion(0, false)).To(Equal(1)) + Expect(agentFileVersion(1, false)).To(Equal(2)) + Expect(agentFileVersion(2, false)).To(Equal(3)) + Expect(agentFileVersion(0, true)).To(Equal(4)) + Expect(agentFileVersion(2, true)).To(Equal(4)) + }) + + It("reads the tool switches of a version 4 file, which an older importer refuses", func() { + raw := []byte(`{"version":4,"kind":"localai-agent","agent":{"name":"Careful","builtins_off":["triggers","web"],"builtins_on":["saved_agents"]}}`) + _, err := readAgentFileUpTo(raw, 3) + Expect(err).To(MatchError(ContainSubstring("version"))) + def, err := readAgentFile(raw) + Expect(err).NotTo(HaveOccurred()) + Expect(def.BuiltinsOff).To(Equal([]string{"triggers", "web"})) + Expect(def.BuiltinsOn).To(Equal([]string{"saved_agents"})) }) It("reads the folders of a version 3 file, and a version 2 reader refuses it", func() { diff --git a/core/http/endpoints/studio/mcpconnectors.go b/core/http/endpoints/studio/mcpconnectors.go index b0865f8b00..420a4730bd 100644 --- a/core/http/endpoints/studio/mcpconnectors.go +++ b/core/http/endpoints/studio/mcpconnectors.go @@ -214,10 +214,11 @@ func writeToolsPanel(c echo.Context, p studiosvc.ToolsPanel) error { Connectors: make([]schema.ChatsMCPConnectorSwitch, 0, len(p.Connectors)), Off: p.Off} for _, g := range p.Builtins { out.Builtins = append(out.Builtins, schema.ChatsBuiltinGroup{Group: g.Group, Tools: g.Tools, Off: g.Off, OptIn: g.OptIn, - Usable: g.Usable, Reason: g.Reason, Provider: g.Provider, Providers: g.Providers}) + Usable: g.Usable, Reason: g.Reason, Provider: g.Provider, Providers: g.Providers, + Title: g.Title, Description: g.Description, WhenOff: g.WhenOff}) } for _, a := range p.Addons { - out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off}) + out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off, Title: a.Title, Description: a.Description}) } for _, f := range p.Fallbacks { out.Fallbacks = append(out.Fallbacks, schema.ChatsToolFallback{Tool: f.Tool, Addon: f.AddonID, AddonTitle: f.Title, Builtin: f.Builtin}) diff --git a/core/schema/chats.go b/core/schema/chats.go index ae28294eaa..85dfb368b2 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -52,6 +52,12 @@ type ChatsAgent struct { // unattended runs: off (the default), registries or all, capped by the // administrator's ceiling. Network string `json:"network"` + // BuiltinsOff are the groups of built-in tools (web, files, triggers...) + // the agent's chats never have, and BuiltinsOn the opt-in groups (saved + // agents) they start with. The names are the ones GET /api/chats/{id}/tools + // lists. Empty for an agent that has every group a chat has by default. + BuiltinsOff []string `json:"builtins_off"` + BuiltinsOn []string `json:"builtins_on"` // Folder is the one folder that chats with the agent work in. It is // absent for an agent that works on all your files. Folder *ChatsAgentFolder `json:"folder,omitempty"` @@ -117,6 +123,11 @@ type ChatsAgentWrite struct { Collections []string `json:"collections,omitempty"` Skills []string `json:"skills,omitempty"` Network string `json:"network,omitempty"` + // BuiltinsOff and BuiltinsOn are the switches of the built-in tools of + // the agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen + // the agent); an empty list clears them. + BuiltinsOff []string `json:"builtins_off,omitempty"` + BuiltinsOn []string `json:"builtins_on,omitempty"` // Folder chooses the one folder the agent's chats work in. A write // without it keeps the folder the agent has; {"path": ""} clears it. In // an export file it is the home-relative path and mode when the file was @@ -819,6 +830,10 @@ type ChatsAddon struct { ID string `json:"id"` Tools int `json:"tools"` Off bool `json:"off"` + // Title and Description are what the add-on's manifest declares for + // people. Title is empty when the add-on declares none; the interface then shows the ID. + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` } // ChatsAddonList lists the add-ons a chat can use. @@ -1552,6 +1567,12 @@ type ChatsBuiltinGroup struct { Provider string `json:"provider" example:"builtin"` // Providers says who serves each tool, by tool name. Providers map[string]string `json:"providers" example:"web_search:builtin"` + // Title and Description say what the group is and what the model can do + // when its switch is on; WhenOff says what it cannot do when it is off. + // They are English; the interface may translate them by group. + Title string `json:"title,omitempty" example:"Web"` + Description string `json:"description,omitempty" example:"The model can search the web and read a public page by its address."` + WhenOff string `json:"when_off,omitempty" example:"The model cannot look anything up online."` } // ChatsWebTestRequest is the body of a test web search. diff --git a/core/services/addons/tools/adapter.go b/core/services/addons/tools/adapter.go index d6ba892377..c10208be1e 100644 --- a/core/services/addons/tools/adapter.go +++ b/core/services/addons/tools/adapter.go @@ -514,6 +514,28 @@ func (t *ToolAdapter) ContextFiles(ctx context.Context, actor contract.Actor) ([ return out, nil } +// AddonListing is the title and description an add-on declares for people. +type AddonListing struct { + AddonID, Title, Description string +} + +// Listings returns the title and description of every add-on the actor can +// see. An add-on that declares neither has its ID as title. +func (t *ToolAdapter) Listings(ctx context.Context, actor contract.Actor) ([]AddonListing, error) { + if err := checkActor(actor); err != nil { + return nil, err + } + list, err := t.backend.Apps(ctx, actor) + if err != nil { + return nil, gateway.ClassifyCheck(err) + } + out := make([]AddonListing, 0, len(list.Apps)) + for _, a := range list.Apps { + out = append(out, AddonListing{AddonID: a.ID, Title: a.Title, Description: a.Description}) + } + return out, nil +} + // AddonShadows is one installed add-on that replaces built-in chat tools, as // the actor sees it, running or not. type AddonShadows struct { diff --git a/core/services/studio/addontools.go b/core/services/studio/addontools.go index 2ce347209b..92dcc56992 100644 --- a/core/services/studio/addontools.go +++ b/core/services/studio/addontools.go @@ -78,6 +78,21 @@ type AddonInfo struct { ID string Tools int Off bool + // Title and Description are what the add-on declares for people, when its + // broker can say (see AddonLister). Empty otherwise. + Title, Description string +} + +// AddonListing is the title and the description an add-on declares in its +// manifest. +type AddonListing struct { + AddonID, Title, Description string +} + +// AddonLister is implemented by a broker that knows what each add-on declares +// for people. It is optional: without it the lists show the add-on IDs only. +type AddonLister interface { + AddonListings(ctx context.Context, user string) ([]AddonListing, error) } // SetAddonsOff sets the add-ons a chat has switched off, replacing the earlier @@ -136,9 +151,19 @@ func (s *Service) addonsFor(ctx context.Context, user string, rec session.ChatRe for _, t := range tools { n[t.AddonID]++ } + listed := map[string]AddonListing{} + if l, ok := s.d.AddonTools.(AddonLister); ok { + if list, err := l.AddonListings(ctx, principal(user)); err != nil { + s.log.Warn("studio: add-on titles unavailable", "user", user, "error", err) + } else { + for _, a := range list { + listed[a.AddonID] = a + } + } + } out := make([]AddonInfo, 0, len(n)) for id, c := range n { - out = append(out, AddonInfo{ID: id, Tools: c, Off: off[id]}) + out = append(out, AddonInfo{ID: id, Tools: c, Off: off[id], Title: listed[id].Title, Description: listed[id].Description}) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil diff --git a/core/services/studio/agents/agentstest/suite.go b/core/services/studio/agents/agentstest/suite.go index 6e7095f66b..9f737c0c01 100644 --- a/core/services/studio/agents/agentstest/suite.go +++ b/core/services/studio/agents/agentstest/suite.go @@ -23,7 +23,7 @@ func sample(id, principal, name string) agents.Agent { return agents.Agent{ ID: id, Principal: principal, Name: name, Description: "d", Instructions: "Be brief.", Model: "m1", MCPServers: []string{"github"}, Addons: []string{"office", "sandbox"}, Collections: []string{"docs"}, Skills: []string{"review"}, - Network: "registries", Created: now, Updated: now, + Network: "registries", BuiltinsOff: []string{"triggers", "web"}, BuiltinsOn: []string{"saved_agents"}, Created: now, Updated: now, } } @@ -44,6 +44,8 @@ func Run(newStore NewStore) { Expect(strings.Join(got.Collections, ",")).To(Equal("docs")) Expect(strings.Join(got.Skills, ",")).To(Equal("review")) Expect(got.Network).To(Equal("registries")) + Expect(got.BuiltinsOff).To(Equal([]string{"triggers", "web"})) + Expect(got.BuiltinsOn).To(Equal([]string{"saved_agents"})) Expect(got.Created.Equal(a.Created)).To(BeTrue(), "got %+v", got) a.Name, a.Addons, a.Updated = "Reviewer 2", nil, a.Updated.Add(time.Hour) a.Created = time.Time{} diff --git a/core/services/studio/agents/gorm.go b/core/services/studio/agents/gorm.go index 312debdcb1..385cb4b456 100644 --- a/core/services/studio/agents/gorm.go +++ b/core/services/studio/agents/gorm.go @@ -36,8 +36,12 @@ type agentRow struct { // AutoMigrate is their migration. FolderID int64 `gorm:"not null;default:0"` FolderMode string `gorm:"size:8;not null;default:''"` - Created time.Time - Updated time.Time + // BuiltinsOff and BuiltinsOn came later too: JSON lists, empty for the + // agents made before. + BuiltinsOff string + BuiltinsOn string + Created time.Time + Updated time.Time } func (agentRow) TableName() string { return "studio_agents" } @@ -119,7 +123,7 @@ func (r agentRow) agent(folders []folderRow) (Agent, error) { for _, f := range []struct { dst *[]string src string - }{{&a.MCPServers, r.MCPServers}, {&a.Addons, r.Addons}, {&a.Collections, r.Collections}, {&a.Skills, r.Skills}} { + }{{&a.MCPServers, r.MCPServers}, {&a.Addons, r.Addons}, {&a.Collections, r.Collections}, {&a.Skills, r.Skills}, {&a.BuiltinsOff, r.BuiltinsOff}, {&a.BuiltinsOn, r.BuiltinsOn}} { if *f.dst, err = listFromJSON(f.src); err != nil { return a, err } @@ -191,7 +195,8 @@ func (s *gormStore) Put(ctx context.Context, a Agent) error { Description: a.Description, Instructions: a.Instructions, Model: a.Model, MCPServers: listJSON(a.MCPServers), Addons: listJSON(a.Addons), Collections: listJSON(a.Collections), Skills: listJSON(a.Skills), Network: a.Network, - FolderID: a.FolderID, FolderMode: a.FolderMode, Created: a.Created, Updated: a.Updated} + FolderID: a.FolderID, FolderMode: a.FolderMode, BuiltinsOff: listJSON(a.BuiltinsOff), BuiltinsOn: listJSON(a.BuiltinsOn), + Created: a.Created, Updated: a.Updated} if exists { row.Created = old.Created err = tx.Save(&row).Error diff --git a/core/services/studio/agents/types.go b/core/services/studio/agents/types.go index 068cfb0f76..589b2d8257 100644 --- a/core/services/studio/agents/types.go +++ b/core/services/studio/agents/types.go @@ -51,6 +51,13 @@ type Agent struct { Addons []string // add-on IDs Collections []string // collection names Skills []string // skill names + // BuiltinsOff are the groups of built-in tools (web, files, triggers...) + // the agent's chats never have, and BuiltinsOn the opt-in groups (saved + // agents) they have from the start. They are a ceiling: a chat of the + // agent cannot switch on a group listed in BuiltinsOff. Empty: every + // group a chat has by default, as agents saved before the field. + BuiltinsOff []string + BuiltinsOn []string // Network is the network preset of the Sandbox commands of the agent's // unattended runs (triggers, connectors): off, registries or all, capped // by the administrator's ceiling. Empty is off. A run never asks a @@ -171,7 +178,7 @@ func Validate(a Agent) error { name string entries []string addon bool - }{{"MCP servers", a.MCPServers, false}, {"add-ons", a.Addons, true}, {"collections", a.Collections, false}, {"skills", a.Skills, false}} { + }{{"MCP servers", a.MCPServers, false}, {"add-ons", a.Addons, true}, {"collections", a.Collections, false}, {"skills", a.Skills, false}, {"built-in tool groups off", a.BuiltinsOff, false}, {"built-in tool groups on", a.BuiltinsOn, false}} { if len(l.entries) > MaxListEntries { return bad("%s has more than %d entries", l.name, MaxListEntries) } @@ -226,5 +233,7 @@ func (a Agent) clone() Agent { a.Addons = append([]string(nil), a.Addons...) a.Collections = append([]string(nil), a.Collections...) a.Skills = append([]string(nil), a.Skills...) + a.BuiltinsOff = append([]string(nil), a.BuiltinsOff...) + a.BuiltinsOn = append([]string(nil), a.BuiltinsOn...) return a.normalizeFolders() } diff --git a/core/services/studio/agentsvc.go b/core/services/studio/agentsvc.go index b4b67cb06d..51d5e888fe 100644 --- a/core/services/studio/agentsvc.go +++ b/core/services/studio/agentsvc.go @@ -48,6 +48,7 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent return agents.Agent{}, ErrAgentsOff } p := principal(user) + keepOff, keepOn := a.BuiltinsOff == nil, a.BuiltinsOn == nil if folders != nil { list, err := s.resolveAgentFolders(ctx, p, *folders) if err != nil { @@ -58,6 +59,7 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent if err := agents.Validate(a); err != nil { return agents.Agent{}, err } + a.BuiltinsOff, a.BuiltinsOn = s.agentGroups(a.BuiltinsOff, false), s.agentGroups(a.BuiltinsOn, true) if folders == nil { if err := s.checkSharedRead(ctx, p, a.FolderList()); err != nil { return agents.Agent{}, err @@ -76,6 +78,13 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent if folders == nil { a.Folders, a.FolderID, a.FolderMode = old.Folders, old.FolderID, old.FolderMode } + // A write that does not mention the tool switches keeps them. + if keepOff { + a.BuiltinsOff = old.BuiltinsOff + } + if keepOn { + a.BuiltinsOn = old.BuiltinsOn + } } if err := s.d.Agents.Put(ctx, a); err != nil { return agents.Agent{}, err @@ -267,3 +276,20 @@ func (s *Service) AgentRuns(ctx context.Context, user, id string, limit int) (Ag } return h, nil } + +// agentGroups keeps the switch groups of an agent that this server has, with +// the opt-in ones in on and the others in off. A group this server does not +// offer is dropped, not refused: an agent file from a server with more tools +// (files or collections on) must still import. A chat of the agent has the +// groups the server offers. +func (s *Service) agentGroups(groups []string, optIn bool) []string { + seen := map[string]bool{} + out := make([]string, 0, len(groups)) + for _, g := range groups { + if !seen[g] && s.builtins.IsGroup(g) && s.builtins.IsOptIn(g) == optIn { + seen[g] = true + out = append(out, g) + } + } + return out +} diff --git a/core/services/studio/builtins/builtins.go b/core/services/studio/builtins/builtins.go index 57065ce029..5d495010b4 100644 --- a/core/services/studio/builtins/builtins.go +++ b/core/services/studio/builtins/builtins.go @@ -141,8 +141,48 @@ func (t Tool) On() bool { return t.Enabled == nil || t.Enabled() } // Registry holds the built-in tools. It is safe for concurrent use. type Registry struct { - mu sync.RWMutex - tools []Tool + mu sync.RWMutex + tools []Tool + groups map[string]GroupInfo +} + +// GroupInfo is what a person reads about a switch group: a title and a +// description of what the model can do when the switch is on. Both are +// English; the interface may translate them by group ID. A group without a +// declaration falls back to the label and description of its first tool. +type GroupInfo struct { + Title, Description string + // WhenOff says what changes for the model when the switch is off. Empty + // when "the model has none of these tools" says it all. + WhenOff string +} + +// DescribeGroup declares the title and description of a switch group. The +// code that registers the tools of a group declares it next to them. +func (r *Registry) DescribeGroup(group string, info GroupInfo) { + r.mu.Lock() + defer r.mu.Unlock() + if r.groups == nil { + r.groups = map[string]GroupInfo{} + } + r.groups[group] = info +} + +// GroupInfo returns the declared information of a group, or the label and +// description of its first registered tool. +func (r *Registry) GroupInfo(group string) GroupInfo { + r.mu.RLock() + info, ok := r.groups[group] + r.mu.RUnlock() + if ok { + return info + } + for _, t := range r.Tools() { + if t.Group == group { + return GroupInfo{Title: t.Label, Description: t.Description} + } + } + return GroupInfo{} } // NewRegistry returns an empty registry. diff --git a/core/services/studio/draftswitches.go b/core/services/studio/draftswitches.go index b26a2756c1..6e85024fb6 100644 --- a/core/services/studio/draftswitches.go +++ b/core/services/studio/draftswitches.go @@ -56,5 +56,7 @@ func (s *Service) draftRecord(ctx context.Context, user, model, agentID string) if ag.Model != "" { rec.Model = ag.Model } + // The opt-in groups the agent starts with are on in the draft. + rec.BuiltinsOn = mergeGroups(rec.BuiltinsOn, ag.BuiltinsOn) return rec, nil } diff --git a/core/services/studio/groupinfo.go b/core/services/studio/groupinfo.go new file mode 100644 index 0000000000..deea9fa70f --- /dev/null +++ b/core/services/studio/groupinfo.go @@ -0,0 +1,66 @@ +package studio + +import "github.com/mudler/LocalAI/core/services/studio/builtins" + +// describeGroups declares the title and the description of every switch group +// of built-in tools. The Tools menu of a chat and the editor of a saved agent +// show them next to each switch, so a person can tell what the model may do +// when it is on. A description says what the model can do, in the words of the +// tools of the group; the descriptions of the tools themselves are written for +// the model and stay as they are. A group the server does not offer is +// described all the same: the registry only lists the groups it has. +func (s *Service) describeGroups() { + for group, info := range map[string]builtins.GroupInfo{ + builtins.GroupShell: { + Title: "Run commands", + Description: "The model can run shell commands and scripts. They run in an add-on that isolates them when one is installed, " + + "otherwise directly on this machine.", + WhenOff: "The model can write files but cannot run anything.", + }, + builtins.GroupFiles: { + Title: "Your files", + Description: "The model can list, read, write, edit, move, delete and search your files, and read documents. " + + "A chat of an agent that works in one folder reaches only that folder.", + WhenOff: "The model cannot see or change your files.", + }, + builtins.GroupWeb: { + Title: "Web", + Description: "The model can search the web and read a public page by its address. Addresses inside your network are refused unless an administrator allows them.", + WhenOff: "The model cannot look anything up online.", + }, + builtins.GroupMedia: { + Title: "Images and audio", + Description: "The model can describe an image or transcribe a recording from your files with a helper model.", + WhenOff: "The model cannot read images or recordings itself.", + }, + builtins.GroupCollections: { + Title: "Collections", + Description: "The model can list your collections and search them for the passages that match a question.", + WhenOff: "The model cannot read your collections.", + }, + builtins.GroupSubAgents: { + Title: "Sub-agents", + Description: "The model can start a sub-agent that works on one task with the same switches as this chat and reports back.", + WhenOff: "The model works alone.", + }, + builtins.GroupSavedAgents: { + Title: "Saved agents", + Description: "The model can list your saved agents and start one as a sub-agent, with that agent's own instructions and model.", + WhenOff: "The model cannot see or start your saved agents.", + }, + builtins.GroupTriggers: { + Title: "Triggers", + Description: "The model can propose, list, change and delete triggers: tasks that run by themselves at a set time or when a file arrives in a folder. " + + "A new trigger or a change waits on a card in the chat until you confirm it; pausing or resuming a trigger applies at once and you can undo it.", + WhenOff: "The model cannot create or manage triggers. A run that a trigger or a connector started never has them.", + }, + builtins.GroupChanges: { + Title: "Memory and profile changes", + Description: "The model can propose an edit to your memory notes or to an agent's standing rules, and undo an edit you confirmed. " + + "Nothing changes until you confirm the proposal on a card in the chat.", + WhenOff: "The model cannot propose edits to your memory or to an agent's rules.", + }, + } { + s.builtins.DescribeGroup(group, info) + } +} diff --git a/core/services/studio/service.go b/core/services/studio/service.go index efb2f9f8f5..86d1600aeb 100644 --- a/core/services/studio/service.go +++ b/core/services/studio/service.go @@ -399,6 +399,7 @@ func New(d Deps) (*Service, error) { if s.builtins == nil { s.builtins = builtins.NewRegistry() } + s.describeGroups() if err := s.registerHostBash(); err != nil { return nil, err } @@ -599,6 +600,7 @@ func (s *Service) Create(ctx context.Context, user string, in CreateInput) (sess func (s *Service) CreateAs(ctx context.Context, user string, in CreateInput, origin Origin) (session.ChatRecord, error) { model := in.Model scoped := false + var agentOn []string if in.Assistant { if in.AgentID != "" { return session.ChatRecord{}, ErrAssistantAgent @@ -621,6 +623,7 @@ func (s *Service) CreateAs(ctx context.Context, user string, in CreateInput, ori if ag.Model != "" { model = ag.Model } + agentOn = ag.BuiltinsOn if ag.HasFolder() { // A chat never starts on the whole home in place of a folder // that is missing or not chosen. @@ -641,6 +644,9 @@ func (s *Service) CreateAs(ctx context.Context, user string, in CreateInput, ori if err != nil { return session.ChatRecord{}, err } + // The opt-in groups the agent starts with (saved agents) are on in its + // chats from the first turn; the chat may still switch them off. + sw.BuiltinsOn = mergeGroups(sw.BuiltinsOn, agentOn) title := in.Title if title == "" { title = session.DefaultTitle @@ -1483,3 +1489,13 @@ func (s *Service) Delete(ctx context.Context, user, conv string) error { // MCPConnectors returns the service that keeps the users' MCP connectors, or // nil when the chats have none. func (s *Service) MCPConnectors() *mcpconns.Service { return s.d.MCPConnectors } + +// mergeGroups returns a with the entries of b it lacks, in order. +func mergeGroups(a, b []string) []string { + for _, g := range b { + if !slices.Contains(a, g) { + a = append(a, g) + } + } + return a +} diff --git a/core/services/studio/toolgroupinfo_test.go b/core/services/studio/toolgroupinfo_test.go new file mode 100644 index 0000000000..9114b77fe2 --- /dev/null +++ b/core/services/studio/toolgroupinfo_test.go @@ -0,0 +1,114 @@ +package studio_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/builtins" + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// describedAddons is a broker that also says what its add-ons declare. +type describedAddons struct { + *fakeAddons + listings []studio.AddonListing +} + +func (l describedAddons) AddonListings(context.Context, string) ([]studio.AddonListing, error) { + return l.listings, nil +} + +var _ = Describe("what a person reads next to a tool switch", func() { + It("gives every listed group a title and a description of what the model can do", func() { + svc, _ := newGroupsChat(nil) + id := create(svc, alice) + p, err := svc.Tools(context.Background(), alice, id) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Builtins).NotTo(BeEmpty()) + for _, g := range p.Builtins { + Expect(g.Title).NotTo(BeEmpty(), g.Group) + Expect(g.Description).To(HavePrefix("The model can"), g.Group) + Expect(g.WhenOff).NotTo(BeEmpty(), g.Group) + } + // The group with no tool in the registry list of the interface + // ("changes") reads as a title, not as its ID. + changes := builtinGroup(p.Builtins, builtins.GroupChanges) + Expect(changes.Title).To(Equal("Memory and profile changes")) + Expect(changes.Description).To(ContainSubstring("until you confirm")) + }) + + It("falls back to the first tool of a group that nobody described", func() { + r := builtins.NewRegistry() + Expect(r.Register(builtins.Tool{ID: "x_tool", Group: "x", Label: "X tool", Description: "Does x.", Unlisted: true, + Build: func(context.Context, builtins.Chat) (session.Tool, bool, string) { return session.Tool{}, true, "" }})).To(Succeed()) + Expect(r.GroupInfo("x")).To(Equal(builtins.GroupInfo{Title: "X tool", Description: "Does x."})) + Expect(r.GroupInfo("nothing")).To(Equal(builtins.GroupInfo{})) + r.DescribeGroup("y", builtins.GroupInfo{Title: "Y", Description: "The model can y."}) + Expect(r.GroupInfo("y").Title).To(Equal("Y")) + }) + + It("lists the title and the description an add-on declares", func() { + broker := describedAddons{&fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}, + []studio.AddonListing{{AddonID: "sandbox", Title: "Sandbox", Description: "Runs code in isolation."}}} + svc, _ := newGroupsChat(broker) + id := create(svc, alice) + p, err := svc.Tools(context.Background(), alice, id) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Addons).NotTo(BeEmpty()) + Expect(p.Addons[0].Title).To(Equal("Sandbox")) + Expect(p.Addons[0].Description).To(Equal("Runs code in isolation.")) + }) +}) + +var _ = Describe("the tool switches of a saved agent", func() { + newAgentChat := func() (*studio.Service, agents.Agent) { + ag, err := agents.NewJSON("") + Expect(err).NotTo(HaveOccurred()) + svc, _ := newGroupsChat(nil, func(d *studio.Deps) { d.Agents = ag }) + a, err := svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Careful", BuiltinsOff: []string{"files", "nope"}, BuiltinsOn: []string{"saved_agents"}}) + Expect(err).NotTo(HaveOccurred()) + return svc, a + } + + It("keeps the groups the server has, and keeps them when a write does not mention them", func() { + svc, a := newAgentChat() + Expect(a.BuiltinsOff).To(Equal([]string{"files"})) + again, err := svc.SaveAgent(context.Background(), alice, agents.Agent{ID: a.ID, Name: "Careful 2"}) + Expect(err).NotTo(HaveOccurred()) + Expect(again.BuiltinsOff).To(Equal([]string{"files"}), "an older client cannot widen the agent") + cleared, err := svc.SaveAgent(context.Background(), alice, agents.Agent{ID: a.ID, Name: "Careful 2", BuiltinsOff: []string{}, BuiltinsOn: []string{}}) + Expect(err).NotTo(HaveOccurred()) + Expect(cleared.BuiltinsOff).To(BeEmpty()) + }) + + It("is a ceiling: a chat of the agent cannot switch the group on, and the draft says why", func() { + svc, a := newAgentChat() + rec, err := svc.Create(context.Background(), alice, studio.CreateInput{Model: "m", AgentID: a.ID}) + Expect(err).NotTo(HaveOccurred()) + Expect(rec.BuiltinsOn).To(ContainElement("saved_agents"), "the agent starts with saved agents on") + Expect(svc.SetBuiltinsOff(context.Background(), alice, rec.ID, []string{})).To(Succeed()) + for _, p := range []studio.ToolsPanel{mustTools(svc, rec.ID), mustDraft(svc, a.ID)} { + files := builtinGroup(p.Builtins, "files") + Expect(files.Off).To(BeTrue()) + Expect(files.Usable).To(BeFalse()) + Expect(files.Reason).To(Equal(studio.ReasonOffForAgent)) + Expect(builtinGroup(p.Builtins, "media").Off).To(BeFalse()) + } + }) +}) + +func mustTools(svc *studio.Service, id string) studio.ToolsPanel { + p, err := svc.Tools(context.Background(), alice, id) + Expect(err).NotTo(HaveOccurred()) + return p +} + +func mustDraft(svc *studio.Service, agentID string) studio.ToolsPanel { + p, err := svc.DraftTools(context.Background(), alice, "m", agentID) + Expect(err).NotTo(HaveOccurred()) + return p +} diff --git a/core/services/studio/toolgroups.go b/core/services/studio/toolgroups.go index 381dae3bd1..bcccafda62 100644 --- a/core/services/studio/toolgroups.go +++ b/core/services/studio/toolgroups.go @@ -103,16 +103,34 @@ func (s *Service) registerToolGroups() error { // switchesOf returns the groups a chat switched off and on. A sub-agent has // none of its own: it follows its parent chat. +// +// The groups the chat's saved agent turns off are off whatever the chat says: +// they are the agent's ceiling, and a sub-agent follows the ceiling of the +// agent of its parent. func (s *Service) switchesOf(ctx context.Context, rec session.ChatRecord) (off, on []string) { if rec.Kind == session.KindSubAgent && rec.ParentID != "" { parent, err := s.d.Store.GetChat(ctx, rec.Principal, rec.ParentID) if err == nil { - return parent.BuiltinsOff, parent.BuiltinsOn + return mergeGroups(slices.Clone(parent.BuiltinsOff), s.agentOff(ctx, parent)), parent.BuiltinsOn } s.log.Warn("studio: the parent of a sub-agent is gone; it keeps every group off", "chat", rec.ID, "error", err) return s.builtins.Groups(), nil } - return rec.BuiltinsOff, rec.BuiltinsOn + return mergeGroups(slices.Clone(rec.BuiltinsOff), s.agentOff(ctx, rec)), rec.BuiltinsOn +} + +// agentOff are the groups of built-in tools the saved agent of the chat turns +// off, or none for a chat without an agent or an agent that cannot be read +// (the scope of a missing agent already leaves the chat without tools). +func (s *Service) agentOff(ctx context.Context, rec session.ChatRecord) []string { + if rec.AgentID == "" || s.d.Agents == nil { + return nil + } + ag, err := s.d.Agents.Get(ctx, rec.Principal, rec.AgentID) + if err != nil { + return nil + } + return ag.BuiltinsOff } // hiddenFor returns the names of the tools the chat's switches take away diff --git a/core/services/studio/webtools.go b/core/services/studio/webtools.go index 70a7cc4150..58e9add74d 100644 --- a/core/services/studio/webtools.go +++ b/core/services/studio/webtools.go @@ -151,8 +151,15 @@ type BuiltinGroup struct { Provider string // Providers says who serves each tool of the group, by tool name. Providers map[string]string + // Title, Description and WhenOff are what a person reads next to the + // switch (see builtins.GroupInfo). + Title, Description, WhenOff string } +// ReasonOffForAgent is why a group of built-in tools cannot be switched on in +// a chat: the saved agent of the chat turns it off. +const ReasonOffForAgent = "the agent of this chat does not use it" + // MixedProvider is the Provider of a group whose tools are served by // different providers. const MixedProvider = "mixed" @@ -181,7 +188,9 @@ func (s *Service) builtinGroups(ctx context.Context, rec session.ChatRecord, too i = len(out) byGroup[t.Group] = i isOff := off[t.Group] || (t.OptIn && !on[t.Group]) - out = append(out, BuiltinGroup{Group: t.Group, Off: isOff, OptIn: t.OptIn, Providers: map[string]string{}}) + info := s.builtins.GroupInfo(t.Group) + out = append(out, BuiltinGroup{Group: t.Group, Off: isOff, OptIn: t.OptIn, Providers: map[string]string{}, + Title: info.Title, Description: info.Description, WhenOff: info.WhenOff}) } out[i].Tools = append(out[i].Tools, t.ID) provider := BuiltinProvider @@ -216,7 +225,13 @@ func (s *Service) builtinGroups(ctx context.Context, rec session.ChatRecord, too out[i].Reason = reason } } + byAgent := set(s.agentOff(ctx, rec)) for i := range out { + if byAgent[out[i].Group] { + // The agent's ceiling: the chat cannot switch the group on. + out[i].Usable, out[i].Reason = false, ReasonOffForAgent + continue + } if out[i].Group == builtins.GroupShell && !out[i].Usable { out[i].Reason = shellReason(s.commandsOf(ctx, rec.Principal, tools, rec.AddonsOff, s.scopeOf(ctx, rec)), out[i].Reason) } diff --git a/core/services/studio/webtools_test.go b/core/services/studio/webtools_test.go index 026e34646b..edfb61db9e 100644 --- a/core/services/studio/webtools_test.go +++ b/core/services/studio/webtools_test.go @@ -115,7 +115,7 @@ var _ = Describe("built-in web tools", func() { p, err := svc.Tools(context.Background(), alice, id) Expect(err).NotTo(HaveOccurred()) - Expect(builtinGroup(p.Builtins, "web")).To(Equal(studio.BuiltinGroup{Group: "web", Tools: []string{"web_search", "web_fetch"}, Off: true, Usable: true, Provider: "builtin", + Expect(bare(builtinGroup(p.Builtins, "web"))).To(Equal(studio.BuiltinGroup{Group: "web", Tools: []string{"web_search", "web_fetch"}, Off: true, Usable: true, Provider: "builtin", Providers: map[string]string{"web_search": "builtin", "web_fetch": "builtin"}})) }) @@ -131,7 +131,7 @@ var _ = Describe("built-in web tools", func() { id := create(svc, alice) p, err := svc.Tools(context.Background(), alice, id) Expect(err).NotTo(HaveOccurred()) - Expect(builtinGroup(p.Builtins, "web")).To(Equal(studio.BuiltinGroup{Group: "web", Tools: []string{"web_search"}, Usable: true, Provider: "builtin", Providers: map[string]string{"web_search": "builtin"}})) + Expect(bare(builtinGroup(p.Builtins, "web"))).To(Equal(studio.BuiltinGroup{Group: "web", Tools: []string{"web_search"}, Usable: true, Provider: "builtin", Providers: map[string]string{"web_search": "builtin"}})) svc2, _ := newToolService(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, withWeb(&fakeWeb{})) id2 := create(svc2, alice) @@ -339,3 +339,10 @@ var _ = Describe("an add-on tool that shadows web_search", func() { Expect(h.ToolNames(id)).NotTo(ContainElement("addon__searcher__search")) }) }) + +// bare is a group without the words a person reads, which the specs of the +// switches do not compare (toolgroupinfo_test.go does). +func bare(g studio.BuiltinGroup) studio.BuiltinGroup { + g.Title, g.Description, g.WhenOff = "", "", "" + return g +} diff --git a/docs/content/features/chats-agents.md b/docs/content/features/chats-agents.md index f1b62ded41..2734183ae0 100644 --- a/docs/content/features/chats-agents.md +++ b/docs/content/features/chats-agents.md @@ -24,9 +24,16 @@ An agent has: | `addons` | IDs of installed add-ons. | | `collections` | Names of your collections. | | `skills` | Names of skills. The index of skills and the `load_skill` tool of the chat offer only these. | +| `builtins_off` | Groups of built-in tools the agent's chats never have: `shell`, `files`, `web`, `media`, `collections`, `subagents`, `triggers` and `changes`. It is a ceiling: a chat with the agent shows the group switched off and cannot switch it on. Empty (the default): the chat has every group it has by default. A group this server does not offer is dropped. | +| `builtins_on` | Opt-in groups of built-in tools that chats with the agent start with: `saved_agents`. The chat can switch them off. | | `network` | The network of the [Sandbox]({{%relref "features/addons#network-access" %}}) commands of the agent's trigger and connector runs: `off` (the default), `registries` (the package registries) or `all` (every public host), capped by the administrator's `chats_egress_ceiling`. A run never shows a card: a host outside it is blocked, and the block is in the run's output. A chat you start with the agent starts with this preset too; you change it in the chat's Tools menu. The agent form sets it in the add-ons fold. | | `folders` | Optional. Up to 8 folders of your files that chats with the agent work in, each with its own mode: `read` or `write`. `folder` is the same with one entry. Without them the agent works on all your files. See [Folders of an agent](#folders-of-an-agent). | +The agent form shows the same Tools list as the Tools menu of a chat, with a +short description under each switch: the built-in tool groups, the add-ons, your +connectors, and the skills. What you switch on is saved in `addons`, `mcp_servers` +and `skills`; what you switch off among the built-in groups is saved in `builtins_off`. + Each list is an exact grant. An empty list grants nothing: a chat with an agent that names no add-ons has no add-on tools, even when you could use some. A plain chat, with no agent, is not changed. @@ -97,7 +104,7 @@ curl -X POST .../api/chats -d '{"agent_id": "", "model": "my-model"}' ``` `GET`, `PUT` and `DELETE /api/chats/agents/{id}` read, replace and delete one. -A `PUT` replaces every field except `folders` (see below). Chats that already run with the agent use the new +A `PUT` replaces every field except `folders` (see below) and the tool switches `builtins_off` and `builtins_on`, which it keeps when the body does not name them (an empty list clears them). Chats that already run with the agent use the new definition from their next turn. Another user's agent is always `404`. Agents need the `agents` feature, which is off by default for a user (see @@ -246,7 +253,7 @@ curl -X POST .../api/chats/agents/import -F file=@reviewer.json ``` The file holds the definition only (kind `localai-agent`): no ID, owner or -time. An agent without a folder is version 1. An agent with one folder is +time. An agent with tool switches (`builtins_off`, `builtins_on`) is version 4, so that a server that does not know them refuses the file instead of giving the agent every tool. An agent without a folder or tool switches is version 1. An agent with one folder is version 2 and carries `folder` with the folder's path at export time and the mode, for example `{"path": "/Projects/Acme", "mode": "read"}` (the path is empty when the folder is missing, not chosen, or now outside your home). diff --git a/swagger/docs.go b/swagger/docs.go index b9dfb13d82..f1a7158d39 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -4696,7 +4696,7 @@ const docTemplate = `{ }, "/api/chats/agents/{id}/export": { "get": { - "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", + "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", "tags": [ "chats" ], @@ -18379,12 +18379,19 @@ const docTemplate = `{ "schema.ChatsAddon": { "type": "object", "properties": { + "description": { + "type": "string" + }, "id": { "type": "string" }, "off": { "type": "boolean" }, + "title": { + "description": "Title and Description are what the add-on's manifest declares for\npeople. Title is empty when the add-on declares none; the interface then shows the ID.", + "type": "string" + }, "tools": { "type": "integer" } @@ -18723,6 +18730,19 @@ const docTemplate = `{ "type": "string" } }, + "builtins_off": { + "description": "BuiltinsOff are the groups of built-in tools (web, files, triggers...)\nthe agent's chats never have, and BuiltinsOn the opt-in groups (saved\nagents) they start with. The names are the ones GET /api/chats/{id}/tools\nlists. Empty for an agent that has every group a chat has by default.", + "type": "array", + "items": { + "type": "string" + } + }, + "builtins_on": { + "type": "array", + "items": { + "type": "string" + } + }, "collections": { "type": "array", "items": { @@ -18981,6 +19001,19 @@ const docTemplate = `{ "type": "string" } }, + "builtins_off": { + "description": "BuiltinsOff and BuiltinsOn are the switches of the built-in tools of\nthe agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen\nthe agent); an empty list clears them.", + "type": "array", + "items": { + "type": "string" + } + }, + "builtins_on": { + "type": "array", + "items": { + "type": "string" + } + }, "collections": { "type": "array", "items": { @@ -19076,6 +19109,10 @@ const docTemplate = `{ "schema.ChatsBuiltinGroup": { "type": "object", "properties": { + "description": { + "type": "string", + "example": "The model can search the web and read a public page by its address." + }, "group": { "type": "string", "example": "web" @@ -19105,6 +19142,11 @@ const docTemplate = `{ "reason": { "type": "string" }, + "title": { + "description": "Title and Description say what the group is and what the model can do\nwhen its switch is on; WhenOff says what it cannot do when it is off.\nThey are English; the interface may translate them by group.", + "type": "string", + "example": "Web" + }, "tools": { "type": "array", "items": { @@ -19118,6 +19160,10 @@ const docTemplate = `{ "usable": { "description": "Usable is false when the group cannot serve this chat now; Reason says\nwhy.", "type": "boolean" + }, + "when_off": { + "type": "string", + "example": "The model cannot look anything up online." } } }, diff --git a/swagger/swagger.json b/swagger/swagger.json index 7b30c43230..e52d912960 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -4693,7 +4693,7 @@ }, "/api/chats/agents/{id}/export": { "get": { - "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", + "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", "tags": [ "chats" ], @@ -18376,12 +18376,19 @@ "schema.ChatsAddon": { "type": "object", "properties": { + "description": { + "type": "string" + }, "id": { "type": "string" }, "off": { "type": "boolean" }, + "title": { + "description": "Title and Description are what the add-on's manifest declares for\npeople. Title is empty when the add-on declares none; the interface then shows the ID.", + "type": "string" + }, "tools": { "type": "integer" } @@ -18720,6 +18727,19 @@ "type": "string" } }, + "builtins_off": { + "description": "BuiltinsOff are the groups of built-in tools (web, files, triggers...)\nthe agent's chats never have, and BuiltinsOn the opt-in groups (saved\nagents) they start with. The names are the ones GET /api/chats/{id}/tools\nlists. Empty for an agent that has every group a chat has by default.", + "type": "array", + "items": { + "type": "string" + } + }, + "builtins_on": { + "type": "array", + "items": { + "type": "string" + } + }, "collections": { "type": "array", "items": { @@ -18978,6 +18998,19 @@ "type": "string" } }, + "builtins_off": { + "description": "BuiltinsOff and BuiltinsOn are the switches of the built-in tools of\nthe agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen\nthe agent); an empty list clears them.", + "type": "array", + "items": { + "type": "string" + } + }, + "builtins_on": { + "type": "array", + "items": { + "type": "string" + } + }, "collections": { "type": "array", "items": { @@ -19073,6 +19106,10 @@ "schema.ChatsBuiltinGroup": { "type": "object", "properties": { + "description": { + "type": "string", + "example": "The model can search the web and read a public page by its address." + }, "group": { "type": "string", "example": "web" @@ -19102,6 +19139,11 @@ "reason": { "type": "string" }, + "title": { + "description": "Title and Description say what the group is and what the model can do\nwhen its switch is on; WhenOff says what it cannot do when it is off.\nThey are English; the interface may translate them by group.", + "type": "string", + "example": "Web" + }, "tools": { "type": "array", "items": { @@ -19115,6 +19157,10 @@ "usable": { "description": "Usable is false when the group cannot serve this chat now; Reason says\nwhy.", "type": "boolean" + }, + "when_off": { + "type": "string", + "example": "The model cannot look anything up online." } } }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index c2e82a9783..db15c813f7 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -2829,10 +2829,17 @@ definitions: type: object schema.ChatsAddon: properties: + description: + type: string id: type: string "off": type: boolean + title: + description: |- + Title and Description are what the add-on's manifest declares for + people. Title is empty when the add-on declares none; the interface then shows the ID. + type: string tools: type: integer type: object @@ -3102,6 +3109,19 @@ definitions: items: type: string type: array + builtins_off: + description: |- + BuiltinsOff are the groups of built-in tools (web, files, triggers...) + the agent's chats never have, and BuiltinsOn the opt-in groups (saved + agents) they start with. The names are the ones GET /api/chats/{id}/tools + lists. Empty for an agent that has every group a chat has by default. + items: + type: string + type: array + builtins_on: + items: + type: string + type: array collections: items: type: string @@ -3306,6 +3326,18 @@ definitions: items: type: string type: array + builtins_off: + description: |- + BuiltinsOff and BuiltinsOn are the switches of the built-in tools of + the agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen + the agent); an empty list clears them. + items: + type: string + type: array + builtins_on: + items: + type: string + type: array collections: items: type: string @@ -3383,6 +3415,9 @@ definitions: type: object schema.ChatsBuiltinGroup: properties: + description: + example: The model can search the web and read a public page by its address. + type: string group: example: web type: string @@ -3409,6 +3444,13 @@ definitions: type: object reason: type: string + title: + description: |- + Title and Description say what the group is and what the model can do + when its switch is on; WhenOff says what it cannot do when it is off. + They are English; the interface may translate them by group. + example: Web + type: string tools: example: - web_search @@ -3421,6 +3463,9 @@ definitions: Usable is false when the group cannot serve this chat now; Reason says why. type: boolean + when_off: + example: The model cannot look anything up online. + type: string type: object schema.ChatsBuiltinSkill: properties: @@ -14731,8 +14776,10 @@ paths: carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server - that knows only version 1 refuses it. An agent without a folder is version - 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export. + that knows only version 1 refuses it. An agent with switches of built-in tool + groups (builtins_off, builtins_on) is version 4. An agent without a folder + or switches is version 1. POST /api/chats/agents/import reads it. This replaces + the old /api/agents/{name}/export. parameters: - description: the agent's id in: path