mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(memory): add Always confirm, an agent save rule and a messaging row
A person can now choose four save levels on the Memory tab: Save it and tell me, Ask me first (the default), Always confirm and Never. Always confirm is like ask, and nothing can loosen it: no agent or messaging setting overrides it, and the model cannot undo or forget a note by itself (revert_change is refused, and the person uses the Undo). One function, effectiveSaveLevel, combines the rules. The strictest wins, in the order auto, ask, always, never. The inputs are the level of the person, the rule of the saved agent, the messaging row and the chat. A table test covers every combination. A saved agent has an optional memory_save field: always or never. It can only be stricter than the person, so auto is not offered and the API refuses it. A write without the field keeps the stored rule, and use_mine clears it. It is stored by both stores and is part of an agent export and import. The agent form shows three cards under When it wants to save a note. The new row In messaging apps (messaging_save on GET and PUT /api/chats/memory) takes ask (the default), paired_private or never. With paired_private a note is saved at once only when the sender is paired and the chat is one-to-one, and it goes to the notes of that conversation, with an Undo. The limits stay: five notes a reply, the size limit, and a note that looks like a secret still waits. The row cannot loosen the level of the person when that is always or never. A setting that cannot be read reads as ask. The connector turn now carries whether the chat is one-to-one and whether the sender is paired, in the turn grant and in the worker JSON. Both read as false when unknown. The Telegram, Slack and Discord transports already set Direct; new specs pin it for private, group and supergroup chats, direct messages, channels, group conversations and threads. A note from a messaging chat goes to the conversation first. It reaches the notes of the agent or of the person only from a paired sender, only when the memory choice of the agent allows that scope, and only after that sender confirms it. Under Always confirm only the paired sender who asked can confirm. An agent whose memory is None still saves nothing. The script of the confirm-in-chat specs now proposes a note for the conversation, because a sender who is only listed cannot write to the notes of the person. Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
52 files changed
+2041
-202
No files matched your search
@@ -106,6 +106,18 @@ var _ = Describe("the Gateway link", func() {
|
||||
Expect(got[1].Mention).To(BeTrue())
|
||||
})
|
||||
|
||||
It("marks a message of a server, a channel or a thread as not direct", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("THREAD_CREATE", map[string]any{"id": threadI, "type": 11, "parent_id": chanID, "guild_id": guildID, "newly_created": true})
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: chanID, guild: guildID, sender: userID, text: "in the channel", mentionBot: true}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: threadI, guild: guildID, sender: userID, text: "in the thread", mentionBot: true}.payload())
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: userID, text: "in a direct message"}.payload())
|
||||
got := next(g, 1, 3)
|
||||
Expect(got).To(HaveLen(3))
|
||||
Expect([]bool{got[0].Direct, got[1].Direct, got[2].Direct}).To(Equal([]bool{false, false, true}))
|
||||
Expect(got[1].Thread).To(Equal(threadI))
|
||||
})
|
||||
|
||||
It("ignores every message that a bot, a webhook or Discord wrote, and the bot's own", func() {
|
||||
connected(f, g)
|
||||
f.dispatch("MESSAGE_CREATE", msg{channel: dmID, sender: botID, text: "my own"}.payload())
|
||||
|
||||
@@ -91,6 +91,25 @@ var _ = Describe("the connector route", func() {
|
||||
Expect(pollUntil(r, out.NextOffset, 1).Updates[0].Thread).To(Equal("500.5"))
|
||||
})
|
||||
|
||||
It("marks only a direct message as direct: a group message, a channel mention and a thread are not", func() {
|
||||
f := fake()
|
||||
r := runtimeOf(f)
|
||||
call(r, contract.ConnectorRequest{Op: "poll", Offset: 1})
|
||||
Eventually(func() bool { return f.Connected(appToken) }, 5*time.Second).Should(BeTrue())
|
||||
// A conversation of several people is not read unless the bot is named,
|
||||
// and what is read is never direct.
|
||||
f.Push(appToken, map[string]any{"type": "message", "channel_type": "mpim", "channel": "G1", "user": "U1", "text": "talking", "ts": "1.1"})
|
||||
f.Push(appToken, map[string]any{"type": "message", "channel_type": "group", "channel": "G2", "user": "U1", "text": "talking", "ts": "1.2"})
|
||||
mention := slacktest.Mention("G1", "U1", "UBOT", "hello group", "")
|
||||
f.Push(appToken, mention)
|
||||
// A direct message inside a thread is still direct.
|
||||
f.Push(appToken, map[string]any{"type": "message", "channel_type": "im", "channel": "D1", "user": "U1", "text": "in a thread", "ts": "2.2", "thread_ts": "2.1"})
|
||||
out := pollUntil(r, 1, 2)
|
||||
Expect(out.Updates).To(HaveLen(2))
|
||||
Expect([]any{out.Updates[0].Chat, out.Updates[0].Direct, out.Updates[0].Mention}).To(Equal([]any{"G1", false, true}))
|
||||
Expect([]any{out.Updates[1].Chat, out.Updates[1].Direct, out.Updates[1].Thread}).To(Equal([]any{"D1", true, "2.1"}))
|
||||
})
|
||||
|
||||
It("waits for an event during a long poll", func() {
|
||||
f := fake()
|
||||
r := runtimeOf(f)
|
||||
|
||||
@@ -4,6 +4,7 @@ package transport_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -43,6 +44,25 @@ var _ = Describe("the Bot API client", func() {
|
||||
Expect(c.Path).To(ContainSubstring("/bot" + secretToken + "/"))
|
||||
})
|
||||
|
||||
DescribeTable("marks a conversation as direct only for a private chat",
|
||||
func(chatType string, direct bool) {
|
||||
f := newFakeBot()
|
||||
f.reply["getUpdates"] = func(map[string]any) (int, string) {
|
||||
return 200, fmt.Sprintf(`{"ok":true,"result":[
|
||||
{"update_id":10,"message":{"chat":{"id":-300,"type":%q},"from":{"id":7,"first_name":"Bo"},"text":"hi"}}]}`, chatType)
|
||||
}
|
||||
ups, err := f.client().Updates(ctx, secretToken, 10, 25)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ups).To(HaveLen(1))
|
||||
Expect(ups[0].Direct).To(Equal(direct), chatType)
|
||||
},
|
||||
Entry("private", "private", true),
|
||||
Entry("group", "group", false),
|
||||
Entry("supergroup", "supergroup", false),
|
||||
Entry("a type nobody knows counts as a group", "forum", false),
|
||||
Entry("no type counts as a group", "", false),
|
||||
)
|
||||
|
||||
DescribeTable("types an error and keeps the token out of it",
|
||||
func(code int, body string, check func(error) bool) {
|
||||
f := newFakeBot()
|
||||
|
||||
@@ -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), Memory: a.Memory, BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Builtins: nz(a.Builtins), Connections: a.Connections, Created: a.Created, Updated: a.Updated}
|
||||
Network: networkOf(a.Network), Memory: a.Memory, MemorySave: a.MemorySave, BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Builtins: nz(a.Builtins), Connections: a.Connections, 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,7 @@ 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, Memory: req.Memory,
|
||||
MCPServers: req.MCPServers, Addons: req.Addons, Collections: req.Collections, Skills: req.Skills, Network: req.Network, Memory: req.Memory, MemorySave: req.MemorySave,
|
||||
BuiltinsOff: req.BuiltinsOff, BuiltinsOn: req.BuiltinsOn, Builtins: req.Builtins, Connections: req.Connections},
|
||||
foldersIn(req), true, nil
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ 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, Memory: a.Memory,
|
||||
MCPServers: a.MCPServers, Addons: a.Addons, Collections: a.Collections, Skills: a.Skills, Network: a.Network, Memory: a.Memory, MemorySave: a.MemorySave,
|
||||
BuiltinsOff: a.BuiltinsOff, BuiltinsOn: a.BuiltinsOn, Builtins: a.Builtins}
|
||||
rows := h.svc.AgentFolderRows(c.Request().Context(), userID(c), []agents.Agent{a})[a.ID]
|
||||
for i, f := range a.FolderList() {
|
||||
@@ -147,7 +147,7 @@ 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, Memory: def.Memory,
|
||||
MCPServers: def.MCPServers, Addons: def.Addons, Collections: def.Collections, Skills: def.Skills, Network: def.Network, Memory: def.Memory, MemorySave: def.MemorySave,
|
||||
BuiltinsOff: def.BuiltinsOff, BuiltinsOn: def.BuiltinsOn, Builtins: def.Builtins}
|
||||
var rowsIn []studiosvc.AgentFolderWrite
|
||||
switch {
|
||||
|
||||
@@ -18,7 +18,7 @@ var memoryRules = []errRule{
|
||||
func failMemory(c echo.Context, err error) error { return mapErr(c, err, memoryRules, failFor) }
|
||||
|
||||
func memorySettingsOut(m studiosvc.MemorySettings) schema.ChatsMemorySettings {
|
||||
return schema.ChatsMemorySettings{Enabled: m.Enabled, EnabledSet: m.EnabledSet, SaveMode: m.SaveMode,
|
||||
return schema.ChatsMemorySettings{Enabled: m.Enabled, EnabledSet: m.EnabledSet, SaveMode: m.SaveMode, MessagingSave: m.MessagingSave,
|
||||
SaveModeSet: m.SaveModeSet, Provider: m.Provider, Available: m.Available}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func memoryStateOut(m studiosvc.MemoryReason) schema.ChatsMemoryState {
|
||||
|
||||
// GetMemory reads your memory choices.
|
||||
// @Summary Read your memory settings.
|
||||
// @Description Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved (ask, auto or never) and applies as the strictest rule with the rules of the host; a chat from a messaging app always asks. Your notes stay and can be listed, exported and deleted while memory is off.
|
||||
// @Description Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved: auto (save it and tell me), ask (the default), always (always confirm: like ask, nothing can loosen it, and the model cannot undo or forget by itself) or never. It applies as the strictest rule together with the rule of the saved agent (memory_save) and the rules of the host. A chat from a messaging app asks, unless messaging_save is paired_private and the sender is paired and the chat is one-to-one; the row cannot loosen always or never. Your notes stay and can be listed, exported and deleted while memory is off.
|
||||
// @Tags chats
|
||||
// @Success 200 {object} schema.ChatsMemorySettings "Your memory settings"
|
||||
// @Router /api/chats/memory [get]
|
||||
@@ -46,7 +46,7 @@ func (h *Handlers) GetMemory(c echo.Context) error {
|
||||
|
||||
// PutMemory changes your memory choices.
|
||||
// @Summary Change your memory settings.
|
||||
// @Description Sets enabled (memory On or Off for every chat and agent) and save_mode (ask, auto or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.
|
||||
// @Description Sets enabled (memory On or Off for every chat and agent), save_mode (auto, ask, always or never) and messaging_save (ask, paired_private or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.
|
||||
// @Tags chats
|
||||
// @Accept json
|
||||
// @Param request body schema.ChatsMemoryRequest true "the choices to change"
|
||||
@@ -55,11 +55,11 @@ func (h *Handlers) GetMemory(c echo.Context) error {
|
||||
// @Failure 503 {object} schema.ErrorResponse "The settings cannot be stored on this server"
|
||||
// @Router /api/chats/memory [put]
|
||||
func (h *Handlers) PutMemory(c echo.Context) error {
|
||||
req, ok := bind[schema.ChatsMemoryRequest](c, "the body must be {enabled, save_mode}")
|
||||
req, ok := bind[schema.ChatsMemoryRequest](c, "the body must be {enabled, save_mode, messaging_save}")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
m, err := h.svc.SetMemory(c.Request().Context(), userID(c), studiosvc.MemoryUpdate{Enabled: req.Enabled, SaveMode: req.SaveMode})
|
||||
m, err := h.svc.SetMemory(c.Request().Context(), userID(c), studiosvc.MemoryUpdate{Enabled: req.Enabled, SaveMode: req.SaveMode, MessagingSave: req.MessagingSave})
|
||||
if err != nil {
|
||||
return failMemory(c, err)
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ const GROUPS = [
|
||||
{ source: 'memory', scope: 'agent', key: 'gone', deleted: true, max_bytes: 2048, max_lines: 40, files: [f('Memory/agents/gone/index.md', { index: true, lines: 1 })] },
|
||||
]
|
||||
|
||||
async function serve(page, { enabled = true, agents = AGENTS, groups = GROUPS } = {}) {
|
||||
async function serve(page, { enabled = true, agents = AGENTS, groups = GROUPS, save = 'ask' } = {}) {
|
||||
const s = await installAgents(page, { agents })
|
||||
await page.route('**/api/chats/agents/builtin-grants', route => route.fulfill({ json: { grants: GRANTS } }))
|
||||
await page.route('**/api/chats/memory', route => route.fulfill({ json: { enabled, enabled_set: true, save_mode: 'ask', save_mode_set: false, provider: 'builtin', available: true } }))
|
||||
await page.route('**/api/chats/memory', route => route.fulfill({ json: { enabled, enabled_set: true, save_mode: save, messaging_save: 'ask', save_mode_set: false, provider: 'builtin', available: true } }))
|
||||
await page.route('**/api/chats/context-files', route => route.fulfill({ json: { limits: LIMITS, groups } }))
|
||||
await page.route('**/api/chats/memory/export*', route => route.fulfill({ status: 200, contentType: 'application/zip', body: 'PK' }))
|
||||
// A delete that says what to do with the notes carries a query, which the shared stub does not match.
|
||||
@@ -109,6 +109,50 @@ test.describe('Memory section of the agent form', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('The save rule of the agent', () => {
|
||||
const open = async (page, path) => {
|
||||
await page.goto(path)
|
||||
await fold(page, 'Notes').locator('.ag-fold__button').click()
|
||||
}
|
||||
|
||||
test('offers three cards, never auto, says what is in force, and sends nothing until it is changed', async ({ page }) => {
|
||||
const s = await serve(page, { save: 'auto' })
|
||||
await open(page, '/app/agents/a2/edit')
|
||||
const rule = page.getByTestId('agent-memory-rule')
|
||||
await expect(rule.getByRole('radio')).toHaveCount(3)
|
||||
await expect(rule.locator('.am__title')).toHaveText(['Use my setting', 'Always confirm', 'Never'])
|
||||
await expect(rule).toContainText('Currently: Save it and tell me.')
|
||||
await expect(page.getByTestId('agent-memory-rule-use_mine').locator('input')).toBeChecked()
|
||||
await expect(rule.getByText('Save it and tell me', { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByTestId('agent-memory-rule-hint')).toContainText('never less')
|
||||
await shot(page, 'agent-memory-rule', { fullPage: true })
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click()
|
||||
await expect(page).toHaveURL(/\/app\/agents$/)
|
||||
expect('memory_save' in s.calls.find(c => c.method === 'PUT').body).toBe(false)
|
||||
})
|
||||
|
||||
test('sends always, then never, then use_mine to clear it', async ({ page }) => {
|
||||
const s = await serve(page, { agents: [{ ...AGENTS[1], memory: 'own', memory_save: 'always' }] })
|
||||
await open(page, '/app/agents/a2/edit')
|
||||
await expect(page.getByTestId('agent-memory-rule-always').locator('input')).toBeChecked()
|
||||
await page.getByTestId('agent-memory-rule-never').click()
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click()
|
||||
await expect(page).toHaveURL(/\/app\/agents$/)
|
||||
expect(s.calls.filter(c => c.method === 'PUT').at(-1).body.memory_save).toBe('never')
|
||||
await open(page, '/app/agents/a2/edit')
|
||||
await page.getByTestId('agent-memory-rule-use_mine').click()
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click()
|
||||
await expect(page).toHaveURL(/\/app\/agents$/)
|
||||
expect(s.calls.filter(c => c.method === 'PUT').at(-1).body.memory_save).toBe('use_mine')
|
||||
})
|
||||
|
||||
test('is hidden when the agent has no memory', async ({ page }) => {
|
||||
await serve(page, { agents: [{ ...AGENTS[1], memory: 'none' }] })
|
||||
await open(page, '/app/agents/a2/edit')
|
||||
await expect(page.getByTestId('agent-memory-rule')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Deleting an agent that has notes', () => {
|
||||
const del = async (page, choice) => {
|
||||
await page.goto('/app/agents')
|
||||
|
||||
@@ -20,8 +20,8 @@ async function shot(page, name, options = {}) {
|
||||
const LIMITS = { index_lines: 40, index_bytes: 2048, profile_bytes: 4096, turn_bytes: 6144 }
|
||||
const NOTES = { 'Memory/index.md': '- home: lives in Rome -> home.md\n- units: metric, 24-hour clock -> units.md\n', 'Memory/home.md': 'Rome.\n', 'Memory/units.md': 'Metric.\n' }
|
||||
|
||||
async function serveAccount(page, { enabled = true, save = 'ask', fail = false } = {}) {
|
||||
const s = { enabled, save, puts: [], exports: 0, removed: [] }
|
||||
async function serveAccount(page, { enabled = true, save = 'ask', messaging = 'ask', fail = false } = {}) {
|
||||
const s = { enabled, save, messaging, puts: [], exports: 0, removed: [] }
|
||||
await mockChat(page)
|
||||
await page.route('**/api/chats/memory', (route) => {
|
||||
const req = route.request()
|
||||
@@ -31,8 +31,9 @@ async function serveAccount(page, { enabled = true, save = 'ask', fail = false }
|
||||
if (fail) return route.fulfill({ status: 503, json: { error: { message: 'the memory settings cannot be stored on this server' } } })
|
||||
if (typeof body.enabled === 'boolean') s.enabled = body.enabled
|
||||
if (body.save_mode) s.save = body.save_mode
|
||||
if (body.messaging_save) s.messaging = body.messaging_save
|
||||
}
|
||||
return route.fulfill({ json: { enabled: s.enabled, enabled_set: s.puts.length > 0, save_mode: s.save, save_mode_set: false, provider: 'builtin', available: true } })
|
||||
return route.fulfill({ json: { enabled: s.enabled, enabled_set: s.puts.length > 0, save_mode: s.save, messaging_save: s.messaging, save_mode_set: false, provider: 'builtin', available: true } })
|
||||
})
|
||||
await page.route('**/api/chats/memory/export*', (route) => { s.exports++; return route.fulfill({ status: 200, contentType: 'application/zip', body: 'PK' }) })
|
||||
await page.route('**/api/chats/context-files', route => route.fulfill({ json: {
|
||||
@@ -81,18 +82,47 @@ test.describe('Settings, Memory: the switch and how a note is saved', () => {
|
||||
await expect(card).toHaveAttribute('data-state', 'on')
|
||||
})
|
||||
|
||||
test('lists the three save levels, marks the default and stores the one picked', async ({ page }) => {
|
||||
test('lists the four save levels, marks the default and stores the one picked', async ({ page }) => {
|
||||
const s = await serveAccount(page)
|
||||
await page.goto('/app/account?tab=memory')
|
||||
const radios = page.getByRole('radio')
|
||||
await expect(radios).toHaveCount(3)
|
||||
await expect(page.getByRole('radio', { name: /Ask me first/ })).toBeChecked()
|
||||
const levels = page.getByRole('radiogroup', { name: 'Saving a note' })
|
||||
await expect(levels.getByRole('radio')).toHaveCount(4)
|
||||
await shot(page, 'memory-settings-default', { fullPage: true })
|
||||
await expect(levels.locator('.ms-level__title')).toHaveText(['Save it and tell me', 'Ask me firstDefault', 'Always confirm', 'Never'])
|
||||
await expect(levels.getByRole('radio', { name: /^Ask me first/ })).toBeChecked()
|
||||
await expect(page.getByTestId('memory-save-ask')).toContainText('Default')
|
||||
await page.getByRole('radio', { name: /Save it and tell me/ }).click()
|
||||
await levels.getByRole('radio', { name: /^Save it and tell me/ }).click()
|
||||
await expect.poll(() => s.puts).toEqual([{ save_mode: 'auto' }])
|
||||
await expect(page.getByRole('radio', { name: /Save it and tell me/ })).toBeChecked()
|
||||
await page.getByRole('radio', { name: /Never/ }).click()
|
||||
await expect(levels.getByRole('radio', { name: /^Save it and tell me/ })).toBeChecked()
|
||||
await levels.getByRole('radio', { name: /^Always confirm/ }).click()
|
||||
await expect.poll(() => s.puts.at(-1)).toEqual({ save_mode: 'always' })
|
||||
await expect(levels.getByRole('radio', { name: /^Always confirm/ })).toBeChecked()
|
||||
await expect(page.getByTestId('memory-save-always')).toContainText('nothing can loosen it')
|
||||
await levels.getByRole('radio', { name: /Never/ }).click()
|
||||
await expect.poll(() => s.puts.at(-1)).toEqual({ save_mode: 'never' })
|
||||
await shot(page, 'memory-settings-levels', { fullPage: true })
|
||||
})
|
||||
|
||||
test('has an In messaging apps row: Ask first by default, stores the choice, and says when it cannot loosen the level', async ({ page }) => {
|
||||
const s = await serveAccount(page)
|
||||
await page.goto('/app/account?tab=memory')
|
||||
const row = page.getByRole('radiogroup', { name: 'In messaging apps' })
|
||||
await expect(row.locator('.ms-level__title')).toHaveText(['Ask me firstDefault', 'Save it for a paired sender in a private chat', 'Never'])
|
||||
await expect(row.getByRole('radio', { name: /^Ask me first/ })).toBeChecked()
|
||||
await expect(page.getByTestId('memory-messaging-paired_private')).toContainText('Never looser than the setting above.')
|
||||
await expect(page.getByTestId('memory-messaging-locked')).toHaveCount(0)
|
||||
await row.getByRole('radio', { name: /paired sender in a private chat/ }).click()
|
||||
await expect.poll(() => s.puts).toEqual([{ messaging_save: 'paired_private' }])
|
||||
await expect(row.getByRole('radio', { name: /paired sender/ })).toBeChecked()
|
||||
await row.getByRole('radio', { name: /^Never/ }).click()
|
||||
await expect.poll(() => s.puts.at(-1)).toEqual({ messaging_save: 'never' })
|
||||
// The levels above and the row are independent controls: picking a level does not touch the row.
|
||||
await page.getByRole('radiogroup', { name: 'Saving a note' }).getByRole('radio', { name: /^Always confirm/ }).click()
|
||||
await expect.poll(() => s.puts.at(-1)).toEqual({ save_mode: 'always' })
|
||||
await expect(page.getByTestId('memory-messaging-locked')).toContainText('Always confirm, so this row cannot save a note at once')
|
||||
await page.getByRole('radiogroup', { name: 'Saving a note' }).getByRole('radio', { name: /^Never/ }).click()
|
||||
await expect(page.getByTestId('memory-messaging-locked')).toContainText('Your setting above is Never')
|
||||
await shot(page, 'memory-settings-messaging', { fullPage: true })
|
||||
})
|
||||
|
||||
test('a choice the server did not store is not shown as stored', async ({ page }) => {
|
||||
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "Speichern und Bescheid geben",
|
||||
"body": "In deinen eigenen Chats wird die Notiz sofort gespeichert. Der Chat zeigt „Im Gedächtnis gespeichert“ mit Rückgängig. Bis zu 5 Notizen pro Antwort. Eine lange Notiz oder eine, die wie ein Geheimnis aussieht, wartet trotzdem auf dich."
|
||||
},
|
||||
"always": {
|
||||
"title": "Immer bestätigen",
|
||||
"body": "Wie Erst fragen, und nichts kann es lockern. Auch Rückgängig und Vergessen warten auf dich. Keine Einstellung eines Agenten oder einer Messaging-App hebt es auf."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nie",
|
||||
"body": "Der Assistent kann keine Notizen speichern. Er sagt dir, dass das Speichern aus ist."
|
||||
}
|
||||
},
|
||||
"default": "Standard",
|
||||
"saveNote": "Chats aus Messaging-Apps fragen immer zuerst. Hintergrundläufe und Trigger speichern nie eine Notiz. Ein gespeicherter Agent darf mehr fragen, aber nie weniger als du hier gewählt hast.",
|
||||
"saveNote": "Hintergrundläufe und Trigger speichern nie eine Notiz. Ein gespeicherter Agent darf mehr fragen, aber nie weniger als du hier gewählt hast.",
|
||||
"messaging": {
|
||||
"title": "In Messaging-Apps",
|
||||
"lede": "Was passiert, wenn in einem Chat einer Messaging-App eine Notiz vorgeschlagen wird, wo der Absender nicht du sein muss.",
|
||||
"ask": {
|
||||
"title": "Erst fragen",
|
||||
"body": "Der Bot fragt im Chat: „Antworte mit Ja zum Speichern.“ Ein gelisteter Absender kann dort antworten. Andere warten auf dich in der App."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "Für einen gekoppelten Absender im privaten Chat speichern",
|
||||
"body": "Wird nur sofort gespeichert, wenn der Absender gekoppelt ist und der Chat ein Einzelchat ist. Die Notiz geht in diese Unterhaltung, mit Rückgängig. Eine Notiz, die wie ein Geheimnis aussieht, wartet weiter."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nie",
|
||||
"body": "Aus Messaging-Apps werden keine Notizen gespeichert."
|
||||
},
|
||||
"limit": "Nie lockerer als die Einstellung oben.",
|
||||
"lockedAlways": "Deine Einstellung oben ist Immer bestätigen, daher kann diese Zeile keine Notiz sofort speichern.",
|
||||
"lockedNever": "Deine Einstellung oben ist Nie, daher wird aus Messaging-Apps keine Notiz gespeichert."
|
||||
},
|
||||
"saveFailed": "Deine Auswahl konnte nicht gespeichert werden.",
|
||||
"loadError": "Die Gedächtnis-Einstellungen konnten nicht geladen werden.",
|
||||
"notesTitle": "Notizen verwalten",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "Notizen ebenfalls löschen",
|
||||
"body": "Sie wandern in den Papierkorb und lassen sich über Dateien wiederherstellen."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "Wenn er eine Notiz speichern will",
|
||||
"use_mine": {
|
||||
"title": "Meine Einstellung",
|
||||
"body": "Aktuell: {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "Immer bestätigen",
|
||||
"body": "Strenger als meine Einstellung."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nie",
|
||||
"body": "Liest, speichert nie."
|
||||
},
|
||||
"hint": "Ein Agent darf mehr fragen als deine Einstellung, aber nie weniger. „Speichern und Bescheid geben“ gibt es hier nicht. In einem Messaging-Chat geht eine Notiz zuerst in diese Unterhaltung und erreicht die eigenen Notizen dieses Agenten nur von einem gekoppelten Absender."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1211,13 +1211,36 @@
|
||||
"title": "Save it and tell me",
|
||||
"body": "In your own chats the note is saved at once. The chat shows \"Saved to memory\" with an Undo. Up to 5 notes a reply. A long note, or one that looks like a secret, still waits for you."
|
||||
},
|
||||
"always": {
|
||||
"title": "Always confirm",
|
||||
"body": "Like Ask me first, and nothing can loosen it. Undo and forget also wait for you. No agent or messaging setting can override it."
|
||||
},
|
||||
"never": {
|
||||
"title": "Never",
|
||||
"body": "The assistant cannot save notes. It tells you that saving is off."
|
||||
}
|
||||
},
|
||||
"default": "Default",
|
||||
"saveNote": "Chats from messaging apps always ask first. Background runs and triggers never save a note. A saved agent can ask more, never less, than you chose here.",
|
||||
"saveNote": "Background runs and triggers never save a note. A saved agent can ask more, never less, than you chose here.",
|
||||
"messaging": {
|
||||
"title": "In messaging apps",
|
||||
"lede": "What happens when a note is proposed in a chat from a messaging app, where the sender may not be you.",
|
||||
"ask": {
|
||||
"title": "Ask me first",
|
||||
"body": "The bot asks in the chat: \"Reply yes to save it.\" A listed sender can answer there. Others wait for you in the app."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "Save it for a paired sender in a private chat",
|
||||
"body": "Saved at once only when the sender is paired and the chat is one-to-one. The note goes to that conversation, with an Undo. A note that looks like a secret still waits."
|
||||
},
|
||||
"never": {
|
||||
"title": "Never",
|
||||
"body": "No notes are saved from messaging apps."
|
||||
},
|
||||
"limit": "Never looser than the setting above.",
|
||||
"lockedAlways": "Your setting above is Always confirm, so this row cannot save a note at once.",
|
||||
"lockedNever": "Your setting above is Never, so no note is saved from a messaging app."
|
||||
},
|
||||
"saveFailed": "Could not store your choice.",
|
||||
"loadError": "Could not load your memory settings.",
|
||||
"notesTitle": "Manage your notes",
|
||||
@@ -1292,6 +1315,22 @@
|
||||
"title": "Delete the notes too",
|
||||
"body": "They move to the Trash and you can restore them from Files."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "When it wants to save a note",
|
||||
"use_mine": {
|
||||
"title": "Use my setting",
|
||||
"body": "Currently: {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "Always confirm",
|
||||
"body": "Stricter than my setting."
|
||||
},
|
||||
"never": {
|
||||
"title": "Never",
|
||||
"body": "It reads, never saves."
|
||||
},
|
||||
"hint": "An agent can ask more than your setting, never less. \"Save it and tell me\" is not offered here. In a messaging chat a note goes to that conversation first, and reaches this agent's own notes only from a paired sender."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "Guardar y avisarme",
|
||||
"body": "En tus propios chats la nota se guarda al instante. El chat muestra «Guardado en la memoria» con Deshacer. Hasta 5 notas por respuesta. Una nota larga, o una que parece un secreto, sigue esperando tu confirmación."
|
||||
},
|
||||
"always": {
|
||||
"title": "Confirmar siempre",
|
||||
"body": "Como Preguntarme antes, y nada puede relajarlo. Deshacer y olvidar también esperan tu respuesta. Ningún ajuste de agente ni de mensajería puede anularlo."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nunca",
|
||||
"body": "El asistente no puede guardar notas. Te dice que guardar está desactivado."
|
||||
}
|
||||
},
|
||||
"default": "Predeterminado",
|
||||
"saveNote": "Los chats de apps de mensajería siempre preguntan antes. Las ejecuciones en segundo plano y los disparadores nunca guardan una nota. Un agente guardado puede preguntar más, nunca menos, de lo que elijas aquí.",
|
||||
"saveNote": "Las ejecuciones en segundo plano y los disparadores nunca guardan una nota. Un agente guardado puede preguntar más, nunca menos, de lo que elijas aquí.",
|
||||
"messaging": {
|
||||
"title": "En apps de mensajería",
|
||||
"lede": "Qué pasa cuando se propone una nota en un chat de una app de mensajería, donde quien escribe puede no ser tú.",
|
||||
"ask": {
|
||||
"title": "Preguntarme antes",
|
||||
"body": "El bot pregunta en el chat: «Responde sí para guardarla». Un remitente de la lista puede responder allí. Los demás esperan a que lo hagas en la app."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "Guardar para un remitente emparejado en un chat privado",
|
||||
"body": "Se guarda al instante solo si el remitente está emparejado y el chat es uno a uno. La nota va a esa conversación, con Deshacer. Una nota que parezca un secreto sigue esperando."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nunca",
|
||||
"body": "No se guardan notas desde apps de mensajería."
|
||||
},
|
||||
"limit": "Nunca más permisivo que el ajuste de arriba.",
|
||||
"lockedAlways": "Tu ajuste de arriba es Confirmar siempre, así que esta fila no puede guardar una nota al instante.",
|
||||
"lockedNever": "Tu ajuste de arriba es Nunca, así que no se guarda ninguna nota desde apps de mensajería."
|
||||
},
|
||||
"saveFailed": "No se pudo guardar tu elección.",
|
||||
"loadError": "No se pudieron cargar los ajustes de memoria.",
|
||||
"notesTitle": "Gestionar tus notas",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "Eliminar también las notas",
|
||||
"body": "Van a la papelera y puedes restaurarlas desde Archivos."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "Cuando quiere guardar una nota",
|
||||
"use_mine": {
|
||||
"title": "Usar mi ajuste",
|
||||
"body": "Ahora: {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "Confirmar siempre",
|
||||
"body": "Más estricto que mi ajuste."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nunca",
|
||||
"body": "Lee, nunca guarda."
|
||||
},
|
||||
"hint": "Un agente puede preguntar más que tu ajuste, nunca menos. «Guardar y avisarme» no se ofrece aquí. En un chat de mensajería la nota va primero a esa conversación y llega a las notas propias de este agente solo desde un remitente emparejado."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "Enregistrer et me prévenir",
|
||||
"body": "Dans vos propres conversations, la note est enregistrée tout de suite. La conversation affiche « Enregistré dans la mémoire » avec Annuler. Jusqu'à 5 notes par réponse. Une note longue, ou qui ressemble à un secret, attend quand même votre accord."
|
||||
},
|
||||
"always": {
|
||||
"title": "Toujours confirmer",
|
||||
"body": "Comme Me demander d'abord, et rien ne peut l'assouplir. Annuler et oublier attendent aussi votre réponse. Aucun réglage d'agent ou de messagerie ne peut le contourner."
|
||||
},
|
||||
"never": {
|
||||
"title": "Jamais",
|
||||
"body": "L'assistant ne peut pas enregistrer de notes. Il vous dit que l'enregistrement est désactivé."
|
||||
}
|
||||
},
|
||||
"default": "Par défaut",
|
||||
"saveNote": "Les conversations des applications de messagerie demandent toujours d'abord. Les exécutions en arrière-plan et les déclencheurs n'enregistrent jamais de note. Un agent enregistré peut demander plus, jamais moins, que ce que vous choisissez ici.",
|
||||
"saveNote": "Les exécutions en arrière-plan et les déclencheurs n'enregistrent jamais de note. Un agent enregistré peut demander plus, jamais moins, que ce que vous choisissez ici.",
|
||||
"messaging": {
|
||||
"title": "Dans les applications de messagerie",
|
||||
"lede": "Ce qui se passe quand une note est proposée dans une conversation d'une application de messagerie, où l'expéditeur n'est pas forcément vous.",
|
||||
"ask": {
|
||||
"title": "Me demander d'abord",
|
||||
"body": "Le bot demande dans la conversation : « Répondez oui pour l'enregistrer. » Un expéditeur de la liste peut y répondre. Les autres attendent votre réponse dans l'application."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "Enregistrer pour un expéditeur associé dans une conversation privée",
|
||||
"body": "Enregistrée tout de suite seulement si l'expéditeur est associé et la conversation en tête-à-tête. La note va dans cette conversation, avec Annuler. Une note qui ressemble à un secret attend toujours."
|
||||
},
|
||||
"never": {
|
||||
"title": "Jamais",
|
||||
"body": "Aucune note n'est enregistrée depuis les applications de messagerie."
|
||||
},
|
||||
"limit": "Jamais plus souple que le réglage ci-dessus.",
|
||||
"lockedAlways": "Votre réglage ci-dessus est Toujours confirmer : cette ligne ne peut pas enregistrer une note tout de suite.",
|
||||
"lockedNever": "Votre réglage ci-dessus est Jamais : aucune note n'est enregistrée depuis une application de messagerie."
|
||||
},
|
||||
"saveFailed": "Impossible d'enregistrer votre choix.",
|
||||
"loadError": "Impossible de charger vos réglages de mémoire.",
|
||||
"notesTitle": "Gérer vos notes",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "Supprimer aussi les notes",
|
||||
"body": "Elles vont dans la corbeille et vous pouvez les restaurer depuis Fichiers."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "Quand il veut enregistrer une note",
|
||||
"use_mine": {
|
||||
"title": "Utiliser mon réglage",
|
||||
"body": "Actuellement : {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "Toujours confirmer",
|
||||
"body": "Plus strict que mon réglage."
|
||||
},
|
||||
"never": {
|
||||
"title": "Jamais",
|
||||
"body": "Il lit, n'enregistre jamais."
|
||||
},
|
||||
"hint": "Un agent peut demander plus que votre réglage, jamais moins. « Enregistrer et me prévenir » n'est pas proposé ici. Dans une conversation de messagerie, la note va d'abord à cette conversation et n'atteint les notes propres de cet agent que depuis un expéditeur associé."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "Simpan dan beri tahu saya",
|
||||
"body": "Di obrolan Anda sendiri, catatan langsung disimpan. Obrolan menampilkan \"Disimpan ke memori\" dengan Urungkan. Hingga 5 catatan per balasan. Catatan yang panjang, atau yang tampak seperti rahasia, tetap menunggu Anda."
|
||||
},
|
||||
"always": {
|
||||
"title": "Selalu konfirmasi",
|
||||
"body": "Seperti Tanya saya dulu, dan tidak ada yang bisa melonggarkannya. Urungkan dan lupakan juga menunggu Anda. Tidak ada pengaturan agen atau aplikasi pesan yang bisa menggantinya."
|
||||
},
|
||||
"never": {
|
||||
"title": "Jangan pernah",
|
||||
"body": "Asisten tidak dapat menyimpan catatan. Ia akan memberi tahu bahwa penyimpanan nonaktif."
|
||||
}
|
||||
},
|
||||
"default": "Bawaan",
|
||||
"saveNote": "Obrolan dari aplikasi pesan selalu bertanya dulu. Proses latar belakang dan pemicu tidak pernah menyimpan catatan. Agen tersimpan boleh bertanya lebih banyak, tidak pernah lebih sedikit, daripada pilihan Anda di sini.",
|
||||
"saveNote": "Proses latar belakang dan pemicu tidak pernah menyimpan catatan. Agen tersimpan boleh bertanya lebih banyak, tidak pernah lebih sedikit, daripada pilihan Anda di sini.",
|
||||
"messaging": {
|
||||
"title": "Di aplikasi pesan",
|
||||
"lede": "Yang terjadi saat sebuah catatan diusulkan di obrolan aplikasi pesan, di mana pengirimnya belum tentu Anda.",
|
||||
"ask": {
|
||||
"title": "Tanya saya dulu",
|
||||
"body": "Bot bertanya di obrolan: \"Balas ya untuk menyimpan.\" Pengirim yang terdaftar bisa menjawab di sana. Yang lain menunggu Anda di aplikasi."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "Simpan untuk pengirim yang dipasangkan di obrolan pribadi",
|
||||
"body": "Langsung disimpan hanya jika pengirim dipasangkan dan obrolannya satu lawan satu. Catatan masuk ke percakapan itu, dengan Urungkan. Catatan yang mirip rahasia tetap menunggu."
|
||||
},
|
||||
"never": {
|
||||
"title": "Jangan pernah",
|
||||
"body": "Tidak ada catatan yang disimpan dari aplikasi pesan."
|
||||
},
|
||||
"limit": "Tidak pernah lebih longgar dari pengaturan di atas.",
|
||||
"lockedAlways": "Pengaturan Anda di atas adalah Selalu konfirmasi, jadi baris ini tidak bisa langsung menyimpan catatan.",
|
||||
"lockedNever": "Pengaturan Anda di atas adalah Jangan pernah, jadi tidak ada catatan yang disimpan dari aplikasi pesan."
|
||||
},
|
||||
"saveFailed": "Pilihan Anda tidak dapat disimpan.",
|
||||
"loadError": "Pengaturan memori Anda tidak dapat dimuat.",
|
||||
"notesTitle": "Kelola catatan Anda",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "Hapus juga catatannya",
|
||||
"body": "Dipindahkan ke Sampah dan dapat dipulihkan dari Berkas."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "Saat ingin menyimpan catatan",
|
||||
"use_mine": {
|
||||
"title": "Pakai pengaturan saya",
|
||||
"body": "Saat ini: {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "Selalu konfirmasi",
|
||||
"body": "Lebih ketat dari pengaturan saya."
|
||||
},
|
||||
"never": {
|
||||
"title": "Jangan pernah",
|
||||
"body": "Membaca, tidak pernah menyimpan."
|
||||
},
|
||||
"hint": "Agen boleh bertanya lebih banyak dari pengaturan Anda, tidak pernah lebih sedikit. \"Simpan dan beri tahu saya\" tidak ditawarkan di sini. Di obrolan aplikasi pesan, catatan masuk ke percakapan itu dulu, dan sampai ke catatan milik agen ini hanya dari pengirim yang dipasangkan."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "Salva e avvisami",
|
||||
"body": "Nelle tue chat la nota viene salvata subito. La chat mostra «Salvato in memoria» con Annulla. Fino a 5 note per risposta. Una nota lunga, o che sembra un segreto, aspetta comunque te."
|
||||
},
|
||||
"always": {
|
||||
"title": "Conferma sempre",
|
||||
"body": "Come Chiedimi prima, e nulla può allentarlo. Anche annulla e dimentica aspettano te. Nessuna impostazione di agente o di messaggistica può scavalcarlo."
|
||||
},
|
||||
"never": {
|
||||
"title": "Mai",
|
||||
"body": "L'assistente non può salvare note. Ti dice che il salvataggio è disattivato."
|
||||
}
|
||||
},
|
||||
"default": "Predefinito",
|
||||
"saveNote": "Le chat delle app di messaggistica chiedono sempre prima. Le esecuzioni in background e i trigger non salvano mai una nota. Un agente salvato può chiedere di più, mai di meno, di quanto scegli qui.",
|
||||
"saveNote": "Le esecuzioni in background e i trigger non salvano mai una nota. Un agente salvato può chiedere di più, mai di meno, di quanto scegli qui.",
|
||||
"messaging": {
|
||||
"title": "Nelle app di messaggistica",
|
||||
"lede": "Cosa succede quando una nota viene proposta in una chat di un'app di messaggistica, dove chi scrive può non essere tu.",
|
||||
"ask": {
|
||||
"title": "Chiedimi prima",
|
||||
"body": "Il bot chiede nella chat: «Rispondi sì per salvarla». Un mittente in elenco può rispondere lì. Gli altri aspettano te nell'app."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "Salva per un mittente abbinato in una chat privata",
|
||||
"body": "Salvata subito solo se il mittente è abbinato e la chat è uno a uno. La nota va in quella conversazione, con Annulla. Una nota che sembra un segreto aspetta comunque."
|
||||
},
|
||||
"never": {
|
||||
"title": "Mai",
|
||||
"body": "Nessuna nota viene salvata dalle app di messaggistica."
|
||||
},
|
||||
"limit": "Mai più permissivo dell'impostazione qui sopra.",
|
||||
"lockedAlways": "La tua impostazione qui sopra è Conferma sempre, quindi questa riga non può salvare una nota subito.",
|
||||
"lockedNever": "La tua impostazione qui sopra è Mai, quindi nessuna nota viene salvata dalle app di messaggistica."
|
||||
},
|
||||
"saveFailed": "Non è stato possibile salvare la tua scelta.",
|
||||
"loadError": "Non è stato possibile caricare le impostazioni della memoria.",
|
||||
"notesTitle": "Gestisci le tue note",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "Elimina anche le note",
|
||||
"body": "Vanno nel Cestino e puoi ripristinarle da File."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "Quando vuole salvare una nota",
|
||||
"use_mine": {
|
||||
"title": "Usa la mia impostazione",
|
||||
"body": "Ora: {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "Conferma sempre",
|
||||
"body": "Più rigido della mia impostazione."
|
||||
},
|
||||
"never": {
|
||||
"title": "Mai",
|
||||
"body": "Legge, non salva mai."
|
||||
},
|
||||
"hint": "Un agente può chiedere di più della tua impostazione, mai di meno. «Salva e avvisami» qui non è offerto. In una chat di messaggistica la nota va prima in quella conversazione e arriva alle note proprie di questo agente solo da un mittente abbinato."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "저장하고 알려주기",
|
||||
"body": "내 채팅에서는 메모가 바로 저장됩니다. 채팅에 \"메모리에 저장됨\"과 실행 취소가 표시됩니다. 답변당 최대 5개. 긴 메모나 비밀처럼 보이는 메모는 여전히 확인을 기다립니다."
|
||||
},
|
||||
"always": {
|
||||
"title": "항상 확인",
|
||||
"body": "먼저 물어보기와 같고, 어떤 것도 이를 완화할 수 없습니다. 실행 취소와 삭제도 내 확인을 기다립니다. 에이전트나 메시징 설정으로 바꿀 수 없습니다."
|
||||
},
|
||||
"never": {
|
||||
"title": "안 함",
|
||||
"body": "어시스턴트가 메모를 저장할 수 없습니다. 저장이 꺼져 있다고 알려줍니다."
|
||||
}
|
||||
},
|
||||
"default": "기본값",
|
||||
"saveNote": "메시징 앱의 채팅은 항상 먼저 묻습니다. 백그라운드 실행과 트리거는 메모를 저장하지 않습니다. 저장된 에이전트는 여기서 고른 것보다 더 물어볼 수는 있어도 덜 묻지는 않습니다.",
|
||||
"saveNote": "백그라운드 실행과 트리거는 메모를 저장하지 않습니다. 저장된 에이전트는 여기서 고른 것보다 더 물어볼 수는 있어도 덜 묻지는 않습니다.",
|
||||
"messaging": {
|
||||
"title": "메시징 앱에서",
|
||||
"lede": "보낸 사람이 내가 아닐 수 있는 메시징 앱 채팅에서 메모가 제안될 때의 동작입니다.",
|
||||
"ask": {
|
||||
"title": "먼저 물어보기",
|
||||
"body": "봇이 채팅에서 \"저장하려면 예라고 답하세요\"라고 묻습니다. 목록에 있는 보낸 사람은 거기서 답할 수 있습니다. 다른 사람은 앱에서 내 확인을 기다립니다."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "비공개 채팅에서 페어링된 보낸 사람의 메모는 저장",
|
||||
"body": "보낸 사람이 페어링되어 있고 일대일 채팅일 때만 바로 저장됩니다. 메모는 그 대화에 실행 취소와 함께 들어갑니다. 비밀처럼 보이는 메모는 계속 기다립니다."
|
||||
},
|
||||
"never": {
|
||||
"title": "안 함",
|
||||
"body": "메시징 앱에서는 메모를 저장하지 않습니다."
|
||||
},
|
||||
"limit": "위 설정보다 느슨해지지 않습니다.",
|
||||
"lockedAlways": "위 설정이 항상 확인이므로 이 항목은 메모를 바로 저장할 수 없습니다.",
|
||||
"lockedNever": "위 설정이 안 함이므로 메시징 앱에서는 메모가 저장되지 않습니다."
|
||||
},
|
||||
"saveFailed": "선택을 저장하지 못했습니다.",
|
||||
"loadError": "메모리 설정을 불러오지 못했습니다.",
|
||||
"notesTitle": "메모 관리",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "메모도 삭제",
|
||||
"body": "휴지통으로 이동하며 파일에서 복원할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "메모를 저장하려 할 때",
|
||||
"use_mine": {
|
||||
"title": "내 설정 사용",
|
||||
"body": "현재: {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "항상 확인",
|
||||
"body": "내 설정보다 엄격합니다."
|
||||
},
|
||||
"never": {
|
||||
"title": "안 함",
|
||||
"body": "읽기만 하고 저장하지 않습니다."
|
||||
},
|
||||
"hint": "에이전트는 내 설정보다 더 물어볼 수는 있어도 덜 묻지는 않습니다. \"저장하고 알려주기\"는 여기서 제공되지 않습니다. 메시징 채팅에서는 메모가 먼저 그 대화에 들어가고, 페어링된 보낸 사람일 때만 이 에이전트 자기 메모에 닿습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "Salvar e me avisar",
|
||||
"body": "Nos seus próprios chats a nota é salva na hora. O chat mostra \"Salvo na memória\" com Desfazer. Até 5 notas por resposta. Uma nota longa, ou que parece um segredo, ainda espera por você."
|
||||
},
|
||||
"always": {
|
||||
"title": "Sempre confirmar",
|
||||
"body": "Como Perguntar antes, e nada pode afrouxá-lo. Desfazer e esquecer também esperam por você. Nenhuma configuração de agente ou de mensagens pode sobrepô-lo."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nunca",
|
||||
"body": "O assistente não pode salvar notas. Ele diz que salvar está desativado."
|
||||
}
|
||||
},
|
||||
"default": "Padrão",
|
||||
"saveNote": "Chats de apps de mensagens sempre perguntam antes. Execuções em segundo plano e gatilhos nunca salvam uma nota. Um agente salvo pode perguntar mais, nunca menos, do que você escolheu aqui.",
|
||||
"saveNote": "Execuções em segundo plano e gatilhos nunca salvam uma nota. Um agente salvo pode perguntar mais, nunca menos, do que você escolheu aqui.",
|
||||
"messaging": {
|
||||
"title": "Em apps de mensagens",
|
||||
"lede": "O que acontece quando uma nota é proposta em um chat de app de mensagens, onde quem escreve pode não ser você.",
|
||||
"ask": {
|
||||
"title": "Perguntar antes",
|
||||
"body": "O bot pergunta no chat: \"Responda sim para salvar.\" Um remetente da lista pode responder lá. Os outros esperam por você no app."
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "Salvar para um remetente pareado em chat privado",
|
||||
"body": "Salva na hora só se o remetente estiver pareado e o chat for individual. A nota vai para essa conversa, com Desfazer. Uma nota que pareça um segredo continua esperando."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nunca",
|
||||
"body": "Nenhuma nota é salva a partir de apps de mensagens."
|
||||
},
|
||||
"limit": "Nunca mais frouxo que a configuração acima.",
|
||||
"lockedAlways": "Sua configuração acima é Sempre confirmar, então esta linha não pode salvar uma nota na hora.",
|
||||
"lockedNever": "Sua configuração acima é Nunca, então nenhuma nota é salva a partir de apps de mensagens."
|
||||
},
|
||||
"saveFailed": "Não foi possível salvar sua escolha.",
|
||||
"loadError": "Não foi possível carregar suas configurações de memória.",
|
||||
"notesTitle": "Gerenciar suas notas",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "Excluir as notas também",
|
||||
"body": "Vão para a Lixeira e você pode restaurá-las em Arquivos."
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "Quando quer salvar uma nota",
|
||||
"use_mine": {
|
||||
"title": "Usar minha configuração",
|
||||
"body": "Agora: {{level}}."
|
||||
},
|
||||
"always": {
|
||||
"title": "Sempre confirmar",
|
||||
"body": "Mais rígido que minha configuração."
|
||||
},
|
||||
"never": {
|
||||
"title": "Nunca",
|
||||
"body": "Lê, nunca salva."
|
||||
},
|
||||
"hint": "Um agente pode perguntar mais que a sua configuração, nunca menos. \"Salvar e me avisar\" não é oferecido aqui. Em um chat de mensagens, a nota vai primeiro para essa conversa e só chega às notas próprias deste agente vinda de um remetente pareado."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -889,13 +889,36 @@
|
||||
"title": "保存并告知我",
|
||||
"body": "在你自己的聊天中,笔记会立即保存。聊天会显示“已保存到记忆”和撤销。每次回复最多 5 条。过长或看起来像机密的笔记仍需你确认。"
|
||||
},
|
||||
"always": {
|
||||
"title": "始终确认",
|
||||
"body": "与“先问我”相同,且任何设置都不能放宽它。撤销和忘记也要等你确认。智能体或消息应用的设置都不能覆盖它。"
|
||||
},
|
||||
"never": {
|
||||
"title": "从不",
|
||||
"body": "助手不能保存笔记,并会告诉你保存已关闭。"
|
||||
}
|
||||
},
|
||||
"default": "默认",
|
||||
"saveNote": "来自消息应用的聊天总是先询问。后台运行和触发器从不保存笔记。已保存的智能体可以比这里的选择问得更多,但不会更少。",
|
||||
"saveNote": "后台运行和触发器从不保存笔记。已保存的智能体可以比这里的选择问得更多,但不会更少。",
|
||||
"messaging": {
|
||||
"title": "在消息应用中",
|
||||
"lede": "当消息应用的聊天中有人建议保存笔记、而发送者不一定是你时会发生什么。",
|
||||
"ask": {
|
||||
"title": "先问我",
|
||||
"body": "机器人会在聊天中询问:“回复“是”即可保存。”名单中的发送者可以在那里回答,其他人要等你在应用中确认。"
|
||||
},
|
||||
"paired_private": {
|
||||
"title": "在私聊中为已配对的发送者直接保存",
|
||||
"body": "只有发送者已配对且聊天是一对一时才会立即保存。笔记会存入该对话,并可撤销。看起来像机密的笔记仍然要等待。"
|
||||
},
|
||||
"never": {
|
||||
"title": "从不",
|
||||
"body": "不会从消息应用保存任何笔记。"
|
||||
},
|
||||
"limit": "永远不会比上面的设置更宽松。",
|
||||
"lockedAlways": "你上面的设置是“始终确认”,所以这一项不能立即保存笔记。",
|
||||
"lockedNever": "你上面的设置是“从不”,所以不会从消息应用保存任何笔记。"
|
||||
},
|
||||
"saveFailed": "无法保存你的选择。",
|
||||
"loadError": "无法加载你的记忆设置。",
|
||||
"notesTitle": "管理你的笔记",
|
||||
@@ -970,6 +993,22 @@
|
||||
"title": "同时删除笔记",
|
||||
"body": "笔记会移到回收站,可从“文件”中恢复。"
|
||||
}
|
||||
},
|
||||
"rule": {
|
||||
"title": "当它想保存笔记时",
|
||||
"use_mine": {
|
||||
"title": "使用我的设置",
|
||||
"body": "当前:{{level}}。"
|
||||
},
|
||||
"always": {
|
||||
"title": "始终确认",
|
||||
"body": "比我的设置更严格。"
|
||||
},
|
||||
"never": {
|
||||
"title": "从不",
|
||||
"body": "只读取,从不保存。"
|
||||
},
|
||||
"hint": "智能体可以比你的设置问得更多,但不会更少。这里不提供“保存并告知我”。在消息聊天中,笔记先存入该对话,只有已配对的发送者才能写入这个智能体自己的笔记。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,17 @@ import './memorysettings.css'
|
||||
// The ways a note is saved, in the order they are listed. The value is what the
|
||||
// server stores; the default is the one a person who never chose gets.
|
||||
export const SAVE_LEVELS = [
|
||||
{ value: 'ask', isDefault: true },
|
||||
{ value: 'auto' },
|
||||
{ value: 'ask', isDefault: true },
|
||||
{ value: 'always' },
|
||||
{ value: 'never' },
|
||||
]
|
||||
|
||||
// The rows for a note proposed in a messaging app. The row can never be looser
|
||||
// than the level above when that is Always confirm or Never.
|
||||
export const MESSAGING_LEVELS = [
|
||||
{ value: 'ask', isDefault: true },
|
||||
{ value: 'paired_private' },
|
||||
{ value: 'never' },
|
||||
]
|
||||
|
||||
@@ -25,6 +34,7 @@ export default function MemorySettings({ addToast, children }) {
|
||||
const titleId = useId()
|
||||
const ledeId = useId()
|
||||
const saveId = useId()
|
||||
const messagingId = useId()
|
||||
const notesId = useId()
|
||||
|
||||
const notes = (
|
||||
@@ -40,6 +50,8 @@ export default function MemorySettings({ addToast, children }) {
|
||||
if (!settings) return <div className="ms-page" data-testid="memory-settings"><SkeletonRows count={2} />{notes}</div>
|
||||
|
||||
const on = settings.enabled
|
||||
// Always confirm and Never hold in messaging apps too: the row cannot loosen them.
|
||||
const lockedBy = settings.save_mode === 'always' || settings.save_mode === 'never' ? settings.save_mode : ''
|
||||
const store = async (body) => {
|
||||
if (!(await change(body))) addToast?.(t('memorySettings.saveFailed'), 'error')
|
||||
}
|
||||
@@ -77,6 +89,27 @@ export default function MemorySettings({ addToast, children }) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<h4 className="ms-sub" id={messagingId}>{t('memorySettings.messaging.title')}</h4>
|
||||
<p className="ms-p">{t('memorySettings.messaging.lede')}</p>
|
||||
<div className="ms-levels" role="radiogroup" aria-labelledby={messagingId} data-testid="memory-messaging">
|
||||
{MESSAGING_LEVELS.map((lv) => {
|
||||
const checked = (settings.messaging_save || 'ask') === lv.value
|
||||
return (
|
||||
<label key={lv.value} className={`ms-level${checked ? ' ms-level--on' : ''}`} data-testid={`memory-messaging-${lv.value}`}>
|
||||
<input type="radio" name="memory-messaging-save" value={lv.value} checked={checked} disabled={saving} onChange={() => store({ messaging_save: lv.value })} />
|
||||
<span className="ms-level__text">
|
||||
<span className="ms-level__title">{t(`memorySettings.messaging.${lv.value}.title`)}{lv.isDefault && <span className="ms-tag">{t('memorySettings.default')}</span>}</span>
|
||||
<span className="ms-level__body">{t(`memorySettings.messaging.${lv.value}.body`)}</span>
|
||||
{lv.value === 'paired_private' && <span className="ms-level__fine">{t('memorySettings.messaging.limit')}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{lockedBy && (
|
||||
<p className="ms-p ms-p--note" data-testid="memory-messaging-locked" role="note"><Icon name="info" />{' '}{t(lockedBy === 'always' ? 'memorySettings.messaging.lockedAlways' : 'memorySettings.messaging.lockedNever')}</p>
|
||||
)}
|
||||
<p className="ms-p ms-p--note"><Icon name="info" />{' '}{t('memorySettings.saveNote')}</p>
|
||||
</section>
|
||||
{notes}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Icon from '../Icon'
|
||||
import { Callout } from '../ui'
|
||||
import { MEMORY_CHOICES, memorySaves } from '../../utils/chatAgents'
|
||||
import { MEMORY_CHOICES, MEMORY_SAVE_RULES, memorySaves } from '../../utils/chatAgents'
|
||||
import './agentmemory.css'
|
||||
|
||||
// The Memory section of the agent form: which notes the agent reads and where
|
||||
@@ -12,54 +12,78 @@ import './agentmemory.css'
|
||||
// none: it shows "As before" as a note, with the cards below to pick a clearer
|
||||
// choice. `userOff` is the person's own switch (read only here): when it is
|
||||
// off the choice has no effect, and the form says so.
|
||||
export default function AgentMemoryChoice({ value, onChange, saves, onSaves, userOff }) {
|
||||
export default function AgentMemoryChoice({ value, onChange, saves, onSaves, userOff, saveRule = '', onSaveRule, userLevel }) {
|
||||
const { t } = useTranslation('chats')
|
||||
const groupId = useId()
|
||||
const grantId = useId()
|
||||
const ruleId = useId()
|
||||
return (
|
||||
<div className="am" data-testid="agent-memory-choice">
|
||||
<p className="am__lede" id={groupId}>{t('agentMemory.lede')}</p>
|
||||
{userOff && (
|
||||
<Callout tone="info" icon="eye-off" testId="agent-memory-user-off">
|
||||
{t('agentMemory.offUser')}{' '}<Link className="cs-link" to="/app/account?tab=memory">{t('agentMemory.offUserFix')}</Link>
|
||||
</Callout>
|
||||
)}
|
||||
{!value && (
|
||||
<div className="am__before" data-testid="agent-memory-before" role="note">
|
||||
<Icon name="info" />
|
||||
<span>
|
||||
<b>{t('agentMemory.asBefore.title')}</b>{' '}{t('agentMemory.asBefore.body')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="am__cards" role="radiogroup" aria-labelledby={groupId}>
|
||||
{MEMORY_CHOICES.map((c) => {
|
||||
const checked = value === c
|
||||
return (
|
||||
<label key={c} className={`am__card${checked ? ' am__card--on' : ''}`} data-testid={`agent-memory-${c}`}>
|
||||
<input type="radio" name="agent-memory" value={c} checked={checked} onChange={() => onChange(c)} />
|
||||
<span className="am__text">
|
||||
<span className="am__title">
|
||||
{t(`agentMemory.${c}.title`)}
|
||||
{c === 'mine_and_own' && <span className="am__tag">{t('agentMemory.recommended')}</span>}
|
||||
<div className="am-wrap">
|
||||
<div className="am" data-testid="agent-memory-choice">
|
||||
<p className="am__lede" id={groupId}>{t('agentMemory.lede')}</p>
|
||||
{userOff && (
|
||||
<Callout tone="info" icon="eye-off" testId="agent-memory-user-off">
|
||||
{t('agentMemory.offUser')}{' '}<Link className="cs-link" to="/app/account?tab=memory">{t('agentMemory.offUserFix')}</Link>
|
||||
</Callout>
|
||||
)}
|
||||
{!value && (
|
||||
<div className="am__before" data-testid="agent-memory-before" role="note">
|
||||
<Icon name="info" />
|
||||
<span>
|
||||
<b>{t('agentMemory.asBefore.title')}</b>{' '}{t('agentMemory.asBefore.body')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="am__cards" role="radiogroup" aria-labelledby={groupId}>
|
||||
{MEMORY_CHOICES.map((c) => {
|
||||
const checked = value === c
|
||||
return (
|
||||
<label key={c} className={`am__card${checked ? ' am__card--on' : ''}`} data-testid={`agent-memory-${c}`}>
|
||||
<input type="radio" name="agent-memory" value={c} checked={checked} onChange={() => onChange(c)} />
|
||||
<span className="am__text">
|
||||
<span className="am__title">
|
||||
{t(`agentMemory.${c}.title`)}
|
||||
{c === 'mine_and_own' && <span className="am__tag">{t('agentMemory.recommended')}</span>}
|
||||
</span>
|
||||
<span className="am__body">{t(`agentMemory.${c}.body`)}</span>
|
||||
</span>
|
||||
<span className="am__body">{t(`agentMemory.${c}.body`)}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{value === 'mine' && (
|
||||
<p className="am__warn" role="note" data-testid="agent-memory-mine-warn"><Icon name="warning" />{' '}{t('agentMemory.mineWarn')}</p>
|
||||
)}
|
||||
{memorySaves(value) && (
|
||||
<label className="am__grant" htmlFor={grantId}>
|
||||
<input id={grantId} type="checkbox" checked={Boolean(saves)} onChange={(e) => onSaves(e.target.checked)} data-testid="agent-memory-saves" />
|
||||
<span className="am__text">
|
||||
<span className="am__title">{t('agentMemory.saveGrant')}</span>
|
||||
<span className="am__body">{t('agentMemory.saveGrantHelp')}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{value === 'mine' && (
|
||||
<p className="am__warn" role="note" data-testid="agent-memory-mine-warn"><Icon name="warning" />{' '}{t('agentMemory.mineWarn')}</p>
|
||||
)}
|
||||
{memorySaves(value) && (
|
||||
<label className="am__grant" htmlFor={grantId}>
|
||||
<input id={grantId} type="checkbox" checked={Boolean(saves)} onChange={(e) => onSaves(e.target.checked)} data-testid="agent-memory-saves" />
|
||||
<span className="am__text">
|
||||
<span className="am__title">{t('agentMemory.saveGrant')}</span>
|
||||
<span className="am__body">{t('agentMemory.saveGrantHelp')}</span>
|
||||
</span>
|
||||
</label>
|
||||
{value !== 'none' && onSaveRule && (
|
||||
<div className="am am__rule" data-testid="agent-memory-rule">
|
||||
<p className="am__rule-title" id={ruleId}>{t('agentMemory.rule.title')}</p>
|
||||
<div className="am__rule-cards" role="radiogroup" aria-labelledby={ruleId}>
|
||||
{['', ...MEMORY_SAVE_RULES].map((r) => {
|
||||
const checked = saveRule === r || (r === '' && saveRule === 'use_mine')
|
||||
const key = r || 'use_mine'
|
||||
return (
|
||||
<label key={key} className={`am__card am__card--small${checked ? ' am__card--on' : ''}`} data-testid={`agent-memory-rule-${key}`}>
|
||||
<input type="radio" name="agent-memory-rule" value={key} checked={checked} onChange={() => onSaveRule(r)} />
|
||||
<span className="am__text">
|
||||
<span className="am__title">{t(`agentMemory.rule.${key}.title`)}</span>
|
||||
<span className="am__body">{r ? t(`agentMemory.rule.${key}.body`) : t('agentMemory.rule.use_mine.body', { level: t(`memorySettings.save.${userLevel || 'ask'}.title`) })}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="am__note" data-testid="agent-memory-rule-hint"><Icon name="info" />{' '}{t('agentMemory.rule.hint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="am__note"><Icon name="info" />{' '}{t('agentMemory.copyNote')}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* The Memory section of the agent form, and the dialog that asks what to do with an agent's notes. */
|
||||
.am-wrap { display: grid; gap: var(--sp-3); max-width: 720px; }
|
||||
.am { display: grid; gap: var(--sp-3); max-width: 720px; }
|
||||
.am__lede { margin: 0; font-size: var(--text-sm); color: var(--dk-muted); max-width: 60ch; }
|
||||
.am__cards { display: grid; gap: var(--sp-2); }
|
||||
@@ -10,6 +11,13 @@
|
||||
.am__title { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); font-weight: 600; color: var(--dk-text); }
|
||||
.am__body { font-size: var(--text-sm); color: var(--dk-muted); overflow-wrap: anywhere; }
|
||||
.am__tag { padding: 0 6px; border-radius: var(--dk-radius-3); background: var(--dk-inset); color: var(--dk-muted); font-size: var(--text-xs); font-weight: 400; }
|
||||
.am__rule { display: grid; gap: var(--sp-2); }
|
||||
.am__rule-title { margin: 0; font-size: var(--text-sm); font-weight: 600; color: var(--dk-text); }
|
||||
.am__rule-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-2); }
|
||||
.am__card--small { padding: var(--sp-2) var(--sp-3); }
|
||||
@media (max-width: 560px) {
|
||||
.am__rule-cards { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.am__before, .am__warn, .am__note { display: flex; align-items: flex-start; gap: var(--sp-2); margin: 0; font-size: var(--text-sm); color: var(--dk-muted); }
|
||||
.am__before { padding: var(--sp-3); border-radius: var(--dk-radius-2); background: var(--dk-inset); color: var(--dk-text); }
|
||||
.am__warn { color: var(--dk-warning-text, var(--dk-text)); }
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
.ms-level__title { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); font-weight: 600; color: var(--dk-text); }
|
||||
.ms-level__body { font-size: var(--text-sm); color: var(--dk-muted); overflow-wrap: anywhere; }
|
||||
.ms-tag { padding: 0 6px; border-radius: var(--dk-radius-3); background: var(--dk-inset); color: var(--dk-muted); font-size: var(--text-xs); font-weight: 400; }
|
||||
.ms-sub { margin: var(--sp-3) 0 0; font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--dk-muted); }
|
||||
.ms-level__fine { font-size: var(--text-xs); color: var(--dk-muted); }
|
||||
.ms-notes { display: grid; gap: var(--sp-3); }
|
||||
.ms-notes-head { display: grid; gap: var(--sp-1); }
|
||||
@media (max-width: 560px) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAgentTools } from '../hooks/useAgentTools'
|
||||
import { useToolGroups } from '../hooks/useToolGroups'
|
||||
import { useMemorySettings } from '../hooks/useMemorySettings'
|
||||
import { AGENT_TEMPLATES, diffConfig, displayValue, maskSecrets, summarise } from '../utils/agentConfigTools'
|
||||
import { agentPayload, effectiveModes, emptyAgent, folderRows, folderState, foldersUnchosen, MAX_FOLDERS, MEMORY_GRANT, memoryChoiceOf, missingConnectorGrants, newAgent, setGrant, withMemoryChoice } from '../utils/chatAgents'
|
||||
import { agentPayload, effectiveModes, emptyAgent, folderRows, folderState, foldersUnchosen, MAX_FOLDERS, MEMORY_GRANT, memoryChoiceOf, memorySaveOf, missingConnectorGrants, newAgent, setGrant, withMemoryChoice } from '../utils/chatAgents'
|
||||
import { downloadAgentFile } from '../utils/agentFiles'
|
||||
import { joinAgentMCP, splitAgentMCP } from '../utils/chatMCP'
|
||||
import './agents.css'
|
||||
@@ -109,10 +109,10 @@ export default function AgentCreate() {
|
||||
let initialForm = newAgent()
|
||||
if (isEdit) {
|
||||
const agent = await chatAgentsApi.get(id)
|
||||
initialForm = { ...emptyAgent(), ...agent, memory: memoryChoiceOf(agent), folders: folderRows(agent) }
|
||||
initialForm = { ...emptyAgent(), ...agent, memory: memoryChoiceOf(agent), memory_save: memorySaveOf(agent), folders: folderRows(agent) }
|
||||
setSavedConfig(agent)
|
||||
} else if (imported) {
|
||||
initialForm = { ...emptyAgent(), ...imported.agent, memory: memoryChoiceOf(imported.agent), folders: folderRows(imported.agent) }
|
||||
initialForm = { ...emptyAgent(), ...imported.agent, memory: memoryChoiceOf(imported.agent), memory_save: memorySaveOf(imported.agent), folders: folderRows(imported.agent) }
|
||||
} else if (templateId) {
|
||||
// A starting point fills the form; nothing is saved until the person saves.
|
||||
const tpl = AGENT_TEMPLATES.find(x => x.id === templateId)
|
||||
@@ -329,6 +329,9 @@ export default function AgentCreate() {
|
||||
saves={(form.builtins || []).includes(MEMORY_GRANT)}
|
||||
onSaves={(on) => updateField('builtins', setGrant(form.builtins, MEMORY_GRANT, on))}
|
||||
userOff={memoryUser.settings?.enabled === false}
|
||||
saveRule={form.memory_save || ''}
|
||||
onSaveRule={(r) => updateField('memory_save', r || 'use_mine')}
|
||||
userLevel={memoryUser.settings?.save_mode}
|
||||
/>
|
||||
)
|
||||
case 'MemorySettings':
|
||||
|
||||
+20
@@ -42,6 +42,23 @@ export function memoryChoiceOf(agent) {
|
||||
// Whether a choice saves notes somewhere (None does not).
|
||||
export const memorySaves = (choice) => choice === 'own' || choice === 'mine_and_own' || choice === 'mine'
|
||||
|
||||
// The agent's own rule for saving a note, stricter than the person's setting:
|
||||
// 'always' (every note waits, nothing loosens it) or 'never'. An agent without
|
||||
// one uses the person's setting. 'auto' is not offered: an agent cannot be
|
||||
// looser than the person. The form holds '' (or absent) for "use my setting";
|
||||
// the person's explicit pick of it is 'use_mine', which the server reads as
|
||||
// clear the rule. A write without the field keeps the stored rule.
|
||||
export const MEMORY_SAVE_RULES = ['always', 'never']
|
||||
export const memorySaveOf = (agent) => (MEMORY_SAVE_RULES.includes(agent?.memory_save) ? agent.memory_save : '')
|
||||
|
||||
// The levels of the person's setting, strictest last, and what the agent's rule
|
||||
// does to them: the stricter of the two wins. Used to say what is in force.
|
||||
const LEVEL_RANK = { auto: 0, ask: 1, always: 2, never: 3 }
|
||||
export function stricterLevel(level, rule) {
|
||||
const base = level in LEVEL_RANK ? level : 'ask'
|
||||
return (LEVEL_RANK[rule] ?? -1) > LEVEL_RANK[base] ? rule : base
|
||||
}
|
||||
|
||||
// A brand new agent: it reads your notes and its own and saves to its own. It
|
||||
// is given the grant that lets it save, and, because a list of grants is the
|
||||
// whole answer once it exists, the four connector grants an agent without a
|
||||
@@ -91,6 +108,9 @@ export function agentPayload(form, opts) {
|
||||
// The choice of notes. An agent saved before it has none, and a write
|
||||
// without it keeps the stored one.
|
||||
if (MEMORY_CHOICES.includes(form.memory)) body.memory = form.memory
|
||||
// The save rule: always or never, or use_mine to clear a stored one. A form
|
||||
// that never touched it sends nothing, so the stored rule stays.
|
||||
if (MEMORY_SAVE_RULES.includes(form.memory_save) || form.memory_save === 'use_mine') body.memory_save = form.memory_save
|
||||
for (const k of LIST_KEYS) body[k] = (form[k] || []).map(x => x.trim()).filter((x, i, a) => x && a.indexOf(x) === i)
|
||||
// The connections the agent's chats may use for each add-on with a
|
||||
// credential (add-on ID to a list of binding IDs). The server keeps the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { agentPayload, CONNECTOR_GRANTS, emptyAgent, MEMORY_CHOICES, memoryChoiceOf, memorySaves, newAgent, withMemoryChoice } from '../../src/utils/chatAgents.js'
|
||||
import { agentPayload, CONNECTOR_GRANTS, emptyAgent, MEMORY_CHOICES, MEMORY_SAVE_RULES, memoryChoiceOf, memorySaveOf, memorySaves, newAgent, stricterLevel, withMemoryChoice } from '../../src/utils/chatAgents.js'
|
||||
import { agentNoteCount, agentNotes, noteTabs } from '../../src/utils/memoryFiles.js'
|
||||
|
||||
test('a new agent starts with Mine and its own, the grant to save, and the connector defaults', () => {
|
||||
@@ -34,6 +34,33 @@ test('a choice that saves switches the grant on, and none leaves the grants alon
|
||||
assert.deepEqual([...MEMORY_CHOICES.filter(memorySaves)], ['mine_and_own', 'own', 'mine'])
|
||||
})
|
||||
|
||||
test('the save rule of an agent is always or never, and auto is not offered', () => {
|
||||
assert.deepEqual(MEMORY_SAVE_RULES, ['always', 'never'])
|
||||
assert.equal(memorySaveOf({ memory_save: 'always' }), 'always')
|
||||
assert.equal(memorySaveOf({ memory_save: 'never' }), 'never')
|
||||
assert.equal(memorySaveOf({}), '', 'an agent without a rule uses my setting')
|
||||
assert.equal(memorySaveOf({ memory_save: 'auto' }), '', 'an agent cannot be looser')
|
||||
assert.equal(memorySaveOf({ memory_save: 'ask' }), '')
|
||||
})
|
||||
|
||||
test('the payload sends a save rule only when the form holds one, and use_mine clears it', () => {
|
||||
assert.equal('memory_save' in agentPayload(emptyAgent()), false, 'an older form leaves the stored rule alone')
|
||||
assert.equal(agentPayload({ ...emptyAgent(), memory_save: 'always' }).memory_save, 'always')
|
||||
assert.equal(agentPayload({ ...emptyAgent(), memory_save: 'never' }).memory_save, 'never')
|
||||
assert.equal(agentPayload({ ...emptyAgent(), memory_save: 'use_mine' }).memory_save, 'use_mine')
|
||||
assert.equal('memory_save' in agentPayload({ ...emptyAgent(), memory_save: 'auto' }), false, 'auto is never sent')
|
||||
assert.equal('memory_save' in agentPayload({ ...emptyAgent(), memory_save: '' }), false)
|
||||
})
|
||||
|
||||
test('the stricter of the setting and the rule is the level in force', () => {
|
||||
assert.equal(stricterLevel('auto', 'always'), 'always')
|
||||
assert.equal(stricterLevel('ask', ''), 'ask')
|
||||
assert.equal(stricterLevel('auto', ''), 'auto')
|
||||
assert.equal(stricterLevel('always', 'never'), 'never')
|
||||
assert.equal(stricterLevel('never', 'always'), 'never', 'a rule never loosens')
|
||||
assert.equal(stricterLevel('bogus', ''), 'ask', 'an unknown level counts as ask')
|
||||
})
|
||||
|
||||
const g = (o) => ({ source: 'memory', files: [{ path: `${o.key || 'u'}/index.md` }, { path: `${o.key || 'u'}/b.md` }], ...o })
|
||||
|
||||
test('the notes list has a tab for yours, each agent, the deleted agents and the conversations', () => {
|
||||
|
||||
@@ -73,4 +73,67 @@ var _ = Describe("Chats agents: the memory choice", func() {
|
||||
Expect(s.do("alice", http.MethodGet, "/api/chats/agents/"+bid, nil).Code).To(Equal(http.StatusNotFound))
|
||||
}
|
||||
})
|
||||
|
||||
It("stores the save rule, keeps it when a write leaves it out, clears it with use_mine, and refuses auto", func() {
|
||||
a := create(map[string]any{"name": "Strict", "memory": "own", "memory_save": "always"})
|
||||
Expect(a).To(HaveKeyWithValue("memory_save", "always"))
|
||||
id := a["id"].(string)
|
||||
put := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Strict 2"})
|
||||
Expect(put.Code).To(Equal(http.StatusOK), put.Body.String())
|
||||
Expect(decodeJSON(put)).To(HaveKeyWithValue("memory_save", "always"), "a client that does not know the field cannot loosen the agent")
|
||||
put = s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Strict 2", "memory_save": "never"})
|
||||
Expect(decodeJSON(put)).To(HaveKeyWithValue("memory_save", "never"))
|
||||
for _, bad := range []string{"auto", "ask", "sometimes"} {
|
||||
rec := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Strict 2", "memory_save": bad})
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest), bad)
|
||||
Expect(rec.Body.String()).To(ContainSubstring("memory_save"))
|
||||
}
|
||||
put = s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Strict 2", "memory_save": "use_mine"})
|
||||
Expect(put.Code).To(Equal(http.StatusOK), put.Body.String())
|
||||
Expect(decodeJSON(put)).NotTo(HaveKey("memory_save"))
|
||||
})
|
||||
|
||||
It("shows no save rule for an agent that has none", func() {
|
||||
Expect(create(map[string]any{"name": "Plain"})).NotTo(HaveKey("memory_save"))
|
||||
})
|
||||
|
||||
It("exports the save rule and imports it into a new agent", func() {
|
||||
a := create(map[string]any{"name": "Source", "memory": "own", "memory_save": "never"})
|
||||
exp := s.do("alice", http.MethodGet, "/api/chats/agents/"+a["id"].(string)+"/export", nil)
|
||||
Expect(exp.Code).To(Equal(http.StatusOK))
|
||||
file := decodeJSON(exp)
|
||||
Expect(file["agent"]).To(HaveKeyWithValue("memory_save", "never"))
|
||||
imp := s.do("alice", http.MethodPost, "/api/chats/agents/import", file)
|
||||
Expect(imp.Code).To(Equal(http.StatusCreated), imp.Body.String())
|
||||
Expect(decodeJSON(imp)["agent"]).To(HaveKeyWithValue("memory_save", "never"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Chats memory settings: the levels and the messaging row", func() {
|
||||
var s *studioServer
|
||||
BeforeEach(func() { s = newStudioServer() })
|
||||
|
||||
It("reads ask and ask by default and stores Always confirm and the messaging row", func() {
|
||||
got := s.do("alice", http.MethodGet, "/api/chats/memory", nil)
|
||||
Expect(got.Code).To(Equal(http.StatusOK), got.Body.String())
|
||||
Expect(decodeJSON(got)).To(And(HaveKeyWithValue("save_mode", "ask"), HaveKeyWithValue("messaging_save", "ask")))
|
||||
put := s.do("alice", http.MethodPut, "/api/chats/memory", map[string]any{"save_mode": "always", "messaging_save": "paired_private"})
|
||||
if put.Code == http.StatusServiceUnavailable {
|
||||
Skip("this server keeps no per-person settings")
|
||||
}
|
||||
Expect(put.Code).To(Equal(http.StatusOK), put.Body.String())
|
||||
Expect(decodeJSON(put)).To(And(HaveKeyWithValue("save_mode", "always"), HaveKeyWithValue("messaging_save", "paired_private")))
|
||||
put = s.do("alice", http.MethodPut, "/api/chats/memory", map[string]any{"save_mode": "never"})
|
||||
Expect(decodeJSON(put)).To(And(HaveKeyWithValue("save_mode", "never"), HaveKeyWithValue("messaging_save", "paired_private")))
|
||||
})
|
||||
|
||||
It("refuses a value that is not one", func() {
|
||||
for _, body := range []map[string]any{{"save_mode": "sometimes"}, {"messaging_save": "everyone"}, {"messaging_save": "auto"}} {
|
||||
rec := s.do("alice", http.MethodPut, "/api/chats/memory", body)
|
||||
if rec.Code == http.StatusServiceUnavailable {
|
||||
Skip("this server keeps no per-person settings")
|
||||
}
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest), rec.Body.String())
|
||||
}
|
||||
})
|
||||
})
|
||||
+24
-3
@@ -67,6 +67,12 @@ type ChatsAgent struct {
|
||||
// before the choice existed: it reads notes when its add-ons name Memory
|
||||
// and the model picks the scope of a note.
|
||||
Memory string `json:"memory,omitempty" enums:"own,mine_and_own,mine,none" example:"mine_and_own"`
|
||||
// MemorySave is the agent's own rule for saving a note: always (every note
|
||||
// waits for the person, and the model cannot undo or forget by itself) or
|
||||
// never (it reads notes and saves none). Absent uses the person's setting
|
||||
// (Account > Memory). It can only be stricter than that setting: the host
|
||||
// takes the stricter of the two, and there is no value that saves at once.
|
||||
MemorySave string `json:"memory_save,omitempty" enums:"always,never" example:"always"`
|
||||
// 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
|
||||
@@ -153,6 +159,11 @@ type ChatsAgentWrite struct {
|
||||
// agent; the value must be own, mine_and_own, mine or none (400 otherwise).
|
||||
// A new agent written without it is "as before".
|
||||
Memory string `json:"memory,omitempty" enums:"own,mine_and_own,mine,none"`
|
||||
// MemorySave is the save rule of the agent (see ChatsAgent). A write
|
||||
// without it keeps the stored rule, so a client that does not know the
|
||||
// field cannot loosen the agent. use_mine clears the rule. auto and ask
|
||||
// are not accepted: an agent cannot be looser than the person (400).
|
||||
MemorySave string `json:"memory_save,omitempty" enums:"always,never,use_mine"`
|
||||
// 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.
|
||||
@@ -1958,8 +1969,16 @@ type ChatsMemorySettings struct {
|
||||
// EnabledSet is true once you chose. Before that memory is on, unless you
|
||||
// had disabled the Memory add-on to stop it.
|
||||
EnabledSet bool `json:"enabled_set" example:"false"`
|
||||
// SaveMode is how a note is saved: ask, auto or never.
|
||||
SaveMode string `json:"save_mode" example:"ask"`
|
||||
// SaveMode is how a note is saved: auto (save it and tell me), ask (ask me
|
||||
// first, the default), always (always confirm: like ask, and nothing can
|
||||
// loosen it; undo and forget by the model wait for you too) or never.
|
||||
SaveMode string `json:"save_mode" enums:"auto,ask,always,never" example:"ask"`
|
||||
// MessagingSave is how a note proposed in a chat of a messaging app is
|
||||
// saved: ask (the bot asks in the chat; the default), paired_private
|
||||
// (saved at once to the conversation when the sender is paired and the
|
||||
// chat is one-to-one) or never. It cannot loosen save_mode: with always or
|
||||
// never the row has no looser effect.
|
||||
MessagingSave string `json:"messaging_save" enums:"ask,paired_private,never" example:"ask"`
|
||||
// SaveModeSet is true once you chose in this setting.
|
||||
SaveModeSet bool `json:"save_mode_set" example:"false"`
|
||||
// Provider is what serves memory: builtin.
|
||||
@@ -1972,7 +1991,9 @@ type ChatsMemorySettings struct {
|
||||
// it is.
|
||||
type ChatsMemoryRequest struct {
|
||||
Enabled *bool `json:"enabled,omitempty" example:"false"`
|
||||
SaveMode *string `json:"save_mode,omitempty" example:"ask"`
|
||||
SaveMode *string `json:"save_mode,omitempty" enums:"auto,ask,always,never" example:"ask"`
|
||||
// MessagingSave is the messaging row (see ChatsMemorySettings).
|
||||
MessagingSave *string `json:"messaging_save,omitempty" enums:"ask,paired_private,never" example:"ask"`
|
||||
}
|
||||
|
||||
// ChatsToolFallback is one built-in tool whose replacing add-on cannot serve
|
||||
|
||||
@@ -24,7 +24,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", Memory: agents.MemoryMineAndOwn, BuiltinsOff: []string{"triggers", "web"}, BuiltinsOn: []string{"saved_agents"}, Builtins: []string{"memory.write", "skills.write"},
|
||||
Network: "registries", Memory: agents.MemoryMineAndOwn, MemorySave: agents.MemorySaveAlways, BuiltinsOff: []string{"triggers", "web"}, BuiltinsOn: []string{"saved_agents"}, Builtins: []string{"memory.write", "skills.write"},
|
||||
Connections: connsets.Sets{"tracker": {"b-work", "b-home"}}, Created: now, Updated: now,
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ func Run(newStore NewStore) {
|
||||
Expect(strings.Join(got.Skills, ",")).To(Equal("review"))
|
||||
Expect(got.Network).To(Equal("registries"))
|
||||
Expect(got.Memory).To(Equal("mine_and_own"))
|
||||
Expect(got.MemorySave).To(Equal("always"))
|
||||
Expect(got.BuiltinsOff).To(Equal([]string{"triggers", "web"}))
|
||||
Expect(got.BuiltinsOn).To(Equal([]string{"saved_agents"}))
|
||||
Expect(got.Builtins).To(Equal([]string{"memory.write", "skills.write"}))
|
||||
|
||||
@@ -35,6 +35,8 @@ type agentRow struct {
|
||||
// Memory came later: empty for the agents made before, which read as
|
||||
// "as before".
|
||||
Memory string `gorm:"size:16"`
|
||||
// MemorySave came later: empty uses the person's setting.
|
||||
MemorySave string `gorm:"size:16"`
|
||||
// FolderID and FolderMode came after the table existed: the defaults
|
||||
// fill the rows of agents made before, which have no folder, so
|
||||
// AutoMigrate is their migration.
|
||||
@@ -132,7 +134,7 @@ func listFromJSON(s string) ([]string, error) {
|
||||
|
||||
func (r agentRow) agent(folders []folderRow) (Agent, error) {
|
||||
a := Agent{ID: r.ID, Principal: r.Principal, Name: r.Name, Description: r.Description,
|
||||
Instructions: r.Instructions, Model: r.Model, Network: r.Network, Memory: r.Memory, FolderID: r.FolderID, FolderMode: r.FolderMode, Created: r.Created, Updated: r.Updated}
|
||||
Instructions: r.Instructions, Model: r.Model, Network: r.Network, Memory: r.Memory, MemorySave: r.MemorySave, FolderID: r.FolderID, FolderMode: r.FolderMode, Created: r.Created, Updated: r.Updated}
|
||||
for _, f := range folders {
|
||||
a.Folders = append(a.Folders, AgentFolder{Node: f.Node, Mode: f.Mode})
|
||||
}
|
||||
@@ -216,7 +218,7 @@ func (s *gormStore) Put(ctx context.Context, a Agent) error {
|
||||
row := agentRow{ID: a.ID, Principal: a.Principal, NameKey: NameKey(a.Name), Name: a.Name,
|
||||
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, Memory: a.Memory,
|
||||
Collections: listJSON(a.Collections), Skills: listJSON(a.Skills), Network: a.Network, Memory: a.Memory, MemorySave: a.MemorySave,
|
||||
FolderID: a.FolderID, FolderMode: a.FolderMode, BuiltinsOff: listJSON(a.BuiltinsOff), BuiltinsOn: listJSON(a.BuiltinsOn), Builtins: listJSON(a.Builtins),
|
||||
Connections: connectionsJSON(a.Connections),
|
||||
Created: a.Created, Updated: a.Updated}
|
||||
|
||||
@@ -41,6 +41,7 @@ var _ = Describe("the JSON agent store", func() {
|
||||
Expect(got.FolderID).To(BeZero())
|
||||
Expect(got.FolderMode).To(BeEmpty())
|
||||
Expect(got.Memory).To(BeEmpty(), "an agent stored before the memory choice reads as before")
|
||||
Expect(got.MemorySave).To(BeEmpty(), "an agent stored before the save override uses the setting of the person")
|
||||
})
|
||||
|
||||
It("survives a reopen", func() {
|
||||
|
||||
@@ -70,6 +70,11 @@ type Agent struct {
|
||||
// the choice existed: it reads the notes when its add-ons name Memory and
|
||||
// the model picks the scope of a note. The host enforces any other value.
|
||||
Memory string
|
||||
// MemorySave is the agent's own rule for saving a note: MemorySaveAlways
|
||||
// or MemorySaveNever. Empty uses the person's setting. It can only be
|
||||
// stricter than the person's setting, so there is no value that saves at
|
||||
// once: the host takes the stricter of the two.
|
||||
MemorySave string
|
||||
// Connections names the connections (connector binding IDs) the agent's
|
||||
// chats may use for each add-on whose tools run with a credential, by
|
||||
// add-on ID. The owner chooses them; a chat of the agent cannot change
|
||||
@@ -113,6 +118,23 @@ const (
|
||||
MemoryNone = "none"
|
||||
)
|
||||
|
||||
// The values of Agent.MemorySave. Empty uses the person's setting.
|
||||
const (
|
||||
// MemorySaveAlways: every note waits for the person, and the model cannot
|
||||
// undo or forget by itself.
|
||||
MemorySaveAlways = "always"
|
||||
// MemorySaveNever: the agent reads notes and never saves one.
|
||||
MemorySaveNever = "never"
|
||||
// MemorySaveUseMine is what a client writes to clear the override. It is
|
||||
// never stored: the stored value is empty.
|
||||
MemorySaveUseMine = "use_mine"
|
||||
)
|
||||
|
||||
// ValidMemorySave reports whether v is a value of Agent.MemorySave, or empty.
|
||||
func ValidMemorySave(v string) bool {
|
||||
return v == "" || v == MemorySaveAlways || v == MemorySaveNever
|
||||
}
|
||||
|
||||
// ValidMemory reports whether v is a choice of Agent.Memory, or empty.
|
||||
func ValidMemory(v string) bool {
|
||||
switch v {
|
||||
@@ -207,6 +229,9 @@ func Validate(a Agent) error {
|
||||
if !ValidMemory(a.Memory) {
|
||||
return bad("memory must be own, mine_and_own, mine or none")
|
||||
}
|
||||
if !ValidMemorySave(a.MemorySave) {
|
||||
return bad("memory_save must be always or never")
|
||||
}
|
||||
switch a.Network {
|
||||
case "", "off", "registries", "all":
|
||||
default:
|
||||
|
||||
@@ -20,6 +20,9 @@ var _ = Describe("agent validation", func() {
|
||||
for _, m := range []string{"", "own", "mine_and_own", "mine", "none"} {
|
||||
Expect(agents.Validate(agents.Agent{Name: "a", Memory: m})).To(Succeed(), m)
|
||||
}
|
||||
for _, m := range []string{"", "always", "never"} {
|
||||
Expect(agents.Validate(agents.Agent{Name: "a", MemorySave: m})).To(Succeed(), m)
|
||||
}
|
||||
for _, f := range []agents.Agent{
|
||||
{Name: "a"},
|
||||
{Name: "a", FolderID: 7, FolderMode: agents.FolderRead},
|
||||
@@ -33,19 +36,21 @@ var _ = Describe("agent validation", func() {
|
||||
many[i] = "x"
|
||||
}
|
||||
for name, a := range map[string]agents.Agent{
|
||||
"empty name": {Name: " "},
|
||||
"long name": {Name: strings.Repeat("n", agents.MaxNameRunes+1)},
|
||||
"long desc": {Name: "a", Description: strings.Repeat("d", agents.MaxDescription+1)},
|
||||
"instructions": {Name: "a", Instructions: strings.Repeat("i", agents.MaxInstructions+1)},
|
||||
"model": {Name: "a", Model: strings.Repeat("m", agents.MaxModelRunes+1)},
|
||||
"too many": {Name: "a", Skills: many},
|
||||
"bad addon": {Name: "a", Addons: []string{"Office Tools"}},
|
||||
"empty collection": {Name: "a", Collections: []string{" "}},
|
||||
"long entry": {Name: "a", MCPServers: []string{strings.Repeat("s", agents.MaxEntryRunes+1)}},
|
||||
"bad network": {Name: "a", Network: "everything"},
|
||||
"bad memory": {Name: "a", Memory: "everything"},
|
||||
"folder, no mode": {Name: "a", FolderID: 7},
|
||||
"bad folder mode": {Name: "a", FolderID: 7, FolderMode: "admin"},
|
||||
"empty name": {Name: " "},
|
||||
"long name": {Name: strings.Repeat("n", agents.MaxNameRunes+1)},
|
||||
"long desc": {Name: "a", Description: strings.Repeat("d", agents.MaxDescription+1)},
|
||||
"instructions": {Name: "a", Instructions: strings.Repeat("i", agents.MaxInstructions+1)},
|
||||
"model": {Name: "a", Model: strings.Repeat("m", agents.MaxModelRunes+1)},
|
||||
"too many": {Name: "a", Skills: many},
|
||||
"bad addon": {Name: "a", Addons: []string{"Office Tools"}},
|
||||
"empty collection": {Name: "a", Collections: []string{" "}},
|
||||
"long entry": {Name: "a", MCPServers: []string{strings.Repeat("s", agents.MaxEntryRunes+1)}},
|
||||
"bad network": {Name: "a", Network: "everything"},
|
||||
"bad memory": {Name: "a", Memory: "everything"},
|
||||
"auto is not offered": {Name: "a", MemorySave: "auto"},
|
||||
"ask is not offered": {Name: "a", MemorySave: "ask"},
|
||||
"folder, no mode": {Name: "a", FolderID: 7},
|
||||
"bad folder mode": {Name: "a", FolderID: 7, FolderMode: "admin"},
|
||||
} {
|
||||
Expect(agents.Validate(a)).To(MatchError(agents.ErrInvalid), name)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent
|
||||
// A write without the memory choice keeps the stored one: a client that
|
||||
// does not know the field cannot widen what the agent reads or saves.
|
||||
keepMemory := a.Memory == ""
|
||||
// The save rule of the agent is kept the same way, so an older client
|
||||
// cannot drop a stricter rule. A client that knows it writes use_mine to
|
||||
// clear it.
|
||||
keepMemorySave := a.MemorySave == ""
|
||||
if a.MemorySave == agents.MemorySaveUseMine {
|
||||
a.MemorySave = ""
|
||||
}
|
||||
grants, err := cleanGrants(a.Builtins, false)
|
||||
if err != nil {
|
||||
return agents.Agent{}, fmt.Errorf("%w: %w", agents.ErrInvalid, err)
|
||||
@@ -114,6 +121,9 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent
|
||||
if keepMemory {
|
||||
a.Memory = old.Memory
|
||||
}
|
||||
if keepMemorySave {
|
||||
a.MemorySave = old.MemorySave
|
||||
}
|
||||
}
|
||||
// The grant saved_agents is the opt-in group of the same name: the chats of
|
||||
// the agent start with it on.
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"regexp"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/agents"
|
||||
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
@@ -17,14 +19,24 @@ import (
|
||||
// ask the model proposes, the person confirms (default)
|
||||
// auto the host applies a note from the person's own chat at once, as a
|
||||
// change set the person can undo, and the chat shows a note with Undo
|
||||
// always like ask, and nothing can loosen it: no agent or messaging setting
|
||||
// overrides it, and the model cannot undo or forget by itself either
|
||||
// never the model gets no way to propose a memory note
|
||||
//
|
||||
// Strictness runs auto < ask < always < never. The level that applies to a
|
||||
// write is the strictest among the person's setting, the saved agent's own
|
||||
// rule (always or never, never auto), the messaging row (connector chats) and
|
||||
// the rules of the chat. effectiveSaveLevel is that one function.
|
||||
//
|
||||
// The setting only ever narrows or keeps the confirmation for anything that is
|
||||
// not the person's own chat. The rules, in one place:
|
||||
//
|
||||
// - A connector chat keeps ask whatever the setting says: the sender of a
|
||||
// message is not necessarily the person, and a stranger's message must
|
||||
// never write memory unconfirmed.
|
||||
// - A connector chat asks whatever the person's setting says, unless the
|
||||
// person chose "Save it for a paired sender in a private chat" in the
|
||||
// messaging row and the sender is paired and the chat is one-to-one. The
|
||||
// sender of a message is not necessarily the person, so a stranger's
|
||||
// message never writes memory unconfirmed. The row cannot loosen always
|
||||
// or never.
|
||||
// - An unattended run (a trigger or a job) has no propose tool at all.
|
||||
// - A saved agent cannot widen the choice of the person: the agent's grants
|
||||
// decide whether it may propose, the person's setting decides how the
|
||||
@@ -45,11 +57,29 @@ import (
|
||||
|
||||
// The values of the setting.
|
||||
const (
|
||||
SaveAsk = "ask"
|
||||
SaveAuto = "auto"
|
||||
SaveNever = "never"
|
||||
SaveAsk = "ask"
|
||||
SaveAuto = "auto"
|
||||
SaveAlways = "always"
|
||||
SaveNever = "never"
|
||||
)
|
||||
|
||||
// The values of the messaging row: how a note proposed in a messaging chat is
|
||||
// saved. The default is ask.
|
||||
const (
|
||||
// MessagingAsk: the bot asks in the chat and a listed sender answers there.
|
||||
MessagingAsk = "ask"
|
||||
// MessagingPairedPrivate: saved at once, to the conversation, when the
|
||||
// sender is paired and the chat is one-to-one.
|
||||
MessagingPairedPrivate = "paired_private"
|
||||
// MessagingNever: no memory note is saved from a messaging app.
|
||||
MessagingNever = "never"
|
||||
)
|
||||
|
||||
// ValidMessagingSave reports whether v is a value of the messaging row.
|
||||
func ValidMessagingSave(v string) bool {
|
||||
return v == MessagingAsk || v == MessagingPairedPrivate || v == MessagingNever
|
||||
}
|
||||
|
||||
// The limits of an automatic write. A write over a limit is not lost: it
|
||||
// becomes a normal proposal that the person confirms.
|
||||
const (
|
||||
@@ -59,19 +89,75 @@ const (
|
||||
MaxAutoWriteBytes = 4 << 10
|
||||
)
|
||||
|
||||
// EffectiveSaveMode is the mode that applies to a write, from the stored
|
||||
// choice of the person and the origin of the chat (contract.ChangeSource.Kind:
|
||||
// "" for a chat the person watches). An unknown stored value counts as ask.
|
||||
func EffectiveSaveMode(stored, origin string, subAgent bool) string {
|
||||
switch stored {
|
||||
case SaveNever:
|
||||
return SaveNever
|
||||
case SaveAuto:
|
||||
if origin == "" && !subAgent {
|
||||
return SaveAuto
|
||||
}
|
||||
// SaveInputs is everything that decides how a memory note is saved.
|
||||
type SaveInputs struct {
|
||||
// User is the person's own level (ask, auto, always or never). An unknown
|
||||
// value counts as ask.
|
||||
User string
|
||||
// Agent is the saved agent's rule: agents.MemorySaveAlways, agents.MemorySaveNever
|
||||
// or empty for the person's setting. An unknown value counts as always,
|
||||
// because it can only be a rule that was meant to be strict.
|
||||
Agent string
|
||||
// Messaging is the messaging row. It matters only for a connector chat. An
|
||||
// unknown value counts as ask.
|
||||
Messaging string
|
||||
// Origin is contract.ChangeSource.Kind: "" for a chat the person watches.
|
||||
Origin string
|
||||
SubAgent bool
|
||||
// Direct says the messaging chat is one-to-one. Paired says the sender of
|
||||
// the turn is paired with the bot. Both are false when unknown.
|
||||
Direct, Paired bool
|
||||
}
|
||||
|
||||
// effectiveSaveLevel is the level that applies to a memory write. It is the
|
||||
// strictest of every rule that has a say, and the only place that combines
|
||||
// them:
|
||||
//
|
||||
// - the person's level, and the agent's rule (always or never);
|
||||
// - a sub-agent and an unattended run are not the person: at least ask;
|
||||
// - a connector chat is at least ask, because the sender is not necessarily
|
||||
// the person, except when the messaging row is paired_private and the
|
||||
// sender is paired and the chat is one-to-one. The row never loosens the
|
||||
// person's always or never, and its never makes the answer never.
|
||||
func effectiveSaveLevel(in SaveInputs) string {
|
||||
level := levelRank(normalSaveMode(in.User))
|
||||
agent := 0
|
||||
switch in.Agent {
|
||||
case "":
|
||||
case agents.MemorySaveNever:
|
||||
agent = levelRank(SaveNever)
|
||||
default:
|
||||
agent = levelRank(SaveAlways)
|
||||
}
|
||||
return SaveAsk
|
||||
switch {
|
||||
case in.Origin == "":
|
||||
if in.SubAgent {
|
||||
level = max(level, levelRank(SaveAsk))
|
||||
}
|
||||
case in.Origin == session.OriginConnector:
|
||||
// Only always and never of the person's level hold in a messaging
|
||||
// chat; the row decides between ask, auto and never for the rest.
|
||||
floor := 0
|
||||
if level >= levelRank(SaveAlways) {
|
||||
floor = level
|
||||
}
|
||||
row := levelRank(SaveAsk)
|
||||
switch in.Messaging {
|
||||
case MessagingNever:
|
||||
row = levelRank(SaveNever)
|
||||
case MessagingPairedPrivate:
|
||||
if in.Direct && in.Paired && !in.SubAgent {
|
||||
row = levelRank(SaveAuto)
|
||||
}
|
||||
}
|
||||
level = max(floor, row)
|
||||
if in.SubAgent {
|
||||
level = max(level, levelRank(SaveAsk))
|
||||
}
|
||||
default:
|
||||
level = max(level, levelRank(SaveAsk))
|
||||
}
|
||||
return levelName(max(level, agent))
|
||||
}
|
||||
|
||||
// AddonUserSettingSet is the per-person settings one add-on declares, as the
|
||||
@@ -92,10 +178,12 @@ type AddonUserSettingsLister interface {
|
||||
AddonUserSettings(ctx context.Context, user string) ([]AddonUserSettingSet, error)
|
||||
}
|
||||
|
||||
// strictness orders the modes: the larger one wins when add-ons disagree.
|
||||
func strictness(mode string) int {
|
||||
// levelRank orders the levels: the larger one wins when rules disagree.
|
||||
func levelRank(mode string) int {
|
||||
switch mode {
|
||||
case SaveNever:
|
||||
return 3
|
||||
case SaveAlways:
|
||||
return 2
|
||||
case SaveAsk:
|
||||
return 1
|
||||
@@ -103,8 +191,17 @@ func strictness(mode string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func levelName(rank int) string {
|
||||
return [...]string{SaveAuto, SaveAsk, SaveAlways, SaveNever}[rank]
|
||||
}
|
||||
|
||||
// strictness is levelRank under the name the add-on code uses.
|
||||
func strictness(mode string) int { return levelRank(mode) }
|
||||
|
||||
// ValidSaveMode reports whether v is a value of the save level.
|
||||
func ValidSaveMode(v string) bool { return v == SaveAsk || v == SaveAuto || v == SaveNever }
|
||||
func ValidSaveMode(v string) bool {
|
||||
return v == SaveAsk || v == SaveAuto || v == SaveAlways || v == SaveNever
|
||||
}
|
||||
|
||||
// legacySaveKey is the setting of the Memory add-on, read when the person has
|
||||
// not chosen a level in the host's own setting.
|
||||
@@ -194,15 +291,45 @@ func (c *Changes) storedSaveMode(ctx context.Context, user string) string {
|
||||
|
||||
func normalSaveMode(m string) string {
|
||||
switch m {
|
||||
case SaveAuto, SaveNever:
|
||||
case SaveAuto, SaveAlways, SaveNever:
|
||||
return m
|
||||
}
|
||||
return SaveAsk
|
||||
}
|
||||
|
||||
// saveModeOf is the mode of a memory write made in the chat.
|
||||
// storedMessagingSave reads the messaging row of the person. Anything that
|
||||
// goes wrong reads as ask.
|
||||
func (c *Changes) storedMessagingSave(ctx context.Context, user string) string {
|
||||
if c.s.d.UserSetting == nil {
|
||||
return MessagingAsk
|
||||
}
|
||||
v, ok, err := c.s.d.UserSetting(ctx, principal(user), HostSettingsID, MemorySaveMessagingKey)
|
||||
switch {
|
||||
case err != nil:
|
||||
c.s.log.Warn("studio: the messaging save setting could not be read, asking for confirmation", "error", err)
|
||||
return MessagingAsk
|
||||
case ok && ValidMessagingSave(v):
|
||||
return v
|
||||
}
|
||||
return MessagingAsk
|
||||
}
|
||||
|
||||
// saveInputsOf gathers what decides how a note of the chat is saved. The
|
||||
// messaging facts come from the grant of the turn, read now, so a turn whose
|
||||
// grant is gone counts as a group chat and an unpaired sender.
|
||||
func (c *Changes) saveInputsOf(ctx context.Context, ch builtins.Chat) SaveInputs {
|
||||
in := SaveInputs{User: c.storedSaveMode(ctx, ch.Actor.Principal), Messaging: MessagingAsk,
|
||||
Agent: c.s.agentMemorySave(ctx, ch.Actor.Principal, ch.AgentID), Origin: ch.Origin, SubAgent: ch.SubAgent}
|
||||
if ch.Origin == session.OriginConnector {
|
||||
in.Messaging = c.storedMessagingSave(ctx, ch.Actor.Principal)
|
||||
in.Direct, in.Paired = c.s.messagingFacts(ch.Actor.ChatID)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// saveModeOf is the level of a memory write made in the chat.
|
||||
func (c *Changes) saveModeOf(ctx context.Context, ch builtins.Chat) string {
|
||||
return EffectiveSaveMode(c.storedSaveMode(ctx, ch.Actor.Principal), ch.Origin, ch.SubAgent)
|
||||
return effectiveSaveLevel(c.saveInputsOf(ctx, ch))
|
||||
}
|
||||
|
||||
// autoTurn counts the automatic writes of one turn.
|
||||
|
||||
@@ -27,14 +27,34 @@ type saveSetting struct {
|
||||
set bool
|
||||
err error
|
||||
read int
|
||||
// host holds the settings the host keeps itself (the owner "_host"), by key.
|
||||
host map[string]string
|
||||
hostErr error
|
||||
}
|
||||
|
||||
func (s *saveSetting) put(v string) { s.mu.Lock(); s.val, s.set = v, v != ""; s.mu.Unlock() }
|
||||
|
||||
// putHost stores a host setting: the save level or the messaging row.
|
||||
func (s *saveSetting) putHost(key, v string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.host == nil {
|
||||
s.host = map[string]string{}
|
||||
}
|
||||
s.host[key] = v
|
||||
}
|
||||
|
||||
func (s *saveSetting) get(_ context.Context, user, addon, key string) (string, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.read++
|
||||
if addon == studio.HostSettingsID {
|
||||
v, ok := s.host[key]
|
||||
if key == studio.MemorySaveKey {
|
||||
return v, ok, s.hostErr
|
||||
}
|
||||
return v, ok, nil
|
||||
}
|
||||
if addon != "notes-addon" || key != "save-mode" {
|
||||
return "", false, nil
|
||||
}
|
||||
@@ -72,31 +92,6 @@ func notes(n int, content func(i int) string) func(workspace.Actor, string) []st
|
||||
}
|
||||
}
|
||||
|
||||
var _ = DescribeTable("the mode that applies to a memory write",
|
||||
func(stored, origin string, sub bool, want string) {
|
||||
Expect(studio.EffectiveSaveMode(stored, origin, sub)).To(Equal(want))
|
||||
},
|
||||
// The person's own chat reads the setting as it is.
|
||||
Entry("chat, unset", "", "", false, studio.SaveAsk),
|
||||
Entry("chat, ask", "ask", "", false, studio.SaveAsk),
|
||||
Entry("chat, auto", "auto", "", false, studio.SaveAuto),
|
||||
Entry("chat, never", "never", "", false, studio.SaveNever),
|
||||
Entry("chat, a value nobody declared", "always", "", false, studio.SaveAsk),
|
||||
// A connector chat never saves unasked, and never means never.
|
||||
Entry("connector, unset", "", session.OriginConnector, false, studio.SaveAsk),
|
||||
Entry("connector, ask", "ask", session.OriginConnector, false, studio.SaveAsk),
|
||||
Entry("connector, auto", "auto", session.OriginConnector, false, studio.SaveAsk),
|
||||
Entry("connector, never", "never", session.OriginConnector, false, studio.SaveNever),
|
||||
// An unattended run has no tool; the rule still holds if one ever had.
|
||||
Entry("trigger, auto", "auto", session.OriginTrigger, false, studio.SaveAsk),
|
||||
Entry("trigger, never", "never", session.OriginTrigger, false, studio.SaveNever),
|
||||
Entry("job, auto", "auto", session.OriginJob, false, studio.SaveAsk),
|
||||
Entry("job, ask", "ask", session.OriginJob, false, studio.SaveAsk),
|
||||
// A sub-agent is not the person.
|
||||
Entry("sub-agent, auto", "auto", "", true, studio.SaveAsk),
|
||||
Entry("sub-agent, never", "never", "", true, studio.SaveNever),
|
||||
)
|
||||
|
||||
var _ = Describe("how a memory note is saved", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
||||
"github.com/mudler/LocalAI/core/services/studio/files"
|
||||
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
@@ -158,9 +159,11 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change
|
||||
p := contract.ChangeProposal{V: contract.ChangeProposalVersion, ID: uuid.NewString(), Owner: principal(in.User), Agent: in.Agent,
|
||||
Kind: kind.Name, Scope: contract.ChangeScope(in.Scope), Summary: cleanSummary(in.Summary), Content: in.Content, Source: in.Source,
|
||||
Status: contract.ProposalPending, Auto: in.Auto}
|
||||
if in.Auto && (in.Source.Kind != "" || kind.Name != contract.ChangeMemory) {
|
||||
if in.Auto && !c.autoAllowed(ctx, in, kind.Name) {
|
||||
// Defence in depth: the tool decides this too, but nothing but a memory
|
||||
// note from the person's own chat is ever applied unasked.
|
||||
// note from the person's own chat, or a note to the conversation of a
|
||||
// paired sender in a private chat that the person allowed, is ever
|
||||
// applied unasked.
|
||||
c.audit(ctx, proposals.ActionRefuse, p, 0, "automatic save is for a memory note from the person's own chat")
|
||||
return contract.ChangeProposal{}, fmt.Errorf("%w: only a memory note from your own chat can be saved at once", ErrBadChange)
|
||||
}
|
||||
@@ -541,3 +544,28 @@ func (c *Changes) kindsHeld(ch builtins.Chat) []contract.ChangeKind {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// autoAllowed says whether the host lets a proposal be applied unasked. It is
|
||||
// the second check after the tool: a note from the person's own chat unless
|
||||
// the level in force is always or never, and a note to the conversation of a
|
||||
// messaging chat only when the level in force is auto, which needs the row
|
||||
// paired_private, a paired sender and a one-to-one chat (see
|
||||
// effectiveSaveLevel). Anything else asks.
|
||||
func (c *Changes) autoAllowed(ctx context.Context, in ProposeInput, kind contract.ChangeKind) bool {
|
||||
if kind != contract.ChangeMemory {
|
||||
return false
|
||||
}
|
||||
switch in.Source.Kind {
|
||||
case "":
|
||||
level := effectiveSaveLevel(SaveInputs{User: c.storedSaveMode(ctx, in.User), Agent: c.s.agentMemorySave(ctx, in.User, in.Agent)})
|
||||
return level != SaveAlways && level != SaveNever
|
||||
case session.OriginConnector:
|
||||
if in.Scope != string(contract.ScopeChannel) {
|
||||
return false
|
||||
}
|
||||
direct, paired := c.s.messagingFacts(in.Source.Chat)
|
||||
return effectiveSaveLevel(SaveInputs{User: c.storedSaveMode(ctx, in.User), Agent: c.s.agentMemorySave(ctx, in.User, in.Agent),
|
||||
Messaging: c.storedMessagingSave(ctx, in.User), Origin: session.OriginConnector, Direct: direct, Paired: paired}) == SaveAuto
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -97,6 +97,13 @@ func proposeChangeDescriptionFor(memory []string) string {
|
||||
if memory == nil {
|
||||
return proposeChangeDescription
|
||||
}
|
||||
if memory[0] == string(contract.ScopeChannel) {
|
||||
// A messaging chat: the note goes to this conversation unless the
|
||||
// sender is paired and the agent's choice allows more.
|
||||
fixed := "scope is user (every chat of the person), agent (this agent only) or channel (this conversation only); the other kinds say which. " +
|
||||
"A memory note is saved to this conversation's memory (scope channel) unless the result says otherwise: leave scope out for a note. "
|
||||
return strings.Replace(proposeChangeDescription, scopeSentence, fixed, 1)
|
||||
}
|
||||
where := "the user's own memory (scope user)"
|
||||
if slices.Contains(memory, string(contract.ScopeAgent)) {
|
||||
where = "this agent's own memory (scope agent)"
|
||||
@@ -161,7 +168,7 @@ func (s *Service) registerChangeTools() error {
|
||||
}
|
||||
// An agent with a choice of notes saves to the scope the host
|
||||
// fixes; the model is not offered another.
|
||||
fixed := s.memoryWriteScopes(ctx, c)
|
||||
fixed := s.memoryChatScopes(ctx, c)
|
||||
schema, desc = proposeChangeSchemaFor(kinds, fixed), proposeChangeDescriptionFor(fixed)
|
||||
if !slices.Contains(kinds, contract.ChangeMemory) {
|
||||
schema, desc = proposeChangeSchema(kinds), proposeChangeDescription
|
||||
@@ -182,8 +189,11 @@ func (s *Service) registerChangeTools() error {
|
||||
|
||||
// memoryScopeWords says where a scope of notes is, for a refusal the model reads.
|
||||
func memoryScopeWords(scope string) string {
|
||||
if scope == string(contract.ScopeAgent) {
|
||||
switch scope {
|
||||
case string(contract.ScopeAgent):
|
||||
return "its own memory"
|
||||
case string(contract.ScopeChannel):
|
||||
return "this conversation's memory"
|
||||
}
|
||||
return "the user's memory"
|
||||
}
|
||||
@@ -240,12 +250,14 @@ func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.Raw
|
||||
// The host, not the model, decides where a note of an agent with a
|
||||
// choice of notes goes. Leaving the scope out takes the one the
|
||||
// choice allows; any other is refused.
|
||||
if fixed := h.s.memoryWriteScopes(ctx, c); fixed != nil {
|
||||
if fixed := h.s.memoryChatScopes(ctx, c); fixed != nil {
|
||||
switch {
|
||||
case len(fixed) == 0:
|
||||
return "", errors.New("memory is off here, so nothing was proposed or saved. Do not try again; say so in plain words if they ask.")
|
||||
case args.Scope == "":
|
||||
args.Scope = fixed[0]
|
||||
case c.Origin == session.OriginConnector && !slices.Contains(fixed, args.Scope) && args.Scope != string(contract.ScopeChannel):
|
||||
return "", fmt.Errorf("only a sender who is paired with this bot can add to %s, and this sender is not. Use scope channel (this conversation's memory), or leave scope out", memoryScopeWords(args.Scope))
|
||||
case !slices.Contains(fixed, args.Scope):
|
||||
return "", fmt.Errorf("this agent saves notes to %s only (scope %s). Use that scope, or leave scope out", memoryScopeWords(fixed[0]), fixed[0])
|
||||
}
|
||||
@@ -274,6 +286,12 @@ func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.Raw
|
||||
Scope: contract.ChangeScope(in.Scope), Source: in.Source}, 0, "the person turned memory notes off")
|
||||
return "", errors.New("the person turned off saving to their memory, so nothing was proposed or saved. Do not try again; say so in plain words if they ask.")
|
||||
case SaveAuto:
|
||||
if c.Origin == session.OriginConnector && args.Scope != string(contract.ScopeChannel) {
|
||||
// A messaging chat saves at once only to its own conversation.
|
||||
// Notes for the agent or the person wait for a confirmation.
|
||||
autoNote = " It was not saved at once because a note for the agent or the person needs a confirmation; a note for this conversation is saved at once."
|
||||
break
|
||||
}
|
||||
ok, why := autoFits(args.Content, turn)
|
||||
if ok {
|
||||
return h.saveNow(ctx, in, turn)
|
||||
@@ -336,6 +354,23 @@ func (h *Changes) revertTool(ctx context.Context, c builtins.Chat, raw json.RawM
|
||||
if err := h.allowRevert(ctx, c.Actor.Principal, c.AgentID, sourceOf(c)); err != nil {
|
||||
return "", changeErr(err)
|
||||
}
|
||||
// Always confirm: nothing is loosened, and undoing or forgetting is a
|
||||
// change like any other. The model cannot do it by itself; the person
|
||||
// can, with the Undo of the note or by saying undo in the messaging chat.
|
||||
// A change that is not a note is not covered, so a named id of another
|
||||
// kind still reverts.
|
||||
if h.saveModeOf(ctx, c) == SaveAlways {
|
||||
kind := ""
|
||||
if args.ID != "" {
|
||||
if q, gerr := h.Get(ctx, c.Actor.Principal, args.ID); gerr == nil {
|
||||
kind = string(q.Kind)
|
||||
}
|
||||
}
|
||||
if args.ID == "" || kind == "" || memoryKindOf(kind) {
|
||||
return "", errors.New("the person chose Always confirm for memory, so you cannot undo or forget a note yourself. " +
|
||||
"Tell them they can undo it themselves (the Undo on the note, or by sending undo in this chat), or ask them to confirm a new proposal that removes it. Do not try again.")
|
||||
}
|
||||
}
|
||||
var p contract.ChangeProposal
|
||||
var err error
|
||||
if args.ID != "" {
|
||||
|
||||
@@ -276,10 +276,16 @@ type turnGrant struct {
|
||||
// ones that are in the ceiling of the binding. The tools use it to tell
|
||||
// the person what the owner can change.
|
||||
Denied map[string]string
|
||||
// Direct says the chat of the turn is one-to-one (a direct message, a
|
||||
// private chat) and Paired that the sender is paired with the bot. Both
|
||||
// are false when unknown, and only both together let a note be saved at
|
||||
// once (see effectiveSaveLevel). With several senders they hold only when
|
||||
// they hold for each.
|
||||
Direct, Paired bool
|
||||
}
|
||||
|
||||
func (g turnGrant) merge(o turnGrant) turnGrant {
|
||||
out := turnGrant{Binding: g.Binding, Kind: g.Kind}
|
||||
out := turnGrant{Binding: g.Binding, Kind: g.Kind, Direct: g.Direct && o.Direct, Paired: g.Paired && o.Paired}
|
||||
out.Senders = unionOf(g.Senders, o.Senders)
|
||||
out.Roles = unionOf(g.Roles, o.Roles)
|
||||
if len(g.Denied)+len(o.Denied) > 0 {
|
||||
@@ -312,19 +318,20 @@ func unionOf(a, b []string) []string {
|
||||
}
|
||||
|
||||
func grantOf(w *workerctl.ChatGrant) turnGrant {
|
||||
return turnGrant{Binding: w.Binding, Kind: w.Kind, Senders: w.Senders, Roles: w.Roles, Caps: w.Caps, Denied: w.Denied}
|
||||
return turnGrant{Binding: w.Binding, Kind: w.Kind, Senders: w.Senders, Roles: w.Roles, Caps: w.Caps, Denied: w.Denied, Direct: w.Direct, Paired: w.Paired}
|
||||
}
|
||||
|
||||
func (g turnGrant) wire() *workerctl.ChatGrant {
|
||||
return &workerctl.ChatGrant{Binding: g.Binding, Kind: g.Kind, Senders: g.Senders, Roles: g.Roles, Caps: g.Caps, Denied: g.Denied}
|
||||
return &workerctl.ChatGrant{Binding: g.Binding, Kind: g.Kind, Senders: g.Senders, Roles: g.Roles, Caps: g.Caps, Denied: g.Denied, Direct: g.Direct, Paired: g.Paired}
|
||||
}
|
||||
|
||||
// grantFor computes what the sender of a message may do, for the whole closed
|
||||
// list. It records nothing: the tools record a decision when they use it.
|
||||
func (c *Connectors) grantFor(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage) turnGrant {
|
||||
kind := c.kindOf(ctx, b)
|
||||
g := turnGrant{Binding: b.ID, Kind: b.Kind, Senders: []string{m.SenderID}, Roles: slices.Clone(m.Roles)}
|
||||
now := c.o.Clock()
|
||||
g := turnGrant{Binding: b.ID, Kind: b.Kind, Senders: []string{m.SenderID}, Roles: slices.Clone(m.Roles),
|
||||
Direct: m.Direct, Paired: m.SenderID != "" && b.IsPaired(m.SenderID, now)}
|
||||
for _, name := range contract.ConnectorCapabilityNames() {
|
||||
d := Authorize(b, kind, c.subjectOf(b, m, true), name, now, c.offReason(ctx, b, name))
|
||||
switch {
|
||||
@@ -495,6 +502,17 @@ func (s *Service) turnAllow(chat string) (allow func(string) (bool, string), aud
|
||||
return allow, audit
|
||||
}
|
||||
|
||||
// messagingFacts says whether the chat of the running turn is one-to-one and
|
||||
// its sender paired. Without a grant (no turn of a sender runs) both are false:
|
||||
// an unknown chat counts as a group and an unknown sender as unpaired.
|
||||
func (s *Service) messagingFacts(chat string) (direct, paired bool) {
|
||||
g, ok := s.access.get(chat)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return g.Direct, g.Paired
|
||||
}
|
||||
|
||||
// AccessSubjectView is what one kind of sender may do on a binding.
|
||||
type AccessSubjectView struct {
|
||||
// ID is "listed" (a sender on the allowlist by ID or name), "anyone" (a
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/workerctl"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -132,6 +134,28 @@ var _ = Describe("the grant of a turn", func() {
|
||||
It("is empty when one sender may do nothing", func() {
|
||||
Expect(g("A", nil, "memory.write").merge(g("B", nil)).Caps).To(BeEmpty())
|
||||
})
|
||||
It("is a private chat with a paired sender only when every sender of the turn is", func() {
|
||||
both := turnGrant{Senders: []string{"A"}, Direct: true, Paired: true}
|
||||
Expect(both.merge(turnGrant{Senders: []string{"A"}, Direct: true, Paired: true})).To(And(HaveField("Direct", true), HaveField("Paired", true)))
|
||||
Expect(both.merge(turnGrant{Senders: []string{"B"}, Direct: true})).To(And(HaveField("Direct", true), HaveField("Paired", false)))
|
||||
Expect(both.merge(turnGrant{Senders: []string{"B"}, Paired: true})).To(And(HaveField("Direct", false), HaveField("Paired", true)))
|
||||
Expect(turnGrant{}.merge(both)).To(And(HaveField("Direct", false), HaveField("Paired", false)), "an unknown grant counts as a group and unpaired")
|
||||
})
|
||||
It("carries the chat kind and the pairing over the wire, and reads an older grant as a group", func() {
|
||||
held := turnGrant{Binding: "b1", Senders: []string{"A"}, Direct: true, Paired: true}
|
||||
back := grantOf(held.wire())
|
||||
Expect(back.Direct).To(BeTrue())
|
||||
Expect(back.Paired).To(BeTrue())
|
||||
raw, err := json.Marshal(held.wire())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(raw)).To(ContainSubstring(`"direct":true`))
|
||||
Expect(string(raw)).To(ContainSubstring(`"paired":true`))
|
||||
var old workerctl.ChatGrant
|
||||
Expect(json.Unmarshal([]byte(`{"binding":"b1","senders":["A"],"caps":["memory.write"]}`), &old)).To(Succeed())
|
||||
got := grantOf(&old)
|
||||
Expect(got.Direct).To(BeFalse())
|
||||
Expect(got.Paired).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
// The trust of a sender who is on the allowlist by name: every capability that
|
||||
|
||||
@@ -157,12 +157,30 @@ func (h *Changes) senderMayConfirm(ctx context.Context, c builtins.Chat, p contr
|
||||
return false
|
||||
}
|
||||
if p.Kind == contract.ChangeMemory {
|
||||
return true
|
||||
// A note for the agent or the person, and any note under Always
|
||||
// confirm, is confirmed by a paired sender only.
|
||||
_, paired := h.s.messagingFacts(c.Actor.ChatID)
|
||||
return paired || !h.pairedAnswerOnly(ctx, p)
|
||||
}
|
||||
return h.s.allowCap(ctx, CapabilityRequest{Capability: CapProposalsConfirm, Binding: c.OriginID, Owner: p.Owner, Agent: c.AgentID,
|
||||
Sender: p.Source.Sender, Kind: string(p.Kind), Sensitive: true})
|
||||
}
|
||||
|
||||
// pairedAnswerOnly says whether only a paired sender may confirm the memory
|
||||
// note p: a note that reaches the agent's own notes or the person's (not the
|
||||
// conversation's), or any note while the level in force is Always confirm.
|
||||
func (h *Changes) pairedAnswerOnly(ctx context.Context, p contract.ChangeProposal) bool {
|
||||
return p.Kind == contract.ChangeMemory && (p.Scope != contract.ScopeChannel || h.alwaysConfirm(ctx, p))
|
||||
}
|
||||
|
||||
// alwaysConfirm says whether the person's level or the agent's rule for the
|
||||
// proposal is Always confirm. The messaging row cannot loosen it.
|
||||
func (h *Changes) alwaysConfirm(ctx context.Context, p contract.ChangeProposal) bool {
|
||||
in := SaveInputs{User: h.storedSaveMode(ctx, string(p.Owner)), Agent: h.s.agentMemorySave(ctx, string(p.Owner), p.Agent),
|
||||
Messaging: MessagingAsk, Origin: session.OriginConnector}
|
||||
return effectiveSaveLevel(in) == SaveAlways
|
||||
}
|
||||
|
||||
// connectorProposed writes the proposal in the chat of the sender and words the
|
||||
// result for the model. The model is told what the host did, so it never sends
|
||||
// the person to Studio when they can answer where they are.
|
||||
@@ -219,6 +237,9 @@ func (c *Connectors) hereChanges(ctx context.Context, b connectors.Binding, m co
|
||||
// proposal. A kind that changes what the agent may do is sensitive: only a
|
||||
// paired sender passes.
|
||||
func (c *Connectors) mayAnswerChange(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, p contract.ChangeProposal) bool {
|
||||
if c.svc.changes.pairedAnswerOnly(ctx, p) && !b.IsPaired(m.SenderID, c.o.Clock()) {
|
||||
return false
|
||||
}
|
||||
return c.svc.allowCap(ctx, CapabilityRequest{Capability: CapProposalsConfirm, Binding: b.ID, Owner: b.Owner, Agent: b.AgentID,
|
||||
Sender: m.SenderID, Username: m.SenderUsername, Roles: m.Roles, Kind: string(p.Kind), Sensitive: p.Kind != contract.ChangeMemory})
|
||||
}
|
||||
@@ -239,7 +260,9 @@ func (c *Connectors) answerChange(ctx context.Context, b connectors.Binding, m c
|
||||
}
|
||||
// In a chat of two the other listed sender may answer; in a shared chat
|
||||
// only the sender whose message made the proposal.
|
||||
if !m.Direct && p.Source.Sender != "" && p.Source.Sender != m.SenderID {
|
||||
// Under Always confirm the answer must come from the paired sender who
|
||||
// asked, in a chat of two as well.
|
||||
if p.Source.Sender != "" && p.Source.Sender != m.SenderID && (!m.Direct || ch.alwaysConfirm(ctx, p)) {
|
||||
ch.audit(ctx, proposals.ActionRefuse, p, 0, "not the sender who asked; "+detail)
|
||||
return v.T("change.not_you"), true
|
||||
}
|
||||
@@ -281,7 +304,7 @@ func (c *Connectors) undoChange(ctx context.Context, b connectors.Binding, m con
|
||||
ch.audit(ctx, proposals.ActionRefuse, p, 0, "undo not allowed; by text from sender "+m.SenderID)
|
||||
return v.T("change.no_right"), true
|
||||
}
|
||||
if !m.Direct && p.Source.Sender != "" && p.Source.Sender != m.SenderID {
|
||||
if p.Source.Sender != "" && p.Source.Sender != m.SenderID && (!m.Direct || ch.alwaysConfirm(ctx, p)) {
|
||||
return v.T("change.not_you"), true
|
||||
}
|
||||
if _, err := ch.Revert(ctx, b.Owner, p.ID); err != nil {
|
||||
|
||||
@@ -69,9 +69,9 @@ var _ = Describe("change proposals in a connector chat", func() {
|
||||
}
|
||||
switch text {
|
||||
case "note":
|
||||
return []studiotest.ToolStep{{Tool: "propose_change", Args: proposeArgs("memory", "user", "index.md", "- standup at 09:30\n", "Standup time")}, {Say: "I asked."}}
|
||||
return []studiotest.ToolStep{{Tool: "propose_change", Args: proposeArgs("memory", "channel", "index.md", "- standup at 09:30\n", "Standup time")}, {Say: "I asked."}}
|
||||
case "note two":
|
||||
return []studiotest.ToolStep{{Tool: "propose_change", Args: proposeArgs("memory", "user", "food.md", "- likes tea @channel\n", "Drinks")}, {Say: "I asked."}}
|
||||
return []studiotest.ToolStep{{Tool: "propose_change", Args: proposeArgs("memory", "channel", "food.md", "- likes tea @channel\n", "Drinks")}, {Say: "I asked."}}
|
||||
case "profile", "skill", "page", "dashboard":
|
||||
return kind(text)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ var _ = Describe("change proposals in a connector chat", func() {
|
||||
It("is shown with what will be saved, confirmed by yes without a model turn, and undone by undo", func() {
|
||||
setup()
|
||||
out := say(r, listed, "note", 2)
|
||||
Expect(out[0]).To(ContainSubstring("I can save a note in the memory (every chat of the owner): Standup time"))
|
||||
Expect(out[0]).To(ContainSubstring("I can save a note in the memory (this conversation only): Standup time"))
|
||||
Expect(out[0]).To(ContainSubstring("+ - standup at 09:30"))
|
||||
Expect(out[0]).To(ContainSubstring(`Reply "yes" to save it or "no" to drop it`))
|
||||
p := only()
|
||||
|
||||
@@ -178,8 +178,11 @@ const (
|
||||
HostSettingsID = "_host"
|
||||
// MemoryEnabledKey holds MemoryOn or MemoryOff.
|
||||
MemoryEnabledKey = "memory"
|
||||
// MemorySaveKey holds the save level: ask, auto or never.
|
||||
// MemorySaveKey holds the save level: ask, auto, always or never.
|
||||
MemorySaveKey = "memory-save-mode"
|
||||
// MemorySaveMessagingKey holds the messaging row: ask, paired_private or
|
||||
// never.
|
||||
MemorySaveMessagingKey = "memory-save-messaging"
|
||||
)
|
||||
|
||||
// The values of MemoryEnabledKey.
|
||||
@@ -213,6 +216,21 @@ func (s *Service) agentMemory(ctx context.Context, user, agentID string) string
|
||||
return ag.Memory
|
||||
}
|
||||
|
||||
// agentMemorySave is the save rule of a saved agent (agents.MemorySaveAlways,
|
||||
// agents.MemorySaveNever), or empty for the person's setting and for a chat
|
||||
// without an agent. An agent that cannot be read fails closed: the rule is
|
||||
// always, because the stored rule may have been a stricter one.
|
||||
func (s *Service) agentMemorySave(ctx context.Context, user, agentID string) string {
|
||||
if agentID == "" || s.d.Agents == nil {
|
||||
return ""
|
||||
}
|
||||
ag, err := s.d.Agents.Get(ctx, user, agentID)
|
||||
if err != nil {
|
||||
return agents.MemorySaveAlways
|
||||
}
|
||||
return ag.MemorySave
|
||||
}
|
||||
|
||||
// memorySourcesFor narrows the index files of the notes to what a choice of
|
||||
// the agent reads. The notes of a messaging conversation stay with every
|
||||
// choice but None: they belong to the chat, and a conversation outside a
|
||||
@@ -254,6 +272,30 @@ func (s *Service) memoryWriteScopes(ctx context.Context, c builtins.Chat) []stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// memoryChatScopes are the scopes a chat may save a note to. A chat outside a
|
||||
// messaging app has what memoryWriteScopes says. A messaging chat saves to its
|
||||
// own conversation first, and may also save to the agent or the person only
|
||||
// when the sender is paired with the bot and the agent's choice allows that
|
||||
// scope (with no choice, "as before", the model may name either). An agent
|
||||
// with the choice None saves nowhere. It is never nil for a messaging chat.
|
||||
func (s *Service) memoryChatScopes(ctx context.Context, c builtins.Chat) []string {
|
||||
fixed := s.memoryWriteScopes(ctx, c)
|
||||
if c.Origin != session.OriginConnector {
|
||||
return fixed
|
||||
}
|
||||
if fixed != nil && len(fixed) == 0 {
|
||||
return fixed
|
||||
}
|
||||
out := []string{string(contract.ScopeChannel)}
|
||||
if _, paired := s.messagingFacts(c.Actor.ChatID); !paired {
|
||||
return out
|
||||
}
|
||||
if fixed == nil {
|
||||
return append(out, string(contract.ScopeUser), string(contract.ScopeAgent))
|
||||
}
|
||||
return append(out, fixed...)
|
||||
}
|
||||
|
||||
// memorySkillsOff adds the memory skills to the skills a chat has switched off
|
||||
// when the chat does not read the notes: a skill that says "read your notes"
|
||||
// has no use then, and the model must not look for them.
|
||||
|
||||
@@ -432,7 +432,7 @@ var _ = Describe("memory served by the host", func() {
|
||||
It("stores the switch and the save level with the person's settings", func() {
|
||||
m, err := svc.MemorySettings(ctx, "alice")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(m).To(Equal(MemorySettings{Enabled: true, SaveMode: SaveAsk, Provider: "builtin", Available: true}))
|
||||
Expect(m).To(Equal(MemorySettings{Enabled: true, SaveMode: SaveAsk, MessagingSave: MessagingAsk, Provider: "builtin", Available: true}))
|
||||
off, auto := false, SaveAuto
|
||||
m, err = svc.SetMemory(ctx, "alice", MemoryUpdate{Enabled: &off, SaveMode: &auto})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
@@ -440,9 +440,29 @@ var _ = Describe("memory served by the host", func() {
|
||||
Expect(m.EnabledSet).To(BeTrue())
|
||||
Expect(m.SaveMode).To(Equal(SaveAuto))
|
||||
Expect(m.SaveModeSet).To(BeTrue())
|
||||
bad := "always"
|
||||
bad := "everything"
|
||||
_, err = svc.SetMemory(ctx, "alice", MemoryUpdate{SaveMode: &bad})
|
||||
Expect(err).To(MatchError(ErrInvalidSettings))
|
||||
})
|
||||
|
||||
It("stores Always confirm and the messaging row, and refuses a value that is not one", func() {
|
||||
always, row := SaveAlways, MessagingPairedPrivate
|
||||
m, err := svc.SetMemory(ctx, "alice", MemoryUpdate{SaveMode: &always, MessagingSave: &row})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(m.SaveMode).To(Equal(SaveAlways))
|
||||
Expect(m.MessagingSave).To(Equal(MessagingPairedPrivate))
|
||||
// A field left out stays as it is.
|
||||
never := SaveNever
|
||||
m, err = svc.SetMemory(ctx, "alice", MemoryUpdate{SaveMode: &never})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(m.MessagingSave).To(Equal(MessagingPairedPrivate))
|
||||
for _, bad := range []string{"", "auto", "anyone", "paired"} {
|
||||
_, err = svc.SetMemory(ctx, "alice", MemoryUpdate{MessagingSave: &bad})
|
||||
Expect(err).To(MatchError(ErrInvalidSettings), bad)
|
||||
}
|
||||
m, err = svc.MemorySettings(ctx, "alice")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(m.MessagingSave).To(Equal(MessagingPairedPrivate), "a refused value changes nothing")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/agents"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
)
|
||||
|
||||
// The named cases say the rules in words; the sweep below checks every
|
||||
// combination against a second, plainer statement of the same rules.
|
||||
func TestEffectiveSaveLevelCases(t *testing.T) {
|
||||
conn := session.OriginConnector
|
||||
cases := []struct {
|
||||
name string
|
||||
in SaveInputs
|
||||
want string
|
||||
}{
|
||||
// The person's own chat reads the setting as it is.
|
||||
{"chat, unset", SaveInputs{}, SaveAsk},
|
||||
{"chat, ask", SaveInputs{User: SaveAsk}, SaveAsk},
|
||||
{"chat, auto", SaveInputs{User: SaveAuto}, SaveAuto},
|
||||
{"chat, always", SaveInputs{User: SaveAlways}, SaveAlways},
|
||||
{"chat, never", SaveInputs{User: SaveNever}, SaveNever},
|
||||
{"chat, a value nobody declared reads as ask", SaveInputs{User: "yes"}, SaveAsk},
|
||||
// The agent can only be stricter.
|
||||
{"agent always over auto", SaveInputs{User: SaveAuto, Agent: agents.MemorySaveAlways}, SaveAlways},
|
||||
{"agent always over ask", SaveInputs{User: SaveAsk, Agent: agents.MemorySaveAlways}, SaveAlways},
|
||||
{"agent never over auto", SaveInputs{User: SaveAuto, Agent: agents.MemorySaveNever}, SaveNever},
|
||||
{"agent always does not loosen never", SaveInputs{User: SaveNever, Agent: agents.MemorySaveAlways}, SaveNever},
|
||||
{"agent never over always", SaveInputs{User: SaveAlways, Agent: agents.MemorySaveNever}, SaveNever},
|
||||
{"an agent value that is not offered fails closed", SaveInputs{User: SaveAuto, Agent: SaveAuto}, SaveAlways},
|
||||
// A messaging chat asks unless the row and the facts say otherwise.
|
||||
{"connector, auto, row ask", SaveInputs{User: SaveAuto, Origin: conn}, SaveAsk},
|
||||
{"connector, ask, row ask", SaveInputs{User: SaveAsk, Origin: conn, Messaging: MessagingAsk}, SaveAsk},
|
||||
{"connector, ask, paired private chat", SaveInputs{User: SaveAsk, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveAuto},
|
||||
{"connector, auto, paired private chat", SaveInputs{User: SaveAuto, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveAuto},
|
||||
{"connector, paired but a group", SaveInputs{User: SaveAuto, Origin: conn, Messaging: MessagingPairedPrivate, Paired: true}, SaveAsk},
|
||||
{"connector, private but not paired", SaveInputs{User: SaveAuto, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true}, SaveAsk},
|
||||
{"connector, row never", SaveInputs{User: SaveAuto, Origin: conn, Messaging: MessagingNever, Direct: true, Paired: true}, SaveNever},
|
||||
{"connector, never", SaveInputs{User: SaveNever, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveNever},
|
||||
{"always is not loosened by the row", SaveInputs{User: SaveAlways, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveAlways},
|
||||
{"always, row ask", SaveInputs{User: SaveAlways, Origin: conn, Messaging: MessagingAsk}, SaveAlways},
|
||||
{"always, row never", SaveInputs{User: SaveAlways, Origin: conn, Messaging: MessagingNever}, SaveNever},
|
||||
{"agent always beats the paired private row", SaveInputs{User: SaveAuto, Agent: agents.MemorySaveAlways, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveAlways},
|
||||
{"agent never beats the paired private row", SaveInputs{User: SaveAuto, Agent: agents.MemorySaveNever, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveNever},
|
||||
{"an unknown row reads as ask", SaveInputs{User: SaveAuto, Origin: conn, Messaging: "anyone", Direct: true, Paired: true}, SaveAsk},
|
||||
// An unattended run has no tool; the rule still holds if one ever had.
|
||||
{"trigger, auto", SaveInputs{User: SaveAuto, Origin: session.OriginTrigger}, SaveAsk},
|
||||
{"trigger, auto, row paired private", SaveInputs{User: SaveAuto, Origin: session.OriginTrigger, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveAsk},
|
||||
{"job, always", SaveInputs{User: SaveAlways, Origin: session.OriginJob}, SaveAlways},
|
||||
{"job, never", SaveInputs{User: SaveNever, Origin: session.OriginJob}, SaveNever},
|
||||
// A sub-agent is not the person.
|
||||
{"sub-agent, auto", SaveInputs{User: SaveAuto, SubAgent: true}, SaveAsk},
|
||||
{"sub-agent, never", SaveInputs{User: SaveNever, SubAgent: true}, SaveNever},
|
||||
{"sub-agent on a messaging chat, paired private", SaveInputs{User: SaveAuto, SubAgent: true, Origin: conn, Messaging: MessagingPairedPrivate, Direct: true, Paired: true}, SaveAsk},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := effectiveSaveLevel(c.in); got != c.want {
|
||||
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// oracle restates the rules without the rank helpers: it lists every rule that
|
||||
// has a say and keeps the strictest, in the order never, always, ask, auto.
|
||||
func oracle(in SaveInputs) string {
|
||||
user := in.User
|
||||
if user != SaveAuto && user != SaveAlways && user != SaveNever {
|
||||
user = SaveAsk
|
||||
}
|
||||
var say []string
|
||||
switch in.Agent {
|
||||
case "":
|
||||
case agents.MemorySaveNever:
|
||||
say = append(say, SaveNever)
|
||||
default:
|
||||
say = append(say, SaveAlways)
|
||||
}
|
||||
switch in.Origin {
|
||||
case "":
|
||||
say = append(say, user)
|
||||
if in.SubAgent {
|
||||
say = append(say, SaveAsk)
|
||||
}
|
||||
case session.OriginConnector:
|
||||
if user == SaveAlways || user == SaveNever {
|
||||
say = append(say, user)
|
||||
}
|
||||
switch {
|
||||
case in.Messaging == MessagingNever:
|
||||
say = append(say, SaveNever)
|
||||
case in.Messaging == MessagingPairedPrivate && in.Direct && in.Paired && !in.SubAgent:
|
||||
// the one case that saves at once
|
||||
default:
|
||||
say = append(say, SaveAsk)
|
||||
}
|
||||
if in.SubAgent {
|
||||
say = append(say, SaveAsk)
|
||||
}
|
||||
default:
|
||||
say = append(say, user, SaveAsk)
|
||||
}
|
||||
for _, level := range []string{SaveNever, SaveAlways, SaveAsk} {
|
||||
for _, s := range say {
|
||||
if s == level {
|
||||
return level
|
||||
}
|
||||
}
|
||||
}
|
||||
return SaveAuto
|
||||
}
|
||||
|
||||
func TestEffectiveSaveLevelEveryCombination(t *testing.T) {
|
||||
n := 0
|
||||
for _, user := range []string{"", SaveAsk, SaveAuto, SaveAlways, SaveNever} {
|
||||
for _, agent := range []string{"", agents.MemorySaveAlways, agents.MemorySaveNever} {
|
||||
for _, row := range []string{"", MessagingAsk, MessagingPairedPrivate, MessagingNever} {
|
||||
for _, origin := range []string{"", session.OriginConnector, session.OriginTrigger, session.OriginJob} {
|
||||
for _, sub := range []bool{false, true} {
|
||||
for _, direct := range []bool{false, true} {
|
||||
for _, paired := range []bool{false, true} {
|
||||
in := SaveInputs{User: user, Agent: agent, Messaging: row, Origin: origin, SubAgent: sub, Direct: direct, Paired: paired}
|
||||
got, want := effectiveSaveLevel(in), oracle(in)
|
||||
n++
|
||||
if got != want {
|
||||
t.Errorf("%+v: got %s, want %s", in, got, want)
|
||||
}
|
||||
// Never looser than the person's own level when
|
||||
// that is always or never, whatever else is set.
|
||||
if user == SaveAlways && got == SaveAuto || user == SaveAlways && got == SaveAsk {
|
||||
t.Errorf("%+v: always was loosened to %s", in, got)
|
||||
}
|
||||
if user == SaveNever && got != SaveNever {
|
||||
t.Errorf("%+v: never was loosened to %s", in, got)
|
||||
}
|
||||
// Saving at once needs the person's own chat or the
|
||||
// whole paired private chat condition.
|
||||
if got == SaveAuto && origin != "" && !(origin == session.OriginConnector && direct && paired && row == MessagingPairedPrivate) {
|
||||
t.Errorf("%+v: saved at once without the conditions", in)
|
||||
}
|
||||
// An agent is never looser than the person.
|
||||
if agent != "" && levelRank(got) < levelRank(effectiveSaveLevel(SaveInputs{User: user, Messaging: row, Origin: origin, SubAgent: sub, Direct: direct, Paired: paired})) {
|
||||
t.Errorf("%+v: the agent loosened the level to %s", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != 5*3*4*4*2*2*2 {
|
||||
t.Fatal(fmt.Sprint("swept ", n))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLevelOrder(t *testing.T) {
|
||||
order := []string{SaveAuto, SaveAsk, SaveAlways, SaveNever}
|
||||
for i, a := range order {
|
||||
if levelRank(a) != i || levelName(i) != a {
|
||||
t.Errorf("%s has rank %d, want %d", a, levelRank(a), i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package studio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/telegram/transport"
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/agents"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/core/services/studio/studiotest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/tests/studiobot"
|
||||
)
|
||||
|
||||
// hostShared is a shared rig whose person has chosen the save level in the
|
||||
// host's own setting (no add-on declares one).
|
||||
func hostShared(st *saveSetting) *shared {
|
||||
sh := changeShared(grantAll)
|
||||
inner := sh.deps
|
||||
sh.deps = func(d *studio.Deps) {
|
||||
inner(d)
|
||||
d.UserSetting = st.get
|
||||
}
|
||||
return sh
|
||||
}
|
||||
|
||||
var _ = Describe("the confirmation levels of a memory note", func() {
|
||||
ctx := context.Background()
|
||||
|
||||
Describe("Always confirm in a chat of the person", func() {
|
||||
It("asks for each note, whatever an agent says, and the card never loosens", func() {
|
||||
st := &saveSetting{}
|
||||
st.putHost(studio.MemorySaveKey, studio.SaveAlways)
|
||||
r := hostShared(st).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
|
||||
id := create(r.svc, alice)
|
||||
sent(r.svc, alice, id, "remember")
|
||||
idle(r.svc, alice, id)
|
||||
p := onlyProposal(r)
|
||||
Expect(p.Status).To(Equal(contract.ProposalPending))
|
||||
Expect(p.Auto).To(BeFalse())
|
||||
_, ok := readHome(r, "Memory/index.md")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("keeps the model from undoing or forgetting a note, and the note stays", func() {
|
||||
st := &saveSetting{}
|
||||
st.putHost(studio.MemorySaveKey, studio.SaveAuto)
|
||||
r := hostShared(st).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
|
||||
id := create(r.svc, alice)
|
||||
sent(r.svc, alice, id, "remember")
|
||||
idle(r.svc, alice, id)
|
||||
Expect(onlyProposal(r).Status).To(Equal(contract.ProposalApplied))
|
||||
// The person now chooses Always confirm; the model cannot undo.
|
||||
st.putHost(studio.MemorySaveKey, studio.SaveAlways)
|
||||
sent(r.svc, alice, id, "forget")
|
||||
idle(r.svc, alice, id)
|
||||
res := r.h.ResultsFor("revert_change")
|
||||
Expect(res).To(HaveLen(1))
|
||||
Expect(res[0].Err).To(MatchError(ContainSubstring("Always confirm")))
|
||||
Expect(onlyProposal(r).Status).To(Equal(contract.ProposalApplied), "nothing was undone")
|
||||
_, ok := readHome(r, "Memory/index.md")
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
It("lets the model undo when the level is auto, as before", func() {
|
||||
st := &saveSetting{}
|
||||
st.putHost(studio.MemorySaveKey, studio.SaveAuto)
|
||||
r := hostShared(st).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
|
||||
id := create(r.svc, alice)
|
||||
sent(r.svc, alice, id, "remember")
|
||||
idle(r.svc, alice, id)
|
||||
sent(r.svc, alice, id, "forget")
|
||||
idle(r.svc, alice, id)
|
||||
Expect(r.h.ResultsFor("revert_change")[0].Err).To(BeNil())
|
||||
Expect(onlyProposal(r).Status).To(Equal(contract.ProposalReverted))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("the save rule of a saved agent", func() {
|
||||
run := func(user, rule string) (*trigRig, string) {
|
||||
st := &saveSetting{}
|
||||
if user != "" {
|
||||
st.putHost(studio.MemorySaveKey, user)
|
||||
}
|
||||
r := hostShared(st).rig(changeScript("agent", "- rule\n"), studio.TriggerOptions{})
|
||||
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Helper", Model: "m", Memory: agents.MemoryOwn, MemorySave: rule, Builtins: []string{"memory.write"}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
rec, err := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
sent(r.svc, alice, rec.ID, "remember")
|
||||
idle(r.svc, alice, rec.ID)
|
||||
return r, ag.ID
|
||||
}
|
||||
|
||||
It("saves at once when the person said auto and the agent has no rule", func() {
|
||||
r, _ := run(studio.SaveAuto, "")
|
||||
Expect(onlyProposal(r).Status).To(Equal(contract.ProposalApplied))
|
||||
})
|
||||
|
||||
It("always confirm: the agent asks although the person said auto", func() {
|
||||
r, agent := run(studio.SaveAuto, agents.MemorySaveAlways)
|
||||
p := onlyProposal(r)
|
||||
Expect(p.Status).To(Equal(contract.ProposalPending))
|
||||
Expect(p.Auto).To(BeFalse())
|
||||
Expect(p.Agent).To(Equal(agent))
|
||||
})
|
||||
|
||||
It("never: the agent reads and has no tool to save, although the person said auto", func() {
|
||||
r, _ := run(studio.SaveAuto, agents.MemorySaveNever)
|
||||
list, _ := r.svc.Changes().List(ctx, alice)
|
||||
Expect(list).To(BeEmpty())
|
||||
res := r.h.ResultsFor("propose_change")
|
||||
Expect(res).To(HaveLen(1))
|
||||
Expect(res[0].Err).To(MatchError(ContainSubstring("no tool named")))
|
||||
})
|
||||
|
||||
It("cannot be looser than the person: the person's never wins over always", func() {
|
||||
r, _ := run(studio.SaveNever, agents.MemorySaveAlways)
|
||||
list, _ := r.svc.Changes().List(ctx, alice)
|
||||
Expect(list).To(BeEmpty())
|
||||
Expect(r.h.ResultsFor("propose_change")[0].Err).To(MatchError(ContainSubstring("no tool named")))
|
||||
})
|
||||
|
||||
It("is kept by a write that leaves it out, cleared by use_mine, and refuses auto", func() {
|
||||
r := hostShared(&saveSetting{}).rig(changeScript("agent", "- x\n"), studio.TriggerOptions{})
|
||||
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Helper", Model: "m", MemorySave: agents.MemorySaveNever})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ag.MemorySave).To(Equal("never"))
|
||||
// An older client writes without the field: the rule stays.
|
||||
ag, err = r.svc.SaveAgent(ctx, alice, agents.Agent{ID: ag.ID, Name: "Helper", Model: "m", Instructions: "x"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ag.MemorySave).To(Equal("never"), "an older client cannot loosen the agent")
|
||||
_, err = r.svc.SaveAgent(ctx, alice, agents.Agent{ID: ag.ID, Name: "Helper", Model: "m", MemorySave: "auto"})
|
||||
Expect(err).To(MatchError(agents.ErrInvalid))
|
||||
ag, err = r.svc.SaveAgent(ctx, alice, agents.Agent{ID: ag.ID, Name: "Helper", Model: "m", MemorySave: agents.MemorySaveUseMine})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ag.MemorySave).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("when the setting cannot be read", func() {
|
||||
It("asks, whatever was stored", func() {
|
||||
st := &saveSetting{hostErr: fmt.Errorf("store down")}
|
||||
st.putHost(studio.MemorySaveKey, studio.SaveAuto)
|
||||
r := hostShared(st).rig(changeScript("user", "- home: Rome\n"), studio.TriggerOptions{})
|
||||
id := create(r.svc, alice)
|
||||
sent(r.svc, alice, id, "remember")
|
||||
idle(r.svc, alice, id)
|
||||
Expect(onlyProposal(r).Status).To(Equal(contract.ProposalPending))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("in a messaging app", func() {
|
||||
const (
|
||||
listed = "11" // on the allowlist, not paired
|
||||
paired = "12" // paired
|
||||
)
|
||||
var (
|
||||
st *saveSetting
|
||||
bot *scriptedBot
|
||||
r *connRig
|
||||
b connectors.Binding
|
||||
)
|
||||
// Every kind of change is allowed, so the model can undo the last one.
|
||||
caps := []string{contract.CapMemoryWrite, contract.CapProfileWrite, contract.CapSkillsWrite, contract.CapPagesWrite, contract.CapDashboardWrite, contract.CapProposalsConfirm}
|
||||
AfterEach(func() {
|
||||
if CurrentSpecReport().Failed() && r != nil {
|
||||
GinkgoWriter.Printf("ANSWERS %q\nRESULTS %+v\n", bot.answers(), r.h.ResultsFor("propose_change"))
|
||||
}
|
||||
})
|
||||
note := func(scope, name, content string) []studiotest.ToolStep {
|
||||
return []studiotest.ToolStep{{Tool: "propose_change", Args: proposeArgs("memory", scope, name, content, "A note")}, {Say: "ok"}}
|
||||
}
|
||||
setup := func(user, row string, agent agents.Agent) {
|
||||
st, bot = &saveSetting{}, newScriptedBot()
|
||||
if user != "" {
|
||||
st.putHost(studio.MemorySaveKey, user)
|
||||
}
|
||||
if row != "" {
|
||||
st.putHost(studio.MemorySaveMessagingKey, row)
|
||||
}
|
||||
addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()})
|
||||
addon.Kind.Permissions = caps
|
||||
script := func(_ workspace.Actor, text string) []studiotest.ToolStep {
|
||||
switch {
|
||||
case text == "remember":
|
||||
return note("", "index.md", "- standup at 09:30\n")
|
||||
case text == "remember channel":
|
||||
return note("channel", "index.md", "- standup at 09:30\n")
|
||||
case text == "remember user":
|
||||
return note("user", "index.md", "- standup at 09:30\n")
|
||||
case text == "remember agent":
|
||||
return note("agent", "index.md", "- standup at 09:30\n")
|
||||
case text == "remember secret":
|
||||
return note("channel", "index.md", "- password: hunter2hunter2\n")
|
||||
case text == "remember many":
|
||||
var steps []studiotest.ToolStep
|
||||
for i := range 6 {
|
||||
steps = append(steps, note("channel", fmt.Sprintf("n%d.md", i), fmt.Sprintf("- note %d\n", i))[0])
|
||||
}
|
||||
return append(steps, studiotest.ToolStep{Say: "ok"})
|
||||
case text == "forget":
|
||||
return []studiotest.ToolStep{{Tool: "revert_change", Args: `{}`}, {Say: "ok"}}
|
||||
}
|
||||
return []studiotest.ToolStep{{Say: "model"}}
|
||||
}
|
||||
sh := hostShared(st)
|
||||
r = newConnRigWith(sh, connStore(), studiobot.NewTransport(addon), script, nil)
|
||||
r.bot, r.untrusted = bot, true
|
||||
r.svc.SetCapabilityGate(r.conns.CapabilityGate())
|
||||
r.grantBuiltins(contract.CapMemoryWrite)
|
||||
if agent.Memory != "" || agent.MemorySave != "" {
|
||||
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{ID: r.agentID(), Name: "Support", Model: "m", Addons: []string{"office"},
|
||||
Memory: agent.Memory, MemorySave: agent.MemorySave, Builtins: []string{contract.CapMemoryWrite}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
r.agent = ag
|
||||
}
|
||||
b = r.bind(studio.ConnectorInput{Allow: []string{listed, paired, "-100"}, Caps: caps})
|
||||
got, err := r.store.GetBinding(ctx, b.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
got.Paired = append(got.Paired, connectors.PairedSender{SenderID: paired, At: sh.clock.Now()})
|
||||
Expect(r.store.PutBinding(ctx, got)).To(Succeed())
|
||||
b = got
|
||||
}
|
||||
// say writes in a private chat (direct) or in the group and waits for the answers.
|
||||
say := func(direct bool, sender, text string, n int) {
|
||||
before := len(bot.answers())
|
||||
m := contract.ConnectorMessage{Chat: sender, Direct: true, SenderID: sender, SenderName: "S", Text: text}
|
||||
if !direct {
|
||||
m = contract.ConnectorMessage{Chat: "-100", Mention: true, SenderID: sender, SenderName: "S", Text: text}
|
||||
}
|
||||
Expect(r.conns.Ingest(ctx, "telegram", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: m})).To(Succeed())
|
||||
waitFor("the answers", func() bool { return len(bot.answers()) >= before+n })
|
||||
r.sh.clock.Advance(15 * time.Second)
|
||||
}
|
||||
list := func() []contract.ChangeProposal {
|
||||
l, err := r.svc.Changes().List(ctx, alice)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return l
|
||||
}
|
||||
channelNote := func() (string, bool) {
|
||||
l := list()
|
||||
Expect(l).NotTo(BeEmpty())
|
||||
return readHome(r.trigRig, "Memory/channels/"+channelDirOf(l[0])+"/index.md")
|
||||
}
|
||||
|
||||
It("asks by default, and a note goes to the conversation when no scope is named", func() {
|
||||
setup(studio.SaveAuto, "", agents.Agent{})
|
||||
say(true, paired, "remember", 2)
|
||||
p := list()[0]
|
||||
Expect(p.Status).To(Equal(contract.ProposalPending), "the row is ask: a paired sender in a private chat still asks")
|
||||
Expect(p.Scope).To(Equal(contract.ScopeChannel))
|
||||
Expect(p.Auto).To(BeFalse())
|
||||
})
|
||||
|
||||
It("saves at once to the conversation for a paired sender in a private chat, with an Undo", func() {
|
||||
setup(studio.SaveAsk, studio.MessagingPairedPrivate, agents.Agent{})
|
||||
say(true, paired, "remember", 1)
|
||||
p := list()[0]
|
||||
Expect(p.Status).To(Equal(contract.ProposalApplied))
|
||||
Expect(p.Auto).To(BeTrue())
|
||||
Expect(p.Scope).To(Equal(contract.ScopeChannel))
|
||||
Expect(p.Rev).To(BeNumerically(">", 0), "one change set that can be undone")
|
||||
text, ok := channelNote()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(text).To(Equal("- standup at 09:30\n"))
|
||||
_, ok = readHome(r.trigRig, "Memory/index.md")
|
||||
Expect(ok).To(BeFalse(), "nothing reached the notes of the person")
|
||||
// The model can take it back: the level is not always.
|
||||
say(true, paired, "forget", 1)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalReverted))
|
||||
})
|
||||
|
||||
DescribeTable("asks in every other case of the row paired_private",
|
||||
func(direct bool, sender string) {
|
||||
setup(studio.SaveAuto, studio.MessagingPairedPrivate, agents.Agent{})
|
||||
say(direct, sender, "remember", 2)
|
||||
p := list()[0]
|
||||
Expect(p.Status).To(Equal(contract.ProposalPending))
|
||||
Expect(p.Auto).To(BeFalse())
|
||||
},
|
||||
Entry("a listed sender who is not paired, in a private chat", true, listed),
|
||||
Entry("a paired sender in a group", false, paired),
|
||||
Entry("a listed sender in a group", false, listed),
|
||||
)
|
||||
|
||||
It("is not loosened by the row when the person chose Always confirm", func() {
|
||||
setup(studio.SaveAlways, studio.MessagingPairedPrivate, agents.Agent{})
|
||||
say(true, paired, "remember", 2)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalPending))
|
||||
Expect(list()[0].Auto).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is not loosened by the row when the agent says always", func() {
|
||||
setup(studio.SaveAuto, studio.MessagingPairedPrivate, agents.Agent{Memory: agents.MemoryMineAndOwn, MemorySave: agents.MemorySaveAlways})
|
||||
say(true, paired, "remember", 2)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalPending))
|
||||
})
|
||||
|
||||
DescribeTable("offers no way to save when the rule is never",
|
||||
func(user, row string, agent agents.Agent) {
|
||||
setup(user, row, agent)
|
||||
say(true, paired, "hi", 1)
|
||||
chats, err := r.svc.List(ctx, alice, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(chats).NotTo(BeEmpty())
|
||||
Expect(r.h.ToolNames(chats[0].ID)).NotTo(ContainElement("propose_change"))
|
||||
},
|
||||
Entry("the row is never", studio.SaveAuto, studio.MessagingNever, agents.Agent{}),
|
||||
Entry("the person said never", studio.SaveNever, studio.MessagingPairedPrivate, agents.Agent{}),
|
||||
Entry("the agent says never", studio.SaveAuto, studio.MessagingPairedPrivate, agents.Agent{Memory: agents.MemoryOwn, MemorySave: agents.MemorySaveNever}),
|
||||
)
|
||||
|
||||
It("still refuses for an agent whose memory is none", func() {
|
||||
setup(studio.SaveAsk, studio.MessagingPairedPrivate, agents.Agent{Memory: agents.MemoryNone})
|
||||
say(true, paired, "hi", 1)
|
||||
chats, err := r.svc.List(ctx, alice, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(r.h.ToolNames(chats[0].ID)).NotTo(ContainElement("propose_change"))
|
||||
})
|
||||
|
||||
It("keeps the limit on a secret: a note that looks like one waits", func() {
|
||||
setup(studio.SaveAsk, studio.MessagingPairedPrivate, agents.Agent{})
|
||||
say(true, paired, "remember secret", 2)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalPending), "a note that looks like a secret still waits")
|
||||
})
|
||||
|
||||
It("keeps the limit of five notes a reply: no sixth note is saved at once", func() {
|
||||
setup(studio.SaveAsk, studio.MessagingPairedPrivate, agents.Agent{})
|
||||
say(true, paired, "remember many", 1)
|
||||
applied := 0
|
||||
for _, p := range list() {
|
||||
if p.Status == contract.ProposalApplied {
|
||||
applied++
|
||||
}
|
||||
}
|
||||
Expect(applied).To(Equal(studio.MaxAutoWritesPerTurn))
|
||||
Expect(list()).To(HaveLen(studio.MaxAutoWritesPerTurn), "the sixth is refused by the hourly limit, not saved")
|
||||
})
|
||||
|
||||
It("saves to the agent's or the person's notes only after a paired sender confirms, never at once", func() {
|
||||
setup(studio.SaveAsk, studio.MessagingPairedPrivate, agents.Agent{})
|
||||
say(true, paired, "remember user", 2)
|
||||
p := list()[0]
|
||||
Expect(p.Scope).To(Equal(contract.ScopeUser))
|
||||
Expect(p.Status).To(Equal(contract.ProposalPending), "the row saves to the conversation only")
|
||||
_, ok := readHome(r.trigRig, "Memory/index.md")
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("refuses a note for the person from a sender who is not paired, and says use the conversation", func() {
|
||||
setup(studio.SaveAsk, studio.MessagingAsk, agents.Agent{})
|
||||
say(true, listed, "remember user", 1)
|
||||
Expect(list()).To(BeEmpty())
|
||||
res := r.h.ResultsFor("propose_change")
|
||||
Expect(res).To(HaveLen(1))
|
||||
Expect(res[0].Err).To(MatchError(ContainSubstring("paired")))
|
||||
})
|
||||
|
||||
It("lets only the paired sender who asked answer a note under Always confirm", func() {
|
||||
setup(studio.SaveAlways, "", agents.Agent{})
|
||||
say(true, paired, "remember channel", 2)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalPending))
|
||||
// A listed sender who is not paired cannot confirm it in a group.
|
||||
say(false, listed, "yes", 1)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalPending))
|
||||
// The paired sender who asked can.
|
||||
say(true, paired, "yes", 1)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalApplied))
|
||||
})
|
||||
|
||||
It("holds a note for the person for a paired answer even when the level is ask", func() {
|
||||
setup(studio.SaveAsk, "", agents.Agent{})
|
||||
say(true, paired, "remember agent", 2)
|
||||
p := list()[0]
|
||||
Expect(p.Scope).To(Equal(contract.ScopeAgent))
|
||||
Expect(p.Status).To(Equal(contract.ProposalPending))
|
||||
say(true, listed, "yes", 1)
|
||||
Expect(list()[0].Status).To(Equal(contract.ProposalPending), "a listed sender who is not paired may not confirm it")
|
||||
Expect(p.Source.Kind).To(Equal(session.OriginConnector))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// channelDirOf is the folder name of the conversation of a proposal.
|
||||
func channelDirOf(p contract.ChangeProposal) string {
|
||||
return proposals.ChannelDir(p.Source)
|
||||
}
|
||||
@@ -25,8 +25,11 @@ type MemorySettings struct {
|
||||
// EnabledSet is true once the person chose. Before that the switch is on,
|
||||
// unless they had disabled the Memory add-on to stop memory.
|
||||
EnabledSet bool `json:"enabled_set"`
|
||||
// SaveMode is how a note is saved: ask, auto or never.
|
||||
// SaveMode is how a note is saved: ask, auto, always or never.
|
||||
SaveMode string `json:"save_mode"`
|
||||
// MessagingSave is how a note proposed in a messaging chat is saved: ask,
|
||||
// paired_private or never. It never loosens always or never in SaveMode.
|
||||
MessagingSave string `json:"messaging_save"`
|
||||
// SaveModeSet is true once the person chose in this setting. Before that
|
||||
// the value they gave on the page of the Memory add-on, if any, applies.
|
||||
SaveModeSet bool `json:"save_mode_set"`
|
||||
@@ -40,12 +43,14 @@ type MemorySettings struct {
|
||||
type MemoryUpdate struct {
|
||||
Enabled *bool
|
||||
SaveMode *string
|
||||
// MessagingSave is the messaging row: ask, paired_private or never.
|
||||
MessagingSave *string
|
||||
}
|
||||
|
||||
// MemorySettings reads the memory settings of a person.
|
||||
func (s *Service) MemorySettings(ctx context.Context, user string) (MemorySettings, error) {
|
||||
legacy := legacyMemoryOf(s.addonContextSets(ctx, user))
|
||||
out := MemorySettings{Provider: s.memory.ID(), Available: s.d.Files.Available(), SaveMode: SaveAsk}
|
||||
out := MemorySettings{Provider: s.memory.ID(), Available: s.d.Files.Available(), SaveMode: SaveAsk, MessagingSave: MessagingAsk}
|
||||
out.Enabled = s.memoryUserOn(ctx, user, legacy)
|
||||
if s.d.UserSetting != nil {
|
||||
if v, ok, err := s.d.UserSetting(ctx, principal(user), HostSettingsID, MemoryEnabledKey); err == nil && ok && (v == MemoryOn || v == MemoryOff) {
|
||||
@@ -56,6 +61,7 @@ func (s *Service) MemorySettings(ctx context.Context, user string) (MemorySettin
|
||||
}
|
||||
}
|
||||
out.SaveMode = s.changes.storedSaveMode(ctx, user)
|
||||
out.MessagingSave = s.changes.storedMessagingSave(ctx, user)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -63,9 +69,12 @@ func (s *Service) MemorySettings(ctx context.Context, user string) (MemorySettin
|
||||
// allowed ones is ErrInvalidSettings.
|
||||
func (s *Service) SetMemory(ctx context.Context, user string, in MemoryUpdate) (MemorySettings, error) {
|
||||
if in.SaveMode != nil && !ValidSaveMode(*in.SaveMode) {
|
||||
return MemorySettings{}, fmt.Errorf("%w: save_mode must be ask, auto or never", ErrInvalidSettings)
|
||||
return MemorySettings{}, fmt.Errorf("%w: save_mode must be ask, auto, always or never", ErrInvalidSettings)
|
||||
}
|
||||
if (in.Enabled != nil || in.SaveMode != nil) && s.d.SetUserSetting == nil {
|
||||
if in.MessagingSave != nil && !ValidMessagingSave(*in.MessagingSave) {
|
||||
return MemorySettings{}, fmt.Errorf("%w: messaging_save must be ask, paired_private or never", ErrInvalidSettings)
|
||||
}
|
||||
if (in.Enabled != nil || in.SaveMode != nil || in.MessagingSave != nil) && s.d.SetUserSetting == nil {
|
||||
return MemorySettings{}, ErrMemorySettings
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
@@ -82,6 +91,11 @@ func (s *Service) SetMemory(ctx context.Context, user string, in MemoryUpdate) (
|
||||
return MemorySettings{}, fmt.Errorf("%w: %v", ErrMemorySettings, err)
|
||||
}
|
||||
}
|
||||
if in.MessagingSave != nil {
|
||||
if err := s.d.SetUserSetting(ctx, principal(user), HostSettingsID, MemorySaveMessagingKey, *in.MessagingSave); err != nil {
|
||||
return MemorySettings{}, fmt.Errorf("%w: %v", ErrMemorySettings, err)
|
||||
}
|
||||
}
|
||||
return s.MemorySettings(ctx, user)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ type ChatGrant struct {
|
||||
Caps []string `json:"caps,omitempty"`
|
||||
// Denied says why a capability in the ceiling is not held, by name.
|
||||
Denied map[string]string `json:"denied,omitempty"`
|
||||
// Direct says the chat is one-to-one and Paired that the sender is paired
|
||||
// with the bot. A grant from an older frontend has neither, which reads as
|
||||
// a group chat and an unpaired sender.
|
||||
Direct bool `json:"direct,omitempty"`
|
||||
Paired bool `json:"paired,omitempty"`
|
||||
}
|
||||
|
||||
// ChatStartReply is the worker's answer to a ChatStartRequest. With Error
|
||||
|
||||
@@ -118,7 +118,7 @@ and its own, saves to its own; the interface sets it for a new agent), `own`, `m
|
||||
An agent without it is "as before": it reads notes when its add-ons name `memory`, and the
|
||||
model chooses where a note goes. The host enforces the choice. A `PUT` without `memory`
|
||||
keeps the stored one. `DELETE /api/chats/agents/{id}?memory=keep|export|delete` says what
|
||||
to do with the agent's own notes (default `keep`). See [Short memory](/features/short-memory/#memory-for-each-agent).
|
||||
to do with the agent's own notes (default `keep`). The optional field `memory_save` (`always` or `never`) is the agent's own rule for saving a note: it can only be stricter than the person's setting, `auto` and `ask` are refused with a 400, a `PUT` without it keeps the stored rule, and `use_mine` clears it. See [Short memory](/features/short-memory/#memory-for-each-agent).
|
||||
|
||||
### Connections of an agent
|
||||
|
||||
@@ -331,7 +331,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 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. The `builtins` list and `memory` are extra fields of any version; a server that does not know them ignores them, so the agent arrives without the grants and as before. An import makes a new agent: it has the exported `memory` choice and none of the original's notes. An agent without a folder or tool switches 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. The `builtins` list, `memory` and `memory_save` are extra fields of any version; a server that does not know them ignores them, so the agent arrives without the grants, as before and without its own save rule (it then follows the setting of the person). An import makes a new agent: it has the exported `memory` choice and `memory_save` rule and none of the original's notes. 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).
|
||||
|
||||
@@ -932,8 +932,10 @@ The button follows `fix.kind`: `open_connection` jumps to that setting on the pa
|
||||
When the agent proposes a change that lasts (a memory note, a change of its rules, a
|
||||
skill, a page or the Home dashboard, with `propose_change`) in a chat from a messaging
|
||||
app, LocalAI writes the question in that chat. The model does not write it, and it does
|
||||
not send the person to Studio. Messaging chats always ask first; this only gives the
|
||||
answer a place.
|
||||
not send the person to Studio. A messaging chat asks first by default; the owner can let a
|
||||
paired sender in a private chat save at once with the row **In messaging apps** (see
|
||||
[Memory](/features/short-memory/#in-messaging-apps)). This section is the answer to the
|
||||
question.
|
||||
|
||||
- **The language.** The question and the replies come in the reply language of the
|
||||
binding ([Reply language](#reply-language-reply_language)); the examples here are in
|
||||
@@ -962,6 +964,16 @@ answer a place.
|
||||
owner can confirm this.` or `Only the person who asked can confirm this.`, and the
|
||||
proposal keeps waiting. When the sender who asked cannot confirm, the bot writes no
|
||||
question and the proposal waits in Studio for the owner; the model is told so.
|
||||
- **Notes, scope and pairing.** A memory note from a messaging chat goes to the notes of
|
||||
that conversation (`scope` channel) unless the sender is paired and the agent's memory
|
||||
choice allows the agent's or the owner's notes. A note for those scopes is confirmed by a
|
||||
paired sender only. When the owner chose **Always confirm** (or the agent's rule is
|
||||
**Always confirm**), only the paired sender who asked can confirm, in a private chat too,
|
||||
and the model cannot undo or forget a note by itself.
|
||||
- **One-to-one chats.** The host knows a chat is one-to-one from the `direct` field of the
|
||||
connector message (a Telegram private chat, a Slack direct message, a Discord direct
|
||||
message). The turn carries it, with whether the sender is paired, to the worker. A
|
||||
message that does not say counts as a group chat.
|
||||
- **Time.** A proposal lapses after 24 hours. For 15 minutes after that, a bare word is
|
||||
answered with `That proposal expired. Ask again if you still want it.`; later it is
|
||||
an ordinary message.
|
||||
|
||||
@@ -77,8 +77,10 @@ you confirm; you can restore them from Files. Both work while memory is off.
|
||||
|
||||
### The API
|
||||
|
||||
`GET /api/chats/memory` reads `{enabled, enabled_set, save_mode, save_mode_set, provider,
|
||||
available}` and `PUT /api/chats/memory` with `{enabled, save_mode}` changes them. The server
|
||||
`GET /api/chats/memory` reads `{enabled, enabled_set, save_mode, messaging_save,
|
||||
save_mode_set, provider, available}` and `PUT /api/chats/memory` with `{enabled, save_mode,
|
||||
messaging_save}` changes them (a field left out stays as it is; a value that is not allowed
|
||||
is a 400). The server
|
||||
keeps them with your other settings, so every frontend of a cluster reads the same answer.
|
||||
Before you choose, memory is on, unless you had disabled the old Memory add-on to stop it.
|
||||
|
||||
@@ -100,16 +102,37 @@ assistant wants to save something to your memory:
|
||||
|
||||
| Choice | What happens |
|
||||
|---|---|
|
||||
| **Ask me first** (default) | The assistant proposes the note and you confirm it. |
|
||||
| **Save it and tell me** | In your own chats the note is saved at once. The chat shows "Saved to memory" with an **Undo**, and no confirmation card. |
|
||||
| **Ask me first** (default) | The assistant proposes the note and you confirm it. |
|
||||
| **Always confirm** | Like **Ask me first**, and nothing can loosen it. The model's own undo and forget wait for you too: `revert_change` is refused, and you undo with the **Undo** of the note. No agent setting and no messaging setting can override it. |
|
||||
| **Never** | The assistant cannot propose a note. It says that saving is off if you ask it to remember something. |
|
||||
|
||||
The choice is yours alone and the server keeps it. These rules hold
|
||||
whatever you choose:
|
||||
The values of `save_mode` are `auto`, `ask`, `always` and `never`. The choice is yours alone
|
||||
and the server keeps it.
|
||||
|
||||
- Chats that come from a messaging app always ask first. The person who wrote the
|
||||
message is not always you, so a message from outside can never save to your memory
|
||||
without your confirmation. Scheduled and background runs cannot save notes at all.
|
||||
### Which rule applies
|
||||
|
||||
Several settings can have a say on one note. The strictest wins, in this order:
|
||||
`auto` is the loosest, then `ask`, then `always`, then `never`. One function combines them.
|
||||
|
||||
| Rule | Who sets it | What it can do |
|
||||
|---|---|---|
|
||||
| Your level | You, on the **Memory** tab | Any of the four |
|
||||
| The agent's rule | The agent form: **When it wants to save a note** | **Always confirm** or **Never** only. It can ask more than you, never less, and it cannot choose "Save it and tell me" |
|
||||
| The messaging row | You, on the **Memory** tab | **Ask me first**, **Save it for a paired sender in a private chat**, or **Never**. Only for chats from a messaging app |
|
||||
| The chat | The host | A sub-agent, a trigger or a job is never looser than ask; a chat from a messaging app asks unless the row says otherwise |
|
||||
|
||||
If your level is **Always confirm** or **Never**, the messaging row cannot loosen it: a
|
||||
saved agent and a messaging chat get at most that. With **Ask me first** or **Save it and
|
||||
tell me**, the messaging row decides for messaging chats. When any of these cannot be read,
|
||||
the host asks.
|
||||
|
||||
These rules hold whatever you choose:
|
||||
|
||||
- Chats that come from a messaging app ask first, unless you chose **Save it for a paired
|
||||
sender in a private chat** (see below). The person who wrote the message is not always
|
||||
you, so a message from outside never saves to your memory without your confirmation.
|
||||
Scheduled and background runs cannot save notes at all.
|
||||
The bot writes the question in the chat ("I can save a note ... Reply yes to save it or
|
||||
no to drop it"), and a sender whom you trust with the permission **Confirm changes from
|
||||
the chat** answers there with `yes` or `no`, within 24 hours. `undo` takes a saved note
|
||||
@@ -126,6 +149,27 @@ whatever you choose:
|
||||
|
||||
If the server cannot read your choice, it asks first.
|
||||
|
||||
### In messaging apps
|
||||
|
||||
Under **In messaging apps** on the **Memory** tab you choose how a note proposed in a chat
|
||||
from a messaging app is saved (`messaging_save`):
|
||||
|
||||
| Choice | What happens |
|
||||
|---|---|
|
||||
| **Ask me first** (default) | The bot asks in the chat. A listed sender can answer there; others wait for you in Studio. |
|
||||
| **Save it for a paired sender in a private chat** (`paired_private`) | The note is saved at once only when the sender is paired **and** the chat is one-to-one (a direct message, not a group, channel or thread of several people). It goes to the notes of that conversation, with an Undo. A secret-looking note still waits, and the limit of 5 notes a reply and the size limit stay. |
|
||||
| **Never** | No note is saved from messaging apps. The model gets no way to propose one. |
|
||||
|
||||
The host decides whether a chat is one-to-one from what the platform sent for the message
|
||||
(a Telegram private chat, a Slack direct message, a Discord direct message). When it does
|
||||
not know, the chat counts as a group, so nothing is saved at once.
|
||||
|
||||
A note from a messaging chat goes to **this conversation** first (`Memory/channels/...`).
|
||||
It reaches the agent's own notes or yours only from a paired sender, only when the agent's
|
||||
memory choice allows that scope, and only after that sender confirms it: a note for another
|
||||
scope never saves at once. Under **Always confirm** only the paired sender who asked can
|
||||
confirm a note, in a private chat too. An agent whose memory is **None** saves nothing.
|
||||
|
||||
## Memory for each agent
|
||||
|
||||
A saved agent has its own choice of notes. You set it in the agent form, in the
|
||||
@@ -159,11 +203,18 @@ How it works:
|
||||
pick one of the clearer choices. An agent that never had the field keeps reading and
|
||||
saving exactly as it did. A `PUT` that does not send `memory` keeps the stored choice.
|
||||
- **Messaging apps.** This does not change what a chat from a messaging app reads: it
|
||||
still reads your notes and those of its own conversation, and it still asks you before it
|
||||
saves. Limiting that to a paired sender is planned.
|
||||
still reads your notes and those of its own conversation. What it saves, and where, is
|
||||
in [In messaging apps](#in-messaging-apps): the conversation first, the agent's own notes
|
||||
only after a paired sender confirms.
|
||||
- **The agent's save rule.** Under **When it wants to save a note** the form offers **Use my
|
||||
setting** (the default), **Always confirm** and **Never**. The rule can only be stricter
|
||||
than your level: "Save it and tell me" is not offered, and the API refuses `auto` and
|
||||
`ask` there with a 400. A `PUT` that does not send `memory_save` keeps the stored rule,
|
||||
so an older client cannot loosen an agent; `memory_save: "use_mine"` clears it.
|
||||
|
||||
The API field is `memory` on an agent (`own`, `mine_and_own`, `mine`, `none`; absent is as
|
||||
before). It is part of an agent export file and of an import. A new agent that a client
|
||||
The API fields are `memory` on an agent (`own`, `mine_and_own`, `mine`, `none`; absent is as
|
||||
before) and `memory_save` (`always`, `never`; absent uses your setting). Both are part of an
|
||||
agent export file and of an import. A new agent that a client
|
||||
writes without `memory` is "as before"; the interface sends `mine_and_own`.
|
||||
|
||||
### Delete or copy an agent
|
||||
|
||||
+53
-3
@@ -7053,7 +7053,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/chats/memory": {
|
||||
"get": {
|
||||
"description": "Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved (ask, auto or never) and applies as the strictest rule with the rules of the host; a chat from a messaging app always asks. Your notes stay and can be listed, exported and deleted while memory is off.",
|
||||
"description": "Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved: auto (save it and tell me), ask (the default), always (always confirm: like ask, nothing can loosen it, and the model cannot undo or forget by itself) or never. It applies as the strictest rule together with the rule of the saved agent (memory_save) and the rules of the host. A chat from a messaging app asks, unless messaging_save is paired_private and the sender is paired and the chat is one-to-one; the row cannot loosen always or never. Your notes stay and can be listed, exported and deleted while memory is off.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
@@ -7068,7 +7068,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Sets enabled (memory On or Off for every chat and agent) and save_mode (ask, auto or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.",
|
||||
"description": "Sets enabled (memory On or Off for every chat and agent), save_mode (auto, ask, always or never) and messaging_save (ask, paired_private or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -20884,6 +20884,15 @@ const docTemplate = `{
|
||||
],
|
||||
"example": "mine_and_own"
|
||||
},
|
||||
"memory_save": {
|
||||
"description": "MemorySave is the agent's own rule for saving a note: always (every note\nwaits for the person, and the model cannot undo or forget by itself) or\nnever (it reads notes and saves none). Absent uses the person's setting\n(Account \u003e Memory). It can only be stricter than that setting: the host\ntakes the stricter of the two, and there is no value that saves at once.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"always",
|
||||
"never"
|
||||
],
|
||||
"example": "always"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -21166,6 +21175,15 @@ const docTemplate = `{
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"memory_save": {
|
||||
"description": "MemorySave is the save rule of the agent (see ChatsAgent). A write\nwithout it keeps the stored rule, so a client that does not know the\nfield cannot loosen the agent. use_mine clears the rule. auto and ask\nare not accepted: an agent cannot be looser than the person (400).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"always",
|
||||
"never",
|
||||
"use_mine"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -24080,8 +24098,24 @@ const docTemplate = `{
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"messaging_save": {
|
||||
"description": "MessagingSave is the messaging row (see ChatsMemorySettings).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"paired_private",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
},
|
||||
"save_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"ask",
|
||||
"always",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
}
|
||||
}
|
||||
@@ -24104,14 +24138,30 @@ const docTemplate = `{
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"messaging_save": {
|
||||
"description": "MessagingSave is how a note proposed in a chat of a messaging app is\nsaved: ask (the bot asks in the chat; the default), paired_private\n(saved at once to the conversation when the sender is paired and the\nchat is one-to-one) or never. It cannot loosen save_mode: with always or\nnever the row has no looser effect.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"paired_private",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
},
|
||||
"provider": {
|
||||
"description": "Provider is what serves memory: builtin.",
|
||||
"type": "string",
|
||||
"example": "builtin"
|
||||
},
|
||||
"save_mode": {
|
||||
"description": "SaveMode is how a note is saved: ask, auto or never.",
|
||||
"description": "SaveMode is how a note is saved: auto (save it and tell me), ask (ask me\nfirst, the default), always (always confirm: like ask, and nothing can\nloosen it; undo and forget by the model wait for you too) or never.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"ask",
|
||||
"always",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
},
|
||||
"save_mode_set": {
|
||||
|
||||
+53
-3
@@ -7050,7 +7050,7 @@
|
||||
},
|
||||
"/api/chats/memory": {
|
||||
"get": {
|
||||
"description": "Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved (ask, auto or never) and applies as the strictest rule with the rules of the host; a chat from a messaging app always asks. Your notes stay and can be listed, exported and deleted while memory is off.",
|
||||
"description": "Memory is the short notes the assistant reads before it answers and may save. enabled is the switch for all your chats and agents: a private chat (the memory group of a chat, see PUT /api/chats/{id}/builtins), an agent that turns memory off, or this switch off each make memory off in a chat, and the narrowest wins; a saved agent cannot widen your choice. enabled_set is false until you choose; before that memory is on, unless you had disabled the Memory add-on to stop it. save_mode is how a note is saved: auto (save it and tell me), ask (the default), always (always confirm: like ask, nothing can loosen it, and the model cannot undo or forget by itself) or never. It applies as the strictest rule together with the rule of the saved agent (memory_save) and the rules of the host. A chat from a messaging app asks, unless messaging_save is paired_private and the sender is paired and the chat is one-to-one; the row cannot loosen always or never. Your notes stay and can be listed, exported and deleted while memory is off.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
@@ -7065,7 +7065,7 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"description": "Sets enabled (memory On or Off for every chat and agent) and save_mode (ask, auto or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.",
|
||||
"description": "Sets enabled (memory On or Off for every chat and agent), save_mode (auto, ask, always or never) and messaging_save (ask, paired_private or never). A field left out stays as it is. Off means nothing is put in the prompt, the model has no tool or skill for notes, nothing is proposed or saved (also by triggers and other runs that start by themselves) and the model is told in one line that memory is off. The settings are kept per person with the account, so every frontend of a cluster reads the same answer. 400 for a value that is not allowed.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -20881,6 +20881,15 @@
|
||||
],
|
||||
"example": "mine_and_own"
|
||||
},
|
||||
"memory_save": {
|
||||
"description": "MemorySave is the agent's own rule for saving a note: always (every note\nwaits for the person, and the model cannot undo or forget by itself) or\nnever (it reads notes and saves none). Absent uses the person's setting\n(Account \u003e Memory). It can only be stricter than that setting: the host\ntakes the stricter of the two, and there is no value that saves at once.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"always",
|
||||
"never"
|
||||
],
|
||||
"example": "always"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -21163,6 +21172,15 @@
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"memory_save": {
|
||||
"description": "MemorySave is the save rule of the agent (see ChatsAgent). A write\nwithout it keeps the stored rule, so a client that does not know the\nfield cannot loosen the agent. use_mine clears the rule. auto and ask\nare not accepted: an agent cannot be looser than the person (400).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"always",
|
||||
"never",
|
||||
"use_mine"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -24077,8 +24095,24 @@
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"messaging_save": {
|
||||
"description": "MessagingSave is the messaging row (see ChatsMemorySettings).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"paired_private",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
},
|
||||
"save_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"ask",
|
||||
"always",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
}
|
||||
}
|
||||
@@ -24101,14 +24135,30 @@
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"messaging_save": {
|
||||
"description": "MessagingSave is how a note proposed in a chat of a messaging app is\nsaved: ask (the bot asks in the chat; the default), paired_private\n(saved at once to the conversation when the sender is paired and the\nchat is one-to-one) or never. It cannot loosen save_mode: with always or\nnever the row has no looser effect.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"paired_private",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
},
|
||||
"provider": {
|
||||
"description": "Provider is what serves memory: builtin.",
|
||||
"type": "string",
|
||||
"example": "builtin"
|
||||
},
|
||||
"save_mode": {
|
||||
"description": "SaveMode is how a note is saved: ask, auto or never.",
|
||||
"description": "SaveMode is how a note is saved: auto (save it and tell me), ask (ask me\nfirst, the default), always (always confirm: like ask, and nothing can\nloosen it; undo and forget by the model wait for you too) or never.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"ask",
|
||||
"always",
|
||||
"never"
|
||||
],
|
||||
"example": "ask"
|
||||
},
|
||||
"save_mode_set": {
|
||||
|
||||
+74
-12
@@ -3983,6 +3983,18 @@ definitions:
|
||||
- none
|
||||
example: mine_and_own
|
||||
type: string
|
||||
memory_save:
|
||||
description: |-
|
||||
MemorySave is the agent's own rule for saving a note: always (every note
|
||||
waits for the person, and the model cannot undo or forget by itself) or
|
||||
never (it reads notes and saves none). Absent uses the person's setting
|
||||
(Account > Memory). It can only be stricter than that setting: the host
|
||||
takes the stricter of the two, and there is no value that saves at once.
|
||||
enum:
|
||||
- always
|
||||
- never
|
||||
example: always
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
name:
|
||||
@@ -4223,6 +4235,17 @@ definitions:
|
||||
- mine
|
||||
- none
|
||||
type: string
|
||||
memory_save:
|
||||
description: |-
|
||||
MemorySave is the save rule of the agent (see ChatsAgent). A write
|
||||
without it keeps the stored rule, so a client that does not know the
|
||||
field cannot loosen the agent. use_mine clears the rule. auto and ask
|
||||
are not accepted: an agent cannot be looser than the person (400).
|
||||
enum:
|
||||
- always
|
||||
- never
|
||||
- use_mine
|
||||
type: string
|
||||
model:
|
||||
type: string
|
||||
name:
|
||||
@@ -6468,7 +6491,20 @@ definitions:
|
||||
enabled:
|
||||
example: false
|
||||
type: boolean
|
||||
messaging_save:
|
||||
description: MessagingSave is the messaging row (see ChatsMemorySettings).
|
||||
enum:
|
||||
- ask
|
||||
- paired_private
|
||||
- never
|
||||
example: ask
|
||||
type: string
|
||||
save_mode:
|
||||
enum:
|
||||
- auto
|
||||
- ask
|
||||
- always
|
||||
- never
|
||||
example: ask
|
||||
type: string
|
||||
type: object
|
||||
@@ -6489,12 +6525,33 @@ definitions:
|
||||
had disabled the Memory add-on to stop it.
|
||||
example: false
|
||||
type: boolean
|
||||
messaging_save:
|
||||
description: |-
|
||||
MessagingSave is how a note proposed in a chat of a messaging app is
|
||||
saved: ask (the bot asks in the chat; the default), paired_private
|
||||
(saved at once to the conversation when the sender is paired and the
|
||||
chat is one-to-one) or never. It cannot loosen save_mode: with always or
|
||||
never the row has no looser effect.
|
||||
enum:
|
||||
- ask
|
||||
- paired_private
|
||||
- never
|
||||
example: ask
|
||||
type: string
|
||||
provider:
|
||||
description: 'Provider is what serves memory: builtin.'
|
||||
example: builtin
|
||||
type: string
|
||||
save_mode:
|
||||
description: 'SaveMode is how a note is saved: ask, auto or never.'
|
||||
description: |-
|
||||
SaveMode is how a note is saved: auto (save it and tell me), ask (ask me
|
||||
first, the default), always (always confirm: like ask, and nothing can
|
||||
loosen it; undo and forget by the model wait for you too) or never.
|
||||
enum:
|
||||
- auto
|
||||
- ask
|
||||
- always
|
||||
- never
|
||||
example: ask
|
||||
type: string
|
||||
save_mode_set:
|
||||
@@ -18093,10 +18150,14 @@ paths:
|
||||
that turns memory off, or this switch off each make memory off in a chat,
|
||||
and the narrowest wins; a saved agent cannot widen your choice. enabled_set
|
||||
is false until you choose; before that memory is on, unless you had disabled
|
||||
the Memory add-on to stop it. save_mode is how a note is saved (ask, auto
|
||||
or never) and applies as the strictest rule with the rules of the host; a
|
||||
chat from a messaging app always asks. Your notes stay and can be listed,
|
||||
exported and deleted while memory is off.'
|
||||
the Memory add-on to stop it. save_mode is how a note is saved: auto (save
|
||||
it and tell me), ask (the default), always (always confirm: like ask, nothing
|
||||
can loosen it, and the model cannot undo or forget by itself) or never. It
|
||||
applies as the strictest rule together with the rule of the saved agent (memory_save)
|
||||
and the rules of the host. A chat from a messaging app asks, unless messaging_save
|
||||
is paired_private and the sender is paired and the chat is one-to-one; the
|
||||
row cannot loosen always or never. Your notes stay and can be listed, exported
|
||||
and deleted while memory is off.'
|
||||
responses:
|
||||
"200":
|
||||
description: Your memory settings
|
||||
@@ -18108,13 +18169,14 @@ paths:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Sets enabled (memory On or Off for every chat and agent) and save_mode
|
||||
(ask, auto or never). A field left out stays as it is. Off means nothing is
|
||||
put in the prompt, the model has no tool or skill for notes, nothing is proposed
|
||||
or saved (also by triggers and other runs that start by themselves) and the
|
||||
model is told in one line that memory is off. The settings are kept per person
|
||||
with the account, so every frontend of a cluster reads the same answer. 400
|
||||
for a value that is not allowed.
|
||||
description: Sets enabled (memory On or Off for every chat and agent), save_mode
|
||||
(auto, ask, always or never) and messaging_save (ask, paired_private or never).
|
||||
A field left out stays as it is. Off means nothing is put in the prompt, the
|
||||
model has no tool or skill for notes, nothing is proposed or saved (also by
|
||||
triggers and other runs that start by themselves) and the model is told in
|
||||
one line that memory is off. The settings are kept per person with the account,
|
||||
so every frontend of a cluster reads the same answer. 400 for a value that
|
||||
is not allowed.
|
||||
parameters:
|
||||
- description: the choices to change
|
||||
in: body
|
||||
|
||||
Reference in new issue
Block a user