diff --git a/addons/internal/connector-ui/src/AnswerFields.jsx b/addons/internal/connector-ui/src/AnswerFields.jsx
index 223fa645e2..468690a28b 100644
--- a/addons/internal/connector-ui/src/AnswerFields.jsx
+++ b/addons/internal/connector-ui/src/AnswerFields.jsx
@@ -4,6 +4,7 @@
// files of a turn go out. The words are the same on every platform, so they
// live here as defaults; an add-on can still change one in its namespace.
import { Field, Hint, Select, Switch, TextInput } from './controls'
+import { APPROVAL_TEXT } from './ApprovalFields'
import { FILES_TEXT } from './FilesFields'
import { CONVERSATIONS, MAX_WINDOW_MINUTES, REPLY_MODES } from './connector'
@@ -44,7 +45,7 @@ export const ANSWER_TEXT = {
// text reads a string of the add-on's namespace, with the default above.
export function answerT(t) {
- return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key], ...opts })
+ return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key] ?? APPROVAL_TEXT[key], ...opts })
}
// answerSummary says in a few words how a binding answers, for its row.
diff --git a/addons/internal/connector-ui/src/ApprovalFields.jsx b/addons/internal/connector-ui/src/ApprovalFields.jsx
new file mode 100644
index 0000000000..ee7cf72bd9
--- /dev/null
+++ b/addons/internal/connector-ui/src/ApprovalFields.jsx
@@ -0,0 +1,49 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+// How the triggers that the agent proposes in a chat are approved. The same on
+// every platform, so the words live here as defaults, like the other shared
+// fields. The server enforces the limits; the page only bounds what is typed.
+import { Field, RadioCards, TextInput } from './controls'
+import { APPROVAL_LIMITS, APPROVAL_MODES, approvalProblem } from './connector'
+
+export const APPROVAL_TEXT = {
+ 'approval.title': 'Timers and triggers from the chat',
+ 'approval.help': 'When the agent sets up something that runs later ("in 10 minutes", "every day"), every run is one model run.',
+ 'approval.mode.ask': 'Ask me: the bot proposes it in the chat and the person answers yes or no',
+ 'approval.mode.auto': 'Set it up at once and tell the person how to cancel it',
+ 'approval.mode.auto_readonly': 'At once when the runs only read and report; otherwise ask',
+ 'approval.limitsHelp': 'These limits bound what is set up without asking. Leave a field empty for its default. Anything over a limit is asked instead.',
+ 'approval.max_pending': 'Most timers and triggers at once',
+ 'approval.min_gap_minutes': 'Fewest minutes between runs of a recurring trigger',
+ 'approval.max_runs_per_day': 'Most runs a day, all together',
+ 'approval.max_lifetime_days': 'Longest lifetime, in days',
+ 'approval.invalid': 'Use a whole number from 1 to {{max}}, or leave it empty.',
+ 'approval.default': 'Default: {{value}}.',
+ 'approval.summary': 'Triggers: {{mode}}',
+ 'approval.short.ask': 'asks first',
+ 'approval.short.auto': 'set up at once',
+ 'approval.short.auto_readonly': 'at once if read-only',
+}
+
+export default function ApprovalFields({ form, setForm, s }) {
+ const a = form.approval
+ const set = (patch) => setForm({ ...form, approval: { ...a, ...patch } })
+ const auto = a.mode !== 'ask'
+ const bad = approvalProblem(a)
+ return (
+
+ )
+}
diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx
index d5b7367bad..dca9e7da83 100644
--- a/addons/internal/connector-ui/src/ConnectorPage.jsx
+++ b/addons/internal/connector-ui/src/ConnectorPage.jsx
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog'
import Icon from '../../host-compat/src/components/Icon'
import AnswerFields, { answerSummary, answerT } from './AnswerFields'
+import ApprovalFields from './ApprovalFields'
import FilesFields from './FilesFields'
import { Check, Field, Notice, RadioCards, Select, Switch, TextInput } from './controls'
import NameList from './NameList'
@@ -215,6 +216,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
+
>)}
diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js
index 2aaef39d39..df3a8530f7 100644
--- a/addons/internal/connector-ui/src/connector.js
+++ b/addons/internal/connector-ui/src/connector.js
@@ -6,12 +6,56 @@ export const REPLY_MODES = ['off', 'progress', 'stream']
export const CONVERSATIONS = ['sender', 'chat', 'window', 'thread']
export const MAX_WINDOW_MINUTES = 7 * 24 * 60
+// How the triggers that the agent proposes in a chat are approved, and the
+// limits of the two modes that make one without asking. The server has the
+// same names, defaults and bounds (connectors.Approval).
+export const APPROVAL_MODES = ['ask', 'auto', 'auto_readonly']
+export const APPROVAL_LIMITS = [
+ { key: 'max_pending', max: 50, fallback: 5 },
+ { key: 'min_gap_minutes', max: 1440, fallback: 60 },
+ { key: 'max_runs_per_day', max: 200, fallback: 10 },
+ { key: 'max_lifetime_days', max: 365, fallback: 30 },
+]
+
+export function emptyApproval() {
+ return { mode: 'ask', max_pending: '', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' }
+}
+
+// The approval of a saved binding as a form: a limit of zero is the default
+// and shows empty.
+export function approvalForm(a) {
+ const f = { ...emptyApproval(), mode: a?.mode || 'ask' }
+ for (const { key } of APPROVAL_LIMITS) f[key] = a?.[key] > 0 ? String(a[key]) : ''
+ return f
+}
+
+// Why a typed limit cannot be saved: not a whole number from 1 to its bound.
+// Empty is fine (the default).
+export function approvalProblem(a) {
+ if (!a) return ''
+ if (!APPROVAL_MODES.includes(a.mode)) return 'mode'
+ for (const { key, max } of APPROVAL_LIMITS) {
+ const v = String(a[key] ?? '').trim()
+ if (v === '') continue
+ const n = Number(v)
+ if (!Number.isInteger(n) || n < 1 || n > max) return key
+ }
+ return ''
+}
+
+export function approvalPayload(a) {
+ const body = { mode: APPROVAL_MODES.includes(a?.mode) ? a.mode : 'ask' }
+ for (const { key } of APPROVAL_LIMITS) body[key] = Number(String(a?.[key] ?? '').trim()) || 0
+ return body
+}
+
export function emptyConnector() {
return {
id: '', name: '', token: '', agent_id: '', enabled: true, accept_any: false, allow: [], notify_chat: '',
reply_mode: 'progress', conversation: 'sender', window_minutes: 60, send_files: true,
options: {},
file_receive_mib: '', file_send_mib: '',
+ approval: emptyApproval(),
}
}
@@ -33,6 +77,7 @@ export function connectorForm(c) {
return {
...emptyConnector(), ...c, token: '',
file_receive_mib: bytesToMib(c.file_receive_max_bytes), file_send_mib: bytesToMib(c.file_send_max_bytes),
+ approval: approvalForm(c.approval),
}
}
@@ -86,6 +131,7 @@ export function connectorPayload(form, kind, token = form.token, extra = {}) {
options: Object.fromEntries(Object.entries(form.options || {}).filter(([, v]) => v)),
file_receive_max_bytes: mibToBytes(form.file_receive_mib),
file_send_max_bytes: mibToBytes(form.file_send_mib),
+ approval: approvalPayload(form.approval),
}
if (body.conversation === 'window') body.window_minutes = Number(form.window_minutes) || 0
const secret = (token || '').trim()
@@ -113,6 +159,7 @@ export function canSave(form, confirmed, hasToken = Boolean((form.token || '').t
if (!(form.name || '').trim() || !form.agent_id) return false
if (!form.id && !hasToken) return false
if (form.accept_any && !confirmed) return false
+ if (approvalProblem(form.approval)) return false
if (fileLimitProblem(form.file_receive_mib, files?.receive?.allowed_bytes) || fileLimitProblem(form.file_send_mib, files?.send?.allowed_bytes)) return false
if (form.conversation === 'window') {
const m = Number(form.window_minutes)
diff --git a/addons/telegram/ui/test/connector.test.mjs b/addons/telegram/ui/test/connector.test.mjs
index 39db7683b3..13c96e35c0 100644
--- a/addons/telegram/ui/test/connector.test.mjs
+++ b/addons/telegram/ui/test/connector.test.mjs
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
+import { approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
test('chat connectors: the token goes only when it was typed', () => {
const create = connectorPayload({ ...emptyConnector(), name: ' Bot ', token: ' 1:abc ', agent_id: 'a1', allow: ['11', ' 11', '@ada_l', ''] }, 'telegram')
@@ -132,3 +132,27 @@ test('chat connectors: sizes are written in KiB and MiB', () => {
test('chat connectors: a saved limit reads back to the same number of bytes', () => {
for (const bytes of [1, 1000, 100 * 1024, 256 * 1024, 5 * 1024 * 1024 + 7]) assert.equal(mibToBytes(bytesToMib(bytes)), bytes)
})
+
+test('chat connectors: the approval of chat triggers asks by default and sends its limits as numbers', () => {
+ const base = { ...emptyConnector(), name: 'B', agent_id: 'a' }
+ assert.deepEqual(connectorPayload(base, 'telegram').approval, { mode: 'ask', max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
+ const auto = connectorPayload({ ...base, approval: { mode: 'auto', max_pending: '3', min_gap_minutes: '', max_runs_per_day: ' 8 ', max_lifetime_days: '14' } }, 'telegram')
+ assert.deepEqual(auto.approval, { mode: 'auto', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 8, max_lifetime_days: 14 })
+ assert.equal(connectorPayload({ ...base, approval: { mode: 'sometimes' } }, 'telegram').approval.mode, 'ask', 'an unknown mode falls back to ask')
+})
+
+test('chat connectors: a saved approval reads back, with a zero limit shown as empty', () => {
+ assert.deepEqual(approvalForm({ mode: 'auto_readonly', max_pending: 4, min_gap_minutes: 0 }), { mode: 'auto_readonly', max_pending: '4', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' })
+ assert.equal(connectorForm({ id: 'b' }).approval.mode, 'ask')
+})
+
+test('chat connectors: an approval limit is a whole number within its bound, or empty', () => {
+ assert.equal(approvalProblem({ mode: 'auto', max_pending: '5' }), '')
+ assert.equal(approvalProblem({ mode: 'auto', max_pending: '51' }), 'max_pending')
+ assert.equal(approvalProblem({ mode: 'auto', max_runs_per_day: '2.5' }), 'max_runs_per_day')
+ assert.equal(approvalProblem({ mode: 'auto', max_lifetime_days: '0' }), 'max_lifetime_days')
+ assert.equal(approvalProblem({ mode: 'bad' }), 'mode')
+ const ok = { ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' }
+ assert.equal(canSave({ ...ok, approval: { mode: 'auto', max_pending: '500' } }, false), false)
+ assert.equal(canSave(ok, false), true)
+})
diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go
index 379f64b353..20d978fc06 100644
--- a/core/http/endpoints/studio/connectors.go
+++ b/core/http/endpoints/studio/connectors.go
@@ -25,7 +25,20 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
return schema.ChatsConnector{Caps: capsOut(i.Caps), Grants: grantsOut(i.Grants), Paired: pairedOut(i.Paired), ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny,
Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created,
ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), Options: i.Options,
- Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes()}
+ Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes(), Approval: approvalOut(i.Approval)}
+}
+
+func approvalOut(a connectors.Approval) schema.ChatsConnectorApproval {
+ return schema.ChatsConnectorApproval{Mode: a.ModeOrDefault(), MaxPending: a.MaxPending, MinGapMinutes: a.MinGapMinutes,
+ MaxRunsPerDay: a.MaxRunsPerDay, MaxLifetimeDays: a.MaxLifetimeDays}
+}
+
+func approvalIn(a *schema.ChatsConnectorApproval) *connectors.Approval {
+ if a == nil {
+ return nil
+ }
+ return &connectors.Approval{Mode: a.Mode, MaxPending: a.MaxPending, MinGapMinutes: a.MinGapMinutes,
+ MaxRunsPerDay: a.MaxRunsPerDay, MaxLifetimeDays: a.MaxLifetimeDays}
}
func capsOut(in []string) []string {
@@ -116,7 +129,7 @@ func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput {
return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled,
AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat,
Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, Options: req.Options,
- FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode}
+ FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode, Approval: approvalIn(req.Approval)}
}
var connectorRules = []errRule{
diff --git a/core/http/routes/studio_connectors_test.go b/core/http/routes/studio_connectors_test.go
index 97712977d4..f9e3f50197 100644
--- a/core/http/routes/studio_connectors_test.go
+++ b/core/http/routes/studio_connectors_test.go
@@ -263,6 +263,25 @@ var _ = Describe("Chats connectors endpoints", func() {
Expect(rec.Code).To(Equal(http.StatusOK), "an update that keeps the token is not checked: %s", rec.Body.String())
})
+ It("keeps the approval of chat triggers: ask by default, kept on an update that omits it, refused when out of bounds", func() {
+ ag := agent("alice", "Approval", "test-model")
+ rec := create("alice", map[string]any{"name": "Approval bot", "token": specToken, "agent_id": ag})
+ Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String())
+ b := decodeJSON(rec)
+ id := b["id"].(string)
+ Expect(b).To(HaveKeyWithValue("approval", HaveKeyWithValue("mode", "ask")))
+ rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag,
+ "approval": map[string]any{"mode": "auto", "max_pending": 3, "max_runs_per_day": 6}})
+ Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
+ Expect(decodeJSON(rec)["approval"]).To(And(HaveKeyWithValue("mode", "auto"), HaveKeyWithValue("max_pending", BeNumerically("==", 3)), HaveKeyWithValue("max_runs_per_day", BeNumerically("==", 6))))
+ rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag})
+ Expect(decodeJSON(rec)["approval"]).To(HaveKeyWithValue("mode", "auto"))
+ for _, bad := range []map[string]any{{"mode": "always"}, {"mode": "auto", "max_pending": 51}, {"mode": "auto", "max_lifetime_days": -1}} {
+ rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag, "approval": bad})
+ Expect(rec.Code).To(Equal(http.StatusBadRequest), rec.Body.String())
+ }
+ })
+
It("keeps the capabilities of a binding, refuses unknown ones and issues a pairing code to its owner only", func() {
ag := agent("alice", "Caps", "test-model")
rec := create("alice", map[string]any{"name": "Caps bot", "token": specToken, "agent_id": ag, "caps": []string{"memory.write", "profile.write"}})
diff --git a/core/schema/chats.go b/core/schema/chats.go
index 9946aa698b..29e15f62fb 100644
--- a/core/schema/chats.go
+++ b/core/schema/chats.go
@@ -1256,6 +1256,25 @@ type ChatsConnector struct {
// Paired are the senders you proved with a pairing code. A pairing ends
// at expires_at.
Paired []ChatsConnectorPaired `json:"paired"`
+ // Approval is what the host does with a trigger that the agent proposes
+ // from a chat of this binding.
+ Approval ChatsConnectorApproval `json:"approval"`
+}
+
+// ChatsConnectorApproval is the owner's choice for the triggers that the agent
+// proposes from a chat of a binding. mode is ask (the person answers the
+// proposal in the chat in plain words; the default), auto (the trigger is
+// made at once and the person is told how to cancel it) or auto_readonly (a
+// trigger whose runs only read and report is made at once; any other
+// proposal asks). The limits bound the modes that make a trigger without
+// asking; a zero limit means its default (5 live triggers, one hour between
+// runs of a recurring trigger, 10 runs a day, 30 days).
+type ChatsConnectorApproval struct {
+ Mode string `json:"mode"`
+ MaxPending int `json:"max_pending"`
+ MinGapMinutes int `json:"min_gap_minutes"`
+ MaxRunsPerDay int `json:"max_runs_per_day"`
+ MaxLifetimeDays int `json:"max_lifetime_days"`
}
// ChatsConnectorRoleGrant gives the capabilities caps to the senders that the
@@ -1464,6 +1483,10 @@ type ChatsConnectorWrite struct {
// WriteMode is off, propose or auto (see the connector record). Absent
// means off for a new binding and unchanged for an update.
WriteMode *string `json:"write_mode,omitempty" enums:"off,propose,auto"`
+ // Approval sets how triggers that the agent proposes from a chat of the
+ // binding are approved, and the limits of the automatic modes. Absent
+ // means ask for a new binding and unchanged for an update.
+ Approval *ChatsConnectorApproval `json:"approval,omitempty"`
}
// ChatsConnectorChannel is a chat of the platform where the agent may write.
diff --git a/core/services/studio/connectors/approval.go b/core/services/studio/connectors/approval.go
new file mode 100644
index 0000000000..856149f0bf
--- /dev/null
+++ b/core/services/studio/connectors/approval.go
@@ -0,0 +1,108 @@
+package connectors
+
+import (
+ "fmt"
+)
+
+// The approval modes of a binding: what the host does with a trigger that the
+// agent proposes from a chat of the binding. The names are the same for every
+// connector add-on.
+const (
+ // ApprovalAsk posts the proposal in the chat and waits for a plain answer
+ // from the person. It is the default.
+ ApprovalAsk = "ask"
+ // ApprovalAuto creates the trigger at once and tells the person how to
+ // cancel it, within the limits of the owner.
+ ApprovalAuto = "auto"
+ // ApprovalAutoReadonly creates at once a trigger whose runs only read and
+ // report; any other proposal still asks.
+ ApprovalAutoReadonly = "auto_readonly"
+)
+
+// Defaults and bounds of the limits that apply when a mode creates a trigger
+// without asking. A zero field of Approval means the default.
+const (
+ DefaultMaxPending = 5
+ MaxMaxPending = 50
+ DefaultMinGapMinutes = 60
+ MaxMinGapMinutes = 24 * 60
+ DefaultMaxRunsPerDay = 10
+ MaxMaxRunsPerDay = 200
+ DefaultMaxLifetimeDays = 30
+ MaxMaxLifetimeDays = 365
+)
+
+// Approval is the choice of the owner for the triggers that an agent proposes
+// from a chat of one binding, and the limits that bound the modes which create
+// a trigger without asking. The zero value is ApprovalAsk with the default
+// limits.
+type Approval struct {
+ // Mode is one of the Approval constants; empty means ApprovalAsk.
+ Mode string `json:"mode,omitempty"`
+ // MaxPending is how many triggers made without asking may be live at once
+ // for the binding, timers and recurring ones together.
+ MaxPending int `json:"max_pending,omitempty"`
+ // MinGapMinutes is the shortest time between two runs of a recurring
+ // trigger made without asking.
+ MinGapMinutes int `json:"min_gap_minutes,omitempty"`
+ // MaxRunsPerDay is how many runs a day all the live triggers made without
+ // asking may have together.
+ MaxRunsPerDay int `json:"max_runs_per_day,omitempty"`
+ // MaxLifetimeDays is how far ahead a trigger made without asking may run,
+ // and how long a recurring one lives before it is removed.
+ MaxLifetimeDays int `json:"max_lifetime_days,omitempty"`
+}
+
+// ModeOrDefault gives Mode, or ApprovalAsk when it is empty.
+func (a Approval) ModeOrDefault() string {
+ if a.Mode == "" {
+ return ApprovalAsk
+ }
+ return a.Mode
+}
+
+// Auto says whether the mode may create a trigger without asking.
+func (a Approval) Auto() bool {
+ m := a.ModeOrDefault()
+ return m == ApprovalAuto || m == ApprovalAutoReadonly
+}
+
+// Limits are the limits of an Approval with the defaults filled in.
+type Limits struct {
+ MaxPending, MinGapMinutes, MaxRunsPerDay, MaxLifetimeDays int
+}
+
+// Limits gives the limits with the default for each field that is zero.
+func (a Approval) Limits() Limits {
+ pick := func(v, def int) int {
+ if v <= 0 {
+ return def
+ }
+ return v
+ }
+ return Limits{
+ MaxPending: pick(a.MaxPending, DefaultMaxPending),
+ MinGapMinutes: pick(a.MinGapMinutes, DefaultMinGapMinutes),
+ MaxRunsPerDay: pick(a.MaxRunsPerDay, DefaultMaxRunsPerDay),
+ MaxLifetimeDays: pick(a.MaxLifetimeDays, DefaultMaxLifetimeDays),
+ }
+}
+
+// Validate checks the mode and the bounds of the limits.
+func (a Approval) Validate() error {
+ switch a.Mode {
+ case "", ApprovalAsk, ApprovalAuto, ApprovalAutoReadonly:
+ default:
+ return fmt.Errorf("the approval mode must be %s, %s or %s", ApprovalAsk, ApprovalAuto, ApprovalAutoReadonly)
+ }
+ for _, f := range []struct {
+ name string
+ v, max int
+ }{{"max pending", a.MaxPending, MaxMaxPending}, {"minimum gap in minutes", a.MinGapMinutes, MaxMinGapMinutes},
+ {"runs per day", a.MaxRunsPerDay, MaxMaxRunsPerDay}, {"lifetime in days", a.MaxLifetimeDays, MaxMaxLifetimeDays}} {
+ if f.v < 0 || f.v > f.max {
+ return fmt.Errorf("the approval limit %s must be 0 to %d", f.name, f.max)
+ }
+ }
+ return nil
+}
diff --git a/core/services/studio/connectors/approval_test.go b/core/services/studio/connectors/approval_test.go
new file mode 100644
index 0000000000..238636f90f
--- /dev/null
+++ b/core/services/studio/connectors/approval_test.go
@@ -0,0 +1,37 @@
+package connectors_test
+
+import (
+ "github.com/mudler/LocalAI/core/services/studio/connectors"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("the approval of a binding", func() {
+ It("asks by default, with the default limits", func() {
+ var a connectors.Approval
+ Expect(a.ModeOrDefault()).To(Equal(connectors.ApprovalAsk))
+ Expect(a.Auto()).To(BeFalse())
+ Expect(a.Limits()).To(Equal(connectors.Limits{MaxPending: 5, MinGapMinutes: 60, MaxRunsPerDay: 10, MaxLifetimeDays: 30}))
+ Expect(a.Validate()).To(Succeed())
+ })
+
+ It("knows which modes create without asking", func() {
+ Expect(connectors.Approval{Mode: connectors.ApprovalAuto}.Auto()).To(BeTrue())
+ Expect(connectors.Approval{Mode: connectors.ApprovalAutoReadonly}.Auto()).To(BeTrue())
+ Expect(connectors.Approval{Mode: connectors.ApprovalAsk}.Auto()).To(BeFalse())
+ })
+
+ It("keeps the limits that the owner set", func() {
+ a := connectors.Approval{Mode: connectors.ApprovalAuto, MaxPending: 2, MinGapMinutes: 120, MaxRunsPerDay: 4, MaxLifetimeDays: 7}
+ Expect(a.Limits()).To(Equal(connectors.Limits{MaxPending: 2, MinGapMinutes: 120, MaxRunsPerDay: 4, MaxLifetimeDays: 7}))
+ })
+
+ It("refuses an unknown mode and a limit out of bounds", func() {
+ Expect(connectors.Approval{Mode: "always"}.Validate()).NotTo(Succeed())
+ Expect(connectors.Approval{MaxPending: -1}.Validate()).NotTo(Succeed())
+ Expect(connectors.Approval{MaxPending: connectors.MaxMaxPending + 1}.Validate()).NotTo(Succeed())
+ Expect(connectors.Approval{MaxLifetimeDays: connectors.MaxMaxLifetimeDays + 1}.Validate()).NotTo(Succeed())
+ Expect(connectors.Approval{MaxRunsPerDay: connectors.MaxMaxRunsPerDay + 1}.Validate()).NotTo(Succeed())
+ Expect(connectors.Approval{MinGapMinutes: connectors.MaxMinGapMinutes + 1}.Validate()).NotTo(Succeed())
+ })
+})
diff --git a/core/services/studio/connectors/connectorstest/suite.go b/core/services/studio/connectors/connectorstest/suite.go
index 1876418fa1..b8cfe7cae8 100644
--- a/core/services/studio/connectors/connectorstest/suite.go
+++ b/core/services/studio/connectors/connectorstest/suite.go
@@ -160,6 +160,19 @@ func Run(newStore NewStore) {
Expect(bare.Paired).To(BeEmpty())
})
+ It("keeps the approval mode and limits", func() {
+ s := newStore()
+ b := binding("b1", "u1", "Bot")
+ b.Approval = connectors.Approval{Mode: connectors.ApprovalAuto, MaxPending: 3, MaxRunsPerDay: 7}
+ Expect(s.PutBinding(ctx, b)).To(Succeed())
+ got, err := s.GetBinding(ctx, "b1")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Approval).To(Equal(b.Approval))
+ Expect(s.PutBinding(ctx, binding("b2", "u1", "Bare"))).To(Succeed())
+ bare, _ := s.GetBinding(ctx, "b2")
+ Expect(bare.Approval.ModeOrDefault()).To(Equal(connectors.ApprovalAsk))
+ })
+
It("uses a pairing code once, before it expires, and only with the right hash", func() {
s := newStore()
Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h1", Expires: t0.Add(10 * time.Minute)})).To(Succeed())
diff --git a/core/services/studio/connectors/gorm.go b/core/services/studio/connectors/gorm.go
index 4d2b1fed16..3c2e57cb7a 100644
--- a/core/services/studio/connectors/gorm.go
+++ b/core/services/studio/connectors/gorm.go
@@ -39,6 +39,8 @@ type bindingRow struct {
Channels string
// WriteMode is added by AutoMigrate; a row stored before it reads as off.
WriteMode string `gorm:"size:16"`
+ // Approval is JSON, added by AutoMigrate; a row stored before it asks.
+ Approval string
// The columns of the file limits are added by AutoMigrate; a row stored
// before them has no limit of the owner.
FileReceiveMax int64
@@ -128,7 +130,7 @@ func (r bindingRow) binding() (Binding, error) {
for _, f := range []struct {
raw string
dst any
- }{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}, {r.Resources, &b.Resources}, {r.Channels, &b.Channels}} {
+ }{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}, {r.Resources, &b.Resources}, {r.Channels, &b.Channels}, {r.Approval, &b.Approval}} {
if f.raw == "" {
continue
}
@@ -157,7 +159,7 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error {
raw, _ := json.Marshal(v)
return string(raw)
}
- return s.db.WithContext(ctx).Save(&bindingRow{WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
+ return s.db.WithContext(ctx).Save(&bindingRow{Approval: enc(b.Approval, b.Approval == Approval{}), WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
AcceptAny: b.AcceptAny, Allow: allow, NotifyChat: b.NotifyChat, Reply: b.Reply, Conversation: b.Conversation, WindowMinutes: b.WindowMinutes,
FilesOff: b.FilesOff, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax,
Token: b.Token, Created: b.Created, Updated: b.Updated}).Error
diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go
index cd7a4b6568..5906776763 100644
--- a/core/services/studio/connectors/types.go
+++ b/core/services/studio/connectors/types.go
@@ -192,6 +192,10 @@ type Binding struct {
// WriteMode is one of WriteOff, WritePropose and WriteAuto. Empty means
// WriteOff.
WriteMode string
+ // Approval is what the host does with a trigger that the agent proposes
+ // from a chat of the binding, and the limits of the modes that create one
+ // without asking. The zero value asks.
+ Approval Approval
// Token is the bot token. In a Store it is sealed bytes; the Store returns
// what it was given.
Token []byte
@@ -359,6 +363,9 @@ func Validate(b Binding) error {
if !ValidWriteMode(b.WriteMode) {
return bad("the write mode must be %s, %s or %s", WriteOff, WritePropose, WriteAuto)
}
+ if err := b.Approval.Validate(); err != nil {
+ return bad("%v", err)
+ }
if len(b.Options) > MaxOptions {
return bad("a binding has at most %d options", MaxOptions)
}
diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go
index 2be7414bbf..c2f18a2182 100644
--- a/core/services/studio/connectorsvc.go
+++ b/core/services/studio/connectorsvc.go
@@ -62,6 +62,9 @@ type ConnectorInput struct {
// WriteMode is one of connectors.WriteOff, WritePropose and WriteAuto. Nil:
// new binding off, update unchanged.
WriteMode *string
+ // Approval is the owner's choice for the triggers the agent proposes from
+ // a chat of the binding. Nil: new binding asks, update unchanged.
+ Approval *connectors.Approval
}
// apply sets the fields of the input that an owner writes on a binding.
@@ -98,6 +101,9 @@ func (in ConnectorInput) apply(b *connectors.Binding) {
if in.WriteMode != nil {
b.WriteMode = *in.WriteMode
}
+ if in.Approval != nil {
+ b.Approval = *in.Approval
+ }
}
// ErrTokenRefused means the platform refused the bot token of a binding.
diff --git a/swagger/docs.go b/swagger/docs.go
index 8719ef4abc..a4be0d0da2 100644
--- a/swagger/docs.go
+++ b/swagger/docs.go
@@ -18642,6 +18642,14 @@ const docTemplate = `{
"type": "string"
}
},
+ "approval": {
+ "description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/schema.ChatsConnectorApproval"
+ }
+ ]
+ },
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
@@ -19629,6 +19637,14 @@ const docTemplate = `{
"type": "string"
}
},
+ "approval": {
+ "description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/schema.ChatsConnectorApproval"
+ }
+ ]
+ },
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
@@ -19837,6 +19853,26 @@ const docTemplate = `{
}
}
},
+ "schema.ChatsConnectorApproval": {
+ "type": "object",
+ "properties": {
+ "max_lifetime_days": {
+ "type": "integer"
+ },
+ "max_pending": {
+ "type": "integer"
+ },
+ "max_runs_per_day": {
+ "type": "integer"
+ },
+ "min_gap_minutes": {
+ "type": "integer"
+ },
+ "mode": {
+ "type": "string"
+ }
+ }
+ },
"schema.ChatsConnectorFileLimit": {
"type": "object",
"properties": {
@@ -20123,6 +20159,14 @@ const docTemplate = `{
"type": "string"
}
},
+ "approval": {
+ "description": "Approval sets how triggers that the agent proposes from a chat of the\nbinding are approved, and the limits of the automatic modes. Absent\nmeans ask for a new binding and unchanged for an update.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/schema.ChatsConnectorApproval"
+ }
+ ]
+ },
"caps": {
"description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.",
"type": "array",
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 99e727f886..20db54490c 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -18639,6 +18639,14 @@
"type": "string"
}
},
+ "approval": {
+ "description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/schema.ChatsConnectorApproval"
+ }
+ ]
+ },
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
@@ -19626,6 +19634,14 @@
"type": "string"
}
},
+ "approval": {
+ "description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/schema.ChatsConnectorApproval"
+ }
+ ]
+ },
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
@@ -19834,6 +19850,26 @@
}
}
},
+ "schema.ChatsConnectorApproval": {
+ "type": "object",
+ "properties": {
+ "max_lifetime_days": {
+ "type": "integer"
+ },
+ "max_pending": {
+ "type": "integer"
+ },
+ "max_runs_per_day": {
+ "type": "integer"
+ },
+ "min_gap_minutes": {
+ "type": "integer"
+ },
+ "mode": {
+ "type": "string"
+ }
+ }
+ },
"schema.ChatsConnectorFileLimit": {
"type": "object",
"properties": {
@@ -20120,6 +20156,14 @@
"type": "string"
}
},
+ "approval": {
+ "description": "Approval sets how triggers that the agent proposes from a chat of the\nbinding are approved, and the limits of the automatic modes. Absent\nmeans ask for a new binding and unchanged for an update.",
+ "allOf": [
+ {
+ "$ref": "#/definitions/schema.ChatsConnectorApproval"
+ }
+ ]
+ },
"caps": {
"description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.",
"type": "array",
diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml
index a2ee1e2805..3cb3d6732a 100644
--- a/swagger/swagger.yaml
+++ b/swagger/swagger.yaml
@@ -3026,6 +3026,12 @@ definitions:
items:
type: string
type: array
+ approval:
+ allOf:
+ - $ref: '#/definitions/schema.ChatsConnectorApproval'
+ description: |-
+ Approval is what the host does with a trigger that the agent proposes
+ from a chat of this binding.
caps:
description: |-
Caps are the capabilities you enabled for this binding: the ceiling of
@@ -3847,6 +3853,12 @@ definitions:
items:
type: string
type: array
+ approval:
+ allOf:
+ - $ref: '#/definitions/schema.ChatsConnectorApproval'
+ description: |-
+ Approval is what the host does with a trigger that the agent proposes
+ from a chat of this binding.
caps:
description: |-
Caps are the capabilities you enabled for this binding: the ceiling of
@@ -4017,6 +4029,19 @@ definitions:
label:
type: string
type: object
+ schema.ChatsConnectorApproval:
+ properties:
+ max_lifetime_days:
+ type: integer
+ max_pending:
+ type: integer
+ max_runs_per_day:
+ type: integer
+ min_gap_minutes:
+ type: integer
+ mode:
+ type: string
+ type: object
schema.ChatsConnectorFileLimit:
properties:
allowed_bytes:
@@ -4221,6 +4246,13 @@ definitions:
items:
type: string
type: array
+ approval:
+ allOf:
+ - $ref: '#/definitions/schema.ChatsConnectorApproval'
+ description: |-
+ Approval sets how triggers that the agent proposes from a chat of the
+ binding are approved, and the limits of the automatic modes. Absent
+ means ask for a new binding and unchanged for an update.
caps:
description: |-
Caps are the capabilities you enable for the binding (a closed list,