mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-03 19:14:56 -05:00
* Store the folder and its mode on a saved agent
A saved agent gets FolderID, the workspace node of its folder, and
FolderMode, read or write. The folder is kept by node so that a rename
or a move is followed. A mode without a node is an agent whose folder
is not chosen yet; Validate accepts it, and refuses a node without a
mode.
The GORM store adds two columns with defaults, so AutoMigrate fills
the rows of existing agents as agents without a folder. The JSON store
reads a file without the keys the same way. Both stores pass the new
contract case.
Assisted-by: Claude:claude-opus-5-5
* Keep on a chat whether its saved agent had a folder
A chat whose agent is deleted runs today without the agent's grants.
When that agent had a folder, nothing is left to say which folder the
chat may read, so the chat must be refused instead of getting the whole
home. ChatRecord.ScopedAgent records that the agent had a folder.
CreateChat writes the flag and SetScopedAgent changes it. UpdateChat
leaves it alone, like the tool switches, so the loop's stale copy of
the record cannot clear it. Both chat stores read chats stored before
the flag as not scoped; the GORM column has a default, so AutoMigrate
is the migration.
Assisted-by: Claude:claude-opus-5-5
* Add the design and plan for agent folder scope
A saved agent works on the whole home folder of its user. The spec
lets the user give an agent one folder and a mode, read only or read
and write, and confines every chat that runs it to that folder.
The workspace store enforces the folder through a fence on the actor,
so a caller that forgets it gets less access, not more. The spec maps
every caller that builds a user actor today, including the host bash,
the sandbox runner and the add-on workspace operations, and how each
is moved to the chat's access.
The plan splits the work into ten lanes; the store fence and the agent
record come first.
Assisted-by: Claude:claude-opus-5-5
* Resolve the folder of a saved agent by node
A person chooses an agent's folder by its path in their home; the
agent keeps the node, so a rename or a move is followed. The new
FolderResolver turns the path into the node and back, always as the
user. A path is refused when it is not a live folder, when it is
LocalAI/ or Chats/ or inside them, or when it names a parent. The
home itself ("/") is allowed.
The workspace stores cannot find a node by its ID yet. PathOf reads
the newest change sets of the home first, checks the path it finds
with Stat, and else lists the folders the user can read. A store that
implements NodeByID is used instead. Nothing is cached, so every
frontend answers the same.
SaveAgent keeps the stored folder when it replaces an agent, so a
caller that knows nothing of folders cannot widen one.
SaveAgentWithFolder sets or clears it. ImportAgent looks the folder of
a file up in the importer's home, and saves a folder that is not there
as unset: the mode without a node, which does not start.
Assisted-by: Claude:claude-opus-5-5
* Add a fence to the workspace actor
A chat whose saved agent has a folder must reach only that folder and
the chat's own folders. The store needs to know which folders those
are on every call, so the actor carries them.
Actor.Fenced returns a copy that reaches only the given folders, by
node ID, each at most at its mode. The fence is an unexported field:
a copy of the actor keeps it, and only Fenced can set it. Fencing a
fenced actor adds a layer, and a node is reached only when every
layer allows it, so a fence never grows.
The helpers in export.go let both store implementations apply the
same rules: the mode a fence allows on a node, its roots, and the
limit of MaxFenceRoots roots per call.
Assisted-by: Claude:claude-opus-5-5
* Show and choose the folder of an agent in the API
The agent routes carry folder. A write takes {path, mode}: the server
turns the path into the folder's node, and refuses with 400 a path
that is not a folder of the user's home, or is LocalAI or Chats. A
write without folder keeps the agent's folder, so a client that does
not know the field cannot widen the agent; {path: ""} clears it. A
read answers the folder's path now, its name, the mode and a state:
ok, missing or unset. The node never leaves the server.
An agent with a folder exports as version 2, with the folder's path
and mode. A server that reads only version 1 refuses that file, so a
scoped agent is never imported elsewhere as one with all the files.
An agent without a folder still exports as version 1. The import
reads both versions and looks the folder up in the importer's home;
a folder that is not there saves the agent as unset, with a warning.
The Swagger spec, the API instructions and the agents docs page show
the field and the new file version.
Assisted-by: Claude:claude-opus-5-5
* Enforce the actor fence in both workspace stores
The store is the one place every file call of a chat goes through, so
it applies the fence: a caller that forgets the agent's folder gets
"not found", never the whole workspace.
- The mode of a fenced actor on a node is the lower of its grants and
the fence. A root in the Trash counts for nothing, and is back after
Undo, because the fence holds the node ID.
- A fenced actor never takes the administrator shortcut, so it cannot
grant, revoke or list grants, and ReadablePrefixes gives only fence
roots or folders inside them.
- A root cannot be deleted, moved or renamed by the fenced actor, by
any path: Move, Delete, Undo, an upper layer, or a delete of a folder
above a nested root.
- A fence with too many roots or a bad root is ErrInvalid.
The shared suite in wstest runs the new specs against the JSON store
and the GORM store (SQLite and PostgreSQL).
Assisted-by: Claude:claude-opus-5-5
* Map the model's paths through the chat's place
A chat's file tools spelled every path as a path in the user's home.
A chat whose agent has a folder sees that folder at /workspace/home and
its own chat folder at /workspace/chat, so the mapping must come from
the mounts of the chat's view, not from the principal.
session.Place maps a path the model writes to a workspace path: "/x",
"x", "home/x" and "/workspace/home/x" are x in the primary mount,
"/workspace/<name>/x" is x in the mount of that exact name, and the
skill layers keep their names. It refuses "..", NUL, control characters
and invalid UTF-8 wherever they point, and a /workspace path that is no
mount. Encoded and look-alike separators stay part of a name. Place is
only a mapping: the store's fence enforces the access.
session.Access holds a turn's actor, view and place. It has no exported
fields and one constructor, so a tool cannot be built from a principal
string. Owner gives the user's own actor without the fence.
Assisted-by: Claude:claude-opus-5-5
* Build a chat's view from its folders and describe it
BuildView took the home of the principal as the folder at
/workspace/home. A chat whose agent has a folder needs that folder
there, at the agent's mode, and its own chat folder beside it.
BuildView now takes the primary folder and the chat folder. The chat
folder is mounted at /workspace/chat, read and write; the name chat is
reserved like the skill layer names. The store caps each mount at the
actor's mode, and a fenced actor gets the shared folders read only.
Every caller passes the home and no chat folder, so an unscoped chat
keeps today's view.
LayoutPrompt takes a ScopedPrompt. A scoped chat hears only of its
folders: the agent's folder by its name and mode, with a line that it
cannot see the user's other files, the chat folder and where its
working files go, and the shared folders. It gets no hint to build a
page, because pages live outside its folder. An unscoped chat keeps
today's text.
Assisted-by: Claude:claude-opus-5-5
* Find a workspace node by its ID
A caller that keeps a node ID, such as the folder of a saved agent,
had no way to ask the store where that node is now. It had to search
the journal and then list folders, which costs one query per folder
in a large home.
Store.NodeByID returns a live node at its current path, so a rename
or a move of the node or of a folder above it is followed. It answers
ErrNotFound when the node or a folder above it is deleted, and when
the actor cannot read it; a fenced actor finds only nodes inside its
fence. Both stores implement it and the shared suite checks it. The
remote store of the Sandbox add-on refuses it, like its other calls.
Assisted-by: Claude:claude-opus-5-5
* Name the work folder of a scoped chat at /workspace/chat
A chat whose agent has a folder keeps its chat folder in the home, but
sees it as a mount of its own at /workspace/chat. Its work folder is
then /workspace/chat/work, not a path under /workspace/home.
workfiles reads /workspace/chat as the chat folder: a helper file under
its work folder is working, a script beside the attachments is a stray
helper, and a folder named chat in the agent's folder is an ordinary
folder. ScopedPrompt steers the model to that work folder and sends the
deliverables to the agent's folder, or to the chat folder when the
agent may only read. It says nothing of pages or of the settings
folder, which are outside the agent's folder. The chat picks the text
by its view.
Assisted-by: Claude:claude-opus-5-5
* Look up the folder of an agent by node
The folder resolver searched the journal of the home and then listed
folders to find where an agent's folder is now. The stores can now
find a node by its ID, so the resolver asks them directly and the
search is gone.
Assisted-by: Claude:claude-opus-5-5
* Resolve the file and media tools through the chat's access
The file tools mapped paths to the user's home and acted as the chat's
actor, and the media tools did the same through files.HomePath. A chat
whose agent has a folder must reach only its folders, and search must
start in the agent's folder, so the count of documents still being
indexed cannot count files elsewhere in the home.
files.Service.Tools and media.Service.Tools take a session.Access. Every
path goes through its place and every call acts as its actor. A write
to a read-only folder is refused before the store sees it. A search
with no path starts at the primary folder, and in a scoped chat also
looks in the chat folder; the pending count is taken below that root.
files.HomePath is removed.
UnscopedAccess builds today's access of a chat (the home and the
shared folders), and the studio service takes the turn's access from
one function, turnAccess, which gives that access until the chat
access resolver replaces it.
The new specs run the paths that try to leave the folder (parents,
absolute and doubled paths, encoded and look-alike separators, NUL,
other cases) through every file tool and the media tools, and check
that nothing outside the folder is read, named or changed, and that
search counts no pending file outside it.
Assisted-by: Claude:claude-opus-5-5
* Resolve the access of a chat at every turn
A chat whose saved agent has a folder must reach only that folder. Up
to now every chat got the user's actor, which reaches the whole home.
The chat's access is now resolved from the stores at every turn
start. With no agent folder it is the actor and the view of today.
With a folder the actor is fenced to the agent's folder at its mode,
the chat's own folder read and write, and the shared folders and the
skill layers read only; the chat's folder is made before the fence,
because a fenced actor cannot make it. A sub-agent takes the folder
of its parent's agent, and its own agent's folder inside it.
A folder that is deleted, unreadable or not chosen refuses the chat
with ErrAgentFolderMissing; it never falls back to the whole home.
A chat records whether its agent had a folder, so a chat whose agent
was deleted is refused too. CreateAs refuses such an agent at once.
session.Access carries the actor and the view, has no exported fields
and one constructor. Open takes it, the turn tools are built from it,
and an open chat compares the fence as well, so an edit of the agent
restarts the harness at the next turn on any frontend. The person's
uploads and renames use the owner's actor, without the fence. The
files a command changed are read as the chat can read them.
Nothing is kept between turns, so frontends cannot disagree.
Assisted-by: Claude:claude-opus-5-5
* List a scoped chat's files by its place in the prompt
The turn prompt lists the chat's files with the paths the model uses.
For a chat whose agent has a folder those paths come from its mounts,
and a file the chat touched outside its folders, for example before
the agent got a folder, must not be named.
Manager.FilesIn is Files with a place. For a scoped place, each file
is named by its mount (/workspace/home for the agent's folder,
/workspace/chat for the chat folder) and a file outside every mount is
left out. Other places give what Files gives. The prompt's file list
uses the turn's place; the chats API keeps the user-wide list.
Assisted-by: Claude:claude-opus-5-5
* Answer 409 when an agent's folder is missing
A chat with a saved agent whose folder is missing or not chosen does
not start. The chat endpoints now say so with 409 and the type
agent_folder_missing, both when the chat is created and when a
message is sent to an open chat, instead of a server error.
The agent docs say what a chat with a folder reaches, when a change
of the folder applies, and that nothing is kept between turns.
Assisted-by: Claude:claude-opus-5-5
* Point a scoped chat at rules and work folders in its own folders
The rules nudge walked from the chat folder up to the home root and
named AGENTS.md files there, and the stray-helper note named the work
folder under /workspace/home. A chat whose agent has a folder must hear
only of its own folders.
The nudge takes the turn's access: it reads paths through the place,
starts at the primary folder (the agent's folder) and stops there, so
it never points above the folder or into the chat folder mount. The
helper note names /workspace/chat/work/ in a scoped chat, and a
sub-agent of a scoped parent gets the scoped work folder text.
Assisted-by: Claude:claude-opus-5-5
* Find workspace actors built outside the chat access
An actor built from a principal has no fence and reaches the whole
home. A spec now parses the studio service, the command runners, the
add-on host operations and the application wiring, and fails on a
workspace.Actor literal, a files.UserActor call or an Actor(principal)
call in a function that is not on the allowlist. Each allowed entry
says why: a host read of the user's settings, a user surface, or a
caller that a later change moves to the chat's access. An entry that
builds no actor any more fails the spec too, so the list stays exact.
Assisted-by: Claude:claude-opus-5-5
* Build the expected view of a chat with the view's folders
The spec of a chat without an agent folder compared its view with
BuildView called with the old arguments, so the test package did not
compile after the merge of the view builder. It now passes the home
as the primary folder and no chat folder, which is the view of an
unscoped chat.
Assisted-by: Claude:claude-opus-5-5
* Give every file tool of a turn the chat's resolved access
The resolver of a chat's access built the view of a scoped chat
itself, with no shared folders, while every other chat got its view
from the session manager. And the file tools, the media tools, the
chat's file list in the prompt, the rules nudge and the work folder
lines each asked for an access of their own, the view of the whole
home. A chat whose agent has a folder therefore had a fenced actor
but home-relative paths: it reached its folder only through paths
such as /Projects/Acme/a.txt, and its prompt named the home.
The session manager now builds every chat's view: UserAccess mounts
the home, and the new ScopedAccess mounts the agent's folder at home,
capped at the fence's mode, the chat folder at chat and the shared
folders read only. It refuses an actor without a fence, and a folder
outside the fence is an error, never a view of the home. The
resolver calls it with the innermost folder, so a sub-agent of a
saved agent still works in its own folder.
The access that the resolver builds once at the start of the turn
now goes down to every consumer. A sub-agent of a saved agent acts
for its parent chat with the same access (Access.ForChat), and a
generic sub-agent gets its tools from the parent's access. The seam
that gave the unscoped access, and the files service's own view
builder, are removed, so a new consumer cannot build a view by
mistake. A turn whose access has no folder gets no tools rather than
the home.
A scoped chat now sees its folder at /workspace/home and its chat
folder at /workspace/chat in its tools, its search, its prompt and
its work folder lines. A chat whose agent has no folder is unchanged.
Assisted-by: Claude:claude-opus-5-5
* Find chat views built outside the chat access
The turn's access is resolved once and handed down, but nothing
stopped new code from building a view of its own, which shows a
scoped chat the whole home. A second scan now parses the same
sources as the actor scan and fails on a call of BuildView,
SkillLayerMounts, NewAccess, UserAccess or ScopedAccess, or on a
store's View, in a function that is not on its allowlist. The
resolver is the one allowed caller; the host bash, the sandbox
runner and the add-on host operations are listed until they take
the chat's access. A stale entry fails the scan too.
Assisted-by: Claude:claude-opus-5-5
* Keep a sub-agent inside its parent's folder
A saved agent started from a scoped chat could name a folder
outside the chat's own, and the child was made before its first
turn failed with a message about a missing folder. spawn_agent now
checks the agent against the chat's agent from the stores before it
makes the child: no folder is fine, the same folder or one inside
it is fine, anything else fails with "this agent works in another
folder than this chat". The child never has more reach than its
parent, in folder or in mode.
The ScopedAgent flag of a sub-agent was reset to what its own
agent had at each turn. A child of a scoped chat with an agent
without a folder lost the mark, so deleting the parent's agent
would have given that child the whole home. The flag now follows
both folders.
New specs cover a generic child, a saved-agent child outside,
inside and at the parent's folder with the lower mode, a child
without a folder, and the work folder wording of a child of a
read-only or read-write parent.
Assisted-by: Claude:claude-sonnet-5-5
* Show agent folders in list_agents and limit them by scope
list_agents gave a scoped chat every saved agent, including ones
that spawn_agent would refuse, and told the model nothing about
where an agent works. Each entry now carries the name of the
agent's folder and its mode, or folder_missing when the folder is
deleted or not chosen. The path is left out, since it would show
the folders above the chat's own. A chat whose agent has a folder
lists only the agents that fit under that folder, by the same check
spawn_agent makes.
The docs for saved agents as sub-agents and for the agent folder
describe both.
Assisted-by: Claude:claude-sonnet-5-5
* Keep the triggers of a scoped agent to its folder
A chat of an agent with a folder reaches only that folder, but its
triggers still worked on the whole home. A file trigger could watch
any folder, the rules of a run were read from the home root down
with the user's actor, the first message named home paths, and a run
whose agent's folder was gone fell through to a failed chat start
after the host had already read the home's rules. The trigger tools
of a scoped chat could also list, change or propose triggers that
reach the whole home.
The agent's folder now bounds every step:
- Saving a file trigger (create, update, edit, confirm) refuses a
folder outside the agent's folder, and a trigger of an agent whose
folder is missing or not chosen.
- Each run checks the agent's folder before it reads anything. A
missing or unset folder skips the run with "the agent's folder is
missing" and switches the trigger off; a watched folder that is not
inside the agent's folder does the same with its own reason. No
chat is made and the owner is told.
- The folder check and the rules are read with an actor fenced to
the agent's folder, read only, so no AGENTS.md above it is read.
The rules of a schedule run start at the agent's folder. The first
message names folders and the file as the chat sees them, under
/workspace/home, and the rules nudge of the run's chat counts those
rules as given.
- In a scoped chat the trigger tools read folders as paths of the
chat's place, refuse one outside the agent's folder, and see only
the triggers of that agent.
The trigger watcher takes the service's administrator actor, made
once next to the change feed's, and the trigger functions leave the
actor scan's allowlist.
Assisted-by: Claude:claude-opus-5-5
* Send connector replies through the chat's access
The files a connector reply sends were listed from every change made
in the chat's name and read with the user's actor, so a reply of an
agent limited to one folder could send a file from anywhere in the
home that some host-side write had attributed to the chat. A message
to an agent whose folder was missing made or reopened a chat, failed
to start it, and answered with a generic error.
A reply now resolves the chat's access once, lists the turn's files
through its place (a file outside the chat's folders is left out)
and reads each one with its fenced actor. A file the access cannot
reach is skipped without being named. Before it makes a chat, a
message checks the bound agent's folder; when it is missing or not
chosen, the sender gets one reply, "This agent cannot work right
now: its folder is missing.", the refusal is logged and the binding
stays on. The same reply covers a folder that goes away between the
check and the turn. readChatFile leaves the actor scan's allowlist.
Assisted-by: Claude:claude-opus-5-5
* Name a read-only refusal of a commit by its guest path
The store names an upper entry that a read-only mount refused by
its path in the view (/home/x), not by its store path. The note
that a command's output ends with took every path but an
outside-view one for a store path, so it told the model that
"/home/x was not saved", a path the guest does not have. A path
that no mount holds but that starts with a mount name is now taken
as a path of the view and gets the /workspace prefix.
Assisted-by: Claude:claude-opus-5-5
* Take the actor and view of command runners from the chat's access
The host bash and the sandbox runner made their own actor and view:
the host bash a user actor from the principal and the view of the
whole home, the sandbox runner a plain actor by default and the view
from its own mount requests. Neither knew the chat's agent folder,
so a command of a scoped chat could reach the whole home.
The runner now has no default actor or view. Options.Actor takes a
context and returns an error, and Options.View returns the view
itself; both are asked at every call. A view of another actor than
the chat's is refused, and a sandbox whose actor or mounts changed
is made again, so an agent's new folder or mode applies from the
next command. A failing resolver fails the call, also with a sandbox
running.
The server wires both hooks to the chat's resolved access: the new
Service.CommandActor and Service.CommandView, the latter refusing an
actor that is no longer the chat's. Access.SandboxView adds the skill
layers to the turn's view, at the names the file tools use. The host
bash refuses a fenced actor, at the tool and again at each call, as a
command on this machine reads what the server can read. The Sandbox
add-on passes a label actor and its home view: the host binds each
workspace operation to the call's chat.
The lane 5 entries leave the actor and view scan allowlists.
Assisted-by: Claude:claude-opus-5-5
* Offer no host bash to a chat whose agent works in one folder
A command of the built-in bash runs on this machine and reads every
file LocalAI can read. No workspace rule confines it, so a chat whose
agent works in one folder cannot have it, whatever chats_host_exec
says. The tool's build now refuses a fenced actor, so the chat gets
no bash, its prompt says nothing runs commands, and the shell group
of the Tools menu gives the reason: the agent works in one folder
and runs commands only in the Sandbox add-on. Sub-agents of such a
chat have the fenced actor too.
The docs of saved agents and of the commands on this machine state
the rule and the Sandbox layout of a scoped chat.
Assisted-by: Claude:claude-opus-5-5
* Tell a scoped chat where its Sandbox commands start and what they keep
The commands block of the prompt told every chat with the Sandbox
that a command starts in /workspace/home, the user's home, and that
what it saves there or in a writable shared folder is kept. In a
chat whose agent works in one folder, /workspace/home is that folder,
the chat folder is /workspace/chat and the shared folders are read
only. The block now says so, and with a read-only folder it names
only /workspace/chat as kept.
Assisted-by: Claude:claude-opus-5-5
* Name a scoped chat's attachments where its commands see them
The model learns of an attached file by its guest path, which was
always /workspace/home/Chats/<chat>/<file>. In a chat whose agent
works in one folder, /workspace/home is that folder: the file and
media tools still found the file through the place's chat alias, but
a command sees the chat folder only at /workspace/chat, so the path
named nothing for it.
The text the model gets now names a file of the chat folder at
/workspace/chat/<file> in a scoped chat, through the chat's place:
when a message is sent, queued or injected, in the summary of the
chat, and in the model state rebuilt after an edit, a regenerate, a
fork or an import. What is stored keeps the home spelling, which
SplitMessage reads back, and an unscoped chat is unchanged.
Assisted-by: Claude:claude-opus-5-5
* Remove the duplicated allowlist entries of the actor scan
Assisted-by: Claude:claude-opus-5-5
* Read and write the folder of an agent in the agent helpers
The agent API now answers an agent's folder with its path, mode and
state, and a write keeps the stored folder unless it names one. The
page helpers learn that shape: the folder field of a save is sent only
when the person changed it, an empty path clears it, a missing or
unset folder that the person did not replace is kept, and the mode is
read unless write was chosen. Small helpers say whether a listing
entry can be chosen (not a folder LocalAI manages, not a shared
folder), which folders agents use for the Files page marker, and
whether a chat error is the missing-folder conflict.
Assisted-by: Claude:claude-opus-5-5
* Move the folder walk of the Move dialog into a folder picker
The agent editor needs the same walk through the person's folders as
the Move dialog, so the breadcrumb and the folder list move into
FolderPicker, with their styles, and the Move dialog draws it. The
picker can show a folder as one that cannot be chosen, with the
reason next to it, and a folder that cannot be read now offers to try
again; a folder still loading says so instead of an empty list.
Assisted-by: Claude:claude-opus-5-5
* Choose the folder of an agent in the agent form
The agent form gets a Files section after the instructions: All my
files, or One folder chosen in the folder picker, and whether the
agent's chats can read the folder or read and change it. The picker
shows the folders LocalAI manages as ones that cannot be chosen, and
Home itself can be chosen. A folder that was deleted, or an imported
agent whose folder was not found, is said in place with an offer to
choose another; the rest of the form still saves and keeps it.
A save sends the folder only when the person changed it, so editing
another field never touches it. Picking One folder and choosing
nothing stops the save, as it would otherwise save an agent on all
the files. The Export section now downloads the file the server
writes, which names the folder, and says so.
Assisted-by: Claude:claude-opus-5-5
* Show the folder of an agent on the agents list and its page
An agent with a folder shows it in the list's line and on its page,
with its access in words and a lock when its chats can only read it.
A folder that is gone, or an agent with no folder chosen yet, shows
a warning that opens the form; the agent's page also says so above
its facts and offers to choose another folder.
Assisted-by: Claude:claude-opus-5-5
* Mark the folders that agents work in on the Files page
A folder that a saved agent works in gets a small marker with the
agent's name, or the number of agents, and a tooltip that names each
one with its access. Inside such a folder, a line over the list says
the same. The page reads the agent list it can already fetch, so the
files API is unchanged, and the page still shows all the files.
Assisted-by: Claude:claude-opus-5-5
* Show a chat's folder and say when its agent's folder is missing
A chat with an agent that has a folder shows the folder next to the
agent in the message box, with a lock for read only, and the Tools
menu says which files the chat's tools reach. When the folder is
gone, the chip says so and opens the agent's form.
A chat whose agent's folder is missing does not start, and the server
answers with a conflict. The message box now says that in plain words,
keeps the message, and offers Edit agent, instead of the server's
text.
Assisted-by: Claude:claude-opus-5-5
* Test the folder of an agent across the web interface
The agent stubs now list folders by path, read a written folder back
as the server does, export a version 2 file for an agent with a
folder, import one whose folder is not found as unset, and refuse to
start a chat whose agent's folder is missing.
The specs choose a folder and see it on the list, switch the access,
leave an unchanged folder out of the save, clear it, keep and replace
a missing folder, keep Chats and LocalAI out of the picker, retry a
folder that cannot be read, see the Files marker, the chat's chip and
Tools line, the missing-folder chat error with Edit agent, and the
import warning. A render smoke opens the picker on the real server,
and one spec checks that the copy names no internal path.
Assisted-by: Claude:claude-opus-5-5
* Describe the agent's folder in the web interface
The agent docs said what the folder field does in the API. They now
also say where the web interface sets it, how the agents pages, the
Files page and a chat show it, and what a chat whose folder is
missing offers.
Assisted-by: Claude:claude-opus-5-5
* Record the chat an add-on call was made for
An add-on's workspace operations run as the user of the live request,
with no idea which chat made it. Only an action carries a chat, and
only as a key in its resource ID; a read tool such as the Office
add-on's read-docx carries none. So a chat that should reach only one
folder could reach the whole home through an add-on.
The in-flight record of a request now keeps, next to the envelope, a
binding to the chat the request was made for: the user's principal
and the chat ID. It is host-side only and never a field of the
envelope, which installed add-ons decode with unknown fields refused.
A context carries the binding from the chat turn to the place that
records the request. The broker gives each host operation the chat
and the scope of its live request. An operation ID that requests of
two chats share is not live, so neither chat is trusted.
Assisted-by: Claude:claude-opus-5-5
* Keep the chat binding of a runtime call on the host
The runtime client records each call's envelope for the host operation
channel. It now records the chat binding too when the call's context
carries one, so the add-on's host operations know which chat made the
request. The envelope sent to the add-on is the same bytes with or
without a binding: a spec checks that the body of a bound call equals
the body of an unbound one and still decodes with unknown fields
refused.
Assisted-by: Claude:claude-opus-5-5
* Carry the chat binding in a forwarded add-on request
A frontend that is not the add-on owner forwards an add-on call in a
signed request. The chat the call was made for now rides in that
request as a signed field, chat, and the owner puts it back on the
context of the call it serves, where the runtime client records it.
A changed, added or removed binding fails the signature check, and a
signed but incomplete one is refused. A request that no chat made has
no chat field and signs as before.
An owner that does not know the field refuses a forwarded call that a
chat made, so the owner must be upgraded before the frontends.
Assisted-by: Claude:claude-opus-5-5
* Bind every add-on tool call of a chat to the chat
The chats consumer ran an add-on tool with the conversation ID only,
which the owner used to resolve a selection and, for an action, as the
key of the add-on's state. Nothing told the host which chat a read
tool or a change came from.
The tool adapter now has ExecuteChat, which takes a ChatCall (the
chat's user and ID, from the chat turn's own record) and binds the
call to the chat on the host side for every tool kind: a read with no
resource, an action, and a change made at once. The chats adapter
refuses a call that names a conversation without that binding, so a
chat's call cannot be made unbound by mistake. Other consumers, such
as the OpenAI-compatible route, keep calling without a binding.
Assisted-by: Claude:claude-opus-5-5
* Run add-on workspace operations with the chat's access
The workspace operations of add-ons (materialize, commit, changes,
export and import) worked on the whole home of the request's user. A
chat whose agent has a folder could therefore read and write any file
through the Office or Sandbox add-on.
They now resolve their access from the chat binding of the live
request, through a new Access hook that the chats service answers
(Service.AddonAccess). A chat's call gets the chat's access, as its
file tools do: in a chat whose agent has a folder, the add-on's "/" is
that folder, /workspace/chat names the chat folder, the agent's mode
applies, and paths resolve through the chat's place, so a parent
reference is refused and an encoded separator is part of a name. The
mounts go through Access.SubView, which builds a view only inside the
access, for its fenced actor. When an add-on mounts its "/" at home,
the host adds the chat folder at chat, so the Sandbox sees the chat's
layout without a change. Changes list only what the access may read,
and a commit entry outside the view is a conflict.
A call whose chat cannot be resolved is refused and never runs
user-wide. An action request without a binding is resolved by its
chat key, as before, and refused when it names no chat. A call that
no chat made (an add-on page, the OpenAI-compatible route) still works
on the whole home, through Manager.PersonAccess.
The four lane 6 entries leave the actor scan and the view scan.
PersonAccess joins the view scan's matcher, with AddonAccess as its
one allowed caller. An end-to-end spec runs the real Office and
Sandbox add-ons in a scoped chat.
Assisted-by: Claude:claude-opus-5-5
* Build the chat access of the file index route test
files.Service.Tools takes a session.Access since the chat access
change, so the route test of the text index passed a bare actor and
its package did not compile under the auth tag.
The test now builds the access as the chats service does for a chat
whose agent has no folder: the user's home as the primary folder of
the view, then session.NewAccess. The assertions are unchanged.
Assisted-by: Claude:claude-opus-5-5
* Test the folder of an agent through every surface of a chat
The specs of the folder feature each test one surface. This suite
runs one chat of an agent limited to a folder through all of them at
once, on an indexed SQL workspace: the file and media tools, the search
and its pending count, a command of the Sandbox in a fake sandbox, a
read call and an action call of an add-on through the real workspace
operations bound to the chat, a generic and a saved sub-agent, the
trigger tools, a file trigger run, a connector reply, a renamed folder,
a mode change, an edit that narrows the folder (also seen by a second
frontend) and a deleted folder.
The files outside the folder hold a token that the scripted model never
writes. After each spec the suite collects every text a person or a
model can see: tool results and errors, prompts, messages, transcripts
with their cards, live events, chat file lists, tool panels,
notifications, connector replies and files, the add-on's data directory
and the connector log. The token must be in none of them, and the files
outside must be unchanged.
A control spec runs the same probes from an agent without a folder and
checks that they do find the token, so the probes and the collection
cannot pass by seeing nothing.
Assisted-by: Claude:claude-opus-5-5
* Complete the docs of the folder of an agent
Each change of the folder feature documented its own part. This
makes the pages one account of it:
- Saved agents: the missing and unset states in one bullet, what the
folder does not limit (MCP servers, web tools, collections, pages),
that the Files page stays user-wide, when an edit applies, the
downgrade caveat, and the folder as a stateless row of the cluster
table. The Sandbox of a scoped chat mounts the agent's folder and the
chat folder only, as the add-on asks for them; the page said it also
had the shared folders and skills.
- Chats: the chat folder at /workspace/chat for the model, sub-agents
of a scoped chat, and commands only in the Sandbox.
- Add-ons: the Sandbox and Office sections say what / is in a chat whose
agent has a folder.
- First agent: the optional folder step and the "Folder missing" case.
Assisted-by: Claude:claude-opus-5-5
* Draft the PR description and release note of agent folders
No release notes file exists for the next release yet, so the draft
text lives next to the spec and the plan. It carries the release note
paragraph with the downgrade caveat and the upgrade order of a cluster.
Assisted-by: Claude:claude-opus-5-5
* List the chat binding specs in the per-report add-on inventories
The chat binding change added five specs to the gateway and runtime
packages and named them in the case lists of the gateway, artifacts and
apps suites. These three suites also keep one case list per Ginkgo
report, and the verifier compares each report with its own list. The
new names were missing there, so the three suites failed with an
inventory mismatch.
A dry run of every Ginkgo suite's commands now lists the same It names
as each report's inventory, in both directions.
Assisted-by: Claude:claude-opus-5-5
* Hide stale paths from a fenced actor's history
A folder moved into a fence makes its parent identities readable.
Their historical paths can still name locations outside the fence.
Hide each historical side when its readable parent's current path
differs. Reuse the generic title when filtering hides a side. Keep
the journal and unfenced history unchanged so Undo uses identities.
Assisted-by: nib:gpt-6.1-sol
* List the chat binding cases in Tables tools
The Tables tools runner includes every adapter spec in its output.
List the four chat binding cases so the exact inventory accepts them.
Assisted-by: nib:gpt-6.1-sol
* Keep shared agent folders unset in exports
A followed folder outside the home cannot round-trip through a home path.
Export its mode with an empty path so import warns and stays fail-closed.
Responses keep public shared paths without exposing other internal paths.
Distinguish deleted scoped agents from unscoped agents in the guidance.
Assisted-by: nib:gpt-6.1-sol
* Refuse scoped chats over the folder limit
Keep every required shared root in each intersecting fence. Refuse
creation and turn access when the complete set exceeds 32 roots.
Reserve a chat root before its folder exists and fence listings empty
on refusal. Propagate shared enumeration failures instead of omitting
roots.
Map capacity refusals to a fixed 409 response and give connectors a
safe capacity message. Check boundaries, retries, background access,
and HTTP handlers with focused regressions.
Assisted-by: nib:gpt-6.1-sol
* Verify unset imports refuse chat startup
Assert the folder-specific refusal without a harness start or saved chat.
Clarify public response paths and unset exports in the API metadata.
Assisted-by: nib:gpt-6.1-sol
* Keep the folder retry test under user control
A background refresh consumes the one-shot successful response before
this test observes its load error. Isolate the mocked Files page from
the live workspace stream and agents list. Keep reads failing until
the error assertions pass, then allow the actual Retry click to recover.
Check that Retry reads again and removes the error, without changing
the existing assertions or their timeouts.
Assisted-by: nib:gpt-6.1-sol
* Test and document folder capacity refusals
Use the existing Ginkgo suites for capacity regressions. Exercise a
successful turn, refusal after shares change, and a retry through a
second service on the same stores. Check that refusal leaves no claim
or pending message.
Verify that connectors send one fixed refusal and that triggers stay
enabled after capacity failures. Document the 32-root budget and the
all-required-roots-or-refuse contract.
Assisted-by: nib:gpt-6.1-sol
* Control crashes in the restart budget test
The crash fixture exits after 100 ms, before startup necessarily finishes.
A busy runner can fail the readiness handshake instead of testing restarts.
Start a normal fixture and kill each published incarnation explicitly.
Keep the exact restart count and persistent failed-state assertions.
Assisted-by: nib:gpt-6.1-sol
* Check capacity test cleanup errors
Assert service and workspace cleanup through Ginkgo. Keep service cleanup before workspace cleanup and report close failures instead of discarding them.
Assisted-by: nib:gpt-6.1-sol
---------
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1452 lines
52 KiB
Go
1452 lines
52 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/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/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() map[string]string
|
|
// 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() egress.Settings
|
|
// 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)
|
|
// 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
|
|
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.
|
|
triggers *Triggers // set once by SetTriggers
|
|
loops *Loops // set once by SetLoops
|
|
jobs *Jobs // set once by SetJobs
|
|
connectors *Connectors // set once by SetConnectors
|
|
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()
|
|
}
|
|
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.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.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, 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) {
|
|
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 {
|
|
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
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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.
|
|
rec.Egress = s.newChatEgress()
|
|
if in.AgentID != "" {
|
|
if n := s.agentNetwork(ctx, rec); n != egress.PresetOff {
|
|
rec.Egress.Preset = egress.Min(n, s.egressSettings().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, time.Now().Add(-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})
|
|
// 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 }
|