From 14d7fc631cc2e62ee2b26f8ae8177efd98f5e622 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:37:07 +0000 Subject: [PATCH] feat(studio): wire the proposal store and document change proposals Open the proposal store beside the chats (auth database, file or memory) and document the mechanism for add-on authors: the wire record, the limits, the gate, the audit, the REST routes and how a cluster shares it. Update the registry and actor-scan specs for the new tools. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/application/studio_service.go | 25 +++++++++ core/services/studio/actorscan_test.go | 1 + core/services/studio/webtools_test.go | 6 +-- docs/content/features/addons.md | 73 ++++++++++++++++++++++++++ docs/content/features/chats.md | 1 + 5 files changed, 103 insertions(+), 3 deletions(-) diff --git a/core/application/studio_service.go b/core/application/studio_service.go index a716e036c7..c7cc4b0fb1 100644 --- a/core/application/studio_service.go +++ b/core/application/studio_service.go @@ -28,6 +28,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/nibharness" "github.com/mudler/LocalAI/core/services/studio/notify" "github.com/mudler/LocalAI/core/services/studio/pages" + "github.com/mudler/LocalAI/core/services/studio/proposals" "github.com/mudler/LocalAI/core/services/studio/sharedfolders" "github.com/mudler/LocalAI/core/services/studio/workspace" ) @@ -117,6 +118,7 @@ func (a *Application) initStudioService() { RemoteModel: a.studioRemoteModel, PageConsents: a.studioPageConsents(), SharedFolderAudit: a.studioSharedFolderAudit(), + Proposals: a.studioProposalStore(), EgressSettings: a.studioEgressSettings, EgressAudit: a.studioEgressAudit(), EgressAuditDays: func() int { return a.applicationConfig.EffectiveChatsEgressAuditDays() }, @@ -582,6 +584,29 @@ func (a *Application) studioSharedFolderAudit() sharedfolders.AuditStore { return st } +// studioProposalStore opens the store of the change proposals beside the +// chats: the auth database (shared by the frontends of a cluster), a file under +// the data path, or memory. +func (a *Application) studioProposalStore() proposals.Store { + var ( + st proposals.Store + err error + ) + switch { + case a.authDB != nil: + st, err = proposals.NewGORM(a.authDB) + case a.applicationConfig.DataPath != "": + st, err = proposals.NewJSON(filepath.Join(a.applicationConfig.DataPath, "chats", "proposals")) + default: + st, err = proposals.NewJSON("") + } + if err != nil { + xlog.Warn("Chats: proposed changes are kept in memory", "error", err) + st, _ = proposals.NewJSON("") + } + return st +} + // studioFindUser finds a user by ID, email or name, whatever their status, for // the members of a shared folder. Without an auth database there is no user to // find, and the shared folders take any principal. diff --git a/core/services/studio/actorscan_test.go b/core/services/studio/actorscan_test.go index 0b46fc9770..35deeafbbb 100644 --- a/core/services/studio/actorscan_test.go +++ b/core/services/studio/actorscan_test.go @@ -43,6 +43,7 @@ var actorAllowlist = map[string]string{ "skillsvc.go:Service.chatSkillSet": "reads the index of the skills the chat may use", "loadskill.go:Service.loadSkill": "reads only skills the agent grants, by name", "chatfiles.go:Service.ResolveAttachments": "the attachments a person sees in the transcript are a user surface", + "changes.go:Changes.Revert": "undoes the change set that the owner confirmed; a person's own action, never the model's", "service.go:New": "the administrator of the change feed and the trigger watcher", "../../application/studio_files.go:Application.initStudioTextIndex": "the administrator of the text index", } diff --git a/core/services/studio/webtools_test.go b/core/services/studio/webtools_test.go index 7ef8bc9c91..026e34646b 100644 --- a/core/services/studio/webtools_test.go +++ b/core/services/studio/webtools_test.go @@ -240,7 +240,7 @@ var _ = Describe("the one registry of built-in tools", func() { } else if t.External { Expect(t.Group).To(BeElementOf(builtins.GroupFiles, builtins.GroupMedia, builtins.GroupCollections, builtins.GroupSubAgents), t.ID) } else { - Expect(t.Group).To(Equal(builtins.GroupTriggers), t.ID) + Expect(t.Group).To(BeElementOf(builtins.GroupTriggers, builtins.GroupChanges), t.ID) } Expect(t.Setting).To(BeEmpty(), t.ID) continue @@ -251,7 +251,7 @@ var _ = Describe("the one registry of built-in tools", func() { } Expect(t.Replaceable).To(BeTrue(), t.ID) } - Expect(ids).To(Equal([]string{"bash", "web_search", "web_fetch", "propose_trigger", "list_triggers", "update_trigger", "delete_trigger", "load_skill", "goal_done", "goal_blocked", "spawn_agent"}), + Expect(ids).To(Equal([]string{"bash", "web_search", "web_fetch", "propose_trigger", "list_triggers", "update_trigger", "delete_trigger", "propose_change", "revert_change", "load_skill", "goal_done", "goal_blocked", "spawn_agent"}), "a service without files has no file or media group") for _, id := range []string{"web_search", "web_fetch"} { t, ok := svc.Builtins().Lookup(id) @@ -259,7 +259,7 @@ var _ = Describe("the one registry of built-in tools", func() { Expect(t.Group).To(Equal(builtins.GroupWeb)) Expect(t.ReadOnly).To(BeTrue()) } - Expect(svc.Builtins().Groups()).To(Equal([]string{"shell", "web", "triggers", "subagents"})) + Expect(svc.Builtins().Groups()).To(Equal([]string{"shell", "web", "triggers", "changes", "subagents"})) }) It("lists the web tools in Settings > Tools with their settings", func() { diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 71a992cc7a..849c9d58d4 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -2167,6 +2167,79 @@ A frontend that does not own the add-ons runs no loop. connectors became add-ons) belongs to the add-on `telegram`. While that add-on does not run, the binding is listed as not running with the reason "install the Telegram add-on". +## Change proposals from chats + +A model can ask to change what lasts: a note in the person's memory, or the +profile of a saved agent. The host stores the request as a change proposal and +writes nothing until the owner confirms. The mechanism belongs to the host and +knows no chat platform, so every surface that runs a model uses it the same +way: a Studio chat, the chats of any connector add-on and a chat started from an +add-on page. An add-on needs no code and no manifest field for it. + +How it works for an add-on author: + +- The model calls the built-in tool `propose_change` (kind, scope, target, + content, summary) in the tool group `changes`. A connector add-on carries the + chat like any other: the tool result tells the model that nothing is saved + yet, and the model says so in the platform. +- The proposal records its source as `{kind, id, chat, thread}`. `kind` is the + origin of the chat (empty for a chat a person watches, `connector` for a + messaging app), `id` is the binding, `chat` is the host chat. These names + come from the host, never from the platform. +- The owner confirms in Studio. The host then writes the file as the owner in + one workspace change set, so Files history and `revert_change` can undo it. +- `revert_change {id?}` undoes an applied change at once, because it only + restores the earlier text. Without an id it undoes the last applied change of + the same agent. A file edited since cannot be reverted. + +The record is `contract.ChangeProposal` in `pkg/addons/contract` and is +versioned (`v`: 1). Readers ignore fields they do not know. The kinds are +`memory`, `profile`, `skill`, `page` and `dashboard`; this host offers `memory` +and `profile` and other features register theirs. A memory change writes under +`Memory/` (`Memory/.md` for scope `user`, `Memory/agents//` for +`agent`, `Memory/channels/-/` for `channel`). A profile writes +`LocalAI/agents//profile.md` and is at most 4 KiB. Other contents are at +most 16 KiB. A target that is not a plain lower-case file name is refused, so a +proposal cannot leave `Memory/`. + +Limits and safety: + +| Rule | Value | +|---|---| +| A proposal waits | 24 hours, then it expires | +| Pending proposals of one person | 10 | +| Proposals of one agent (or of plain chats) in an hour | 5 | +| Target changed after the proposal | the confirmation is refused (409) | +| Run started by a trigger, a job or a page, and sub-agents | never offered the tools | +| Chat that runs a saved agent | refused until a grant allows it | + +A grant plugs in as a `proposals.Gate` in the host: it is asked with the +action (`propose` or `revert`), the agent, the kind, the scope and the source +before a tool call acts. The default gate allows a plain chat and refuses a chat +that runs a saved agent, so an agent has no right until the owner gives it. + +Every step is audited (propose, refuse, confirm, dismiss, expire, stale, +revert) with the source and a SHA-256 of the text, never the text. + +REST, under the chats feature: + +| Route | Does | +|---|---| +| `GET /api/chats/changes?status=` | list your proposals | +| `GET /api/chats/changes/{id}` | read one | +| `POST /api/chats/changes/{id}/confirm` | write it | +| `POST /api/chats/changes/{id}/dismiss` | answer no | +| `POST /api/chats/changes/{id}/undo` | restore the file | +| `GET /api/chats/changes/audit` | read the audit | + +In a cluster the proposals and the audit live in the shared database (the auth +database), so any frontend can confirm what another one stored. The limits are +checked in the same transaction as the insert. A race between two frontends can +let a limit be passed by the number of frontends that insert at the same moment, +never more. Without a database they live in a file under the data path, or in +memory when there is no data path. + + ## Owner forwarding and HTTP relays The embedding host can configure `AddonConfig.Transport` at startup. The configuration diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md index e23a37a8f9..76671378fe 100644 --- a/docs/content/features/chats.md +++ b/docs/content/features/chats.md @@ -1235,6 +1235,7 @@ it off, so a chat that never touches the menu works as before: | **Sub-agents** | `subagents` | `spawn_agent` | | **Saved agents** | `saved_agents` | `list_agents` and the `agent` argument of `spawn_agent` (opt-in) | | **Triggers** | `triggers` | `propose_trigger`, `list_triggers`, `update_trigger` and `delete_trigger` | +| **Proposed changes** | `changes` | `propose_change` and `revert_change`: a model asks to change your memory or an agent profile, and you confirm (see [change proposals](/features/addons/#change-proposals-from-chats)) | ```bash curl -s -X PUT localhost:8080/api/chats/$ID/builtins -d '{"off":["web","files"]}'