mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-05 20:13:23 -05:00
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:
5 files changed
+103
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]}'
|
||||
|
||||
Reference in new issue
Block a user