feat(studio): add the thread_context switch to connector bindings

A binding reads the earlier messages of a thread by default. The new
thread_context option turns that off, and the kind list says which kinds
can give a thread's messages. It is stored like send_files: the zero
value keeps the default.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 11:30:11 +00:00
1 parent ca5c8e79e3
commit f20b7250ad
10 files changed
+114 -9

No files matched your search

+4 -4
View File
@@ -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"
@@ -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})
+10
View File
@@ -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.
@@ -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.
+8 -5
View File
@@ -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) {
+8
View File
@@ -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 {
+7
View File
@@ -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.
+16
View File
@@ -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"
},
+16
View File
@@ -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"
},
+22
View File
@@ -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: