chats

package
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 10, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package chats is the chat/membership/message service layer: private (DM) and group chats, membership, message persistence, and the agent turn. It serves both the personal-mode slice (TRD §5: one implicit user, one private chat, the agent always responds) and team mode: group chats (RMI-110), the @-mention agent policy (RMI-113), the INIT-OMNIAGENT-005 agent registry binding (Can(CapCreateChat), chats.agent_id — RMI-308), and per-chat memory scoping (TenantID=team, SubjectID="chat:<id>" — RMI-114). The per-agent runtime the agent turn routes to is supplied through the AgentRuntime seam (RMI-309).

Index

Constants

View Source
const MaxChatNameBytes = 200

MaxChatNameBytes caps a group chat's name.

View Source
const MaxMessageBytes = 32 << 10

MaxMessageBytes caps a single message's length (TRD §5).

View Source
const MemoryTenantTeam = "team"

MemoryTenantTeam is the memory tenant for team-mode chats (RMI-OMNIAGENT-114): every chat turn's memory is attributed to this tenant, subject-scoped per chat. The composition root passes it as Config.MemoryTenant in team mode.

Variables

View Source
var (
	// ErrForbidden is returned when the actor is not a member of the chat.
	ErrForbidden = errors.New("forbidden")
	// ErrNotFound is returned when the referenced chat does not exist (or
	// is invisible to the actor — indistinguishable by design).
	ErrNotFound = errors.New("not found")
	// ErrEmptyMessage is returned for blank message content.
	ErrEmptyMessage = errors.New("message content is empty")
	// ErrNotGroup is returned when a group-only operation targets a chat
	// that is not a group (e.g. inviting into a private DM).
	ErrNotGroup = errors.New("chat is not a group")
	// ErrEmptyName is returned when a group chat is created without a name.
	ErrEmptyName = errors.New("group name is empty")
	// ErrUserNotFound is returned when an invitee username resolves to no user.
	ErrUserNotFound = errors.New("user not found")
	// ErrLastOwner is returned when the sole owner tries to leave a group
	// that still has other members (which would orphan it).
	ErrLastOwner = errors.New("cannot leave: you are the group's only owner")
	// ErrNoAgentRegistry is returned when an agent-bound chat operation is
	// attempted but no AgentGate is configured (personal mode).
	ErrNoAgentRegistry = errors.New("agent registry not configured")
)

Sentinel errors returned by the service layer.

Functions

func SessionID

func SessionID(chatID uuid.UUID) string

SessionID returns the agent session key for a chat (TRD §5): one session per chat, shared by every member.

Types

type Actor

type Actor struct {
	UserID     uuid.UUID
	Superadmin bool
}

Actor identifies the authenticated caller of a group operation. The Superadmin flag flows into the store so owner/superadmin administration rules resolve correctly under row-level security (a superadmin may administer any group's membership, but is not content-privileged).

type AgentGate

type AgentGate interface {
	AuthorizeStartChat(ctx context.Context, userID uuid.UUID, superadmin bool, agentID uuid.UUID) (bool, error)
}

AgentGate authorizes starting a chat with a registered agent (INIT-OMNIAGENT-005). It is defined here — with a primitive signature — so the chats service stays decoupled from the agents package; *agents.Service satisfies it via AuthorizeStartChat. Nil in personal mode, where chats carry no agent binding.

type AgentProcessor

type AgentProcessor interface {
	Process(ctx context.Context, sessionID, content string) (string, error)
}

AgentProcessor runs one conversational turn. *agent.Agent satisfies this structurally; chats stays decoupled from the agent package.

type AgentRuntime

type AgentRuntime interface {
	// Slug returns the bound agent's slug (for @-mention matching).
	Slug(ctx context.Context, agentID uuid.UUID) (string, error)
	// Processor returns a processor bound to the agent's runtime.
	Processor(ctx context.Context, agentID uuid.UUID) (AgentProcessor, error)
}

AgentRuntime resolves a chat's bound agent (RMI-113): its slug, for @-mention matching in group chats, and a processor bound to that agent's runtime — persona + enabled skills + agent-scoped secrets. Splitting the two lets a group turn check the (cheap) slug and build the (heavier) runtime only when the agent is actually mentioned. It is the seam RMI-OMNIAGENT-309 fills with a lazy, bounded per-agent instance cache; chats stays decoupled from the agents package. Nil until a runtime is wired, in which case an agent-bound chat takes no turn — silence beats answering as the wrong agent.

type Config

type Config struct {
	// Agent runs the agent's turn on each user message. Nil echoes the
	// message back instead (matches the gateway's no-API-key fallback).
	Agent AgentProcessor
	// Agents gates agent-bound chat creation on Can(CapCreateChat). Nil in
	// personal mode (StartAgentDM / CreateGroupWithAgent then return
	// ErrNoAgentRegistry).
	Agents AgentGate
	// Runtime resolves an agent-bound chat's slug + runtime processor for the
	// agent turn (RMI-113). Nil when no per-agent runtime is wired: agent-bound
	// chats then take no turn (see AgentRuntime).
	Runtime AgentRuntime
	// MemoryTenant, when non-empty, scopes every agent turn's memory to this
	// tenant and to the chat (SubjectID="chat:<id>") — TenantID=team,
	// SubjectID=chat (RMI-OMNIAGENT-114), so each chat's memories are isolated
	// from every other chat. Empty (personal mode) leaves memory scoping to the
	// agent's own configuration, unchanged. Set it to "team" in team mode.
	MemoryTenant string
	// Logger defaults to slog.Default().
	Logger *slog.Logger
}

Config configures the chats service.

type MemberView

type MemberView struct {
	UserID   uuid.UUID `json:"userId"`
	Username string    `json:"username"`
	Role     string    `json:"role"`
	JoinedAt time.Time `json:"joinedAt"`
}

MemberView pairs a membership with the member's username, for display in a group's member list (RMI-110/111). Usernames are resolved via system context because RLS hides other users' rows from a non-superadmin member; within a shared chat, revealing co-members' usernames is expected.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service exposes chat operations over the store.

func NewService

func NewService(st *store.Store, cfg Config) (*Service, error)

NewService creates the chats service.

func (*Service) AgentTurn

func (s *Service) AgentTurn(ctx context.Context, c *ent.Chat, content string) (*ent.Message, bool, error)

AgentTurn applies the RMI-113 mention policy and, when the bound agent should respond, runs its turn on the agent's runtime, persists the reply as an agent-authored message, and returns it. The bool reports whether the agent responded: a group message that does not @-mention the bound agent — or an agent-bound chat with no runtime wired — yields (nil, false, nil), and the caller broadcasts nothing.

Policy: a private chat always gets a turn; a group chat gets one only when the message @-mentions the bound agent's slug. The turn never self-replies — it runs only on a user message and persists its reply as author_type=agent, which no user can post, so no reply can re-trigger it. The caller must have membership-validated the message (e.g. via PostUserMessage); the reply row is written AsSystem because the agent is not a user principal.

func (*Service) CreateGroup

func (s *Service) CreateGroup(ctx context.Context, actor Actor, name string) (*ent.Chat, error)

CreateGroup creates a group chat owned by the actor, inserting the actor's owner membership in the same transaction. Group members added later via Invite join as conversants (role "member") with no configuration rights. The chat carries no agent binding (see CreateGroupWithAgent for INIT-005).

func (*Service) CreateGroupWithAgent

func (s *Service) CreateGroupWithAgent(ctx context.Context, actor Actor, name string, agentID uuid.UUID) (*ent.Chat, error)

CreateGroupWithAgent creates a group chat bound to an agent, gated on Can(CapCreateChat). Otherwise identical to CreateGroup. Requires an AgentGate (team mode); returns ErrNoAgentRegistry otherwise.

func (*Service) GenerateReply

func (s *Service) GenerateReply(ctx context.Context, chatID uuid.UUID, content string) (*ent.Message, error)

GenerateReply runs the agent's turn for content and persists the reply as an agent-authored message (no membership check — the caller already validated it via PostUserMessage). It always responds, on the service-wide fallback processor, and applies no mention policy: it is the personal-mode / agent-less path (one implicit agent, always answers). Team chats bound to a registered agent use AgentTurn, which applies the RMI-113 mention policy and routes to the bound agent's runtime.

func (*Service) GetChat

func (s *Service) GetChat(ctx context.Context, actor Actor, chatID uuid.UUID) (*ent.Chat, error)

GetChat returns a chat the actor is a member of, or ErrForbidden.

func (*Service) History

func (s *Service) History(ctx context.Context, userID, chatID uuid.UUID, cursor *uuid.UUID, limit int) ([]*ent.Message, error)

History returns up to limit messages for chatID, oldest-first. cursor, if non-nil, excludes messages at or before that message ID's position (keyset pagination by created_at; TRD §5 "Ordering/limits").

func (*Service) HistoryBefore

func (s *Service) HistoryBefore(ctx context.Context, userID, chatID uuid.UUID, before *uuid.UUID, limit int) ([]*ent.Message, error)

HistoryBefore returns up to limit messages older than before, oldest-first (ready to prepend in a scroll-back UI — TRD §5 "Ordering/limits"). A nil before returns the newest limit messages. This is the backward (scroll-back) direction; History is the forward direction.

func (*Service) Invite

func (s *Service) Invite(ctx context.Context, actor Actor, chatID uuid.UUID, username string) (*ent.ChatMember, error)

Invite adds a user (by username) to a group chat as a conversant (member). Owner or superadmin only. Idempotent: re-inviting an existing member returns the existing membership without change.

func (*Service) Leave

func (s *Service) Leave(ctx context.Context, actor Actor, chatID uuid.UUID) error

Leave removes the actor's own membership from a group chat (self-leave). The sole owner cannot leave while other members remain (it would orphan the group); remove the others or the group first.

func (*Service) ListChats

func (s *Service) ListChats(ctx context.Context, actor Actor) ([]*ent.Chat, error)

ListChats returns the chats the actor is a member of, newest first. RLS (and the service's membership scoping) ensures no chat the actor cannot see is returned; a superadmin is not content-privileged, so this lists only the superadmin's own chats.

func (*Service) MemberUserIDs

func (s *Service) MemberUserIDs(ctx context.Context, actor Actor, chatID uuid.UUID) ([]uuid.UUID, error)

MemberUserIDs returns the user IDs of a chat's members — the fan-out recipient set for a message broadcast. The actor must be a member.

func (*Service) Members

func (s *Service) Members(ctx context.Context, actor Actor, chatID uuid.UUID) ([]*ent.ChatMember, error)

Members lists a chat's members (oldest membership first). The actor must be a member, or a superadmin (who may administer membership without joining).

func (*Service) MembersDetailed

func (s *Service) MembersDetailed(ctx context.Context, actor Actor, chatID uuid.UUID) ([]MemberView, error)

MembersDetailed lists a chat's members with their usernames. The actor must be a member (or superadmin).

func (*Service) PostUserMessage

func (s *Service) PostUserMessage(ctx context.Context, userID, chatID uuid.UUID, content string) (*ent.Message, error)

PostUserMessage validates and persists a user message, returning it. It performs the membership check but does not run the agent turn — callers wanting the reply call GenerateReply next (see Send).

func (*Service) PrivateChat

func (s *Service) PrivateChat(ctx context.Context, userID uuid.UUID) (*ent.Chat, error)

PrivateChat returns the user's private (DM) chat with the agent, creating it on first use. At most one private chat exists per user, enforced by the store's partial unique index on (created_by) where type='private'.

func (*Service) RemoveMember

func (s *Service) RemoveMember(ctx context.Context, actor Actor, chatID, memberID uuid.UUID) error

RemoveMember removes another member from a group chat. Owner or superadmin only. Owners are not removable this way (they leave themselves); use Leave to remove yourself.

func (*Service) Send

func (s *Service) Send(ctx context.Context, userID, chatID uuid.UUID, content string) (userMsg, agentMsg *ent.Message, err error)

Send persists the user's message, runs the agent's turn (private chats always respond — TRD §5), persists the reply, and returns both. agentMsg is nil only if the agent turn itself errors after the user message already landed; callers should still show the user's message. Send is the synchronous convenience path; the HTTP layer instead calls PostUserMessage then GenerateReply so the (slow) agent turn can be delivered out-of-band.

func (*Service) StartAgentDM

func (s *Service) StartAgentDM(ctx context.Context, actor Actor, agentID uuid.UUID) (*ent.Chat, error)

StartAgentDM returns the actor's private (DM) chat with a specific agent, creating it on first use — one per user per agent. Creation is gated on the agents registry's Can(CapCreateChat) (listed agents are startable by any allowlisted user; private agents only by editors/superadmin). Requires an AgentGate (team mode); returns ErrNoAgentRegistry otherwise.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL