mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(contract): add the change proposal record
A model can ask to change the person's own state (a memory note, a profile, a skill). Add the versioned wire record, its kinds, scopes, statuses, source and limits, so every surface that runs a model, and any add-on, shares one type without platform code. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
2 files changed
+181
No files matched your search
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package contract
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A change proposal is how a model asks to change the person's own state: a
|
||||
// memory note, a profile, a skill. The host stores it, shows it to the owner
|
||||
// and writes it only after the owner confirms. Every surface that can run a
|
||||
// model uses the same record: a Studio chat, a connector chat of any platform
|
||||
// and an add-on page. An add-on never needs platform code for it. The model
|
||||
// calls the host tool propose_change, and a connector carries the text of the
|
||||
// tool result like any other answer.
|
||||
//
|
||||
// The record is versioned. A reader must ignore fields it does not know.
|
||||
|
||||
// ChangeProposalVersion is the version of the ChangeProposal wire record.
|
||||
const ChangeProposalVersion = 1
|
||||
|
||||
// ChangeKind is what a proposal changes.
|
||||
type ChangeKind string
|
||||
|
||||
// The kinds of change. The host decides which ones it offers.
|
||||
const (
|
||||
ChangeMemory ChangeKind = "memory"
|
||||
ChangeProfile ChangeKind = "profile"
|
||||
ChangeSkill ChangeKind = "skill"
|
||||
ChangePage ChangeKind = "page"
|
||||
ChangeDashboard ChangeKind = "dashboard"
|
||||
)
|
||||
|
||||
// ChangeKinds lists every kind in a stable order.
|
||||
func ChangeKinds() []ChangeKind {
|
||||
return []ChangeKind{ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard}
|
||||
}
|
||||
|
||||
// ValidChangeKind reports whether s names a kind.
|
||||
func ValidChangeKind(s string) bool { return slices.Contains(ChangeKinds(), ChangeKind(s)) }
|
||||
|
||||
// ChangeScope says whose state a proposal changes.
|
||||
type ChangeScope string
|
||||
|
||||
// The scopes. user: the person, in every chat. agent: one saved agent.
|
||||
// channel: one connector conversation.
|
||||
const (
|
||||
ScopeUser ChangeScope = "user"
|
||||
ScopeAgent ChangeScope = "agent"
|
||||
ScopeChannel ChangeScope = "channel"
|
||||
)
|
||||
|
||||
// ValidChangeScope reports whether s names a scope.
|
||||
func ValidChangeScope(s string) bool {
|
||||
switch ChangeScope(s) {
|
||||
case ScopeUser, ScopeAgent, ScopeChannel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProposalStatus is where a proposal is in its life.
|
||||
type ProposalStatus string
|
||||
|
||||
// The statuses. Only pending can change. Applied can become reverted.
|
||||
const (
|
||||
ProposalPending ProposalStatus = "pending"
|
||||
ProposalApplied ProposalStatus = "applied"
|
||||
ProposalDismissed ProposalStatus = "dismissed"
|
||||
ProposalExpired ProposalStatus = "expired"
|
||||
ProposalReverted ProposalStatus = "reverted"
|
||||
// ProposalStale: the target changed after the proposal, so it was refused.
|
||||
ProposalStale ProposalStatus = "stale"
|
||||
)
|
||||
|
||||
// Settled reports whether the proposal has an answer.
|
||||
func (s ProposalStatus) Settled() bool { return s != ProposalPending }
|
||||
|
||||
// ChangeSource says where a proposal came from, without naming a platform.
|
||||
// Kind is the origin of the chat as the host names it ("" for a chat a person
|
||||
// watches, "connector" for a messaging app). ID names the binding of a
|
||||
// connector chat. Chat is the host chat. Thread is for a connector that has
|
||||
// threads; the host fills it when it knows it.
|
||||
type ChangeSource struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Chat string `json:"chat,omitempty"`
|
||||
Thread string `json:"thread,omitempty"`
|
||||
}
|
||||
|
||||
// ChangeProposal is one proposed change. Content is the full new text of the
|
||||
// target. BaseHash is the SHA-256 (hex) of the target when the model proposed,
|
||||
// or empty when the target did not exist; a proposal is refused if the target
|
||||
// has another hash when the owner confirms.
|
||||
type ChangeProposal struct {
|
||||
V int `json:"v"`
|
||||
ID string `json:"id"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Kind ChangeKind `json:"kind"`
|
||||
Scope ChangeScope `json:"scope"`
|
||||
Target string `json:"target"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
BaseHash string `json:"base_hash,omitempty"`
|
||||
Source ChangeSource `json:"source,omitzero"`
|
||||
Status ProposalStatus `json:"status"`
|
||||
// Rev is the workspace change set that applying wrote; RevertRev the one
|
||||
// that reverting wrote. Zero until then.
|
||||
Rev int64 `json:"rev,omitempty"`
|
||||
RevertRev int64 `json:"revert_rev,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Answered time.Time `json:"answered,omitzero"`
|
||||
}
|
||||
|
||||
// The limits of the host. They are decisions of the owner of the product, not
|
||||
// settings of an add-on.
|
||||
const (
|
||||
// ChangeExpiry is how long a proposal waits for an answer.
|
||||
ChangeExpiry = 24 * time.Hour
|
||||
// MaxPendingChanges is the most proposals of one person waiting at once.
|
||||
MaxPendingChanges = 10
|
||||
// MaxChangesPerHour is the most proposals one agent (or one plain chat
|
||||
// user) makes in an hour.
|
||||
MaxChangesPerHour = 5
|
||||
// MaxChangeBytes is the largest content of a proposal.
|
||||
MaxChangeBytes = 16 << 10
|
||||
// MaxProfileBytes is the largest agent profile.
|
||||
MaxProfileBytes = 4 << 10
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("change proposals", func() {
|
||||
It("names every kind and status once", func() {
|
||||
Expect(ChangeKinds()).To(ConsistOf(ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard))
|
||||
for _, k := range ChangeKinds() {
|
||||
Expect(ValidChangeKind(string(k))).To(BeTrue(), "kind %s", k)
|
||||
}
|
||||
Expect(ValidChangeKind("Memory")).To(BeFalse())
|
||||
Expect(ValidChangeKind("")).To(BeFalse())
|
||||
Expect(ValidChangeScope("user")).To(BeTrue())
|
||||
Expect(ValidChangeScope("agent")).To(BeTrue())
|
||||
Expect(ValidChangeScope("channel")).To(BeTrue())
|
||||
Expect(ValidChangeScope("team")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("tells a settled status from a pending one", func() {
|
||||
Expect(ProposalPending.Settled()).To(BeFalse())
|
||||
for _, s := range []ProposalStatus{ProposalApplied, ProposalDismissed, ProposalExpired, ProposalReverted, ProposalStale} {
|
||||
Expect(s.Settled()).To(BeTrue(), "status %s", s)
|
||||
}
|
||||
})
|
||||
|
||||
It("keeps the wire version and omits empty fields", func() {
|
||||
p := ChangeProposal{V: ChangeProposalVersion, ID: "c1", Kind: ChangeMemory, Scope: ScopeUser, Target: "index.md",
|
||||
Status: ProposalPending, Created: time.Date(2026, 11, 3, 9, 0, 0, 0, time.UTC)}
|
||||
raw, err := json.Marshal(p)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(raw)).To(ContainSubstring(`"v":1`))
|
||||
Expect(string(raw)).NotTo(ContainSubstring(`"source"`))
|
||||
Expect(string(raw)).NotTo(ContainSubstring(`"rev"`))
|
||||
})
|
||||
|
||||
It("has the limits the owner decided", func() {
|
||||
Expect(ChangeExpiry).To(Equal(24 * time.Hour))
|
||||
Expect(MaxPendingChanges).To(Equal(10))
|
||||
Expect(MaxChangesPerHour).To(Equal(5))
|
||||
Expect(MaxProfileBytes).To(Equal(4 << 10))
|
||||
Expect(MaxChangeBytes).To(Equal(16 << 10))
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user