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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 09:37:07 +00:00
1 parent acba42ae7b
commit 14d7fc631c
5 files changed
+103 -3

No files matched your search

+25
View File
@@ -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.
+1
View File
@@ -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",
}
+3 -3
View File
@@ -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() {
+73
View File
@@ -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/<file>.md` for scope `user`, `Memory/agents/<agent>/` for
`agent`, `Memory/channels/<binding>-<hash>/` for `channel`). A profile writes
`LocalAI/agents/<agent>/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
+1
View File
@@ -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"]}'