feat(addons): add the history op to the connector contract

A connector kind can declare history.thread in its manifest (api 2) and
then serves the op history: the earlier messages of one thread, oldest
first, bounded by a limit. A kind that declares nothing is never asked.

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 fca8eacf2f
commit ca5c8e79e3
3 files changed
+119 -4

No files matched your search

+85 -3
View File
@@ -23,8 +23,9 @@ import (
// the add-on calls the host operation ConnectorInboundOp.
// - Outbound. The host calls "/connector" with the ops "send", "edit",
// "typing", "send_file" and "me", "media" to read a file a message
// carries, and "draft" for a kind that declares drafts. The host passes
// the token on every call; the add-on never stores it.
// carries, "draft" for a kind that declares drafts and "history" for a
// kind that declares history. The host passes the token on every call;
// the add-on never stores it.
// Delivery says how messages reach the host.
const (
@@ -55,6 +56,10 @@ const (
// that is not a message yet. The platform replaces it when the answer
// is sent. Only a kind with Drafts serves it.
ConnectorOpDraft = "draft"
// ConnectorOpHistory returns the earlier messages of the thread
// ConnectorRequest.Thread of Chat, oldest first. Only a kind with
// History serves it; the host never calls it for another kind.
ConnectorOpHistory = "history"
)
// Codes of a ConnectorError.
@@ -89,6 +94,17 @@ const (
// MinPreviewMs and MaxPreviewMs bound ConnectorKind.PreviewMs.
MinPreviewMs = 250
MaxPreviewMs = 10000
// MinHistoryAPI is the first manifest API version that knows the History
// ability of a connector kind.
MinHistoryAPI uint64 = 2
// DefaultHistoryMessages is how many messages the host asks the op
// history for when the kind declares no limit of its own.
DefaultHistoryMessages = 30
// MaxHistoryMessages bounds ConnectorHistory.MaxMessages and the Limit of
// a history request.
MaxHistoryMessages = 200
// MaxHistoryMedia is how many file names one history message carries.
MaxHistoryMedia = 8
)
// ConnectorKind is one kind of binding an add-on serves, such as telegram.
@@ -114,6 +130,35 @@ type ConnectorKind struct {
// of an answer (an edit or a draft) of one binding. Zero: the host's
// default, one second. Else MinPreviewMs to MaxPreviewMs.
PreviewMs int `json:"previewMs,omitempty"`
// History declares that the add-on serves the op history. A kind without
// it keeps the behaviour of a platform that cannot be asked for old
// messages. It needs manifest api MinHistoryAPI.
History *ConnectorHistory `json:"history,omitempty"`
}
// ConnectorHistory is the ability to read old messages of a platform.
type ConnectorHistory struct {
// Thread says that the op history returns the earlier messages of a
// thread. It must be true: it is the only ability so far.
Thread bool `json:"thread"`
// MaxMessages is the most messages one call returns. Zero: the host
// asks for DefaultHistoryMessages. At most MaxHistoryMessages.
MaxMessages int `json:"maxMessages,omitempty"`
}
// ServesThreadHistory reports whether the kind answers the op history for threads.
func (k ConnectorKind) ServesThreadHistory() bool { return k.History != nil && k.History.Thread }
// HistoryLimit is how many messages the host asks for at most: the limit it
// wants (zero: DefaultHistoryMessages), cut to what the kind declares.
func (k ConnectorKind) HistoryLimit(want int) int {
if want <= 0 || want > MaxHistoryMessages {
want = DefaultHistoryMessages
}
if k.History != nil && k.History.MaxMessages > 0 && k.History.MaxMessages < want {
want = k.History.MaxMessages
}
return want
}
// ConnectorCapability is the manifest field capabilities.connector.
@@ -121,7 +166,7 @@ type ConnectorCapability struct {
Kinds []ConnectorKind `json:"kinds"`
}
func validateConnector(c *ConnectorCapability, ui *UI) error {
func validateConnector(c *ConnectorCapability, ui *UI, api VersionRange) error {
if c == nil {
return nil
}
@@ -143,6 +188,14 @@ func validateConnector(c *ConnectorCapability, ui *UI) error {
if k.PreviewMs != 0 && (k.PreviewMs < MinPreviewMs || k.PreviewMs > MaxPreviewMs) {
return fmt.Errorf("connector previewMs must be 0 or %d to %d", MinPreviewMs, MaxPreviewMs)
}
if h := k.History; h != nil {
if api.Max < MinHistoryAPI {
return fmt.Errorf("connector history needs api %d", MinHistoryAPI)
}
if !h.Thread || h.MaxMessages < 0 || h.MaxMessages > MaxHistoryMessages {
return fmt.Errorf("connector history needs thread and maxMessages 0 to %d", MaxHistoryMessages)
}
}
if k.Page != "" {
ok := false
if ui != nil {
@@ -232,6 +285,12 @@ type ConnectorRequest struct {
DraftID int64 `json:"draftId,omitempty"`
// media: one reference of ConnectorMessage.Media.
Media string `json:"media,omitempty"`
// history: Chat and Thread name the thread. Limit is the most messages to
// return (1 to MaxHistoryMessages): the newest ones before Before. Before
// is the ID of the message that the host handles now; it and the later
// messages are not returned.
Limit int `json:"limit,omitempty"`
Before string `json:"before,omitempty"`
// send_file: the file. Its name is SafeFileName of itself and its data
// is 1 to MaxConnectorFileBytes long.
File *ConnectorFile `json:"file,omitempty"`
@@ -268,6 +327,29 @@ type ConnectorResponse struct {
BotUsername string `json:"botUsername,omitempty"`
// media
File *ConnectorFile `json:"file,omitempty"`
// history: the messages, oldest first, at most the Limit asked for.
// Truncated says that older messages exist that are not returned.
Messages []ConnectorHistoryMessage `json:"messages,omitempty"`
Truncated bool `json:"truncated,omitempty"`
}
// ConnectorHistoryMessage is one earlier message of a thread. The text is
// what other people wrote: the host treats it as untrusted content.
type ConnectorHistoryMessage struct {
MessageID string `json:"messageId,omitempty"`
SenderID string `json:"senderId,omitempty"`
SenderName string `json:"senderName,omitempty"`
SenderUsername string `json:"senderUsername,omitempty"`
Text string `json:"text,omitempty"`
// TS is the time of the message, when the platform tells it.
TS time.Time `json:"ts,omitzero"`
// Own is true for a message of the bot itself.
Own bool `json:"own,omitempty"`
// Media is how many files the message carries; MediaNames names them
// when the platform does, at most MaxHistoryMedia. The bytes are never
// part of a history.
Media int `json:"media,omitempty"`
MediaNames []string `json:"mediaNames,omitempty"`
}
// ConnectorRuntime is a runtime that serves the connector route. An add-on
+33
View File
@@ -42,6 +42,39 @@ var _ = Describe("the connector capability", func() {
m.Capabilities.Connector.Kinds[0].Page = ""
Expect(ValidateManifest(m, host())).To(Succeed())
})
Describe("the history ability", func() {
kind := func() *ConnectorKind { return &m.Capabilities.Connector.Kinds[0] }
BeforeEach(func() { m.API = VersionRange{Min: 1, Max: 2} })
It("accepts a thread history with the manifest api 2", func() {
kind().History = &ConnectorHistory{Thread: true, MaxMessages: 50}
Expect(ValidateManifest(m, host())).To(Succeed())
Expect(kind().ServesThreadHistory()).To(BeTrue())
})
It("refuses it under the manifest api 1", func() {
m.API = VersionRange{Min: 1, Max: 1}
kind().History = &ConnectorHistory{Thread: true}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
})
It("refuses an ability that says nothing and a limit out of bounds", func() {
kind().History = &ConnectorHistory{}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().History = &ConnectorHistory{Thread: true, MaxMessages: MaxHistoryMessages + 1}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().History = &ConnectorHistory{Thread: true, MaxMessages: -1}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
})
It("serves nothing for a kind that declares nothing", func() {
Expect(kind().ServesThreadHistory()).To(BeFalse())
})
It("cuts the limit to what the kind declares", func() {
k := ConnectorKind{History: &ConnectorHistory{Thread: true, MaxMessages: 10}}
Expect(k.HistoryLimit(0)).To(Equal(10))
Expect(k.HistoryLimit(5)).To(Equal(5))
Expect(k.HistoryLimit(500)).To(Equal(10))
Expect(ConnectorKind{History: &ConnectorHistory{Thread: true}}.HistoryLimit(0)).To(Equal(DefaultHistoryMessages))
})
})
DescribeTable("refuses",
func(edit func(*ConnectorCapability)) {
edit(m.Capabilities.Connector)
+1 -1
View File
@@ -403,7 +403,7 @@ func ValidateManifest(m Manifest, h HostCompatibility) error {
if e := validateResourceList(c, m.API); e != nil {
return e
}
if e := validateConnector(c.Connector, c.UI); e != nil {
if e := validateConnector(c.Connector, c.UI, m.API); e != nil {
return e
}
if e := validateSkillDefinitions(m.ID, c.SkillDefinitions); e != nil {