mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(connectors): let a credentials-only kind have bindings without an agent
A connector kind can declare agentOptional. A binding of such a kind may have no agent: it holds the token, the resource list and the write mode for the tools of a chat, and never receives or answers a message. The GitHub kind declares it, so a person no longer makes a throwaway agent only to store a token. Without an agent the host refuses every setting that needs one: listening, accepting senders, an allowlist, a notification chat, talk-back channels, permissions, pairing and trigger approval. The places that read the agent of a binding handle the empty value: inbound routing, the polling loop, notifications, pairing, the capability check (deny by default) and the grant switch. A kind that does not declare the key still needs an agent on every binding. The binding page makes the agent field optional for such a kind with help text, and the list shows "Credentials only". Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo node protoc swag
This commit is contained in:
25 files changed
+529
-43
No files matched your search
@@ -578,6 +578,7 @@
|
||||
"description": "Let your agents read the repositories you choose, and comment on issues and pull requests if you allow it, with a fine-grained access token.",
|
||||
"senderHint": "Not used yet: this connection does not listen for mentions",
|
||||
"delivery": "pull",
|
||||
"agentOptional": true,
|
||||
"page": "apps",
|
||||
"roles": [
|
||||
{
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"reposMany": "At most 50 repositories.",
|
||||
"agent": "Agent",
|
||||
"agentNone": "Choose an agent",
|
||||
"agentCredentialsOnly": "No agent (credentials only)",
|
||||
"agentOptionalHint": "Optional. Without an agent this connection only holds the token, the repository list and the write mode for your chats. It answers no messages and listens for nothing.",
|
||||
"credentialsOnly": "Credentials only",
|
||||
"agentNoModel": "{{name}} (needs a model)",
|
||||
"test": "Test",
|
||||
"testOk": "Token of {{bot}}.",
|
||||
|
||||
@@ -72,3 +72,11 @@ test('github: every write mode has its words, and the note says the text is publ
|
||||
assert.match(en.writeMode.note.propose, /public/)
|
||||
assert.match(en.writeMode.note.auto, /public/)
|
||||
})
|
||||
|
||||
test('github: a connection can be saved without an agent, and the page has words for it', () => {
|
||||
const form = { ...emptyConnector(), name: 'Work', token: 'github_pat_x' }
|
||||
assert.equal(canSave(form, false, true), false, 'a kind that needs an agent refuses an empty one')
|
||||
assert.equal(canSave(form, false, true, null, true), true, 'agentOptional lets the connection hold credentials only')
|
||||
assert.equal(connectorPayload(form, 'github', 'github_pat_x', { enabled: false }).agent_id, '')
|
||||
for (const key of ['agentCredentialsOnly', 'agentOptionalHint', 'credentialsOnly']) assert.ok(en[key], key)
|
||||
})
|
||||
@@ -98,6 +98,8 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
const [capabilities, setCapabilities] = useState([])
|
||||
const [accessVersion, setAccessVersion] = useState(0)
|
||||
const kindFiles = kindInfo?.files || null
|
||||
// A kind that declares agent_optional lets a binding hold credentials only.
|
||||
const agentOptional = Boolean(kindInfo?.agent_optional)
|
||||
const s = answerT(t)
|
||||
const pairedChanged = useCallback(() => setAccessVersion((v) => v + 1), [])
|
||||
|
||||
@@ -210,10 +212,10 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
{(p) => <TextInput {...p} value={form.name} required maxLength={80} onChange={(e) => setForm({ ...form, name: e.target.value })} />}
|
||||
</Field>
|
||||
<TokenFields form={form} setForm={setForm} t={t} />
|
||||
<Field label={t('agent')}>
|
||||
<Field label={t('agent')} hint={agentOptional ? t('agentOptionalHint', { defaultValue: 'Optional. Without an agent this connection only holds the token, the repository list and the write mode for your chats. It answers no messages. Choose an agent to let it listen.' }) : undefined} optional={agentOptional}>
|
||||
{(p) => (
|
||||
<Select {...p} aria-label={t('agent')} value={form.agent_id} required onChange={(e) => setForm({ ...form, agent_id: e.target.value })}>
|
||||
<option value="">{t('agentNone')}</option>
|
||||
<Select {...p} aria-label={t('agent')} value={form.agent_id} required={!agentOptional} onChange={(e) => setForm({ ...form, agent_id: e.target.value })}>
|
||||
<option value="">{agentOptional ? t('agentCredentialsOnly', { defaultValue: 'No agent (credentials only)' }) : t('agentNone')}</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id} disabled={!a.model}>{a.model ? a.name : t('agentNoModel', { name: a.name })}</option>
|
||||
))}
|
||||
@@ -247,7 +249,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
{capabilities.length > 0 && <AccessView binding={form} capabilities={capabilities} s={s} version={accessVersion} />}
|
||||
</>)}
|
||||
<div className="cn-actions">
|
||||
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
|
||||
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles, agentOptional) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
|
||||
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => setForm(null)}>{t('cancel')}</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -271,12 +273,14 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
|
||||
<span className="cn-row__name">{c.name}</span>
|
||||
{messaging ? (<>
|
||||
<span className="cn-muted">
|
||||
{t('agentOf', { name: agentName(c.agent_id) })}
|
||||
{c.credentials_only ? t('credentialsOnly', { defaultValue: 'Credentials only' }) : t('agentOf', { name: agentName(c.agent_id) })}
|
||||
{' · '}
|
||||
{t('accepts', { who: c.accept_any ? t('modes.anyone') : (c.allow || []).length === 0 ? t('modes.nobody') : t('modes.listed', { count: c.allow.length }) })}
|
||||
</span>
|
||||
<span className="cn-muted" data-testid="connector-answer-summary">{answerSummary(c, t)}</span>
|
||||
</>) : <span className="cn-muted">{t('agentOf', { name: agentName(c.agent_id) })}</span>}
|
||||
</>) : <span className="cn-muted" data-testid={c.credentials_only ? 'connector-credentials-only' : undefined}>
|
||||
{c.credentials_only ? t('credentialsOnly', { defaultValue: 'Credentials only' }) : t('agentOf', { name: agentName(c.agent_id) })}
|
||||
</span>}
|
||||
{RowInfo && <RowInfo c={c} t={t} />}
|
||||
{messaging && capabilities.length > 0 && hasNoCaps(c) && (
|
||||
<NoCapsNotice capabilities={capabilities} s={s} busy={busy} name={c.name} onUsual={() => allowUsual(c)} />
|
||||
|
||||
@@ -239,8 +239,8 @@ export function senderLine(s) {
|
||||
|
||||
// "Accept any message" is only saved after the person confirms the warning.
|
||||
// hasToken says whether the add-on's fields hold a complete secret.
|
||||
export function canSave(form, confirmed, hasToken = Boolean((form.token || '').trim()), files = null) {
|
||||
if (!(form.name || '').trim() || !form.agent_id) return false
|
||||
export function canSave(form, confirmed, hasToken = Boolean((form.token || '').trim()), files = null, agentOptional = false) {
|
||||
if (!(form.name || '').trim() || (!form.agent_id && !agentOptional)) return false
|
||||
if (!form.id && !hasToken) return false
|
||||
if (form.accept_any && !confirmed) return false
|
||||
if (approvalProblem(form.approval)) return false
|
||||
|
||||
@@ -22,7 +22,7 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
|
||||
if allow == nil {
|
||||
allow = []string{}
|
||||
}
|
||||
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,
|
||||
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, CredentialsOnly: i.CredentialsOnly(), Enabled: i.Enabled, AcceptAny: i.AcceptAny,
|
||||
Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created,
|
||||
ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), Options: i.Options,
|
||||
Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes(), Approval: approvalOut(i.Approval)}
|
||||
@@ -190,7 +190,7 @@ 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, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options),
|
||||
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, AgentOptional: k.AgentOptional, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options),
|
||||
Files: filesOut(host.KindFileLimits(c.Request().Context(), k)), Roles: rolesOut(k.ConnectorKind), Permissions: permissionsOut(k.ConnectorKind)})
|
||||
}
|
||||
for _, ci := range contract.ConnectorCapabilities() {
|
||||
|
||||
+18
-11
@@ -1220,17 +1220,21 @@ type ChatsPushUnsubscribe struct {
|
||||
// ChatsConnector is a connector binding: a bot bound to one of your saved agents.
|
||||
// The bot token is never returned; has_token says whether one is stored.
|
||||
type ChatsConnector struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Addon string `json:"addon"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AcceptAny bool `json:"accept_any"`
|
||||
Allow []string `json:"allow"`
|
||||
NotifyChat string `json:"notify_chat,omitempty"`
|
||||
HasToken bool `json:"has_token"`
|
||||
Created time.Time `json:"created"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Addon string `json:"addon"`
|
||||
// AgentID is empty for a binding of a kind that declares agent_optional.
|
||||
AgentID string `json:"agent_id"`
|
||||
// CredentialsOnly is true for a binding without an agent: it holds the
|
||||
// token, the resource list and the write mode, and never answers messages.
|
||||
CredentialsOnly bool `json:"credentials_only,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AcceptAny bool `json:"accept_any"`
|
||||
Allow []string `json:"allow"`
|
||||
NotifyChat string `json:"notify_chat,omitempty"`
|
||||
HasToken bool `json:"has_token"`
|
||||
Created time.Time `json:"created"`
|
||||
// ReplyMode is what a sender sees while a turn runs: off (typing, then
|
||||
// the answer), progress (a working message that names the tools as they
|
||||
// start) or stream (a working message that shows the answer as it is
|
||||
@@ -1422,6 +1426,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"`
|
||||
// AgentOptional is true when a binding of this kind may have no agent. It
|
||||
// then only holds credentials (token, resource list, write mode).
|
||||
AgentOptional bool `json:"agent_optional,omitempty"`
|
||||
// ThreadHistory is true when the platform can give the earlier messages
|
||||
// of a thread. The host then reads them whenever the bot is mentioned in a
|
||||
// thread.
|
||||
|
||||
@@ -59,6 +59,8 @@ func Authorize(b connectors.Binding, kind *contract.ConnectorKind, s AccessSubje
|
||||
return Decision{Capability: capability, Reason: fmt.Sprintf(format, a...)}
|
||||
}
|
||||
switch {
|
||||
case b.CredentialsOnly():
|
||||
return deny("this connection has no agent: it holds credentials only")
|
||||
case !contract.KnownCapability(capability):
|
||||
return deny("%q is not a capability", capability)
|
||||
case !s.Allowlisted:
|
||||
|
||||
@@ -34,7 +34,7 @@ var _ = Describe("Authorize", func() {
|
||||
// than it may: a capability above the ceiling, one the kind does not
|
||||
// list, and two sensitive ones.
|
||||
binding := connectors.Binding{
|
||||
ID: "b1", Kind: "chat", Caps: except(contract.CapFilesUpload),
|
||||
ID: "b1", Kind: "chat", AgentID: "a1", Caps: except(contract.CapFilesUpload),
|
||||
Grants: []connectors.RoleGrant{{Role: "admin", Caps: []string{
|
||||
contract.CapMemoryWrite, contract.CapTriggersPropose, contract.CapFilesUpload,
|
||||
contract.CapTalkbackChannels, contract.CapTalkbackNewChan, contract.CapProfileWrite,
|
||||
|
||||
@@ -76,7 +76,7 @@ func agentGrantSwitch(s *Service) CapabilitySwitch {
|
||||
return ""
|
||||
}
|
||||
if b.AgentID == "" {
|
||||
return grantVerdict(capability, nil, nil, false)
|
||||
return "this connection has no agent"
|
||||
}
|
||||
if s.d.Agents == nil {
|
||||
return "the agent of this bot cannot be read"
|
||||
|
||||
@@ -73,7 +73,7 @@ var _ = Describe("the agent grants at the connector gate", func() {
|
||||
kind := &contract.ConnectorKind{ID: "chat", Roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}},
|
||||
Permissions: []string{contract.CapTriggersPropose, contract.CapTalkbackChannels}}
|
||||
binding := func(caps ...string) connectors.Binding {
|
||||
return connectors.Binding{ID: "b", Kind: "chat", Caps: caps, Paired: paired,
|
||||
return connectors.Binding{ID: "b", Kind: "chat", AgentID: "a1", Caps: caps, Paired: paired,
|
||||
Grants: []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapTriggersPropose, contract.CapTalkbackChannels}}}}
|
||||
}
|
||||
type row struct {
|
||||
|
||||
@@ -69,6 +69,9 @@ func (c *Connectors) StartPairing(ctx context.Context, user, id string) (Pairing
|
||||
if err != nil {
|
||||
return PairingCode{}, err
|
||||
}
|
||||
if b.CredentialsOnly() {
|
||||
return PairingCode{}, connectorBad("pairing needs an agent: a connection without an agent receives no messages")
|
||||
}
|
||||
code, err := newPairingCode()
|
||||
if err != nil {
|
||||
return PairingCode{}, err
|
||||
@@ -181,6 +184,9 @@ func (c *Connectors) addPaired(ctx context.Context, id, senderID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if b.CredentialsOnly() {
|
||||
return connectors.ErrForbidden
|
||||
}
|
||||
now := c.o.Clock().UTC()
|
||||
b.Paired = slices.DeleteFunc(b.Paired, func(p connectors.PairedSender) bool {
|
||||
return p.SenderID == senderID || now.Sub(p.At) >= connectors.PairedTTL
|
||||
|
||||
@@ -28,7 +28,7 @@ var _ = Describe("the roles of an inbound message", func() {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return c
|
||||
}
|
||||
b := connectors.Binding{ID: "b1", Kind: "chat", AddonID: "one"}
|
||||
b := connectors.Binding{ID: "b1", Kind: "chat", AddonID: "one", AgentID: "a1"}
|
||||
ctx := context.Background()
|
||||
|
||||
It("keeps the roles that the kind declares and drops the others", func() {
|
||||
|
||||
@@ -306,6 +306,10 @@ func (c *Connectors) Sync(ctx context.Context) {
|
||||
// A loop runs only for a binding whose add-on is running and pulls.
|
||||
want := map[string]connectors.Binding{}
|
||||
for _, b := range enabled {
|
||||
// A binding without an agent has nothing to answer with: no loop.
|
||||
if b.CredentialsOnly() {
|
||||
continue
|
||||
}
|
||||
if k, ok := kindFor(kinds, b); ok && k.Delivery == contract.DeliveryPull {
|
||||
want[b.ID] = b
|
||||
}
|
||||
@@ -360,7 +364,7 @@ func (c *Connectors) poll(ctx context.Context, id string) {
|
||||
poll:
|
||||
for ctx.Err() == nil && !c.closed() {
|
||||
b, err := c.o.Store.GetBinding(ctx, id)
|
||||
if err != nil || !b.Enabled {
|
||||
if err != nil || !b.Enabled || b.CredentialsOnly() {
|
||||
return
|
||||
}
|
||||
token, err := c.openToken(ctx, b)
|
||||
@@ -564,7 +568,7 @@ func (c *Connectors) Ingest(ctx context.Context, addonID string, in contract.Con
|
||||
return fmt.Errorf("studio: read a connector binding: %w", err)
|
||||
}
|
||||
// A missing binding and another add-on's binding look the same to the caller.
|
||||
if err != nil || b.AddonID != addonID || addonID == "" || !b.Enabled {
|
||||
if err != nil || b.AddonID != addonID || addonID == "" || !b.Enabled || b.CredentialsOnly() {
|
||||
return connectors.ErrForbidden
|
||||
}
|
||||
m := in.ConnectorMessage
|
||||
@@ -691,6 +695,9 @@ func (c *Connectors) windowEnded(ctx context.Context, b connectors.Binding, m co
|
||||
|
||||
// handle takes one message of a sender.
|
||||
func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token string, m contract.ConnectorMessage) {
|
||||
if b.CredentialsOnly() {
|
||||
return // Ingest refuses it first; this keeps any other caller from answering
|
||||
}
|
||||
senderID := m.SenderID
|
||||
// A message is answered when it is direct, or names the bot in a group.
|
||||
// An entry of the allowlist names the sender or the chat: a listed group
|
||||
@@ -1197,7 +1204,7 @@ func (c *Connectors) Forward(ctx context.Context, n notify.Notification) {
|
||||
}
|
||||
text := strings.TrimSpace(n.Title + "\n" + n.Body)
|
||||
for _, b := range bindings {
|
||||
if !b.Enabled || b.NotifyChat == "" {
|
||||
if !b.Enabled || b.NotifyChat == "" || b.CredentialsOnly() {
|
||||
continue
|
||||
}
|
||||
token, err := c.openToken(ctx, b)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package connectors_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
)
|
||||
|
||||
var _ = Describe("a binding without an agent", func() {
|
||||
bare := func() connectors.Binding { return connectors.Binding{Name: "Tokens", Kind: "github"} }
|
||||
|
||||
It("is refused by Validate and by a kind that does not declare agentOptional", func() {
|
||||
Expect(connectors.Validate(bare())).To(MatchError(connectors.ErrInvalid))
|
||||
Expect(connectors.ValidateKind(bare(), false)).To(MatchError(ContainSubstring("agent is required")))
|
||||
})
|
||||
It("is accepted by a kind that declares agentOptional while it sets nothing an agent needs", func() {
|
||||
b := bare()
|
||||
b.WriteMode = connectors.WritePropose
|
||||
b.Resources = connectors.NewResources([]string{"acme/widgets"})
|
||||
Expect(connectors.ValidateKind(b, true)).To(Succeed())
|
||||
Expect(b.CredentialsOnly()).To(BeTrue())
|
||||
})
|
||||
DescribeTable("needs an agent for",
|
||||
func(mod func(*connectors.Binding)) {
|
||||
b := bare()
|
||||
mod(&b)
|
||||
Expect(connectors.ValidateKind(b, true)).To(MatchError(connectors.ErrInvalid))
|
||||
b.AgentID = "a1"
|
||||
Expect(connectors.ValidateKind(b, true)).To(Succeed())
|
||||
},
|
||||
Entry("listening", func(b *connectors.Binding) { b.Enabled = true }),
|
||||
Entry("accepting anyone", func(b *connectors.Binding) { b.AcceptAny = true }),
|
||||
Entry("an allowlist", func(b *connectors.Binding) { b.Allow = []string{"11"} }),
|
||||
Entry("a notification chat", func(b *connectors.Binding) { b.NotifyChat = "11" }),
|
||||
Entry("channels", func(b *connectors.Binding) { b.Channels = []connectors.Channel{{ID: "C1"}} }),
|
||||
Entry("a capability ceiling", func(b *connectors.Binding) { b.Caps = []string{"memory.write"} }),
|
||||
Entry("pairing", func(b *connectors.Binding) { b.Paired = []connectors.PairedSender{{SenderID: "11"}} }),
|
||||
Entry("trigger approval", func(b *connectors.Binding) { b.Approval = connectors.Approval{Mode: connectors.ApprovalAutoReadonly} }),
|
||||
)
|
||||
})
|
||||
@@ -130,7 +130,11 @@ type Binding struct {
|
||||
// before connectors became add-ons has none; Normalize gives it the add-on
|
||||
// of its kind.
|
||||
AddonID string
|
||||
// AgentID is required: a binding never inherits the tools of a chat.
|
||||
// AgentID is required: a binding never inherits the tools of a chat. The
|
||||
// one exception is a kind that declares agentOptional (see
|
||||
// contract.ConnectorKind.AgentOptional): its binding may have no agent. It
|
||||
// then only holds the credential, the resource list and the write mode, and
|
||||
// receives and answers nothing (see CredentialsOnly).
|
||||
AgentID string
|
||||
Enabled bool
|
||||
// AcceptAny lets anyone who can message the bot use the agent. Otherwise only
|
||||
@@ -317,8 +321,45 @@ var kindShape = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`)
|
||||
// chatShape is a chat that notifications go to: a numeric ID or a platform ID.
|
||||
var chatShape = regexp.MustCompile(`^(-?[0-9]{1,20}|[A-Z][A-Z0-9]{1,31})$`)
|
||||
|
||||
// Validate checks a binding. The error wraps ErrInvalid.
|
||||
func Validate(b Binding) error {
|
||||
// CredentialsOnly reports whether the binding has no agent. Such a binding
|
||||
// holds a credential for the tools of a chat and never receives or answers a
|
||||
// message. Every reader of AgentID must treat it as unable to listen, answer,
|
||||
// deliver or be authorized.
|
||||
func (b Binding) CredentialsOnly() bool { return b.AgentID == "" }
|
||||
|
||||
// needsAgent names the first setting of b that only makes sense when an agent
|
||||
// answers, or "". A binding without an agent must not set any of them.
|
||||
func needsAgent(b Binding) string {
|
||||
switch {
|
||||
case b.Enabled:
|
||||
return "listening"
|
||||
case b.AcceptAny:
|
||||
return "accepting anyone"
|
||||
case len(b.Allow) > 0:
|
||||
return "the allowlist"
|
||||
case b.NotifyChat != "":
|
||||
return "the notification chat"
|
||||
case len(b.Channels) > 0:
|
||||
return "the talk-back channels"
|
||||
case len(b.Caps) > 0 || len(b.Grants) > 0:
|
||||
return "permissions"
|
||||
case len(b.Paired) > 0:
|
||||
return "pairing"
|
||||
case b.Approval != (Approval{}):
|
||||
return "trigger approval"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Validate checks a binding of a kind that needs an agent. The error wraps
|
||||
// ErrInvalid.
|
||||
func Validate(b Binding) error { return ValidateKind(b, false) }
|
||||
|
||||
// ValidateKind checks a binding. With agentOptional (the kind declares it) a
|
||||
// binding may have no agent, but only while it sets nothing that needs one: a
|
||||
// binding that listens, accepts senders, notifies, talks back or carries
|
||||
// permissions must name an agent.
|
||||
func ValidateKind(b Binding, agentOptional bool) error {
|
||||
bad := func(format string, a ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, a...))
|
||||
}
|
||||
@@ -329,7 +370,12 @@ func Validate(b Binding) error {
|
||||
return bad("the kind must be the ID of a connector kind")
|
||||
}
|
||||
if b.AgentID == "" {
|
||||
return bad("an agent is required: a connector never uses the tools of a chat")
|
||||
if !agentOptional {
|
||||
return bad("an agent is required: a connector never uses the tools of a chat")
|
||||
}
|
||||
if what := needsAgent(b); what != "" {
|
||||
return bad("%s needs an agent: a connection without an agent only holds credentials", what)
|
||||
}
|
||||
}
|
||||
if len(b.Allow) > MaxAllow {
|
||||
return bad("the allowlist has more than %d entries", MaxAllow)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package studio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAI/addons/telegram/transport"
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/notify"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/tests/studiobot"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// A kind that declares agentOptional gives bindings that only hold
|
||||
// credentials. Every place that reads the agent of a binding must treat such a
|
||||
// binding as unable to receive, answer, deliver or be authorized.
|
||||
var _ = Describe("a credentials-only connector binding", func() {
|
||||
ctx := context.Background()
|
||||
var (
|
||||
r *connRig
|
||||
st connectors.Store
|
||||
b connectors.Binding
|
||||
)
|
||||
newRig := func(optional bool) {
|
||||
st = connStore()
|
||||
bot := newScriptedBot()
|
||||
addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()})
|
||||
addon.Kind.AgentOptional = optional
|
||||
r = newConnRigWith(newShared(), st, studiobot.NewTransport(addon), connSay("never"), nil)
|
||||
r.bot, r.untrusted = bot, true
|
||||
}
|
||||
credOnly := func(in studio.ConnectorInput) studio.ConnectorInput {
|
||||
in.Kind, in.Name, in.Token = "telegram", "Tokens", botToken
|
||||
return in
|
||||
}
|
||||
// stored writes an agentless binding that is switched on, as a store could
|
||||
// hold it after an edit by hand or a downgrade.
|
||||
stored := func() connectors.Binding {
|
||||
got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{Resources: []string{"acme/widgets"}}))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
raw, err := st.GetBinding(ctx, got.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
raw.Enabled, raw.AcceptAny = true, true
|
||||
Expect(st.PutBinding(ctx, raw)).To(Succeed())
|
||||
return raw
|
||||
}
|
||||
|
||||
Describe("creating and editing", func() {
|
||||
It("is refused when the kind does not declare agentOptional", func() {
|
||||
newRig(false)
|
||||
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
|
||||
Expect(err).To(MatchError(connectors.ErrInvalid))
|
||||
Expect(err).To(MatchError(ContainSubstring("agent is required")))
|
||||
})
|
||||
It("is accepted when the kind declares it, with no agent and listening off", func() {
|
||||
newRig(true)
|
||||
got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{Resources: []string{"acme/widgets"}}))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(got.AgentID).To(BeEmpty())
|
||||
Expect(got.CredentialsOnly()).To(BeTrue())
|
||||
grant, err := r.conns.CredentialFor(ctx, alice, got.AddonID, "acme/widgets")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(grant.Secret).To(Equal(botToken))
|
||||
})
|
||||
DescribeTable("needs an agent for what only an agent can use",
|
||||
func(mod func(*studio.ConnectorInput), words string) {
|
||||
newRig(true)
|
||||
in := credOnly(studio.ConnectorInput{})
|
||||
mod(&in)
|
||||
_, err := r.conns.CreateConnector(ctx, alice, in)
|
||||
Expect(err).To(MatchError(connectors.ErrInvalid))
|
||||
Expect(err).To(MatchError(ContainSubstring(words)))
|
||||
},
|
||||
Entry("listening", func(in *studio.ConnectorInput) { in.Enabled = true }, "listening needs an agent"),
|
||||
Entry("accepting anyone", func(in *studio.ConnectorInput) { in.AcceptAny = true }, "accepting anyone"),
|
||||
Entry("an allowlist", func(in *studio.ConnectorInput) { in.Allow = []string{"11"} }, "the allowlist"),
|
||||
Entry("a notification chat", func(in *studio.ConnectorInput) { in.NotifyChat = "11" }, "the notification chat"),
|
||||
Entry("talk-back channels", func(in *studio.ConnectorInput) {
|
||||
in.Channels = []connectors.Channel{{ID: "C1", Approved: true}}
|
||||
}, "the talk-back channels"),
|
||||
Entry("permissions", func(in *studio.ConnectorInput) { in.Caps = []string{contract.CapMemoryWrite} }, "permissions"),
|
||||
)
|
||||
It("cannot be switched on, and an agent can be added later", func() {
|
||||
newRig(true)
|
||||
got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{}))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = r.conns.SetConnectorEnabled(ctx, alice, got.ID, true)
|
||||
Expect(err).To(MatchError(connectors.ErrInvalid))
|
||||
on, err := r.conns.UpdateConnector(ctx, alice, got.ID, credOnly(studio.ConnectorInput{AgentID: r.agentID(), Enabled: true, AcceptAny: true}))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(on.CredentialsOnly()).To(BeFalse())
|
||||
Expect(on.Enabled).To(BeTrue())
|
||||
})
|
||||
It("refuses an agent that is not the owner's", func() {
|
||||
newRig(true)
|
||||
_, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{AgentID: "someone-elses"}))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("a record that is on without an agent", func() {
|
||||
BeforeEach(func() { newRig(true); b = stored() })
|
||||
|
||||
It("receives nothing: Ingest refuses it and no sender is logged", func() {
|
||||
err := r.conns.Ingest(ctx, b.AddonID, contract.ConnectorInbound{BindingID: b.ID,
|
||||
ConnectorMessage: contract.ConnectorMessage{Chat: "11", Direct: true, SenderID: "11", Text: "hi"}})
|
||||
Expect(err).To(MatchError(connectors.ErrForbidden))
|
||||
senders, _ := st.Senders(ctx, b.ID, 10)
|
||||
Expect(senders).To(BeEmpty())
|
||||
chats, _ := r.svc.List(ctx, alice, 10)
|
||||
Expect(chats).To(BeEmpty())
|
||||
})
|
||||
It("starts no polling loop", func() {
|
||||
r.conns.Sync(ctx)
|
||||
Consistently(func() int { return len(r.bot.answers()) }, "300ms").Should(BeZero())
|
||||
})
|
||||
It("denies every capability, also for a paired sender with the ceiling set", func() {
|
||||
b.Caps = contract.ConnectorCapabilityNames()
|
||||
b.Paired = []connectors.PairedSender{{SenderID: "11", At: b.Created}}
|
||||
for _, name := range contract.ConnectorCapabilityNames() {
|
||||
d := studio.Authorize(b, nil, studio.AccessSubject{SenderID: "11", Allowlisted: true}, name, b.Updated, "")
|
||||
Expect(d.Allowed).To(BeFalse(), name)
|
||||
Expect(d.Reason).To(ContainSubstring("no agent"))
|
||||
}
|
||||
})
|
||||
It("gives no pairing code", func() {
|
||||
_, err := r.conns.StartPairing(ctx, alice, b.ID)
|
||||
Expect(err).To(MatchError(connectors.ErrInvalid))
|
||||
})
|
||||
It("is not told a notification", func() {
|
||||
raw, _ := st.GetBinding(ctx, b.ID)
|
||||
raw.NotifyChat = "11"
|
||||
Expect(st.PutBinding(ctx, raw)).To(Succeed())
|
||||
r.conns.Forward(ctx, notify.Notification{Principal: alice, Title: "t", Body: "b"})
|
||||
Consistently(func() int { return len(r.bot.answers()) }, "300ms").Should(BeZero())
|
||||
})
|
||||
It("still gives its token to a tool call", func() {
|
||||
grant, err := r.conns.CredentialFor(ctx, alice, b.AddonID, "acme/widgets")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(grant.Secret).To(Equal(botToken))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -312,6 +312,23 @@ func (c *Connectors) checkAgent(ctx context.Context, owner, agentID string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAgentIfAny is checkAgent for a binding that has an agent. The binding
|
||||
// was validated first, so an empty ID here belongs to a kind that declares
|
||||
// agentOptional.
|
||||
func (c *Connectors) checkAgentIfAny(ctx context.Context, owner, agentID string) error {
|
||||
if agentID == "" {
|
||||
return nil
|
||||
}
|
||||
return c.checkAgent(ctx, owner, agentID)
|
||||
}
|
||||
|
||||
// agentOptional says whether the running kind of the binding lets it have no
|
||||
// agent. A kind whose add-on does not run cannot say, so it needs an agent.
|
||||
func (c *Connectors) agentOptional(ctx context.Context, b connectors.Binding) bool {
|
||||
k := c.kindOf(ctx, b)
|
||||
return k != nil && k.AgentOptional
|
||||
}
|
||||
|
||||
func (c *Connectors) owned(ctx context.Context, user, id string) (connectors.Binding, error) {
|
||||
b, err := c.o.Store.GetBinding(ctx, id)
|
||||
if err != nil || b.Owner != principal(user) {
|
||||
@@ -333,7 +350,7 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
|
||||
}
|
||||
b := connectors.Binding{ID: newID(), Owner: owner, Kind: kind, AddonID: addon}
|
||||
in.apply(&b)
|
||||
if err := connectors.Validate(b); err != nil {
|
||||
if err := connectors.ValidateKind(b, c.agentOptional(ctx, b)); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := c.checkOptions(ctx, b); err != nil {
|
||||
@@ -348,7 +365,7 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
|
||||
if strings.TrimSpace(in.Token) == "" {
|
||||
return ConnectorInfo{}, connectorBad("the bot token is required")
|
||||
}
|
||||
if err := c.checkAgent(ctx, owner, in.AgentID); err != nil {
|
||||
if err := c.checkAgentIfAny(ctx, owner, in.AgentID); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
// Before the write lock: the check asks the platform.
|
||||
@@ -401,7 +418,7 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
|
||||
}
|
||||
b := old
|
||||
in.apply(&b)
|
||||
if err := connectors.Validate(b); err != nil {
|
||||
if err := connectors.ValidateKind(b, c.agentOptional(ctx, b)); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := c.checkOptions(ctx, b); err != nil {
|
||||
@@ -413,7 +430,7 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
|
||||
if err := c.checkOwnerLimits(ctx, b, in); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if err := c.checkAgent(ctx, old.Owner, in.AgentID); err != nil {
|
||||
if err := c.checkAgentIfAny(ctx, old.Owner, in.AgentID); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if strings.TrimSpace(in.Token) != "" {
|
||||
@@ -441,6 +458,9 @@ func (c *Connectors) SetConnectorEnabled(ctx context.Context, user, id string, o
|
||||
if err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
}
|
||||
if on && b.CredentialsOnly() {
|
||||
return ConnectorInfo{}, connectorBad("listening needs an agent: a connection without an agent only holds credentials")
|
||||
}
|
||||
b.Enabled, b.Updated = on, c.o.Clock().UTC()
|
||||
if err := c.o.Store.PutBinding(ctx, b); err != nil {
|
||||
return ConnectorInfo{}, err
|
||||
|
||||
@@ -2282,6 +2282,7 @@ The manifest declares the capability. It needs no tool and no `service` flag:
|
||||
| `kinds[].title`, `description`, `senderHint` | Plain text for people, with the bounds of the UI texts. |
|
||||
| `kinds[].delivery` | `pull`: the host asks for updates (long polling). `push`: the add-on calls the host operation `connector.inbound`. |
|
||||
| `kinds[].page` | Optional. The add-on page that adds and edits bindings; it must be a declared page. The Connectors page links to it. |
|
||||
| `kinds[].agentOptional` | Optional, manifest `api` 2. `true` lets a binding of the kind have no agent. Such a binding is credentials only: it holds the token, the resource list and the write mode, and the host never delivers a message to it, polls for it, answers from it or authorizes a sender on it. The host refuses an empty agent together with listening, accepting senders, an allowlist, a notification chat, talk-back channels, permissions, pairing or trigger approval. Without the key every binding of the kind needs an agent. |
|
||||
| `kinds[].drafts` | Optional. `true` when the add-on serves the op `draft`. The host then shows the live answer of a direct conversation as a draft instead of a working message. |
|
||||
| `kinds[].previewMs` | Optional. The shortest time between two previews (edits or drafts) of one binding, 250 to 10000 ms. Without it the host waits one second. |
|
||||
| `kinds[].history` | Optional, manifest `api` 2. `{"thread": true, "maxMessages": N}`: the add-on serves the op `history` for threads, and returns at most `maxMessages` (1 to 200; without it the host asks for 30). A kind without it is never asked, so it keeps the behaviour of a platform that cannot give old messages. |
|
||||
|
||||
@@ -140,7 +140,9 @@ The GitHub add-on lets your [saved agents](/features/chats-agents/) read reposit
|
||||
|
||||
1. Install the **GitHub** add-on from the add-on gallery.
|
||||
2. On GitHub, make a **fine-grained personal access token** for only the repositories you want to share. Give it read access to Contents, Issues and Pull requests. To let the agent write, also give read and write access to Issues and Pull requests. Classic tokens are not accepted.
|
||||
3. On the add-on page, choose **New GitHub connection**. Pick an agent, paste the token and list the repositories, one `owner/name` per line (at most 50). Press **Test** to see whose token it is and which listed repositories it reaches.
|
||||
3. On the add-on page, choose **New GitHub connection**. Paste the token and list the repositories, one `owner/name` per line (at most 50). Press **Test** to see whose token it is and which listed repositories it reaches.
|
||||
|
||||
The agent field is optional. A connection without an agent shows **Credentials only** in the list: it holds the token, the repository list and the write mode for your chats, and it never answers a message. It cannot be switched on, take an allowlist, send notifications or hold permissions; the server refuses those with a message that asks for an agent. This is the manifest key `agentOptional` of the kind (see [Connector add-ons](/features/addons/)), which the GitHub kind declares; Telegram and Slack bindings always need an agent. Over the API, `agent_id` may be empty for such a kind and the connection reports `credentials_only: true`.
|
||||
|
||||
The list is checked by LocalAI before the add-on is called, so an agent is refused any repository that is not on it, even if the token could reach it. The token is kept sealed and is never shown again, and no tool result contains it. With several connections, a call uses the first one that lists the repository.
|
||||
|
||||
|
||||
@@ -117,6 +117,11 @@ type ConnectorKind struct {
|
||||
// Page is the add-on page that adds and edits bindings of this kind. It
|
||||
// must be one of the pages of the add-on's UI.
|
||||
Page string `json:"page,omitempty"`
|
||||
// AgentOptional lets a binding of this kind have no agent. Such a binding
|
||||
// is credentials only: it holds the token, the resource list and the write
|
||||
// mode for the tools of a chat, and the host never delivers a message to it
|
||||
// or answers one from it. A kind without it needs an agent on every binding.
|
||||
AgentOptional bool `json:"agentOptional,omitempty"`
|
||||
// Drafts tells that the add-on serves the op draft. The host then shows
|
||||
// a live answer in a direct conversation as a draft, not as a working
|
||||
// message that it edits.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -37,6 +38,16 @@ var _ = Describe("the connector capability", func() {
|
||||
m.Capabilities.Connector.Kinds[0].PreviewMs = MinPreviewMs
|
||||
Expect(ValidateManifest(m, host())).To(Succeed())
|
||||
})
|
||||
It("accepts agentOptional and keeps it through a JSON round trip", func() {
|
||||
m.Capabilities.Connector.Kinds[0].AgentOptional = true
|
||||
Expect(ValidateManifest(m, host())).To(Succeed())
|
||||
raw, err := json.Marshal(m.Capabilities.Connector)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(raw)).To(ContainSubstring(`"agentOptional":true`))
|
||||
m.Capabilities.Connector.Kinds[0].AgentOptional = false
|
||||
raw, _ = json.Marshal(m.Capabilities.Connector)
|
||||
Expect(string(raw)).NotTo(ContainSubstring("agentOptional"))
|
||||
})
|
||||
It("lets an add-on declare nothing but the connector", func() {
|
||||
m.Capabilities.UI = nil
|
||||
m.Capabilities.Connector.Kinds[0].Page = ""
|
||||
|
||||
+60
-2
@@ -5382,7 +5382,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/chats/draft/addons": {
|
||||
"get": {
|
||||
"description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their default switch and whether the chat could run commands at its first turn.",
|
||||
"description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their switch and whether the chat could run commands at its first turn. The draft's current switches may be passed (addons_off, builtins_off, builtins_on: comma-separated lists, as the create request takes them) so the answer is the one the chat would get: with the shell group off, commands is switched_off.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
@@ -5399,11 +5399,29 @@ const docTemplate = `{
|
||||
"description": "a saved agent of yours",
|
||||
"name": "agent_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "comma-separated add-on IDs the draft switched off",
|
||||
"name": "addons_off",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "comma-separated built-in groups the draft switched off",
|
||||
"name": "builtins_off",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "comma-separated opt-in built-in groups the draft switched on",
|
||||
"name": "builtins_on",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The add-ons and their default switches",
|
||||
"description": "The add-ons and the commands state, with the draft switches applied",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.ChatsAddonList"
|
||||
}
|
||||
@@ -18636,6 +18654,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is empty for a binding of a kind that declares agent_optional.",
|
||||
"type": "string"
|
||||
},
|
||||
"allow": {
|
||||
@@ -18673,6 +18692,10 @@ const docTemplate = `{
|
||||
"created": {
|
||||
"type": "string"
|
||||
},
|
||||
"credentials_only": {
|
||||
"description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -18783,6 +18806,13 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"acts_held": {
|
||||
"description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18845,6 +18875,12 @@ const docTemplate = `{
|
||||
"quiet_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"read_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"read_only_why": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -19638,6 +19674,7 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is empty for a binding of a kind that declares agent_optional.",
|
||||
"type": "string"
|
||||
},
|
||||
"allow": {
|
||||
@@ -19675,6 +19712,10 @@ const docTemplate = `{
|
||||
"created": {
|
||||
"type": "string"
|
||||
},
|
||||
"credentials_only": {
|
||||
"description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -19956,6 +19997,10 @@ const docTemplate = `{
|
||||
"addon": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent_optional": {
|
||||
"description": "AgentOptional is true when a binding of this kind may have no agent. It\nthen only holds credentials (token, resource list, write mode).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22736,6 +22781,13 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"acts_held": {
|
||||
"description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22795,6 +22847,12 @@ const docTemplate = `{
|
||||
"quiet_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"read_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"read_only_why": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+60
-2
@@ -5379,7 +5379,7 @@
|
||||
},
|
||||
"/api/chats/draft/addons": {
|
||||
"get": {
|
||||
"description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their default switch and whether the chat could run commands at its first turn.",
|
||||
"description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their switch and whether the chat could run commands at its first turn. The draft's current switches may be passed (addons_off, builtins_off, builtins_on: comma-separated lists, as the create request takes them) so the answer is the one the chat would get: with the shell group off, commands is switched_off.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
@@ -5396,11 +5396,29 @@
|
||||
"description": "a saved agent of yours",
|
||||
"name": "agent_id",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "comma-separated add-on IDs the draft switched off",
|
||||
"name": "addons_off",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "comma-separated built-in groups the draft switched off",
|
||||
"name": "builtins_off",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "comma-separated opt-in built-in groups the draft switched on",
|
||||
"name": "builtins_on",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The add-ons and their default switches",
|
||||
"description": "The add-ons and the commands state, with the draft switches applied",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/schema.ChatsAddonList"
|
||||
}
|
||||
@@ -18633,6 +18651,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is empty for a binding of a kind that declares agent_optional.",
|
||||
"type": "string"
|
||||
},
|
||||
"allow": {
|
||||
@@ -18670,6 +18689,10 @@
|
||||
"created": {
|
||||
"type": "string"
|
||||
},
|
||||
"credentials_only": {
|
||||
"description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -18780,6 +18803,13 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"acts_held": {
|
||||
"description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18842,6 +18872,12 @@
|
||||
"quiet_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"read_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"read_only_why": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -19635,6 +19671,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is empty for a binding of a kind that declares agent_optional.",
|
||||
"type": "string"
|
||||
},
|
||||
"allow": {
|
||||
@@ -19672,6 +19709,10 @@
|
||||
"created": {
|
||||
"type": "string"
|
||||
},
|
||||
"credentials_only": {
|
||||
"description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -19953,6 +19994,10 @@
|
||||
"addon": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent_optional": {
|
||||
"description": "AgentOptional is true when a binding of this kind may have no agent. It\nthen only holds credentials (token, resource list, write mode).",
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22733,6 +22778,13 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"acts_held": {
|
||||
"description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22792,6 +22844,12 @@
|
||||
"quiet_seconds": {
|
||||
"type": "integer"
|
||||
},
|
||||
"read_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"read_only_why": {
|
||||
"type": "string"
|
||||
},
|
||||
"schedule": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+63
-2
@@ -3005,6 +3005,7 @@ definitions:
|
||||
addon:
|
||||
type: string
|
||||
agent_id:
|
||||
description: AgentID is empty for a binding of a kind that declares agent_optional.
|
||||
type: string
|
||||
allow:
|
||||
items:
|
||||
@@ -3040,6 +3041,11 @@ definitions:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
credentials_only:
|
||||
description: |-
|
||||
CredentialsOnly is true for a binding without an agent: it holds the
|
||||
token, the resource list and the write mode, and never answers messages.
|
||||
type: boolean
|
||||
enabled:
|
||||
type: boolean
|
||||
file_receive_max_bytes:
|
||||
@@ -3139,6 +3145,16 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
acts_held:
|
||||
description: |-
|
||||
ActsHeld are the act.* names that apply to a run now: Acts within the
|
||||
grants of the agent, plus act.files when the plan names a file it writes.
|
||||
ReadOnly is true when a run can only read, and ReadOnlyWhy says why:
|
||||
declared (the proposal said so), binding (a connection in read only mode
|
||||
made it), no_acts (it holds none) or agent (its agent holds none of them).
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
agent_id:
|
||||
type: string
|
||||
cost:
|
||||
@@ -3183,6 +3199,10 @@ definitions:
|
||||
type: string
|
||||
quiet_seconds:
|
||||
type: integer
|
||||
read_only:
|
||||
type: boolean
|
||||
read_only_why:
|
||||
type: string
|
||||
schedule:
|
||||
type: string
|
||||
source_chat_id:
|
||||
@@ -3840,6 +3860,7 @@ definitions:
|
||||
addon:
|
||||
type: string
|
||||
agent_id:
|
||||
description: AgentID is empty for a binding of a kind that declares agent_optional.
|
||||
type: string
|
||||
allow:
|
||||
items:
|
||||
@@ -3875,6 +3896,11 @@ definitions:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
credentials_only:
|
||||
description: |-
|
||||
CredentialsOnly is true for a binding without an agent: it holds the
|
||||
token, the resource list and the write mode, and never answers messages.
|
||||
type: boolean
|
||||
enabled:
|
||||
type: boolean
|
||||
file_receive_max_bytes:
|
||||
@@ -4091,6 +4117,11 @@ definitions:
|
||||
properties:
|
||||
addon:
|
||||
type: string
|
||||
agent_optional:
|
||||
description: |-
|
||||
AgentOptional is true when a binding of this kind may have no agent. It
|
||||
then only holds credentials (token, resource list, write mode).
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
files:
|
||||
@@ -6161,6 +6192,16 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
acts_held:
|
||||
description: |-
|
||||
ActsHeld are the act.* names that apply to a run now: Acts within the
|
||||
grants of the agent, plus act.files when the plan names a file it writes.
|
||||
ReadOnly is true when a run can only read, and ReadOnlyWhy says why:
|
||||
declared (the proposal said so), binding (a connection in read only mode
|
||||
made it), no_acts (it holds none) or agent (its agent holds none of them).
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
agent_id:
|
||||
type: string
|
||||
cost:
|
||||
@@ -6203,6 +6244,10 @@ definitions:
|
||||
type: string
|
||||
quiet_seconds:
|
||||
type: integer
|
||||
read_only:
|
||||
type: boolean
|
||||
read_only_why:
|
||||
type: string
|
||||
schedule:
|
||||
type: string
|
||||
source_chat_id:
|
||||
@@ -15687,7 +15732,10 @@ paths:
|
||||
get:
|
||||
description: 'What GET /api/chats/{id}/addons lists, for a chat that does not
|
||||
exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their
|
||||
default switch and whether the chat could run commands at its first turn.'
|
||||
switch and whether the chat could run commands at its first turn. The draft''s
|
||||
current switches may be passed (addons_off, builtins_off, builtins_on: comma-separated
|
||||
lists, as the create request takes them) so the answer is the one the chat
|
||||
would get: with the shell group off, commands is switched_off.'
|
||||
parameters:
|
||||
- description: the model of the new chat
|
||||
in: query
|
||||
@@ -15697,9 +15745,22 @@ paths:
|
||||
in: query
|
||||
name: agent_id
|
||||
type: string
|
||||
- description: comma-separated add-on IDs the draft switched off
|
||||
in: query
|
||||
name: addons_off
|
||||
type: string
|
||||
- description: comma-separated built-in groups the draft switched off
|
||||
in: query
|
||||
name: builtins_off
|
||||
type: string
|
||||
- description: comma-separated opt-in built-in groups the draft switched on
|
||||
in: query
|
||||
name: builtins_on
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: The add-ons and their default switches
|
||||
description: The add-ons and the commands state, with the draft switches
|
||||
applied
|
||||
schema:
|
||||
$ref: '#/definitions/schema.ChatsAddonList'
|
||||
"400":
|
||||
|
||||
Reference in new issue
Block a user