From 89e29e6008ad71ffa2058c19bf3aed08219f6c23 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 20:50:20 +0000 Subject: [PATCH] feat(react-ui): show and edit the acts of a trigger The trigger form has a Permissions section that lists the act.* grants like the grants of an agent: a plain-words line on each, sensitive ones marked, and a grant the chosen agent does not hold cannot be ticked. It says in a callout when a run only reads and why: no acts, the agent holds none of them, the proposal said read only, or a read only connection made it. The details page shows the same, the trigger list marks a read only trigger, and the proposal card has a Permissions row, so the person sees the acts before pressing Create. The editor always sends the acts it loaded, so saving a form never drops them silently. New strings are in every locale. Assisted-by: Claude:claude-sonnet-5-5 node playwright --- .../react-ui/e2e/chats-proposal-card.spec.js | 27 ++++- .../react-ui/public/locales/de/agents.json | 10 +- .../react-ui/public/locales/de/chats.json | 25 +++- .../react-ui/public/locales/en/agents.json | 10 +- .../react-ui/public/locales/en/chats.json | 25 +++- .../react-ui/public/locales/es/agents.json | 10 +- .../react-ui/public/locales/es/chats.json | 25 +++- .../react-ui/public/locales/fr/agents.json | 10 +- .../react-ui/public/locales/fr/chats.json | 25 +++- .../react-ui/public/locales/id/agents.json | 10 +- .../react-ui/public/locales/id/chats.json | 25 +++- .../react-ui/public/locales/it/agents.json | 10 +- .../react-ui/public/locales/it/chats.json | 25 +++- .../react-ui/public/locales/ko/agents.json | 10 +- .../react-ui/public/locales/ko/chats.json | 25 +++- .../react-ui/public/locales/pt-BR/agents.json | 10 +- .../react-ui/public/locales/pt-BR/chats.json | 25 +++- .../react-ui/public/locales/zh-CN/agents.json | 10 +- .../react-ui/public/locales/zh-CN/chats.json | 25 +++- .../react-ui/src/components/TriggerActs.jsx | 114 ++++++++++++++++++ .../src/components/chat/ProposalCard.jsx | 2 + .../react-ui/src/components/trigger-acts.css | 6 + core/http/react-ui/src/pages/AgentJobs.jsx | 2 + .../react-ui/src/pages/AgentTaskDetails.jsx | 19 +++ core/http/react-ui/src/utils/chatTrigger.js | 7 ++ core/http/react-ui/src/utils/triggerActs.js | 96 +++++++++++++++ .../react-ui/tests/unit/chattrigger.test.mjs | 2 +- .../react-ui/tests/unit/triggeracts.test.mjs | 103 ++++++++++++++++ 28 files changed, 654 insertions(+), 39 deletions(-) create mode 100644 core/http/react-ui/src/components/TriggerActs.jsx create mode 100644 core/http/react-ui/src/components/trigger-acts.css create mode 100644 core/http/react-ui/src/utils/triggerActs.js create mode 100644 core/http/react-ui/tests/unit/triggeracts.test.mjs diff --git a/core/http/react-ui/e2e/chats-proposal-card.spec.js b/core/http/react-ui/e2e/chats-proposal-card.spec.js index 67f61ec634..68d83ecaef 100644 --- a/core/http/react-ui/e2e/chats-proposal-card.spec.js +++ b/core/http/react-ui/e2e/chats-proposal-card.spec.js @@ -21,11 +21,11 @@ const HISTORY = [ ] // The server, in memory: the state of the proposal and what each call does. -async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails = false } = {}) { +async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails = false, extra = {} } = {}) { const s = { state: 'pending', canUndo: true, blocked: '', calls: [] } const view = () => ({ state: s.state, - trigger: trigger(s.state === 'accepted' ? { enabled: true, next_run: '2026-11-02T08:00:00Z' } : {}), + trigger: trigger({ ...extra, ...(s.state === 'accepted' ? { enabled: true, next_run: '2026-11-02T08:00:00Z' } : {}) }), answered_at: s.state === 'pending' ? undefined : AT, can_undo: s.state === 'accepted' && s.canUndo, undo_blocked: s.blocked || undefined, @@ -36,6 +36,10 @@ async function serve(page, { messages = HISTORY, undoRefusal = '', confirmFails await page.route('**/api/chats/c1', route => route.fulfill({ json: detail })) await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true } })) await page.route('**/api/chats/agents*', route => route.fulfill({ json: { agents: [] } })) + await page.route('**/api/chats/agents/builtin-grants', route => route.fulfill({ json: { grants: [ + { name: 'act.files', title: 'Write files on its own', description: 'A run that nobody watches may write files inside the agent\'s folders.', sensitive: true }, + { name: 'act.spend', title: 'Use paid or external tools on its own', description: 'A run that nobody watches may use paid or external tools and the network.', sensitive: true }, + ] } })) await page.route('**/api/chats/triggers/preview*', route => route.fulfill({ json: { times: ['2026-11-02T08:00:00Z'], summary: 'Every Monday at 09:00' } })) await page.route(/\/api\/chats\/triggers\/proposals\/p1(\/[a-z]+)?$/, (route) => { const req = route.request() @@ -100,6 +104,25 @@ for (const [name, size] of [['desktop', { width: 1440, height: 900 }], ['phone', await page.screenshot({ path: `test-results/proposal-${name}-pending.png` }) }) + test('the card says a run only reads when the trigger holds no act, and why', async ({ page }) => { + await open(page, { extra: { acts: [], acts_held: [], read_only: true, read_only_why: 'no_acts' } }) + const sum = card(page).getByTestId('trigger-acts-summary') + await expect(sum).toHaveAttribute('data-read-only', 'true') + await expect(sum).toContainText('Read only') + await expect(card(page).getByTestId('trigger-acts-why')).toContainText('only read and report') + }) + + test('the card lists the acts the trigger would run with, sensitive ones marked, before it is created', async ({ page }) => { + const s = await open(page, { extra: { acts: ['act.files', 'act.spend'], acts_held: ['act.files'], read_only: false } }) + const sum = card(page).getByTestId('trigger-acts-summary') + await expect(sum.locator('[data-act="act.files"]')).toContainText('Write files on its own') + await expect(sum.locator('[data-act="act.files"]')).toContainText('needs care') + await expect(sum.locator('[data-act="act.spend"]')).toHaveCount(0) + await expect(card(page).getByTestId('trigger-acts-unused')).toContainText('Use paid or external tools on its own') + expect(s.calls).not.toContain('POST confirm') + await page.screenshot({ path: `test-results/proposal-${name}-acts.png` }) + }) + test('accepting leaves a receipt in place, undo takes it back, and the state survives a reload', async ({ page }) => { const s = await open(page) await card(page).getByRole('button', { name: 'Create trigger' }).click() diff --git a/core/http/react-ui/public/locales/de/agents.json b/core/http/react-ui/public/locales/de/agents.json index 36548bbdfd..74ff6bf423 100644 --- a/core/http/react-ui/public/locales/de/agents.json +++ b/core/http/react-ui/public/locales/de/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "Berechtigungen" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "Berechtigungen" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "Darf {{count}} Art von Aktion nutzen", + "acts_other": "Darf {{count}} Arten von Aktionen nutzen" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json index 012bcf24c6..6a2c2c9bf1 100644 --- a/core/http/react-ui/public/locales/de/chats.json +++ b/core/http/react-ui/public/locales/de/chats.json @@ -56,7 +56,8 @@ "costUnknown_other": "Die Tokens eines Laufs sind nach dem ersten Lauf bekannt. Höchstens {{count}} Läufe pro Tag.", "remoteNote": "{{model}} ist ein entfernter Dienst: Was ein Lauf liest, verlässt diesen Server.", "stopWidget": "Pausiere ihn im Widget oder lösche ihn unter Trigger", - "stop": "Schalte ihn aus oder lösche ihn unter Trigger" + "stop": "Schalte ihn aus oder lösche ihn unter Trigger", + "actsLabel": "Berechtigungen" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "{{count}} Dateien löschen", "cleared_one": "{{count}} Datei in den Papierkorb verschoben.", "cleared_other": "{{count}} Dateien in den Papierkorb verschoben." + }, + "triggerActs": { + "help": "Ein Lauf startet von selbst, also bestätigt niemand einen Schritt. Er kann lesen und berichten. Hake an, was er zusätzlich tun darf. Er bekommt nie mehr, als sein Agent hat.", + "noAgent": "Es ist kein Agent gewählt, daher begrenzt nur das Angehakte den Lauf.", + "readOnly": "Nur lesen", + "canAct": "Darf selbstständig handeln", + "canActText_one": "Ein Lauf darf {{count}} Art von Aktion ohne Rückfrage nutzen.", + "canActText_other": "Ein Lauf darf {{count}} Arten von Aktionen ohne Rückfrage nutzen.", + "sensitive": "mit Vorsicht", + "addonRow": "Ein Lauf darf die Aktionen des Add-ons {{id}} nutzen.", + "blocked": "Dein Agent hat dies nicht, daher lässt es sich nicht anhaken. Füge es zuerst dem Agenten hinzu.", + "unused": "Dein Agent hat dies nicht, daher nutzt ein Lauf es nicht.", + "unusedList": "Aufgeführt, aber nicht genutzt, weil der Agent es nicht hat: {{names}}.", + "writesNote": "Sein Plan nennt die Datei {{file}}, daher darf ein Lauf auch Dateien schreiben.", + "listCan_one": "Kann handeln ({{count}})", + "listCan_other": "Kann handeln ({{count}})", + "why": { + "no_acts": "Keine Berechtigung zum Handeln gesetzt, daher lesen und berichten seine Läufe nur.", + "agent": "Sein Agent hat die verlangten Berechtigungen nicht, daher lesen und berichten seine Läufe nur.", + "declared": "Er wurde so eingerichtet, dass er nur liest und berichtet. Nichts, was hier angehakt wird, ändert das.", + "binding": "Eine Verbindung im Nur-Lesen-Modus hat ihn erstellt. Nichts, was hier angehakt wird, ändert das." + } } } diff --git a/core/http/react-ui/public/locales/en/agents.json b/core/http/react-ui/public/locales/en/agents.json index bf13f3cf8e..0e50b7b8a2 100644 --- a/core/http/react-ui/public/locales/en/agents.json +++ b/core/http/react-ui/public/locales/en/agents.json @@ -419,7 +419,8 @@ "runMeta": "took {{time}}", "agent": "Agent", "cap": "Most runs a day", - "watches": "Watches the folder {{folder}}" + "watches": "Watches the folder {{folder}}", + "acts": "Permissions" }, "edit": { "titleNew": "New trigger", @@ -444,7 +445,8 @@ "schedule": "When", "media": "Media sources", "webhooks": "Webhooks", - "limits": "Limits" + "limits": "Limits", + "acts": "Permissions" }, "summary": { "basics": "{{name}}, {{model}}", @@ -453,7 +455,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "May use {{count}} kind of action", + "acts_other": "May use {{count}} kinds of action" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json index c50966a2e8..b6a2ad9156 100644 --- a/core/http/react-ui/public/locales/en/chats.json +++ b/core/http/react-ui/public/locales/en/chats.json @@ -723,7 +723,8 @@ "costUnknown_other": "The tokens of a run are known after the first run. At most {{count}} runs a day.", "remoteNote": "{{model}} is a remote service: what a run reads leaves this server.", "stopWidget": "Pause it from the widget, or delete it in Triggers", - "stop": "Switch it off or delete it in Triggers" + "stop": "Switch it off or delete it in Triggers", + "actsLabel": "Permissions" } }, "subAgents": { @@ -1052,5 +1053,27 @@ "clearYes_other": "Delete {{count}} files", "cleared_one": "Moved {{count}} file to the Trash.", "cleared_other": "Moved {{count}} files to the Trash." + }, + "triggerActs": { + "help": "A run starts by itself, so nobody is there to approve a step. It can read and report. Tick what it may also do. It never gets more than its agent holds.", + "noAgent": "No agent is chosen, so only what you tick limits it.", + "readOnly": "Read only", + "canAct": "May act on its own", + "canActText_one": "A run may use {{count}} kind of action without asking.", + "canActText_other": "A run may use {{count}} kinds of action without asking.", + "sensitive": "needs care", + "addonRow": "A run may use the actions of the add-on {{id}}.", + "blocked": "Your agent does not hold this, so it cannot be ticked. Add it to the agent first.", + "unused": "Your agent does not hold this, so a run does not use it.", + "unusedList": "Listed, but not used because the agent does not hold it: {{names}}.", + "writesNote": "Its plan names the file {{file}}, so a run may also write files.", + "listCan_one": "Can act ({{count}})", + "listCan_other": "Can act ({{count}})", + "why": { + "no_acts": "No permission to act is set, so its runs only read and report.", + "agent": "Its agent does not hold the permissions it asks for, so its runs only read and report.", + "declared": "It was set up to read and report only. Nothing ticked here changes that.", + "binding": "A connection in read only mode made it. Nothing ticked here changes that." + } } } diff --git a/core/http/react-ui/public/locales/es/agents.json b/core/http/react-ui/public/locales/es/agents.json index 2cbe6bd5e7..82bde6ef5c 100644 --- a/core/http/react-ui/public/locales/es/agents.json +++ b/core/http/react-ui/public/locales/es/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "Permisos" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "Permisos" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "Puede usar {{count}} tipo de acción", + "acts_other": "Puede usar {{count}} tipos de acción" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json index ae6d5f4da8..cc98466293 100644 --- a/core/http/react-ui/public/locales/es/chats.json +++ b/core/http/react-ui/public/locales/es/chats.json @@ -56,7 +56,8 @@ "costUnknown_other": "Los tokens de una ejecución se conocen tras la primera. Como máximo {{count}} ejecuciones al día.", "remoteNote": "{{model}} es un servicio remoto: lo que lee una ejecución sale de este servidor.", "stopWidget": "Páusalo desde el widget o elimínalo en Disparadores", - "stop": "Apágalo o elimínalo en Disparadores" + "stop": "Apágalo o elimínalo en Disparadores", + "actsLabel": "Permisos" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "Eliminar {{count}} archivos", "cleared_one": "{{count}} archivo movido a la Papelera.", "cleared_other": "{{count}} archivos movidos a la Papelera." + }, + "triggerActs": { + "help": "Una ejecución empieza sola, así que nadie aprueba un paso. Puede leer e informar. Marca lo que además puede hacer. Nunca recibe más de lo que tiene su agente.", + "noAgent": "No hay agente elegido, así que solo lo que marques la limita.", + "readOnly": "Solo lectura", + "canAct": "Puede actuar por sí misma", + "canActText_one": "Una ejecución puede usar {{count}} tipo de acción sin preguntar.", + "canActText_other": "Una ejecución puede usar {{count}} tipos de acción sin preguntar.", + "sensitive": "requiere cuidado", + "addonRow": "Una ejecución puede usar las acciones del complemento {{id}}.", + "blocked": "Tu agente no tiene esto, así que no se puede marcar. Añádelo antes al agente.", + "unused": "Tu agente no tiene esto, así que una ejecución no lo usa.", + "unusedList": "En la lista, pero sin uso porque el agente no lo tiene: {{names}}.", + "writesNote": "Su plan nombra el archivo {{file}}, así que una ejecución también puede escribir archivos.", + "listCan_one": "Puede actuar ({{count}})", + "listCan_other": "Puede actuar ({{count}})", + "why": { + "no_acts": "No tiene permiso para actuar, así que sus ejecuciones solo leen e informan.", + "agent": "Su agente no tiene los permisos que pide, así que sus ejecuciones solo leen e informan.", + "declared": "Se creó para leer e informar solamente. Nada de lo que marques aquí lo cambia.", + "binding": "La creó una conexión en modo de solo lectura. Nada de lo que marques aquí lo cambia." + } } } diff --git a/core/http/react-ui/public/locales/fr/agents.json b/core/http/react-ui/public/locales/fr/agents.json index 4587e03865..240d9b5072 100644 --- a/core/http/react-ui/public/locales/fr/agents.json +++ b/core/http/react-ui/public/locales/fr/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "Permissions" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "Permissions" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "Peut utiliser {{count}} type d'action", + "acts_other": "Peut utiliser {{count}} types d'action" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json index 99a5225994..4ff0606baa 100644 --- a/core/http/react-ui/public/locales/fr/chats.json +++ b/core/http/react-ui/public/locales/fr/chats.json @@ -56,7 +56,8 @@ "costUnknown_other": "Les jetons d’une exécution sont connus après la première. Au plus {{count}} exécutions par jour.", "remoteNote": "{{model}} est un service distant : ce que lit une exécution quitte ce serveur.", "stopWidget": "Mettez-le en pause depuis le widget, ou supprimez-le dans Déclencheurs", - "stop": "Désactivez-le ou supprimez-le dans Déclencheurs" + "stop": "Désactivez-le ou supprimez-le dans Déclencheurs", + "actsLabel": "Permissions" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "Supprimer {{count}} fichiers", "cleared_one": "{{count}} fichier déplacé dans la corbeille.", "cleared_other": "{{count}} fichiers déplacés dans la corbeille." + }, + "triggerActs": { + "help": "Une exécution démarre seule, donc personne n'approuve une étape. Elle peut lire et rendre compte. Cochez ce qu'elle peut faire en plus. Elle n'obtient jamais plus que son agent.", + "noAgent": "Aucun agent n'est choisi, donc seul ce que vous cochez la limite.", + "readOnly": "Lecture seule", + "canAct": "Peut agir seule", + "canActText_one": "Une exécution peut utiliser {{count}} type d'action sans demander.", + "canActText_other": "Une exécution peut utiliser {{count}} types d'action sans demander.", + "sensitive": "à manier avec soin", + "addonRow": "Une exécution peut utiliser les actions du module {{id}}.", + "blocked": "Votre agent n'a pas cette permission, on ne peut donc pas la cocher. Ajoutez-la d'abord à l'agent.", + "unused": "Votre agent n'a pas cette permission, donc une exécution ne l'utilise pas.", + "unusedList": "Listée, mais inutilisée car l'agent ne l'a pas : {{names}}.", + "writesNote": "Son plan nomme le fichier {{file}}, donc une exécution peut aussi écrire des fichiers.", + "listCan_one": "Peut agir ({{count}})", + "listCan_other": "Peut agir ({{count}})", + "why": { + "no_acts": "Aucune permission d'agir n'est définie, donc ses exécutions lisent et rendent compte seulement.", + "agent": "Son agent n'a pas les permissions demandées, donc ses exécutions lisent et rendent compte seulement.", + "declared": "Il a été créé pour lire et rendre compte seulement. Rien de ce qui est coché ici ne change cela.", + "binding": "Une connexion en lecture seule l'a créé. Rien de ce qui est coché ici ne change cela." + } } } diff --git a/core/http/react-ui/public/locales/id/agents.json b/core/http/react-ui/public/locales/id/agents.json index 5bee76947e..24cf0317a6 100644 --- a/core/http/react-ui/public/locales/id/agents.json +++ b/core/http/react-ui/public/locales/id/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "Izin" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "Izin" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "Boleh memakai {{count}} jenis tindakan", + "acts_other": "Boleh memakai {{count}} jenis tindakan" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json index 8914c8a848..2661019d17 100644 --- a/core/http/react-ui/public/locales/id/chats.json +++ b/core/http/react-ui/public/locales/id/chats.json @@ -56,7 +56,8 @@ "costUnknown_one": "Token per jalan diketahui setelah jalan pertama. Paling banyak {{count}} jalan per hari.", "remoteNote": "{{model}} adalah layanan jarak jauh: apa yang dibaca sebuah jalan meninggalkan server ini.", "stopWidget": "Jeda dari widget, atau hapus di Pemicu", - "stop": "Matikan atau hapus di Pemicu" + "stop": "Matikan atau hapus di Pemicu", + "actsLabel": "Izin" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "Hapus {{count}} berkas", "cleared_one": "{{count}} berkas dipindahkan ke Tempat Sampah.", "cleared_other": "{{count}} berkas dipindahkan ke Tempat Sampah." + }, + "triggerActs": { + "help": "Satu jalan dimulai sendiri, jadi tidak ada yang menyetujui langkahnya. Ia bisa membaca dan melapor. Centang apa yang boleh dilakukannya juga. Ia tidak pernah mendapat lebih dari yang dimiliki agennya.", + "noAgent": "Belum ada agen yang dipilih, jadi hanya centangan Anda yang membatasinya.", + "readOnly": "Hanya baca", + "canAct": "Boleh bertindak sendiri", + "canActText_one": "Satu jalan boleh memakai {{count}} jenis tindakan tanpa bertanya.", + "canActText_other": "Satu jalan boleh memakai {{count}} jenis tindakan tanpa bertanya.", + "sensitive": "perlu hati-hati", + "addonRow": "Satu jalan boleh memakai tindakan add-on {{id}}.", + "blocked": "Agen Anda tidak memiliki izin ini, jadi tidak bisa dicentang. Tambahkan dulu ke agen.", + "unused": "Agen Anda tidak memiliki izin ini, jadi satu jalan tidak memakainya.", + "unusedList": "Terdaftar, tetapi tidak dipakai karena agen tidak memilikinya: {{names}}.", + "writesNote": "Rencananya menyebut berkas {{file}}, jadi satu jalan juga boleh menulis berkas.", + "listCan_one": "Bisa bertindak ({{count}})", + "listCan_other": "Bisa bertindak ({{count}})", + "why": { + "no_acts": "Tidak ada izin bertindak, jadi jalannya hanya membaca dan melapor.", + "agent": "Agennya tidak memiliki izin yang diminta, jadi jalannya hanya membaca dan melapor.", + "declared": "Ini dibuat untuk hanya membaca dan melapor. Centangan di sini tidak mengubahnya.", + "binding": "Dibuat oleh koneksi dalam mode hanya baca. Centangan di sini tidak mengubahnya." + } } } diff --git a/core/http/react-ui/public/locales/it/agents.json b/core/http/react-ui/public/locales/it/agents.json index 7a2994b9b2..5c9e87ed6b 100644 --- a/core/http/react-ui/public/locales/it/agents.json +++ b/core/http/react-ui/public/locales/it/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "Permessi" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "Permessi" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "Può usare {{count}} tipo di azione", + "acts_other": "Può usare {{count}} tipi di azione" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json index c7f9b38cbb..5001d6a0fc 100644 --- a/core/http/react-ui/public/locales/it/chats.json +++ b/core/http/react-ui/public/locales/it/chats.json @@ -56,7 +56,8 @@ "costUnknown_other": "I token di un'esecuzione si conoscono dopo la prima. Al massimo {{count}} esecuzioni al giorno.", "remoteNote": "{{model}} è un servizio remoto: quello che un'esecuzione legge esce da questo server.", "stopWidget": "Mettilo in pausa dal widget, o eliminalo in Trigger", - "stop": "Spegnilo o eliminalo in Trigger" + "stop": "Spegnilo o eliminalo in Trigger", + "actsLabel": "Permessi" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "Elimina {{count}} file", "cleared_one": "{{count}} file spostato nel Cestino.", "cleared_other": "{{count}} file spostati nel Cestino." + }, + "triggerActs": { + "help": "Un'esecuzione parte da sola, quindi nessuno approva un passo. Può leggere e riferire. Spunta ciò che può fare in più. Non ottiene mai più di quanto ha il suo agente.", + "noAgent": "Nessun agente scelto, quindi la limita solo ciò che spunti.", + "readOnly": "Sola lettura", + "canAct": "Può agire da sola", + "canActText_one": "Un'esecuzione può usare {{count}} tipo di azione senza chiedere.", + "canActText_other": "Un'esecuzione può usare {{count}} tipi di azione senza chiedere.", + "sensitive": "da usare con cura", + "addonRow": "Un'esecuzione può usare le azioni del componente {{id}}.", + "blocked": "Il tuo agente non ha questo permesso, quindi non si può spuntare. Aggiungilo prima all'agente.", + "unused": "Il tuo agente non ha questo permesso, quindi un'esecuzione non lo usa.", + "unusedList": "In elenco, ma non usato perché l'agente non lo ha: {{names}}.", + "writesNote": "Il suo piano nomina il file {{file}}, quindi un'esecuzione può anche scrivere file.", + "listCan_one": "Può agire ({{count}})", + "listCan_other": "Può agire ({{count}})", + "why": { + "no_acts": "Nessun permesso di agire, quindi le sue esecuzioni leggono e riferiscono soltanto.", + "agent": "Il suo agente non ha i permessi richiesti, quindi le sue esecuzioni leggono e riferiscono soltanto.", + "declared": "È stato creato per leggere e riferire soltanto. Nulla di ciò che spunti qui lo cambia.", + "binding": "L'ha creato una connessione in sola lettura. Nulla di ciò che spunti qui lo cambia." + } } } diff --git a/core/http/react-ui/public/locales/ko/agents.json b/core/http/react-ui/public/locales/ko/agents.json index 329cc3a340..439287c0fb 100644 --- a/core/http/react-ui/public/locales/ko/agents.json +++ b/core/http/react-ui/public/locales/ko/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "권한" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "권한" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "{{count}}가지 종류의 동작 사용 가능", + "acts_other": "{{count}}가지 종류의 동작 사용 가능" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json index f1590041e2..7bee49b8bd 100644 --- a/core/http/react-ui/public/locales/ko/chats.json +++ b/core/http/react-ui/public/locales/ko/chats.json @@ -56,7 +56,8 @@ "costUnknown_one": "실행당 토큰은 첫 실행 후에 알 수 있습니다. 하루 최대 {{count}}회.", "remoteNote": "{{model}}은(는) 원격 서비스입니다. 실행이 읽는 내용이 이 서버 밖으로 나갑니다.", "stopWidget": "위젯에서 일시 중지하거나 트리거에서 삭제하세요", - "stop": "트리거에서 끄거나 삭제하세요" + "stop": "트리거에서 끄거나 삭제하세요", + "actsLabel": "권한" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "파일 {{count}}개 삭제", "cleared_one": "파일 {{count}}개를 휴지통으로 옮겼습니다.", "cleared_other": "파일 {{count}}개를 휴지통으로 옮겼습니다." + }, + "triggerActs": { + "help": "실행은 스스로 시작되므로 단계를 승인해 줄 사람이 없습니다. 읽고 보고할 수 있습니다. 추가로 허용할 것을 선택하세요. 에이전트가 가진 것보다 많이 받지는 않습니다.", + "noAgent": "에이전트를 고르지 않았으므로 선택한 항목만 제한합니다.", + "readOnly": "읽기 전용", + "canAct": "스스로 동작 가능", + "canActText_one": "실행은 묻지 않고 {{count}}가지 종류의 동작을 쓸 수 있습니다.", + "canActText_other": "실행은 묻지 않고 {{count}}가지 종류의 동작을 쓸 수 있습니다.", + "sensitive": "주의 필요", + "addonRow": "실행은 애드온 {{id}}의 동작을 쓸 수 있습니다.", + "blocked": "에이전트에 이 권한이 없어 선택할 수 없습니다. 먼저 에이전트에 추가하세요.", + "unused": "에이전트에 이 권한이 없어 실행에서 쓰지 않습니다.", + "unusedList": "목록에 있지만 에이전트에 없어서 쓰지 않음: {{names}}.", + "writesNote": "계획에 파일 {{file}}이(가) 있어 실행이 파일도 쓸 수 있습니다.", + "listCan_one": "동작 가능 ({{count}})", + "listCan_other": "동작 가능 ({{count}})", + "why": { + "no_acts": "동작 권한이 없어 실행은 읽고 보고만 합니다.", + "agent": "에이전트에 요청한 권한이 없어 실행은 읽고 보고만 합니다.", + "declared": "읽고 보고만 하도록 만들어졌습니다. 여기서 선택해도 바뀌지 않습니다.", + "binding": "읽기 전용 모드의 연결이 만들었습니다. 여기서 선택해도 바뀌지 않습니다." + } } } diff --git a/core/http/react-ui/public/locales/pt-BR/agents.json b/core/http/react-ui/public/locales/pt-BR/agents.json index ff37baee81..c3184acb31 100644 --- a/core/http/react-ui/public/locales/pt-BR/agents.json +++ b/core/http/react-ui/public/locales/pt-BR/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "Permissões" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "Permissões" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "Pode usar {{count}} tipo de ação", + "acts_other": "Pode usar {{count}} tipos de ação" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json index 81e849ed30..18c86430dd 100644 --- a/core/http/react-ui/public/locales/pt-BR/chats.json +++ b/core/http/react-ui/public/locales/pt-BR/chats.json @@ -56,7 +56,8 @@ "costUnknown_other": "Os tokens de uma execução são conhecidos após a primeira. No máximo {{count}} execuções por dia.", "remoteNote": "{{model}} é um serviço remoto: o que uma execução lê sai deste servidor.", "stopWidget": "Pause pelo widget ou exclua em Gatilhos", - "stop": "Desligue ou exclua em Gatilhos" + "stop": "Desligue ou exclua em Gatilhos", + "actsLabel": "Permissões" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "Excluir {{count}} arquivos", "cleared_one": "{{count}} arquivo movido para a Lixeira.", "cleared_other": "{{count}} arquivos movidos para a Lixeira." + }, + "triggerActs": { + "help": "Uma execução começa sozinha, então ninguém aprova um passo. Ela pode ler e relatar. Marque o que ela também pode fazer. Ela nunca recebe mais do que o seu agente tem.", + "noAgent": "Nenhum agente escolhido, então só o que você marcar a limita.", + "readOnly": "Somente leitura", + "canAct": "Pode agir sozinha", + "canActText_one": "Uma execução pode usar {{count}} tipo de ação sem perguntar.", + "canActText_other": "Uma execução pode usar {{count}} tipos de ação sem perguntar.", + "sensitive": "requer cuidado", + "addonRow": "Uma execução pode usar as ações do complemento {{id}}.", + "blocked": "Seu agente não tem esta permissão, então ela não pode ser marcada. Adicione-a antes ao agente.", + "unused": "Seu agente não tem esta permissão, então uma execução não a usa.", + "unusedList": "Listada, mas sem uso porque o agente não a tem: {{names}}.", + "writesNote": "O plano cita o arquivo {{file}}, então uma execução também pode gravar arquivos.", + "listCan_one": "Pode agir ({{count}})", + "listCan_other": "Pode agir ({{count}})", + "why": { + "no_acts": "Sem permissão para agir, então suas execuções só leem e relatam.", + "agent": "Seu agente não tem as permissões pedidas, então suas execuções só leem e relatam.", + "declared": "Foi criado para apenas ler e relatar. Nada que você marque aqui muda isso.", + "binding": "Foi criado por uma conexão em modo somente leitura. Nada que você marque aqui muda isso." + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/agents.json b/core/http/react-ui/public/locales/zh-CN/agents.json index e6072793a4..80f4a5b998 100644 --- a/core/http/react-ui/public/locales/zh-CN/agents.json +++ b/core/http/react-ui/public/locales/zh-CN/agents.json @@ -218,7 +218,8 @@ "watches": "Watches the folder {{folder}}", "noRuns": "This trigger has not run yet.", "runMeta": "took {{time}}", - "prompt": "What to do" + "prompt": "What to do", + "acts": "权限" }, "edit": { "titleNew": "New trigger", @@ -234,7 +235,8 @@ "basics": "Basics", "prompt": "What to do", "schedule": "When", - "limits": "Limits" + "limits": "Limits", + "acts": "权限" }, "summary": { "basics": "{{name}}, {{model}}", @@ -242,7 +244,9 @@ "promptEmpty": "Nothing to do yet", "folderEmpty": "Needs a folder", "cap": "Up to {{count}} runs a day", - "capDefault": "Default limit" + "capDefault": "Default limit", + "acts_one": "可使用 {{count}} 类操作", + "acts_other": "可使用 {{count}} 类操作" }, "name": "Name", "namePlaceholder": "daily-digest", diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json index 3f2fe9adc5..7913ea9c42 100644 --- a/core/http/react-ui/public/locales/zh-CN/chats.json +++ b/core/http/react-ui/public/locales/zh-CN/chats.json @@ -56,7 +56,8 @@ "costUnknown_one": "每次运行的 token 数在首次运行后可知。每天最多 {{count}} 次。", "remoteNote": "{{model}} 是远程服务:运行读取的内容会离开此服务器。", "stopWidget": "在小组件中暂停,或在触发器中删除", - "stop": "在触发器中关闭或删除" + "stop": "在触发器中关闭或删除", + "actsLabel": "权限" } }, "mcpPage": { @@ -730,5 +731,27 @@ "clearYes_other": "删除 {{count}} 个文件", "cleared_one": "已将 {{count}} 个文件移到回收站。", "cleared_other": "已将 {{count}} 个文件移到回收站。" + }, + "triggerActs": { + "help": "运行会自行开始,没有人批准每一步。它可以读取并汇报。勾选它还可以做什么。它得到的权限不会超过其代理。", + "noAgent": "未选择代理,因此只有你勾选的项目起限制作用。", + "readOnly": "只读", + "canAct": "可自行操作", + "canActText_one": "一次运行可在不询问的情况下使用 {{count}} 类操作。", + "canActText_other": "一次运行可在不询问的情况下使用 {{count}} 类操作。", + "sensitive": "需谨慎", + "addonRow": "一次运行可使用附加组件 {{id}} 的操作。", + "blocked": "你的代理没有此权限,因此无法勾选。请先将它添加到代理。", + "unused": "你的代理没有此权限,因此运行不会使用它。", + "unusedList": "已列出,但代理没有,所以不使用:{{names}}。", + "writesNote": "其计划指明了文件 {{file}},因此一次运行也可以写入文件。", + "listCan_one": "可操作({{count}})", + "listCan_other": "可操作({{count}})", + "why": { + "no_acts": "未设置操作权限,因此其运行只读取并汇报。", + "agent": "其代理没有它要求的权限,因此其运行只读取并汇报。", + "declared": "它被设置为只读取并汇报。在此勾选不会改变这一点。", + "binding": "它由只读模式的连接创建。在此勾选不会改变这一点。" + } } } diff --git a/core/http/react-ui/src/components/TriggerActs.jsx b/core/http/react-ui/src/components/TriggerActs.jsx new file mode 100644 index 0000000000..84ae3aa884 --- /dev/null +++ b/core/http/react-ui/src/components/TriggerActs.jsx @@ -0,0 +1,114 @@ +import { useTranslation } from 'react-i18next' +import { useBuiltinGrants } from '../hooks/useBuiltinGrants' +import { actNamed, actRows, actsAfter, actsStatus, actsStatusOf, actCatalog } from '../utils/triggerActs' +import { Callout } from './ui' +import './trigger-acts.css' + +// What the runs of a trigger may do with nobody present. Three views of the +// same rules (utils/triggerActs.js): +// ActsEditor the checklist of the trigger form, drawn like the grants of an +// agent: a plain-words line on each, sensitive ones marked +// ActsSummary the read only view of a trigger the server returned (details +// page, proposal card) +// ReadOnlyTag a short mark for a list row +// A run that holds no act only reads and reports. The server enforces this at +// each tool call; the interface only shows it. + +function Tag({ children }) { + return {children} +} + +/** One short reason why a run only reads, in plain words. */ +function ReadOnlyWhy({ why, t }) { + return <>{t(`triggerActs.why.${why || 'no_acts'}`)} +} + +/** + * The editable list. value is the acts of the form, agentGrants the grants of + * the chosen agent (null: no agent chosen), writes the file the plan names, + * lock why the trigger is read only whatever is ticked. + */ +// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react +export function ActsEditor({ value, onChange, agentGrants, writes = '', lock = '' }) { + const { t } = useTranslation('chats') + const catalog = actCatalog(useBuiltinGrants()) + const rows = actRows(catalog, value, agentGrants) + const status = actsStatus({ acts: value, agentGrants, writes, lock }) + return ( +
+

{t('triggerActs.help')}

+ {agentGrants == null &&

{t('triggerActs.noAgent')}

} + + {status.readOnly + ? + : t('triggerActs.canActText', { count: status.held.length })} + + {writes && !lock &&

{t('triggerActs.writesNote', { file: writes })}

} +
+ {rows.map((g) => ( + + ))} +
+
+ ) +} + +/** + * The read only view of a trigger as the server returned it. compact leaves out + * the explanation of each grant (the proposal card is short). + */ +// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react +export function ActsSummary({ trigger, compact = false }) { + const { t } = useTranslation('chats') + const catalog = actCatalog(useBuiltinGrants()) + const status = actsStatusOf(trigger) + if (status.readOnly || status.held.length === 0) { + const why = status.why || (status.unused.length > 0 ? 'agent' : 'no_acts') + return ( +
+ {t('triggerActs.readOnly')}{' '} +
+ ) + } + const rows = actNamed(catalog, status.held) + const unused = actNamed(catalog, status.unused) + return ( +
+ + {unused.length > 0 && ( +

{t('triggerActs.unusedList', { names: unused.map((g) => g.title).join(', ') })}

+ )} +
+ ) +} + +/** A mark for a list row: nothing for a run that can act, "Read only" for one that cannot. */ +// eslint-disable-next-line no-unused-vars -- used in JSX; this config has no eslint-plugin-react +export function ReadOnlyTag({ trigger }) { + const { t } = useTranslation('chats') + const status = actsStatusOf(trigger) + if (!status.readOnly) return {t('triggerActs.listCan', { count: status.held.length })} + return {t('triggerActs.readOnly')} +} diff --git a/core/http/react-ui/src/components/chat/ProposalCard.jsx b/core/http/react-ui/src/components/chat/ProposalCard.jsx index fe9d230ae2..c46285e89e 100644 --- a/core/http/react-ui/src/components/chat/ProposalCard.jsx +++ b/core/http/react-ui/src/components/chat/ProposalCard.jsx @@ -5,6 +5,7 @@ import { proposalEdit, proposalView, receiptWhen } from '../../utils/chatProposa import { useWellKnown } from '../../hooks/useWellKnown' import { widgetList } from '../../utils/wellKnown' import { plainSummary } from '../../utils/widgetTrigger' +import { ActsSummary } from '../TriggerActs' import Icon from '../Icon' import { Button } from '../ui' @@ -49,6 +50,7 @@ function Plan({ p, t }) { ? <>{p.plan.writes}{t('proposal.plan.writesWidget', { widget: widget.title })} : {p.plan.writes}], ['cost', costText], + ['acts', ], ['stop', t(widget ? 'proposal.plan.stopWidget' : 'proposal.plan.stop')], ].filter(Boolean) return ( diff --git a/core/http/react-ui/src/components/trigger-acts.css b/core/http/react-ui/src/components/trigger-acts.css new file mode 100644 index 0000000000..53364452a3 --- /dev/null +++ b/core/http/react-ui/src/components/trigger-acts.css @@ -0,0 +1,6 @@ +/* What the runs of a trigger may do alone: the editor list, the summary and the tag. */ +.ta { display: flex; flex-direction: column; gap: var(--sp-2); } +.ta--sum { margin: 0; } +.ta-tag { margin-left: var(--sp-2); font-size: var(--fs-small); color: var(--dk-muted); } +.ta-list { margin: 0; padding-left: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-1); } +.ta-desc { display: block; } diff --git a/core/http/react-ui/src/pages/AgentJobs.jsx b/core/http/react-ui/src/pages/AgentJobs.jsx index 6a699a51b8..74788c369b 100644 --- a/core/http/react-ui/src/pages/AgentJobs.jsx +++ b/core/http/react-ui/src/pages/AgentJobs.jsx @@ -14,6 +14,7 @@ import { JobMark, JobStrip } from '../components/agents/JobBits' import { jobSentence } from '../utils/agentJobText' import { AgentViews, StatusMark } from '../components/agents/AgentBits' import { ClockTime, OutputBlock, RelTime, RunItem } from '../components/agents/RunBits' +import { ReadOnlyTag } from '../components/TriggerActs' import Icon from '../components/Icon' import { Button, EmptyState, LoadError, Page, PageHeader, Segmented, SkeletonRows, Switch } from '../components/ui' import './agents.css' @@ -361,6 +362,7 @@ function TriggerRow({ task, jobs, agent, onToggle, onRun, onOpen, onEdit, onDele : {task.model || t('jobs.tasks.noAgent')}} {!off && Date.parse(task.next_run || '') > Date.now() && {t('jobs.tasks.next')} } {off && {t('jobs.task.disabled')}} +
diff --git a/core/http/react-ui/src/pages/AgentTaskDetails.jsx b/core/http/react-ui/src/pages/AgentTaskDetails.jsx index 30be94351a..8807420f55 100644 --- a/core/http/react-ui/src/pages/AgentTaskDetails.jsx +++ b/core/http/react-ui/src/pages/AgentTaskDetails.jsx @@ -21,10 +21,12 @@ import { jobOfRun, STRIP } from '../utils/agentJobs' import { jobSentence } from '../utils/agentJobText' import { formatDuration, firstLine } from '../utils/agentRuns' import { jobDurationMs } from '../utils/agentJobs' +import { actsStatus } from '../utils/triggerActs' import { EVERY_HOURS, EVERY_MINUTES, HOURLY_MINUTES, dayCodes, emptyTrigger, formatNext, joinTimes, presetOrder, scheduleText, triggerForm, triggerPayload, } from '../utils/chatTrigger' +import { ActsEditor, ActsSummary } from '../components/TriggerActs' import Icon from '../components/Icon' // eslint-disable-next-line no-unused-vars import { Button, LoadError, Page, PageHeader, SkeletonRows, Switch } from '../components/ui' @@ -178,6 +180,8 @@ function TaskView({ id }) { {task.kind === 'file' ? t('jobs.task.watches', { folder: task.folder }) : task.summary} {task.next_run && !off ? · {t('chats:triggersPage.next', { time: formatNext(task.next_run, task.time_zone) })} : null} +
{t('jobs.task.acts')}
+
{t('jobs.task.cap')}
{task.daily_cap || '-'}
{t('jobs.task.enabledSwitch')}
@@ -243,6 +247,7 @@ const SECTIONS = [ { id: 'basics', keys: ['name', 'agent_id', 'model', 'enabled'] }, { id: 'prompt', keys: ['prompt'] }, { id: 'schedule', keys: ['kind', 'schedule', 'time_zone', 'folder', 'quiet_seconds'] }, + { id: 'acts', keys: ['acts'] }, { id: 'limits', keys: ['daily_cap'] }, ] @@ -440,6 +445,9 @@ function TaskForm({ id, isNew }) { }) const picked = agents.find(a => a.id === task.agentId) + // The grants of the chosen agent cap what the trigger can hold; with no agent only the list does. + const agentGrants = picked ? (picked.builtins || []) : null + const status = actsStatus({ acts: task.acts, agentGrants, writes: task.writes, lock: task.lock }) const summary = (sid) => { if (sid === 'basics') { if (!task.name.trim()) return { state: 'needs', text: t('jobs.edit.summary.basicsEmpty') } @@ -452,6 +460,10 @@ function TaskForm({ id, isNew }) { if (task.schedule.preset === 'file') return { state: task.folder.trim() ? 'ready' : 'needs', text: task.folder.trim() ? t('jobs.task.watches', { folder: task.folder }) : t('jobs.edit.summary.folderEmpty') } return { state: 'ready', text: scheduleText(task.schedule) } } + if (sid === 'acts') { + if (status.readOnly) return { state: 'ready', text: t('chats:triggerActs.readOnly') } + return { state: 'ready', text: t('jobs.edit.summary.acts', { count: status.held.length }) } + } return { state: 'ready', text: task.dailyCap ? t('jobs.edit.summary.cap', { count: Number(task.dailyCap) }) : t('jobs.edit.summary.capDefault') } } @@ -564,6 +576,13 @@ function TaskForm({ id, isNew }) {
) } + if (sid === 'acts') { + return ( +
+ set('acts', acts)} agentGrants={agentGrants} writes={task.writes} lock={task.lock} /> +
+ ) + } return (
diff --git a/core/http/react-ui/src/utils/chatTrigger.js b/core/http/react-ui/src/utils/chatTrigger.js index 84fcd6d520..ed6800f3a0 100644 --- a/core/http/react-ui/src/utils/chatTrigger.js +++ b/core/http/react-ui/src/utils/chatTrigger.js @@ -126,6 +126,7 @@ export function emptyTrigger() { return { id: '', name: '', prompt: '', enabled: true, schedule: emptyForm(), zone: browserZone(), folder: '', quiet: '', dailyCap: '', agentId: '', model: '', sourceChatId: '', + acts: [], writes: '', lock: '', } } @@ -135,6 +136,10 @@ export function triggerForm(tr) { id: tr.id, name: tr.name, prompt: tr.prompt, enabled: tr.enabled, schedule: presetOf(tr), zone: tr.time_zone || browserZone(), folder: tr.folder || '', quiet: tr.quiet_seconds ? String(tr.quiet_seconds) : '', dailyCap: String(tr.daily_cap || ''), agentId: tr.agent_id || '', model: tr.model || '', sourceChatId: tr.source_chat_id || '', + // What a run may do alone. writes (the file its plan names) and lock (why a + // run is read only whatever is ticked) come from the server and are not sent. + acts: Array.isArray(tr.acts) ? tr.acts : [], writes: tr.plan?.writes || '', + lock: tr.read_only && (tr.read_only_why === 'declared' || tr.read_only_why === 'binding') ? tr.read_only_why : '', } } @@ -144,6 +149,8 @@ export function triggerPayload(v) { const body = { name: v.name.trim(), prompt: v.prompt, kind: file ? 'file' : 'schedule', enabled: v.enabled, daily_cap: v.dailyCap ? Number(v.dailyCap) : 0, + // Always sent: an edit that leaves acts out keeps them, an empty list takes them away. + acts: v.acts || [], agent_id: v.agentId || undefined, model: v.model || undefined, source_chat_id: v.sourceChatId || undefined, } if (file) { diff --git a/core/http/react-ui/src/utils/triggerActs.js b/core/http/react-ui/src/utils/triggerActs.js new file mode 100644 index 0000000000..52706e6689 --- /dev/null +++ b/core/http/react-ui/src/utils/triggerActs.js @@ -0,0 +1,96 @@ +// What the runs of a trigger may do with nobody present: the act.* names of a +// trigger. A run starts read only; each name adds one class of action, within +// the grants of the trigger's agent. These functions are the rules the editor, +// the details page and the proposal card share, so they say the same thing. +// The server applies the same rules at each tool call. No React, so a node test +// drives them. + +const list = (x) => (Array.isArray(x) ? x : []) + +export const ACT_FILES = 'act.files' +export const ACT_ADDONS = 'act.addons' + +/** True for the name of an act: act.files, act.addons, act.addons:, act.send, act.spend. */ +export const isActGrant = (name) => typeof name === 'string' && name.startsWith('act.') + +/** The act.* entries of the builtin grants the server declares. */ +export function actCatalog(grants) { + return list(grants).filter((g) => isActGrant(g?.name)) +} + +/** + * Whether an agent's grants cover an act: the name itself, or act.addons for + * act.addons:. Without an agent (null) nothing but the trigger's own list + * limits it. + */ +export function withinAgent(name, agentGrants) { + if (agentGrants == null) return true + const held = list(agentGrants) + return held.includes(name) || (name.startsWith(`${ACT_ADDONS}:`) && held.includes(ACT_ADDONS)) +} + +/** + * The rows of the permission list: every act the server declares, on when the + * trigger holds it, and blocked when the agent does not hold it (a blocked + * row can be switched off but not on). An act the trigger holds that the + * server does not list, such as act.addons:, stays as an extra row so it + * can be taken away. + */ +export function actRows(catalog, acts, agentGrants) { + const on = new Set(list(acts)) + const known = new Set(list(catalog).map((g) => g.name)) + const rows = list(catalog).map((g) => ({ + name: g.name, title: g.title, description: g.description, sensitive: !!g.sensitive, + on: on.has(g.name), blocked: !withinAgent(g.name, agentGrants), extra: false, + })) + for (const name of list(acts)) { + if (known.has(name)) continue + rows.push({ name, title: name, description: '', sensitive: true, on: true, blocked: !withinAgent(name, agentGrants), extra: true }) + } + return rows +} + +/** The acts after one is switched on or off. */ +export function actsAfter(acts, name, on) { + const rest = list(acts).filter((x) => x !== name) + return on ? [...rest, name] : rest +} + +/** + * What the runs of a trigger can do, from the fields of the form: + * { readOnly, why, held, unused }. held are the acts that apply; unused the + * ones the trigger lists but its agent does not hold. why is declared or + * binding (set by how the trigger was made, and nothing ticked changes it), + * agent (it lists acts its agent holds none of) or no_acts. A trigger whose + * plan names a file it writes also holds act.files, as the server does. + */ +export function actsStatus({ acts, agentGrants = null, writes = '', lock = '' }) { + const listed = list(acts) + if (lock) return { readOnly: true, why: lock, held: [], unused: listed } + const held = listed.filter((n) => withinAgent(n, agentGrants)) + if (writes && !held.includes(ACT_FILES) && withinAgent(ACT_FILES, agentGrants)) held.push(ACT_FILES) + const unused = listed.filter((n) => !held.includes(n)) + if (held.length === 0) return { readOnly: true, why: listed.length > 0 ? 'agent' : 'no_acts', held, unused } + return { readOnly: false, why: '', held, unused } +} + +/** The same answer from a trigger as the server returns it (acts, acts_held, read_only, read_only_why). */ +export function actsStatusOf(trigger) { + const acts = list(trigger?.acts) + const held = list(trigger?.acts_held) + return { + readOnly: !!trigger?.read_only, why: trigger?.read_only_why || '', held, + unused: trigger?.read_only ? acts : acts.filter((n) => !held.includes(n)), + } +} + +/** The rows of a list of act names, with the title and weight the server declares. */ +export function actNamed(catalog, names) { + const byName = new Map(list(catalog).map((g) => [g.name, g])) + return list(names).map((name) => { + const g = byName.get(name) + return g + ? { name, title: g.title, description: g.description, sensitive: !!g.sensitive } + : { name, title: name, description: '', sensitive: name.startsWith(`${ACT_ADDONS}:`) } + }) +} diff --git a/core/http/react-ui/tests/unit/chattrigger.test.mjs b/core/http/react-ui/tests/unit/chattrigger.test.mjs index 8c90d92bc4..2b30df9699 100644 --- a/core/http/react-ui/tests/unit/chattrigger.test.mjs +++ b/core/http/react-ui/tests/unit/chattrigger.test.mjs @@ -49,7 +49,7 @@ test('chat triggers: a stored file trigger is the file preset', () => { test('chat triggers: the request body of a schedule and of a file trigger', () => { const base = { name: ' Digest ', prompt: 'Go.', enabled: true, dailyCap: '', agentId: '', model: 'm', sourceChatId: '', zone: 'Europe/Rome', folder: '', quiet: '' } assert.deepEqual(triggerPayload({ ...base, schedule: form({ preset: 'weekdays', time: '09:00' }) }), { - name: 'Digest', prompt: 'Go.', kind: 'schedule', enabled: true, daily_cap: 0, agent_id: undefined, model: 'm', source_chat_id: undefined, + name: 'Digest', prompt: 'Go.', kind: 'schedule', enabled: true, daily_cap: 0, acts: [], agent_id: undefined, model: 'm', source_chat_id: undefined, schedule: 'weekdays 09:00', time_zone: 'Europe/Rome', }) const file = triggerPayload({ ...base, schedule: form({ preset: 'file' }), folder: ' /Contracts ', quiet: '45', dailyCap: '5', agentId: 'a1' }) diff --git a/core/http/react-ui/tests/unit/triggeracts.test.mjs b/core/http/react-ui/tests/unit/triggeracts.test.mjs new file mode 100644 index 0000000000..b64f449ff7 --- /dev/null +++ b/core/http/react-ui/tests/unit/triggeracts.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + actCatalog, actNamed, actRows, actsAfter, actsStatus, actsStatusOf, isActGrant, withinAgent, +} from '../../src/utils/triggerActs.js' +import { emptyTrigger, triggerForm, triggerPayload } from '../../src/utils/chatTrigger.js' + +const grants = [ + { name: 'memory.write', title: 'Save memory', description: 'd', sensitive: false }, + { name: 'act.files', title: 'Write files on its own', description: 'files', sensitive: true }, + { name: 'act.addons', title: 'Use add-on actions on its own', description: 'addons', sensitive: true }, + { name: 'act.send', title: 'Send messages on its own', description: 'send', sensitive: true }, + { name: 'act.spend', title: 'Use paid or external tools on its own', description: 'spend', sensitive: true }, +] + +test('trigger acts: the catalog keeps only the act names', () => { + assert.deepEqual(actCatalog(grants).map((g) => g.name), ['act.files', 'act.addons', 'act.send', 'act.spend']) + assert.deepEqual(actCatalog(undefined), []) + assert.equal(isActGrant('act.files'), true) + assert.equal(isActGrant('memory.write'), false) +}) + +test('trigger acts: an agent covers an act by name, or act.addons covers one add-on', () => { + assert.equal(withinAgent('act.files', ['act.files']), true) + assert.equal(withinAgent('act.spend', ['act.files']), false) + assert.equal(withinAgent('act.addons:mail', ['act.addons']), true) + assert.equal(withinAgent('act.addons:mail', ['act.files']), false) + assert.equal(withinAgent('act.spend', null), true, 'no agent, no ceiling') + assert.equal(withinAgent('act.spend', []), false, 'an agent with no grants covers nothing') +}) + +test('trigger acts: rows are on when held, blocked when the agent lacks them, and keep an unlisted act', () => { + const rows = actRows(actCatalog(grants), ['act.files', 'act.addons:mail'], ['act.files']) + const by = Object.fromEntries(rows.map((r) => [r.name, r])) + assert.equal(by['act.files'].on, true) + assert.equal(by['act.files'].blocked, false) + assert.equal(by['act.spend'].on, false) + assert.equal(by['act.spend'].blocked, true) + assert.equal(by['act.files'].sensitive, true) + assert.equal(by['act.addons:mail'].extra, true) + assert.equal(by['act.addons:mail'].on, true) + assert.equal(by['act.addons:mail'].blocked, true, 'the agent holds act.files only') + assert.equal(rows.length, 5) +}) + +test('trigger acts: switching an act on or off', () => { + assert.deepEqual(actsAfter(['act.files'], 'act.send', true), ['act.files', 'act.send']) + assert.deepEqual(actsAfter(['act.files', 'act.send'], 'act.files', false), ['act.send']) + assert.deepEqual(actsAfter(undefined, 'act.send', false), []) +}) + +test('trigger acts: a trigger with no acts is read only and says so', () => { + const s = actsStatus({ acts: [], agentGrants: ['act.files'] }) + assert.deepEqual([s.readOnly, s.why], [true, 'no_acts']) +}) + +test('trigger acts: acts the agent does not hold do not apply, and the reason is the agent', () => { + const s = actsStatus({ acts: ['act.spend'], agentGrants: ['act.files'] }) + assert.deepEqual([s.readOnly, s.why, s.held, s.unused], [true, 'agent', [], ['act.spend']]) + const mixed = actsStatus({ acts: ['act.spend', 'act.files'], agentGrants: ['act.files'] }) + assert.deepEqual([mixed.readOnly, mixed.held, mixed.unused], [false, ['act.files'], ['act.spend']]) +}) + +test('trigger acts: a plan that names a file it writes holds act.files', () => { + const s = actsStatus({ acts: [], agentGrants: ['act.files'], writes: 'data/out.json' }) + assert.deepEqual([s.readOnly, s.held], [false, ['act.files']]) + assert.equal(actsStatus({ acts: [], agentGrants: [], writes: 'data/out.json' }).readOnly, true, 'the agent must hold it too') +}) + +test('trigger acts: a lock from the way it was made wins over every act', () => { + for (const lock of ['declared', 'binding']) { + const s = actsStatus({ acts: ['act.files'], agentGrants: ['act.files'], lock }) + assert.deepEqual([s.readOnly, s.why, s.held], [true, lock, []]) + } +}) + +test('trigger acts: the status of a trigger as the server returns it', () => { + const t = { acts: ['act.files', 'act.spend'], acts_held: ['act.files'], read_only: false } + assert.deepEqual(actsStatusOf(t), { readOnly: false, why: '', held: ['act.files'], unused: ['act.spend'] }) + const ro = actsStatusOf({ acts: ['act.files'], read_only: true, read_only_why: 'binding' }) + assert.deepEqual([ro.readOnly, ro.why, ro.unused], [true, 'binding', ['act.files']]) + assert.equal(actsStatusOf(null).readOnly, false) +}) + +test('trigger acts: names get the title and weight of the catalog', () => { + const rows = actNamed(grants, ['act.send', 'act.addons:mail']) + assert.deepEqual(rows.map((r) => [r.name, r.title, r.sensitive]), [ + ['act.send', 'Send messages on its own', true], + ['act.addons:mail', 'act.addons:mail', true], + ]) +}) + +test('trigger acts: the editor sends the acts it loaded, and an empty list when none', () => { + const loaded = triggerForm({ id: 'x', name: 'n', prompt: 'p', enabled: true, kind: 'schedule', schedule: 'daily 08:00', acts: ['act.files'], read_only: false, + plan: { writes: 'data/out.json' } }) + assert.deepEqual(loaded.acts, ['act.files']) + assert.equal(loaded.writes, 'data/out.json') + assert.deepEqual(triggerPayload(loaded).acts, ['act.files']) + assert.deepEqual(triggerPayload(emptyTrigger()).acts, [], 'a new trigger sends an empty list: read only') + const locked = triggerForm({ id: 'x', name: 'n', prompt: 'p', enabled: true, kind: 'schedule', schedule: 'daily 08:00', read_only: true, read_only_why: 'declared' }) + assert.equal(locked.lock, 'declared') + assert.deepEqual(triggerPayload(locked).acts, []) +})