mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
A connector chat whose sender holds talkback.channels gets the tool send_message. It writes in the conversation, or as a new top-level message in a channel that the owner listed on the binding (id, label, approved; at most 10). The tool names no platform. An unlisted channel is refused and the error lists the ones that are. A listed channel that the owner did not approve needs the sensitive talkback.new_channel for each message, so only a paired sender can use it. The agent cannot add or approve a channel, so text it read in a page or a file cannot widen where it writes. Messages go through the outbox (rate limit and queue), with the size cap and the mention breaking. Each attempt adds an audit row with channel, kind, capability, length and a hash, never the text. A trigger can report to an approved channel with delivery target channel. The binding, the capability and the listing are checked again when the run ends; any failure sends the notice to the Inbox. LOCALAI_DISABLE_CONNECTOR_TALKBACK turns both off. Assisted-by: Claude:claude-sonnet-5-5 go swag
1508 lines
55 KiB
Go
1508 lines
55 KiB
Go
// Package studio runs the chats of the new conversation API: one loop for the
|
|
// web chat, for saved agents and for connectors. It holds no state of its own
|
|
// that another frontend would need; pending messages and the turn claim live in
|
|
// the store.
|
|
package studio
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
mcpendpoint "github.com/mudler/LocalAI/core/http/endpoints/mcp"
|
|
"github.com/mudler/LocalAI/core/services/studio/agents"
|
|
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
|
"github.com/mudler/LocalAI/core/services/studio/contextfiles"
|
|
"github.com/mudler/LocalAI/core/services/studio/egress"
|
|
"github.com/mudler/LocalAI/core/services/studio/egress/audit"
|
|
"github.com/mudler/LocalAI/core/services/studio/files"
|
|
"github.com/mudler/LocalAI/core/services/studio/mcpconns"
|
|
"github.com/mudler/LocalAI/core/services/studio/media"
|
|
"github.com/mudler/LocalAI/core/services/studio/nibharness"
|
|
"github.com/mudler/LocalAI/core/services/studio/notify"
|
|
"github.com/mudler/LocalAI/core/services/studio/pages"
|
|
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
|
"github.com/mudler/LocalAI/core/services/studio/provider"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/sharedfolders"
|
|
"github.com/mudler/LocalAI/core/services/studio/store"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
"github.com/mudler/LocalAI/core/services/studio/wsfeed"
|
|
)
|
|
|
|
// AnonymousPrincipal is the owner of conversations when auth is off. The loop
|
|
// needs a non-empty owner, so the empty user becomes this one.
|
|
const AnonymousPrincipal = "anonymous"
|
|
|
|
// ErrNoModel means a conversation was created without a model.
|
|
var ErrNoModel = errors.New("studio: a conversation needs a model")
|
|
|
|
// ErrAgentsOff means a chat named a saved agent but the service has no agent store.
|
|
var ErrAgentsOff = errors.New("studio: saved agents are not available")
|
|
|
|
// ErrPushOff means browser push is off: the server keeps nothing across a
|
|
// restart, or it has no push service.
|
|
var ErrPushOff = errors.New("studio: browser push is not available")
|
|
|
|
// ErrConnectorsOff means the service has no connector manager.
|
|
var ErrConnectorsOff = errors.New("studio: connectors are not available")
|
|
|
|
// ErrNotificationsOff means the service has no inbox.
|
|
var ErrNotificationsOff = errors.New("studio: notifications are not available")
|
|
|
|
// ErrSubAgentReadOnly means a user tried to send to a sub-agent conversation.
|
|
// It runs with the tools its parent chose, so only the parent starts it.
|
|
var ErrSubAgentReadOnly = errors.New("studio: a sub-agent conversation takes no messages from the user")
|
|
|
|
// UserLookup returns the active user with this ID. It is nil when auth is off.
|
|
type UserLookup func(ctx context.Context, id string) (*auth.User, error)
|
|
|
|
// Deps is what the service needs.
|
|
type Deps struct {
|
|
Store store.Store
|
|
Provider *provider.Provider
|
|
Users UserLookup
|
|
// StateDir holds the harness's private files, one folder per chat.
|
|
StateDir string
|
|
// HostTools supplies the tools a chat gets besides the ones the service adds
|
|
// itself (for example the tools of MCP servers). A sub-agent gets all of
|
|
// them, or the subset its parent names. Nil: none.
|
|
HostTools func(ctx context.Context, a workspace.Actor) ([]session.Tool, error)
|
|
// MCP returns the MCP tool executor for a user's chat with a model, or nil
|
|
// when the model has none. Its tools become the chat's tools. Nil: no MCP.
|
|
// With limited set, only the servers named in only are used, whether or not
|
|
// the model picks them by itself; missing returns the named servers the user
|
|
// cannot use. Without limited, only is nil and missing is empty.
|
|
MCP func(ctx context.Context, user, model string, only []string, limited bool) (exec mcpendpoint.ToolExecutor, missing []string)
|
|
// MCPServers lists the MCP servers the model's own configuration has, for a
|
|
// user: Auto says whether a chat with no agent uses it by itself. Empty when
|
|
// the user may not use MCP. It feeds the per-chat switches; nil: a switch of
|
|
// a model server has no effect on a chat with no agent.
|
|
MCPServers func(ctx context.Context, user, model string) []ModelMCPServer
|
|
// MCPContent reads the prompts and resources of the model's own MCP
|
|
// servers. Nil: they offer none.
|
|
MCPContent MCPContent
|
|
// Allowed says whether a user may use a feature (auth.FeatureTriggers,
|
|
// auth.FeatureConnectors) right now. The unattended parts ask it before each
|
|
// run, so an owner whose permission was taken away stops running, not only
|
|
// stops creating. Nil: everyone may.
|
|
Allowed func(user *auth.User, feature string) bool
|
|
// Connectors holds the MCP servers the users defined. Nil: none.
|
|
Connectors ConnectorTools
|
|
// MCPConnectors is the same service with its management calls, for the
|
|
// connectors API. When set and Connectors is not, it is used for both.
|
|
MCPConnectors *mcpconns.Service
|
|
// Agents holds the saved agents. Nil: a chat cannot name an agent.
|
|
Agents agents.Store
|
|
// Notify stores and delivers notifications. Nil: there is no inbox.
|
|
Notify *notify.Service
|
|
// Push sends notifications to browsers. Nil: browser push is off.
|
|
Push *notify.Pusher
|
|
// AddonTools supplies the tools of the installed add-ons the user may use.
|
|
// Nil: a chat has none.
|
|
AddonTools AddonTools
|
|
// Assistant gives a chat the tools that manage this instance. Nil: no
|
|
// assistant chat can start.
|
|
Assistant Assistant
|
|
// Collections is the knowledge base a chat can search. Nil: no collection
|
|
// tools.
|
|
Collections Collections
|
|
// ContextTokens returns the context window of a model in tokens, or zero
|
|
// when it is not known (the harness then uses its default). Nil: zero.
|
|
ContextTokens func(model string) int
|
|
// AgentOptions returns the tool-loop options of a model (its agent: block).
|
|
// Nil: nib's defaults.
|
|
AgentOptions func(model string) nibharness.AgentOptions
|
|
// Bus carries live events and control messages between the frontends that
|
|
// serve a chat. Nil: a bus for this process only.
|
|
Bus Bus
|
|
// Workers runs the turns of unattended runs (triggers, jobs, connector
|
|
// messages) on chat workers; see SendBackground. Nil: they run here.
|
|
Workers ChatWorkers
|
|
// RunLimit is, on a chat worker, the longest a turn that a frontend sent may
|
|
// run, by the chat's origin (session.OriginJob, ...). Zero or nil: no bound
|
|
// on the worker; the frontend that watches the run bounds it.
|
|
RunLimit func(origin string) time.Duration
|
|
// MaxWorkerTurns is, on a chat worker, the most turns this process runs at
|
|
// once when it takes a start (ServeStart). A start that would begin one
|
|
// more is refused with ErrWorkerFull; a start for a chat whose turn runs
|
|
// is taken, because its message only waits. Zero: no cap.
|
|
MaxWorkerTurns int
|
|
// HeartbeatEvery is how often a running turn tells the store it is alive.
|
|
// Zero means 15 seconds.
|
|
HeartbeatEvery time.Duration
|
|
// Files is the workspace of the chats. Nil or disabled: the chats have no
|
|
// files, and everything else works.
|
|
Files *files.Service
|
|
// Media runs the image and audio helper tools and transcribes voice notes.
|
|
// Nil: neither.
|
|
Media *media.Service
|
|
// Web serves the built-in web_search and web_fetch tools. Nil: neither.
|
|
Web WebTools
|
|
// Modalities says which inputs a model takes natively: with Vision an
|
|
// attached image goes to the chat model as an image part, with Video a
|
|
// video does. A model without them gets the file and the describe_image
|
|
// tool. Nil: no model takes either.
|
|
Modalities func(model string) Modalities
|
|
// PageConsents keeps the consents of the viewers of shared pages. Nil: an
|
|
// in-memory store, for a single process and tests.
|
|
PageConsents pages.ConsentStore
|
|
// SharedFolderAudit keeps the audit of the shared folders. Nil: an
|
|
// in-memory audit, for a single process and tests.
|
|
SharedFolderAudit sharedfolders.AuditStore
|
|
// FindUser returns the user that ref names (an ID, an email or a name),
|
|
// whatever their status, for the members of a shared folder. Nil: Users
|
|
// looks members up by ID, and with no Users any principal is a member.
|
|
FindUser func(ctx context.Context, ref string) (*auth.User, error)
|
|
// Builtins is the registry of the built-in chat tools an add-on may
|
|
// replace. Nil: the service makes one. The service registers bash in it
|
|
// when HostExec is set.
|
|
Builtins *builtins.Registry
|
|
// HostExec runs the built-in bash on this machine. Nil: there is none.
|
|
HostExec HostExec
|
|
// HostExecMode is the setting chats_host_exec as it applies now: off, ask
|
|
// or on. Nil: off.
|
|
HostExecMode func() string
|
|
// SavedAgentsOn is the setting chats_saved_agents: whether a chat that
|
|
// switched it on may call the user's saved agents as sub-agents. Read at
|
|
// each turn. Nil: on.
|
|
SavedAgentsOn func() bool
|
|
// WorkFolderOn is the setting chats_work_folder: whether the prompt of a
|
|
// chat names its work folder for scripts and other working files, and a
|
|
// helper file saved elsewhere gets a nudge. Read at each turn. Nil: on.
|
|
WorkFolderOn func() bool
|
|
// IsAdmin says whether a user is an administrator. Nil: everyone is (a
|
|
// server without accounts).
|
|
IsAdmin func(ctx context.Context, user string) bool
|
|
// ToolPins names, for a built-in tool that several add-ons replace, the
|
|
// add-on an administrator chose to serve it (runtime setting
|
|
// chats_tool_providers). Nil: no pins.
|
|
ToolPins func(context.Context) (map[string]string, error)
|
|
// SharedPagesOn says whether the pages of shared folders are on; an
|
|
// administrator switches them at run time. Nil: on.
|
|
SharedPagesOn func() bool
|
|
// RemoteModel says whether a model runs on a remote service (a cloud
|
|
// proxy), so a cost shown to the person can say that the tokens leave the
|
|
// server. Nil: no model is remote.
|
|
RemoteModel func(model string) bool
|
|
// Persistent is true when the store keeps conversations across a restart.
|
|
Persistent bool
|
|
// SuggestionsDefault says whether the assistant may offer things (to save a
|
|
// name, a shortcut, a schedule) to a user who did not choose. Read at each
|
|
// turn, so a change applies at once. Nil: on.
|
|
SuggestionsDefault func() bool
|
|
// EgressSettings returns the administrator's limits of the Sandbox
|
|
// network (chats_egress_*), read at each check. Nil: the ceiling is off and
|
|
// no command reaches the network.
|
|
EgressSettings func(context.Context) (egress.Settings, error)
|
|
// EgressAudit keeps the audit of the Sandbox network requests. Nil: an
|
|
// in-memory audit, for a single process and tests.
|
|
EgressAudit audit.Store
|
|
// EgressAuditDays is chats_egress_audit_days. Nil or out of range: 30.
|
|
EgressAuditDays func() int
|
|
// EgressCardWait is how long a connection waits for the answer of a
|
|
// network card. Zero: 60 seconds. Tests set it shorter.
|
|
EgressCardWait time.Duration
|
|
// GoalLimits returns chats_goal_max_turns and chats_goal_max_steps: the
|
|
// most turns the server starts by itself for one goal, and the most model
|
|
// requests the goal's turns make, before the goal pauses. Read when a turn
|
|
// ends. Nil or a value below 1: DefaultGoalMaxTurns, DefaultGoalMaxSteps.
|
|
GoalLimits func() (turns, steps int)
|
|
// Proposals keeps the change proposals (propose_change) and their audit.
|
|
// Nil: a store in memory, for a single process and tests.
|
|
Proposals proposals.Store
|
|
// ProposalGate decides whether a chat may propose or revert a change. Nil:
|
|
// proposals.DefaultGate, which refuses a chat that runs a saved agent.
|
|
ProposalGate proposals.Gate
|
|
// Clock is the time the offer limits use. Nil: time.Now. It is for tests.
|
|
Clock func() time.Time
|
|
// Harness replaces the nib harness. It is for tests.
|
|
Harness session.Harness
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// Modalities are the inputs a chat model takes besides text.
|
|
type Modalities struct{ Vision, Video bool }
|
|
|
|
// CreateInput describes a new conversation.
|
|
type CreateInput struct {
|
|
Title string
|
|
Model string
|
|
// AgentID is a saved agent of the user. The agent narrows the chat's tools
|
|
// and adds its instructions; its model, when it has one, replaces Model.
|
|
AgentID string
|
|
// Assistant starts a chat with the LocalAI management tools. Only an
|
|
// administrator may, and only while the assistant is on. It cannot be
|
|
// combined with AgentID.
|
|
Assistant bool
|
|
// Switches are the switches the chat starts with. They are checked as
|
|
// the Set*Off calls check them and stored with the new record, so the
|
|
// chat never exists without them.
|
|
Switches Switches
|
|
}
|
|
|
|
// Switches are the lists of a chat's switches: the add-ons, MCP servers and
|
|
// connectors, groups of built-in tools and skills that are off, and the
|
|
// opt-in groups of built-in tools that are on.
|
|
type Switches struct {
|
|
AddonsOff, McpOff, BuiltinsOff, BuiltinsOn, SkillsOff []string
|
|
}
|
|
|
|
// cleanSwitches checks every list of sw and returns them cleaned.
|
|
func (s *Service) cleanSwitches(sw Switches) (Switches, error) {
|
|
var out Switches
|
|
var err error
|
|
if out.AddonsOff, err = cleanAddonSwitches(sw.AddonsOff); err != nil {
|
|
return out, err
|
|
}
|
|
if out.McpOff, err = cleanMcpSwitches(sw.McpOff); err != nil {
|
|
return out, err
|
|
}
|
|
if out.BuiltinsOff, err = s.cleanGroups(sw.BuiltinsOff, false); err != nil {
|
|
return out, err
|
|
}
|
|
if out.BuiltinsOn, err = s.cleanGroups(sw.BuiltinsOn, true); err != nil {
|
|
return out, err
|
|
}
|
|
if out.SkillsOff, err = cleanSkillSwitches(sw.SkillsOff); err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// orNil keeps an empty list nil, as a chat that never switched anything has it.
|
|
func orNil(l []string) []string {
|
|
if len(l) == 0 {
|
|
return nil
|
|
}
|
|
return l
|
|
}
|
|
|
|
// Origin says what started a conversation when a person did not.
|
|
type Origin struct{ Kind, ID string }
|
|
|
|
// SendResult says what Send did with a message.
|
|
type SendResult struct {
|
|
// Started is true when the message began a turn. Otherwise a turn was
|
|
// running, and the message is pending under PendingID.
|
|
Started bool
|
|
PendingID string
|
|
}
|
|
|
|
// Service runs conversations.
|
|
type Service struct {
|
|
d Deps
|
|
mgr *session.Manager
|
|
// builtins holds the built-in chat tools an add-on may replace.
|
|
builtins *builtins.Registry
|
|
// access holds the grants of the running connector turns and the recent
|
|
// permission decisions (see connectoraccess.go).
|
|
access accessState
|
|
bus Bus
|
|
log *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
runs map[string]*run // the turn this process runs, by conversation
|
|
pumps map[string]<-chan session.Event // the event channel read for each conversation
|
|
locks map[string]*keyLock
|
|
|
|
// admit makes the check of MaxWorkerTurns and the start it admits one step,
|
|
// so two starts cannot both take the last place.
|
|
admit sync.Mutex
|
|
|
|
// Sub-agents; see subagent.go.
|
|
changes *Changes // the change proposals
|
|
triggers *Triggers // set once by SetTriggers
|
|
loops *Loops // set once by SetLoops
|
|
jobs *Jobs // set once by SetJobs
|
|
connectors *Connectors // set once by SetConnectors
|
|
capGate CapabilityGate // set once by SetCapabilityGate; nil denies
|
|
ctxfiles *contextfiles.Loader
|
|
conversion ConversionSource // set once by SetConversion
|
|
|
|
videos map[string][]string // videos for the next model request of a turn, by conversation
|
|
children map[string]map[string]*child // running sub-agents, by parent conversation
|
|
allowed map[string][]session.Tool // the tools of a sub-agent conversation
|
|
waiters map[string][]chan store.TurnStatus // callers waiting for a turn to end
|
|
closed chan struct{}
|
|
|
|
feed *wsfeed.Feed // changes of the home folders; nil without files
|
|
|
|
// admin is the administrator's actor of the hosts that watch every
|
|
// home's changes (the change feed and the trigger watcher). It matches
|
|
// paths and reads no file for a model.
|
|
admin workspace.Actor
|
|
|
|
nudged nudges // the turns whose results said where helper files go
|
|
|
|
shared *sharedfolders.Service // the folders an administrator shares
|
|
|
|
egressAuditStore audit.Store // see egress.go
|
|
egressChats egressChats // see egresscheck.go
|
|
}
|
|
|
|
type run struct {
|
|
id string
|
|
started time.Time
|
|
stopped bool
|
|
failed bool
|
|
// paused is set when the tool loop reached its step limit: the turn ends
|
|
// as paused, so the person sees that the work is not finished.
|
|
paused bool
|
|
// steps counts the model requests of the turn (see inputs), for the
|
|
// chat's goal.
|
|
steps int
|
|
// chat is the chat the turn runs on. Stop goes through it and never opens
|
|
// the chat again: a turn that ended has closed it, and a chat left open by
|
|
// a late Stop would hold stale state while another frontend runs the next turn.
|
|
chat *session.Chat
|
|
// end stops what the turn started besides the loop itself: the heartbeat and
|
|
// the listener for control messages.
|
|
end func()
|
|
}
|
|
|
|
const defaultHeartbeat = 15 * time.Second
|
|
|
|
type keyLock struct {
|
|
mu sync.Mutex
|
|
n int
|
|
}
|
|
|
|
// New returns a Service.
|
|
func New(d Deps) (*Service, error) {
|
|
if d.Store == nil || d.StateDir == "" {
|
|
return nil, errors.New("studio: a store and a state folder are required")
|
|
}
|
|
if d.Connectors == nil && d.MCPConnectors != nil {
|
|
d.Connectors = d.MCPConnectors
|
|
}
|
|
log := d.Logger
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
bus := d.Bus
|
|
if bus == nil {
|
|
bus = NewLocalBus()
|
|
}
|
|
s := &Service{d: d, bus: bus, log: log, runs: map[string]*run{}, pumps: map[string]<-chan session.Event{}, locks: map[string]*keyLock{},
|
|
videos: map[string][]string{}, children: map[string]map[string]*child{}, allowed: map[string][]session.Tool{}, waiters: map[string][]chan store.TurnStatus{}, closed: make(chan struct{})}
|
|
s.egressAuditStore = d.EgressAudit
|
|
if s.egressAuditStore == nil {
|
|
s.egressAuditStore, _ = audit.NewJSON("")
|
|
}
|
|
s.builtins = d.Builtins
|
|
if s.builtins == nil {
|
|
s.builtins = builtins.NewRegistry()
|
|
}
|
|
s.describeGroups()
|
|
if err := s.registerHostBash(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.registerWebTools(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.registerSavedAgents(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.registerTriggerTools(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.registerTalkbackTool(); err != nil {
|
|
return nil, err
|
|
}
|
|
changes, err := newChanges(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.changes = changes
|
|
if err := s.registerChangeTools(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.registerLoadSkill(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.registerGoalTools(); err != nil {
|
|
return nil, err
|
|
}
|
|
h := d.Harness
|
|
if h == nil {
|
|
if d.Provider == nil {
|
|
return nil, errors.New("studio: a provider is required without a test harness")
|
|
}
|
|
h = nibharness.New(nibharness.Config{LLM: s.llmFor})
|
|
}
|
|
if d.Files == nil {
|
|
d.Files = files.Disabled("")
|
|
s.d.Files = d.Files
|
|
}
|
|
// After the files: whether the file and media groups exist follows them.
|
|
if err := s.registerToolGroups(); err != nil {
|
|
return nil, err
|
|
}
|
|
s.ctxfiles = contextfiles.NewLoader(contextfiles.NewRegistry(), homeReader{f: d.Files}, log)
|
|
s.shared = sharedfolders.New(sharedfolders.Options{Files: d.Files, Consents: d.PageConsents, Audit: d.SharedFolderAudit, Users: s.folderUsers()})
|
|
// A chat mounts the shared folders of its owner at /workspace/shared-<name>.
|
|
mgr, err := session.New(session.Options{Store: d.Store, Workspace: d.Files.Store(), Harness: h, StateDir: d.StateDir, Inputs: s.inputs, ChatTools: s.chatTools,
|
|
Shared: s.shared.Roots, Resolve: s.resolveAccess, ProductScope: s.productScope, Logger: log})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mgr = mgr
|
|
s.admin = workspace.Actor{Principal: files.AdminPrincipal}
|
|
if d.Files.Available() {
|
|
s.feed = wsfeed.New(wsfeed.Options{Store: d.Files.Store(), Admin: s.admin, Logger: log})
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// SharedFolders returns the service of the shared folders.
|
|
func (s *Service) SharedFolders() *sharedfolders.Service { return s.shared }
|
|
|
|
// folderUsers finds the members of a shared folder in the user database.
|
|
func (s *Service) folderUsers() sharedfolders.Users {
|
|
find := s.d.FindUser
|
|
if find == nil && s.d.Users != nil {
|
|
find = func(ctx context.Context, ref string) (*auth.User, error) { return s.d.Users(ctx, ref) }
|
|
}
|
|
if find == nil {
|
|
return nil
|
|
}
|
|
return folderUsers(find)
|
|
}
|
|
|
|
type folderUsers func(ctx context.Context, ref string) (*auth.User, error)
|
|
|
|
func (f folderUsers) Find(ctx context.Context, ref string) (sharedfolders.User, error) {
|
|
u, err := f(ctx, ref)
|
|
if errors.Is(err, ErrNoUser) || (err == nil && u == nil) {
|
|
return sharedfolders.User{}, sharedfolders.ErrUnknownUser
|
|
}
|
|
if err != nil {
|
|
return sharedfolders.User{}, err
|
|
}
|
|
name := u.Name
|
|
if name == "" {
|
|
name = u.Email
|
|
}
|
|
return sharedfolders.User{ID: u.ID, Name: name}, nil
|
|
}
|
|
|
|
// isClosed says whether Close has been called.
|
|
func (s *Service) isClosed() bool {
|
|
select {
|
|
case <-s.closed:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Close stops every chat. A turn that it cuts short is recorded as interrupted.
|
|
func (s *Service) Close(ctx context.Context) error {
|
|
select {
|
|
case <-s.closed:
|
|
default:
|
|
close(s.closed)
|
|
}
|
|
if s.feed != nil {
|
|
s.feed.Close()
|
|
}
|
|
return s.mgr.Close(ctx)
|
|
}
|
|
|
|
// List returns the user's conversations, newest first. Sub-agent runs are
|
|
// included; they have Kind session.KindSubAgent and a ParentID.
|
|
func (s *Service) List(ctx context.Context, user string, limit int) ([]session.ChatRecord, error) {
|
|
return s.d.Store.ListChats(ctx, principal(user), session.ChatCursor{}, limit)
|
|
}
|
|
|
|
// ListChats returns the user's chats, newest first, without sub-agent runs. The
|
|
// limit applies to chats, so a burst of newer runs cannot push older chats out
|
|
// of the page: the store is read page by page until enough chats are found.
|
|
func (s *Service) ListChats(ctx context.Context, user string, limit int) ([]session.ChatRecord, error) {
|
|
return s.ListChatsWhere(ctx, user, limit, nil)
|
|
}
|
|
|
|
// ListChatsWhere is ListChats for the chats that keep returns true for (nil
|
|
// keeps all). The limit counts the chats kept, so a flood of chats that a
|
|
// connector or a trigger started cannot push a person's chats out of a page of
|
|
// person-started chats, and the other way round.
|
|
func (s *Service) ListChatsWhere(ctx context.Context, user string, limit int, keep func(session.ChatRecord) bool) ([]session.ChatRecord, error) {
|
|
var out []session.ChatRecord
|
|
var cur session.ChatCursor
|
|
for {
|
|
page, err := s.d.Store.ListChats(ctx, principal(user), cur, 200)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range page {
|
|
if r.Kind == session.KindSubAgent || (keep != nil && !keep(r)) {
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
if limit > 0 && len(out) >= limit {
|
|
return out, nil
|
|
}
|
|
}
|
|
if len(page) < 200 {
|
|
return out, nil
|
|
}
|
|
last := page[len(page)-1]
|
|
cur = session.ChatCursor{Updated: last.Updated, ID: last.ID}
|
|
}
|
|
}
|
|
|
|
func principal(user string) string {
|
|
if user == "" {
|
|
return AnonymousPrincipal
|
|
}
|
|
return user
|
|
}
|
|
|
|
func key(p, conv string) string { return p + "\x00" + conv }
|
|
|
|
func newID() string {
|
|
var b [12]byte
|
|
_, _ = rand.Read(b[:])
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
func (s *Service) lock(k string) func() {
|
|
s.mu.Lock()
|
|
l := s.locks[k]
|
|
if l == nil {
|
|
l = &keyLock{}
|
|
s.locks[k] = l
|
|
}
|
|
l.n++
|
|
s.mu.Unlock()
|
|
l.mu.Lock()
|
|
return func() {
|
|
l.mu.Unlock()
|
|
s.mu.Lock()
|
|
if l.n--; l.n == 0 {
|
|
delete(s.locks, k)
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Create starts a new conversation for user.
|
|
func (s *Service) Create(ctx context.Context, user string, in CreateInput) (session.ChatRecord, error) {
|
|
return s.CreateAs(ctx, user, in, Origin{})
|
|
}
|
|
|
|
// CreateAs creates a conversation of user that was started by origin: a person
|
|
// (the zero Origin), a trigger or a connector. The agent, when named, must be
|
|
// the user's own.
|
|
func (s *Service) CreateAs(ctx context.Context, user string, in CreateInput, origin Origin) (session.ChatRecord, error) {
|
|
model := in.Model
|
|
scoped := false
|
|
var agentOn []string
|
|
if in.Assistant {
|
|
if in.AgentID != "" {
|
|
return session.ChatRecord{}, ErrAssistantAgent
|
|
}
|
|
if origin.Kind != "" {
|
|
return session.ChatRecord{}, ErrAssistantForbidden
|
|
}
|
|
if err := s.assistantAllowed(ctx, user); err != nil {
|
|
return session.ChatRecord{}, err
|
|
}
|
|
}
|
|
if in.AgentID != "" {
|
|
if s.d.Agents == nil {
|
|
return session.ChatRecord{}, ErrAgentsOff
|
|
}
|
|
ag, err := s.d.Agents.Get(ctx, principal(user), in.AgentID)
|
|
if err != nil {
|
|
return session.ChatRecord{}, err
|
|
}
|
|
if ag.Model != "" {
|
|
model = ag.Model
|
|
}
|
|
agentOn = ag.BuiltinsOn
|
|
if ag.HasFolder() {
|
|
// A chat never starts on the whole home in place of a folder
|
|
// that is missing or not chosen.
|
|
sc, err := s.liveFolder(ctx, principal(user), ag)
|
|
if err != nil {
|
|
return session.ChatRecord{}, err
|
|
}
|
|
if _, _, err := s.scopedRoots(ctx, userChatActor(principal(user), ""), []*folderScope{sc}); err != nil {
|
|
return session.ChatRecord{}, err
|
|
}
|
|
scoped = true
|
|
}
|
|
}
|
|
if strings.TrimSpace(model) == "" {
|
|
return session.ChatRecord{}, ErrNoModel
|
|
}
|
|
sw, err := s.cleanSwitches(in.Switches)
|
|
if err != nil {
|
|
return session.ChatRecord{}, err
|
|
}
|
|
// The opt-in groups the agent starts with (saved agents) are on in its
|
|
// chats from the first turn; the chat may still switch them off.
|
|
sw.BuiltinsOn = mergeGroups(sw.BuiltinsOn, agentOn)
|
|
title := in.Title
|
|
if title == "" {
|
|
title = session.DefaultTitle
|
|
}
|
|
now := time.Now().UTC()
|
|
rec := session.ChatRecord{ID: newID(), Principal: principal(user), Title: title, Created: now, Updated: now, Kind: session.KindChat, Model: model,
|
|
AgentID: in.AgentID, ScopedAgent: scoped, Origin: origin.Kind, OriginID: origin.ID, Assistant: in.Assistant,
|
|
AddonsOff: orNil(sw.AddonsOff), McpOff: orNil(sw.McpOff), BuiltinsOff: orNil(sw.BuiltinsOff),
|
|
BuiltinsOn: orNil(sw.BuiltinsOn), SkillsOff: orNil(sw.SkillsOff)}
|
|
if origin.Kind == "" {
|
|
// A chat a person starts gets the instance default, or the network
|
|
// of its saved agent when the agent sets one, under the ceiling. An
|
|
// unattended run follows its agent's network field at each check.
|
|
set, err := s.egressSettings(ctx)
|
|
if err != nil {
|
|
return session.ChatRecord{}, err
|
|
}
|
|
rec.Egress = egress.ChatState{Preset: set.DefaultPreset()}
|
|
if in.AgentID != "" {
|
|
if n := s.agentNetwork(ctx, rec); n != egress.PresetOff {
|
|
rec.Egress.Preset = egress.Min(n, set.Ceiling)
|
|
}
|
|
}
|
|
}
|
|
if err := s.d.Store.CreateChat(ctx, rec); err != nil {
|
|
return session.ChatRecord{}, err
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
// Get returns a conversation the user owns.
|
|
func (s *Service) Get(ctx context.Context, user, conv string) (session.ChatRecord, error) {
|
|
return s.d.Store.GetChat(ctx, principal(user), conv)
|
|
}
|
|
|
|
// Messages returns transcript entries after seq.
|
|
func (s *Service) Messages(ctx context.Context, user, conv string, after int64, limit int) ([]session.Entry, error) {
|
|
if _, err := s.Get(ctx, user, conv); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.d.Store.Entries(ctx, conv, after, limit)
|
|
}
|
|
|
|
// Turn returns the turn state of a conversation.
|
|
func (s *Service) Turn(ctx context.Context, user, conv string) (store.TurnInfo, error) {
|
|
return s.d.Store.Turn(ctx, principal(user), conv)
|
|
}
|
|
|
|
// Principal returns the owner ID the service uses for a user: the user's ID,
|
|
// or the anonymous owner when authentication is off.
|
|
func (s *Service) Principal(user string) string { return principal(user) }
|
|
|
|
// Files returns the workspace of the chats. It is never nil.
|
|
func (s *Service) Files() *files.Service { return s.d.Files }
|
|
|
|
// PageConsents returns the store of the consents to shared pages, or nil.
|
|
func (s *Service) PageConsents() pages.ConsentStore { return s.d.PageConsents }
|
|
|
|
// SharedPagesOn says whether the pages of shared folders are on now.
|
|
func (s *Service) SharedPagesOn() bool { return s.d.SharedPagesOn == nil || s.d.SharedPagesOn() }
|
|
|
|
// UserName returns the name of an account to show to another user: its
|
|
// display name when the server has accounts, the principal otherwise.
|
|
func (s *Service) UserName(ctx context.Context, id string) string {
|
|
if s.d.Users == nil || id == "" {
|
|
return id
|
|
}
|
|
if u, err := s.d.Users(ctx, id); err == nil && u != nil && u.Name != "" {
|
|
return u.Name
|
|
}
|
|
return id
|
|
}
|
|
|
|
// RemoteModel says whether model runs on a remote service.
|
|
func (s *Service) RemoteModel(model string) bool {
|
|
return s.d.RemoteModel != nil && model != "" && s.d.RemoteModel(model)
|
|
}
|
|
|
|
// Persistent reports whether conversations survive a restart of the server.
|
|
func (s *Service) Persistent() bool { return s.d.Persistent }
|
|
|
|
// ImageModel names the model that describes images for a chat whose own model
|
|
// cannot look at them, or "" when there is none.
|
|
func (s *Service) ImageModel() string { return s.d.Media.VisionModel("") }
|
|
|
|
// Pending lists the messages waiting for the model.
|
|
func (s *Service) Pending(ctx context.Context, user, conv string) ([]store.Pending, error) {
|
|
return s.d.Store.Pending(ctx, principal(user), conv)
|
|
}
|
|
|
|
// EditPending changes a waiting message.
|
|
func (s *Service) EditPending(ctx context.Context, user, conv, id, text string) error {
|
|
p := principal(user)
|
|
// The person edits the words; the files of the message stay with it.
|
|
waiting, err := s.d.Store.Pending(ctx, p, conv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, w := range waiting {
|
|
if w.ID == id {
|
|
_, note, atts := session.SplitMessage(w.Text)
|
|
text = session.ComposeMessage(text, note, atts)
|
|
break
|
|
}
|
|
}
|
|
return s.d.Store.EditPending(ctx, p, conv, id, text)
|
|
}
|
|
|
|
// CancelPending removes a waiting message.
|
|
func (s *Service) CancelPending(ctx context.Context, user, conv, id string) error {
|
|
return s.d.Store.CancelPending(ctx, principal(user), conv, id)
|
|
}
|
|
|
|
// Events subscribes to the live events of a conversation. Call cancel when done.
|
|
func (s *Service) Events(ctx context.Context, user, conv string) (<-chan session.Event, func(), error) {
|
|
p := principal(user)
|
|
if _, err := s.d.Store.GetChat(ctx, p, conv); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
ch, cancel := s.bus.Subscribe(key(p, conv))
|
|
return ch, cancel, nil
|
|
}
|
|
|
|
// Send gives the conversation a message. With no turn running it starts one.
|
|
// With a turn running the message waits and reaches the model, together with
|
|
// any others, at the loop's next model request.
|
|
func (s *Service) Send(ctx context.Context, user, conv, text string) (SendResult, error) {
|
|
return s.SendWithFiles(ctx, user, conv, text, nil)
|
|
}
|
|
|
|
// SendWithFiles is Send with files attached. The files are saved in the chat's
|
|
// folder of the user's workspace and the model gets their paths. With a turn
|
|
// running they are saved at once and the message waits like any other, with
|
|
// the paths in its text. With files off it is files.ErrUnavailable.
|
|
func (s *Service) SendWithFiles(ctx context.Context, user, conv, text string, up []session.Upload) (SendResult, error) {
|
|
return s.sendWithFiles(ctx, user, conv, text, up, "", nil)
|
|
}
|
|
|
|
// sendWithFiles is SendWithFiles with the directive and the invocation of a
|
|
// slash command (see SendCommand).
|
|
func (s *Service) sendWithFiles(ctx context.Context, user, conv, text string, up []session.Upload, directive string, inv *session.Invocation) (SendResult, error) {
|
|
rec, err := s.d.Store.GetChat(ctx, principal(user), conv)
|
|
if err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
if rec.Kind == session.KindSubAgent {
|
|
return SendResult{}, ErrSubAgentReadOnly
|
|
}
|
|
if len(up) > 0 && !s.d.Files.Available() {
|
|
return SendResult{}, fmt.Errorf("%w: %s", files.ErrUnavailable, s.d.Files.Unavailable())
|
|
}
|
|
note := ""
|
|
if len(up) > 0 && s.d.Media != nil {
|
|
// Voice notes are transcribed and documents are read before the turn
|
|
// starts, and the text goes to the model with the message; the files
|
|
// are kept as they are. The transcript keeps that text apart from the
|
|
// user's words.
|
|
var perr error
|
|
if note, up, perr = s.d.Media.PrepareUploads(ctx, principal(user), "", up); perr != nil {
|
|
return SendResult{}, perr
|
|
}
|
|
if note, up, perr = s.d.Media.ExtractDocuments(ctx, note, up); perr != nil {
|
|
return SendResult{}, perr
|
|
}
|
|
}
|
|
up, parts, err := s.nativeMedia(rec.Model, up)
|
|
if err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
return s.send(ctx, user, conv, session.Message{Text: text, Note: note, Files: up, Media: parts, Directive: directive, Invoked: inv})
|
|
}
|
|
|
|
// joinPending puts waiting messages and msg, in that order, into one message:
|
|
// the words are joined by blank lines, the files and the text read from them
|
|
// follow. A waiting message holds the text the model gets (see
|
|
// session.ComposeMessage), so its files are split out of it.
|
|
func joinPending(taken []store.Pending, msg session.Message) session.Message {
|
|
if len(taken) == 0 {
|
|
return msg
|
|
}
|
|
var (
|
|
texts, notes []string
|
|
atts []session.Attachment
|
|
)
|
|
for _, t := range taken {
|
|
body, note, a := session.SplitMessage(t.Text)
|
|
if body != "" {
|
|
texts = append(texts, body)
|
|
}
|
|
notes, atts = append(notes, note), append(atts, a...)
|
|
}
|
|
if strings.TrimSpace(msg.Text) != "" {
|
|
texts = append(texts, msg.Text)
|
|
}
|
|
msg.Text = strings.Join(texts, "\n\n")
|
|
msg.Note = strings.Join(notes, "") + msg.Note
|
|
msg.Attachments = append(atts, msg.Attachments...)
|
|
return msg
|
|
}
|
|
|
|
// send is SendWithFiles without the check on the kind: the service itself
|
|
// starts sub-agent conversations through it.
|
|
func (s *Service) send(ctx context.Context, user, conv string, msg session.Message) (SendResult, error) {
|
|
p := principal(user)
|
|
if _, err := s.d.Store.GetChat(ctx, p, conv); err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
unlock := s.lock(key(p, conv))
|
|
defer unlock()
|
|
runID := newID()
|
|
switch err := s.d.Store.ClaimTurn(ctx, p, conv, runID); {
|
|
case err == nil:
|
|
s.nameAfter(ctx, p, conv, msg.Text)
|
|
if err := s.startClaimed(ctx, p, conv, runID, msg); err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
return SendResult{Started: true}, nil
|
|
case errors.Is(err, store.ErrTurnRunning):
|
|
if msg.Directive != "" {
|
|
// A waiting message is plain text: the directive would reach the
|
|
// transcript as words the person did not write.
|
|
return SendResult{}, ErrCommandBusy
|
|
}
|
|
atts := msg.Attachments
|
|
if len(msg.Files) > 0 {
|
|
// The files are saved now, so the waiting message can carry their paths.
|
|
saved, aerr := s.attachWhileRunning(ctx, p, conv, msg.Files)
|
|
if aerr != nil {
|
|
return SendResult{}, aerr
|
|
}
|
|
atts = append(slices.Clip(atts), saved...)
|
|
}
|
|
// A waiting message holds the text the model gets; the transcript and
|
|
// the list of waiting messages split the files out of it again.
|
|
pend, err := s.d.Store.AddPending(ctx, p, conv, session.ComposeMessage(msg.Text, msg.Note, atts))
|
|
if err != nil {
|
|
return SendResult{}, err
|
|
}
|
|
// The turn may have ended on another frontend between the claim and the
|
|
// queue. Nothing would start for this message then, so look again; the
|
|
// claim decides who starts it.
|
|
if info, terr := s.d.Store.Turn(ctx, p, conv); terr == nil && !info.Running && info.Last.Finished() {
|
|
if _, err := s.startFromPending(ctx, p, conv); err != nil {
|
|
s.log.Warn("studio: could not start a turn for a message that arrived late", "chat", conv, "error", err)
|
|
}
|
|
}
|
|
return SendResult{PendingID: pend.ID}, nil
|
|
default:
|
|
return SendResult{}, err
|
|
}
|
|
}
|
|
|
|
// startClaimed runs msg as the turn that the claim runID holds, after the
|
|
// messages that wait: the new one must not overtake them. The caller holds the
|
|
// lock of the chat.
|
|
func (s *Service) startClaimed(ctx context.Context, p, conv, runID string, msg session.Message) error {
|
|
taken, err := s.d.Store.TakePending(ctx, p, conv)
|
|
if err != nil {
|
|
_ = s.d.Store.ReleaseTurn(ctx, p, conv, runID, store.TurnFailed)
|
|
return err
|
|
}
|
|
ids := make([]string, len(taken))
|
|
for i, t := range taken {
|
|
ids[i] = t.ID
|
|
}
|
|
if err := s.startTurn(ctx, p, conv, runID, joinPending(taken, msg)); err != nil {
|
|
_ = s.d.Store.RestorePending(ctx, p, conv, ids)
|
|
return err
|
|
}
|
|
if len(ids) > 0 {
|
|
s.confirm(p, conv, ids)
|
|
s.bus.Publish(key(p, conv), session.Event{Kind: session.KindPendingDelivered, IDs: ids})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// startIdle starts a turn with msg only when the chat is idle: unlike send, it
|
|
// never queues the message behind a running turn. It reports whether the turn
|
|
// started. A loop uses it, so that a busy chat skips the run instead of
|
|
// piling runs up.
|
|
func (s *Service) startIdle(ctx context.Context, p, conv string, msg session.Message) (bool, error) {
|
|
unlock := s.lock(key(p, conv))
|
|
defer unlock()
|
|
runID := newID()
|
|
switch err := s.d.Store.ClaimTurn(ctx, p, conv, runID); {
|
|
case errors.Is(err, store.ErrTurnRunning):
|
|
return false, nil
|
|
case err != nil:
|
|
return false, err
|
|
}
|
|
if err := s.startClaimed(ctx, p, conv, runID, msg); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// attachWhileRunning saves the files of a message that waits for a running
|
|
// turn and returns them. The turn may run on another frontend; the files go to
|
|
// the shared workspace either way.
|
|
func (s *Service) attachWhileRunning(ctx context.Context, p, conv string, up []session.Upload) ([]session.Attachment, error) {
|
|
s.mu.Lock()
|
|
r := s.runs[key(p, conv)]
|
|
s.mu.Unlock()
|
|
if r != nil {
|
|
return r.chat.Attach(ctx, up)
|
|
}
|
|
chat, err := s.chat(ctx, p, conv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if cerr := chat.Close(); cerr != nil {
|
|
s.log.Warn("studio: could not close the chat after saving attachments", "chat", conv, "error", cerr)
|
|
}
|
|
}()
|
|
return chat.Attach(ctx, up)
|
|
}
|
|
|
|
// nameAfter gives a chat that still has the default title one made from its
|
|
// first message. The caller holds the claim of the turn and the chat is not
|
|
// open, so nothing writes the old title back. A failure only leaves the default.
|
|
func (s *Service) nameAfter(ctx context.Context, p, conv, text string) {
|
|
rec, err := s.d.Store.GetChat(ctx, p, conv)
|
|
if err != nil || rec.Title != session.DefaultTitle || rec.Turns > 0 {
|
|
return
|
|
}
|
|
title := session.TitleFrom(text)
|
|
if title == "" {
|
|
return
|
|
}
|
|
rec.Title = title
|
|
if err := s.d.Store.UpdateChat(ctx, rec); err != nil {
|
|
s.log.Warn("studio: could not name a chat after its first message", "chat", conv, "error", err)
|
|
}
|
|
}
|
|
|
|
// SendNow starts a turn from the waiting messages. It is what the user chooses
|
|
// after a Stop or a failure; nothing else starts them then.
|
|
func (s *Service) SendNow(ctx context.Context, user, conv string) error {
|
|
p := principal(user)
|
|
if _, err := s.d.Store.GetChat(ctx, p, conv); err != nil {
|
|
return err
|
|
}
|
|
unlock := s.lock(key(p, conv))
|
|
defer unlock()
|
|
_, err := s.startFromPending(ctx, p, conv)
|
|
return err
|
|
}
|
|
|
|
// Stop ends the running turn. Waiting messages stay. The turn may run on another
|
|
// frontend, which then hears the request over the bus.
|
|
func (s *Service) Stop(ctx context.Context, user, conv string) error {
|
|
p := principal(user)
|
|
if _, err := s.d.Store.GetChat(ctx, p, conv); err != nil {
|
|
return err
|
|
}
|
|
// The sub-agents of the chat stop with it, also when its own turn has
|
|
// ended and they run on in the background.
|
|
s.stopChildren(p, conv)
|
|
k := key(p, conv)
|
|
s.mu.Lock()
|
|
local := s.runs[k] != nil
|
|
s.mu.Unlock()
|
|
// The sub-agents of a conversation stop with it, even when the parent has
|
|
// no turn left to stop: a background sub-agent can outlive the turn.
|
|
s.stopChildren(p, conv)
|
|
// Stop pauses the chat's goal, also between two of its turns: nothing
|
|
// goes on by itself after a Stop.
|
|
s.pauseGoal(ctx, p, conv, session.GoalPausedByUser)
|
|
if !local {
|
|
s.bus.Control(k, ControlMsg{Stop: true})
|
|
return nil
|
|
}
|
|
return s.stopLocal(ctx, p, conv)
|
|
}
|
|
|
|
// stopLocal stops a turn that runs in this process.
|
|
func (s *Service) stopLocal(ctx context.Context, p, conv string) error {
|
|
k := key(p, conv)
|
|
s.mu.Lock()
|
|
r := s.runs[k]
|
|
if r != nil {
|
|
r.stopped = true
|
|
}
|
|
s.mu.Unlock()
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
return r.chat.Stop(ctx)
|
|
}
|
|
|
|
// startFromPending starts a turn whose message is the waiting messages joined
|
|
// in order. It reports whether it started one. The caller holds the lock.
|
|
func (s *Service) startFromPending(ctx context.Context, p, conv string) (bool, error) {
|
|
runID := newID()
|
|
// A claim that finds nothing to run gives the chat back as it was: a
|
|
// paused turn must still read as paused.
|
|
last := store.TurnDone
|
|
if info, terr := s.d.Store.Turn(ctx, p, conv); terr == nil && info.Last != "" {
|
|
last = info.Last
|
|
}
|
|
if err := s.d.Store.ClaimTurn(ctx, p, conv, runID); err != nil {
|
|
if errors.Is(err, store.ErrTurnRunning) {
|
|
return false, nil // a turn is running; it takes them at its next request
|
|
}
|
|
return false, err
|
|
}
|
|
taken, err := s.d.Store.TakePending(ctx, p, conv)
|
|
if err != nil || len(taken) == 0 {
|
|
_ = s.d.Store.ReleaseTurn(ctx, p, conv, runID, last)
|
|
if err == nil {
|
|
// A message may have been queued on another frontend while this claim
|
|
// was held: it saw a running turn and did not start one. Look again
|
|
// now that the claim is free.
|
|
if left, perr := s.d.Store.Pending(ctx, p, conv); perr == nil && len(left) > 0 {
|
|
return s.startFromPending(ctx, p, conv)
|
|
}
|
|
}
|
|
return false, err
|
|
}
|
|
ids := make([]string, len(taken))
|
|
for i, t := range taken {
|
|
ids[i] = t.ID
|
|
}
|
|
if err := s.startTurn(ctx, p, conv, runID, joinPending(taken, session.Message{})); err != nil {
|
|
_ = s.d.Store.RestorePending(ctx, p, conv, ids)
|
|
return false, err
|
|
}
|
|
s.confirm(p, conv, ids)
|
|
s.bus.Publish(key(p, conv), session.Event{Kind: session.KindPendingDelivered, IDs: ids})
|
|
return true, nil
|
|
}
|
|
|
|
// startTurn runs msg as a turn the claim runID already covers.
|
|
func (s *Service) startTurn(ctx context.Context, p, conv, runID string, msg session.Message) error {
|
|
chat, err := s.chat(ctx, p, conv)
|
|
if err != nil {
|
|
_ = s.d.Store.ReleaseTurn(ctx, p, conv, runID, store.TurnFailed)
|
|
return err
|
|
}
|
|
k := key(p, conv)
|
|
// While the turn runs, tell the store it is alive and listen for a stop
|
|
// requested on another frontend.
|
|
hbCtx, stopBeat := context.WithCancel(context.Background())
|
|
go s.heartbeat(hbCtx, p, conv, runID) // #nosec G118 -- the heartbeat outlives the request and stops with the turn
|
|
stopListening := s.bus.OnControl(k, func(m ControlMsg) {
|
|
if m.Stop {
|
|
if err := s.stopLocal(context.Background(), p, conv); err != nil {
|
|
s.log.Warn("studio: could not stop a turn on request", "chat", conv, "error", err)
|
|
}
|
|
}
|
|
})
|
|
end := func() { stopBeat(); stopListening() }
|
|
// The cards still pending in the record belong to an earlier turn, whose
|
|
// frontend may have died: nobody waits on them any more.
|
|
s.expireConsents(ctx, p, conv, "")
|
|
s.mu.Lock()
|
|
s.runs[k] = &run{id: runID, started: time.Now(), end: end, chat: chat}
|
|
s.mu.Unlock()
|
|
msg.Media = s.keepVideos(k, msg.Media)
|
|
if err := chat.Send(context.WithoutCancel(ctx), msg); err != nil {
|
|
s.mu.Lock()
|
|
delete(s.runs, k)
|
|
s.mu.Unlock()
|
|
end()
|
|
// s.chat opened the chat; with no turn running nothing else will close it.
|
|
if cerr := chat.Close(); cerr != nil {
|
|
s.log.Warn("studio: could not close the chat after a failed start", "chat", conv, "error", cerr)
|
|
}
|
|
_ = s.d.Store.ReleaseTurn(ctx, p, conv, runID, store.TurnFailed)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// heartbeat tells the store that a turn is alive until ctx ends.
|
|
func (s *Service) heartbeat(ctx context.Context, p, conv, runID string) {
|
|
every := s.d.HeartbeatEvery
|
|
if every <= 0 {
|
|
every = defaultHeartbeat
|
|
}
|
|
tick := time.NewTicker(every)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-tick.C:
|
|
err := s.d.Store.TouchTurn(ctx, p, conv, runID)
|
|
if err == nil || ctx.Err() != nil {
|
|
continue
|
|
}
|
|
if errors.Is(err, store.ErrNotClaimed) {
|
|
// Another frontend reaped this turn as dead and may have started a
|
|
// new one. Two turns on one chat must not run: stop this one.
|
|
s.log.Warn("studio: a turn lost its claim and is stopping", "chat", conv)
|
|
if serr := s.stopLocal(context.Background(), p, conv); serr != nil {
|
|
s.log.Warn("studio: could not stop a turn that lost its claim", "chat", conv, "error", serr)
|
|
}
|
|
return
|
|
}
|
|
s.log.Warn("studio: could not record a turn's heartbeat", "chat", conv, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ReapStale ends the turns that have not beaten for olderThan and tells the
|
|
// watchers of each. A frontend that died leaves such turns behind. In a cluster
|
|
// one frontend runs this on a timer; a standalone process runs it once at
|
|
// startup with a zero duration, because every claim then belongs to a process
|
|
// that no longer exists.
|
|
func (s *Service) ReapStale(ctx context.Context, olderThan time.Duration) (int, error) {
|
|
reaped, err := s.d.Store.ReapTurns(ctx, olderThan)
|
|
for _, r := range reaped {
|
|
k := key(r.Principal, r.ChatID)
|
|
// The frontend that ran the turn is gone; so are the waiters of its
|
|
// cards.
|
|
s.expireConsents(ctx, r.Principal, r.ChatID, r.RunID)
|
|
s.bus.Publish(k, session.Event{Kind: session.KindError, Text: "The turn was interrupted."})
|
|
s.bus.Publish(k, session.Event{Kind: session.KindTurnEnd})
|
|
// A parent waits for a sub-agent whose turn ended this way too.
|
|
s.notifyWaiters(k, store.TurnInterrupted)
|
|
// The goal of a turn that died does not go on by itself: the frontend
|
|
// that would have continued it is gone, and the person resumes it.
|
|
s.pauseGoal(ctx, r.Principal, r.ChatID, session.GoalPausedByInterrupted)
|
|
}
|
|
return len(reaped), err
|
|
}
|
|
|
|
// chat opens the conversation in the loop and makes sure its events are read.
|
|
func (s *Service) chat(ctx context.Context, p, conv string) (*session.Chat, error) {
|
|
if s.d.Files.Available() {
|
|
// The loop needs the user's home before it opens a chat.
|
|
if err := s.d.Files.EnsureHome(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// The access is resolved at every open, which every turn does: an edit
|
|
// of the chat's agent applies from the next turn.
|
|
rec, err := s.d.Store.GetChat(ctx, p, conv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
x, err := s.chatAccess(ctx, rec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chat, err := s.mgr.Open(ctx, x, conv)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
k := key(p, conv)
|
|
// A chat that was closed after its last turn has a new event channel when
|
|
// it reopens, so the pump is told apart by its channel, not by the chat:
|
|
// the pump of the old channel may not have noticed that it ended yet.
|
|
evs := chat.Events()
|
|
s.mu.Lock()
|
|
start := s.pumps[k] != evs
|
|
s.pumps[k] = evs
|
|
s.mu.Unlock()
|
|
if start {
|
|
go s.pump(chat, evs, p, conv)
|
|
}
|
|
return chat, nil
|
|
}
|
|
|
|
// pump reads the events of one chat for its lifetime, publishes them, and
|
|
// settles the turn when it ends.
|
|
func (s *Service) pump(chat *session.Chat, evs <-chan session.Event, p, conv string) {
|
|
k := key(p, conv)
|
|
defer func() {
|
|
s.mu.Lock()
|
|
if s.pumps[k] == evs { // a newer pump may have taken the place
|
|
delete(s.pumps, k)
|
|
}
|
|
s.mu.Unlock()
|
|
}()
|
|
for ev := range evs {
|
|
if ev.Kind == session.KindTurnEnd {
|
|
// The end is recorded before it is announced: a client that reads
|
|
// the chat when it sees turn_end must not find it still running.
|
|
next := s.finishTurn(chat, p, conv)
|
|
s.bus.Publish(k, ev)
|
|
next()
|
|
continue
|
|
}
|
|
s.bus.Publish(k, ev)
|
|
switch ev.Kind {
|
|
case session.KindError:
|
|
// Only the error that ended the turn fails it. A failed save or a
|
|
// recovered tool panic leaves a turn that finished normally.
|
|
if !ev.Fatal {
|
|
break
|
|
}
|
|
s.mu.Lock()
|
|
if r := s.runs[k]; r != nil {
|
|
r.failed = true
|
|
}
|
|
s.mu.Unlock()
|
|
case session.KindPaused:
|
|
s.mu.Lock()
|
|
if r := s.runs[k]; r != nil {
|
|
r.paused = true
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// finishTurn records how the turn ended. It returns what to run once the end
|
|
// is announced: after a normal end only, it starts the next turn from messages
|
|
// that arrived too late for the last request.
|
|
func (s *Service) finishTurn(chat *session.Chat, p, conv string) (next func()) {
|
|
next = func() {}
|
|
ctx := context.Background()
|
|
k := key(p, conv)
|
|
unlock := s.lock(k)
|
|
defer unlock()
|
|
s.mu.Lock()
|
|
r := s.runs[k]
|
|
delete(s.runs, k)
|
|
s.mu.Unlock()
|
|
if r == nil {
|
|
return next
|
|
}
|
|
if r.end != nil {
|
|
r.end()
|
|
}
|
|
// Another frontend may run the next turn, so nothing of this chat is kept in
|
|
// memory past its turn, and is saved before the claim is given up: the next turn here loads the record again.
|
|
if err := chat.Close(); err != nil {
|
|
s.log.Warn("studio: could not close the chat after its turn", "chat", conv, "error", err)
|
|
}
|
|
// The network cards of the turn: unanswered ones expire, each is tied to
|
|
// its tool line.
|
|
s.settleEgressCards(ctx, p, conv, r.started)
|
|
status := store.TurnDone
|
|
switch {
|
|
case r.stopped:
|
|
status = store.TurnStopped
|
|
case r.failed:
|
|
status = store.TurnFailed
|
|
case s.isClosed():
|
|
// The server is going down: the turn was cut short, its text is partial,
|
|
// and nothing starts after it. The owner of a run is told.
|
|
status = store.TurnInterrupted
|
|
case r.paused:
|
|
status = store.TurnPaused
|
|
}
|
|
if err := s.releaseTurn(ctx, p, conv, r.id, status); err != nil {
|
|
s.log.Warn("studio: could not release the turn", "chat", conv, "error", err)
|
|
// The turn is over all the same, and a parent may wait for it. If the
|
|
// claim was reaped, whoever holds the chat now decides what runs next;
|
|
// if the store is failing, the reaper frees the chat later. Either way
|
|
// this one only cleans up after itself and reports a terminal status.
|
|
status = store.TurnInterrupted
|
|
}
|
|
s.settleGoal(ctx, p, conv, status, r.steps)
|
|
s.observeTurn(ctx, p, conv, r.started, outcomeOf(r.stopped, r.failed || status == store.TurnInterrupted))
|
|
s.notifyWaiters(k, status)
|
|
if !status.Finished() {
|
|
// A stopped or failed turn takes its running sub-agents with it, and the
|
|
// waiting messages stay for the user to decide.
|
|
s.stopChildren(p, conv)
|
|
return next
|
|
}
|
|
// The next turn is started from another goroutine: this one reads the events
|
|
// that the new turn will produce.
|
|
return func() {
|
|
go func() {
|
|
unlock := s.lock(k)
|
|
defer unlock()
|
|
// The person's waiting messages go first; with none, an active
|
|
// goal goes on by itself.
|
|
started, err := s.startFromPending(ctx, p, conv)
|
|
if err == nil && !started {
|
|
started, err = s.continueGoal(ctx, p, conv, goalContinue)
|
|
}
|
|
if err != nil {
|
|
s.log.Warn("studio: could not start the next turn", "chat", conv, "error", err)
|
|
return
|
|
}
|
|
if started {
|
|
// On a chat worker the next turn gets the same bound as the
|
|
// turn that a frontend started: nothing else watches it when
|
|
// that frontend is gone.
|
|
s.limitWorkerRun(ctx, p, conv)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// releaseTurn ends the claim. A store can fail for a moment (a locked SQLite
|
|
// database), and a chat left claimed stays "running" until the reaper finds
|
|
// it, so the release is tried a few times first.
|
|
func (s *Service) releaseTurn(ctx context.Context, p, conv, runID string, status store.TurnStatus) error {
|
|
const attempts = 5
|
|
wait := 25 * time.Millisecond
|
|
var err error
|
|
for i := range attempts {
|
|
if err = s.d.Store.ReleaseTurn(ctx, p, conv, runID, status); err == nil || errors.Is(err, store.ErrNotClaimed) {
|
|
return err
|
|
}
|
|
if i == attempts-1 {
|
|
break
|
|
}
|
|
select {
|
|
case <-time.After(wait):
|
|
case <-s.closed:
|
|
return err
|
|
}
|
|
wait *= 2
|
|
}
|
|
return err
|
|
}
|
|
|
|
// inputs is the source of pending messages for one chat's harness. It runs
|
|
// before each model request: it takes what is waiting, and if the request
|
|
// fails, puts it back.
|
|
func (s *Service) inputs(a workspace.Actor) func() ([]string, func(error)) {
|
|
return func() ([]string, func(error)) {
|
|
ctx := context.Background()
|
|
// The harness asks before each model request: that is one step of the
|
|
// turn, which the chat's goal counts.
|
|
s.mu.Lock()
|
|
if r := s.runs[key(a.Principal, a.ChatID)]; r != nil {
|
|
r.steps++
|
|
}
|
|
s.mu.Unlock()
|
|
taken, err := s.d.Store.TakePending(ctx, a.Principal, a.ChatID)
|
|
if err != nil || len(taken) == 0 {
|
|
return nil, nil
|
|
}
|
|
texts := make([]string, len(taken))
|
|
ids := make([]string, len(taken))
|
|
for i, t := range taken {
|
|
texts[i], ids[i] = t.Text, t.ID
|
|
}
|
|
// The loop counts a turn in the record only when it ends, so the turn
|
|
// that runs now is the next one.
|
|
turn := 0
|
|
if rec, gerr := s.d.Store.GetChat(ctx, a.Principal, a.ChatID); gerr == nil {
|
|
turn = rec.Turns + 1
|
|
}
|
|
return texts, func(err error) {
|
|
if err != nil {
|
|
if rerr := s.d.Store.RestorePending(ctx, a.Principal, a.ChatID, ids); rerr != nil {
|
|
s.log.Warn("studio: could not put pending messages back", "chat", a.ChatID, "error", rerr)
|
|
}
|
|
return
|
|
}
|
|
for _, t := range texts {
|
|
// The transcript keeps the words apart from the files.
|
|
body, note, atts := session.SplitMessage(t)
|
|
e := session.Entry{Turn: turn, Role: session.RoleUser, Text: body, Note: note, Attachments: atts}
|
|
if _, aerr := s.d.Store.AppendEntry(ctx, a.ChatID, e); aerr != nil {
|
|
s.log.Warn("studio: could not record a delivered message", "chat", a.ChatID, "error", aerr)
|
|
}
|
|
}
|
|
s.confirm(a.Principal, a.ChatID, ids)
|
|
s.bus.Publish(key(a.Principal, a.ChatID), session.Event{Kind: session.KindPendingDelivered, IDs: ids})
|
|
}
|
|
}
|
|
}
|
|
|
|
// confirm tells the store that the messages are in the transcript, so a turn
|
|
// that is reaped later does not give them back.
|
|
func (s *Service) confirm(p, conv string, ids []string) {
|
|
if err := s.d.Store.ConfirmPending(context.Background(), p, conv, ids); err != nil {
|
|
s.log.Warn("studio: could not confirm delivered messages", "chat", conv, "error", err)
|
|
}
|
|
}
|
|
|
|
// llmFor gives the harness of a chat its model client, acting as the chat's owner.
|
|
func (s *Service) llmFor(a workspace.Actor) (nibharness.Model, error) {
|
|
ctx := context.Background()
|
|
rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
|
|
if err != nil {
|
|
return nibharness.Model{}, err
|
|
}
|
|
var user *auth.User
|
|
if s.d.Users != nil && a.Principal != AnonymousPrincipal {
|
|
if user, err = s.d.Users(ctx, a.Principal); err != nil {
|
|
return nibharness.Model{}, fmt.Errorf("studio: load the chat's user: %w", err)
|
|
}
|
|
}
|
|
set := rec.Settings
|
|
limit := set.ContextSize
|
|
if limit == 0 && s.d.ContextTokens != nil {
|
|
limit = s.d.ContextTokens(rec.Model)
|
|
}
|
|
params := provider.Params{
|
|
Temperature: set.Temperature, TopP: set.TopP, TopK: set.TopK, ReasoningEffort: set.ReasoningEffort,
|
|
OnUsage: func(u provider.Usage) { s.reportUsage(a, u, rec.Model, limit) },
|
|
TakeVideos: func() []string { return s.takeVideos(a.Principal, a.ChatID) },
|
|
// An image goes to the model with its message, not with every later one.
|
|
OnlyLastImages: true,
|
|
}
|
|
m := nibharness.Model{
|
|
LLM: s.d.Provider.LLMWith(user, rec.Model, params), Name: rec.Model,
|
|
ContextTokens: limit, ReasoningEffort: set.ReasoningEffort,
|
|
}
|
|
if s.d.AgentOptions != nil {
|
|
m.Agent = s.d.AgentOptions(rec.Model)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// reportUsage tells the watchers of the chat what one model request used.
|
|
func (s *Service) reportUsage(a workspace.Actor, u provider.Usage, model string, limit int) {
|
|
s.bus.Publish(key(a.Principal, a.ChatID), session.Event{Kind: session.KindUsage, Usage: &session.UsageEvent{
|
|
Used: u.PromptTokens, Limit: limit, Completion: u.CompletionTokens, TokensPerSecond: u.TokensPerSecond,
|
|
Model: model, Node: u.Node, PromptMillis: u.PromptMillis,
|
|
}})
|
|
}
|
|
|
|
// Delete removes a conversation and its transcript. A running turn is stopped
|
|
// first, and so are its running sub-agents. Another user's conversation is
|
|
// session.ErrNotFound.
|
|
func (s *Service) Delete(ctx context.Context, user, conv string) error {
|
|
p := principal(user)
|
|
if _, err := s.d.Store.GetChat(ctx, p, conv); err != nil {
|
|
return err
|
|
}
|
|
if err := s.Stop(ctx, p, conv); err != nil {
|
|
return err
|
|
}
|
|
s.stopChildren(p, conv)
|
|
// A delete is the person's action: it needs no access of the chat, so a
|
|
// chat whose agent folder is missing can still be deleted.
|
|
if err := s.mgr.Delete(ctx, userChatActor(p, conv), conv); err != nil {
|
|
return err
|
|
}
|
|
if s.loops != nil {
|
|
s.loops.forgetChat(ctx, p, conv)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MCPConnectors returns the service that keeps the users' MCP connectors, or
|
|
// nil when the chats have none.
|
|
func (s *Service) MCPConnectors() *mcpconns.Service { return s.d.MCPConnectors }
|
|
|
|
// mergeGroups returns a with the entries of b it lacks, in order.
|
|
func mergeGroups(a, b []string) []string {
|
|
for _, g := range b {
|
|
if !slices.Contains(a, g) {
|
|
a = append(a, g)
|
|
}
|
|
}
|
|
return a
|
|
}
|