From b8626132aef01ec10241f52ecc379a5ad4a5d462 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 14:42:19 +0000 Subject: [PATCH] feat(connectors): let the agent write to listed channels with send_message A connector chat whose sender holds talkback.channels gets the tool send_message. It writes in the conversation, or as a new top-level message in a channel that the owner listed on the binding (id, label, approved; at most 10). The tool names no platform. An unlisted channel is refused and the error lists the ones that are. A listed channel that the owner did not approve needs the sensitive talkback.new_channel for each message, so only a paired sender can use it. The agent cannot add or approve a channel, so text it read in a page or a file cannot widen where it writes. Messages go through the outbox (rate limit and queue), with the size cap and the mention breaking. Each attempt adds an audit row with channel, kind, capability, length and a hash, never the text. A trigger can report to an approved channel with delivery target channel. The binding, the capability and the listing are checked again when the run ends; any failure sends the notice to the Inbox. LOCALAI_DISABLE_CONNECTOR_TALKBACK turns both off. Assisted-by: Claude:claude-sonnet-5-5 go swag --- core/application/studio_connectors.go | 4 + core/cli/run.go | 18 +- core/config/application_config.go | 6 + core/http/endpoints/studio/connectors.go | 23 +- core/schema/chats.go | 16 + core/services/studio/connectoraccess.go | 8 + core/services/studio/connectors/gorm.go | 6 +- core/services/studio/connectors/types.go | 69 ++++ core/services/studio/connectorsend.go | 128 ++++++ core/services/studio/connectorsvc.go | 7 + .../services/studio/connectortalkback_test.go | 383 ++++++++++++++++++ core/services/studio/service.go | 3 + core/services/studio/talkbacktool.go | 107 +++++ core/services/studio/triggerchannel.go | 80 ++++ core/services/studio/triggerroute.go | 10 +- core/services/studio/triggers/types.go | 13 +- core/services/studio/triggertools.go | 14 +- docs/content/features/chats-connectors.md | 41 +- docs/content/features/chats.md | 1 + docs/content/reference/cli-reference.md | 1 + swagger/docs.go | 60 +++ swagger/swagger.json | 60 +++ swagger/swagger.yaml | 67 +++ 23 files changed, 1106 insertions(+), 19 deletions(-) create mode 100644 core/services/studio/connectorsend.go create mode 100644 core/services/studio/connectortalkback_test.go create mode 100644 core/services/studio/talkbacktool.go create mode 100644 core/services/studio/triggerchannel.go diff --git a/core/application/studio_connectors.go b/core/application/studio_connectors.go index f45811033e..91a2b79e0f 100644 --- a/core/application/studio_connectors.go +++ b/core/application/studio_connectors.go @@ -113,6 +113,10 @@ func (a *Application) startStudioConnectors(svc *studio.Service) { svc.SetConnectors(c) // Connector chats may use a capability only as the permission model allows. svc.SetCapabilityGate(c.CapabilityGate()) + if a.applicationConfig.DisableConnectorTalkback { + xlog.Info("Connector talk-back is turned off (LOCALAI_DISABLE_CONNECTOR_TALKBACK)") + c.AddCapabilitySwitch(studio.TalkbackSwitch("turned off on this server (LOCALAI_DISABLE_CONNECTOR_TALKBACK)")) + } a.studioConnectorsHook.Store(c) if a.ownsAddonsOnly() { // The manager stays: frontends get the stored bot credentials from it diff --git a/core/cli/run.go b/core/cli/run.go index 830c169642..80a555f9bc 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -171,13 +171,14 @@ type RunCMD struct { RemovedAgentOptions `embed:""` // Chats: the chats API, saved agents, triggers, connectors and agent jobs - DisableChats bool `env:"LOCALAI_DISABLE_CHATS" default:"false" help:"Turn off the chats: the /api/chats API, saved agents, triggers, connectors and the agent tasks and jobs. Nothing of them starts" group:"chats"` - DisableTriggers bool `env:"LOCALAI_DISABLE_TRIGGERS" default:"false" help:"Turn off the trigger runner and the triggers API (the rest of the chats stays on)" group:"chats"` - DisableConnectors bool `env:"LOCALAI_DISABLE_CONNECTORS" default:"false" help:"Turn off the connector bots (Telegram, Slack) and the connectors API (the rest of the chats stays on)" group:"chats"` - AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Days to keep finished agent jobs" group:"chats"` - TriggerRunKeep int `env:"LOCALAI_TRIGGER_RUN_KEEP" default:"200" help:"Finished runs kept for each trigger, beyond those of the last two days (-1 keeps all)" group:"chats"` - TriggerRunRetention int `name:"trigger-run-retention-days" env:"LOCALAI_TRIGGER_RUN_RETENTION_DAYS" default:"30" help:"Days to keep finished trigger runs (-1 keeps them forever)" group:"chats"` - MCPCIJobTimeout time.Duration `name:"mcp-ci-job-timeout" env:"LOCALAI_MCP_CI_JOB_TIMEOUT" default:"10m" help:"Longest an agent job may run" group:"chats"` + DisableChats bool `env:"LOCALAI_DISABLE_CHATS" default:"false" help:"Turn off the chats: the /api/chats API, saved agents, triggers, connectors and the agent tasks and jobs. Nothing of them starts" group:"chats"` + DisableTriggers bool `env:"LOCALAI_DISABLE_TRIGGERS" default:"false" help:"Turn off the trigger runner and the triggers API (the rest of the chats stays on)" group:"chats"` + DisableConnectors bool `env:"LOCALAI_DISABLE_CONNECTORS" default:"false" help:"Turn off the connector bots (Telegram, Slack) and the connectors API (the rest of the chats stays on)" group:"chats"` + DisableConnectorTalkback bool `env:"LOCALAI_DISABLE_CONNECTOR_TALKBACK" default:"false" help:"Turn off the tool with which an agent writes to a chat of its connection, and the reports of triggers to a channel (the rest of the connectors stays on)" group:"chats"` + AgentJobRetentionDays int `env:"LOCALAI_AGENT_JOB_RETENTION_DAYS,AGENT_JOB_RETENTION_DAYS" default:"30" help:"Days to keep finished agent jobs" group:"chats"` + TriggerRunKeep int `env:"LOCALAI_TRIGGER_RUN_KEEP" default:"200" help:"Finished runs kept for each trigger, beyond those of the last two days (-1 keeps all)" group:"chats"` + TriggerRunRetention int `name:"trigger-run-retention-days" env:"LOCALAI_TRIGGER_RUN_RETENTION_DAYS" default:"30" help:"Days to keep finished trigger runs (-1 keeps them forever)" group:"chats"` + MCPCIJobTimeout time.Duration `name:"mcp-ci-job-timeout" env:"LOCALAI_MCP_CI_JOB_TIMEOUT" default:"10m" help:"Longest an agent job may run" group:"chats"` // Collections (the knowledge base) DisableCollections bool `env:"LOCALAI_DISABLE_COLLECTIONS" default:"false" help:"Disable the collections service (the knowledge base API under /api/collections)" group:"collections"` @@ -681,6 +682,9 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { if r.DisableConnectors { opts = append(opts, config.DisableConnectors) } + if r.DisableConnectorTalkback { + opts = append(opts, config.DisableConnectorTalkback) + } opts = append(opts, config.WithAgentJobRetentionDays(r.AgentJobRetentionDays), config.WithMCPCIJobTimeout(r.MCPCIJobTimeout)) opts = append(opts, config.WithTriggerRunRetention(r.TriggerRunKeep, r.TriggerRunRetention)) diff --git a/core/config/application_config.go b/core/config/application_config.go index e92f48d0ee..13e382842e 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -348,6 +348,10 @@ type ApplicationConfig struct { // 404. DisableTriggers bool DisableConnectors bool + // DisableConnectorTalkback hides the tool with which an agent writes to a + // chat of its connection and stops the reports of triggers to a channel. + // Owner notices keep working. + DisableConnectorTalkback bool // AgentJobRetentionDays is how long finished agent jobs are kept. Zero // means DefaultAgentJobRetentionDays. AgentJobRetentionDays int @@ -1393,6 +1397,8 @@ var ( DisableChats = func(o *ApplicationConfig) { o.DisableChats = true } DisableTriggers = func(o *ApplicationConfig) { o.DisableTriggers = true } DisableConnectors = func(o *ApplicationConfig) { o.DisableConnectors = true } + // DisableConnectorTalkback is the option of LOCALAI_DISABLE_CONNECTOR_TALKBACK. + DisableConnectorTalkback = func(o *ApplicationConfig) { o.DisableConnectorTalkback = true } ) // WithAgentJobRetentionDays sets how long finished agent jobs are kept. diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 8c50cdab11..7c8a694d39 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -25,7 +25,7 @@ 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)} + Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels)} } func capsOut(in []string) []string { @@ -62,6 +62,25 @@ func grantsIn(in []schema.ChatsConnectorRoleGrant) []connectors.RoleGrant { return out } +func channelsOut(in []connectors.Channel) []schema.ChatsConnectorChannel { + out := make([]schema.ChatsConnectorChannel, len(in)) + for i, c := range in { + out[i] = schema.ChatsConnectorChannel{ID: c.ID, Label: c.Label, Approved: c.Approved} + } + return out +} + +func channelsIn(in []schema.ChatsConnectorChannel) []connectors.Channel { + if in == nil { + return nil + } + out := make([]connectors.Channel, len(in)) + for i, c := range in { + out[i] = connectors.Channel{ID: c.ID, Label: c.Label, Approved: c.Approved} + } + return out +} + func rolesOut(k contract.ConnectorKind) []schema.ChatsConnectorRole { var out []schema.ChatsConnectorRole for _, r := range k.Roles { @@ -97,7 +116,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} + FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels)} } var connectorRules = []errRule{ diff --git a/core/schema/chats.go b/core/schema/chats.go index 76269609f5..7804114bbd 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1232,6 +1232,9 @@ type ChatsConnector struct { // tools of the add-on may reach with this binding. Nothing else is // reachable. Resources []string `json:"resources"` + // Channels are the chats where the agent may write besides the + // conversation. Only you can change the list. + Channels []ChatsConnectorChannel `json:"channels"` // Caps are the capabilities you enabled for this binding: the ceiling of // what any sender can use. Grants give roles of the kind some of them. Caps []string `json:"caps"` @@ -1438,6 +1441,19 @@ type ChatsConnectorWrite struct { // with this binding, such as "owner/repo", at most 50. Absent means none // for a new binding and unchanged for an update; an empty list clears it. Resources []string `json:"resources,omitempty"` + // Channels are the chats of the platform where the agent may write + // besides the conversation, at most 10: {id, label, approved}. A channel + // that is not approved needs a paired sender for each message. Absent + // means none for a new binding and unchanged for an update; an empty list + // clears it. + Channels []ChatsConnectorChannel `json:"channels,omitempty"` +} + +// ChatsConnectorChannel is a chat of the platform where the agent may write. +type ChatsConnectorChannel struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Approved bool `json:"approved,omitempty"` } // ChatsConnectorList is the response of the connector list endpoint. diff --git a/core/services/studio/connectoraccess.go b/core/services/studio/connectoraccess.go index 0488c06592..f9c146b0ab 100644 --- a/core/services/studio/connectoraccess.go +++ b/core/services/studio/connectoraccess.go @@ -255,6 +255,14 @@ type AccessEvent struct { Allowed bool `json:"allowed"` Via string `json:"via,omitempty"` Reason string `json:"reason,omitempty"` + // The fields below are set by a message the host wrote on its own (see + // Connectors.Send). They hold where, how long and a hash of the text, and + // never the text. + Action string `json:"action,omitempty"` + Channel string `json:"channel,omitempty"` + Thread string `json:"thread,omitempty"` + Length int `json:"length,omitempty"` + Hash string `json:"hash,omitempty"` } type heldGrant struct { diff --git a/core/services/studio/connectors/gorm.go b/core/services/studio/connectors/gorm.go index 8e43a9875b..510b536246 100644 --- a/core/services/studio/connectors/gorm.go +++ b/core/services/studio/connectors/gorm.go @@ -35,6 +35,8 @@ type bindingRow struct { Options string // Resources is JSON, added by AutoMigrate; a row stored before it lists none. Resources string + // Channels is JSON, added by AutoMigrate; a row stored before it lists none. + Channels string // The columns of the file limits are added by AutoMigrate; a row stored // before them has no limit of the owner. FileReceiveMax int64 @@ -124,7 +126,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.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}, {r.Resources, &b.Resources}, {r.Channels, &b.Channels}} { if f.raw == "" { continue } @@ -153,7 +155,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{Resources: enc(b.Resources, len(b.Resources) == 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{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 9fb05fea7d..c7f399cde7 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -17,6 +17,7 @@ import ( "slices" "strings" "time" + "unicode" "unicode/utf8" "github.com/mudler/LocalAI/pkg/addons/contract" @@ -77,6 +78,11 @@ const ( MaxGrants = contract.MaxConnectorRoles // MaxFileLimit is the largest file limit a binding can hold: 1 TiB. MaxFileLimit int64 = 1 << 40 + // MaxChannels is how many channels a binding lists for talk-back. + MaxChannels = 10 + // MaxChannelIDBytes and MaxChannelLabelRunes bound a Channel. + MaxChannelIDBytes = 64 + MaxChannelLabelRunes = 60 // MaxResources is the most entries a binding lists. MaxResources = 50 // MaxResourceKeyRunes is the longest external name of a resource. @@ -149,6 +155,10 @@ type Binding struct { // Paired are the senders that the owner proved by a pairing code. Only a // paired sender may use a sensitive capability. At most MaxPaired. Paired []PairedSender + // Channels are the chats of the platform, besides the conversation, where + // the agent may write (see Channel). The owner sets the list; nothing the + // agent or a sender says can add to it. At most MaxChannels. + Channels []Channel // Resources is the list of external resources (a repository, a folder) that // the tools of the add-on may reach with the credential of this binding. // The host checks a tool call against it before the add-on is called. The @@ -162,6 +172,35 @@ type Binding struct { Updated time.Time } +// Channel is a chat of the platform where the owner lets the agent write. ID +// is the platform's own name for it and Label is what the owner reads. A +// channel that is not Approved needs the sensitive capability talkback.new_channel +// for each message; the owner approves it by setting Approved. +type Channel struct { + ID string `json:"id"` + Label string `json:"label,omitempty"` + Approved bool `json:"approved,omitempty"` +} + +// FindChannel returns the listed channel with this ID. +func (b Binding) FindChannel(id string) (Channel, bool) { + for _, c := range b.Channels { + if c.ID == id { + return c, true + } + } + return Channel{}, false +} + +// ChannelIDs lists the IDs of the channels of the binding. +func (b Binding) ChannelIDs() []string { + out := make([]string, len(b.Channels)) + for i, c := range b.Channels { + out[i] = c.ID + } + return out +} + // RoleGrant gives the capabilities Caps to the senders that have the role Role. type RoleGrant struct { Role string `json:"role"` @@ -287,6 +326,9 @@ func Validate(b Binding) error { if err := validateResources(b.Resources); err != nil { return err } + if err := validateChannels(b.Channels); err != nil { + return err + } if len(b.Options) > MaxOptions { return bad("a binding has at most %d options", MaxOptions) } @@ -391,5 +433,32 @@ func (b Binding) clone() Binding { b.Grants = g } b.Resources = slices.Clone(b.Resources) + b.Channels = slices.Clone(b.Channels) return b } + +func validateChannels(list []Channel) error { + bad := func(format string, a ...any) error { + return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, a...)) + } + if len(list) > MaxChannels { + return bad("a binding lists at most %d channels", MaxChannels) + } + for i, c := range list { + if c.ID == "" || len(c.ID) > MaxChannelIDBytes || strings.TrimSpace(c.ID) != c.ID { + return bad("a channel ID has 1 to %d bytes, with no space at its ends", MaxChannelIDBytes) + } + for _, r := range c.ID { + if unicode.IsControl(r) || unicode.IsSpace(r) { + return bad("a channel ID has no space or control character") + } + } + if utf8.RuneCountInString(c.Label) > MaxChannelLabelRunes || strings.ContainsFunc(c.Label, unicode.IsControl) { + return bad("a channel label has at most %d characters and no control character", MaxChannelLabelRunes) + } + if slices.ContainsFunc(list[:i], func(o Channel) bool { return o.ID == c.ID }) { + return bad("the channel %q is listed twice", c.ID) + } + } + return nil +} diff --git a/core/services/studio/connectorsend.go b/core/services/studio/connectorsend.go new file mode 100644 index 0000000000..798be87378 --- /dev/null +++ b/core/services/studio/connectorsend.go @@ -0,0 +1,128 @@ +package studio + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode/utf8" + + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// This file is the one door for text that the agent, or a trigger on its +// behalf, writes to a platform chat. It adds the text rules and the audit to +// the outbox (connectoroutbox.go), which keeps the rate limit and the queue. + +// The kinds of an outgoing message, as the audit shows them. +const ( + // SendReply is a message in the conversation chat and thread. + SendReply = "reply" + // SendChannel is a top-level message in an approved channel of the binding. + SendChannel = "channel" + // SendNew is a top-level message in a listed channel that the owner has not + // approved: it needs the sensitive capability each time. + SendNew = "new" + // SendDelivery is the report of a trigger run. + SendDelivery = "delivery" +) + +// ErrMessageEmpty and ErrMessageTooLong are the refusals of the text rules. +var ( + ErrMessageEmpty = errors.New("studio: the message has no text") + ErrMessageTooLong = fmt.Errorf("studio: a message has at most %d characters", MaxReportRunes) +) + +// Outgoing is one message to write to a chat of a binding. +type Outgoing struct { + Binding, Chat, Thread, Text string + // Kind is one of the Send constants and Capability the capability that + // allowed it; both go to the audit. + Kind, Capability string + // Senders and Roles are who made the turn that wrote it, for the audit. + Senders, Roles []string + // OnFail is called when a message that waited in the queue is not sent. + OnFail func(error) +} + +// Send writes the message through the outbox. It refuses an empty text and a +// text over MaxReportRunes, breaks the mentions that notify a whole channel, +// and records an audit row without the text. It reports whether the message +// waits for the rate limit instead of going at once. +func (c *Connectors) Send(ctx context.Context, o Outgoing) (queued bool, err error) { + text := strings.TrimSpace(o.Text) + switch { + case text == "": + return false, ErrMessageEmpty + case utf8.RuneCountInString(text) > MaxReportRunes: + return false, ErrMessageTooLong + } + text = reportText(text) + ev := AccessEvent{At: c.o.Clock().UTC(), Binding: o.Binding, Senders: o.Senders, Roles: o.Roles, Capability: o.Capability, + Action: o.Kind, Channel: o.Chat, Thread: o.Thread, Length: utf8.RuneCountInString(text), Hash: hashOf(text)} + if b, gerr := c.o.Store.GetBinding(ctx, o.Binding); gerr == nil { + ev.Kind = b.Kind + } + queued, err = c.Post(ctx, Post{Binding: o.Binding, Chat: o.Chat, Thread: o.Thread, Text: text, + OnFail: func(err error) { + f := ev + f.Reason = "failed after waiting" + c.svc.access.append(f) + if o.OnFail != nil { + o.OnFail(err) + } + }}) + switch { + case err != nil: + ev.Reason = "failed: " + err.Error() + case queued: + ev.Allowed, ev.Reason = true, "queued" + default: + ev.Allowed, ev.Reason = true, "sent" + } + c.svc.access.append(ev) + return queued, err +} + +// ChannelTarget says where a channel message goes and what it needs. A +// channel that is listed and approved needs talkback.channels; one that is +// listed and not approved needs talkback.new_channel; one that is not listed +// is refused, with the list in the reason. +func ChannelTarget(b connectors.Binding, channel string) (kind, capability string, err error) { + ch, ok := b.FindChannel(channel) + switch { + case !ok: + return "", "", fmt.Errorf("%q is not a channel the owner listed for this bot%s", channel, listedChannels(b)) + case ch.Approved: + return SendChannel, contract.CapTalkbackChannels, nil + } + return SendNew, contract.CapTalkbackNewChan, nil +} + +// listedChannels words the channels of a binding for the model. +func listedChannels(b connectors.Binding) string { + if len(b.Channels) == 0 { + return "; the owner listed none" + } + var parts []string + for _, c := range b.Channels { + if c.Label != "" { + parts = append(parts, c.Label+" ("+c.ID+")") + } else { + parts = append(parts, c.ID) + } + } + return "; the channels are " + strings.Join(parts, ", ") +} + +// TalkbackSwitch is a CapabilitySwitch that turns off both talk-back +// capabilities, with the reason shown to the owner. The operator switch uses it. +func TalkbackSwitch(reason string) CapabilitySwitch { + return func(_ context.Context, _ connectors.Binding, capability string) string { + if capability == contract.CapTalkbackChannels || capability == contract.CapTalkbackNewChan { + return reason + } + return "" + } +} diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 67308363f8..a6044a6221 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -55,6 +55,10 @@ type ConnectorInput struct { // unchanged. An empty list clears it. Duplicates (compared without regard // to case) collapse into the first. Resources []string + // Channels are the chats where the agent may write besides the + // conversation (see connectors.Channel). Nil: new binding none, update + // unchanged. An empty list clears it. + Channels []connectors.Channel } // apply sets the fields of the input that an owner writes on a binding. @@ -82,6 +86,9 @@ func (in ConnectorInput) apply(b *connectors.Binding) { if in.Grants != nil { b.Grants = slices.Clone(in.Grants) } + if in.Channels != nil { + b.Channels = slices.Clone(in.Channels) + } if in.Resources != nil { b.Resources = connectors.NewResources(in.Resources) } diff --git a/core/services/studio/connectortalkback_test.go b/core/services/studio/connectortalkback_test.go new file mode 100644 index 0000000000..4cfb7cf236 --- /dev/null +++ b/core/services/studio/connectortalkback_test.go @@ -0,0 +1,383 @@ +package studio_test + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/core/services/studio/studiotest" + "github.com/mudler/LocalAI/core/services/studio/triggers" + "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/tests/studiobot" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The agent of a connector chat writes to a chat of the platform with +// send_message. The kind in these specs is a made-up platform; nothing here +// depends on which add-on serves it. +var _ = Describe("talk-back from a connector chat", func() { + ctx := context.Background() + const ( + chatID = "C1" + thread = "T1" + general = "G1" + ops = "G2" + ) + var ( + fake *postAddon + gate *capSwitch + r *connRig + b connectors.Binding + args string // the arguments of the next send_message + seq int + ) + script := func(_ workspace.Actor, text string) []studiotest.ToolStep { + switch { + case text == "send": + return []studiotest.ToolStep{{Tool: "send_message", Args: args}, {Say: "done"}} + case text == "propose": + return []studiotest.ToolStep{{Tool: "propose_trigger", Args: args}, {Say: "proposed"}} + case strings.Contains(text, "RUN"): + return []studiotest.ToolStep{{Say: "result is 42"}} + } + return []studiotest.ToolStep{{Say: "ok"}} + } + channels := []connectors.Channel{{ID: general, Label: "General", Approved: true}, {ID: ops, Label: "Ops"}} + setup := func(opt func(*studio.ConnectorOptions)) { + fake = &postAddon{} + gate = &capSwitch{} + tr := studiobot.NewTransport(studiobot.Addon{ID: "chat-addon", Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPull}, Runtime: fake}) + r = newConnRigOpts(newShared(), connStore(), tr, script, nil, func(o *studio.ConnectorOptions) { + o.OutboxPoll = 10 * time.Millisecond + if opt != nil { + opt(o) + } + }) + r.svc.SetCapabilityGate(gate) + b = r.bind(studio.ConnectorInput{Kind: "chatty", Token: "tok", Allow: []string{"11"}, Channels: channels}) + seq = 0 + } + update := func(f func(*studio.ConnectorInput)) { + in := studio.ConnectorInput{Name: b.Name, AgentID: b.AgentID, Enabled: true, Allow: b.Allow} + f(&in) + _, err := r.conns.UpdateConnector(ctx, alice, b.ID, in) + Expect(err).NotTo(HaveOccurred()) + } + say := func(text string) { + seq++ + Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{ + Chat: chatID, Thread: thread, Mention: true, MessageID: "m" + string(rune('a'+seq)), SenderID: "11", SenderName: "Cy", Text: text}})).To(Succeed()) + } + // turn runs a turn and waits for its answer. + turn := func(text string) { + before := len(fake.texts(chatID, thread)) + say(text) + waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) > before }) + } + chatTools := func() []string { + l, err := r.svc.List(ctx, alice, 10) + Expect(err).NotTo(HaveOccurred()) + Expect(l).NotTo(BeEmpty()) + return r.h.ToolNames(l[0].ID) + } + lastSend := func() studiotest.ToolResult { + res := r.h.ResultsFor("send_message") + Expect(res).NotTo(BeEmpty()) + return res[len(res)-1] + } + recent := func() []studio.AccessEvent { + v, err := r.conns.Access(ctx, alice, b.ID) + Expect(err).NotTo(HaveOccurred()) + return v.Recent + } + + Describe("the tool", func() { + It("is offered when the sender holds talkback.channels", func() { + setup(nil) + turn("hello") + Expect(chatTools()).To(ContainElement("send_message")) + }) + + It("is absent when the owner did not enable the capability for the bot", func() { + setup(nil) + update(func(in *studio.ConnectorInput) { in.Caps = []string{contract.CapMemoryWrite} }) + turn("hello") + Expect(chatTools()).NotTo(ContainElement("send_message")) + }) + + It("is absent when the sender is not paired and has no granted role", func() { + setup(nil) + bb, _ := r.store.GetBinding(ctx, b.ID) + bb.Paired = nil + Expect(r.store.PutBinding(ctx, bb)).To(Succeed()) + turn("hello") + Expect(chatTools()).NotTo(ContainElement("send_message")) + }) + + It("is absent when the server switch is off", func() { + setup(nil) + r.conns.AddCapabilitySwitch(studio.TalkbackSwitch("off")) + turn("hello") + Expect(chatTools()).NotTo(ContainElement("send_message")) + }) + + It("is absent in a chat that no connector started", func() { + setup(nil) + c, err := r.svc.Create(ctx, alice, studio.CreateInput{Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + Expect(r.h.ToolNames(c.ID)).NotTo(ContainElement("send_message")) + }) + + It("is absent for the run of a trigger", func() { + setup(nil) + gate.set(studio.CapTriggersRun, true) + tr := r.schedule(studio.TriggerInput{AgentID: r.agent.ID, Prompt: "RUN go"}) + r.due() + run := r.waitEnded(tr.ID) + Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("send_message")) + }) + }) + + Describe("a message", func() { + It("without channel goes to the conversation chat and thread", func() { + setup(nil) + args = `{"text":"hello there"}` + turn("send") + waitFor("the message", func() bool { return len(fake.texts(chatID, thread)) >= 2 }) + Expect(fake.texts(chatID, thread)).To(ContainElement("hello there")) + Expect(lastSend().Out).To(Equal("Sent.")) + }) + + It("to an approved channel is a top-level message there", func() { + setup(nil) + args = `{"text":"news for @channel and ","channel":"` + general + `"}` + turn("send") + waitFor("the message", func() bool { return len(fake.texts(general, "")) == 1 }) + got := fake.texts(general, "")[0] + Expect(got).NotTo(MatchRegexp(`(@| 64 { + return "", errors.New("the channel or the thread is not valid") + } + switch { + case in.Channel == "": + if in.Thread != "" { + return "", errors.New("thread goes with channel") + } + chat, thread, ok := c.PlaceOf(ctx, b.ID, ch.Actor.ChatID) + if !ok { + return "", errors.New("I cannot tell where this conversation is written; the answer to the person is the way to reply") + } + o.Kind, o.Capability, o.Chat, o.Thread = SendReply, contract.CapTalkbackChannels, chat, thread + default: + kind, capability, terr := ChannelTarget(b, in.Channel) + if terr != nil { + return "", terr + } + o.Kind, o.Capability, o.Chat, o.Thread = kind, capability, in.Channel, in.Thread + } + if err := ch.Require(o.Capability); err != nil { + return "", err + } + queued, err := c.Send(ctx, o) + switch { + case errors.Is(err, ErrMessageEmpty), errors.Is(err, ErrMessageTooLong): + return "", err + case errors.Is(err, ErrOutboxFull): + return "", errors.New("too many messages wait to be sent; try later") + case err != nil: + return "", fmt.Errorf("the message was not sent: %w", err) + case queued: + return "The message waits for the rate limit of this bot and goes soon, in order.", nil + } + return "Sent.", nil +} diff --git a/core/services/studio/triggerchannel.go b/core/services/studio/triggerchannel.go new file mode 100644 index 0000000000..b574766470 --- /dev/null +++ b/core/services/studio/triggerchannel.go @@ -0,0 +1,80 @@ +package studio + +import ( + "context" + "errors" + "fmt" + + "github.com/mudler/LocalAI/core/services/studio/builtins" + "github.com/mudler/LocalAI/core/services/studio/proposals" + "github.com/mudler/LocalAI/core/services/studio/triggers" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// checkChannelTarget is what a proposal that reports to a channel must pass at +// once, so the model hears the reason while the person is there: the sender +// holds talkback.channels, and the channel is one the owner listed and +// approved. A run has no sender at delivery time, so a channel that waits for +// a paired sender is not a target for reports. +func (h *Triggers) checkChannelTarget(ctx context.Context, c builtins.Chat, channel string) error { + conns := h.svc.connectors + if conns == nil { + return errors.New("connections to messaging apps are off on this server") + } + b, err := conns.o.Store.GetBinding(ctx, c.OriginID) + if err != nil { + return errors.New("this connection is gone") + } + kind, _, terr := ChannelTarget(b, channel) + if terr != nil { + return terr + } + if kind != SendChannel { + return fmt.Errorf("the channel %q is not approved for reports; the owner approves it in Studio", channel) + } + return c.Require(contract.CapTalkbackChannels) +} + +// deliverChannel writes body in the channel of the trigger. It checks, now and +// not when the trigger was made, that the binding is on, the capability is held +// by the binding, and the channel is still listed and approved. Any failure +// returns false so that the Inbox gets the notice. fallback runs when a queued +// message cannot be sent later. +func (h *Triggers) deliverChannel(ctx context.Context, t triggers.Trigger, body string, fallback func()) bool { + c := h.svc.connectors + refuse := func(why string) bool { + h.audit(ctx, proposals.ActionDeliver, t, "refused: "+why) + return false + } + if c == nil { + return refuse("connectors are off") + } + b, err := c.o.Store.GetBinding(ctx, t.Route.Binding) + if err != nil || !b.Enabled { + return refuse("the binding is off") + } + kind, _, terr := ChannelTarget(b, t.Route.Channel) + switch { + case terr != nil: + return refuse("the channel is not listed") + case kind != SendChannel: + return refuse("the channel is not approved") + case !h.svc.allowCap(ctx, CapabilityRequest{Capability: contract.CapTalkbackChannels, Binding: t.Route.Binding, Owner: t.Owner, Agent: t.AgentID}): + return refuse("capability off") + } + queued, err := c.Send(ctx, Outgoing{Binding: b.ID, Chat: t.Route.Channel, Text: body, Kind: SendDelivery, Capability: contract.CapTalkbackChannels, + OnFail: func(error) { + h.audit(context.Background(), proposals.ActionDeliver, t, "failed; sent to the Inbox") + fallback() + }}) + if err != nil { + h.audit(ctx, proposals.ActionDeliver, t, "failed; sent to the Inbox: "+err.Error()) + return false + } + if queued { + h.audit(ctx, proposals.ActionDeliver, t, "channel, queued") + } else { + h.audit(ctx, proposals.ActionDeliver, t, "channel, sent") + } + return true +} diff --git a/core/services/studio/triggerroute.go b/core/services/studio/triggerroute.go index 60e36e8bb6..cf77135323 100644 --- a/core/services/studio/triggerroute.go +++ b/core/services/studio/triggerroute.go @@ -235,8 +235,11 @@ func (h *Triggers) proposalText(t triggers.Trigger) string { prompt = string([]rune(prompt)[:280]) + "…" } where := "the Inbox in Studio" - if t.Route.TargetOrDefault() == triggers.TargetOrigin { + switch t.Route.TargetOrDefault() { + case triggers.TargetOrigin: where = "this chat" + case triggers.TargetChannel: + where = "the channel " + t.Route.Channel } var mode string switch t.Route.ModeOrDefault() { @@ -324,6 +327,11 @@ func (h *Triggers) report(ctx context.Context, t triggers.Trigger, run triggers. return true } } + if t.Route.TargetOrDefault() == triggers.TargetChannel { + return h.deliverChannel(ctx, t, t.Name+"\n\n"+reportText(text), func() { + h.tell(context.WithoutCancel(ctx), t, run.ChatID, notify.KindRunEnded, notify.StatusOK, t.Name+" finished", text) + }) + } if t.Route.TargetOrDefault() != triggers.TargetOrigin { return false } diff --git a/core/services/studio/triggers/types.go b/core/services/studio/triggers/types.go index 3b435ff4ae..aee0a511c3 100644 --- a/core/services/studio/triggers/types.go +++ b/core/services/studio/triggers/types.go @@ -147,6 +147,9 @@ type Trigger struct { const ( TargetInbox = "inbox" TargetOrigin = "origin" + // TargetChannel reports in a channel that the owner listed for the + // binding, named by Route.Channel. + TargetChannel = "channel" ) // When a run is reported. Always reports every run, OnChange a run whose @@ -179,6 +182,8 @@ type Route struct { // ModeAlways. Target string `json:",omitempty"` Mode string `json:",omitempty"` + // Channel is the channel of TargetChannel, as the owner listed it. + Channel string `json:",omitempty"` } // FromConnector says whether a connector chat proposed the trigger. @@ -208,15 +213,19 @@ func (r Route) Validate() error { if r.Binding == "" || r.Chat == "" { return errors.New("a run can report to the chat of the proposal only when a connector chat proposed it") } + case TargetChannel: + if r.Binding == "" || r.Channel == "" { + return errors.New("a run can report to a channel only when a connector chat proposed it and names the channel") + } default: - return fmt.Errorf("delivery target must be %q or %q", TargetInbox, TargetOrigin) + return fmt.Errorf("delivery target must be %q, %q or %q", TargetInbox, TargetOrigin, TargetChannel) } switch r.Mode { case "", ModeAlways, ModeOnChange, ModeOnProblem: default: return fmt.Errorf("delivery mode must be %q, %q or %q", ModeAlways, ModeOnChange, ModeOnProblem) } - for _, v := range []string{r.Binding, r.Chat, r.Thread, r.Code} { + for _, v := range []string{r.Binding, r.Chat, r.Thread, r.Code, r.Channel} { if len(v) > MaxRefBytes || hasControl(v, false) { return errors.New("the connector place of a trigger is too long or has control characters") } diff --git a/core/services/studio/triggertools.go b/core/services/studio/triggertools.go index 5203ba96af..7f790f83d4 100644 --- a/core/services/studio/triggertools.go +++ b/core/services/studio/triggertools.go @@ -69,7 +69,8 @@ const proposeTriggerConnectorSchema = `{"type":"object","properties":{` + `"widget":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{0,39}$","description":"The id of the Home widget that shows the file."},` + `"estimated_tokens":{"type":"integer","minimum":0,"maximum":10000000,"description":"About how many tokens one run uses."},` + `"delivery":{"type":"object","description":"Where the answer of each run goes. Default: the owner's Inbox.","properties":{` + - `"target":{"type":"string","enum":["inbox","origin"],"description":"origin writes the answer in this chat and thread, inbox in the owner's Inbox."},` + + `"target":{"type":"string","enum":["inbox","origin","channel"],"description":"origin writes the answer in this chat and thread, inbox in the owner's Inbox, channel in a channel the owner listed and approved (name it in channel)."},` + + `"channel":{"type":"string","description":"With target channel: the ID of the channel."},` + `"mode":{"type":"string","enum":["always","on_change","on_problem"],"description":"always: every run. on_change: only when the answer differs from the run before. on_problem: only when a run fails."}}}},` + `"required":["name","prompt","when"]}` @@ -302,8 +303,9 @@ func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, c builtin Widget string `json:"widget"` Tokens int `json:"estimated_tokens"` Delivery *struct { - Target string `json:"target"` - Mode string `json:"mode"` + Target string `json:"target"` + Mode string `json:"mode"` + Channel string `json:"channel"` } `json:"delivery"` } if err := json.Unmarshal(raw, &args); err != nil { @@ -332,6 +334,12 @@ func (h *Triggers) proposeTool(ctx context.Context, a workspace.Actor, c builtin } if d := args.Delivery; d != nil { in.Route.Target, in.Route.Mode = d.Target, d.Mode + if d.Target == triggers.TargetChannel { + if err := h.checkChannelTarget(ctx, c, d.Channel); err != nil { + return "", err + } + in.Route.Channel = d.Channel + } } if in.Route.Target == "" { in.Route.Target = triggers.TargetInbox diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index 97e727fc45..3762de08ce 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -531,8 +531,9 @@ trigger stays in Studio. Nothing runs before the owner confirms. with the grant). A code is used once, and a proposal lapses after 24 hours. Studio always works as well. - **Limits.** A binding can make 3 proposals an hour, and an owner can have 10 waiting. -- **Where a run reports.** The proposal sets `delivery`: `inbox` (the default) or - `origin`, the chat and thread of the proposal, and a mode: `always`, `on_change` or +- **Where a run reports.** The proposal sets `delivery`: `inbox` (the default), + `origin` (the chat and thread of the proposal) or `channel` (a channel of the binding that + the owner approved, named in `delivery.channel`), and a mode: `always`, `on_change` or `on_problem`. LocalAI writes the last message of the run, cut to 4,000 characters, with mentions that notify everyone broken. A final message of exactly `[SILENT]` writes nothing. After three failed runs in a row the chat gets one alert, and not @@ -542,6 +543,42 @@ trigger stays in Studio. Nothing runs before the owner confirms. - **Pause.** While `triggers.run` is off for the binding, its triggers do not run. The records stay and are not replayed when the capability comes back. +## Writing to a chat on its own (`send_message`) + +When the sender of a turn holds the capability `talkback.channels`, the agent gets the +tool `send_message`. The tool names no platform. The agent can use it to: + +- **Write in the conversation.** With no `channel`, the message goes to the chat and + thread of the conversation. +- **Write in a channel.** With `channel`, the message is a new top-level message in a + channel that the owner listed for the binding (`channels` in the binding: `id`, + `label` and `approved`, at most 10; an add-on settings page or the API sets them). A + channel that is not listed is refused, and the error lists the ones that are. `thread` + answers in a thread of that channel. +- **Write in a channel the owner has not approved.** A listed channel with `approved` + false needs the sensitive capability `talkback.new_channel` for each message, so only a + paired sender can use it. A role never grants it. The owner approves a channel by + setting `approved` to true. + +The agent cannot add a channel or approve one: only the owner changes the list, so +text that the agent read in a web page or a file cannot widen where it writes. + +A message has at most 4,000 characters, and mentions that notify everyone are broken. +The message goes through the same queue as other messages that LocalAI writes on its own: +a binding sends 20 a minute, and more wait in order. Each attempt adds a row to the audit +of the binding (time, binding, senders, channel, thread, kind, capability, result, length +and a SHA-256 hash of the text, never the text). `LOCALAI_DISABLE_CONNECTOR_TALKBACK=true` +turns the tool and the reports to a channel off for every binding; owner notices +continue. + +A trigger can report to an approved channel with `delivery.target` `channel`. The +proposal is refused if the channel is not listed and approved. When a run ends, LocalAI +checks again that the binding is on, the capability is on and the channel is still listed +and approved; if not, the Inbox gets the notice. + +Not included yet: sending a file with `send_message`, and mapping a reply in the thread of +a message that the agent started to a conversation with the same agent. + ## Notifications in Telegram Set a notification chat ID on a binding to receive your run notifications there diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md index 2b7e88e2c7..da2420807f 100644 --- a/docs/content/features/chats.md +++ b/docs/content/features/chats.md @@ -87,6 +87,7 @@ what runs (`chats`, `agents`, `triggers`, `connectors`). | `LOCALAI_DISABLE_CHATS` | `false` | Turns off the chats: the `/api/chats` API, saved agents, triggers, connectors, notifications and the agent jobs (`/api/chats/*`, `/api/workspace/*` and `/api/agent/*` answer `404` with the type `chats_off`). The files workspace of the add-ons is not affected. | | `LOCALAI_DISABLE_TRIGGERS` | `false` | Turns off the trigger runner. Trigger routes answer `409 triggers_off`. | | `LOCALAI_DISABLE_CONNECTORS` | `false` | Turns off the connector bots. Connector routes answer `409 connectors_off`. | +| `LOCALAI_DISABLE_CONNECTOR_TALKBACK` | `false` | Turns off `send_message` and the reports of triggers to a channel for every connector binding. | | `LOCALAI_DISABLE_AGENTS` | `false` | Deprecated. It kept the meaning it had in the old agent pool, which also held the knowledge base: it sets `LOCALAI_DISABLE_CHATS` and `LOCALAI_DISABLE_COLLECTIONS`, and logs a warning. | `LOCALAI_DISABLE_COLLECTIONS` stays separate and turns off only the diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md index 37f01ff105..21a6586b77 100644 --- a/docs/content/reference/cli-reference.md +++ b/docs/content/reference/cli-reference.md @@ -225,6 +225,7 @@ See [Authentication & Authorization]({{%relref "features/authentication" %}}) fo | `--disable-chats` | `false` | Turn off the chats: the `/api/chats` API, saved agents, triggers, connectors and the agent tasks and jobs. Nothing of them starts | `$LOCALAI_DISABLE_CHATS` | | `--disable-triggers` | `false` | Turn off the trigger runner and the triggers API; the rest of the chats stays on | `$LOCALAI_DISABLE_TRIGGERS` | | `--disable-connectors` | `false` | Turn off the connector bots (Telegram, Slack) and the connectors API; the rest of the chats stays on | `$LOCALAI_DISABLE_CONNECTORS` | +| `--disable-connector-talkback` | `false` | Turn off the tool with which an agent writes to a chat of its connection, and the reports of triggers to a channel (the rest of the connectors stays on) | `$LOCALAI_DISABLE_CONNECTOR_TALKBACK` | | `--agent-job-retention-days` | `30` | Days to keep ended agent jobs | `$LOCALAI_AGENT_JOB_RETENTION_DAYS`, `$AGENT_JOB_RETENTION_DAYS` | | `--trigger-run-keep` | `200` | Finished runs kept for each trigger, beyond the runs of the last two days. `-1` keeps all | `$LOCALAI_TRIGGER_RUN_KEEP` | | `--trigger-run-retention-days` | `30` | Days to keep finished trigger runs. `-1` keeps them forever | `$LOCALAI_TRIGGER_RUN_RETENTION_DAYS` | diff --git a/swagger/docs.go b/swagger/docs.go index e86e6b5150..dac2d1f897 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -15834,6 +15834,10 @@ const docTemplate = `{ }, "resourceId": { "type": "string" + }, + "resourceKey": { + "description": "ResourceKey and Credential are set by the host alone, for an add-on that\ndeclares credentials (see CredentialSlot). ResourceKey is the external\nname of the resource the call works on, after the host checked it against\nthe person's list, and ResourceID is then the host ID of that entry.\nCredential is the secret of the person's binding. An add-on never stores\nor logs it, and the host drops both fields from every request that a\ncaller sends.", + "type": "string" } } }, @@ -18565,6 +18569,13 @@ const docTemplate = `{ "type": "string" } }, + "channels": { + "description": "Channels are the chats where the agent may write besides the\nconversation. Only you can change the list.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorChannel" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -18636,6 +18647,13 @@ const docTemplate = `{ "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" }, + "resources": { + "description": "Resources are the external resources, such as repositories, that the\ntools of the add-on may reach with this binding. Nothing else is\nreachable.", + "type": "array", + "items": { + "type": "string" + } + }, "send_files": { "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" @@ -19484,6 +19502,13 @@ const docTemplate = `{ "type": "string" } }, + "channels": { + "description": "Channels are the chats where the agent may write besides the\nconversation. Only you can change the list.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorChannel" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -19552,6 +19577,13 @@ const docTemplate = `{ "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" }, + "resources": { + "description": "Resources are the external resources, such as repositories, that the\ntools of the add-on may reach with this binding. Nothing else is\nreachable.", + "type": "array", + "items": { + "type": "string" + } + }, "send_files": { "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" @@ -19648,6 +19680,20 @@ const docTemplate = `{ } } }, + "schema.ChatsConnectorChannel": { + "type": "object", + "properties": { + "approved": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, "schema.ChatsConnectorFileLimit": { "type": "object", "properties": { @@ -19941,6 +19987,13 @@ const docTemplate = `{ "type": "string" } }, + "channels": { + "description": "Channels are the chats of the platform where the agent may write\nbesides the conversation, at most 10: {id, label, approved}. A channel\nthat is not approved needs a paired sender for each message. Absent\nmeans none for a new binding and unchanged for an update; an empty list\nclears it.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorChannel" + } + }, "confirm_accept_any": { "description": "ConfirmAcceptAny must be the exact text \"accept any message\" in the\nrequest that turns accept_any on (a create with accept_any, or an update\nthat changes it from off to on). Anyone who can message the bot then uses\nthe agent's tools as you, so the server asks you to say you mean it.", "type": "string" @@ -19987,6 +20040,13 @@ const docTemplate = `{ "description": "ReplyMode is off, progress or stream; empty means progress.", "type": "string" }, + "resources": { + "description": "Resources are the external resources the tools of the add-on may reach\nwith this binding, such as \"owner/repo\", at most 50. Absent means none\nfor a new binding and unchanged for an update; an empty list clears it.", + "type": "array", + "items": { + "type": "string" + } + }, "send_files": { "description": "SendFiles turns the sending of a turn's files on or off. Absent means\non for a new binding and unchanged for an update.", "type": "boolean" diff --git a/swagger/swagger.json b/swagger/swagger.json index bd9347d053..8763c20ddd 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -15831,6 +15831,10 @@ }, "resourceId": { "type": "string" + }, + "resourceKey": { + "description": "ResourceKey and Credential are set by the host alone, for an add-on that\ndeclares credentials (see CredentialSlot). ResourceKey is the external\nname of the resource the call works on, after the host checked it against\nthe person's list, and ResourceID is then the host ID of that entry.\nCredential is the secret of the person's binding. An add-on never stores\nor logs it, and the host drops both fields from every request that a\ncaller sends.", + "type": "string" } } }, @@ -18562,6 +18566,13 @@ "type": "string" } }, + "channels": { + "description": "Channels are the chats where the agent may write besides the\nconversation. Only you can change the list.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorChannel" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -18633,6 +18644,13 @@ "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" }, + "resources": { + "description": "Resources are the external resources, such as repositories, that the\ntools of the add-on may reach with this binding. Nothing else is\nreachable.", + "type": "array", + "items": { + "type": "string" + } + }, "send_files": { "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" @@ -19481,6 +19499,13 @@ "type": "string" } }, + "channels": { + "description": "Channels are the chats where the agent may write besides the\nconversation. Only you can change the list.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorChannel" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -19549,6 +19574,13 @@ "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" }, + "resources": { + "description": "Resources are the external resources, such as repositories, that the\ntools of the add-on may reach with this binding. Nothing else is\nreachable.", + "type": "array", + "items": { + "type": "string" + } + }, "send_files": { "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" @@ -19645,6 +19677,20 @@ } } }, + "schema.ChatsConnectorChannel": { + "type": "object", + "properties": { + "approved": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, "schema.ChatsConnectorFileLimit": { "type": "object", "properties": { @@ -19938,6 +19984,13 @@ "type": "string" } }, + "channels": { + "description": "Channels are the chats of the platform where the agent may write\nbesides the conversation, at most 10: {id, label, approved}. A channel\nthat is not approved needs a paired sender for each message. Absent\nmeans none for a new binding and unchanged for an update; an empty list\nclears it.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorChannel" + } + }, "confirm_accept_any": { "description": "ConfirmAcceptAny must be the exact text \"accept any message\" in the\nrequest that turns accept_any on (a create with accept_any, or an update\nthat changes it from off to on). Anyone who can message the bot then uses\nthe agent's tools as you, so the server asks you to say you mean it.", "type": "string" @@ -19984,6 +20037,13 @@ "description": "ReplyMode is off, progress or stream; empty means progress.", "type": "string" }, + "resources": { + "description": "Resources are the external resources the tools of the add-on may reach\nwith this binding, such as \"owner/repo\", at most 50. Absent means none\nfor a new binding and unchanged for an update; an empty list clears it.", + "type": "array", + "items": { + "type": "string" + } + }, "send_files": { "description": "SendFiles turns the sending of a turn's files on or off. Absent means\non for a new binding and unchanged for an update.", "type": "boolean" diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index a3a12eb022..67219b5d1f 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -911,6 +911,16 @@ definitions: type: string resourceId: type: string + resourceKey: + description: |- + ResourceKey and Credential are set by the host alone, for an add-on that + declares credentials (see CredentialSlot). ResourceKey is the external + name of the resource the call works on, after the host checked it against + the person's list, and ResourceID is then the host ID of that entry. + Credential is the secret of the person's binding. An add-on never stores + or logs it, and the host drops both fields from every request that a + caller sends. + type: string type: object contract.Result: properties: @@ -2958,6 +2968,13 @@ definitions: items: type: string type: array + channels: + description: |- + Channels are the chats where the agent may write besides the + conversation. Only you can change the list. + items: + $ref: '#/definitions/schema.ChatsConnectorChannel' + type: array conversation: description: |- Conversation says which chat a message belongs to: sender (one chat @@ -3023,6 +3040,14 @@ definitions: start) or stream (a working message that shows the answer as it is written). The answer replaces the working message. type: string + resources: + description: |- + Resources are the external resources, such as repositories, that the + tools of the add-on may reach with this binding. Nothing else is + reachable. + items: + type: string + type: array send_files: description: |- SendFiles sends the files that a turn makes to the sender, after the @@ -3713,6 +3738,13 @@ definitions: items: type: string type: array + channels: + description: |- + Channels are the chats where the agent may write besides the + conversation. Only you can change the list. + items: + $ref: '#/definitions/schema.ChatsConnectorChannel' + type: array conversation: description: |- Conversation says which chat a message belongs to: sender (one chat @@ -3776,6 +3808,14 @@ definitions: start) or stream (a working message that shows the answer as it is written). The answer replaces the working message. type: string + resources: + description: |- + Resources are the external resources, such as repositories, that the + tools of the add-on may reach with this binding. Nothing else is + reachable. + items: + type: string + type: array send_files: description: |- SendFiles sends the files that a turn makes to the sender, after the @@ -3841,6 +3881,15 @@ definitions: title: type: string type: object + schema.ChatsConnectorChannel: + properties: + approved: + type: boolean + id: + type: string + label: + type: string + type: object schema.ChatsConnectorFileLimit: properties: allowed_bytes: @@ -4054,6 +4103,16 @@ definitions: items: type: string type: array + channels: + description: |- + Channels are the chats of the platform where the agent may write + besides the conversation, at most 10: {id, label, approved}. A channel + that is not approved needs a paired sender for each message. Absent + means none for a new binding and unchanged for an update; an empty list + clears it. + items: + $ref: '#/definitions/schema.ChatsConnectorChannel' + type: array confirm_accept_any: description: |- ConfirmAcceptAny must be the exact text "accept any message" in the @@ -4102,6 +4161,14 @@ definitions: reply_mode: description: ReplyMode is off, progress or stream; empty means progress. type: string + resources: + description: |- + Resources are the external resources the tools of the add-on may reach + with this binding, such as "owner/repo", at most 50. Absent means none + for a new binding and unchanged for an update; an empty list clears it. + items: + type: string + type: array send_files: description: |- SendFiles turns the sending of a turn's files on or off. Absent means