feat(studio): let the assistant propose a text for Knowledge

Add the change kind `knowledge` and the sensitive grant and capability
`knowledge.write`. A text for a collection goes through the same
proposal flow as the other kinds, but it is never applied unasked:
the Ask, Auto and Always confirm levels all wait for the owner, and the
Never level takes the kind away. Confirm adds one entry to the
collection and Undo removes it. Text is capped at 64 KiB.

A paired sender can answer yes, no or undo in a messaging chat; a
sender who is only on the allowlist cannot. The connection page
explains a block with the shared reason codes, plus knowledge_none and
knowledge_no_embedding. The Suggested changes card names the
collection and previews the text. A kind can now declare a destination
other than the home folder, so core code stays generic.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 01:21:17 +00:00
1 parent 8cf8079b25
commit f0caddf8f1
48 files changed
+1487 -87

No files matched your search

@@ -89,7 +89,7 @@ export function usualCaps(capabilities) {
// tick what the agent holds. proposals.confirm and tools.write are not grants:
// they belong to the binding alone.
export const AGENT_GRANT_CAPS = [
'memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write',
'memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write', 'knowledge.write',
'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel',
]
+70
View File
@@ -2,6 +2,10 @@ package application
import (
"context"
"errors"
"os"
"path"
"path/filepath"
"strings"
"github.com/mudler/LocalAI/core/http/auth"
@@ -60,3 +64,69 @@ func (c studioCollections) Search(ctx context.Context, user, collection, query s
}
return out, nil
}
// EmbeddingReady holds when the model that indexes the collections is installed.
func (c studioCollections) EmbeddingReady(context.Context) error {
if c.a.ApplicationConfig() == nil || !c.a.EmbeddingModelInstalled(c.a.ApplicationConfig().Collections.EmbeddingModel) {
return errors.New("the embedding model is not installed")
}
return nil
}
// Add stores text as one entry of the user's collection.
func (c studioCollections) Add(ctx context.Context, user, collection, name, text string) error {
svc := c.a.CollectionsService()
if svc == nil || !c.Allowed(ctx, user) {
return studio.ErrNoCollection
}
_, err := svc.UploadForUser(ctx, c.owner(user), collection, name, strings.NewReader(text))
if err != nil && strings.Contains(err.Error(), "not found") {
return studio.ErrNoCollection
}
return err
}
// Remove deletes the entry that Add stored under the file name. The collection
// keeps each entry under a key that holds a generated folder before the file
// name, so the entry is found by its base name. An entry that is gone already
// is not an error: the undo has nothing left to do.
func (c studioCollections) Remove(ctx context.Context, user, collection, name string) error {
svc := c.a.CollectionsService()
if svc == nil || !c.Allowed(ctx, user) {
return studio.ErrNoCollection
}
entries, err := svc.ListEntriesForUser(c.owner(user), collection)
if err != nil {
if strings.Contains(err.Error(), "not found") {
return studio.ErrNoCollection
}
return err
}
for _, e := range entries {
if path.Base(e) == name {
if _, err := svc.DeleteEntryForUser(c.owner(user), collection, e); err != nil {
return err
}
}
}
return nil
}
// EmbeddingModelInstalled reports whether the model that the collections embed
// with is installed: it has a configuration, or a file of that name is in the
// models folder. The collections call this server's embeddings endpoint, which
// answers 404 for a model it does not have.
func (a *Application) EmbeddingModelInstalled(name string) bool {
if name == "" {
return false
}
if _, ok := a.ModelConfigLoader().GetModelConfig(name); ok {
return true
}
st := a.ApplicationConfig().SystemState
if st == nil || st.Model.ModelsPath == "" || strings.ContainsAny(name, "/\\") {
return false
}
info, err := os.Stat(filepath.Join(st.Model.ModelsPath, name))
return err == nil && !info.IsDir()
}
@@ -3,8 +3,6 @@ package localai
import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
@@ -400,16 +398,5 @@ func ListCollectionSourcesEndpoint(app *application.Application) echo.HandlerFun
// models folder. The collections call this server's embeddings endpoint, which
// answers 404 for a model it does not have.
func embeddingModelInstalled(app *application.Application, name string) bool {
if name == "" {
return false
}
if _, ok := app.ModelConfigLoader().GetModelConfig(name); ok {
return true
}
st := app.ApplicationConfig().SystemState
if st == nil || st.Model.ModelsPath == "" || strings.ContainsAny(name, "/\\") {
return false
}
info, err := os.Stat(filepath.Join(st.Model.ModelsPath, name))
return err == nil && !info.IsDir()
return app.EmbeddingModelInstalled(name)
}
File diff suppressed because one or more lines are too long.
+18 -2
View File
@@ -88,6 +88,22 @@ test.describe('Agent form', () => {
await expect.poll(() => s.calls.find(c => c.method === 'PUT')?.body).toMatchObject({ builtins_off: [], builtins: ['triggers.propose', 'act.files', 'triggers.run'] })
})
test('adding to knowledge is a sensitive option under the Knowledge switch, off until the owner turns it on', async ({ page }) => {
const s = await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['memory.write'] }] })
await page.goto('/app/agents/a2/edit')
const panel = page.getByTestId('agent-tools-panel')
const options = panel.getByRole('group', { name: 'Options of Knowledge' })
await expect(options).toBeVisible()
const add = options.getByRole('switch', { name: 'Add to knowledge' })
await expect(add).not.toBeChecked()
await expect(options).toContainText('Sensitive')
// It is not also listed in the Suggested changes options or a section of its own.
await expect(panel.getByRole('switch', { name: 'Add to knowledge' })).toHaveCount(1)
await add.click()
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect.poll(() => s.calls.find(c => c.method === 'PUT')?.body.builtins).toEqual(['memory.write', 'knowledge.write'])
})
test('saved agents is one switch: turning it off takes the grant away too, turning it on adds only the group', async ({ page }) => {
const s = await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['memory.write', 'saved_agents'] }, { ...AGENTS[0], id: 'a9', name: 'plain' }] })
await page.goto('/app/agents/a2/edit')
@@ -192,10 +208,10 @@ test.describe('Agent form', () => {
await page.goto('/app/agents/a1/edit')
const panel = page.getByTestId('agent-tools-panel')
// The same words as the Tools menu of a chat: a title, a description, and
// the add-on's declared title instead of its id. Eight rows are the built-in
// the add-on's declared title instead of its id. Nine rows are the built-in
// groups and add-ons; the others are the options and permissions under them.
const rows = panel.getByTestId('agent-tools-item')
await expect(rows).toHaveCount(22)
await expect(rows).toHaveCount(24)
for (const row of await rows.all()) await expect(row.getByTestId('agent-tools-blurb')).not.toBeEmpty()
await expect(panel.getByRole('switch', { name: 'Suggested changes' })).toBeChecked()
await expect(panel.getByRole('switch', { name: 'Office' })).toBeChecked()
+2
View File
@@ -23,6 +23,7 @@ export const GRANTS = [
{ name: 'skills.write', title: 'Write skills', description: 'The agent can propose a skill.', sensitive: true },
{ name: 'pages.write', title: 'Create pages', description: 'The agent can propose a page.', sensitive: true },
{ name: 'dashboard.write', title: 'Change the dashboard', description: 'The agent can propose a dashboard layout.', sensitive: true },
{ name: 'knowledge.write', title: 'Add to knowledge', description: 'The agent can propose a text for one of its collections.', sensitive: true },
{ name: 'triggers.propose', title: 'Propose triggers', description: 'The agent can propose a task that runs by itself.', sensitive: false },
{ name: 'triggers.run', title: 'Run triggers', description: 'The triggers of this agent may start from a messaging app.', sensitive: false },
{ name: 'talkback.channels', title: 'Reply on known channels', description: 'The agent can send a message to a channel it already talks in.', sensitive: false },
@@ -122,6 +123,7 @@ export const DRAFT_TOOLS = {
{ group: 'web', tools: ['web_search', 'web_fetch'], off: false, usable: true, provider: 'builtin', providers: { web_search: 'builtin', web_fetch: 'builtin' }, title: 'Look things up online', description: 'The model can search the web and read a public page by its address.', when_off: 'The model cannot look anything up online.' },
{ group: 'saved_agents', tools: ['list_agents'], off: true, opt_in: true, usable: true, provider: 'builtin', providers: { list_agents: 'builtin' }, title: 'Saved agents', description: 'The model can list your saved agents and start one as a sub-agent.', when_off: 'The model cannot see or start your saved agents.' },
{ group: 'triggers', tools: ['propose_trigger'], off: false, usable: true, provider: 'builtin', providers: { propose_trigger: 'builtin' }, title: 'Triggers', description: 'The model can propose, list, change and delete triggers; a new one waits for your confirmation.', when_off: 'The model cannot create or manage triggers.' },
{ group: 'collections', tools: ['list_collections', 'search_collection'], off: false, usable: true, provider: 'builtin', providers: { list_collections: 'builtin', search_collection: 'builtin' }, title: 'Knowledge', description: 'The model can search the collections you chose for it.', when_off: 'The model cannot search your collections.' },
{ group: 'changes', tools: ['propose_change'], off: false, usable: true, provider: 'builtin', providers: { propose_change: 'builtin' }, title: 'Suggested changes', description: 'The model can propose an edit to your memory notes; nothing changes until you confirm.', when_off: 'The model cannot propose edits to your memory.' },
],
addons: [{ id: 'office', tools: 2, off: false, title: 'Office', description: 'Read and write documents and spreadsheets.' }],
@@ -0,0 +1,94 @@
import { test, expect } from './coverage-fixtures.js'
import { makeInbox, stubInbox } from './inbox-fixtures.js'
// A text that a model proposed for a collection of the Knowledge, in the list
// of suggested changes. It names the collection instead of a file, shows the
// first lines and the size, is confirmed and undone like any other change, and
// never reads a file of the home. The API is stubbed; that the server never
// applies it unasked is tested in Go.
const AT = '2026-11-05T09:00:00Z'
const later = () => new Date(Date.now() + 5 * 3600_000).toISOString()
const TEXT = Array.from({ length: 24 }, (_, i) => `Step ${i + 1}: hold the reset button, wait for the light.`).join('\n') + '\n'
const make = (extra = {}) => ({
v: 1, id: 'k1', owner: 'alice', kind: 'knowledge', scope: 'user', target: 'router-manuals', summary: 'Router reset steps', content: TEXT,
source: { kind: 'connector', id: 'team-bot', chat: 'x1', sender: 'Ana' }, status: 'pending', created: AT, expires: later(), ...extra,
})
async function serve(page, list) {
const s = { list, calls: [], fileReads: 0 }
await stubInbox(page, makeInbox(0))
await page.route('**/api/chats/files/content*', (route) => { s.fileReads++; return route.fulfill({ status: 404, json: { error: { message: 'not found' } } }) })
await page.route(/\/api\/chats\/changes(\?.*)?$/, route => route.fulfill({ json: s.list.filter(p => p.status === 'pending') }))
await page.route(/\/api\/chats\/changes\/([a-z0-9]+)(\/[a-z]+)?$/, (route) => {
const req = route.request()
const parts = new URL(req.url()).pathname.split('/')
const verb = parts.length > 5 ? parts[5] : ''
const p = s.list.find(x => x.id === parts[4])
s.calls.push(`${req.method()} ${parts[4]} ${verb}`.trim())
if (!p) return route.fulfill({ status: 404, json: { error: { message: 'not found' } } })
if (verb === 'confirm') Object.assign(p, { status: 'applied' })
else if (verb === 'dismiss') p.status = 'dismissed'
else if (verb === 'undo') p.status = 'reverted'
return route.fulfill({ json: p })
})
return s
}
async function shot(page, name, locator) {
for (const theme of ['light', 'dark']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(500)
await (locator || page).screenshot({ path: `test-results/${name}-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'))
}
test('a knowledge proposal names the collection, shows the text and its size, and reads no file', async ({ page }) => {
const s = await serve(page, [make()])
await page.goto('/app/chats/proposals')
const card = page.getByTestId('change-proposal')
await expect(card).toHaveCount(1)
await expect(card).toHaveAttribute('data-kind', 'knowledge')
await expect(card.getByTestId('change-summary')).toHaveText('Router reset steps')
const rows = card.getByTestId('change-rows')
await expect(rows.locator('[data-row="kind"]')).toContainText('Knowledge')
await expect(rows.locator('[data-row="target"] dt')).toHaveText('Collection')
await expect(rows.locator('[data-row="target"] dd')).toHaveText('router-manuals')
await expect(rows.locator('[data-row="source"]')).toContainText('Connector team-bot, from Ana')
const preview = card.getByTestId('change-knowledge-preview')
await expect(preview).toContainText('Step 1: hold the reset button')
await expect(preview).toContainText('Step 20:')
await expect(preview).not.toContainText('Step 21:')
await expect(preview).toContainText('4 more lines not shown')
await expect(card.getByTestId('change-knowledge-size')).toContainText('of at most 64 KiB')
await expect(preview).toContainText('Nothing is added until you confirm')
await expect(card.getByTestId('change-diff')).toHaveCount(0)
expect(s.fileReads).toBe(0)
await shot(page, 'knowledge-suggested-change', page.getByTestId('proposals-page'))
})
test('Confirm adds it and keeps Undo although the change has no change set; Undo takes it back', async ({ page }) => {
const s = await serve(page, [make()])
await page.goto('/app/chats/proposals')
const card = page.getByTestId('change-proposal')
await card.getByRole('button', { name: 'Confirm', exact: true }).click()
await expect(card).toHaveAttribute('data-state', 'applied')
await expect(card).toContainText('router-manuals')
await card.getByRole('button', { name: 'Undo' }).click()
await expect(card).toHaveAttribute('data-state', 'reverted')
expect(s.calls).toEqual(expect.arrayContaining(['POST k1 confirm', 'POST k1 undo']))
expect(s.fileReads).toBe(0)
})
test('Edit then confirm labels the text with the collection, and Dismiss adds nothing', async ({ page }) => {
const s = await serve(page, [make(), make({ id: 'k2', summary: 'Wiki page' })])
await page.goto('/app/chats/proposals')
const cards = page.getByTestId('change-proposal')
await cards.first().getByRole('button', { name: 'Edit' }).click()
await expect(cards.first().getByLabel('Text to add to router-manuals')).toBeVisible()
await cards.first().getByRole('button', { name: 'Cancel' }).click()
await cards.nth(1).getByRole('button', { name: 'Dismiss' }).click()
await expect(cards.nth(1)).toHaveAttribute('data-state', 'dismissed')
expect(s.calls).not.toContain('POST k2 confirm')
})
+1 -1
View File
@@ -56,7 +56,7 @@ const WORDS = {
'triggers.propose': 'set reminders and scheduled tasks', 'triggers.run': 'run scheduled tasks',
'talkback.channels': 'write to other channels', 'talkback.new_channel': 'start conversations in new channels',
'profile.write': "change the agent's rules", 'skills.write': 'save skills', 'pages.write': 'publish pages',
'dashboard.write': 'change the dashboard', 'tools.write': 'change things in other services with add-on tools',
'dashboard.write': 'change the dashboard', 'knowledge.write': 'write to knowledge', 'tools.write': 'change things in other services with add-on tools',
}
export function previewFor(body, roles = []) {
@@ -423,6 +423,10 @@
"title": "Dashboard ändern",
"description": "Das Modell kann ein neues Layout für dein Home-Dashboard vorschlagen. Du bestätigst jede Änderung."
},
"knowledge_write": {
"title": "Zum Wissen hinzufügen",
"description": "Erlaubt dem Modell, einen Text für eine Sammlung deines Wissens vorzuschlagen. Du bestätigst jeden einzelnen; er wird nie sofort hinzugefügt."
},
"triggers_propose": {
"title": "Auch aus Messaging-Apps vorschlagen",
"description": "Das Modell kann einen Trigger vorschlagen, wenn jemand in einer Messaging-App danach fragt. Du bestätigst ihn."
@@ -725,7 +729,8 @@
"profile": "Profil",
"skill": "Skill",
"page": "Seite",
"dashboard": "Dashboard"
"dashboard": "Dashboard",
"knowledge": "Wissen"
},
"scope": {
"user": "Alle deine Chats",
@@ -738,7 +743,8 @@
"kind": "Art",
"target": "Datei",
"source": "Von",
"expires": "Läuft ab in"
"expires": "Läuft ab in",
"collection": "Sammlung"
},
"source": {
"chat": "Ein Chat",
@@ -787,6 +793,12 @@
"dismissed": "Änderung abgelehnt.",
"undone": "Änderung rückgängig gemacht.",
"asked": "Der Assistent wurde gebeten, es erneut vorzuschlagen."
},
"knowledge": {
"label": "Text zum Hinzufügen zu {{collection}}",
"size": "{{size}} von höchstens {{max}}",
"note": "Nichts wird hinzugefügt, bevor du bestätigst. Der Text wird dann in Abschnitte geteilt und indexiert, und jeder Chat, der diese Sammlung durchsucht, kann ihn nutzen.",
"editLabel": "Text zum Hinzufügen zu {{target}}"
}
},
"proposalsPage": {
@@ -523,6 +523,10 @@
"title": "Change the dashboard",
"description": "Lets the model propose a new layout for your Home dashboard. You confirm each change."
},
"knowledge_write": {
"title": "Add to knowledge",
"description": "Lets the model propose a text for a collection of your Knowledge. You confirm each one; it is never added at once."
},
"triggers_propose": {
"title": "Also propose from messaging apps",
"description": "Lets the model propose a trigger when someone asks in a messaging app. You confirm it."
@@ -1047,7 +1051,8 @@
"profile": "Profile",
"skill": "Skill",
"page": "Page",
"dashboard": "Dashboard"
"dashboard": "Dashboard",
"knowledge": "Knowledge"
},
"scope": {
"user": "All your chats",
@@ -1060,7 +1065,8 @@
"kind": "Kind",
"target": "File",
"source": "From",
"expires": "Expires in"
"expires": "Expires in",
"collection": "Collection"
},
"source": {
"chat": "A chat",
@@ -1109,6 +1115,12 @@
"dismissed": "Change dismissed.",
"undone": "Change undone.",
"asked": "Asked the assistant to propose it again."
},
"knowledge": {
"label": "Text to add to {{collection}}",
"size": "{{size}} of at most {{max}}",
"note": "Nothing is added until you confirm. The text is then split into passages and indexed, and every chat that searches this collection can use it.",
"editLabel": "Text to add to {{target}}"
}
},
"proposalsPage": {
@@ -423,6 +423,10 @@
"title": "Cambiar el panel",
"description": "El modelo puede proponer un nuevo diseño de tu panel de Inicio. Tú confirmas cada cambio."
},
"knowledge_write": {
"title": "Añadir al conocimiento",
"description": "Permite al modelo proponer un texto para una colección de tu Conocimiento. Confirmas cada uno; nunca se añade de inmediato."
},
"triggers_propose": {
"title": "Proponer también desde aplicaciones de mensajería",
"description": "El modelo puede proponer un disparador cuando alguien lo pide en una aplicación de mensajería. Tú lo confirmas."
@@ -725,7 +729,8 @@
"profile": "Perfil",
"skill": "Habilidad",
"page": "Página",
"dashboard": "Panel"
"dashboard": "Panel",
"knowledge": "Conocimiento"
},
"scope": {
"user": "Todos tus chats",
@@ -738,7 +743,8 @@
"kind": "Tipo",
"target": "Archivo",
"source": "De",
"expires": "Caduca en"
"expires": "Caduca en",
"collection": "Colección"
},
"source": {
"chat": "Un chat",
@@ -787,6 +793,12 @@
"dismissed": "Cambio descartado.",
"undone": "Cambio deshecho.",
"asked": "Se pidió al asistente que lo proponga de nuevo."
},
"knowledge": {
"label": "Texto para añadir a {{collection}}",
"size": "{{size}} de un máximo de {{max}}",
"note": "No se añade nada hasta que confirmes. Después el texto se divide en pasajes y se indexa, y cualquier chat que busque en esta colección puede usarlo.",
"editLabel": "Texto para añadir a {{target}}"
}
},
"proposalsPage": {
@@ -423,6 +423,10 @@
"title": "Changer le tableau de bord",
"description": "Le modèle peut proposer une nouvelle disposition de votre tableau de bord d'accueil. Vous confirmez chaque changement."
},
"knowledge_write": {
"title": "Ajouter à la connaissance",
"description": "Permet au modèle de proposer un texte pour une collection de votre Connaissance. Vous confirmez chaque texte ; il n'est jamais ajouté immédiatement."
},
"triggers_propose": {
"title": "Proposer aussi depuis les applications de messagerie",
"description": "Le modèle peut proposer un déclencheur quand quelqu'un le demande dans une application de messagerie. Vous le confirmez."
@@ -725,7 +729,8 @@
"profile": "Profil",
"skill": "Skill",
"page": "Page",
"dashboard": "Tableau de bord"
"dashboard": "Tableau de bord",
"knowledge": "Connaissance"
},
"scope": {
"user": "Toutes vos conversations",
@@ -738,7 +743,8 @@
"kind": "Type",
"target": "Fichier",
"source": "De",
"expires": "Expire dans"
"expires": "Expire dans",
"collection": "Collection"
},
"source": {
"chat": "Une conversation",
@@ -787,6 +793,12 @@
"dismissed": "Modification refusée.",
"undone": "Modification annulée.",
"asked": "L'assistant a été prié de la proposer à nouveau."
},
"knowledge": {
"label": "Texte à ajouter à {{collection}}",
"size": "{{size}} sur {{max}} au maximum",
"note": "Rien n'est ajouté tant que vous n'avez pas confirmé. Le texte est ensuite découpé en passages et indexé, et tout chat qui interroge cette collection peut l'utiliser.",
"editLabel": "Texte à ajouter à {{target}}"
}
},
"proposalsPage": {
@@ -423,6 +423,10 @@
"title": "Mengubah dasbor",
"description": "Model dapat mengusulkan tata letak baru untuk dasbor Beranda Anda. Anda mengonfirmasi setiap perubahan."
},
"knowledge_write": {
"title": "Tambahkan ke pengetahuan",
"description": "Memungkinkan model mengusulkan teks untuk koleksi Pengetahuan Anda. Anda mengonfirmasi setiap teks; teks tidak pernah ditambahkan langsung."
},
"triggers_propose": {
"title": "Usulkan juga dari aplikasi pesan",
"description": "Model dapat mengusulkan pemicu saat seseorang memintanya di aplikasi pesan. Anda mengonfirmasinya."
@@ -725,7 +729,8 @@
"profile": "Profil",
"skill": "Skill",
"page": "Halaman",
"dashboard": "Dasbor"
"dashboard": "Dasbor",
"knowledge": "Pengetahuan"
},
"scope": {
"user": "Semua obrolan Anda",
@@ -738,7 +743,8 @@
"kind": "Jenis",
"target": "Berkas",
"source": "Dari",
"expires": "Kedaluwarsa dalam"
"expires": "Kedaluwarsa dalam",
"collection": "Koleksi"
},
"source": {
"chat": "Obrolan",
@@ -787,6 +793,12 @@
"dismissed": "Perubahan ditolak.",
"undone": "Perubahan dibatalkan.",
"asked": "Asisten diminta mengusulkannya lagi."
},
"knowledge": {
"label": "Teks untuk ditambahkan ke {{collection}}",
"size": "{{size}} dari maksimal {{max}}",
"note": "Tidak ada yang ditambahkan sebelum Anda mengonfirmasi. Teks lalu dipecah menjadi bagian dan diindeks, dan setiap chat yang mencari di koleksi ini dapat memakainya.",
"editLabel": "Teks untuk ditambahkan ke {{target}}"
}
},
"proposalsPage": {
@@ -423,6 +423,10 @@
"title": "Cambiare la dashboard",
"description": "Il modello può proporre un nuovo layout della tua dashboard Home. Confermi tu ogni modifica."
},
"knowledge_write": {
"title": "Aggiungi alla conoscenza",
"description": "Permette al modello di proporre un testo per una raccolta della tua Conoscenza. Confermi ogni testo; non viene mai aggiunto subito."
},
"triggers_propose": {
"title": "Proponi anche dalle app di messaggistica",
"description": "Il modello può proporre un trigger quando qualcuno lo chiede in un'app di messaggistica. Lo confermi tu."
@@ -725,7 +729,8 @@
"profile": "Profilo",
"skill": "Skill",
"page": "Pagina",
"dashboard": "Dashboard"
"dashboard": "Dashboard",
"knowledge": "Conoscenza"
},
"scope": {
"user": "Tutte le tue chat",
@@ -738,7 +743,8 @@
"kind": "Tipo",
"target": "File",
"source": "Da",
"expires": "Scade tra"
"expires": "Scade tra",
"collection": "Raccolta"
},
"source": {
"chat": "Una chat",
@@ -787,6 +793,12 @@
"dismissed": "Modifica rifiutata.",
"undone": "Modifica annullata.",
"asked": "Ho chiesto all'assistente di riproporla."
},
"knowledge": {
"label": "Testo da aggiungere a {{collection}}",
"size": "{{size}} su un massimo di {{max}}",
"note": "Non viene aggiunto nulla finché non confermi. Poi il testo viene diviso in passaggi e indicizzato, e ogni chat che cerca in questa raccolta può usarlo.",
"editLabel": "Testo da aggiungere a {{target}}"
}
},
"proposalsPage": {
@@ -423,6 +423,10 @@
"title": "대시보드 변경",
"description": "모델이 홈 대시보드의 새 레이아웃을 제안할 수 있습니다. 변경마다 직접 확인합니다."
},
"knowledge_write": {
"title": "지식에 추가",
"description": "모델이 지식의 컬렉션에 넣을 텍스트를 제안할 수 있게 합니다. 하나씩 직접 확인하며, 바로 추가되는 일은 없습니다."
},
"triggers_propose": {
"title": "메신저 앱에서도 제안",
"description": "누군가 메신저 앱에서 요청하면 모델이 트리거를 제안할 수 있습니다. 직접 확인합니다."
@@ -725,7 +729,8 @@
"profile": "프로필",
"skill": "스킬",
"page": "페이지",
"dashboard": "대시보드"
"dashboard": "대시보드",
"knowledge": "지식"
},
"scope": {
"user": "내 모든 채팅",
@@ -738,7 +743,8 @@
"kind": "종류",
"target": "파일",
"source": "출처",
"expires": "만료까지"
"expires": "만료까지",
"collection": "컬렉션"
},
"source": {
"chat": "채팅",
@@ -787,6 +793,12 @@
"dismissed": "변경을 거절했습니다.",
"undone": "변경을 되돌렸습니다.",
"asked": "어시스턴트에게 다시 제안하도록 요청했습니다."
},
"knowledge": {
"label": "{{collection}}에 추가할 텍스트",
"size": "최대 {{max}} 중 {{size}}",
"note": "확인하기 전에는 아무것도 추가되지 않습니다. 확인하면 텍스트가 구절로 나뉘어 색인되며, 이 컬렉션을 검색하는 모든 채팅에서 사용할 수 있습니다.",
"editLabel": "{{target}}에 추가할 텍스트"
}
},
"proposalsPage": {
@@ -423,6 +423,10 @@
"title": "Alterar o painel",
"description": "O modelo pode propor um novo layout do seu painel inicial. Você confirma cada alteração."
},
"knowledge_write": {
"title": "Adicionar ao conhecimento",
"description": "Permite que o modelo proponha um texto para uma coleção do seu Conhecimento. Você confirma cada um; ele nunca é adicionado na hora."
},
"triggers_propose": {
"title": "Propor também de aplicativos de mensagens",
"description": "O modelo pode propor um gatilho quando alguém pede em um aplicativo de mensagens. Você o confirma."
@@ -725,7 +729,8 @@
"profile": "Perfil",
"skill": "Skill",
"page": "Página",
"dashboard": "Painel"
"dashboard": "Painel",
"knowledge": "Conhecimento"
},
"scope": {
"user": "Todos os seus chats",
@@ -738,7 +743,8 @@
"kind": "Tipo",
"target": "Arquivo",
"source": "De",
"expires": "Expira em"
"expires": "Expira em",
"collection": "Coleção"
},
"source": {
"chat": "Um chat",
@@ -787,6 +793,12 @@
"dismissed": "Alteração dispensada.",
"undone": "Alteração desfeita.",
"asked": "O assistente foi solicitado a propor de novo."
},
"knowledge": {
"label": "Texto a adicionar em {{collection}}",
"size": "{{size}} de no máximo {{max}}",
"note": "Nada é adicionado até você confirmar. Depois o texto é dividido em trechos e indexado, e qualquer chat que busque nesta coleção pode usá-lo.",
"editLabel": "Texto a adicionar em {{target}}"
}
},
"proposalsPage": {
@@ -423,6 +423,10 @@
"title": "修改仪表板",
"description": "模型可以提议你首页仪表板的新布局。每次修改由你确认。"
},
"knowledge_write": {
"title": "添加到知识库",
"description": "允许模型为你的知识库中的某个集合提出一段文本。每一条都由你确认,绝不会立即添加。"
},
"triggers_propose": {
"title": "也可从消息应用提议",
"description": "有人在消息应用中提出请求时,模型可以提议触发器。由你确认。"
@@ -725,7 +729,8 @@
"profile": "配置文件",
"skill": "技能",
"page": "页面",
"dashboard": "仪表板"
"dashboard": "仪表板",
"knowledge": "知识库"
},
"scope": {
"user": "你的所有聊天",
@@ -738,7 +743,8 @@
"kind": "类型",
"target": "文件",
"source": "来源",
"expires": "剩余时间"
"expires": "剩余时间",
"collection": "集合"
},
"source": {
"chat": "聊天",
@@ -787,6 +793,12 @@
"dismissed": "更改已拒绝。",
"undone": "更改已撤销。",
"asked": "已请助手重新提议。"
},
"knowledge": {
"label": "要添加到 {{collection}} 的文本",
"size": "{{size}},上限 {{max}}",
"note": "确认之前不会添加任何内容。确认后文本会被切分为段落并建立索引,所有搜索此集合的聊天都能使用它。",
"editLabel": "要添加到 {{target}} 的文本"
}
},
"proposalsPage": {
@@ -3,7 +3,8 @@ import { useCallback, useContext, useEffect, useId, useMemo, useState } from 're
import { Link } from 'react-router-dom'
import { chatChangesApi, filesApi } from '../../utils/api'
import {
appliedReceiptKey, byteText, CHANGES_EVENT, changeProposalView, KNOWN_KINDS, KNOWN_SCOPES, scopeOfProposal, sourceOfProposal, targetPath, timeLeft,
appliedReceiptKey, byteText, CHANGES_EVENT, changeProposalView, KNOWLEDGE_MAX_BYTES, KNOWN_KINDS, KNOWN_SCOPES, previewLines, scopeOfProposal, sourceOfProposal,
targetPath, timeLeft, writesElsewhere,
} from '../../utils/changeProposal'
import { diffLines, diffStats, withContext } from '../../utils/lineDiff'
import Icon from '../Icon'
@@ -80,6 +81,27 @@ function Diff({ before, after, target, t }) {
)
}
// The text a knowledge change would add to a collection: its first lines, how
// big it is against the cap, and what happens when it is confirmed. There is no
// file to compare it with, so no diff is drawn.
function KnowledgePreview({ p, t }) {
const text = p.content || ''
const { shown, more } = previewLines(text)
const size = new TextEncoder().encode(text).length
return (
<div className="cp-diff cp-knowledge" role="group" aria-label={t('changes.knowledge.label', { collection: p.target })} data-testid="change-knowledge-preview">
<p className="cp-diff__stat" data-testid="change-knowledge-size">{t('changes.knowledge.size', { size: byteText(size), max: byteText(KNOWLEDGE_MAX_BYTES) })}</p>
<ul className="cp-diff__lines">
{shown.map((line, i) => (
<li key={i} className="cp-diff__line" data-op="same"><code className="cp-diff__text">{line || ' '}</code></li>
))}
{more > 0 && <li className="cp-diff__gap" data-op="gap">{t('changes.diff.more', { count: more })}</li>}
</ul>
<p className="cx-help">{t('changes.knowledge.note')}</p>
</div>
)
}
// A change that a model proposed to something that lasts (a memory note, an
// agent profile, and the kinds that come later), drawn for any kind and any
// source: a tool line whose card is of kind change_proposal (card.id is the
@@ -108,7 +130,7 @@ export default function ChangeProposalCard({ id, initial, t, tick = 0 }) {
v = changeProposalView(null, e)
}
setView(v)
if (v.kind === 'pending') {
if (v.kind === 'pending' && !writesElsewhere(v.proposal)) {
try {
setCurrent(await filesApi.readText(targetPath(v.proposal), MAX_READ))
} catch (e) {
@@ -161,7 +183,13 @@ export default function ChangeProposalCard({ id, initial, t, tick = 0 }) {
const p = view.proposal
const target = p.target || ''
const meta = [
const knowledge = writesElsewhere(p)
// A knowledge change belongs to the person and names a collection, not a file.
const meta = knowledge ? [
['kind', t('changes.row.kind'), kindName(p, t)],
['target', t('changes.row.collection'), <code key="c">{target}</code>],
['source', t('changes.row.source'), sourceName(p, t)],
] : [
['kind', t('changes.row.kind'), `${kindName(p, t)} · ${scopeName(p, t)}`],
['target', t('changes.row.target'), <code key="c">{target}</code>],
['source', t('changes.row.source'), sourceName(p, t)],
@@ -173,7 +201,7 @@ export default function ChangeProposalCard({ id, initial, t, tick = 0 }) {
<Icon name="check-circle" className="cx-receipt__icon" />
<div className="cx-receipt__main">
<p className="cx-receipt__line"><b>{t(appliedReceiptKey(p))}</b> <span data-testid="change-summary">{p.summary}</span></p>
<p className="cx-receipt__sub"><span>{kindName(p, t)} · {scopeName(p, t)}</span><code>{target}</code></p>
<p className="cx-receipt__sub"><span>{knowledge ? kindName(p, t) : `${kindName(p, t)} · ${scopeName(p, t)}`}</span><code>{target}</code></p>
{alert}
</div>
<span className="cx-receipt__acts">
@@ -223,17 +251,16 @@ export default function ChangeProposalCard({ id, initial, t, tick = 0 }) {
))}
{expiry && <div className="cx-plan__row" data-row="expires"><dt>{t('changes.row.expires')}</dt><dd>{expiry}</dd></div>}
</dl>
{current === undefined ? null : (edit === null
? <Diff before={current ?? ''} after={p.content || ''} target={target} t={t} />
: null)}
{current === null && <p className="cx-help">{t('changes.unreadFile')}</p>}
{knowledge && edit === null && <KnowledgePreview p={p} t={t} />}
{!knowledge && current !== undefined && edit === null && <Diff before={current ?? ''} after={p.content || ''} target={target} t={t} />}
{!knowledge && current === null && <p className="cx-help">{t('changes.unreadFile')}</p>}
{edit !== null ? (
<form
className="cx-proposal__edit cp-edit"
onSubmit={(e) => { e.preventDefault(); act('confirm', () => chatChangesApi.confirm(p.id, edit), t('changes.announce.applied')) }}
onKeyDown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); setEdit(null) } }}
>
<label className="cx-field__label" htmlFor={`${uid}-text`}>{t('changes.editLabel', { target })}</label>
<label className="cx-field__label" htmlFor={`${uid}-text`}>{t(knowledge ? 'changes.knowledge.editLabel' : 'changes.editLabel', { target })}</label>
<textarea
id={`${uid}-text`} className="cx-in cp-edit__text" rows={Math.min(14, Math.max(5, edit.split('\n').length + 1))}
value={edit} spellCheck={false} autoFocus data-testid="change-edit-text"
+17 -2
View File
@@ -27,7 +27,22 @@ export function changeProposalView(p, err, now = Date.now()) {
if (!known.includes(p?.status)) return { kind: 'error', message: `unknown change state ${p?.status}` }
let kind = p.status
if (kind === 'pending' && p.expires && Date.parse(p.expires) <= now) kind = 'expired'
return { kind, proposal: p, canUndo: kind === 'applied' && !!p.rev }
return { kind, proposal: p, canUndo: kind === 'applied' && (!!p.rev || writesElsewhere(p)) }
}
// A knowledge change adds an entry to a collection instead of writing a file of
// the home, so it has no change set (rev stays 0) and its target is the name
// of the collection, not a path. Undo removes the entry.
export const writesElsewhere = (p) => p?.kind === 'knowledge'
// The most a knowledge text may hold, as the server bounds it
// (contract.MaxKnowledgeBytes).
export const KNOWLEDGE_MAX_BYTES = 64 * 1024
// The first lines of a text, for the preview of a knowledge change.
export function previewLines(text, max = 20) {
const lines = String(text || '').replace(/\s+$/, '').split('\n')
return { shown: lines.slice(0, max), more: Math.max(0, lines.length - max) }
}
// The key of the words that open the receipt of an applied change. A note that
@@ -69,7 +84,7 @@ export function timeLeft(p, now = Date.now()) {
}
// A kind the card has no wording for is shown by its raw name.
export const KNOWN_KINDS = ['memory', 'profile', 'skill', 'page', 'dashboard']
export const KNOWN_KINDS = ['memory', 'profile', 'skill', 'page', 'dashboard', 'knowledge']
export const KNOWN_SCOPES = ['user', 'agent', 'channel']
// A short, readable size.
+4 -1
View File
@@ -11,6 +11,9 @@ export const SAVED_AGENTS_GROUP = 'saved_agents'
export const GROUP_OPTIONS = {
triggers: ['triggers.propose', 'triggers.run'],
changes: ['memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write'],
// The grant to add a text to a collection sits under the Knowledge switch,
// where the collections are.
collections: ['knowledge.write'],
files: ['files.upload', 'act.files'],
}
@@ -24,7 +27,7 @@ export const GRANT_SECTIONS = [
// The grants a messaging connection also has to allow on its own permissions
// page (the capabilities of a binding). The act.* grants are the agent's alone.
export const BINDING_CAPS = new Set([
'memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write',
'memory.write', 'profile.write', 'skills.write', 'pages.write', 'dashboard.write', 'knowledge.write',
'files.upload', 'triggers.propose', 'triggers.run', 'talkback.channels', 'talkback.new_channel',
])
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
appliedReceiptKey, byteText, changeProposalIds, changeProposalView, sourceOfProposal, targetPath, timeLeft,
appliedReceiptKey, byteText, changeProposalIds, changeProposalView, KNOWLEDGE_MAX_BYTES, KNOWN_KINDS, previewLines, sourceOfProposal, targetPath, timeLeft,
} from '../../src/utils/changeProposal.js'
import { diffLines, diffStats, splitLines, withContext } from '../../src/utils/lineDiff.js'
import { allFiles, groupKind, indexFits, indexUse } from '../../src/utils/memoryFiles.js'
@@ -141,3 +141,22 @@ test('change proposals: a note the host saved at once says so, a confirmed one s
assert.equal(v.kind, 'applied')
assert.equal(v.canUndo, true, 'an automatic note offers Undo')
})
test('change proposals: a knowledge change has no change set but can still be undone', () => {
const k = proposal({ kind: 'knowledge', target: 'manuals', status: 'applied', rev: 0 })
assert.equal(changeProposalView(k, null, NOW).canUndo, true)
assert.equal(changeProposalView(proposal({ kind: 'memory', status: 'applied', rev: 0 }), null, NOW).canUndo, false)
assert.equal(changeProposalView({ ...k, status: 'reverted' }, null, NOW).canUndo, false)
assert.ok(KNOWN_KINDS.includes('knowledge'))
})
test('change proposals: the preview of a knowledge text shows 20 lines and counts the rest', () => {
const text = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join('\n') + '\n'
const { shown, more } = previewLines(text)
assert.equal(shown.length, 20)
assert.equal(shown[19], 'line 20')
assert.equal(more, 5)
assert.deepEqual(previewLines('one\n\n'), { shown: ['one'], more: 0 })
assert.deepEqual(previewLines(null), { shown: [''], more: 0 })
assert.equal(KNOWLEDGE_MAX_BYTES, 65536)
})
+2
View File
@@ -24,6 +24,7 @@ const (
GrantSkillsWrite = contract.CapSkillsWrite
GrantPagesWrite = contract.CapPagesWrite
GrantDashboardWrite = contract.CapDashboardWrite
GrantKnowledgeWrite = contract.CapKnowledgeWrite
GrantTriggersPropose = contract.CapTriggersPropose
GrantTriggersRun = contract.CapTriggersRun
GrantTalkbackChannels = contract.CapTalkbackChannels
@@ -60,6 +61,7 @@ var grants = []Grant{
{GrantSkillsWrite, "Write skills", "The agent can propose a new or changed skill for itself or for you. You confirm each one, and a skill you locked stays as it is.", true},
{GrantPagesWrite, "Create pages", "The agent can propose a page: a small app that opens in a sandboxed frame. You see its files and confirm.", true},
{GrantDashboardWrite, "Change the dashboard", "The agent can propose a new layout of your Home dashboard. You confirm each change.", true},
{GrantKnowledgeWrite, "Add to knowledge", "The agent can propose a text for one of its collections. The host indexes it only after you confirm, and never at once.", true},
{GrantTriggersPropose, "Propose triggers", "The agent can propose a task that runs by itself, from a messaging app too. You confirm it.", false},
{GrantTriggersRun, "Run triggers", "The triggers of this agent may start from a messaging app.", false},
{GrantTalkbackChannels, "Reply on known channels", "The agent can send a message to a channel it already talks in.", false},
+1 -1
View File
@@ -232,7 +232,7 @@ func clipText(s string, n int) string {
// registerChangeKinds adds the kinds that go beyond a file name to the host.
func (c *Changes) registerChangeKinds() error {
for _, k := range []proposals.Kind{skillKind, pageKind, dashboardKind} {
for _, k := range []proposals.Kind{skillKind, pageKind, dashboardKind, c.knowledgeKind()} {
if err := c.kinds.Register(k); err != nil {
return err
}
+10 -3
View File
@@ -44,8 +44,10 @@ import (
// when the person said ask.
// - never applies to every origin: it is the person's choice, and refusing is
// the narrow side.
// - Only the memory kind reads the setting. Profile, skill, page and
// dashboard changes always ask.
// - Only the memory kind reads the setting to decide how it is saved.
// Profile, skill, page, dashboard and knowledge changes always ask.
// Never also takes the knowledge kind away; Always confirm changes
// nothing for it, since it always asks.
//
// The setting belongs to an add-on, not to the host: an add-on declares a user
// setting with the role contract.UserSettingRoleMemorySave in its manifest,
@@ -382,9 +384,14 @@ func memoryKindOf(kind string) bool { return kind == string(contract.ChangeMemor
// withoutMemory removes the memory kind from a list.
func withoutMemory(kinds []contract.ChangeKind) []contract.ChangeKind {
return withoutKind(kinds, contract.ChangeMemory)
}
// withoutKind returns the kinds without one.
func withoutKind(kinds []contract.ChangeKind, drop contract.ChangeKind) []contract.ChangeKind {
out := make([]contract.ChangeKind, 0, len(kinds))
for _, k := range kinds {
if k != contract.ChangeMemory {
if k != drop {
out = append(out, k)
}
}
+83 -7
View File
@@ -189,13 +189,19 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change
case p.Summary == "":
return contract.ChangeProposal{}, fmt.Errorf("%w: give a short summary of the change", ErrBadChange)
}
snap, err := c.snapshot(ctx, p.Owner, kind, rel, in.Content)
if err != nil {
return contract.ChangeProposal{}, err
var snap snap
if kind.Dest == nil {
if snap, err = c.snapshot(ctx, p.Owner, kind, rel, in.Content); err != nil {
return contract.ChangeProposal{}, err
}
}
if err := c.check(ctx, p.Owner, kind, aim, rel, in.Content, snap); err != nil {
return contract.ChangeProposal{}, err
}
if err := c.prepare(ctx, kind, p); err != nil {
c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error())
return contract.ChangeProposal{}, err
}
p.BaseHash = snap.hash
if snap.same {
return contract.ChangeProposal{}, fmt.Errorf("%w: %s has this content already", ErrBadChange, rel)
@@ -214,6 +220,18 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change
return p, nil
}
// prepare asks the destination of a kind that has one whether it can take the
// change now. A kind that writes to the home has nothing to ask.
func (c *Changes) prepare(ctx context.Context, kind proposals.Kind, p contract.ChangeProposal) error {
if kind.Dest == nil || kind.Dest.Prepare == nil {
return nil
}
if err := kind.Dest.Prepare(ctx, proposals.ApplyInput{Owner: p.Owner, Proposal: p}); err != nil {
return fmt.Errorf("%w: %s", ErrBadChange, err)
}
return nil
}
// snap is the state of what a change would write, for the base hash.
type snap struct {
hash string // "" when no target file exists
@@ -301,7 +319,7 @@ func targetName(p contract.ChangeProposal) string {
switch p.Kind {
case contract.ChangeSkill:
t = strings.TrimSuffix(t, "/SKILL.md")
case contract.ChangeMemory, contract.ChangeProfile, contract.ChangeDashboard:
case contract.ChangeMemory, contract.ChangeProfile, contract.ChangeDashboard, contract.ChangeKnowledge:
return t
}
if i := strings.LastIndexByte(t, '/'); i >= 0 {
@@ -378,6 +396,9 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string)
}
p.Content = *edited
}
if kind.Dest != nil {
return c.confirmExternal(ctx, owner, p, kind)
}
// The base hash covers the files the model proposed; an edit by the owner
// may change the files to write, not what the proposal was based on.
was, err := c.snapshot(ctx, owner, kind, p.Target, orig)
@@ -426,6 +447,35 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string)
return c.store.Get(ctx, owner, id)
}
// confirmExternal applies a proposal of a kind with a destination of its own.
// It has no base hash and no change set: the destination is asked again, since
// it may have changed since the proposal, and the write is claimed first so two
// confirmations apply once.
func (c *Changes) confirmExternal(ctx context.Context, owner string, p contract.ChangeProposal, kind proposals.Kind) (contract.ChangeProposal, error) {
if err := c.check(ctx, owner, kind, aimOf(p), p.Target, p.Content, snap{}); err != nil {
return contract.ChangeProposal{}, err
}
if err := c.prepare(ctx, kind, p); err != nil {
return contract.ChangeProposal{}, err
}
won, err := c.store.Settle(ctx, owner, p.ID, contract.ProposalPending, contract.ProposalApplied, c.s.now().UTC(), 0)
if err != nil {
return contract.ChangeProposal{}, err
}
if !won {
return contract.ChangeProposal{}, ErrChangeAnswered
}
if err := kind.Dest.Apply(ctx, proposals.ApplyInput{Owner: owner, Proposal: p}); err != nil {
if _, rerr := c.store.Settle(ctx, owner, p.ID, contract.ProposalApplied, contract.ProposalPending, time.Time{}, 0); rerr != nil {
c.s.log.Warn("studio: could not reopen a change that failed to apply", "proposal", p.ID, "error", rerr)
}
return contract.ChangeProposal{}, fmt.Errorf("studio: could not apply the change: %w", err)
}
p.Status = contract.ProposalApplied
c.audit(ctx, proposals.ActionConfirm, p, 0, "")
return c.store.Get(ctx, owner, p.ID)
}
// write puts the files of a change into the home as one change set.
func (c *Changes) write(ctx context.Context, owner string, fs []proposals.FileWrite, title string, max int64) (int64, error) {
if len(fs) == 1 {
@@ -466,9 +516,13 @@ func (c *Changes) Revert(ctx context.Context, user, id string) (contract.ChangeP
if err != nil {
return contract.ChangeProposal{}, err
}
if p.Status != contract.ProposalApplied || p.Rev == 0 {
kind, _ := c.kinds.Lookup(string(p.Kind))
if p.Status != contract.ProposalApplied || (p.Rev == 0 && kind.Dest == nil) {
return contract.ChangeProposal{}, ErrNotApplied
}
if kind.Dest != nil {
return c.revertExternal(ctx, owner, p, kind)
}
now := c.s.now().UTC()
won, err := c.store.Settle(ctx, owner, id, contract.ProposalApplied, contract.ProposalReverted, now, 0)
if err != nil {
@@ -495,15 +549,37 @@ func (c *Changes) Revert(ctx context.Context, user, id string) (contract.ChangeP
return c.store.Get(ctx, owner, id)
}
// revertExternal takes back a change of a kind with a destination of its own.
func (c *Changes) revertExternal(ctx context.Context, owner string, p contract.ChangeProposal, kind proposals.Kind) (contract.ChangeProposal, error) {
now := c.s.now().UTC()
won, err := c.store.Settle(ctx, owner, p.ID, contract.ProposalApplied, contract.ProposalReverted, now, 0)
if err != nil {
return contract.ChangeProposal{}, err
}
if !won {
return contract.ChangeProposal{}, ErrNotApplied
}
if err := kind.Dest.Undo(ctx, proposals.ApplyInput{Owner: owner, Proposal: p}); err != nil {
if _, rerr := c.store.Settle(ctx, owner, p.ID, contract.ProposalReverted, contract.ProposalApplied, p.Answered, 0); rerr != nil {
c.s.log.Warn("studio: could not reopen a change that failed to revert", "proposal", p.ID, "error", rerr)
}
return contract.ChangeProposal{}, fmt.Errorf("studio: could not revert the change: %w", err)
}
c.audit(ctx, proposals.ActionRevert, p, 0, "")
return c.store.Get(ctx, owner, p.ID)
}
// RevertLast reverts the change applied last for the user in the scope of
// agent: the changes proposed in chats of that agent, or in plain chats when
// agent is empty.
func (c *Changes) RevertLast(ctx context.Context, user, agent string) (contract.ChangeProposal, error) {
// The kinds in skip are left out: a chat that may not touch them does not undo
// them either.
func (c *Changes) RevertLast(ctx context.Context, user, agent string, skip ...contract.ChangeKind) (contract.ChangeProposal, error) {
applied, err := c.List(ctx, user, contract.ProposalApplied)
if err != nil {
return contract.ChangeProposal{}, err
}
applied = slices.DeleteFunc(applied, func(p contract.ChangeProposal) bool { return p.Agent != agent })
applied = slices.DeleteFunc(applied, func(p contract.ChangeProposal) bool { return p.Agent != agent || slices.Contains(skip, p.Kind) })
if len(applied) == 0 {
return contract.ChangeProposal{}, ErrNothingToRevert
}
+61 -7
View File
@@ -34,10 +34,11 @@ const proposeChangeDescription = "Ask to change something that lasts: a note in
"content is the full new text of the file, not a patch: read the current file first and keep what stays. " +
"kind is memory (a Markdown file under Memory/), profile (the agent's standing rules, at most 4 KiB, scope agent), " +
"skill (a full SKILL.md with name and description, target is the skill name, scope user or agent; a skill its owner locked cannot be changed), " +
"page (a small app: target is the page name, scope user, files is an object of path to text and must hold index.html) " +
"or dashboard (the full home.json, scope user; it must follow the schema of the localai-workspace skill). " +
"page (a small app: target is the page name, scope user, files is an object of path to text and must hold index.html), " +
"dashboard (the full home.json, scope user; it must follow the schema of the localai-workspace skill) " +
"or knowledge (a document or long text to look up later, not a short fact about the person: target is the name of a collection from list_collections, scope user, content is the text, at most 64 KiB; the owner always confirms it before it is indexed). " +
scopeSentence +
"target is the file name for memory, such as index.md or food.md, the name for a skill or a page; a profile and the dashboard have no target. " +
"target is the file name for memory, such as index.md or food.md, the name for a skill or a page, the collection for knowledge; a profile and the dashboard have no target. " +
"summary is one line that tells the owner what changes."
// proposeChangeSchema is the schema of the tool for a chat that may propose
@@ -52,6 +53,7 @@ var kindScopes = map[contract.ChangeKind][]string{
contract.ChangeSkill: {"user", "agent"},
contract.ChangePage: {"user"},
contract.ChangeDashboard: {"user"},
contract.ChangeKnowledge: {"user"},
}
// proposeChangeSchemaFor is proposeChangeSchema for a chat whose agent has a
@@ -84,7 +86,7 @@ func proposeChangeSchemaFor(kinds []contract.ChangeKind, memory []string) string
return `{"type":"object","properties":{` +
`"kind":{"type":"string","enum":` + string(enum) + `},` +
`"scope":{"type":"string","enum":` + scopes + `},` +
`"target":{"type":"string","maxLength":64,"description":"File name for a memory change, such as index.md; the name of a skill or a page."},` +
`"target":{"type":"string","maxLength":64,"description":"File name for a memory change, such as index.md; the name of a skill or a page; the collection for a knowledge change."},` +
`"content":{"type":"string","description":"The full new text of the file. For a page use files instead."},` +
`"files":{"type":"object","additionalProperties":{"type":"string"},"description":"For a page: file paths inside the page folder and their text, such as index.html."},` +
`"summary":{"type":"string","minLength":1,"maxLength":160,"description":"One line for the owner."}},` +
@@ -153,6 +155,9 @@ func (s *Service) registerChangeTools() error {
if !s.memoryWritable(ctx, c) {
kinds = withoutMemory(kinds)
}
if !s.knowledgeOffered(ctx, c) {
kinds = withoutKind(kinds, contract.ChangeKnowledge)
}
if len(kinds) == 0 {
return session.Tool{}, false, "this agent was not given the right to change anything"
}
@@ -161,7 +166,7 @@ func (s *Service) registerChangeTools() error {
// The person can turn memory notes off: the model then does
// not see the memory kind and cannot propose one.
if s.changes.saveModeOf(ctx, c) == SaveNever {
kinds = withoutMemory(kinds)
kinds = withoutKind(withoutMemory(kinds), contract.ChangeKnowledge)
if len(kinds) == 0 {
return session.Tool{}, false, "saving to memory is turned off in the settings of the person"
}
@@ -201,7 +206,7 @@ func memoryScopeWords(scope string) string {
// changeKindCaps are the capabilities that the kinds of change need in a
// connector chat. The tool is offered when the sender holds one of them; the
// call checks the one its kind needs.
var changeKindCaps = []string{contract.CapMemoryWrite, contract.CapProfileWrite, contract.CapSkillsWrite, contract.CapPagesWrite, contract.CapDashboardWrite}
var changeKindCaps = []string{contract.CapMemoryWrite, contract.CapProfileWrite, contract.CapSkillsWrite, contract.CapPagesWrite, contract.CapDashboardWrite, contract.CapKnowledgeWrite}
// changeCapability is the capability that a change of a kind needs. A kind
// that is not listed needs one that nobody holds.
@@ -215,6 +220,8 @@ func changeCapability(kind string) string {
return contract.CapPagesWrite
case "dashboard":
return contract.CapDashboardWrite
case "knowledge":
return contract.CapKnowledgeWrite
}
return contract.CapSkillsWrite
}
@@ -243,6 +250,11 @@ func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.Raw
if err := c.Require(changeCapability(args.Kind)); err != nil {
return "", err
}
if args.Kind == string(contract.ChangeKnowledge) {
if err := h.knowledgeGate(ctx, c, &args.Scope); err != nil {
return "", err
}
}
if memoryKindOf(args.Kind) && !h.s.memoryWritable(ctx, c) {
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.")
}
@@ -342,6 +354,39 @@ func (h *Changes) saveNow(ctx context.Context, in ProposeInput, turn *autoTurn)
"To undo it, call revert_change with this id.", done.Summary, done.Kind, done.Scope, done.ID), nil
}
// knowledgeGate is the check of the tool for a knowledge change, on top of the
// grant and the capability. The level of the person for memory applies: Never
// takes the kind away. Always confirm and Ask change nothing here, because a
// knowledge change is never saved at once.
func (h *Changes) knowledgeGate(ctx context.Context, c builtins.Chat, scope *string) error {
if h.saveModeOf(ctx, c) == SaveNever {
h.audit(ctx, proposals.ActionRefuse, contract.ChangeProposal{Owner: principal(c.Actor.Principal), Agent: c.AgentID, Kind: contract.ChangeKnowledge,
Scope: contract.ScopeUser, Source: h.sourceOf(c)}, 0, "the person turned saving off")
return errors.New("the person turned off saving, so nothing was proposed. Do not try again; say so in plain words if they ask.")
}
if !h.s.knowledgeOffered(ctx, c) {
return errors.New("adding to Knowledge is not available here, so nothing was proposed. Do not try again; say so in plain words if they ask.")
}
if *scope == "" {
*scope = string(contract.ScopeUser)
}
return nil
}
// knowledgeOffered says whether a chat may propose a knowledge change at all:
// the server can add to collections, the Knowledge switch of the chat is on,
// and an agent has at least one collection on its list.
func (s *Service) knowledgeOffered(ctx context.Context, c builtins.Chat) bool {
if !s.knowledgeAvailable(ctx, c.Actor.Principal) || slices.Contains(c.GroupsOff, builtins.GroupCollections) {
return false
}
if c.AgentID == "" || s.d.Agents == nil {
return true
}
ag, err := s.d.Agents.Get(ctx, principal(c.Actor.Principal), c.AgentID)
return err == nil && len(ag.Collections) > 0
}
func (h *Changes) revertTool(ctx context.Context, c builtins.Chat, raw json.RawMessage) (string, error) {
var args struct {
ID string `json:"id"`
@@ -390,12 +435,21 @@ func (h *Changes) revertTool(ctx context.Context, c builtins.Chat, raw json.RawM
} else {
// The last change can be of any kind: the sender needs the right to
// make each.
// Knowledge is an optional kind: a sender without it can still undo
// the last change, which then skips the knowledge ones.
var skip []contract.ChangeKind
if held, _ := c.Holds(contract.CapKnowledgeWrite); !held {
skip = append(skip, contract.ChangeKnowledge)
}
for _, need := range changeKindCaps {
if need == contract.CapKnowledgeWrite && len(skip) > 0 {
continue
}
if err := c.Require(need); err != nil {
return "", fmt.Errorf("%w (to undo the last change without its id, every kind of change must be allowed)", err)
}
}
p, err = h.RevertLast(ctx, c.Actor.Principal, c.AgentID)
p, err = h.RevertLast(ctx, c.Actor.Principal, c.AgentID, skip...)
}
if err != nil {
return "", changeErr(err)
+16 -2
View File
@@ -54,6 +54,8 @@ func kindWords(v hostmsg.Voice, k contract.ChangeKind) string {
return v.T("change.kind.page")
case contract.ChangeDashboard:
return v.T("change.kind.dashboard")
case contract.ChangeKnowledge:
return v.T("change.kind.knowledge")
}
return v.T("change.kind.other")
}
@@ -110,6 +112,11 @@ func (h *Changes) proposalChatText(ctx context.Context, v hostmsg.Voice, p contr
switch p.Kind {
case contract.ChangePage:
shown = v.T("change.page_files")
case contract.ChangeKnowledge:
// The text is new to the collection: show its first lines.
if lines := addedLines("", p.Content); len(lines) > 0 {
shown = excerptOf(lines)
}
default:
if st, err := h.snapshot(ctx, p.Owner, kind, p.Target, p.Content); err == nil {
if add := addedLines(st.current, p.Content); len(add) > 0 {
@@ -121,7 +128,11 @@ func (h *Changes) proposalChatText(ctx context.Context, v hostmsg.Voice, p contr
}
}
var b strings.Builder
b.WriteString(v.T("change.propose", "kind", kindWords(v, p.Kind), "scope", scopeWords(v, p.Scope), "summary", p.Summary))
if p.Kind == contract.ChangeKnowledge {
b.WriteString(v.T("change.propose_knowledge", "collection", p.Target, "summary", p.Summary))
} else {
b.WriteString(v.T("change.propose", "kind", kindWords(v, p.Kind), "scope", scopeWords(v, p.Scope), "summary", p.Summary))
}
if shown != "" {
b.WriteString("\n" + shown)
}
@@ -278,8 +289,11 @@ func (c *Connectors) answerChange(ctx context.Context, b connectors.Binding, m c
return v.T("proposal.failed", "error", changeErr(err)), true
}
key := "change.done"
if done.Kind == contract.ChangeMemory {
switch done.Kind {
case contract.ChangeMemory:
key = "change.saved"
case contract.ChangeKnowledge:
return v.T("change.knowledge_done", "collection", done.Target, "summary", done.Summary, "minutes", int(cancelWindow/time.Minute)), true
}
return v.T(key, "summary", done.Summary, "minutes", int(cancelWindow/time.Minute)), true
}
+35 -3
View File
@@ -105,6 +105,14 @@ var toolGroupOf = map[string]string{
contract.CapSkillsWrite: builtins.GroupChanges,
contract.CapPagesWrite: builtins.GroupChanges,
contract.CapDashboardWrite: builtins.GroupChanges,
contract.CapKnowledgeWrite: builtins.GroupChanges,
}
// extraToolGroupOf names a second group that a capability also needs: a
// knowledge text is proposed with the change tools but searched and indexed
// through the Knowledge group, and the tool picker nests the grant under it.
var extraToolGroupOf = map[string]string{
contract.CapKnowledgeWrite: builtins.GroupCollections,
}
// toolsSwitch is the CapabilitySwitch of the Tools switch: a capability whose
@@ -120,10 +128,34 @@ func toolsSwitch(s *Service) CapabilitySwitch {
if err != nil {
return Block{} // the grants switch reports an agent it cannot read
}
if slices.Contains(ag.BuiltinsOff, group) || (s.builtins.IsOptIn(group) && !slices.Contains(ag.BuiltinsOn, group)) {
return Block{Code: CodeToolsSwitchOff, Reason: "the " + group + " tools are switched off for the agent of this bot",
Fix: &Fix{Kind: FixOpenAgent, Target: b.AgentID, Field: "tools:" + group}}
for _, g := range []string{group, extraToolGroupOf[capability]} {
if g == "" {
continue
}
if slices.Contains(ag.BuiltinsOff, g) || (s.builtins.IsOptIn(g) && !slices.Contains(ag.BuiltinsOn, g)) {
return Block{Code: CodeToolsSwitchOff, Reason: "the " + g + " tools are switched off for the agent of this bot",
Fix: &Fix{Kind: FixOpenAgent, Target: b.AgentID, Field: "tools:" + g}}
}
}
return Block{}
}
}
// knowledgeSwitch is the CapabilitySwitch of adding to Knowledge: the server
// must be able to index a text, and the agent of the binding needs a
// collection on its list. Other capabilities pass.
func knowledgeSwitch(s *Service) CapabilitySwitch {
return func(ctx context.Context, b connectors.Binding, capability string) Block {
if capability != contract.CapKnowledgeWrite {
return Block{}
}
code, reason := s.knowledgeBlock(ctx, b.Owner, b.AgentID)
switch code {
case "":
return Block{}
case CodeKnowledgeNone:
return Block{Code: code, Reason: reason, Fix: &Fix{Kind: FixOpenAgent, Target: b.AgentID, Field: "collections"}}
}
return Block{Code: code, Reason: reason}
}
}
+15 -1
View File
@@ -49,13 +49,20 @@ const (
// CodeContextRule: the capability is held but this place or thread is not
// one the feature allows.
CodeContextRule = "context_rule"
// CodeKnowledgeNone: the agent has no collection on its list, so there is
// nowhere to add a text.
CodeKnowledgeNone = "knowledge_none"
// CodeKnowledgeNoEmbedding: the model that indexes Knowledge is not
// installed on the server.
CodeKnowledgeNoEmbedding = "knowledge_no_embedding"
)
// BlockCodes lists every code, in the order of the chain.
func BlockCodes() []string {
return []string{CodeNoAgent, CodeUnknownCapability, CodeServerSettingOff, CodeToolsSwitchOff, CodeAgentPermissionOff,
CodeAgentUnavailable, CodeConnectionPermissionOff, CodeSenderNotListed, CodeSenderNeedsPairing,
CodeChannelNotTrusted, CodeRoleNotGranted, CodeKindNotRunning, CodeContextRule}
CodeChannelNotTrusted, CodeRoleNotGranted, CodeKindNotRunning, CodeContextRule,
CodeKnowledgeNone, CodeKnowledgeNoEmbedding}
}
// Who holds the link of the chain that refused.
@@ -160,6 +167,12 @@ var blockTable = map[string]blockInfo{
CodeKindNotRunning: {BySender,
"The app for this connection is not running, so roles cannot be checked. Start the app.",
"The app for this bot is not running, so it cannot check what you may do."},
CodeKnowledgeNone: {ByAgent,
"No collection is on the list of the agent, so the bot has nowhere to {action}. Add a collection to the agent.",
"This bot has no collection to {action} yet."},
CodeKnowledgeNoEmbedding: {ByServer,
"The embedding model for Knowledge is not installed, so the bot cannot {action}. Install it in Models.",
"The server cannot index new texts right now, so the bot cannot {action}."},
CodeContextRule: {ByConnection,
"The bot cannot {action} in this place.",
"This bot cannot {action} in this place."},
@@ -184,6 +197,7 @@ var capabilityWords = map[string]capabilityPhrase{
contract.CapSkillsWrite: {"save skills", "saving skills"},
contract.CapPagesWrite: {"publish pages", "publishing pages"},
contract.CapDashboardWrite: {"change the dashboard", "changing the dashboard"},
contract.CapKnowledgeWrite: {"write to knowledge", "writing to knowledge"},
contract.CapToolsWrite: {"change things in other services with add-on tools", "changes made by add-on tools"},
}
+1 -1
View File
@@ -195,7 +195,7 @@ func NewConnectors(svc *Service, o ConnectorOptions) (*Connectors, error) {
o.Logger = slog.Default()
}
return &Connectors{svc: svc, o: o, log: o.Logger, loops: map[string]context.CancelFunc{}, senders: map[string]*senderState{}, nextEdit: map[string]time.Time{},
wake: make(chan struct{}, 1), done: make(chan struct{}), gates: []CapabilitySwitch{agentGrantSwitch(svc), toolsSwitch(svc)}}, nil
wake: make(chan struct{}, 1), done: make(chan struct{}), gates: []CapabilitySwitch{agentGrantSwitch(svc), toolsSwitch(svc), knowledgeSwitch(svc)}}, nil
}
// Notify asks Run to look at the bindings now.
+1 -1
View File
@@ -65,7 +65,7 @@ var _ = Describe("builtin grants", func() {
Expect(g.Title).NotTo(BeEmpty(), g.Name)
Expect(g.Description).NotTo(BeEmpty(), g.Name)
}
Expect(names).To(Equal([]string{"memory.write", "profile.write", "skills.write", "pages.write", "dashboard.write",
Expect(names).To(Equal([]string{"memory.write", "profile.write", "skills.write", "pages.write", "dashboard.write", "knowledge.write",
"triggers.propose", "triggers.run", "talkback.channels", "talkback.new_channel", "files.upload",
"act.files", "act.addons", "act.send", "act.spend", "saved_agents", "history.read"}))
})
+2
View File
@@ -52,6 +52,8 @@ func (s *Service) grantUsable(ctx context.Context, user, grant string) bool {
return false
}
return s.changes.grantsAKind(grant)
case builtins.GrantKnowledgeWrite:
return s.changes != nil && s.changes.grantsAKind(grant) && s.knowledgeAvailable(ctx, user)
case builtins.GrantTriggersPropose, builtins.GrantTriggersRun:
return s.triggers != nil && s.mayUseTriggers(ctx, user) == nil
case builtins.GrantFilesUpload, builtins.GrantActFiles:
@@ -62,6 +62,7 @@
"change.kind.skill": "einen Skill",
"change.kind.page": "eine Seite",
"change.kind.dashboard": "eine Änderung des Home-Dashboards",
"change.kind.knowledge": "ein Text für das Wissen",
"change.kind.other": "eine Änderung",
"change.scope.agent": "nur dieser Agent",
"change.scope.channel": "nur diese Unterhaltung",
@@ -69,6 +70,8 @@
"change.page_files": "eine Seite mit ihren Dateien",
"change.only_removes": "(es werden nur Zeilen entfernt)",
"change.propose": "Ich kann {kind} speichern ({scope}): {summary}",
"change.propose_knowledge": "Ich kann einen Text zu Ihrem Wissen hinzufügen, in die Sammlung \"{collection}\": {summary}",
"change.knowledge_done": "Hinzugefügt zu {collection}: {summary}. Antworten Sie innerhalb von {minutes} Minuten mit \"rückgängig\", um es zurückzunehmen.",
"change.ask": "Es wird nichts gespeichert, bevor du antwortest. Antworte hier innerhalb von 24 Stunden mit \"ja\", um zu speichern, oder mit \"nein\", um zu verwerfen. Wenn mehrere warten, antworte mit \"bestätigen {code}\" oder \"abbrechen {code}\".",
"trigger.where.inbox": "in die Inbox von Studio",
"trigger.where.chat": "in diesen Chat",
@@ -62,6 +62,7 @@
"change.kind.skill": "a skill",
"change.kind.page": "a page",
"change.kind.dashboard": "a change of the Home dashboard",
"change.kind.knowledge": "a text for Knowledge",
"change.kind.other": "a change",
"change.scope.agent": "this agent only",
"change.scope.channel": "this conversation only",
@@ -69,6 +70,8 @@
"change.page_files": "a page with its files",
"change.only_removes": "(it only removes lines)",
"change.propose": "I can save {kind} ({scope}): {summary}",
"change.propose_knowledge": "I can add a text to your Knowledge, in the collection \"{collection}\": {summary}",
"change.knowledge_done": "Added to {collection}: {summary}. Reply \"undo\" within {minutes} minutes to take it back.",
"change.ask": "Nothing is saved until you answer. Reply \"yes\" to save it or \"no\" to drop it, here, within 24 hours. If more than one waits, reply \"confirm {code}\" or \"cancel {code}\".",
"trigger.where.inbox": "the Inbox in Studio",
"trigger.where.chat": "this chat",
@@ -62,6 +62,7 @@
"change.kind.skill": "una skill",
"change.kind.page": "una página",
"change.kind.dashboard": "un cambio en el panel de Home",
"change.kind.knowledge": "un texto para el Conocimiento",
"change.kind.other": "un cambio",
"change.scope.agent": "solo este agente",
"change.scope.channel": "solo esta conversación",
@@ -69,6 +70,8 @@
"change.page_files": "una página con sus archivos",
"change.only_removes": "(solo quita líneas)",
"change.propose": "Puedo guardar {kind} ({scope}): {summary}",
"change.propose_knowledge": "Puedo añadir un texto a tu Conocimiento, en la colección \"{collection}\": {summary}",
"change.knowledge_done": "Añadido a {collection}: {summary}. Responde \"deshacer\" en {minutes} minutos para deshacerlo.",
"change.ask": "No se guarda nada hasta que respondas. Responde \"sí\" para guardarlo o \"no\" para descartarlo, aquí, en un plazo de 24 horas. Si hay más de uno esperando, responde \"confirmar {code}\" o \"cancelar {code}\".",
"trigger.where.inbox": "a la Inbox de Studio",
"trigger.where.chat": "a este chat",
@@ -62,6 +62,7 @@
"change.kind.skill": "un skill",
"change.kind.page": "une page",
"change.kind.dashboard": "une modification du tableau de bord Home",
"change.kind.knowledge": "un texte pour la Connaissance",
"change.kind.other": "une modification",
"change.scope.agent": "cet agent uniquement",
"change.scope.channel": "cette conversation uniquement",
@@ -69,6 +70,8 @@
"change.page_files": "une page avec ses fichiers",
"change.only_removes": "(cela ne fait que retirer des lignes)",
"change.propose": "Je peux enregistrer {kind} ({scope}) : {summary}",
"change.propose_knowledge": "Je peux ajouter un texte à votre Connaissance, dans la collection \"{collection}\" : {summary}",
"change.knowledge_done": "Ajouté à {collection} : {summary}. Répondez « annuler la modification » dans les {minutes} minutes pour l'annuler.",
"change.ask": "Rien n'est enregistré avant votre réponse. Répondez « oui » pour l'enregistrer ou « non » pour l'abandonner, ici, dans les 24 heures. Si plusieurs attendent, répondez « confirmer {code} » ou « annuler {code} ».",
"trigger.where.inbox": "dans l'Inbox de Studio",
"trigger.where.chat": "dans cette conversation",
@@ -62,6 +62,7 @@
"change.kind.skill": "una skill",
"change.kind.page": "una pagina",
"change.kind.dashboard": "una modifica alla dashboard Home",
"change.kind.knowledge": "un testo per la Conoscenza",
"change.kind.other": "una modifica",
"change.scope.agent": "solo questo agente",
"change.scope.channel": "solo questa conversazione",
@@ -69,6 +70,8 @@
"change.page_files": "una pagina con i suoi file",
"change.only_removes": "(toglie soltanto delle righe)",
"change.propose": "Posso salvare {kind} ({scope}): {summary}",
"change.propose_knowledge": "Posso aggiungere un testo alla tua Conoscenza, nella raccolta \"{collection}\": {summary}",
"change.knowledge_done": "Aggiunto a {collection}: {summary}. Rispondi \"annulla modifica\" entro {minutes} minuti per annullare.",
"change.ask": "Non viene salvato nulla finché non rispondi. Rispondi \"sì\" per salvare o \"no\" per scartare, qui, entro 24 ore. Se ce n'è più di uno in attesa, rispondi \"conferma {code}\" oppure \"annulla {code}\".",
"trigger.where.inbox": "nell'Inbox di Studio",
"trigger.where.chat": "in questa chat",
@@ -62,6 +62,7 @@
"change.kind.skill": "uma skill",
"change.kind.page": "uma página",
"change.kind.dashboard": "uma alteração no painel Home",
"change.kind.knowledge": "um texto para o Conhecimento",
"change.kind.other": "uma alteração",
"change.scope.agent": "só este agente",
"change.scope.channel": "só esta conversa",
@@ -69,6 +70,8 @@
"change.page_files": "uma página com os arquivos dela",
"change.only_removes": "(apenas remove linhas)",
"change.propose": "Posso salvar {kind} ({scope}): {summary}",
"change.propose_knowledge": "Posso adicionar um texto ao seu Conhecimento, na coleção \"{collection}\": {summary}",
"change.knowledge_done": "Adicionado a {collection}: {summary}. Responda \"desfazer\" em até {minutes} minutos para desfazer.",
"change.ask": "Nada é salvo até você responder. Responda \"sim\" para salvar ou \"não\" para descartar, aqui, em até 24 horas. Se houver mais de uma esperando, responda \"confirmar {code}\" ou \"cancelar {code}\".",
"trigger.where.inbox": "para a Inbox do Studio",
"trigger.where.chat": "para este chat",
+196
View File
@@ -0,0 +1,196 @@
package studio
import (
"context"
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Knowledge writes. A model can propose a text for a collection of the
// person's Knowledge. It goes through the change proposals like a memory note,
// with two differences that are on purpose: the host never applies it unasked,
// whatever the save level says (a wrong text would feed later answers of every
// chat that searches the collection, and indexing costs compute), and applying
// it adds an entry to a collection instead of writing a file of the home.
// ErrNoKnowledgeWriter means the server cannot add to collections.
var ErrNoKnowledgeWriter = errors.New("studio: this server cannot add to Knowledge")
// CollectionWriter is the part of the collections that adds and removes a text.
// A Collections value may implement it; without it the knowledge kind is not
// offered. Every call runs as the user and sees only the user's collections.
type CollectionWriter interface {
// EmbeddingReady returns nil when the model that indexes the collections
// can be used, or an error that says what is missing.
EmbeddingReady(ctx context.Context) error
// Add stores text as one entry of the collection under the file name.
Add(ctx context.Context, user, collection, name, text string) error
// Remove deletes the entry that Add stored under the file name.
Remove(ctx context.Context, user, collection, name string) error
}
// Reasons a knowledge proposal is refused, in the words the model passes on.
var (
errKnowledgeEmbedding = errors.New("the embedding model is not installed, so nothing can be added to Knowledge until an administrator installs it")
errKnowledgeNoList = errors.New("no collection is on this agent's list, so it cannot add to Knowledge")
)
const maxCollectionName = 128
// knowledgeWriter returns the writer of the server, or nil.
func (s *Service) knowledgeWriter() CollectionWriter {
w, _ := s.d.Collections.(CollectionWriter)
return w
}
// knowledgeAvailable says whether the user can add to Knowledge on this
// server: the collections run, the account may use them and the embedding
// model is there.
func (s *Service) knowledgeAvailable(ctx context.Context, user string) bool {
w := s.knowledgeWriter()
return w != nil && s.d.Collections.Allowed(ctx, principal(user)) && w.EmbeddingReady(ctx) == nil
}
// knowledgeBlock says why the capability to add to Knowledge is off for the
// agent of a connection, or "" with the code when it is on. The server link
// comes first: the embedding model is missing for everybody.
func (s *Service) knowledgeBlock(ctx context.Context, user, agentID string) (code, reason string) {
w := s.knowledgeWriter()
if w == nil || !s.d.Collections.Allowed(ctx, principal(user)) {
return CodeServerSettingOff, "Knowledge is not available on this server for this person"
}
if w.EmbeddingReady(ctx) != nil {
return CodeKnowledgeNoEmbedding, "the embedding model is not installed"
}
if agentID != "" && s.d.Agents != nil {
ag, err := s.d.Agents.Get(ctx, principal(user), agentID)
if err == nil && len(ag.Collections) == 0 {
return CodeKnowledgeNone, "no collection is on the list of the agent"
}
}
return "", ""
}
func validCollectionName(n string) bool {
if n == "" || len(n) > maxCollectionName || !utf8.ValidString(n) {
return false
}
return !strings.ContainsFunc(n, unicode.IsControl)
}
// knowledgeEntryName is the file name a proposal's text gets in the collection:
// a short slug of the summary and the start of the proposal ID, so the entry
// can be found again to undo it.
func knowledgeEntryName(p contract.ChangeProposal) string {
var b strings.Builder
for _, r := range strings.ToLower(p.Summary) {
switch {
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
b.WriteRune(r)
case b.Len() > 0 && !strings.HasSuffix(b.String(), "-"):
b.WriteByte('-')
}
if b.Len() >= 40 {
break
}
}
slug := strings.Trim(b.String(), "-")
if slug == "" {
slug = "note"
}
id := strings.ReplaceAll(p.ID, "-", "")
return slug + "-" + id[:min(8, len(id))] + ".md"
}
// knowledgeEntryText is what is stored: the summary as a title, then the text.
func knowledgeEntryText(p contract.ChangeProposal) string {
return "# " + p.Summary + "\n\n" + strings.TrimSpace(p.Content) + "\n"
}
// knowledgeKind builds the knowledge kind on the collections of the service.
func (c *Changes) knowledgeKind() proposals.Kind {
s := c.s
return proposals.Kind{
Name: contract.ChangeKnowledge,
Grant: contract.CapKnowledgeWrite,
Sensitive: true,
MaxBytes: contract.MaxKnowledgeBytes,
Path: func(a proposals.Aim) (string, error) {
switch {
case a.Scope != contract.ScopeUser:
return "", fmt.Errorf("%w: Knowledge belongs to the user: use scope user", proposals.ErrBadTarget)
case !validCollectionName(a.Target):
return "", fmt.Errorf("%w: the target of a knowledge change is the name of a collection (list_collections shows them)", proposals.ErrBadTarget)
}
return a.Target, nil
},
Check: func(in proposals.CheckInput) error {
if strings.TrimSpace(in.Content) == "" {
return errors.New("the text to add is empty")
}
return nil
},
Dest: &proposals.Destination{
Prepare: func(ctx context.Context, in proposals.ApplyInput) error {
w := s.knowledgeWriter()
if w == nil {
return ErrNoKnowledgeWriter
}
if !s.d.Collections.Allowed(ctx, in.Owner) {
return ErrNoKnowledgeWriter
}
if err := w.EmbeddingReady(ctx); err != nil {
return errKnowledgeEmbedding
}
mine, err := s.d.Collections.List(ctx, in.Owner)
if err != nil {
return fmt.Errorf("cannot list the collections: %v", err)
}
name := in.Proposal.Target
if !set(mine)[name] {
return fmt.Errorf("there is no collection %q", name)
}
if in.Proposal.Agent == "" {
return nil
}
// A saved agent adds only to the collections on its own list.
if s.d.Agents == nil {
return errors.New("the agent cannot be read")
}
ag, err := s.d.Agents.Get(ctx, in.Owner, in.Proposal.Agent)
switch {
case errors.Is(err, agents.ErrNotFound):
return errors.New("the agent no longer exists")
case err != nil:
return fmt.Errorf("cannot read the agent: %v", err)
case len(ag.Collections) == 0:
return errKnowledgeNoList
case !set(ag.Collections)[name]:
return fmt.Errorf("the collection %q is not on this agent's list", name)
}
return nil
},
Apply: func(ctx context.Context, in proposals.ApplyInput) error {
w := s.knowledgeWriter()
if w == nil {
return ErrNoKnowledgeWriter
}
return w.Add(ctx, in.Owner, in.Proposal.Target, knowledgeEntryName(in.Proposal), knowledgeEntryText(in.Proposal))
},
Undo: func(ctx context.Context, in proposals.ApplyInput) error {
w := s.knowledgeWriter()
if w == nil {
return ErrNoKnowledgeWriter
}
return w.Remove(ctx, in.Owner, in.Proposal.Target, knowledgeEntryName(in.Proposal))
},
},
}
}
+566
View File
@@ -0,0 +1,566 @@
package studio_test
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"sync"
. "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/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/tests/studiobot"
)
// fakeKnowledge is a Collections value that can also add and remove a text,
// as the real service does. It keeps the entries by user, collection and name.
type fakeKnowledge struct {
*fakeCollections
mu sync.Mutex
noEmbed bool
addErr error
entries map[string]string
}
func newFakeKnowledge(user string, names ...string) *fakeKnowledge {
f := &fakeKnowledge{fakeCollections: &fakeCollections{mine: map[string]map[string][]studio.CollectionHit{user: {}}}, entries: map[string]string{}}
for _, n := range names {
f.mine[user][n] = nil
}
return f
}
func (f *fakeKnowledge) EmbeddingReady(context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.noEmbed {
return errors.New("no embedding model")
}
return nil
}
func (f *fakeKnowledge) Add(_ context.Context, user, collection, name, text string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.addErr != nil {
return f.addErr
}
f.entries[user+"|"+collection+"|"+name] = text
return nil
}
func (f *fakeKnowledge) Remove(_ context.Context, user, collection, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.entries, user+"|"+collection+"|"+name)
return nil
}
func (f *fakeKnowledge) held() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for k := range f.entries {
out = append(out, k)
}
slices.Sort(out)
return out
}
func knowledgeArgs(collection, content, summary string) string {
return proposeArgs("knowledge", "user", collection, content, summary)
}
// knowledgeShared is a rig whose service can add to the collections of fk.
func knowledgeShared(fk *fakeKnowledge, st *saveSetting) *shared {
sh := changeShared(grantAll)
inner := sh.deps
sh.deps = func(d *studio.Deps) {
inner(d)
d.Collections = fk
if st != nil {
d.UserSetting = st.get
}
}
return sh
}
var _ = Describe("knowledge changes", func() {
ctx := context.Background()
var (
fk *fakeKnowledge
r *trigRig
ag agents.Agent
)
BeforeEach(func() {
fk = newFakeKnowledge(alice, "manuals", "wiki")
r = knowledgeShared(fk, nil).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
var err error
ag, err = r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Librarian", Model: "m", Collections: []string{"manuals"},
Builtins: []string{contract.CapKnowledgeWrite}})
Expect(err).NotTo(HaveOccurred())
})
propose := func(in studio.ProposeInput) (contract.ChangeProposal, error) {
if in.User == "" {
in.User = alice
}
if in.Agent == "" && in.Kind == "" {
in.Agent = ag.ID
}
if in.Kind == "" {
in.Kind = "knowledge"
}
if in.Scope == "" {
in.Scope = "user"
}
if in.Summary == "" {
in.Summary = "Router reset steps"
}
return r.svc.Changes().Propose(ctx, in)
}
It("is stored as a proposal that adds nothing, then added as one entry by a confirmation and removed by an undo", func() {
p, err := propose(studio.ProposeInput{Target: "manuals", Content: "Hold the reset button for ten seconds."})
Expect(err).NotTo(HaveOccurred())
Expect(p.Kind).To(Equal(contract.ChangeKnowledge))
Expect(p.Target).To(Equal("manuals"))
Expect(p.Status).To(Equal(contract.ProposalPending))
Expect(fk.held()).To(BeEmpty(), "a proposal indexes nothing")
done, err := r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
Expect(done.Status).To(Equal(contract.ProposalApplied))
Expect(done.Auto).To(BeFalse())
Expect(done.Rev).To(BeZero(), "a collection has no change set")
Expect(fk.held()).To(HaveLen(1))
key := fk.held()[0]
Expect(key).To(HavePrefix(alice + "|manuals|router-reset-steps-"))
Expect(key).To(HaveSuffix(".md"))
Expect(fk.entries[key]).To(Equal("# Router reset steps\n\nHold the reset button for ten seconds.\n"))
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(MatchError(studio.ErrChangeAnswered), "a second confirmation adds nothing")
Expect(fk.held()).To(HaveLen(1))
back, err := r.svc.Changes().Revert(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
Expect(back.Status).To(Equal(contract.ProposalReverted))
Expect(fk.held()).To(BeEmpty())
_, err = r.svc.Changes().Revert(ctx, alice, p.ID)
Expect(err).To(MatchError(studio.ErrNotApplied))
events, err := r.svc.Changes().Events(ctx, alice, 10)
Expect(err).NotTo(HaveOccurred())
var actions []string
for _, e := range events {
actions = append(actions, e.Action)
}
Expect(actions).To(ContainElements(proposals.ActionPropose, proposals.ActionConfirm, proposals.ActionRevert))
})
It("adds the text the owner edited, and holds the same cap", func() {
p, err := propose(studio.ProposeInput{Target: "manuals", Content: "draft"})
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, strings.Repeat("x", contract.MaxKnowledgeBytes+1))
Expect(err).To(MatchError(studio.ErrBadChange))
Expect(fk.held()).To(BeEmpty())
_, err = r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, "final text")
Expect(err).NotTo(HaveOccurred())
Expect(fk.entries[fk.held()[0]]).To(ContainSubstring("final text"))
})
It("undoes the last change of the agent by RevertLast and ignores the knowledge ones when asked", func() {
p, err := propose(studio.ProposeInput{Target: "manuals", Content: "a"})
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
_, err = r.svc.Changes().RevertLast(ctx, alice, ag.ID, contract.ChangeKnowledge)
Expect(err).To(MatchError(studio.ErrNothingToRevert))
got, err := r.svc.Changes().RevertLast(ctx, alice, ag.ID)
Expect(err).NotTo(HaveOccurred())
Expect(got.ID).To(Equal(p.ID))
Expect(fk.held()).To(BeEmpty())
})
It("stays pending when the collection fails, and can be tried again", func() {
p, err := propose(studio.ProposeInput{Target: "manuals", Content: "a"})
Expect(err).NotTo(HaveOccurred())
fk.addErr = errors.New("disk full")
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(MatchError(ContainSubstring("disk full")))
got, _ := r.svc.Changes().Get(ctx, alice, p.ID)
Expect(got.Status).To(Equal(contract.ProposalPending))
fk.addErr = nil
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
Expect(fk.held()).To(HaveLen(1))
})
It("asks the destination again at the confirmation: a model lost since the proposal, or a collection that was removed", func() {
p, err := propose(studio.ProposeInput{Target: "manuals", Content: "a"})
Expect(err).NotTo(HaveOccurred())
fk.noEmbed = true
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(MatchError(ContainSubstring("embedding model")))
fk.noEmbed = false
delete(fk.mine[alice], "manuals")
_, err = r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).To(MatchError(ContainSubstring(`no collection "manuals"`)))
got, _ := r.svc.Changes().Get(ctx, alice, p.ID)
Expect(got.Status).To(Equal(contract.ProposalPending))
Expect(fk.held()).To(BeEmpty())
})
DescribeTable("the access chain of a proposal",
func(in studio.ProposeInput, mutate func(), want error, text string) {
if mutate != nil {
mutate()
}
_, err := propose(in)
if want == nil {
Expect(err).NotTo(HaveOccurred())
return
}
Expect(err).To(MatchError(want))
if text != "" {
Expect(err).To(MatchError(ContainSubstring(text)))
}
list, _ := r.svc.Changes().List(ctx, alice)
Expect(list).To(BeEmpty(), "a refused proposal is not stored")
},
Entry("a collection of the agent list", studio.ProposeInput{Target: "manuals", Content: "t"}, nil, nil, ""),
Entry("a collection that exists but is not on the agent list", studio.ProposeInput{Target: "wiki", Content: "t"}, nil, studio.ErrBadChange, "not on this agent's list"),
Entry("a collection that does not exist", studio.ProposeInput{Target: "nope", Content: "t"}, nil, studio.ErrBadChange, "no collection"),
Entry("no target", studio.ProposeInput{Target: "", Content: "t"}, nil, studio.ErrBadChange, "name of a collection"),
Entry("a control character in the name", studio.ProposeInput{Target: "man\nuals", Content: "t"}, nil, studio.ErrBadChange, "name of a collection"),
Entry("a name that is too long", studio.ProposeInput{Target: strings.Repeat("a", 129), Content: "t"}, nil, studio.ErrBadChange, "name of a collection"),
Entry("an empty text", studio.ProposeInput{Target: "manuals", Content: " \n"}, nil, studio.ErrBadChange, "empty"),
Entry("scope agent", studio.ProposeInput{Target: "manuals", Content: "t", Scope: "agent"}, nil, studio.ErrBadChange, "scope user"),
Entry("scope channel", studio.ProposeInput{Target: "manuals", Content: "t", Scope: "channel"}, nil, studio.ErrBadChange, "scope user"),
Entry("exactly the cap", studio.ProposeInput{Target: "manuals", Content: strings.Repeat("a", contract.MaxKnowledgeBytes)}, nil, nil, ""),
Entry("one byte over the cap", studio.ProposeInput{Target: "manuals", Content: strings.Repeat("a", contract.MaxKnowledgeBytes+1)}, nil, studio.ErrBadChange, "may have 65536"),
Entry("binary text", studio.ProposeInput{Target: "manuals", Content: "a\x00b"}, nil, studio.ErrBadChange, "must be text"),
Entry("the embedding model is not installed: the grant is not usable", studio.ProposeInput{Target: "manuals", Content: "t"}, func() { fk.noEmbed = true }, proposals.ErrNotGranted, ""),
Entry("the account may not use collections", studio.ProposeInput{Target: "manuals", Content: "t"}, func() { fk.blocked = map[string]bool{alice: true} }, proposals.ErrNotGranted, ""),
Entry("an agent without the grant", studio.ProposeInput{Target: "manuals", Content: "t"}, func() {
var err error
ag, err = r.svc.SaveAgent(ctx, alice, agents.Agent{ID: ag.ID, Name: "Librarian", Model: "m", Collections: []string{"manuals"}, Builtins: []string{contract.CapMemoryWrite}})
Expect(err).NotTo(HaveOccurred())
}, proposals.ErrNotGranted, ""),
Entry("an agent with an empty collection list", studio.ProposeInput{Target: "manuals", Content: "t"}, func() {
var err error
ag, err = r.svc.SaveAgent(ctx, alice, agents.Agent{ID: ag.ID, Name: "Librarian", Model: "m", Builtins: []string{contract.CapKnowledgeWrite}})
Expect(err).NotTo(HaveOccurred())
}, studio.ErrBadChange, "no collection is on this agent's list"),
Entry("a run that started by itself", studio.ProposeInput{Target: "manuals", Content: "t", Source: contract.ChangeSource{Kind: "trigger"}}, nil, proposals.ErrNotGranted, ""),
)
It("lets a plain chat add to any collection of the person", func() {
_, err := r.svc.Changes().Propose(ctx, studio.ProposeInput{User: alice, Kind: "knowledge", Scope: "user", Target: "wiki", Content: "t", Summary: "s"})
Expect(err).NotTo(HaveOccurred())
})
It("never lets the host apply it unasked, whatever the caller asks", func() {
_, err := propose(studio.ProposeInput{Target: "manuals", Content: "t", Auto: true})
Expect(err).To(MatchError(studio.ErrBadChange))
Expect(fk.held()).To(BeEmpty())
})
It("keeps the limits of the other kinds: 10 waiting, 5 an hour", func() {
for i := range contract.MaxChangesPerHour {
_, err := propose(studio.ProposeInput{Target: "manuals", Content: strings.Repeat("a", i+1)})
Expect(err).NotTo(HaveOccurred())
}
_, err := propose(studio.ProposeInput{Target: "manuals", Content: "more"})
Expect(err).To(MatchError(proposals.ErrRateLimited))
})
It("cannot be proposed on a server whose collections cannot add", func() {
plain := &fakeCollections{mine: map[string]map[string][]studio.CollectionHit{alice: {"manuals": nil}}}
sh := changeShared(grantAll)
inner := sh.deps
sh.deps = func(d *studio.Deps) { inner(d); d.Collections = plain }
rr := sh.rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{})
_, err := rr.svc.Changes().Propose(ctx, studio.ProposeInput{User: alice, Kind: "knowledge", Scope: "user", Target: "manuals", Content: "t", Summary: "s"})
Expect(err).To(MatchError(studio.ErrBadChange))
Expect(rr.svc.BuiltinGrants()).NotTo(BeEmpty())
})
})
var _ = Describe("knowledge changes from a chat of the person", func() {
ctx := context.Background()
// run sends "add" to a plain chat under a save level and returns what the
// proposal tool said and what was stored.
run := func(level string) (*trigRig, *fakeKnowledge) {
fk := newFakeKnowledge(alice, "manuals")
st := &saveSetting{}
if level != "" {
st.putHost(studio.MemorySaveKey, level)
}
r := knowledgeShared(fk, st).rig(func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "add" {
return []studiotest.ToolStep{{Tool: "propose_change", Args: knowledgeArgs("manuals", "Hold reset for ten seconds.", "Reset steps")}, {Say: "ok"}}
}
return []studiotest.ToolStep{{Say: "ok"}}
}, studio.TriggerOptions{})
id := create(r.svc, alice)
sent(r.svc, alice, id, "add")
idle(r.svc, alice, id)
return r, fk
}
DescribeTable("is a proposal the owner confirms at every save level",
func(level string) {
r, fk := run(level)
p := onlyProposal(r)
Expect(p.Kind).To(Equal(contract.ChangeKnowledge))
Expect(p.Status).To(Equal(contract.ProposalPending))
Expect(p.Auto).To(BeFalse())
Expect(fk.held()).To(BeEmpty())
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(BeNil())
Expect(res[0].Out).To(ContainSubstring("Nothing is saved yet"))
_, err := r.svc.Changes().Confirm(ctx, alice, p.ID)
Expect(err).NotTo(HaveOccurred())
Expect(fk.held()).To(HaveLen(1))
},
Entry("ask", studio.SaveAsk),
Entry("auto: a memory note would be saved at once, a text for Knowledge never", studio.SaveAuto),
Entry("always confirm", studio.SaveAlways),
Entry("nothing chosen", ""),
)
It("is not offered when the person chose Never: the kind leaves the schema and a call is refused", func() {
r, fk := run(studio.SaveNever)
list, _ := r.svc.Changes().List(ctx, alice)
Expect(list).To(BeEmpty())
Expect(fk.held()).To(BeEmpty())
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(MatchError(ContainSubstring("turned off saving")))
events, _ := r.svc.Changes().Events(ctx, alice, 5)
Expect(events).NotTo(BeEmpty())
Expect(events[0].Action).To(Equal(proposals.ActionRefuse))
})
It("is not offered when the Knowledge tools are switched off for the chat", func() {
fk := newFakeKnowledge(alice, "manuals")
r := knowledgeShared(fk, nil).rig(func(_ workspace.Actor, text string) []studiotest.ToolStep {
return []studiotest.ToolStep{{Tool: "propose_change", Args: knowledgeArgs("manuals", "t", "s")}, {Say: "ok"}}
}, studio.TriggerOptions{})
rec, err := r.svc.Create(ctx, alice, studio.CreateInput{Model: "m"})
Expect(err).NotTo(HaveOccurred())
Expect(r.svc.SetBuiltinsOff(ctx, alice, rec.ID, []string{"collections"})).To(Succeed())
sent(r.svc, alice, rec.ID, "add")
idle(r.svc, alice, rec.ID)
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).NotTo(BeNil())
list, _ := r.svc.Changes().List(ctx, alice)
Expect(list).To(BeEmpty())
})
It("lists knowledge among the kinds the model can propose", func() {
fk := newFakeKnowledge(alice, "manuals")
r := knowledgeShared(fk, nil).rig(func(workspace.Actor, string) []studiotest.ToolStep { return []studiotest.ToolStep{{Say: "ok"}} }, studio.TriggerOptions{})
id := create(r.svc, alice)
sent(r.svc, alice, id, "hi")
idle(r.svc, alice, id)
var doc struct {
Properties struct {
Kind struct {
Enum []string `json:"enum"`
} `json:"kind"`
} `json:"properties"`
}
Expect(json.Unmarshal([]byte(r.h.ToolSchema(id, "propose_change")), &doc)).To(Succeed())
Expect(doc.Properties.Kind.Enum).To(ContainElement("knowledge"))
})
})
var _ = Describe("knowledge changes from a messaging app", func() {
ctx := context.Background()
const (
listed = "11"
paired = "12"
)
var (
fk *fakeKnowledge
bot *scriptedBot
r *connRig
b connectors.Binding
)
caps := []string{contract.CapMemoryWrite, contract.CapKnowledgeWrite, contract.CapProposalsConfirm}
setup := func(embed bool, collections []string) {
fk, bot = newFakeKnowledge(alice, "manuals"), newScriptedBot()
fk.noEmbed = !embed
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 text {
case "add":
return []studiotest.ToolStep{{Tool: "propose_change", Args: knowledgeArgs("manuals", "Hold reset for ten seconds.", "Reset steps")}, {Say: "ok"}}
case "forget":
return []studiotest.ToolStep{{Tool: "revert_change", Args: `{}`}, {Say: "ok"}}
}
return []studiotest.ToolStep{{Say: "model"}}
}
sh := knowledgeShared(fk, nil)
r = newConnRigWith(sh, connStore(), studiobot.NewTransport(addon), script, nil)
r.bot, r.untrusted = bot, true
r.svc.SetCapabilityGate(r.conns.CapabilityGate())
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Support", Model: "m", Addons: []string{"office"}, Collections: collections,
Builtins: []string{contract.CapMemoryWrite, contract.CapKnowledgeWrite}})
Expect(err).NotTo(HaveOccurred())
r.agent = ag
b = r.bind(studio.ConnectorInput{Allow: []string{listed, paired}, 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 := func(sender, text string, n int) {
before := len(bot.answers())
m := contract.ConnectorMessage{Chat: sender, Direct: 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 })
}
list := func() []contract.ChangeProposal {
l, err := r.svc.Changes().List(ctx, alice)
Expect(err).NotTo(HaveOccurred())
return l
}
AfterEach(func() {
if CurrentSpecReport().Failed() && bot != nil {
GinkgoWriter.Printf("ANSWERS %q\nRESULTS %+v\n", bot.answers(), r.h.ResultsFor("propose_change"))
}
})
It("is written in the chat for a paired sender, confirmed by yes and undone by undo", func() {
setup(true, []string{"manuals"})
say(paired, "add", 2)
Expect(list()).To(HaveLen(1))
Expect(list()[0].Status).To(Equal(contract.ProposalPending))
asked := strings.Join(bot.answers(), "\n")
Expect(asked).To(ContainSubstring(`"manuals"`))
Expect(asked).To(ContainSubstring("Reset steps"))
Expect(asked).To(ContainSubstring("Hold reset for ten seconds."))
Expect(asked).To(ContainSubstring(`Reply "yes"`))
Expect(fk.held()).To(BeEmpty())
say(paired, "yes", 1)
Expect(list()[0].Status).To(Equal(contract.ProposalApplied))
Expect(fk.held()).To(HaveLen(1))
Expect(bot.answers()[len(bot.answers())-1]).To(ContainSubstring("Added to manuals"))
say(paired, "undo", 1)
Expect(list()[0].Status).To(Equal(contract.ProposalReverted))
Expect(fk.held()).To(BeEmpty())
})
It("is dropped by no, and a code answers when several wait", func() {
setup(true, []string{"manuals"})
say(paired, "add", 2)
say(paired, "no", 1)
Expect(list()[0].Status).To(Equal(contract.ProposalDismissed))
Expect(fk.held()).To(BeEmpty())
})
It("is refused for a listed sender who is not paired: the capability is sensitive", func() {
setup(true, []string{"manuals"})
say(listed, "add", 1)
Expect(list()).To(BeEmpty())
res := r.h.ResultsFor("propose_change")
Expect(res).To(HaveLen(1))
Expect(res[0].Err).To(MatchError(ContainSubstring("not permitted")))
Expect(fk.held()).To(BeEmpty())
})
Describe("the connection page", func() {
decision := func(v studio.AccessView, subject string) studio.Decision {
i := slices.IndexFunc(v.Subjects, func(s studio.AccessSubjectView) bool { return s.ID == subject })
Expect(i).To(BeNumerically(">=", 0), subject)
j := slices.IndexFunc(v.Subjects[i].Decisions, func(d studio.Decision) bool { return d.Capability == contract.CapKnowledgeWrite })
Expect(j).To(BeNumerically(">=", 0))
return v.Subjects[i].Decisions[j]
}
saved := func() studio.AccessView {
v, err := r.conns.Access(ctx, alice, b.ID)
Expect(err).NotTo(HaveOccurred())
return v
}
It("says a paired sender may and a listed one or a channel member needs pairing", func() {
setup(true, []string{"manuals"})
v := saved()
Expect(decision(v, "paired").Allowed).To(BeTrue())
for _, who := range []string{"listed", "anyone"} {
d := decision(v, who)
Expect(d.Allowed).To(BeFalse(), who)
Expect(d.Code).To(Equal(studio.CodeSenderNeedsPairing), who)
Expect(d.BlockedBy).To(Equal(studio.BySender))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixPairSender, Target: "pairing"}))
}
})
It("names the server when the embedding model is missing", func() {
setup(false, []string{"manuals"})
d := decision(saved(), "paired")
Expect(d.Allowed).To(BeFalse())
Expect(d.Code).To(Equal(studio.CodeKnowledgeNoEmbedding))
Expect(d.BlockedBy).To(Equal(studio.ByServer))
Expect(d.Message).To(Equal("The embedding model for Knowledge is not installed, so the bot cannot write to knowledge. Install it in Models."))
Expect(studio.BlockSay(d.Code, d.Capability)).To(Equal("The server cannot index new texts right now, so the bot cannot write to knowledge."))
})
It("names the agent when it has no collection on its list", func() {
setup(true, nil)
d := decision(saved(), "paired")
Expect(d.Code).To(Equal(studio.CodeKnowledgeNone))
Expect(d.BlockedBy).To(Equal(studio.ByAgent))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenAgent, Target: r.agent.ID, Field: "collections"}))
Expect(d.Message).To(ContainSubstring("No collection is on the list of the agent"))
})
It("names the Tools switch of Knowledge, which the grant nests under", func() {
setup(true, []string{"manuals"})
ag := r.agent
ag.BuiltinsOff = []string{"collections"}
saved2, err := r.svc.SaveAgent(ctx, alice, ag)
Expect(err).NotTo(HaveOccurred())
r.agent = saved2
d := decision(saved(), "paired")
Expect(d.Code).To(Equal(studio.CodeToolsSwitchOff))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenAgent, Target: r.agent.ID, Field: "tools:collections"}))
})
It("names the connection when the owner did not tick it, and the preview answers for a draft", func() {
setup(true, []string{"manuals"})
v, err := r.conns.AccessPreview(ctx, alice, b.ID, studio.AccessDraft{Caps: []string{contract.CapMemoryWrite}, Sender: studio.SenderPaired})
Expect(err).NotTo(HaveOccurred())
Expect(v.Preview).To(BeTrue())
d := decision(v, "paired")
Expect(d.Code).To(Equal(studio.CodeConnectionPermissionOff))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenConnection, Target: "caps", Field: contract.CapKnowledgeWrite}))
v, err = r.conns.AccessPreview(ctx, alice, b.ID, studio.AccessDraft{Caps: caps, Sender: studio.SenderPaired})
Expect(err).NotTo(HaveOccurred())
Expect(decision(v, "paired").Allowed).To(BeTrue())
})
It("is never usual: the preset does not tick it", func() {
Expect(contract.SensitiveCapability(contract.CapKnowledgeWrite)).To(BeTrue())
Expect(contract.UsualConnectorCapabilities()).NotTo(ContainElement(contract.CapKnowledgeWrite))
})
})
})
+26
View File
@@ -1,6 +1,7 @@
package proposals
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -45,6 +46,9 @@ type Kind struct {
// relative to the folder Path returned. Nil: the content is the one file
// Path returned.
Files func(content string) ([]FileWrite, error)
// Dest, when set, replaces the write to the home: Path then only names the
// target (for example a collection) and the change has no change set.
Dest *Destination
}
// FileWrite is one file of a change that writes several.
@@ -53,6 +57,28 @@ type FileWrite struct {
Content string
}
// ApplyInput is what Kind.Prepare, Apply and Undo get for a kind that writes
// somewhere other than the owner's home.
type ApplyInput struct {
Owner string
Proposal contract.ChangeProposal
}
// Destination makes a kind write to a store of its own (a collection of the
// Knowledge, say) instead of a file of the home. A kind with a Destination has
// no change set: Apply writes, Undo takes the write back, and Prepare refuses
// a proposal whose destination cannot take it now. The host still keeps the
// proposal, the audit, the limits and the answer of the owner.
type Destination struct {
// Prepare judges the destination when the proposal is made and again when
// the owner confirms. Nil: nothing to check.
Prepare func(ctx context.Context, in ApplyInput) error
// Apply writes the change after the owner confirmed.
Apply func(ctx context.Context, in ApplyInput) error
// Undo takes an applied change back.
Undo func(ctx context.Context, in ApplyInput) error
}
// CheckInput is what Kind.Check judges.
type CheckInput struct {
Aim Aim
+3 -3
View File
@@ -2600,7 +2600,7 @@ The manifest declares the capability. It needs no tool and no `service` flag:
| `kinds[].options` | Optional, manifest `api` 2, at most 4. A choice that each binding of the kind makes: `{"id", "title", "description", "values": [{"id", "title"}], "default"}` with 2 to 8 values and a default among them. The host shows the choice on the binding page, checks the owner's value against the declaration, stores it, and sends the value in effect to the add-on in `options` of every `poll`. What the option means is the add-on's own rule. |
| `kinds[].files` | Optional. The files the kind can receive and send, with a limit for each. Needs manifest `api` 2. See [File abilities and limits](#file-abilities-and-limits). |
| `kinds[].roles` | Optional, manifest `api` 2, at most 8. A role the kind can give to a sender: `{"id", "title", "description"}`, with an `id` in the same form as a kind `id`. The add-on owns the membership (for example, the admins of a workspace) and sends the role IDs of the sender in `roles` of each message, at most 8. The host drops a role the kind does not declare, and all roles for a kind that declares none. The owner of a binding maps each role to capabilities in the host. |
| `kinds[].permissions` | Optional, manifest `api` 2, at most 12. The capabilities the kind can grant to a role, from the host's closed list: `proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`, `triggers.run`, `talkback.channels` and the sensitive `talkback.new_channel`, `profile.write`, `skills.write`, `pages.write`, `dashboard.write`. An unknown name is refused. A sensitive name may be listed, but it is never granted to a role: only a sender that the owner paired can use it. |
| `kinds[].permissions` | Optional, manifest `api` 2, at most 13. The capabilities the kind can grant to a role, from the host's closed list: `proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`, `triggers.run`, `talkback.channels` and the sensitive `talkback.new_channel`, `profile.write`, `skills.write`, `pages.write`, `dashboard.write`, `knowledge.write`. An unknown name is refused. A sensitive name may be listed, but it is never granted to a role: only a sender that the owner paired can use it. |
**Outbound: the connector route.** The host calls `POST /connector` on the add-on
with the same bearer credential and incarnation header as every other route and a body
@@ -2855,8 +2855,8 @@ Limits and safety:
| Chat that runs a saved agent | refused until the owner grants the kind (see below) |
A saved agent proposes a kind only when its `builtins` list holds the grant of
that kind: `memory.write`, `profile.write`, `skills.write`, `pages.write` or
`dashboard.write`. The tool exists in a chat when the agent holds the grant, the
that kind: `memory.write`, `profile.write`, `skills.write`, `pages.write`,
`dashboard.write` or `knowledge.write`. The tool exists in a chat when the agent holds the grant, the
user may still use it and the chat did not switch the `changes` group off; its
schema lists only the kinds the chat holds. A plain chat holds what its user
may use. A run that started by itself never holds a grant. `GET
+5 -3
View File
@@ -280,7 +280,7 @@ empty by default, no template or import turns a grant on, and a chat of the
agent holds only the grants you may use at that moment.
`GET /api/chats/agents/builtin-grants` returns `{grants: [{name, title,
description, sensitive}]}` for the editor. The names are `memory.write`,
`profile.write`, `skills.write`, `pages.write`, `dashboard.write`,
`profile.write`, `skills.write`, `pages.write`, `dashboard.write`, `knowledge.write`,
`triggers.propose`, `triggers.run`, `talkback.channels`, `talkback.new_channel`,
`files.upload`, `act.files`, `act.addons`, `act.send`, `act.spend`,
`saved_agents` (the opt-in group of that name; granting it also switches the
@@ -291,11 +291,13 @@ watches, a messaging chat and a sub-agent never hold it; see
[Past chats]({{%relref "features/chats#past-chats" %}})). A sensitive grant changes what the agent may do or reach later; the
editor marks it. The five `*.write` grants let the agent propose a memory note,
its profile, a skill, a page or a dashboard change with `propose_change`; you
confirm every proposal, and you can undo it. The **Tools** list of the agent form
confirm every proposal, and you can undo it. `knowledge.write` (**Add to
knowledge**, sensitive) lets it propose a text for one of its collections; see
[Adding to Knowledge]({{%relref "features/collections#let-an-agent-propose-a-text-for-a-collection" %}}). The **Tools** list of the agent form
and of a chat show each grant once, as an option under the switch it refines:
"Create and manage triggers" holds "Also propose from messaging apps" (`triggers.propose`)
and "Let triggers start from messaging apps" (`triggers.run`); "Suggested
changes" holds the five `*.write` grants; "Your files" holds `files.upload` and
changes" holds the five `*.write` grants; "Knowledge" holds `knowledge.write`; "Your files" holds `files.upload` and
`act.files`. An option is greyed, with the reason, while its switch is off, and keeps its
stored value. `talkback.channels` and `talkback.new_channel` are in the section
"Messaging apps", and `act.addons`, `act.send` and `act.spend` in "When nobody is
+3 -1
View File
@@ -732,7 +732,7 @@ add-on.
- **Capabilities** (`caps`). The ceiling you enable for a binding, from a closed list
(`proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`,
`triggers.run`, `talkback.channels`, `tools.write`, and the sensitive `talkback.new_channel`,
`profile.write`, `skills.write`, `pages.write`, `dashboard.write`). The default is
`profile.write`, `skills.write`, `pages.write`, `dashboard.write`, `knowledge.write`). The default is
no: a binding with none enabled lets a sender talk to the agent, but the agent can
not use any of these on the sender's behalf. A binding saved before permissions
were enforced has none, so the first start after the upgrade gives it the usual set
@@ -883,6 +883,8 @@ shapes the result and never blocks.
| `role_not_granted` | `sender` | The role of the sender has no grant. `fix.target` is `grants`. | `open_connection` |
| `kind_not_running` | `sender` | The app of the connection is not running, so roles are unknown. | none |
| `context_rule` | `connection` | Held, but not in this place or thread. | none |
| `knowledge_none` | `agent` | `knowledge.write` only: no collection is on the list of the agent. `fix.field` is `collections`. | `open_agent` |
| `knowledge_no_embedding` | `server` | `knowledge.write` only: the embedding model for Knowledge is not installed. | none |
| `unknown_capability` | `server` | Not a capability. | none |
The server sends no URL. A page maps `fix.kind` and `fix.target` to its own links. The
+36
View File
@@ -131,3 +131,39 @@ Add the names of your collections to the `collections` list of a
[saved agent](/features/chats-agents/). A chat with that agent can then search
only those collections. If a listed collection is deleted, the chat still starts
and the model is told that it is not available.
## Let an agent propose a text for a collection
A model can ask to add a text to a collection, for example a procedure that you
pasted in the chat, with the `propose_change` tool and the kind `knowledge`. It
works like any other [suggested change]({{%relref "features/chats-agents#builtin-grants" %}}),
with these rules:
- **A grant.** The agent needs the grant `knowledge.write` (**Add to knowledge**
in the Tools list, under **Knowledge**). It is sensitive and off by default. A
plain chat can propose to any of your collections; a saved agent only to the
collections on its own list.
- **Always your decision.** The text is never added by the model alone, and never
at once, whatever your memory save level is: **Ask**, **Auto** and **Always
confirm** all wait for your **Confirm**. The level **Never** takes the kind
away. A wrong text would feed the answers of every chat that searches the
collection, and indexing costs compute, so there is no "save and tell me" for
Knowledge.
- **What you see.** The card in **Suggested changes** names the collection and
shows the first 20 lines of the text and its size against the limit of 64 KiB.
You can edit the text before you confirm. A change waits 24 hours, and you can
have 10 waiting and an agent can make 5 an hour, as for the other kinds.
- **What happens.** **Confirm** stores the text as one entry of the collection,
titled with the summary, and the server splits it into passages and embeds it.
**Undo** deletes that entry again. The embedding model must be installed;
without it the kind is not offered.
- **Messaging apps.** `knowledge.write` is also a permission of a connection. It
is sensitive: only a sender you paired can use it, never a sender who is only
on the allowlist or in a listed channel. The bot writes the proposal in the
chat with the collection and the first lines of the text, and the paired sender
answers `yes`, `no` or `undo` there. The connection page explains a block with
the usual reasons, plus `knowledge_none` (the agent has no collection) and
`knowledge_no_embedding` (the embedding model is not installed).
This first version takes a text. Following a URL or adding a file of your Files
folder by proposal is not available yet; add those yourself on the Knowledge page.
+8 -1
View File
@@ -29,11 +29,14 @@ const (
ChangeSkill ChangeKind = "skill"
ChangePage ChangeKind = "page"
ChangeDashboard ChangeKind = "dashboard"
// ChangeKnowledge adds a text to a collection of the person's Knowledge.
// It is never applied unasked.
ChangeKnowledge ChangeKind = "knowledge"
)
// ChangeKinds lists every kind in a stable order.
func ChangeKinds() []ChangeKind {
return []ChangeKind{ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard}
return []ChangeKind{ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard, ChangeKnowledge}
}
// ValidChangeKind reports whether s names a kind.
@@ -137,6 +140,10 @@ const (
// MaxPageChangeBytes is the largest content of a page proposal: all the
// files of the page together.
MaxPageChangeBytes = 256 << 10
// MaxKnowledgeBytes is the largest text of a knowledge proposal. The host
// cuts it into passages and embeds each one, so the bound is also a bound
// on compute.
MaxKnowledgeBytes = 64 << 10
// MaxProfileBytes is the largest agent profile.
MaxProfileBytes = 4 << 10
)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
var _ = Describe("change proposals", func() {
It("names every kind and status once", func() {
Expect(ChangeKinds()).To(ConsistOf(ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard))
Expect(ChangeKinds()).To(ConsistOf(ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard, ChangeKnowledge))
for _, k := range ChangeKinds() {
Expect(ValidChangeKind(string(k))).To(BeTrue(), "kind %s", k)
}
+7 -2
View File
@@ -9,7 +9,7 @@ import (
// Limits of the permissions of a connector kind.
const (
MaxConnectorRoles = 8
MaxConnectorPermissions = 12
MaxConnectorPermissions = 13
// MaxMessageRoles is how many role IDs one ConnectorMessage carries.
MaxMessageRoles = 8
)
@@ -29,6 +29,9 @@ const (
CapSkillsWrite = "skills.write"
CapPagesWrite = "pages.write"
CapDashboardWrite = "dashboard.write"
// CapKnowledgeWrite lets a sender have the agent propose a text for the
// person's Knowledge. The owner still confirms each one.
CapKnowledgeWrite = "knowledge.write"
// CapToolsWrite lets a sender make an add-on tool change something outside
// LocalAI, such as a comment on an issue. It is for the tools of a kind
// whose binding holds a credential.
@@ -49,6 +52,7 @@ var capabilities = map[string]bool{
CapSkillsWrite: true,
CapPagesWrite: true,
CapDashboardWrite: true,
CapKnowledgeWrite: true,
CapToolsWrite: false,
}
@@ -83,7 +87,7 @@ type CapabilityInfo struct {
// capabilityText is the title and description of each capability. Titles are
// short verbs; descriptions say what changes and who can cause it.
var capabilityText = map[string][2]string{
CapProposalsConfirm: {"Confirm changes from the chat", "A sender can confirm a trigger or a change that the agent proposed (a memory note, the agent rules, a skill, a page or the dashboard) by answering yes or no in the same chat. Changes to the rules, skills, pages and dashboard need a paired sender."},
CapProposalsConfirm: {"Confirm changes from the chat", "A sender can confirm a trigger or a change that the agent proposed (a memory note, the agent rules, a skill, a page, the dashboard or a text for Knowledge) by answering yes or no in the same chat. Every change except a memory note needs a paired sender."},
CapMemoryWrite: {"Save to memory", "The agent may remember what a sender asks it to remember."},
CapFilesUpload: {"Receive files", "The agent may read files that senders attach to a message."},
CapTriggersPropose: {"Propose triggers", "The agent may propose a timer or an event trigger when a sender asks for one."},
@@ -94,6 +98,7 @@ var capabilityText = map[string][2]string{
CapSkillsWrite: {"Write skills", "The agent may write or change skills."},
CapPagesWrite: {"Write pages", "The agent may write or change pages."},
CapDashboardWrite: {"Write the dashboard", "The agent may change the dashboard."},
CapKnowledgeWrite: {"Add to knowledge", "The agent may propose a text for a collection of the Knowledge. The owner confirms each one."},
CapToolsWrite: {"Change things outside LocalAI", "An add-on tool may change something with the credential of the binding, such as a comment on an issue."},
}