diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 6a48b95cc3..a56cf686f3 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -24,7 +24,7 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector { } return schema.ChatsConnector{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()} + ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), ThreadContext: i.ReadsThread()} } // acceptAnyConfirmation is what a request must say to turn on accept_any. @@ -38,7 +38,7 @@ func failAcceptAny(c echo.Context) error { 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} + Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, ThreadContext: req.ThreadContext} } var connectorRules = []errRule{ @@ -99,14 +99,14 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error { } out := schema.ChatsConnectorKindList{Kinds: make([]schema.ChatsConnectorKind, 0, len(kinds))} for _, k := range kinds { - out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page}) + out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page, ThreadHistory: k.ServesThreadHistory()}) } return c.JSON(http.StatusOK, out) } // CreateConnector creates a binding. // @Summary Create a connector binding. -// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender) and send_files (default true) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 10 bindings. +// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender) and send_files (default true) say how the bot answers. thread_context (default true) lets the bot read the earlier messages of the thread in which it is mentioned, on a platform that can give them. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 10 bindings. // @Tags chats // @Param request body schema.ChatsConnectorWrite true "the binding" // @Success 201 {object} schema.ChatsConnector "The binding" diff --git a/core/http/routes/studio_connectors_test.go b/core/http/routes/studio_connectors_test.go index c27befbdd9..99ccb3701a 100644 --- a/core/http/routes/studio_connectors_test.go +++ b/core/http/routes/studio_connectors_test.go @@ -163,6 +163,19 @@ var _ = Describe("Chats connectors endpoints", func() { Expect(s.do("alice", http.MethodPost, "/api/chats/connectors/"+id+"/senders/99/allow", nil).Code).To(Equal(http.StatusBadRequest)) }) + It("stores the thread context switch: on for a new binding, kept by an update that omits it", func() { + ag := agent("alice", "Context", "test-model") + rec := create("alice", map[string]any{"name": "Context bot", "token": specToken, "agent_id": ag}) + Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String()) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("thread_context", true), "the default") + id := decodeJSON(rec)["id"].(string) + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Context bot", "agent_id": ag, "thread_context": false}) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("thread_context", false)) + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Context bot", "agent_id": ag}) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("thread_context", false), "an update without the field keeps it") + }) + It("stores the reply mode, the conversation mode and the file switch, and refuses unknown ones", func() { ag := agent("alice", "Modes", "test-model") rec := create("alice", map[string]any{"name": "Modes bot", "token": specToken, "agent_id": ag}) diff --git a/core/schema/chats.go b/core/schema/chats.go index 88ce10388b..c9eb3cab0d 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1177,6 +1177,10 @@ type ChatsConnector struct { // SendFiles sends the files that a turn makes to the sender, after the // answer. SendFiles bool `json:"send_files"` + // ThreadContext lets the bot read the earlier messages of the thread in + // which it is mentioned. It has an effect only on a platform that can + // give them. + ThreadContext bool `json:"thread_context"` // Active is false when the add-on that serves the binding is not running. The // binding is kept and works once the add-on is installed. Active bool `json:"active"` @@ -1193,6 +1197,9 @@ type ChatsConnectorKind struct { SenderHint string `json:"sender_hint,omitempty"` // Page is the add-on page that adds and edits bindings of this kind. Page string `json:"page,omitempty"` + // ThreadHistory is true when the platform can give the earlier messages + // of a thread, so a binding of this kind has the thread_context option. + ThreadHistory bool `json:"thread_history,omitempty"` } // ChatsConnectorKindList lists the connector kinds of the add-ons that run now. @@ -1227,6 +1234,9 @@ type ChatsConnectorWrite struct { // SendFiles turns the sending of a turn's files on or off. Absent means // on for a new binding and unchanged for an update. SendFiles *bool `json:"send_files,omitempty"` + // ThreadContext turns the reading of the earlier messages of a thread on + // or off. Absent means on for a new binding and unchanged for an update. + ThreadContext *bool `json:"thread_context,omitempty"` } // ChatsConnectorList is the response of the connector list endpoint. diff --git a/core/services/studio/connectors/connectorstest/suite.go b/core/services/studio/connectors/connectorstest/suite.go index 345bd367ec..58aa567572 100644 --- a/core/services/studio/connectors/connectorstest/suite.go +++ b/core/services/studio/connectors/connectorstest/suite.go @@ -70,6 +70,16 @@ func Run(newStore NewStore) { got, _ = s.GetBinding(ctx, "modes") Expect(got.ReplyMode()).To(Equal(connectors.ReplyProgress)) Expect(got.SendsFiles()).To(BeTrue()) + Expect(got.ReadsThread()).To(BeTrue(), "a binding that says nothing reads the thread") + }) + It("keeps the thread context switch", func() { + s := newStore() + b := binding("ctx", "u1", "Context") + b.ThreadContextOff = true + Expect(s.PutBinding(ctx, b)).To(Succeed()) + got, err := s.GetBinding(ctx, "ctx") + Expect(err).NotTo(HaveOccurred()) + Expect(got.ReadsThread()).To(BeFalse()) }) It("gives an old binding the add-on of its kind", func() { // A binding stored before connectors became add-ons has no add-on ID. diff --git a/core/services/studio/connectors/gorm.go b/core/services/studio/connectors/gorm.go index 6adb4c3a03..99155a00e7 100644 --- a/core/services/studio/connectors/gorm.go +++ b/core/services/studio/connectors/gorm.go @@ -28,9 +28,12 @@ type bindingRow struct { Conversation string `gorm:"size:16"` WindowMinutes int FilesOff bool - Token []byte - Created time.Time - Updated time.Time + // ThreadContextOff is added by AutoMigrate; a row stored before it reads + // the thread. + ThreadContextOff bool + Token []byte + Created time.Time + Updated time.Time } func (bindingRow) TableName() string { return "studio_connectors" } @@ -84,7 +87,7 @@ func NewGORM(db *gorm.DB) (Store, error) { func (r bindingRow) binding() (Binding, error) { b := Binding{ID: r.ID, Owner: r.Owner, Kind: r.Kind, AddonID: r.AddonID, Name: r.Name, AgentID: r.AgentID, Enabled: r.Enabled, AcceptAny: r.AcceptAny, - NotifyChat: r.NotifyChat, Reply: r.Reply, Conversation: r.Conversation, WindowMinutes: r.WindowMinutes, FilesOff: r.FilesOff, + NotifyChat: r.NotifyChat, Reply: r.Reply, Conversation: r.Conversation, WindowMinutes: r.WindowMinutes, FilesOff: r.FilesOff, ThreadContextOff: r.ThreadContextOff, Token: append([]byte(nil), r.Token...), Created: r.Created.UTC(), Updated: r.Updated.UTC()} if r.Allow != "" { if err := json.Unmarshal([]byte(r.Allow), &b.Allow); err != nil { @@ -102,7 +105,7 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error { } return s.db.WithContext(ctx).Save(&bindingRow{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, Token: b.Token, Created: b.Created, Updated: b.Updated}).Error + FilesOff: b.FilesOff, ThreadContextOff: b.ThreadContextOff, Token: b.Token, Created: b.Created, Updated: b.Updated}).Error } func (s *gormStore) GetBinding(ctx context.Context, id string) (Binding, error) { diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go index 68a18d6308..7c2417661a 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -105,6 +105,10 @@ type Binding struct { // zero value sends them, so a binding stored before the option sends // them too. FilesOff bool + // ThreadContextOff keeps the host from reading the earlier messages of + // a thread when the bot is mentioned in it. The zero value reads them, + // on a platform whose kind declares the ability. + ThreadContextOff bool // Token is the bot token. In a Store it is sealed bytes; the Store returns // what it was given. Token []byte @@ -222,6 +226,10 @@ func (b Binding) ConversationMode() string { // SendsFiles tells whether the files a turn makes go to the sender. func (b Binding) SendsFiles() bool { return !b.FilesOff } +// ReadsThread tells whether the earlier messages of a thread are read when +// the bot is mentioned in it. +func (b Binding) ReadsThread() bool { return !b.ThreadContextOff } + // Allows tells whether a sender is on the allowlist. A match is by ID or, for an // @name entry, by username, ignoring case. func (b Binding) Allows(senderID, username string) bool { diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 35dcb217e0..78e705d56d 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -37,6 +37,10 @@ type ConnectorInput struct { // SendFiles sends the files a turn makes to the sender. Nil means on for // a new binding and unchanged for an update. SendFiles *bool + // ThreadContext lets the bot read the earlier messages of the thread in + // which it is mentioned. Nil means on for a new binding and unchanged for + // an update. + ThreadContext *bool } // apply sets the fields of the input that an owner writes on a binding. @@ -49,6 +53,9 @@ func (in ConnectorInput) apply(b *connectors.Binding) { if in.SendFiles != nil { b.FilesOff = !*in.SendFiles } + if in.ThreadContext != nil { + b.ThreadContextOff = !*in.ThreadContext + } } // ErrTokenRefused means the platform refused the bot token of a binding. diff --git a/swagger/docs.go b/swagger/docs.go index b270a4c040..4008240fd7 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -18329,6 +18329,10 @@ const docTemplate = `{ "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" }, + "thread_context": { + "description": "ThreadContext lets the bot read the earlier messages of the thread in\nwhich it is mentioned. It has an effect only on a platform that can\ngive them.", + "type": "boolean" + }, "window_minutes": { "type": "integer" } @@ -19130,6 +19134,10 @@ const docTemplate = `{ "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" }, + "thread_context": { + "description": "ThreadContext lets the bot read the earlier messages of the thread in\nwhich it is mentioned. It has an effect only on a platform that can\ngive them.", + "type": "boolean" + }, "window_minutes": { "type": "integer" } @@ -19154,6 +19162,10 @@ const docTemplate = `{ "sender_hint": { "type": "string" }, + "thread_history": { + "description": "ThreadHistory is true when the platform can give the earlier messages\nof a thread, so a binding of this kind has the thread_context option.", + "type": "boolean" + }, "title": { "type": "string" } @@ -19270,6 +19282,10 @@ const docTemplate = `{ "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" }, + "thread_context": { + "description": "ThreadContext turns the reading of the earlier messages of a thread on\nor off. Absent means on for a new binding and unchanged for an update.", + "type": "boolean" + }, "token": { "type": "string" }, diff --git a/swagger/swagger.json b/swagger/swagger.json index d94fde4a47..e63eaadbce 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -18326,6 +18326,10 @@ "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" }, + "thread_context": { + "description": "ThreadContext lets the bot read the earlier messages of the thread in\nwhich it is mentioned. It has an effect only on a platform that can\ngive them.", + "type": "boolean" + }, "window_minutes": { "type": "integer" } @@ -19127,6 +19131,10 @@ "description": "SendFiles sends the files that a turn makes to the sender, after the\nanswer.", "type": "boolean" }, + "thread_context": { + "description": "ThreadContext lets the bot read the earlier messages of the thread in\nwhich it is mentioned. It has an effect only on a platform that can\ngive them.", + "type": "boolean" + }, "window_minutes": { "type": "integer" } @@ -19151,6 +19159,10 @@ "sender_hint": { "type": "string" }, + "thread_history": { + "description": "ThreadHistory is true when the platform can give the earlier messages\nof a thread, so a binding of this kind has the thread_context option.", + "type": "boolean" + }, "title": { "type": "string" } @@ -19267,6 +19279,10 @@ "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" }, + "thread_context": { + "description": "ThreadContext turns the reading of the earlier messages of a thread on\nor off. Absent means on for a new binding and unchanged for an update.", + "type": "boolean" + }, "token": { "type": "string" }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 243dc90b84..5b0f694e74 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -2857,6 +2857,12 @@ definitions: SendFiles sends the files that a turn makes to the sender, after the answer. type: boolean + thread_context: + description: |- + ThreadContext lets the bot read the earlier messages of the thread in + which it is mentioned. It has an effect only on a platform that can + give them. + type: boolean window_minutes: type: integer type: object @@ -3497,6 +3503,12 @@ definitions: SendFiles sends the files that a turn makes to the sender, after the answer. type: boolean + thread_context: + description: |- + ThreadContext lets the bot read the earlier messages of the thread in + which it is mentioned. It has an effect only on a platform that can + give them. + type: boolean window_minutes: type: integer type: object @@ -3514,6 +3526,11 @@ definitions: type: string sender_hint: type: string + thread_history: + description: |- + ThreadHistory is true when the platform can give the earlier messages + of a thread, so a binding of this kind has the thread_context option. + type: boolean title: type: string type: object @@ -3599,6 +3616,11 @@ definitions: SendFiles turns the sending of a turn's files on or off. Absent means on for a new binding and unchanged for an update. type: boolean + thread_context: + description: |- + ThreadContext turns the reading of the earlier messages of a thread on + or off. Absent means on for a new binding and unchanged for an update. + type: boolean token: type: string window_minutes: