From 24c837e453324cb8731645e4a124d64687046bd4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 14:50:14 +0000 Subject: [PATCH] feat(studio): grant built-in capabilities to a saved agent A saved agent now has a Builtins list: the capabilities its owner gave it (memory.write, profile.write, skills.write, pages.write, dashboard.write, triggers.propose, triggers.run, talkback.channels, talkback.new_channel, files.upload, act.files, act.addons, act.send, act.spend and the opt-in group saved_agents). The list is empty by default, and nothing turns a grant on for a template, an import or a write that omits the field. A chat of the agent holds the grants the user may use at the turn (agent scope). A tool exists when the grant is held, the chat did not switch its group off and the run can hold rights: a sub-agent or a run that started by itself never does. propose_change and revert_change now exist only for a chat that holds the grant of at least one kind, and the schema lists only those kinds. propose_change gains the kinds skill, page and dashboard. A skill is a SKILL.md for the personal or the agent layer, and a skill with locked: true is refused, also as a shadow of a locked personal skill. A page is a set of files checked against the Pages rules and written as one change set. The dashboard is a home.json checked against its schema. All three are re-checked when the owner confirms or edits. GET /api/chats/agents/builtin-grants lists each grant with its title, description and sensitive flag. The agent record, the write body and the export file carry builtins; an old file imports as before and an unknown name is dropped in an import and refused in a write. Assisted-by: Claude:claude-sonnet-5-5 go gofmt swag --- core/http/auth/features.go | 1 + core/http/auth/features_chats_test.go | 1 + .../endpoints/localai/api_instructions.go | 2 +- core/http/endpoints/studio/agents.go | 4 +- core/http/endpoints/studio/agentsfile.go | 20 +- core/http/routes/studio.go | 1 + core/http/routes/studio_agentsfile_test.go | 57 ++++ core/schema/chats.go | 26 ++ .../studio/agents/agentstest/suite.go | 3 +- core/services/studio/agents/gorm.go | 10 +- core/services/studio/agents/types.go | 8 +- core/services/studio/agentscope.go | 4 + core/services/studio/agentsvc.go | 22 +- core/services/studio/builtins/builtins.go | 4 + core/services/studio/builtins/grants.go | 111 +++++++ core/services/studio/changekinds.go | 230 ++++++++++++++ core/services/studio/changes.go | 190 +++++++++++- core/services/studio/changes_test.go | 40 ++- core/services/studio/changetools.go | 69 +++-- .../studio/connectors_enforce_test.go | 1 + .../studio/contextturn_internal_test.go | 2 +- core/services/studio/files/homefile.go | 50 ++++ core/services/studio/grantkinds_test.go | 260 ++++++++++++++++ core/services/studio/grants_test.go | 282 ++++++++++++++++++ core/services/studio/grantscope.go | 141 +++++++++ core/services/studio/hostbash.go | 2 +- core/services/studio/proposals/kinds.go | 46 ++- core/services/studio/proposals/kinds_test.go | 13 + core/services/studio/shadowers.go | 2 +- core/services/studio/skills/parse.go | 14 + core/services/studio/skills/parse_test.go | 13 + core/services/studio/studiotest/tools.go | 16 + core/services/studio/webtools.go | 2 +- docs/content/features/addons.md | 38 ++- docs/content/features/chats-agents.md | 20 +- pkg/addons/contract/changeproposal.go | 3 + swagger/docs.go | 64 +++- swagger/swagger.json | 64 +++- swagger/swagger.yaml | 65 +++- 39 files changed, 1821 insertions(+), 80 deletions(-) create mode 100644 core/services/studio/builtins/grants.go create mode 100644 core/services/studio/changekinds.go create mode 100644 core/services/studio/grantkinds_test.go create mode 100644 core/services/studio/grants_test.go create mode 100644 core/services/studio/grantscope.go diff --git a/core/http/auth/features.go b/core/http/auth/features.go index f8978fe6f5..30695635f2 100644 --- a/core/http/auth/features.go +++ b/core/http/auth/features.go @@ -169,6 +169,7 @@ var RouteFeatureRegistry = []RouteFeature{ {"GET", "/api/chats/agents", FeatureAgents}, {"POST", "/api/chats/agents", FeatureAgents}, {"GET", "/api/chats/agents/templates", FeatureAgents}, + {"GET", "/api/chats/agents/builtin-grants", FeatureAgents}, {"POST", "/api/chats/agents/templates/:id/create", FeatureAgents}, {"POST", "/api/chats/agents/import", FeatureAgents}, {"GET", "/api/chats/agents/:id/export", FeatureAgents}, diff --git a/core/http/auth/features_chats_test.go b/core/http/auth/features_chats_test.go index 4f504609a5..011635a126 100644 --- a/core/http/auth/features_chats_test.go +++ b/core/http/auth/features_chats_test.go @@ -41,6 +41,7 @@ var _ = Describe("Chats feature registration", func() { "GET /api/chats/agents", "POST /api/chats/agents", "GET /api/chats/agents/templates", + "GET /api/chats/agents/builtin-grants", "POST /api/chats/agents/templates/:id/create", "POST /api/chats/agents/import", "GET /api/chats/agents/:id/export", diff --git a/core/http/endpoints/localai/api_instructions.go b/core/http/endpoints/localai/api_instructions.go index f202c4b82c..1387d602ce 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -28,7 +28,7 @@ var instructionDefs = []instructionDef{ {Name: "agent-jobs", Description: "Agent tasks and jobs: saved prompt templates run in the background, with webhooks", Tags: []string{"agent-jobs"}, Intro: "A task is a prompt template ({{.name}} parameters) run by a model or a saved agent (agent_id); a job is one run of it, a background chat of its owner with origin job. POST /api/agent/tasks creates a task {name, model, agent_id, prompt, enabled, webhooks [{url, method, headers, payload_template}]}; GET, PUT and DELETE /api/agent/tasks/{id} read, replace and delete it; header values are never returned (sent back as [hidden] they keep their value). POST /api/agent/jobs/execute {task_id, parameters, images, videos, audios, files} or POST /api/agent/tasks/{name}/execute {parameters} starts a job and answers 201 {job_id, status, url} at once; attachments are base64 data URIs, an address is never fetched. GET /api/agent/jobs/{id} gives status (pending, running, completed, failed, cancelled), result, error, traces and the webhook delivery; GET /api/agent/jobs lists newest first (task_id, status, limit); POST .../jobs/{id}/cancel stops one, POST .../jobs/{id}/rerun runs it again, DELETE removes it with its chat. cron and multimedia_sources on a task are refused: schedule a saved agent with a trigger. A webhook to a private address is refused unless an administrator allows it. Needs the mcp_jobs feature (off by default); ?all_users=true gives an administrator a read-only view of every user's tasks and jobs."}, {Name: "collections", Description: "Collections: the knowledge base of documents the chats can search, with files and followed URLs as sources", Tags: []string{"collections"}, Intro: "A collection is a set of files the server cuts into passages and embeds. GET and POST /api/collections list and create (POST {name}) the collections of the user; POST /api/collections/{name}/upload (multipart field file) adds a file; GET .../entries, .../entries/{entry} and .../entries-raw/{entry} read them; POST .../search {query, max_results} returns the best passages (a number above the passages stored returns all of them); DELETE .../entry/delete {entry} removes one; POST .../reset removes every entry and keeps the collection and its sources; GET, POST and DELETE .../sources follow a URL. Names with special characters must be URL-encoded. Each user sees only their own collections. The routes need the collections feature (off by default for a user); the service can be turned off with LOCALAI_DISABLE_COLLECTIONS. A saved agent can search a collection when it is granted in the agent's collections list. The deprecated path /api/agents/collections serves the same store with a Deprecation header."}, {Name: "workspace", Description: "A live feed of the changes to your files, whoever made them", Tags: []string{"workspace"}, Intro: "GET /api/workspace/events streams server-sent events: a change event {revision, paths, kinds} when files in your home folder change, from any client, chat, trigger, connector or frontend. Paths are inside your home, without a leading slash; kinds are the kinds of the change sets (user, agent, upload, undo). Changes close together come as one event, about once a second at most. A resync event {revision} means some changes were left out: read again what you show. The event id is the revision; send it back as Last-Event-ID or ?after= to resume, otherwise the stream starts from now. A comment line comes every 25 seconds. You see only paths you may read. At most 8 streams per user (429 after). Needs the chats feature; 409 files_off when the server has no files."}, - {Name: "chats", Description: "Chats the server keeps: steering, sub-agents and live events", Tags: []string{"chats"}, Intro: "POST /api/chats starts a chat with a model; PATCH /api/chats/{id} {title, model, settings} renames it (1 to 80 characters), moves it to another model (history stays) or sets its settings {system_prompt, temperature, top_p, top_k, reasoning_effort, context_size}, which apply from the next turn (a new title or model needs an idle chat: 409 otherwise). POST .../{id}/regenerate answers the last message again, POST .../{id}/messages/{seq}/edit {text} replaces one of your messages and runs again from it (later lines are removed), POST .../{id}/fork {from_seq, title} copies a chat up to a line into a new chat, GET /api/chats/search?q= finds chats by title or text, GET .../{id}/export?format=markdown|json returns a chat as a file and POST /api/chats/import {chat, model} stores a JSON export as a new chat. Events also carry usage {used, limit, completion, tokens_per_second, model, node, prompt_ms} after each model request, for a context meter (node only when the server exposes the node header, prompt_ms only when the backend reports timings). Sub-agents: GET /api/chats/{id}/agents lists the sub-agent runs of a chat with running, last_turn and the background limit (from the shared store), GET .../{id}/agents/{agent_id}/messages reads one run's transcript and POST .../{id}/agents/{agent_id}/stop stops it; sub_agent_started and sub_agent_done events mark them in the parent's stream. A chat that PUT .../{id}/builtins {on: [\"saved_agents\"]} switched on (off by default; needs the agents feature and the chats_saved_agents setting) also gets list_agents and the agent argument of spawn_agent (a saved agent's id or name): the sub-agent then runs with that agent's instructions, model and grants. POST .../{id}/messages sends a message: with no turn running it starts one; with a turn running it waits (started false, pending_id) and reaches the model, together with any others and in order, at the model's next request. GET .../{id} lists the waiting messages; PATCH and DELETE .../{id}/pending/{pid} edit or cancel one until it is delivered (409 after). After a stop or a failed turn the waiting messages stay; POST .../{id}/send-now starts a turn from them and POST .../{id}/stop ends a running turn. Goals: PUT .../{id}/goal {text} sets a goal for the chat (it replaces the one it had, returned as replaced) and starts a turn on it when none runs; while the goal is active every turn's prompt carries it, the model gets goal_done {summary} and goal_blocked {reason}, and when a turn ends by itself the server starts the next one, until the goal is done or blocked, you stop it (POST .../{id}/stop pauses the goal), or chats_goal_max_turns (20 by default) automatic turns or chats_goal_max_steps (400) model steps pause it. Your messages still steer it: they go first. GET .../{id}/goal reads it {text, state active|paused|done|blocked, paused_by user|turn_limit|step_limit|error|interrupted, note, turns, max_turns, steps, max_steps}, DELETE .../{id}/goal clears it and POST .../{id}/goal/resume resumes a paused or blocked one with new counts; GET .../{id} carries it as goal, a goal event says it changed, and the transcript has goal lines (role goal, goal_event set|replaced|continue|paused|resumed|done|blocked|cleared). A chat that a trigger, a connector, a job or a page started, and a sub-agent run, cannot have a goal. A turn whose tool loop reaches the step limit (the runtime setting chats_max_iterations, 40 by default, or the model's agent.max_iterations) ends with last_turn paused after a paused event {steps}: the model answered from what it had, and a new message (for example \"Continue\") goes on. GET .../{id}/messages reads the transcript (a user line has text, the words typed, and attachments [{path, name, size, mime, version, missing}], the files it carried, with path inside your home for the files API; a waiting message has the same two fields; the model still gets the paths of the files with the message) and GET .../{id}/events streams live server-sent events, so read the transcript first. A chat is named after its first message. A conversation belongs to its user; another user's id is always 404. POST /api/chats/import/browser takes the chats the old chat page kept in the browser (the value of localStorage localai_chats_data) and stores them as yours; a chat that is there already is skipped. Needs the chats feature, which is on by default. Saved agents need the agents feature, triggers the triggers feature and connectors the connectors feature; all three are off by default for a user and an administrator grants them (an administrator has them all). The server can switch the chats off (LOCALAI_DISABLE_CHATS: every /api/chats path then answers 404 chats_off), and the triggers or the connectors alone (LOCALAI_DISABLE_TRIGGERS, LOCALAI_DISABLE_CONNECTORS: their routes answer 409 triggers_off or connectors_off); GET /api/features says what runs. Your files: GET /api/chats/files?path= lists a folder of your workspace (paths are inside your home, \"/\"), GET and PUT /api/chats/files/content?path= download and upload a file (a second upload that does not name the version it started from keeps both files and reports conflict_of), POST .../files/folders and .../files/move create and rename, DELETE .../files?path= moves to the Trash, GET .../files/trash lists deletions (each with files, folders and bytes, and totals for the whole Trash) and POST .../files/undo {rev} restores one, DELETE .../files/trash empties the Trash for good and DELETE .../files/trash/{rev} deletes one entry for good (neither can be undone; they touch only your own Trash and the agent tools never do this), GET .../files/history lists changes, GET .../files/search?q= searches names, text files and the text of PDF, Word, Excel and PowerPoint files (a document hit has document, page, sheet or slide; indexing counts documents still being read), and GET .../files/preview?path= reads a .xlsx, .xlsm, .csv, .tsv, .docx or .pptx file of up to 20 MiB for a preview (kind table: sheets of at most 200 rows by 30 columns with total_rows and total_cols; kind document: headings, list items, paragraphs and tables {type table, rows}; kind slides: titles, text and notes; 415 for another type). POST .../{id}/messages also takes multipart/form-data (text and files) to attach files (with a turn running the files are saved at once and the message waits with their paths): images go to a vision model inside the message (image_url parts; other models get the path and describe_image), videos to a model that takes video, and the text of PDF, Word, Excel, PowerPoint and EPUB files is added to the message (the model reads the rest with read_document). Skills: GET /api/chats/skills?chat= lists the skills of the five layers (folder, personal, shared, addon, builtin; an addon skill names its add-on in addon and addon_title) with enabled and shadowed, POST creates one {layer personal|shared, name, description, body} (shared needs an administrator), GET PUT DELETE .../skills/{layer}/{name} read, change and delete (to the Trash), PUT /api/chats/{id}/skills {off} switches skills off for one chat. Well-known files: GET and PUT /api/chats/wellknown/{name} (persona, user, shortcuts, home, ui, or widget-data with ?path=) read and change the files of your home that set the assistant's persona, your profile, the composer shortcuts, the Home widgets and the look. A read is validated against the JSON Schema at .../wellknown/{name}/schema and falls back to the last valid version (fallback, errors); a write {values, replace} merges the keys it sends (null removes one), takes the version you read in If-Match (409 with current when the file changed) and is refused with errors when the result is invalid; POST .../wellknown/{name}/revert restores the previous valid version as a new change, or the version {rev} that GET .../wellknown/{name}/versions lists (newest first, with who wrote it and in which chat). A checkpoint line of a transcript carries rev, the change set of the turn, and lists the paths it changed. Add-ons: GET /api/chats/{id}/addons lists the add-ons whose tools you may use, each with its switch for that chat, and PUT /api/chats/{id}/addons {off: [ids]} switches the tools of add-ons off for one chat. Commands: a chat runs commands with bash, served by an add-on that runs commands (isolated) or, when no add-on serves it, by the built-in bash directly on this machine with no isolation and with its network; the add-on list says which (commands_provider, commands_isolation, commands_consent, commands_fallback). With chats_host_exec ask, each command waits: a host_exec_consent event carries {ids: [id], text: command} and POST /api/chats/{id}/host-exec/consent {id, decision: once|deny} answers it (once needs an administrator). Administrators: GET /api/chats/admin/tools lists the built-in tools an add-on may replace (Settings > Tools; change them with POST /api/settings chats_host_exec and chats_tool_providers) and GET /api/chats/admin/host-exec/events?limit= reads the audit of the commands run on this machine. Network of the isolated commands: the guest has no network device and reaches HTTP and HTTPS only through a proxy that asks the server about each connection. GET /api/chats/{id}/egress returns {preset, ceiling, effective, applies, applies_reason, addon, registries, rules, cards} (applies is false when commands run on this machine, which has the server's network; addon is the add-on that runs the commands and draws the chat's network controls), PUT .../{id}/egress {preset: off|registries|all} sets the chat's preset (400 egress_above_ceiling above the administrator's chats_egress_ceiling), DELETE .../{id}/egress/rules/{host}/{port} removes an allow or deny rule, an egress_consent event {ids: [card id], text: host:port} asks for a host the chat does not allow (the command waits up to 60 seconds) and POST .../{id}/egress/consent {id, decision: once|chat|deny} answers it (allowing a host inside your network needs an administrator), and egress_card events say that the chat's network changed. Administrators: GET /api/chats/admin/egress reads the chats_egress_* settings in force, the registries and the counts of the last 24 hours, and GET /api/chats/admin/egress/events?limit=&user=&chat=&verdict=&host= reads the audit. Saved agents: GET and POST /api/chats/agents, GET PUT DELETE .../agents/{id}: an agent is {name, description, instructions, model, mcp_servers, addons, collections, skills, network, folder}; folder {path, mode read|write} gives it one folder of your files (\"/\" is all of them; not LocalAI, Chats or a shared folder; 400 otherwise), kept by the folder itself so a rename or move is followed, shown back as {path, name, mode, state ok|missing|unset}; a write without folder keeps it and folder {path: \"\"} clears it; network (off, registries or all; off by default) is the network of the isolated commands of its trigger and connector runs, capped by chats_egress_ceiling, which never show a card; the lists are exact grants (an empty list grants nothing) and a chat gets only what you may use at that moment. POST /api/chats {agent_id} starts a chat with one (the agent's model replaces model when it has one). GET .../agents/{id}/export returns the definition as a JSON file (version 1, kind localai-agent; version 2 with folder {path, mode} for an agent with a folder, which a server that knows only version 1 refuses), POST .../agents/import takes that file (JSON body or multipart file field) and saves a new agent (a taken name gets a number, an unavailable model is dropped, a folder you do not have leaves the agent unset until you choose one; warnings say so), GET .../agents/templates lists starter agents and POST .../agents/templates/{id}/create saves one, GET .../agents/{id}/runs lists the chats that ran with the agent (yours, trigger runs, connector runs) with last_turn and counts. Inbox: GET /api/chats/notifications?unread=1 lists the notifications of your trigger runs (kind run_ended, run_failed or needs_attention) with the unread count, GET .../notifications/count counts them, POST .../notifications/read {ids} or {all: true} marks them read, DELETE .../notifications {ids} removes some, or {all: true} empties your inbox ({all: true, read_only: true} removes only the read ones; the answer has deleted and the unread count left), the list also gives total (everything kept, since limit, at most 500, can cut the list), and GET .../notifications/events streams new ones as server-sent events plus a notifications_changed event when notifications were read or deleted (from any tab or frontend), after which a client reads the list again. A user only ever reaches their own notifications. Opening a chat (GET /api/chats/{id}) marks its notifications read. Triggers: GET and POST /api/chats/triggers, GET PUT DELETE .../triggers/{id}, POST .../{id}/run (start now), .../{id}/enable and .../{id}/disable, GET .../{id}/runs, GET .../triggers/preview?when=&tz= (next times of a schedule). A trigger is {name, prompt, kind schedule|file, schedule + time_zone, or folder + quiet_seconds, daily_cap, enabled, agent_id, model, source_chat_id}: it starts a chat as you by itself, on a schedule (\"daily 08:00\", \"weekdays 09:30\", \"weekly mon,thu 09:30\", \"monthly 15 08:00\", \"once 2026-12-24 18:00\" or five cron fields) or when a file arrives in the folder; a chat it starts has origin trigger. A trigger also has plan {reads, writes, widget} (what a run reads, the file it rewrites and the Home widget that shows it), cost {estimated_tokens, average_tokens, runs, max_runs_per_day, remote} and last_done, and each run its tokens; a file trigger waits min_gap_seconds (60 by default) after a run before a file that changed again starts another. The four trigger tools (propose_trigger, list_triggers, update_trigger, delete_trigger) are the built-in tool group triggers: on in a chat a person starts, switched together with PUT .../{id}/builtins {off: [\"triggers\"]}, listed in GET .../{id}/tools and GET .../draft/tools with usable and reason (not usable in a run a trigger or connector started, for a user without the triggers feature; not listed when the server runs no triggers), and never given to a sub-agent. A model can propose a trigger in a chat (propose_trigger; a schedule from a chat runs at most once an hour); GET .../triggers/proposals?chat= lists them, PUT .../triggers/proposals/{id} edits one (it stays a proposal), POST .../triggers/proposals/{id}/confirm {time_zone} turns one into a trigger that is on, DELETE dismisses it (the answer is kept: GET .../triggers/proposals/{id} returns {state pending|accepted|declined|undone, trigger, answered_at, can_undo, undo_blocked}, POST .../undo takes an accepted one back unless the trigger ran or was edited since (409 says why) and POST .../reopen restores a declined or undone one); the chat carries a proposal as a card {kind trigger_proposal, id} on the tool line (tool_end event and transcript) and as a trigger_proposed event. A model can also ask to change a trigger of yours (list_triggers gives each a ref; update_trigger edits, pauses or resumes, delete_trigger deletes): an edit or a delete waits for you, and a pause or a resume applies at once; the chat carries it as a card {kind trigger_change, id}. GET .../triggers/changes/{id} returns {id, op update|delete|pause|resume, state pending|applied|dismissed|undone, trigger_id, before, after, fields, answered_at, stale, can_undo, undo_blocked, last_run}, POST .../changes/{id}/apply makes it (409 when the trigger changed since), DELETE dismisses it and POST .../changes/{id}/undo puts the trigger back as it was, for a day after it applied (a deleted trigger comes back with its id, without its run history). Proposed changes: a model asks to change your memory or an agent profile with propose_change {kind memory|profile, scope user|agent|channel, target, content, summary} (in the built-in tool group changes; revert_change {id} undoes an applied one). Nothing is written until you confirm. GET /api/chats/changes?status= lists the proposals (id, kind, scope, target, summary, content, source {kind, id, chat}, status pending|applied|dismissed|expired|reverted|stale, rev), GET .../changes/{id} reads one, POST .../changes/{id}/confirm writes it as one change set, or with a body {content} your own text in its place (409 when the file changed since, the proposal expired after 24 hours or it was answered), POST .../changes/{id}/dismiss answers no, POST .../changes/{id}/undo restores the file (409 when it was edited since) and GET .../changes/audit lists who proposed, confirmed or reverted what, with a hash and never the text. GET /api/chats/context-files lists the notes the host puts in the prompt (limits, and groups of {source, scope, key, max_bytes, max_lines, files}) for the Memory tab of Your settings; it only reads. At most 10 proposals wait per user and 5 are made per hour per agent. A chat that runs a saved agent cannot propose until the agent is granted it. Browser push: GET /api/chats/push/key returns the server's VAPID public key, POST /api/chats/push/subscribe takes a browser PushSubscription {endpoint, keys: {p256dh, auth}} (an https endpoint on a public address) and DELETE .../push/subscribe {endpoint} removes it; a notification is then pushed with its title and link only. These answer 409 push_off when the server keeps nothing across a restart. Conversion: at start the server converts the old chat page, agents, tasks, skills and Telegram connectors; GET /api/chats/conversion lists what happened to your items (kind, name, status converted|skipped|failed|dropped, reason) and gives an administrator the totals. Converted triggers and connectors are switched off. Connectors: GET /api/chats/connectors/kinds lists the platforms that an installed add-on serves (each kind comes with the add-on that serves it), GET and POST /api/chats/connectors, GET PUT DELETE .../connectors/{id}, POST .../{id}/enable, .../disable and .../test (asks the platform who the bot is), GET .../{id}/senders (who wrote to the bot) and POST .../{id}/senders/{sender}/allow. POST .../{id}/pairing makes a one-use pairing code (valid ten minutes) that you send as a direct message to the bot from an allowlisted account to pair it (a pairing lasts 90 days, at most three senders), and DELETE .../{id}/paired/{sender} ends one. A binding also has caps (the capabilities you enabled, from a closed list), grants [{role, caps}] (roles that the kind declares in roles and permissions of the kinds list; never a sensitive capability) and paired. A binding is {kind (the platform, telegram when empty; set at create only), name, token (write-only, sealed, never returned; has_token says it is stored), agent_id (required: an agent of yours that names a model), enabled, accept_any, allow [chat IDs, user IDs or @usernames], notify_chat}: a person who messages the bot talks to that agent in a conversation of their own, as you, with only the agent's grants. The allowlist is empty at first, so nobody is accepted; a sender who is not accepted is ignored and logged. accept_any lets anyone who can message the bot use the agent; the create or update that turns it on must also send confirm_accept_any: \"accept any message\" (else 400). Files and skills answer 409 with the reason when the server has no data path. Administrators: GET /api/chats/admin/agents, .../admin/triggers and .../admin/connectors list what every user has, each with its owner and never a token or instructions; GET .../admin/triggers/{id}/runs lists the runs of a trigger, POST .../admin/triggers/{id}/disable and .../admin/connectors/{id}/disable switch one off, and DELETE .../admin/agents/{id}, .../admin/triggers/{id} and .../admin/connectors/{id} remove one. No MCP tool maps to these routes: they are the user's own chat and files, not administration. GET POST /api/chats/mcp-connectors, GET PUT DELETE /api/chats/mcp-connectors/{id} and POST .../enable, .../disable, .../test manage your MCP connectors: MCP servers you define (transport http, or stdio for administrators outside distributed mode), stored per user, with token, headers and env sealed and never returned; a test answers 200 with ok and the tool names or the error. Addresses on private networks are refused at connect time unless an administrator sets mcp_connectors_allow_private. A chat with no agent gets your enabled connectors; a saved agent names one as connector: in mcp_servers. GET /api/chats/{id}/tools lists the built-in tool groups, add-ons, model MCP servers and connectors of a chat with their switches. The built-in groups are on until PUT /api/chats/{id}/builtins {off: [...]} switches them off for that chat: shell (bash, whoever serves it; commands then says switched_off), web, files (the file tools and read_document), media (describe_image and transcribe_audio), collections, subagents (spawn_agent) and triggers; load_skill and the goal and loop tools have no switch, and a sub-agent follows its parent's switches; PUT /api/chats/{id}/mcp {off: [server name or connector:]} switches MCP servers and connectors off for that chat. GET /api/chats/{id}/mcp/prompts and .../mcp/resources list the prompts and resources of the servers the chat can use now; POST .../mcp/prompts {server, name, arguments} and POST .../mcp/resources/read {server, uri} return their text (403 mcp_refused for a server the chat cannot use); POST .../{id}/messages takes context: [{server, name, arguments} or {server, uri}] to add them to a message. MCP Apps: a tool that declares an interactive page (_meta.ui.resourceUri) gives its tool_end event and its transcript line an app {server, tool, resource_uri, visibility}; the transcript line also holds the call input and result. GET /api/chats/{id}/mcp-apps/resource?server=&uri= reads the ui:// page (html, mime_type, meta with csp, permissions and prefersBorder) for a server the chat may use now and a resource a tool of that server declares, and POST /api/chats/{id}/mcp-apps/call {server, tool, arguments} runs a tool of the same server whose visibility includes app, for the page to call; both answer 403 mcp_app_refused for anything else and need the MCP feature. Loops repeat a prompt inside a chat: POST .../{id}/loops {interval, prompt} starts one (interval such as 5m, at least chats_loop_min_interval; empty for a self-paced loop whose model calls schedule_next or loop_done after each run), GET .../{id}/loops lists them (also the loops field of GET /api/chats/{id}), PUT .../{id}/loops/{loopId} {state: paused|active} pauses, resumes or confirms one (the first loop of a chat is pending until confirmed), DELETE .../{id}/loops/{loopId} or DELETE .../{id}/loops stops them; a loops event tells other tabs, and a user line a loop sent carries loop {id, run}. A loop runs in its own chat and as often as every minute; a trigger starts a new chat at most once an hour."}, + {Name: "chats", Description: "Chats the server keeps: steering, sub-agents and live events", Tags: []string{"chats"}, Intro: "POST /api/chats starts a chat with a model; PATCH /api/chats/{id} {title, model, settings} renames it (1 to 80 characters), moves it to another model (history stays) or sets its settings {system_prompt, temperature, top_p, top_k, reasoning_effort, context_size}, which apply from the next turn (a new title or model needs an idle chat: 409 otherwise). POST .../{id}/regenerate answers the last message again, POST .../{id}/messages/{seq}/edit {text} replaces one of your messages and runs again from it (later lines are removed), POST .../{id}/fork {from_seq, title} copies a chat up to a line into a new chat, GET /api/chats/search?q= finds chats by title or text, GET .../{id}/export?format=markdown|json returns a chat as a file and POST /api/chats/import {chat, model} stores a JSON export as a new chat. Events also carry usage {used, limit, completion, tokens_per_second, model, node, prompt_ms} after each model request, for a context meter (node only when the server exposes the node header, prompt_ms only when the backend reports timings). Sub-agents: GET /api/chats/{id}/agents lists the sub-agent runs of a chat with running, last_turn and the background limit (from the shared store), GET .../{id}/agents/{agent_id}/messages reads one run's transcript and POST .../{id}/agents/{agent_id}/stop stops it; sub_agent_started and sub_agent_done events mark them in the parent's stream. A chat that PUT .../{id}/builtins {on: [\"saved_agents\"]} switched on (off by default; needs the agents feature and the chats_saved_agents setting) also gets list_agents and the agent argument of spawn_agent (a saved agent's id or name): the sub-agent then runs with that agent's instructions, model and grants. POST .../{id}/messages sends a message: with no turn running it starts one; with a turn running it waits (started false, pending_id) and reaches the model, together with any others and in order, at the model's next request. GET .../{id} lists the waiting messages; PATCH and DELETE .../{id}/pending/{pid} edit or cancel one until it is delivered (409 after). After a stop or a failed turn the waiting messages stay; POST .../{id}/send-now starts a turn from them and POST .../{id}/stop ends a running turn. Goals: PUT .../{id}/goal {text} sets a goal for the chat (it replaces the one it had, returned as replaced) and starts a turn on it when none runs; while the goal is active every turn's prompt carries it, the model gets goal_done {summary} and goal_blocked {reason}, and when a turn ends by itself the server starts the next one, until the goal is done or blocked, you stop it (POST .../{id}/stop pauses the goal), or chats_goal_max_turns (20 by default) automatic turns or chats_goal_max_steps (400) model steps pause it. Your messages still steer it: they go first. GET .../{id}/goal reads it {text, state active|paused|done|blocked, paused_by user|turn_limit|step_limit|error|interrupted, note, turns, max_turns, steps, max_steps}, DELETE .../{id}/goal clears it and POST .../{id}/goal/resume resumes a paused or blocked one with new counts; GET .../{id} carries it as goal, a goal event says it changed, and the transcript has goal lines (role goal, goal_event set|replaced|continue|paused|resumed|done|blocked|cleared). A chat that a trigger, a connector, a job or a page started, and a sub-agent run, cannot have a goal. A turn whose tool loop reaches the step limit (the runtime setting chats_max_iterations, 40 by default, or the model's agent.max_iterations) ends with last_turn paused after a paused event {steps}: the model answered from what it had, and a new message (for example \"Continue\") goes on. GET .../{id}/messages reads the transcript (a user line has text, the words typed, and attachments [{path, name, size, mime, version, missing}], the files it carried, with path inside your home for the files API; a waiting message has the same two fields; the model still gets the paths of the files with the message) and GET .../{id}/events streams live server-sent events, so read the transcript first. A chat is named after its first message. A conversation belongs to its user; another user's id is always 404. POST /api/chats/import/browser takes the chats the old chat page kept in the browser (the value of localStorage localai_chats_data) and stores them as yours; a chat that is there already is skipped. Needs the chats feature, which is on by default. Saved agents need the agents feature, triggers the triggers feature and connectors the connectors feature; all three are off by default for a user and an administrator grants them (an administrator has them all). The server can switch the chats off (LOCALAI_DISABLE_CHATS: every /api/chats path then answers 404 chats_off), and the triggers or the connectors alone (LOCALAI_DISABLE_TRIGGERS, LOCALAI_DISABLE_CONNECTORS: their routes answer 409 triggers_off or connectors_off); GET /api/features says what runs. Your files: GET /api/chats/files?path= lists a folder of your workspace (paths are inside your home, \"/\"), GET and PUT /api/chats/files/content?path= download and upload a file (a second upload that does not name the version it started from keeps both files and reports conflict_of), POST .../files/folders and .../files/move create and rename, DELETE .../files?path= moves to the Trash, GET .../files/trash lists deletions (each with files, folders and bytes, and totals for the whole Trash) and POST .../files/undo {rev} restores one, DELETE .../files/trash empties the Trash for good and DELETE .../files/trash/{rev} deletes one entry for good (neither can be undone; they touch only your own Trash and the agent tools never do this), GET .../files/history lists changes, GET .../files/search?q= searches names, text files and the text of PDF, Word, Excel and PowerPoint files (a document hit has document, page, sheet or slide; indexing counts documents still being read), and GET .../files/preview?path= reads a .xlsx, .xlsm, .csv, .tsv, .docx or .pptx file of up to 20 MiB for a preview (kind table: sheets of at most 200 rows by 30 columns with total_rows and total_cols; kind document: headings, list items, paragraphs and tables {type table, rows}; kind slides: titles, text and notes; 415 for another type). POST .../{id}/messages also takes multipart/form-data (text and files) to attach files (with a turn running the files are saved at once and the message waits with their paths): images go to a vision model inside the message (image_url parts; other models get the path and describe_image), videos to a model that takes video, and the text of PDF, Word, Excel, PowerPoint and EPUB files is added to the message (the model reads the rest with read_document). Skills: GET /api/chats/skills?chat= lists the skills of the five layers (folder, personal, shared, addon, builtin; an addon skill names its add-on in addon and addon_title) with enabled and shadowed, POST creates one {layer personal|shared, name, description, body} (shared needs an administrator), GET PUT DELETE .../skills/{layer}/{name} read, change and delete (to the Trash), PUT /api/chats/{id}/skills {off} switches skills off for one chat. Well-known files: GET and PUT /api/chats/wellknown/{name} (persona, user, shortcuts, home, ui, or widget-data with ?path=) read and change the files of your home that set the assistant's persona, your profile, the composer shortcuts, the Home widgets and the look. A read is validated against the JSON Schema at .../wellknown/{name}/schema and falls back to the last valid version (fallback, errors); a write {values, replace} merges the keys it sends (null removes one), takes the version you read in If-Match (409 with current when the file changed) and is refused with errors when the result is invalid; POST .../wellknown/{name}/revert restores the previous valid version as a new change, or the version {rev} that GET .../wellknown/{name}/versions lists (newest first, with who wrote it and in which chat). A checkpoint line of a transcript carries rev, the change set of the turn, and lists the paths it changed. Add-ons: GET /api/chats/{id}/addons lists the add-ons whose tools you may use, each with its switch for that chat, and PUT /api/chats/{id}/addons {off: [ids]} switches the tools of add-ons off for one chat. Commands: a chat runs commands with bash, served by an add-on that runs commands (isolated) or, when no add-on serves it, by the built-in bash directly on this machine with no isolation and with its network; the add-on list says which (commands_provider, commands_isolation, commands_consent, commands_fallback). With chats_host_exec ask, each command waits: a host_exec_consent event carries {ids: [id], text: command} and POST /api/chats/{id}/host-exec/consent {id, decision: once|deny} answers it (once needs an administrator). Administrators: GET /api/chats/admin/tools lists the built-in tools an add-on may replace (Settings > Tools; change them with POST /api/settings chats_host_exec and chats_tool_providers) and GET /api/chats/admin/host-exec/events?limit= reads the audit of the commands run on this machine. Network of the isolated commands: the guest has no network device and reaches HTTP and HTTPS only through a proxy that asks the server about each connection. GET /api/chats/{id}/egress returns {preset, ceiling, effective, applies, applies_reason, addon, registries, rules, cards} (applies is false when commands run on this machine, which has the server's network; addon is the add-on that runs the commands and draws the chat's network controls), PUT .../{id}/egress {preset: off|registries|all} sets the chat's preset (400 egress_above_ceiling above the administrator's chats_egress_ceiling), DELETE .../{id}/egress/rules/{host}/{port} removes an allow or deny rule, an egress_consent event {ids: [card id], text: host:port} asks for a host the chat does not allow (the command waits up to 60 seconds) and POST .../{id}/egress/consent {id, decision: once|chat|deny} answers it (allowing a host inside your network needs an administrator), and egress_card events say that the chat's network changed. Administrators: GET /api/chats/admin/egress reads the chats_egress_* settings in force, the registries and the counts of the last 24 hours, and GET /api/chats/admin/egress/events?limit=&user=&chat=&verdict=&host= reads the audit. Saved agents: GET and POST /api/chats/agents, GET PUT DELETE .../agents/{id}: an agent is {name, description, instructions, model, mcp_servers, addons, collections, skills, network, folder}; folder {path, mode read|write} gives it one folder of your files (\"/\" is all of them; not LocalAI, Chats or a shared folder; 400 otherwise), kept by the folder itself so a rename or move is followed, shown back as {path, name, mode, state ok|missing|unset}; a write without folder keeps it and folder {path: \"\"} clears it; network (off, registries or all; off by default) is the network of the isolated commands of its trigger and connector runs, capped by chats_egress_ceiling, which never show a card; the lists are exact grants (an empty list grants nothing) and a chat gets only what you may use at that moment. POST /api/chats {agent_id} starts a chat with one (the agent's model replaces model when it has one). GET .../agents/{id}/export returns the definition as a JSON file (version 1, kind localai-agent; version 2 with folder {path, mode} for an agent with a folder, which a server that knows only version 1 refuses), POST .../agents/import takes that file (JSON body or multipart file field) and saves a new agent (a taken name gets a number, an unavailable model is dropped, a folder you do not have leaves the agent unset until you choose one; warnings say so), GET .../agents/templates lists starter agents and POST .../agents/templates/{id}/create saves one, GET .../agents/{id}/runs lists the chats that ran with the agent (yours, trigger runs, connector runs) with last_turn and counts. Inbox: GET /api/chats/notifications?unread=1 lists the notifications of your trigger runs (kind run_ended, run_failed or needs_attention) with the unread count, GET .../notifications/count counts them, POST .../notifications/read {ids} or {all: true} marks them read, DELETE .../notifications {ids} removes some, or {all: true} empties your inbox ({all: true, read_only: true} removes only the read ones; the answer has deleted and the unread count left), the list also gives total (everything kept, since limit, at most 500, can cut the list), and GET .../notifications/events streams new ones as server-sent events plus a notifications_changed event when notifications were read or deleted (from any tab or frontend), after which a client reads the list again. A user only ever reaches their own notifications. Opening a chat (GET /api/chats/{id}) marks its notifications read. Triggers: GET and POST /api/chats/triggers, GET PUT DELETE .../triggers/{id}, POST .../{id}/run (start now), .../{id}/enable and .../{id}/disable, GET .../{id}/runs, GET .../triggers/preview?when=&tz= (next times of a schedule). A trigger is {name, prompt, kind schedule|file, schedule + time_zone, or folder + quiet_seconds, daily_cap, enabled, agent_id, model, source_chat_id}: it starts a chat as you by itself, on a schedule (\"daily 08:00\", \"weekdays 09:30\", \"weekly mon,thu 09:30\", \"monthly 15 08:00\", \"once 2026-12-24 18:00\" or five cron fields) or when a file arrives in the folder; a chat it starts has origin trigger. A trigger also has plan {reads, writes, widget} (what a run reads, the file it rewrites and the Home widget that shows it), cost {estimated_tokens, average_tokens, runs, max_runs_per_day, remote} and last_done, and each run its tokens; a file trigger waits min_gap_seconds (60 by default) after a run before a file that changed again starts another. The four trigger tools (propose_trigger, list_triggers, update_trigger, delete_trigger) are the built-in tool group triggers: on in a chat a person starts, switched together with PUT .../{id}/builtins {off: [\"triggers\"]}, listed in GET .../{id}/tools and GET .../draft/tools with usable and reason (not usable in a run a trigger or connector started, for a user without the triggers feature; not listed when the server runs no triggers), and never given to a sub-agent. A model can propose a trigger in a chat (propose_trigger; a schedule from a chat runs at most once an hour); GET .../triggers/proposals?chat= lists them, PUT .../triggers/proposals/{id} edits one (it stays a proposal), POST .../triggers/proposals/{id}/confirm {time_zone} turns one into a trigger that is on, DELETE dismisses it (the answer is kept: GET .../triggers/proposals/{id} returns {state pending|accepted|declined|undone, trigger, answered_at, can_undo, undo_blocked}, POST .../undo takes an accepted one back unless the trigger ran or was edited since (409 says why) and POST .../reopen restores a declined or undone one); the chat carries a proposal as a card {kind trigger_proposal, id} on the tool line (tool_end event and transcript) and as a trigger_proposed event. A model can also ask to change a trigger of yours (list_triggers gives each a ref; update_trigger edits, pauses or resumes, delete_trigger deletes): an edit or a delete waits for you, and a pause or a resume applies at once; the chat carries it as a card {kind trigger_change, id}. GET .../triggers/changes/{id} returns {id, op update|delete|pause|resume, state pending|applied|dismissed|undone, trigger_id, before, after, fields, answered_at, stale, can_undo, undo_blocked, last_run}, POST .../changes/{id}/apply makes it (409 when the trigger changed since), DELETE dismisses it and POST .../changes/{id}/undo puts the trigger back as it was, for a day after it applied (a deleted trigger comes back with its id, without its run history). Proposed changes: a model asks to change your memory or an agent profile with propose_change {kind memory|profile, scope user|agent|channel, target, content, summary} (in the built-in tool group changes; revert_change {id} undoes an applied one). Nothing is written until you confirm. GET /api/chats/changes?status= lists the proposals (id, kind, scope, target, summary, content, source {kind, id, chat}, status pending|applied|dismissed|expired|reverted|stale, rev), GET .../changes/{id} reads one, POST .../changes/{id}/confirm writes it as one change set, or with a body {content} your own text in its place (409 when the file changed since, the proposal expired after 24 hours or it was answered), POST .../changes/{id}/dismiss answers no, POST .../changes/{id}/undo restores the file (409 when it was edited since) and GET .../changes/audit lists who proposed, confirmed or reverted what, with a hash and never the text. GET /api/chats/context-files lists the notes the host puts in the prompt (limits, and groups of {source, scope, key, max_bytes, max_lines, files}) for the Memory tab of Your settings; it only reads. At most 10 proposals wait per user and 5 are made per hour per agent. A chat that runs a saved agent cannot propose until the agent is granted it: the agent's builtins list holds memory.write, profile.write, skills.write, pages.write or dashboard.write (GET /api/chats/agents/builtin-grants lists every grant with title, description and sensitive; none is on by default). propose_change also takes kind skill (a SKILL.md; a skill with locked: true in its frontmatter is refused), page (target is the page name, files is {path: text} with an index.html) and dashboard (the full home.json, checked against its schema). Browser push: GET /api/chats/push/key returns the server's VAPID public key, POST /api/chats/push/subscribe takes a browser PushSubscription {endpoint, keys: {p256dh, auth}} (an https endpoint on a public address) and DELETE .../push/subscribe {endpoint} removes it; a notification is then pushed with its title and link only. These answer 409 push_off when the server keeps nothing across a restart. Conversion: at start the server converts the old chat page, agents, tasks, skills and Telegram connectors; GET /api/chats/conversion lists what happened to your items (kind, name, status converted|skipped|failed|dropped, reason) and gives an administrator the totals. Converted triggers and connectors are switched off. Connectors: GET /api/chats/connectors/kinds lists the platforms that an installed add-on serves (each kind comes with the add-on that serves it), GET and POST /api/chats/connectors, GET PUT DELETE .../connectors/{id}, POST .../{id}/enable, .../disable and .../test (asks the platform who the bot is), GET .../{id}/senders (who wrote to the bot) and POST .../{id}/senders/{sender}/allow. POST .../{id}/pairing makes a one-use pairing code (valid ten minutes) that you send as a direct message to the bot from an allowlisted account to pair it (a pairing lasts 90 days, at most three senders), and DELETE .../{id}/paired/{sender} ends one. A binding also has caps (the capabilities you enabled, from a closed list), grants [{role, caps}] (roles that the kind declares in roles and permissions of the kinds list; never a sensitive capability) and paired. A binding is {kind (the platform, telegram when empty; set at create only), name, token (write-only, sealed, never returned; has_token says it is stored), agent_id (required: an agent of yours that names a model), enabled, accept_any, allow [chat IDs, user IDs or @usernames], notify_chat}: a person who messages the bot talks to that agent in a conversation of their own, as you, with only the agent's grants. The allowlist is empty at first, so nobody is accepted; a sender who is not accepted is ignored and logged. accept_any lets anyone who can message the bot use the agent; the create or update that turns it on must also send confirm_accept_any: \"accept any message\" (else 400). Files and skills answer 409 with the reason when the server has no data path. Administrators: GET /api/chats/admin/agents, .../admin/triggers and .../admin/connectors list what every user has, each with its owner and never a token or instructions; GET .../admin/triggers/{id}/runs lists the runs of a trigger, POST .../admin/triggers/{id}/disable and .../admin/connectors/{id}/disable switch one off, and DELETE .../admin/agents/{id}, .../admin/triggers/{id} and .../admin/connectors/{id} remove one. No MCP tool maps to these routes: they are the user's own chat and files, not administration. GET POST /api/chats/mcp-connectors, GET PUT DELETE /api/chats/mcp-connectors/{id} and POST .../enable, .../disable, .../test manage your MCP connectors: MCP servers you define (transport http, or stdio for administrators outside distributed mode), stored per user, with token, headers and env sealed and never returned; a test answers 200 with ok and the tool names or the error. Addresses on private networks are refused at connect time unless an administrator sets mcp_connectors_allow_private. A chat with no agent gets your enabled connectors; a saved agent names one as connector: in mcp_servers. GET /api/chats/{id}/tools lists the built-in tool groups, add-ons, model MCP servers and connectors of a chat with their switches. The built-in groups are on until PUT /api/chats/{id}/builtins {off: [...]} switches them off for that chat: shell (bash, whoever serves it; commands then says switched_off), web, files (the file tools and read_document), media (describe_image and transcribe_audio), collections, subagents (spawn_agent) and triggers; load_skill and the goal and loop tools have no switch, and a sub-agent follows its parent's switches; PUT /api/chats/{id}/mcp {off: [server name or connector:]} switches MCP servers and connectors off for that chat. GET /api/chats/{id}/mcp/prompts and .../mcp/resources list the prompts and resources of the servers the chat can use now; POST .../mcp/prompts {server, name, arguments} and POST .../mcp/resources/read {server, uri} return their text (403 mcp_refused for a server the chat cannot use); POST .../{id}/messages takes context: [{server, name, arguments} or {server, uri}] to add them to a message. MCP Apps: a tool that declares an interactive page (_meta.ui.resourceUri) gives its tool_end event and its transcript line an app {server, tool, resource_uri, visibility}; the transcript line also holds the call input and result. GET /api/chats/{id}/mcp-apps/resource?server=&uri= reads the ui:// page (html, mime_type, meta with csp, permissions and prefersBorder) for a server the chat may use now and a resource a tool of that server declares, and POST /api/chats/{id}/mcp-apps/call {server, tool, arguments} runs a tool of the same server whose visibility includes app, for the page to call; both answer 403 mcp_app_refused for anything else and need the MCP feature. Loops repeat a prompt inside a chat: POST .../{id}/loops {interval, prompt} starts one (interval such as 5m, at least chats_loop_min_interval; empty for a self-paced loop whose model calls schedule_next or loop_done after each run), GET .../{id}/loops lists them (also the loops field of GET /api/chats/{id}), PUT .../{id}/loops/{loopId} {state: paused|active} pauses, resumes or confirms one (the first loop of a chat is pending until confirmed), DELETE .../{id}/loops/{loopId} or DELETE .../{id}/loops stops them; a loops event tells other tabs, and a user line a loop sent carries loop {id, run}. A loop runs in its own chat and as often as every minute; a trigger starts a new chat at most once an hour."}, {Name: "pages", Description: "Pages: HTML pages in your workspace that the UI shows in a sandboxed frame", Tags: []string{"pages"}, Intro: "A page is the folder pages// of your home (name ^[a-z0-9][a-z0-9-]{0,63}$) with an index.html, an optional page.json {title, icon, description, widget: {size small|medium|large, title}} and its data files; a page with a widget block can be a tile on Home (a widget {type: page, page: , folder?: } in LocalAI/home.json, at most 3), where it runs with localai.page.mode \"widget\". GET /api/chats/pages lists them (source own, author you or assistant, updated_at, updated_by); GET /api/chats/pages/{name} returns the page with its HTML (at most 1 MiB). The bridge of a page: GET /api/chats/pages/{name}/files?dir=, GET and PUT /api/chats/pages/{name}/file (text files of at most 1 MiB, paths relative to the page's folder; a page writes data files only, code and document files such as .js, .css, .html, index and page.json are refused with 403; writes are rate limited; GET .../file?path=&encoding=base64 reads an image of the folder as base64, which the UI puts into the page), and POST /api/chats/pages/{name}/chat {text, chat_id, model} to put a message in a chat after the person confirmed it. GET /api/chats/page-starters and POST /api/chats/page-starters/{id}/copy copy a starter page. Write a page with the files API or from a chat. Shared pages: GET /api/chats/shared-pages lists the pages of the folders an administrator shared with you (folder, folder_id, author_name, hash, code_files, consent none, allowed or changed; the hash covers index.html, page.json and code_files, the scripts, style sheets and modules the page names, so a change to any of them asks again; enabled false when an administrator turned shared pages off). GET /api/chats/shared-pages/{folder_id}/{name} returns one with its HTML and your consent; PUT .../consent {hash, once} allows that version (once gives a visit token), DELETE .../consent revokes it. The bridge of a shared page is .../files, .../file and .../chat as for your own pages, each with the header X-LocalAI-Page-Hash (the version that runs) and X-LocalAI-Page-Visit for a visit allowed once; without a consent it answers 403 page_not_allowed, a read of a file of code by a frame of an older version answers 409, and a read-only grant cannot write. Needs the chats and pages features (pages is off by default for users who are not administrators)."}, {Name: "addons", Description: "Owner-routed addon lifecycle and response relays", Tags: []string{"Addons"}, Intro: "POST /api/addons/dispatch submits typed lifecycle operations or reads actor-bound operation-status. POST /api/addons/relay serves status and coarse lifecycle progress on the receiving frontend. Named authentication and the addons feature are required; lifecycle operations require administration. Preserve operationId after outcome_unknown and poll status, never submit a new mutation. An add-on that refuses a request as invalid answers 400 invalid_request from every route, never an outage: nothing was applied, so correct the request instead of repeating it. Resource and output routes deny access until host admission and resolution providers are installed. Data and schema mutations are stored proposals: POST /api/addons/proposals previews and stores one (nothing is applied), GET /api/addons/proposals/{id} reads it, GET /api/addons/proposals?state=pending lists the caller's own pending proposals (newest first, at most 32; where a proposal made through the OpenAI-compatible chat route, an API client or an MCP session waits for the proposer's own browser session; the chats of /api/chats, their agents and their triggers apply a change at once because turning the add-on on for the chat is the consent, and are not proposals), POST .../cancel withdraws one and POST .../reconcile resolves an unknown outcome by receipt lookup, and when no receipt can be found POST .../resolve lets the resource owner's own browser session abandon it with a reason (same confirmation-surface rules as confirm; nothing is applied again). The owner role finds what to resolve with GET /api/addons/resources/{resourceId}/proposals?state=unresolved (same rules, X-LocalAI-Confirm equals the resource ID): it lists the proposal ID, proposer, effects, digest and age of every unresolved proposal on the resource, never its arguments. A write blocked by an unresolved outcome is denied (403) and, for the owner role only, names it as outcome_blocked with blockedBy. POST .../confirm applies exactly the stored operation once and is accepted only from the proposer's own browser session (same-origin fetch, X-LocalAI-Confirm header equal to the proposal ID, digest of what was shown); API keys, MCP sessions and tool or agent calls can propose but never confirm, and no MCP tool maps to confirm. Sharing and database deletion are never proposals. A read that returns a file result gives private artifact IDs, never addon handles: GET /api/addons/artifacts/{id} returns the transfer state, name, size and expiry, and GET /api/addons/artifacts/{id}/download streams the complete file as an attachment. Only the requesting principal can read or download an artifact, an unknown, foreign or expired ID is the same 403, access to every source resource is checked again at completion and while streaming, and a disabled addon denies downloads until it is enabled again. Only GET /backends/library/updates has an MCP tool, list_backend_library_updates; no MCP tool maps to the other routes. The Apps surface: GET /api/addons/apps lists the installed, enabled addons any caller with the addons feature may use, with title, description, icon URL, version, generation, state (running, starting, failed or stopped; an observation separate from enabled), a main page path only when the manifest declares a valid one, and a bounded tool summary; administrators also see disabled addons and per-app controls. GET /api/addons/apps/{id}/icon?generation=N serves the verified icon of a visible app. POST /api/addons/apps/{id}/start and .../stop are administrator-only, take {operationId, generation} with the generation that was listed, deny a stale generation and keep the lifecycle rules (preserve operationId after outcome_unknown). No MCP tool maps to the Apps routes. The listed ui entry of an app gives the same-origin URLs of its UI code: GET /api/addons/{id}/assets/{digest}/{path} streams one declared file (the entry module or a style sheet) of the current installed package, relayed from the owner, with Cache-Control private, max-age=31536000, immutable and nosniff. The digest is the package sha256 in lower-case hex; a file the manifest does not declare, a traversal, another addon, an older package and a disabled or uninstalled addon are all one 403, never cached. A credential in the URL is never accepted; the browser uses its same-origin session. No MCP tool maps to the asset route. Management (administrators only, through POST /api/addons/dispatch): the reads catalog-search, catalog-details, catalog-sources and addons-installed return catalog entries, one resolved entry, the configured libraries with their last fetch state and the installed addons with verified package facts, health, retained generations and storage; catalog-source-save stores or removes a library by reference only (a secret in place of a reference is denied) and catalog-source-refresh fetches one again; upgrade installs a newer release of an installed addon. Removing a library never touches installed addons. The installed view has an update object computed by the owner's update check (state, candidate version and digest, check and last-success time, reasons, redacted error class); an add-on the owner cannot answer for has none, and a failed check is never up-to-date. No MCP tool maps to these actions. UI-only resource management (never tools; no MCP tool maps to any of it): GET /api/addons/resources?addonId=tables lists the resources the caller owns or that were shared with the caller, with the caller's own role, the display name, the owner's display name for a shared one and, for the owner role only, the number of people it is shared with and the revision. POST /api/addons/resources creates one by the person's own action (operationId makes it idempotent; the arguments are the add-on's create tool arguments), POST .../{resourceId}/rename and .../delete rename and delete it (the owner role only; generation and revision must be the listed ones or the call is 409 stale; delete needs confirm equal to the display name, removes the data and every grant and never uninstalls the add-on), GET .../{resourceId}/grants, PUT/DELETE .../grants/{userId} and GET .../{resourceId}/users?q= list, set (viewer or editor, whole resource) and remove grants and search the people it can be shared with (immutable account ID and display name only; local-owner mode answers 409 sharing_disabled). All of them accept only the owner's own browser session with the X-LocalAI-Confirm header equal to the resource ID (the operation ID for a creation); API keys are refused. POST .../{resourceId}/apply changes the data or the schema by the person's own action with no card, through a write or schema tool the add-on declares ({operationId, generation, revision, tool, arguments} with the revision the caller read; editors write rows, only the owner changes the schema; 409 stale when the generation or revision moved; the same operationId with the same request is one change; reads, creations, rename, delete and undeclared tools are refused). POST .../resources/{resourceId}/changes/{changeId}/undo ({operationId, generation}) undoes one change an add-on tool made at once from a chat, an agent or a trigger, by the change set the add-on kept for it; the change ID is the one the tool result named (undoable true, changeId, undo). It is the person's own action in a browser tab and accepts only the browser session with X-LocalAI-Confirm equal to the resource ID: an API key, an MCP session or a tool call is refused, so a model can never undo work. The role is the one the change needed (editors undo data changes, only the owner role undoes a schema change or a creation). It is applied against the revision the add-on just reported: when the resource changed after the change the answer is 409 stale with the words of what changed and nothing is overwritten. The other refusals are typed: not_found (404), expired (410, after 7 days), already_undone (409) and cannot_undo (409, for example a job that finished); the same operationId with the same change is one undo. GET .../resources/{resourceId}/operations/{operationId}?kind=rename|delete|apply|undo reads the receipt of a rename, a deletion, a change or an undo after outcome_unknown, which carries the original operationId. GET/PUT/DELETE /api/addons/conversations/{conversationId}/selection/{addonId} keep the resource a conversation works on for the authenticated user; a chat tool call that names no resourceId uses it, authorized again by the owner on every call, and with none selected the tool answers an error result with code select_resource. Data recovery (administrators only, never tools; no MCP tool maps to any of it): GET /api/addons/{id}/recovery returns the recovery state of an add-on's data (ready, upgrading, restoring, needs-recovery or purging), its backups newest first with size, reason, mode and last verification result, the retention policy and the receipt of the last restore. POST /api/addons/{id}/recovery/backup makes a verified, consistent backup (the add-on is stopped while its SQLite files are copied with VACUUM INTO, admission is blocked and pending proposals are invalidated; nothing is left behind on failure; 503 insufficient_storage when it does not fit), .../verify reads one completely, .../restore restores one (the add-on must not be running, confirm is the add-on ID, mapping names the account that takes the data of a backup made in local-owner mode, the answer carries warnings and no share is given back that was removed since the backup or whose account is gone; 409 needs_recovery, addon_running, backup_corrupt, incompatible_format, identity_changed, mapping_required or downgrade_unsupported), .../delete-backup removes one (never the only verified copy, 409 sole_recovery_copy) and .../prune applies the retention policy. POST /api/addons/{id}/purge removes retained data by explicit scope (data, backups, recoveryCopies) with confirm equal to the add-on ID, for an add-on that is uninstalled or disabled; it is not a database deletion or an uninstall. Every mutation accepts only the administrator's own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId); a repeated operationId returns the recorded result and never runs again; after outcome_unknown read the state and keep the operationId. An upgrade of an add-on that has data is backed up, migrated under exclusive ownership and published only when healthy; a failed migration that changes the data format leaves the add-on in needs-recovery with the previous package and the verified backup retained. Updates (never tools; no MCP tool maps to any of it): GET /api/addons/{id}/update reads the owner's update status of an installed add-on for every caller with the addons feature who may see it (administrators any installed add-on, others an enabled one; the same 403 otherwise). The state is unknown, up-to-date, update-available, incompatible (reasons: platform, host-version, api-version, protocol-version, sdk-version, data-format), check-failed (a redacted error class and lastSuccess, the time the library was last read; never shown as up-to-date) or integrity-error (an immutable version was listed again with another digest; never an update). A moving channel (development, a branch, latest) compares resolved digests, so a changed digest with the same version label is an update; an immutable version selects the highest compatible greater version of the same major. The library and the channel the add-on came from never change by themselves. Callers who are not administrators get the state, versions and times only. The route never uses the network. POST /api/addons/{id}/update/check (administrators, own browser session, X-LocalAI-Confirm equal to the operationId) fetches the library again and returns the status; it is rate limited and answers throttled true when it did not fetch, and it applies nothing. POST /api/addons/{id}/upgrade (administrators, own browser session) is the only request that applies an update: it carries operationId, expectedDigest (the installed package the caller saw) and candidateDigest (the candidate the check resolved), and the owner re-resolves, re-checks compatibility and refuses before it records anything with 409 stale_installed, candidate_changed, incompatible, integrity_error or upgrade_conflict (the same transition under another operationId); 503 check_failed when the library cannot be read. A failure keeps the last good generation or enters the recovery state; the previous installation is never deleted. The apps list marks an app update: update-pending from the owner's cached check only and never reaches a library."}, {Name: "library-sources", Description: "Library sources of add-ons, backends and models", Tags: []string{"library"}, Intro: "Administrators only. GET /api/library/sources lists every source of the add-on, backend and model kinds, shipped defaults and removed defaults (tombstones) included, with kind, id, name, url, mirrors, enabled, default, removed, suspended, a conflict message and the last fetch state. POST /api/library/sources adds a source of your own ({kind: addon|backend|model, url, id, name, format: library|gallery}); the same kind and URL twice is a 409. POST /api/library/sources/{id}/enable and /disable turn one on or off (enabling a removed default brings it back). PUT /api/library/sources/{id} replaces the mirrors of a model or backend source ({mirrors: [..]}); POST also takes mirrors. Each mirror is an http(s), github:, oci:// or file:// address, at most 8. DELETE /api/library/sources/{id} removes a source; a shipped default stays as a tombstone so it does not return at the next start. The routes work on the store that `local-ai library` edits. On a frontend (--addons-role=frontend) the routes forward to the add-on owner, which is the only writer; they answer 503 when the owner is not reachable or runs on another platform. `GET /backends/library/updates` and `POST /backends/library/install` forward the same way. The runtime setting official_libraries (default on) turns every shipped default off at once: they stay listed with suspended true. No MCP tool maps to these routes."}, diff --git a/core/http/endpoints/studio/agents.go b/core/http/endpoints/studio/agents.go index 8c6958d302..36d17bf6ea 100644 --- a/core/http/endpoints/studio/agents.go +++ b/core/http/endpoints/studio/agents.go @@ -77,7 +77,7 @@ func agentOut(a agents.Agent) schema.ChatsAgent { } return schema.ChatsAgent{ID: a.ID, Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model, MCPServers: nz(a.MCPServers), Addons: nz(a.Addons), Collections: nz(a.Collections), Skills: nz(a.Skills), - Network: networkOf(a.Network), BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Created: a.Created, Updated: a.Updated} + Network: networkOf(a.Network), BuiltinsOff: nz(a.BuiltinsOff), BuiltinsOn: nz(a.BuiltinsOn), Builtins: nz(a.Builtins), Created: a.Created, Updated: a.Updated} } // agentIn reads the body. A model the user may not use is refused here, so a @@ -101,7 +101,7 @@ func (h *Handlers) agentIn(c echo.Context) (a agents.Agent, folders *[]studiosvc } return agents.Agent{Name: req.Name, Description: req.Description, Instructions: req.Instructions, Model: req.Model, MCPServers: req.MCPServers, Addons: req.Addons, Collections: req.Collections, Skills: req.Skills, Network: req.Network, - BuiltinsOff: req.BuiltinsOff, BuiltinsOn: req.BuiltinsOn}, + BuiltinsOff: req.BuiltinsOff, BuiltinsOn: req.BuiltinsOn, Builtins: req.Builtins}, foldersIn(req), true, nil } diff --git a/core/http/endpoints/studio/agentsfile.go b/core/http/endpoints/studio/agentsfile.go index 6e024ba51c..8e09d70001 100644 --- a/core/http/endpoints/studio/agentsfile.go +++ b/core/http/endpoints/studio/agentsfile.go @@ -47,7 +47,7 @@ func agentFileVersion(folders int, hasSwitches bool) int { // ExportAgent returns a saved agent as a file. // @Summary Export a saved agent. -// @Description Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export. +// @Description Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. The builtins list (builtin grants) is an extra field of any version: a server that does not know it ignores it, so the agent comes back without the grants. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export. // @Tags chats // @Param id path string true "the agent's id" // @Success 200 {object} schema.ChatsAgentExport "The agent file" @@ -59,7 +59,7 @@ func (h *Handlers) ExportAgent(c echo.Context) error { } w := schema.ChatsAgentWrite{Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model, MCPServers: a.MCPServers, Addons: a.Addons, Collections: a.Collections, Skills: a.Skills, Network: a.Network, - BuiltinsOff: a.BuiltinsOff, BuiltinsOn: a.BuiltinsOn} + BuiltinsOff: a.BuiltinsOff, BuiltinsOn: a.BuiltinsOn, Builtins: a.Builtins} rows := h.svc.AgentFolderRows(c.Request().Context(), userID(c), []agents.Agent{a})[a.ID] for i, f := range a.FolderList() { // Imports resolve paths in the home only. A followed root outside @@ -148,7 +148,7 @@ func (h *Handlers) ImportAgent(c echo.Context) error { } a := agents.Agent{Name: def.Name, Description: def.Description, Instructions: def.Instructions, Model: def.Model, MCPServers: def.MCPServers, Addons: def.Addons, Collections: def.Collections, Skills: def.Skills, Network: def.Network, - BuiltinsOff: def.BuiltinsOff, BuiltinsOn: def.BuiltinsOn} + BuiltinsOff: def.BuiltinsOff, BuiltinsOn: def.BuiltinsOn, Builtins: def.Builtins} var rowsIn []studiosvc.AgentFolderWrite switch { case def.Folders != nil: @@ -221,6 +221,20 @@ func (h *Handlers) AgentTemplates(c echo.Context) error { return c.JSON(http.StatusOK, out) } +// BuiltinGrants lists the builtin grants an agent can be given. +// @Summary List the builtin grants of a saved agent. +// @Description Each grant has a name (the value for builtins in an agent), a title, a description and sensitive. A sensitive grant changes what the agent may do or reach later (profile, skills, pages, dashboard, new channels, actions of unattended runs); none is on by default. A chat of the agent holds only the grants you may use at that moment. +// @Tags chats +// @Success 200 {object} schema.ChatsBuiltinGrantList "The grants, in display order" +// @Router /api/chats/agents/builtin-grants [get] +func (h *Handlers) BuiltinGrants(c echo.Context) error { + out := schema.ChatsBuiltinGrantList{Grants: []schema.ChatsBuiltinGrant{}} + for _, g := range h.svc.BuiltinGrants() { + out.Grants = append(out.Grants, schema.ChatsBuiltinGrant{Name: g.Name, Title: g.Title, Description: g.Description, Sensitive: g.Sensitive}) + } + return c.JSON(http.StatusOK, out) +} + // CreateAgentFromTemplate saves a starter agent. // @Summary Save a starter agent as your own. // @Tags chats diff --git a/core/http/routes/studio.go b/core/http/routes/studio.go index 7b815a0259..b913dae484 100644 --- a/core/http/routes/studio.go +++ b/core/http/routes/studio.go @@ -242,6 +242,7 @@ func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioend e.GET(base+"/:id/mcp-apps/resource", h.GetMCPAppResource) e.POST(base+"/:id/mcp-apps/call", h.CallMCPAppTool) e.GET(base+"/agents/templates", h.AgentTemplates) + e.GET(base+"/agents/builtin-grants", h.BuiltinGrants) e.POST(base+"/agents/templates/:id/create", h.CreateAgentFromTemplate) e.POST(base+"/agents/import", h.ImportAgent) e.GET(base+"/agents/:id/export", h.ExportAgent) diff --git a/core/http/routes/studio_agentsfile_test.go b/core/http/routes/studio_agentsfile_test.go index 17ba6b3d34..66d3874a6c 100644 --- a/core/http/routes/studio_agentsfile_test.go +++ b/core/http/routes/studio_agentsfile_test.go @@ -31,6 +31,7 @@ var _ = Describe("Chats agent export, import, templates and history", func() { Entry("export", http.MethodGet, "/api/chats/agents/a1/export"), Entry("import", http.MethodPost, "/api/chats/agents/import"), Entry("templates", http.MethodGet, "/api/chats/agents/templates"), + Entry("builtin grants", http.MethodGet, "/api/chats/agents/builtin-grants"), Entry("use a template", http.MethodPost, "/api/chats/agents/templates/reviewer/create"), Entry("runs", http.MethodGet, "/api/chats/agents/a1/runs"), ) @@ -71,6 +72,62 @@ var _ = Describe("Chats agent export, import, templates and history", func() { Expect(decodeJSON(s.do("bob", http.MethodGet, "/api/chats/agents", nil))["agents"]).To(HaveLen(1)) }) + Describe("builtin grants", func() { + It("lists the declared grants with their sensitivity", func() { + rec := s.do("alice", http.MethodGet, "/api/chats/agents/builtin-grants", nil) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + list := decodeJSON(rec)["grants"].([]any) + byName := map[string]map[string]any{} + for _, g := range list { + m := g.(map[string]any) + Expect(m["title"]).NotTo(BeEmpty()) + Expect(m["description"]).NotTo(BeEmpty()) + byName[m["name"].(string)] = m + } + Expect(byName).To(And(HaveKey("memory.write"), HaveKey("skills.write"), HaveKey("pages.write"), HaveKey("dashboard.write"), HaveKey("saved_agents"))) + Expect(byName["skills.write"]["sensitive"]).To(BeTrue()) + Expect(byName["memory.write"]["sensitive"]).To(BeFalse()) + }) + + It("saves, keeps, clears, exports and imports the grants of an agent", func() { + create := s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Maker", "model": "test-model", "builtins": []string{"memory.write", "skills.write"}}) + Expect(create.Code).To(Equal(http.StatusCreated), create.Body.String()) + a := decodeJSON(create) + id := a["id"].(string) + Expect(a["builtins"]).To(ConsistOf("memory.write", "skills.write")) + + // A new agent has none, and the field is always present. + plain := decodeJSON(s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Plain", "model": "test-model"})) + Expect(plain).To(HaveKeyWithValue("builtins", BeEmpty())) + + // A write that omits the field keeps the grants; an empty list clears them. + keep := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Maker", "model": "test-model", "instructions": "x"}) + Expect(keep.Code).To(Equal(http.StatusOK), keep.Body.String()) + Expect(decodeJSON(keep)["builtins"]).To(ConsistOf("memory.write", "skills.write")) + + exp := decodeJSON(s.do("alice", http.MethodGet, "/api/chats/agents/"+id+"/export", nil)) + Expect(exp["agent"].(map[string]any)["builtins"]).To(ConsistOf("memory.write", "skills.write")) + imp := s.do("bob", http.MethodPost, "/api/chats/agents/import", exp) + Expect(imp.Code).To(Equal(http.StatusCreated), imp.Body.String()) + Expect(decodeJSON(imp)["agent"].(map[string]any)["builtins"]).To(ConsistOf("memory.write", "skills.write")) + + clear := s.do("alice", http.MethodPut, "/api/chats/agents/"+id, map[string]any{"name": "Maker", "model": "test-model", "builtins": []string{}}) + Expect(decodeJSON(clear)["builtins"]).To(BeEmpty()) + }) + + It("refuses an unknown grant on a write and drops it on an import; an old file imports as before", func() { + bad := s.do("alice", http.MethodPost, "/api/chats/agents", map[string]any{"name": "Bad", "model": "test-model", "builtins": []string{"root.shell"}}) + Expect(bad.Code).To(Equal(http.StatusBadRequest), bad.Body.String()) + file := map[string]any{"version": 1, "kind": "localai-agent", "agent": map[string]any{"name": "Future", "model": "test-model", "builtins": []string{"memory.write", "from.the.future"}}} + imp := s.do("alice", http.MethodPost, "/api/chats/agents/import", file) + Expect(imp.Code).To(Equal(http.StatusCreated), imp.Body.String()) + Expect(decodeJSON(imp)["agent"].(map[string]any)["builtins"]).To(ConsistOf("memory.write")) + old := s.do("alice", http.MethodPost, "/api/chats/agents/import", map[string]any{"version": 1, "kind": "localai-agent", "agent": map[string]any{"name": "Old", "model": "test-model"}}) + Expect(old.Code).To(Equal(http.StatusCreated), old.Body.String()) + Expect(decodeJSON(old)["agent"].(map[string]any)["builtins"]).To(BeEmpty()) + }) + }) + It("imports a file sent as a multipart form, and drops a model the server does not have", func() { file := map[string]any{"version": 1, "kind": "localai-agent", "agent": map[string]any{"name": "Shared", "instructions": "Hi", "model": "model-from-elsewhere"}} var buf bytes.Buffer diff --git a/core/schema/chats.go b/core/schema/chats.go index 1fb23b274b..9946aa698b 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -58,6 +58,11 @@ type ChatsAgent struct { // lists. Empty for an agent that has every group a chat has by default. BuiltinsOff []string `json:"builtins_off"` BuiltinsOn []string `json:"builtins_on"` + // Builtins are the builtin capabilities granted to the agent, by the names + // that GET /api/chats/agents/builtin-grants lists (memory.write, + // skills.write and so on). Empty grants none. A chat of the agent holds + // the grants you may use at that moment. + Builtins []string `json:"builtins"` // Folder is the one folder that chats with the agent work in. It is // absent for an agent that works on all your files. Folder *ChatsAgentFolder `json:"folder,omitempty"` @@ -128,6 +133,10 @@ type ChatsAgentWrite struct { // the agent); an empty list clears them. BuiltinsOff []string `json:"builtins_off,omitempty"` BuiltinsOn []string `json:"builtins_on,omitempty"` + // Builtins are the builtin grants of the agent (see ChatsAgent). A write + // without them keeps the stored ones; an empty list clears them; a name + // the server does not declare is a 400, except in an import, which drops it. + Builtins []string `json:"builtins,omitempty"` // Folder chooses the one folder the agent's chats work in. A write // without it keeps the folder the agent has; {"path": ""} clears it. In // an export file it is the home-relative path and mode when the file was @@ -1970,3 +1979,20 @@ type ChatsExecutionStatus struct { Generation string `json:"generation,omitempty"` Incarnation string `json:"incarnation,omitempty"` } + +// ChatsBuiltinGrant describes one builtin grant for the agent editor. +type ChatsBuiltinGrant struct { + // Name is the value to put in an agent's builtins list. + Name string `json:"name"` + Title string `json:"title"` + // Description says what the agent can do with the grant, in English. + Description string `json:"description"` + // Sensitive grants change what the agent may do or reach later. They are + // never on by default and the editor marks them. + Sensitive bool `json:"sensitive"` +} + +// ChatsBuiltinGrantList is the list of builtin grants, in display order. +type ChatsBuiltinGrantList struct { + Grants []ChatsBuiltinGrant `json:"grants"` +} diff --git a/core/services/studio/agents/agentstest/suite.go b/core/services/studio/agents/agentstest/suite.go index 9f737c0c01..b166d82be2 100644 --- a/core/services/studio/agents/agentstest/suite.go +++ b/core/services/studio/agents/agentstest/suite.go @@ -23,7 +23,7 @@ func sample(id, principal, name string) agents.Agent { return agents.Agent{ ID: id, Principal: principal, Name: name, Description: "d", Instructions: "Be brief.", Model: "m1", MCPServers: []string{"github"}, Addons: []string{"office", "sandbox"}, Collections: []string{"docs"}, Skills: []string{"review"}, - Network: "registries", BuiltinsOff: []string{"triggers", "web"}, BuiltinsOn: []string{"saved_agents"}, Created: now, Updated: now, + Network: "registries", BuiltinsOff: []string{"triggers", "web"}, BuiltinsOn: []string{"saved_agents"}, Builtins: []string{"memory.write", "skills.write"}, Created: now, Updated: now, } } @@ -46,6 +46,7 @@ func Run(newStore NewStore) { Expect(got.Network).To(Equal("registries")) Expect(got.BuiltinsOff).To(Equal([]string{"triggers", "web"})) Expect(got.BuiltinsOn).To(Equal([]string{"saved_agents"})) + Expect(got.Builtins).To(Equal([]string{"memory.write", "skills.write"})) Expect(got.Created.Equal(a.Created)).To(BeTrue(), "got %+v", got) a.Name, a.Addons, a.Updated = "Reviewer 2", nil, a.Updated.Add(time.Hour) a.Created = time.Time{} diff --git a/core/services/studio/agents/gorm.go b/core/services/studio/agents/gorm.go index 385cb4b456..74f9a27741 100644 --- a/core/services/studio/agents/gorm.go +++ b/core/services/studio/agents/gorm.go @@ -40,8 +40,10 @@ type agentRow struct { // agents made before. BuiltinsOff string BuiltinsOn string - Created time.Time - Updated time.Time + // Builtins (the grants) came after them: a JSON list, empty before. + Builtins string + Created time.Time + Updated time.Time } func (agentRow) TableName() string { return "studio_agents" } @@ -123,7 +125,7 @@ func (r agentRow) agent(folders []folderRow) (Agent, error) { for _, f := range []struct { dst *[]string src string - }{{&a.MCPServers, r.MCPServers}, {&a.Addons, r.Addons}, {&a.Collections, r.Collections}, {&a.Skills, r.Skills}, {&a.BuiltinsOff, r.BuiltinsOff}, {&a.BuiltinsOn, r.BuiltinsOn}} { + }{{&a.MCPServers, r.MCPServers}, {&a.Addons, r.Addons}, {&a.Collections, r.Collections}, {&a.Skills, r.Skills}, {&a.BuiltinsOff, r.BuiltinsOff}, {&a.BuiltinsOn, r.BuiltinsOn}, {&a.Builtins, r.Builtins}} { if *f.dst, err = listFromJSON(f.src); err != nil { return a, err } @@ -195,7 +197,7 @@ func (s *gormStore) Put(ctx context.Context, a Agent) error { Description: a.Description, Instructions: a.Instructions, Model: a.Model, MCPServers: listJSON(a.MCPServers), Addons: listJSON(a.Addons), Collections: listJSON(a.Collections), Skills: listJSON(a.Skills), Network: a.Network, - FolderID: a.FolderID, FolderMode: a.FolderMode, BuiltinsOff: listJSON(a.BuiltinsOff), BuiltinsOn: listJSON(a.BuiltinsOn), + FolderID: a.FolderID, FolderMode: a.FolderMode, BuiltinsOff: listJSON(a.BuiltinsOff), BuiltinsOn: listJSON(a.BuiltinsOn), Builtins: listJSON(a.Builtins), Created: a.Created, Updated: a.Updated} if exists { row.Created = old.Created diff --git a/core/services/studio/agents/types.go b/core/services/studio/agents/types.go index 589b2d8257..69ff016010 100644 --- a/core/services/studio/agents/types.go +++ b/core/services/studio/agents/types.go @@ -58,6 +58,11 @@ type Agent struct { // group a chat has by default, as agents saved before the field. BuiltinsOff []string BuiltinsOn []string + // Builtins are the built-in capabilities the owner granted the agent, by + // the names of core/services/studio/builtins (memory.write, skills.write + // and so on). Empty grants nothing: no grant is on by default, and a chat + // gets the intersection with what its user may use at the turn. + Builtins []string // Network is the network preset of the Sandbox commands of the agent's // unattended runs (triggers, connectors): off, registries or all, capped // by the administrator's ceiling. Empty is off. A run never asks a @@ -178,7 +183,7 @@ func Validate(a Agent) error { name string entries []string addon bool - }{{"MCP servers", a.MCPServers, false}, {"add-ons", a.Addons, true}, {"collections", a.Collections, false}, {"skills", a.Skills, false}, {"built-in tool groups off", a.BuiltinsOff, false}, {"built-in tool groups on", a.BuiltinsOn, false}} { + }{{"MCP servers", a.MCPServers, false}, {"add-ons", a.Addons, true}, {"collections", a.Collections, false}, {"skills", a.Skills, false}, {"built-in tool groups off", a.BuiltinsOff, false}, {"built-in tool groups on", a.BuiltinsOn, false}, {"built-in grants", a.Builtins, false}} { if len(l.entries) > MaxListEntries { return bad("%s has more than %d entries", l.name, MaxListEntries) } @@ -235,5 +240,6 @@ func (a Agent) clone() Agent { a.Skills = append([]string(nil), a.Skills...) a.BuiltinsOff = append([]string(nil), a.BuiltinsOff...) a.BuiltinsOn = append([]string(nil), a.BuiltinsOn...) + a.Builtins = append([]string(nil), a.Builtins...) return a.normalizeFolders() } diff --git a/core/services/studio/agentscope.go b/core/services/studio/agentscope.go index e1fa63996d..a9dc30a8b9 100644 --- a/core/services/studio/agentscope.go +++ b/core/services/studio/agentscope.go @@ -20,6 +20,8 @@ type scope struct { addons map[string]bool collections map[string]bool skills map[string]bool + builtins map[string]bool // the builtin grants the agent holds now + builtinNames []string skillNames []string addonNames []string collNames []string @@ -49,7 +51,9 @@ func (s *Service) scopeOf(ctx context.Context, rec session.ChatRecord) *scope { s.log.Warn("studio: agent unavailable", "chat", rec.ID, "agent", rec.AgentID, "error", err) return &scope{deleted: true} } + held := s.usableGrants(ctx, rec.Principal, ag.Builtins) return &scope{ + builtins: set(held), builtinNames: held, instructions: strings.TrimSpace(ag.Instructions), mcp: ag.MCPServers, addons: set(ag.Addons), collections: set(ag.Collections), skills: set(ag.Skills), addonNames: ag.Addons, collNames: ag.Collections, skillNames: ag.Skills, diff --git a/core/services/studio/agentsvc.go b/core/services/studio/agentsvc.go index 51d5e888fe..ec7e642a0a 100644 --- a/core/services/studio/agentsvc.go +++ b/core/services/studio/agentsvc.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "slices" "strings" "time" "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/builtins" "github.com/mudler/LocalAI/core/services/studio/files" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/store" @@ -48,7 +50,12 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent return agents.Agent{}, ErrAgentsOff } p := principal(user) - keepOff, keepOn := a.BuiltinsOff == nil, a.BuiltinsOn == nil + keepOff, keepOn, keepGrants := a.BuiltinsOff == nil, a.BuiltinsOn == nil, a.Builtins == nil + grants, err := cleanGrants(a.Builtins, false) + if err != nil { + return agents.Agent{}, fmt.Errorf("%w: %w", agents.ErrInvalid, err) + } + a.Builtins = grants if folders != nil { list, err := s.resolveAgentFolders(ctx, p, *folders) if err != nil { @@ -85,6 +92,16 @@ func (s *Service) SaveAgentWithFolders(ctx context.Context, user string, a agent if keepOn { a.BuiltinsOn = old.BuiltinsOn } + // Likewise for the grants: a client that does not know them cannot + // widen or narrow the agent. + if keepGrants { + a.Builtins = old.Builtins + } + } + // The grant saved_agents is the opt-in group of the same name: the chats of + // the agent start with it on. + if slices.Contains(a.Builtins, builtins.GrantSavedAgents) && s.builtins.IsGroup(builtins.GroupSavedAgents) && !slices.Contains(a.BuiltinsOn, builtins.GroupSavedAgents) { + a.BuiltinsOn = append(slices.Clone(a.BuiltinsOn), builtins.GroupSavedAgents) } if err := s.d.Agents.Put(ctx, a); err != nil { return agents.Agent{}, err @@ -143,6 +160,9 @@ func (s *Service) ImportAgent(ctx context.Context, user string, a agents.Agent, // a row that is not chosen, so the person can see which one to fix. func (s *Service) ImportAgentFolders(ctx context.Context, user string, a agents.Agent, folders []AgentFolderWrite) (agents.Agent, error) { a.ID, a.FolderID, a.FolderMode, a.Folders = "", 0, "", nil + // A file from a server that has more grants still imports; the grants + // this server does not declare are dropped. + a.Builtins, _ = cleanGrants(a.Builtins, true) if len(folders) > agents.MaxFolders { return agents.Agent{}, folderRefused("an agent may have at most %d folders", agents.MaxFolders) } diff --git a/core/services/studio/builtins/builtins.go b/core/services/studio/builtins/builtins.go index 36ce28b3fa..f38fa30d30 100644 --- a/core/services/studio/builtins/builtins.go +++ b/core/services/studio/builtins/builtins.go @@ -88,6 +88,10 @@ type Chat struct { GroupsOff []string // GroupsOn are the opt-in switch groups the chat switched on. GroupsOn []string + // Grants are the builtin grants of the chat's saved agent after the + // intersection with what the user may use. Empty for a chat without an + // agent: see HoldsGrant. + Grants []string // GoalActive is true while the chat works toward an active goal. GoalActive bool // Allow answers whether the sender of this turn may use a capability diff --git a/core/services/studio/builtins/grants.go b/core/services/studio/builtins/grants.go new file mode 100644 index 0000000000..8b43b8601b --- /dev/null +++ b/core/services/studio/builtins/grants.go @@ -0,0 +1,111 @@ +package builtins + +import ( + "slices" + + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// A grant is a right that the owner gives a saved agent on top of its lists: +// to ask for a change of the owner's own state, to set up a trigger, to reach +// a channel. Grants are the Builtins of an agent. None is on by default, and a +// grant that is sensitive is marked so that every screen that offers it can +// say so. The declarations live here, in code, so the core names no add-on and +// the editor and the chat read one list. +// +// The names are the capability names of the contract where one exists, and +// the opt-in group saved_agents. + +// The grant names. +const ( + GrantMemoryWrite = contract.CapMemoryWrite + GrantProfileWrite = contract.CapProfileWrite + GrantSkillsWrite = contract.CapSkillsWrite + GrantPagesWrite = contract.CapPagesWrite + GrantDashboardWrite = contract.CapDashboardWrite + GrantTriggersPropose = contract.CapTriggersPropose + GrantTriggersRun = contract.CapTriggersRun + GrantTalkbackChannels = contract.CapTalkbackChannels + GrantTalkbackNewChan = contract.CapTalkbackNewChan + GrantFilesUpload = contract.CapFilesUpload + GrantActFiles = "act.files" + GrantActAddons = "act.addons" + GrantActSend = "act.send" + GrantActSpend = "act.spend" + // GrantSavedAgents is the opt-in group GroupSavedAgents. + GrantSavedAgents = GroupSavedAgents +) + +// Grant describes one grant for a person: a title and what the agent can do +// with it. Both are English; the interface may translate them by Name. +type Grant struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + // Sensitive grants change what the agent may do or reach later (a rule, a + // skill, a page, an action): they are never on by default and the editor + // marks them. + Sensitive bool `json:"sensitive"` +} + +// grants is the closed list, in the order a screen shows it. +var grants = []Grant{ + {GrantMemoryWrite, "Remember things", "The agent can propose a note in your memory, or in its own. You confirm each note.", false}, + {GrantProfileWrite, "Change its own rules", "The agent can propose an edit to its profile, the standing rules it follows in every chat. You confirm each edit.", true}, + {GrantSkillsWrite, "Write skills", "The agent can propose a new or changed skill for itself or for you. You confirm each one, and a skill you locked stays as it is.", true}, + {GrantPagesWrite, "Create pages", "The agent can propose a page: a small app that opens in a sandboxed frame. You see its files and confirm.", true}, + {GrantDashboardWrite, "Change the dashboard", "The agent can propose a new layout of your Home dashboard. You confirm each change.", true}, + {GrantTriggersPropose, "Propose triggers", "The agent can propose a task that runs by itself, from a messaging app too. You confirm it.", false}, + {GrantTriggersRun, "Run triggers", "The triggers of this agent may start from a messaging app.", false}, + {GrantTalkbackChannels, "Reply on known channels", "The agent can send a message to a channel it already talks in.", false}, + {GrantTalkbackNewChan, "Start new channels", "The agent can start a conversation on a channel it has not used before.", true}, + {GrantFilesUpload, "Send files", "The agent can send a file from your folders to a channel.", false}, + {GrantActFiles, "Write files on its own", "A run that nobody watches may write files inside the agent's folders.", true}, + {GrantActAddons, "Use add-on actions on its own", "A run that nobody watches may call the actions of the add-ons it has.", true}, + {GrantActSend, "Send messages on its own", "A run that nobody watches may send messages beyond its delivery target.", true}, + {GrantActSpend, "Use paid or external tools on its own", "A run that nobody watches may use paid or external tools and the network.", true}, + {GrantSavedAgents, "Call your saved agents", "The agent can list your saved agents and start one as a sub-agent.", false}, +} + +// Grants returns the declared grants in display order. The caller may change +// the copy. +func Grants() []Grant { return slices.Clone(grants) } + +// LookupGrant returns the declaration of a grant name. +func LookupGrant(name string) (Grant, bool) { + for _, g := range grants { + if g.Name == name { + return g, true + } + } + return Grant{}, false +} + +// ValidGrant reports whether name is a declared grant. +func ValidGrant(name string) bool { _, ok := LookupGrant(name); return ok } + +// SensitiveGrant reports whether name is a declared sensitive grant. +func SensitiveGrant(name string) bool { g, ok := LookupGrant(name); return ok && g.Sensitive } + +// holdingOrigin says whether a run that started with this origin can hold the +// rights of its agent: a chat a person watches and a connector chat can. A +// run that started by itself (trigger, job) starts read only; what it may act +// on is the business of the action policy, not of a tool that exists. +func holdingOrigin(origin string) bool { return origin == "" || origin == session.OriginConnector } + +// HoldsGrant is the tool-exists rule for a grant: the chat holds it when its run may +// have it at all (not a sub-agent, not a run that started by itself) and, for a +// chat of a saved agent, the owner granted it and the user may still use it +// (Grants is that intersection). A chat without an agent is the user's own and +// holds what the user may use; whether the server offers the feature is the +// tool's own check. +func (c Chat) HoldsGrant(grant string) bool { + if c.SubAgent || !holdingOrigin(c.Origin) { + return false + } + if !c.Agent { + return true + } + return slices.Contains(c.Grants, grant) +} diff --git a/core/services/studio/changekinds.go b/core/services/studio/changekinds.go new file mode 100644 index 0000000000..14b5f111ac --- /dev/null +++ b/core/services/studio/changekinds.go @@ -0,0 +1,230 @@ +package studio + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" + + "github.com/mudler/LocalAI/core/services/studio/pages" + "github.com/mudler/LocalAI/core/services/studio/proposals" + "github.com/mudler/LocalAI/core/services/studio/skills" + "github.com/mudler/LocalAI/core/services/studio/wellknown" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// The kinds that need more than a file name: a skill, a page and the dashboard. +// Each one is a proposal like a memory note. The model never writes the file; +// the owner confirms, and the host writes as the owner with a change set. + +// maxPageFiles bounds the files of one page proposal. +const maxPageFiles = 32 + +const ( + maxPageMeta = 16 << 10 + maxPageTitle = 120 + maxPageDesc = 500 + pageFolderTop = pages.Dir +) + +var pageIconRE = regexp.MustCompile(`^[a-z0-9-]{1,40}$`) + +// skillRel is the home-relative SKILL.md of a skill in the personal layer. +func skillRel(name string) string { return skills.SkillsDir + "/" + name + "/" + skills.SkillFile } + +var skillKind = proposals.Kind{ + Name: contract.ChangeSkill, + Grant: contract.CapSkillsWrite, + Sensitive: true, + Path: func(a proposals.Aim) (string, error) { + if !skills.ValidName(a.Target) { + return "", fmt.Errorf("%w: a skill target is its name: lowercase letters, digits and dashes, at most 64", proposals.ErrBadTarget) + } + switch a.Scope { + case contract.ScopeUser: + return skillRel(a.Target), nil + case contract.ScopeAgent: + if !proposals.IDSafe(a.Agent) { + return "", fmt.Errorf("%w: an agent skill needs a chat that runs a saved agent", proposals.ErrBadTarget) + } + return "LocalAI/agents/" + a.Agent + "/skills/" + a.Target + "/" + skills.SkillFile, nil + } + return "", fmt.Errorf("%w: a skill is for the user (personal) or for this agent, not for a channel", proposals.ErrBadTarget) + }, + Check: func(in proposals.CheckInput) error { + name, _, err := skills.Parse([]byte(in.Content)) + switch { + case err != nil: + return fmt.Errorf("the text is not a valid SKILL.md (%v): it starts with --- lines that hold name and description", err) + case name != in.Aim.Target: + return fmt.Errorf("the name in the frontmatter is %q but the target is %q", name, in.Aim.Target) + case in.Exists && skills.Locked([]byte(in.Current)): + return fmt.Errorf("the skill %q is locked by its owner: ask them to unlock it", in.Aim.Target) + } + // A skill of the agent layer shadows the personal one of the same name, + // so a locked personal skill is locked against it too. + if in.Aim.Scope == contract.ScopeAgent && in.Read != nil { + cur, ok, err := in.Read(skillRel(in.Aim.Target)) + if err != nil { + return fmt.Errorf("cannot read the personal skill %q: %v", in.Aim.Target, err) + } + if ok && skills.Locked([]byte(cur)) { + return fmt.Errorf("the personal skill %q is locked by its owner: an agent skill of that name would replace it", in.Aim.Target) + } + } + return nil + }, +} + +var dashboardKind = proposals.Kind{ + Name: contract.ChangeDashboard, + Grant: contract.CapDashboardWrite, + Sensitive: true, + Path: func(a proposals.Aim) (string, error) { + if a.Scope != contract.ScopeUser { + return "", fmt.Errorf("%w: the dashboard is the user's: use scope user", proposals.ErrBadTarget) + } + return strings.TrimPrefix(wellknown.File(wellknown.Home), "/"), nil + }, + Check: func(in proposals.CheckInput) error { + if _, bad := wellknown.Validate(wellknown.Home, []byte(in.Content)); len(bad) > 0 { + return fmt.Errorf("home.json does not match its schema: %s", strings.Join(bad, "; ")) + } + return nil + }, +} + +var pageKind = proposals.Kind{ + Name: contract.ChangePage, + Grant: contract.CapPagesWrite, + Sensitive: true, + MaxBytes: contract.MaxPageChangeBytes, + Path: func(a proposals.Aim) (string, error) { + switch { + case a.Scope != contract.ScopeUser: + return "", fmt.Errorf("%w: a page is a folder of the user's home: use scope user", proposals.ErrBadTarget) + case !pages.ValidName(a.Target): + return "", fmt.Errorf("%w: a page target is its name: lowercase letters, digits and dashes, at most 64", proposals.ErrBadTarget) + } + return pageFolderTop + "/" + a.Target, nil + }, + Check: func(in proposals.CheckInput) error { + _, err := pageFiles(in.Content) + return err + }, + Files: pageFiles, +} + +// pageFiles reads the content of a page proposal, {"files": {path: text}}, and +// checks every file against the rules of the Pages feature: the paths stay +// inside the page folder, index.html is there, each file is text and fits, and +// page.json is the small JSON object the Pages screen reads. +func pageFiles(content string) ([]proposals.FileWrite, error) { + var doc struct { + Files map[string]string `json:"files"` + } + dec := json.NewDecoder(strings.NewReader(content)) + dec.DisallowUnknownFields() + if err := dec.Decode(&doc); err != nil || len(doc.Files) == 0 { + return nil, errors.New(`the content of a page is JSON like {"files": {"index.html": "...", "page.json": "{...}"}}`) + } + if dec.More() { + return nil, errors.New("the content of a page is one JSON object") + } + if len(doc.Files) > maxPageFiles { + return nil, fmt.Errorf("a page has at most %d files", maxPageFiles) + } + out := make([]proposals.FileWrite, 0, len(doc.Files)) + seen := map[string]string{} + for rel, text := range doc.Files { + if _, err := pages.CleanPath(rel); err != nil { + return nil, fmt.Errorf("%q is not a file inside the page folder", clipText(rel, 60)) + } + key := strings.ToLower(rel) + if other, dup := seen[key]; dup { + return nil, fmt.Errorf("%q and %q are the same file", other, rel) + } + seen[key] = rel + switch { + case len(text) > pages.MaxFileSize: + return nil, fmt.Errorf("%s is larger than %d bytes", rel, pages.MaxFileSize) + case !utf8.ValidString(text) || strings.ContainsRune(text, 0): + return nil, fmt.Errorf("%s must be text", rel) + } + out = append(out, proposals.FileWrite{Path: rel, Content: text}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + // A file cannot also be a folder of another file. + for _, f := range out { + for _, g := range out { + if strings.HasPrefix(g.Path, f.Path+"/") { + return nil, fmt.Errorf("%s is a file and also a folder of %s", f.Path, g.Path) + } + } + } + index := false + for _, f := range out { + switch f.Path { + case pages.IndexFile: + index = strings.TrimSpace(f.Content) != "" + case pages.MetaFile: + if err := checkPageMeta(f.Content); err != nil { + return nil, err + } + } + } + if !index { + return nil, fmt.Errorf("a page needs a %s that is not empty", pages.IndexFile) + } + return out, nil +} + +func checkPageMeta(text string) error { + if len(text) > maxPageMeta { + return fmt.Errorf("%s is larger than %d bytes", pages.MetaFile, maxPageMeta) + } + var m struct { + Title string `json:"title"` + Icon string `json:"icon"` + Description string `json:"description"` + Widget *struct { + Size string `json:"size"` + Title string `json:"title"` + } `json:"widget"` + } + if err := json.NewDecoder(bytes.NewReader([]byte(text))).Decode(&m); err != nil { + return fmt.Errorf("%s is not a JSON object: %v", pages.MetaFile, err) + } + switch { + case utf8.RuneCountInString(m.Title) > maxPageTitle: + return fmt.Errorf("the title in %s is longer than %d characters", pages.MetaFile, maxPageTitle) + case utf8.RuneCountInString(m.Description) > maxPageDesc: + return fmt.Errorf("the description in %s is longer than %d characters", pages.MetaFile, maxPageDesc) + case m.Icon != "" && !pageIconRE.MatchString(m.Icon): + return fmt.Errorf("the icon in %s is a name of lowercase letters, digits and dashes", pages.MetaFile) + case m.Widget != nil && !pages.ValidWidgetSize(m.Widget.Size): + return fmt.Errorf("the widget size in %s is small, medium or large", pages.MetaFile) + } + return nil +} + +func clipText(s string, n int) string { + if r := []rune(s); len(r) > n { + return string(r[:n]) + "..." + } + return s +} + +// registerChangeKinds adds the kinds that go beyond a file name to the host. +func (c *Changes) registerChangeKinds() error { + for _, k := range []proposals.Kind{skillKind, pageKind, dashboardKind} { + if err := c.kinds.Register(k); err != nil { + return err + } + } + return nil +} diff --git a/core/services/studio/changes.go b/core/services/studio/changes.go index 3a26c90bbd..b0e9bd1356 100644 --- a/core/services/studio/changes.go +++ b/core/services/studio/changes.go @@ -13,6 +13,8 @@ import ( "github.com/google/uuid" + "github.com/mudler/LocalAI/core/services/studio/builtins" + "github.com/mudler/LocalAI/core/services/studio/files" "github.com/mudler/LocalAI/core/services/studio/proposals" "github.com/mudler/LocalAI/core/services/studio/workspace" "github.com/mudler/LocalAI/pkg/addons/contract" @@ -67,9 +69,15 @@ func newChanges(s *Service) (*Changes, error) { } g := s.d.ProposalGate if g == nil { - g = proposals.DefaultGate + // The grants of the agent are checked in Propose and Revert, for every + // gate. Without a gate of its own the host adds nothing to them. + g = proposals.GateFunc(func(context.Context, proposals.Request) error { return nil }) } - return &Changes{s: s, store: st, gate: g, kinds: proposals.NewKinds()}, nil + c := &Changes{s: s, store: st, gate: g, kinds: proposals.NewKinds()} + if err := c.registerChangeKinds(); err != nil { + return nil, err + } + return c, nil } // Changes returns the host of the change proposals. @@ -144,11 +152,16 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change p := contract.ChangeProposal{V: contract.ChangeProposalVersion, ID: uuid.NewString(), Owner: principal(in.User), Agent: in.Agent, Kind: kind.Name, Scope: contract.ChangeScope(in.Scope), Summary: cleanSummary(in.Summary), Content: in.Content, Source: in.Source, Status: contract.ProposalPending} + if err := c.s.checkChangeGrant(ctx, p.Owner, in.Agent, in.Source, kind.Grant); err != nil { + c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error()) + return contract.ChangeProposal{}, err + } if err := c.gate.Allow(ctx, proposals.Request{Action: proposals.GatePropose, User: p.Owner, Agent: in.Agent, Kind: p.Kind, Scope: p.Scope, Source: in.Source}); err != nil { c.audit(ctx, proposals.ActionRefuse, p, 0, err.Error()) return contract.ChangeProposal{}, err } - rel, err := kind.Path(proposals.Aim{Scope: p.Scope, Agent: in.Agent, Target: in.Target, Source: in.Source}) + aim := proposals.Aim{Scope: p.Scope, Agent: in.Agent, Target: in.Target, Source: in.Source} + rel, err := kind.Path(aim) if err != nil { return contract.ChangeProposal{}, fmt.Errorf("%w: %s", ErrBadChange, strings.TrimPrefix(err.Error(), proposals.ErrBadTarget.Error()+": ")) } @@ -161,14 +174,15 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change case p.Summary == "": return contract.ChangeProposal{}, fmt.Errorf("%w: give a short summary of the change", ErrBadChange) } - cur, exists, err := c.s.d.Files.ReadHome(ctx, p.Owner, rel, maxTargetRead) + snap, err := c.snapshot(ctx, p.Owner, kind, rel, in.Content) if err != nil { - return contract.ChangeProposal{}, fmt.Errorf("%w: cannot read %s: %v", ErrBadChange, rel, err) + return contract.ChangeProposal{}, err } - if exists { - p.BaseHash = hashOf(cur) + if err := c.check(ctx, p.Owner, kind, aim, rel, in.Content, snap); err != nil { + return contract.ChangeProposal{}, err } - if exists && cur == in.Content { + p.BaseHash = snap.hash + if snap.same { return contract.ChangeProposal{}, fmt.Errorf("%w: %s has this content already", ErrBadChange, rel) } c.sweep(ctx) @@ -185,6 +199,102 @@ func (c *Changes) Propose(ctx context.Context, in ProposeInput) (contract.Change return p, nil } +// snap is the state of what a change would write, for the base hash. +type snap struct { + hash string // "" when no target file exists + exists bool + same bool // every file already holds the proposed text + current string +} + +// filesOf returns the files a content writes: the one target, or the files of +// a kind that writes a folder. +func filesOf(kind proposals.Kind, rel, content string) ([]proposals.FileWrite, error) { + if kind.Files == nil { + return []proposals.FileWrite{{Path: rel, Content: content}}, nil + } + fs, err := kind.Files(content) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrBadChange, err) + } + for i := range fs { + fs[i].Path = rel + "/" + fs[i].Path + } + return fs, nil +} + +// snapshot reads what the change would replace. For one file the hash is the +// hash of the file; for a folder it covers the path and hash of each file the +// content names, so a change to any of them makes the proposal stale. +func (c *Changes) snapshot(ctx context.Context, owner string, kind proposals.Kind, rel, content string) (snap, error) { + fs, err := filesOf(kind, rel, content) + if err != nil { + return snap{}, err + } + var out snap + out.same = true + var sum strings.Builder + for _, f := range fs { + cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, f.Path, maxTargetRead) + if err != nil { + return snap{}, fmt.Errorf("%w: cannot read %s: %v", ErrBadChange, f.Path, err) + } + out.exists = out.exists || exists + out.same = out.same && exists && cur == f.Content + if kind.Files == nil { + out.current = cur + if exists { + out.hash = hashOf(cur) + } + return out, nil + } + h := "-" + if exists { + h = hashOf(cur) + } + sum.WriteString(f.Path + "\x00" + h + "\n") + } + if out.exists { + out.hash = hashOf(sum.String()) + } + return out, nil +} + +// check runs the kind's own rules on a content. +func (c *Changes) check(ctx context.Context, owner string, kind proposals.Kind, aim proposals.Aim, rel, content string, st snap) error { + if kind.Check == nil { + return nil + } + err := kind.Check(proposals.CheckInput{Aim: aim, Path: rel, Content: content, Current: st.current, Exists: st.exists, Read: func(p string) (string, bool, error) { + return c.s.d.Files.ReadHome(ctx, owner, p, maxTargetRead) + }}) + if err != nil { + return fmt.Errorf("%w: %s", ErrBadChange, err) + } + return nil +} + +// aimOf rebuilds where a stored proposal writes, for the checks at confirm. +func aimOf(p contract.ChangeProposal) proposals.Aim { + return proposals.Aim{Scope: p.Scope, Agent: p.Agent, Target: targetName(p), Source: p.Source} +} + +// targetName is the name the model gave, recovered from the stored path: the +// last folder for a skill or a page, which Check compares with its content. +func targetName(p contract.ChangeProposal) string { + t := strings.TrimSuffix(p.Target, "/") + switch p.Kind { + case contract.ChangeSkill: + t = strings.TrimSuffix(t, "/SKILL.md") + case contract.ChangeMemory, contract.ChangeProfile, contract.ChangeDashboard: + return t + } + if i := strings.LastIndexByte(t, '/'); i >= 0 { + t = t[i+1:] + } + return t +} + // List returns the proposals of the user, newest first, with the expired ones // settled first. func (c *Changes) List(ctx context.Context, user string, statuses ...contract.ProposalStatus) ([]contract.ChangeProposal, error) { @@ -243,6 +353,7 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) if !ok { return contract.ChangeProposal{}, fmt.Errorf("%w: this server no longer offers %s changes", ErrBadChange, p.Kind) } + orig := p.Content if edited != nil { switch { case len(*edited) > kind.Limit(): @@ -252,20 +363,30 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) } p.Content = *edited } - cur, exists, err := c.s.d.Files.ReadHome(ctx, owner, p.Target, maxTargetRead) + // The base hash covers the files the model proposed; an edit by the owner + // may change the files to write, not what the proposal was based on. + was, err := c.snapshot(ctx, owner, kind, p.Target, orig) if err != nil { return contract.ChangeProposal{}, err } - have := "" - if exists { - have = hashOf(cur) - } - if have != p.BaseHash { + if was.hash != p.BaseHash { if won, _ := c.store.Settle(ctx, owner, id, contract.ProposalPending, contract.ProposalStale, c.s.now().UTC(), 0); won { c.audit(ctx, proposals.ActionStale, p, 0, "") } return contract.ChangeProposal{}, ErrTargetChanged } + // Rules such as a lock set after the proposal apply to the text that is written. + now, err := c.snapshot(ctx, owner, kind, p.Target, p.Content) + if err != nil { + return contract.ChangeProposal{}, err + } + if err := c.check(ctx, owner, kind, aimOf(p), p.Target, p.Content, now); err != nil { + return contract.ChangeProposal{}, err + } + writes, err := filesOf(kind, p.Target, p.Content) + if err != nil { + return contract.ChangeProposal{}, err + } // Claim the answer first, so two confirmations write once. won, err := c.store.Settle(ctx, owner, id, contract.ProposalPending, contract.ProposalApplied, c.s.now().UTC(), 0) if err != nil { @@ -275,7 +396,7 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) return contract.ChangeProposal{}, ErrChangeAnswered } title := string(p.Kind) + ": " + p.Summary - rev, err := c.s.d.Files.WriteHome(ctx, owner, p.Target, p.Content, title, int64(kind.Limit())) + rev, err := c.write(ctx, owner, writes, title, int64(kind.Limit())) if err != nil { if _, rerr := c.store.Settle(ctx, owner, id, contract.ProposalApplied, contract.ProposalPending, time.Time{}, 0); rerr != nil { c.s.log.Warn("studio: could not reopen a change that failed to write", "proposal", id, "error", rerr) @@ -290,6 +411,18 @@ func (c *Changes) confirm(ctx context.Context, user, id string, edited *string) return c.store.Get(ctx, owner, id) } +// write puts the files of a change into the home as one change set. +func (c *Changes) write(ctx context.Context, owner string, fs []proposals.FileWrite, title string, max int64) (int64, error) { + if len(fs) == 1 { + return c.s.d.Files.WriteHome(ctx, owner, fs[0].Path, fs[0].Content, title, max) + } + hf := make([]files.HomeFile, len(fs)) + for i, f := range fs { + hf[i] = files.HomeFile{Path: f.Path, Content: f.Content} + } + return c.s.d.Files.WriteHomeFiles(ctx, owner, hf, title, max) +} + // Dismiss answers a pending proposal with no. func (c *Changes) Dismiss(ctx context.Context, user, id string) (contract.ChangeProposal, error) { owner := principal(user) @@ -365,9 +498,34 @@ func (c *Changes) RevertLast(ctx context.Context, user, agent string) (contract. // Allow asks the gate about a revert of a chat. func (c *Changes) allowRevert(ctx context.Context, user, agent string, src contract.ChangeSource) error { - err := c.gate.Allow(ctx, proposals.Request{Action: proposals.GateRevert, User: principal(user), Agent: agent, Source: src}) + err := c.s.checkAnyChangeGrant(ctx, user, agent, src) + if err == nil { + err = c.gate.Allow(ctx, proposals.Request{Action: proposals.GateRevert, User: principal(user), Agent: agent, Source: src}) + } if err != nil { c.audit(ctx, proposals.ActionRefuse, contract.ChangeProposal{Owner: principal(user), Agent: agent, Source: src}, 0, err.Error()) } return err } + +// grantsAKind reports whether the grant is the one of a registered kind. +func (c *Changes) grantsAKind(grant string) bool { + for _, n := range c.kinds.Names() { + if k, ok := c.kinds.Lookup(string(n)); ok && k.Grant == grant { + return true + } + } + return false +} + +// kindsHeld lists the kinds a chat may propose: for a chat of a saved agent +// the ones whose grant it holds. +func (c *Changes) kindsHeld(ch builtins.Chat) []contract.ChangeKind { + var out []contract.ChangeKind + for _, n := range c.kinds.Names() { + if k, ok := c.kinds.Lookup(string(n)); ok && (k.Grant == "" || ch.HoldsGrant(k.Grant)) { + out = append(out, n) + } + } + return out +} diff --git a/core/services/studio/changes_test.go b/core/services/studio/changes_test.go index f1e1060e13..b18ebf008c 100644 --- a/core/services/studio/changes_test.go +++ b/core/services/studio/changes_test.go @@ -54,6 +54,16 @@ func changeShared(gate proposals.Gate) *shared { return sh } +// grantMemory makes the saved agent of the rig one that may propose memory. +func (c *connRig) grantMemory() { c.grantBuiltins("memory.write") } + +// grantBuiltins gives the saved agent of the rig the builtin grants. +func (c *connRig) grantBuiltins(grants ...string) { + ag, err := c.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Support", Model: "m", Addons: []string{"office"}, Builtins: grants}) + Expect(err).NotTo(HaveOccurred()) + c.agent = ag +} + func channelFile(src contract.ChangeSource) string { return "Memory/channels/" + proposals.ChannelDir(src) + "/index.md" } @@ -168,13 +178,15 @@ var _ = Describe("change proposals", func() { It("holds a profile to 4 KiB", func() { r := changeShared(grantAll).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{}) - in := studio.ProposeInput{User: alice, Agent: "ag1", Kind: "profile", Scope: "agent", Content: strings.Repeat("a", contract.MaxProfileBytes+1), Summary: "s"} - _, err := r.svc.Changes().Propose(context.Background(), in) + ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Helper", Model: "m", Builtins: []string{"profile.write"}}) + Expect(err).NotTo(HaveOccurred()) + in := studio.ProposeInput{User: alice, Agent: ag.ID, Kind: "profile", Scope: "agent", Content: strings.Repeat("a", contract.MaxProfileBytes+1), Summary: "s"} + _, err = r.svc.Changes().Propose(context.Background(), in) Expect(err).To(MatchError(studio.ErrBadChange)) in.Content = strings.Repeat("a", contract.MaxProfileBytes) p, err := r.svc.Changes().Propose(context.Background(), in) Expect(err).NotTo(HaveOccurred()) - Expect(p.Target).To(Equal("LocalAI/agents/ag1/profile.md")) + Expect(p.Target).To(Equal("LocalAI/agents/" + ag.ID + "/profile.md")) }) It("limits 10 pending per user and 5 per hour per agent", func() { @@ -307,26 +319,26 @@ var _ = Describe("change proposals", func() { }) Describe("from a chat that runs a saved agent", func() { - It("is refused by default and allowed by a gate that grants it", func() { - for name, gate := range map[string]proposals.Gate{"default": nil, "granted": grantAll} { - r := changeShared(gate).rig(changeScript("agent", "- rule\n"), studio.TriggerOptions{}) - ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Helper", Model: "m"}) + It("is refused without the grant and allowed with it", func() { + for name, grant := range map[string][]string{"default": nil, "granted": {"memory.write"}} { + r := changeShared(nil).rig(changeScript("agent", "- rule\n"), studio.TriggerOptions{}) + ag, err := r.svc.SaveAgent(context.Background(), alice, agents.Agent{Name: "Helper", Model: "m", Builtins: grant}) Expect(err).NotTo(HaveOccurred()) rec, err := r.svc.Create(context.Background(), alice, studio.CreateInput{AgentID: ag.ID}) Expect(err).NotTo(HaveOccurred()) sent(r.svc, alice, rec.ID, "remember") idle(r.svc, alice, rec.ID) res := r.h.ResultsFor("propose_change") - Expect(res).To(HaveLen(1), name) list, _ := r.svc.Changes().List(context.Background(), alice) if name == "default" { - Expect(res[0].Err).To(HaveOccurred()) + // Without a grant the tool does not exist in the chat. + Expect(r.h.ToolNames(rec.ID)).NotTo(ContainElement("propose_change")) + Expect(res).To(HaveLen(1)) + Expect(res[0].Err).To(MatchError(ContainSubstring("no tool named"))) Expect(list).To(BeEmpty()) - events, _ := r.svc.Changes().Events(context.Background(), alice, 5) - Expect(events).To(HaveLen(1)) - Expect(events[0].Action).To(Equal("refuse")) continue } + Expect(res).To(HaveLen(1), name) Expect(res[0].Err).To(BeNil()) Expect(list).To(HaveLen(1)) Expect(list[0].Agent).To(Equal(ag.ID)) @@ -348,7 +360,7 @@ var _ = Describe("change proposals", func() { _, err = r.svc.Changes().Confirm(ctx, alice, p.ID) Expect(err).NotTo(HaveOccurred()) other = p.ID - ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Helper", Model: "m"}) + ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Helper", Model: "m", Builtins: []string{"memory.write"}}) rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID}) sent(r.svc, alice, rec.ID, "forget") idle(r.svc, alice, rec.ID) @@ -366,6 +378,7 @@ var _ = Describe("change proposals", func() { It("works the same over Telegram", func() { sh, bot := changeShared(grantAll), newScriptedBot() r := newConnRig(sh, connStore(), bot, changeScript("channel", "- home: Rome\n"), nil) + r.grantMemory() b := r.bind(studio.ConnectorInput{Allow: []string{"11"}}) n := 0 send := func(text string) { @@ -385,6 +398,7 @@ var _ = Describe("change proposals", func() { rt := slack.New(&slack.API{BaseURL: f.URL, FilesHost: f.FilesHost}) DeferCleanup(rt.Close) r := newConnRigWith(sh, connStore(), studiobot.NewTransport(studiobot.Slack(rt)), changeScript("channel", "- home: Rome\n"), nil) + r.grantMemory() b := r.bind(studio.ConnectorInput{Kind: "slack", Token: slackBot + " " + slackApp, Allow: []string{"C1"}}) waitFor("the Socket Mode link", func() bool { return f.Connected(slackApp) }) posted := func() int { diff --git a/core/services/studio/changetools.go b/core/services/studio/changetools.go index 301d4ff9da..f86151bf13 100644 --- a/core/services/studio/changetools.go +++ b/core/services/studio/changetools.go @@ -22,23 +22,32 @@ const ( revertChangeName = "revert_change" ) -const proposeChangeDescription = "Ask to change something that lasts: a note in the user's memory, or the profile of this agent. " + - "Use it when the user asks you to remember, change or forget something, or gives a rule you must keep. " + +const proposeChangeDescription = "Ask to change something that lasts: a note in the user's memory, the profile of this agent, a skill, a page or the Home dashboard. " + + "Use it when the user asks you to remember, change or forget something, gives a rule you must keep, or asks for a skill, a page or a dashboard change. " + "It changes nothing by itself: the owner must confirm in Studio, and the proposal expires after a day. " + "Until then never say that it is saved. " + "content is the full new text of the file, not a patch: read the current file first and keep what stays. " + - "kind is memory (a Markdown file under Memory/) or profile (the agent's standing rules, at most 4 KiB, scope agent). " + + "kind is memory (a Markdown file under Memory/), profile (the agent's standing rules, at most 4 KiB, scope agent), " + + "skill (a full SKILL.md with name and description, target is the skill name, scope user or agent; a skill its owner locked cannot be changed), " + + "page (a small app: target is the page name, scope user, files is an object of path to text and must hold index.html) " + + "or dashboard (the full home.json, scope user; it must follow the schema of the localai-workspace skill). " + "scope is user (every chat of the person), agent (this agent only) or channel (this conversation only). " + - "target is the file name for memory, such as index.md or food.md; a profile has no target. " + + "target is the file name for memory, such as index.md or food.md, the name for a skill or a page; a profile and the dashboard have no target. " + "summary is one line that tells the owner what changes." -const proposeChangeSchema = `{"type":"object","properties":{` + - `"kind":{"type":"string","enum":["memory","profile"]},` + - `"scope":{"type":"string","enum":["user","agent","channel"]},` + - `"target":{"type":"string","maxLength":64,"description":"File name for a memory change, such as index.md."},` + - `"content":{"type":"string","description":"The full new text of the file."},` + - `"summary":{"type":"string","minLength":1,"maxLength":160,"description":"One line for the owner."}},` + - `"required":["kind","scope","content","summary"]}` +// proposeChangeSchema is the schema of the tool for a chat that may propose +// the given kinds. +func proposeChangeSchema(kinds []contract.ChangeKind) string { + enum, _ := json.Marshal(kinds) + return `{"type":"object","properties":{` + + `"kind":{"type":"string","enum":` + string(enum) + `},` + + `"scope":{"type":"string","enum":["user","agent","channel"]},` + + `"target":{"type":"string","maxLength":64,"description":"File name for a memory change, such as index.md; the name of a skill or a page."},` + + `"content":{"type":"string","description":"The full new text of the file. For a page use files instead."},` + + `"files":{"type":"object","additionalProperties":{"type":"string"},"description":"For a page: file paths inside the page folder and their text, such as index.html."},` + + `"summary":{"type":"string","minLength":1,"maxLength":160,"description":"One line for the owner."}},` + + `"required":["kind","scope","summary"]}` +} const revertChangeDescription = "Undo a change that the owner confirmed earlier, for example when the user says to forget that. " + "Give the id the earlier propose_change returned; without an id the last applied change of this agent is undone. " + @@ -49,7 +58,7 @@ const revertChangeSchema = `{"type":"object","properties":{"id":{"type":"string" // registerChangeTools adds propose_change and revert_change as one switch // group. A person's chat and a connector chat can have them; an unattended // run and a sub-agent never do. Whether a chat that runs a saved agent may use -// them is the gate's decision at the call. +// them is the grant of the agent; in a connector chat the sender must also hold the capability. func (s *Service) registerChangeTools() error { type def struct { id, label, desc, schema string @@ -57,7 +66,7 @@ func (s *Service) registerChangeTools() error { caps []string } defs := []def{ - {proposeChangeName, "Propose a change", proposeChangeDescription, proposeChangeSchema, s.changes.proposeTool, changeKindCaps}, + {proposeChangeName, "Propose a change", proposeChangeDescription, "", s.changes.proposeTool, changeKindCaps}, {revertChangeName, "Revert a change", revertChangeDescription, revertChangeSchema, s.changes.revertTool, changeKindCaps}, } for _, d := range defs { @@ -72,7 +81,17 @@ func (s *Service) registerChangeTools() error { case c.Origin != "" && c.Origin != session.OriginConnector: return session.Tool{}, false, "a run that started by itself cannot propose changes" } - return session.Tool{Description: d.desc, Schema: json.RawMessage(d.schema), + // The tool exists when the chat holds a grant of some kind; it + // offers the kinds it holds and no others. + kinds := s.changes.kindsHeld(c) + if len(kinds) == 0 { + return session.Tool{}, false, "this agent was not given the right to change anything" + } + schema := d.schema + if schema == "" { + schema = proposeChangeSchema(kinds) + } + return session.Tool{Description: d.desc, Schema: json.RawMessage(schema), Run: func(ctx context.Context, raw json.RawMessage) (string, error) { return d.run(ctx, c, raw) }}, true, "" }, }) @@ -84,9 +103,9 @@ func (s *Service) registerChangeTools() error { } // changeKindCaps are the capabilities that the kinds of change need in a -// connector chat: a memory note and the agent's profile. The tool is offered -// when the sender holds one of them; the call checks the one its kind needs. -var changeKindCaps = []string{contract.CapMemoryWrite, contract.CapProfileWrite} +// connector chat. The tool is offered when the sender holds one of them; the +// call checks the one its kind needs. +var changeKindCaps = []string{contract.CapMemoryWrite, contract.CapProfileWrite, contract.CapSkillsWrite, contract.CapPagesWrite, contract.CapDashboardWrite} // changeCapability is the capability that a change of a kind needs. A kind // that is not listed needs one that nobody holds. @@ -96,6 +115,10 @@ func changeCapability(kind string) string { return contract.CapMemoryWrite case "profile": return contract.CapProfileWrite + case "page": + return contract.CapPagesWrite + case "dashboard": + return contract.CapDashboardWrite } return contract.CapSkillsWrite } @@ -116,6 +139,7 @@ func changeErr(err error) error { func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.RawMessage) (string, error) { var args struct { Kind, Scope, Target, Content, Summary string + Files map[string]string } if err := json.Unmarshal(raw, &args); err != nil { return "", fmt.Errorf("bad arguments: %w", err) @@ -123,6 +147,17 @@ func (h *Changes) proposeTool(ctx context.Context, c builtins.Chat, raw json.Raw if err := c.Require(changeCapability(args.Kind)); err != nil { return "", err } + if args.Files != nil { + if args.Kind != string(contract.ChangePage) || args.Content != "" { + return "", errors.New("files is for a page only, and a page has no content") + } + // A page is a set of files; the proposal keeps them as one JSON text. + b, err := json.Marshal(map[string]any{"files": args.Files}) + if err != nil { + return "", fmt.Errorf("bad arguments: %w", err) + } + args.Content = string(b) + } p, err := h.Propose(ctx, ProposeInput{User: c.Actor.Principal, Agent: c.AgentID, Source: sourceOf(c), Kind: args.Kind, Scope: args.Scope, Target: args.Target, Content: args.Content, Summary: args.Summary}) if err != nil { diff --git a/core/services/studio/connectors_enforce_test.go b/core/services/studio/connectors_enforce_test.go index 24cf1c5a59..12cb5ed426 100644 --- a/core/services/studio/connectors_enforce_test.go +++ b/core/services/studio/connectors_enforce_test.go @@ -60,6 +60,7 @@ var _ = Describe("permissions in the turns of a connector chat", func() { sh := changeShared(grantAll) r = newConnRigWith(sh, connStore(), studiobot.NewTransport(addon), script, nil) r.bot, r.untrusted = bot, true + r.grantBuiltins(contract.CapMemoryWrite, contract.CapProfileWrite) b = r.bind(studio.ConnectorInput{Allow: []string{"11", "12"}, Caps: caps, Grants: grants}) got, err := r.store.GetBinding(ctx, b.ID) Expect(err).NotTo(HaveOccurred()) diff --git a/core/services/studio/contextturn_internal_test.go b/core/services/studio/contextturn_internal_test.go index 4041593d56..f368db2f17 100644 --- a/core/services/studio/contextturn_internal_test.go +++ b/core/services/studio/contextturn_internal_test.go @@ -413,7 +413,7 @@ var _ = Describe("context files in the prompt of a chat", func() { m, err := contract.ParseManifest(raw) Expect(err).NotTo(HaveOccurred()) br.set(AddonContextSet{AddonID: m.ID, Running: true, Files: m.Capabilities.ContextFiles}) - ag, err := svc.SaveAgentWithFolder(ctx, "alice", agents.Agent{Name: "Bot", Model: "m", Addons: []string{m.ID}}, nil) + ag, err := svc.SaveAgentWithFolder(ctx, "alice", agents.Agent{Name: "Bot", Model: "m", Addons: []string{m.ID}, Builtins: []string{"memory.write"}}, nil) Expect(err).NotTo(HaveOccurred()) rec, err := svc.CreateAs(ctx, "alice", CreateInput{Model: "m", AgentID: ag.ID}, Origin{Kind: session.OriginConnector, ID: "b-tg"}) Expect(err).NotTo(HaveOccurred()) diff --git a/core/services/studio/files/homefile.go b/core/services/studio/files/homefile.go index 6482a4ec48..8ea5ab0f5e 100644 --- a/core/services/studio/files/homefile.go +++ b/core/services/studio/files/homefile.go @@ -63,3 +63,53 @@ func (s *Service) WriteHome(ctx context.Context, principal, rel, content, title s.Written(ctx, n, principal) return cs.Rev, nil } + +// HomeFile is one file of a change that writes several. +type HomeFile struct { + Path string // inside the home + Content string +} + +// WriteHomeFiles replaces several files in the home of principal as one change +// set, so one undo restores all of them. It writes as the user, for a change +// the user confirmed. Each file may be at most max bytes. Nothing is written +// when one file fails. +func (s *Service) WriteHomeFiles(ctx context.Context, principal string, fs []HomeFile, title string, max int64) (int64, error) { + if !s.Available() { + return 0, ErrUnavailable + } + abs := make([]string, len(fs)) + for i, f := range fs { + a, err := s.Abs(principal, f.Path) + if err != nil { + return 0, err + } + abs[i] = a + } + if err := s.EnsureHome(ctx, principal); err != nil { + return 0, err + } + title = strings.TrimSpace(title) + tx, err := s.ws.Begin(ctx, s.Actor(principal), title) + if err != nil { + return 0, err + } + nodes := make([]workspace.Node, len(fs)) + for i, f := range fs { + n, err := tx.Put(ctx, abs[i], strings.NewReader(f.Content), workspace.PutOptions{Overwrite: true, MaxSize: max, Title: title}) + if err != nil { + _ = tx.Rollback() + return 0, err + } + nodes[i] = n + } + cs, err := tx.Commit() + if err != nil && !errors.Is(err, workspace.ErrCommitted) { + return 0, err + } + for _, n := range nodes { + n.Rev = cs.Rev + s.Written(ctx, n, principal) + } + return cs.Rev, nil +} diff --git a/core/services/studio/grantkinds_test.go b/core/services/studio/grantkinds_test.go new file mode 100644 index 0000000000..ccf9b5795d --- /dev/null +++ b/core/services/studio/grantkinds_test.go @@ -0,0 +1,260 @@ +package studio_test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/studiotest" + "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +func pageContent(files map[string]string) string { + b, _ := json.Marshal(map[string]any{"files": files}) + return string(b) +} + +var _ = Describe("skill, page and dashboard proposals", func() { + ctx := context.Background() + var ( + r *trigRig + ag agents.Agent + ) + BeforeEach(func() { + r = changeShared(nil).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{}) + var err error + ag, err = r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Maker", Model: "m", + Builtins: []string{"skills.write", "pages.write", "dashboard.write"}}) + Expect(err).NotTo(HaveOccurred()) + }) + propose := func(kind, scope, target, content string) (contract.ChangeProposal, error) { + return r.svc.Changes().Propose(ctx, studio.ProposeInput{User: alice, Agent: ag.ID, Kind: kind, Scope: scope, Target: target, Content: content, Summary: "s"}) + } + write := func(rel, text string) { + _, err := r.sh.files.WriteHome(ctx, alice, rel, text, "seed", 1<<20) + Expect(err).NotTo(HaveOccurred()) + } + + Describe("a page", func() { + good := map[string]string{"index.html": "

Hi

", "page.json": `{"title":"Hi","icon":"home","widget":{"size":"small"}}`, "data/items.json": "[]"} + + It("is written as one change set after the owner confirms, and one undo removes it", func() { + p, err := propose("page", "user", "hello", pageContent(good)) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Target).To(Equal("pages/hello")) + _, ok := readHome(r, "pages/hello/index.html") + Expect(ok).To(BeFalse()) + done, err := r.svc.Changes().Confirm(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + for rel, text := range good { + got, ok := readHome(r, "pages/hello/"+rel) + Expect(ok).To(BeTrue(), rel) + Expect(got).To(Equal(text)) + } + Expect(done.Rev).To(BeNumerically(">", 0)) + _, err = r.svc.Changes().Revert(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + for rel := range good { + _, ok := readHome(r, "pages/hello/"+rel) + Expect(ok).To(BeFalse(), rel+" survived the undo") + } + }) + + DescribeTable("refuses a bad page", + func(target, scope string, files map[string]string) { + _, err := propose("page", scope, target, pageContent(files)) + Expect(err).To(MatchError(studio.ErrBadChange)) + list, _ := r.svc.Changes().List(ctx, alice) + Expect(list).To(BeEmpty()) + }, + Entry("a name with upper case", "Hello", "user", good), + Entry("a name with a slash", "a/b", "user", good), + Entry("a name that climbs", "..", "user", good), + Entry("no name", "", "user", good), + Entry("the agent scope", "hello", "agent", good), + Entry("the channel scope", "hello", "channel", good), + Entry("no index.html", "hello", "user", map[string]string{"page.json": "{}"}), + Entry("an empty index.html", "hello", "user", map[string]string{"index.html": " "}), + Entry("a parent path", "hello", "user", map[string]string{"index.html": "x", "../evil.html": "x"}), + Entry("a nested parent path", "hello", "user", map[string]string{"index.html": "x", "a/../../evil": "x"}), + Entry("an absolute path", "hello", "user", map[string]string{"index.html": "x", "/etc/passwd": "x"}), + Entry("a backslash path", "hello", "user", map[string]string{"index.html": "x", `a\b`: "x"}), + Entry("an empty segment", "hello", "user", map[string]string{"index.html": "x", "a//b": "x"}), + Entry("a dot segment", "hello", "user", map[string]string{"index.html": "x", "./a": "x"}), + Entry("a file that is also a folder", "hello", "user", map[string]string{"index.html": "x", "a": "x", "a/b": "x"}), + Entry("two spellings of one file", "hello", "user", map[string]string{"index.html": "x", "A.js": "x", "a.js": "x"}), + Entry("a binary file", "hello", "user", map[string]string{"index.html": "x", "a.bin": "a\x00b"}), + Entry("page.json that is not an object", "hello", "user", map[string]string{"index.html": "x", "page.json": "[1]"}), + Entry("page.json with a bad widget size", "hello", "user", map[string]string{"index.html": "x", "page.json": `{"widget":{"size":"huge"}}`}), + Entry("page.json with a long title", "hello", "user", map[string]string{"index.html": "x", "page.json": `{"title":"` + strings.Repeat("t", 121) + `"}`}), + Entry("page.json with a bad icon", "hello", "user", map[string]string{"index.html": "x", "page.json": `{"icon":"Bad Icon"}`}), + ) + + It("refuses content that is not a files object, too many files and more than 256 KiB", func() { + for name, content := range map[string]string{ + "plain html": "

hi

", + "no files": `{"files":{}}`, + "extra key": `{"files":{"index.html":"x"},"run":"rm"}`, + "two docs": `{"files":{"index.html":"x"}} {"files":{}}`, + } { + _, err := propose("page", "user", "hello", content) + Expect(err).To(MatchError(studio.ErrBadChange), name) + } + many := map[string]string{"index.html": "x"} + for i := range 32 { + many[fmt.Sprintf("f%d.json", i)] = "{}" + } + _, err := propose("page", "user", "hello", pageContent(many)) + Expect(err).To(MatchError(studio.ErrBadChange)) + _, err = propose("page", "user", "hello", pageContent(map[string]string{"index.html": strings.Repeat("a", contract.MaxPageChangeBytes)})) + Expect(err).To(MatchError(studio.ErrBadChange), "the JSON wrapper pushes it over the limit") + _, err = propose("page", "user", "hello", pageContent(map[string]string{"index.html": strings.Repeat("a", contract.MaxPageChangeBytes-1024)})) + Expect(err).NotTo(HaveOccurred()) + }) + + It("is stale when a file of the page changed after the proposal", func() { + write("pages/hello/index.html", "

old

") + p, err := propose("page", "user", "hello", pageContent(good)) + Expect(err).NotTo(HaveOccurred()) + Expect(p.BaseHash).NotTo(BeEmpty()) + write("pages/hello/index.html", "

hand made

") + _, err = r.svc.Changes().Confirm(ctx, alice, p.ID) + Expect(err).To(MatchError(studio.ErrTargetChanged)) + got, _ := readHome(r, "pages/hello/index.html") + Expect(got).To(Equal("

hand made

")) + }) + + It("refuses the owner's edit when it breaks the rules", func() { + p, err := propose("page", "user", "hello", pageContent(good)) + Expect(err).NotTo(HaveOccurred()) + _, err = r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, pageContent(map[string]string{"page.json": "{}"})) + Expect(err).To(MatchError(studio.ErrBadChange)) + }) + }) + + Describe("the dashboard", func() { + ok := `{"widgets":[{"id":"c","type":"chats","title":"Chats"}]}` + + It("writes LocalAI/home.json after the owner confirms and undoes it", func() { + p, err := propose("dashboard", "user", "", ok) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Target).To(Equal("LocalAI/home.json")) + _, found := readHome(r, "LocalAI/home.json") + Expect(found).To(BeFalse()) + _, err = r.svc.Changes().Confirm(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + text, _ := readHome(r, "LocalAI/home.json") + Expect(text).To(Equal(ok)) + _, err = r.svc.Changes().Revert(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + _, found = readHome(r, "LocalAI/home.json") + Expect(found).To(BeFalse()) + }) + + DescribeTable("refuses a file that breaks the schema", + func(scope, content string) { + _, err := propose("dashboard", scope, "", content) + Expect(err).To(MatchError(studio.ErrBadChange)) + }, + Entry("not JSON", "user", "widgets"), + Entry("not an object", "user", "[]"), + Entry("an unknown widget type", "user", `{"widgets":[{"id":"c","type":"rocket","title":"x"}]}`), + Entry("a page widget with a bad page name", "user", `{"widgets":[{"id":"p","type":"page","page":"Bad Name"}]}`), + Entry("too many page widgets", "user", `{"widgets":[`+strings.TrimSuffix(strings.Repeat(`{"id":"p1","type":"page","page":"news"},`, 4), ",")+`]}`), + Entry("the agent scope", "agent", ok), + Entry("the channel scope", "channel", ok), + ) + + It("is checked again when the owner edits it", func() { + p, err := propose("dashboard", "user", "", ok) + Expect(err).NotTo(HaveOccurred()) + _, err = r.svc.Changes().ConfirmEdited(ctx, alice, p.ID, `{"widgets":[{"id":"p","type":"page","page":"Bad Name"}]}`) + Expect(err).To(MatchError(studio.ErrBadChange)) + }) + }) + + Describe("a skill", func() { + Describe("of the personal layer", func() { + It("is written to .skills when the owner confirms", func() { + p, err := propose("skill", "user", "daily-note", skillText) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Target).To(Equal(".skills/daily-note/SKILL.md")) + _, err = r.svc.Changes().Confirm(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + text, _ := readHome(r, ".skills/daily-note/SKILL.md") + Expect(text).To(Equal(skillText)) + }) + + It("is refused when the owner locked the existing skill", func() { + write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: true\n---\nMy steps.\n") + _, err := propose("skill", "user", "daily-note", skillText) + Expect(err).To(MatchError(studio.ErrBadChange)) + Expect(err.Error()).To(ContainSubstring("locked")) + }) + + It("is refused at confirm when the owner locks it after the proposal", func() { + write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\n---\nMy steps.\n") + p, err := propose("skill", "user", "daily-note", skillText) + Expect(err).NotTo(HaveOccurred()) + // The owner marks the skill as locked, which changes the file, so the + // base hash moves as well: either answer keeps the file as it is. + write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: true\n---\nMy steps.\n") + _, err = r.svc.Changes().Confirm(ctx, alice, p.ID) + Expect(err).To(HaveOccurred()) + text, _ := readHome(r, ".skills/daily-note/SKILL.md") + Expect(text).To(ContainSubstring("My steps.")) + }) + + It("may change a skill that is not locked", func() { + write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: false\n---\nOld.\n") + _, err := propose("skill", "user", "daily-note", skillText) + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("of the agent layer", func() { + It("is written under the folder of the agent", func() { + p, err := propose("skill", "agent", "daily-note", skillText) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Target).To(Equal("LocalAI/agents/" + ag.ID + "/skills/daily-note/SKILL.md")) + _, err = r.svc.Changes().Confirm(ctx, alice, p.ID) + Expect(err).NotTo(HaveOccurred()) + }) + + It("cannot replace a personal skill the owner locked", func() { + write(".skills/daily-note/SKILL.md", "---\nname: daily-note\ndescription: Mine.\nlocked: \"true\"\n---\nMy steps.\n") + _, err := propose("skill", "agent", "daily-note", skillText) + Expect(err).To(MatchError(studio.ErrBadChange)) + Expect(err.Error()).To(ContainSubstring("locked")) + }) + }) + + DescribeTable("refuses a bad skill", + func(scope, target, content string) { + _, err := propose("skill", scope, target, content) + Expect(err).To(MatchError(studio.ErrBadChange)) + }, + Entry("the channel scope", "channel", "daily-note", skillText), + Entry("a name with upper case", "user", "Daily", strings.Replace(skillText, "daily-note", "Daily", 2)), + Entry("a path as the name", "user", "../x", skillText), + Entry("no name", "user", "", skillText), + Entry("no frontmatter", "user", "daily-note", "just text"), + Entry("no description", "user", "daily-note", "---\nname: daily-note\n---\nbody\n"), + Entry("a name that differs from the target", "user", "other-note", skillText), + ) + + It("is not offered for a shared or built-in layer: the only places are the personal and the agent layer", func() { + for _, scope := range []string{"shared", "builtin", "team"} { + _, err := propose("skill", scope, "daily-note", skillText) + Expect(err).To(MatchError(studio.ErrBadChange), scope) + } + }) + }) +}) diff --git a/core/services/studio/grants_test.go b/core/services/studio/grants_test.go new file mode 100644 index 0000000000..2dc12a02f2 --- /dev/null +++ b/core/services/studio/grants_test.go @@ -0,0 +1,282 @@ +package studio_test + +import ( + "context" + "encoding/json" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/builtins" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/studiotest" + "github.com/mudler/LocalAI/core/services/studio/workspace" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +const skillText = "---\nname: daily-note\ndescription: Write the daily note.\n---\nList what changed today.\n" + +// grantCase is one builtin grant with a proposal that only it allows. +type grantCase struct { + grant string + args map[string]any + // plainOK is false where a chat without an agent cannot use the kind at all. + plainOK bool + file string // the file or folder the proposal names +} + +func pageFilesJSON() map[string]string { + return map[string]string{"index.html": "

Notes

", "page.json": `{"title":"Notes"}`} +} + +var grantCases = []grantCase{ + {"memory.write", map[string]any{"kind": "memory", "scope": "user", "target": "a.md", "content": "- a\n", "summary": "s"}, true, "Memory/a.md"}, + {"profile.write", map[string]any{"kind": "profile", "scope": "agent", "content": "Be brief.", "summary": "s"}, false, ""}, + {"skills.write", map[string]any{"kind": "skill", "scope": "user", "target": "daily-note", "content": skillText, "summary": "s"}, true, ".skills/daily-note/SKILL.md"}, + {"pages.write", map[string]any{"kind": "page", "scope": "user", "target": "notes", "files": pageFilesJSON(), "summary": "s"}, true, "pages/notes"}, + {"dashboard.write", map[string]any{"kind": "dashboard", "scope": "user", "content": `{"widgets":[{"id":"c","type":"chats","title":"Chats"}]}`, "summary": "s"}, true, "LocalAI/home.json"}, +} + +func argsJSON(m map[string]any) string { + b, _ := json.Marshal(m) + return string(b) +} + +func grantScript(c grantCase) func(workspace.Actor, string) []studiotest.ToolStep { + return func(_ workspace.Actor, text string) []studiotest.ToolStep { + if text == "go" { + return []studiotest.ToolStep{{Tool: "propose_change", Args: argsJSON(c.args)}, {Say: "ok"}} + } + return []studiotest.ToolStep{{Say: "ok"}} + } +} + +var _ = Describe("builtin grants", func() { + ctx := context.Background() + + Describe("the declarations", func() { + It("names every grant of the spec once, with a title and a description", func() { + var names []string + for _, g := range builtins.Grants() { + names = append(names, g.Name) + Expect(g.Title).NotTo(BeEmpty(), g.Name) + Expect(g.Description).NotTo(BeEmpty(), g.Name) + } + Expect(names).To(Equal([]string{"memory.write", "profile.write", "skills.write", "pages.write", "dashboard.write", + "triggers.propose", "triggers.run", "talkback.channels", "talkback.new_channel", "files.upload", + "act.files", "act.addons", "act.send", "act.spend", "saved_agents"})) + }) + + It("marks the sensitive ones and shares the sensitivity of the contract", func() { + for _, g := range builtins.Grants() { + if contract.KnownCapability(g.Name) { + Expect(g.Sensitive).To(Equal(contract.SensitiveCapability(g.Name)), g.Name) + } + } + for _, n := range []string{"profile.write", "skills.write", "pages.write", "dashboard.write", "talkback.new_channel", "act.files", "act.addons", "act.send", "act.spend"} { + Expect(builtins.SensitiveGrant(n)).To(BeTrue(), n) + } + Expect(builtins.SensitiveGrant("memory.write")).To(BeFalse()) + }) + + It("turns on nothing by default: a new agent, a template and an import have no grants", func() { + r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }) + ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Plain", Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + Expect(ag.Builtins).To(BeEmpty()) + for _, t := range agents.Templates() { + made, err := r.svc.CreateAgentFromTemplate(ctx, alice, t.ID, "From "+t.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(made.Builtins).To(BeEmpty(), t.ID) + } + imp, err := r.svc.ImportAgent(ctx, alice, agents.Agent{Name: "Old file", Model: "m"}, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(imp.Builtins).To(BeEmpty()) + }) + }) + + Describe("saving an agent", func() { + It("refuses a grant that is not declared, keeps the grants of a write that omits them and clears them with an empty list", func() { + r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }) + _, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"memory.write", "root.shell"}}) + Expect(err).To(MatchError(studio.ErrUnknownGrant)) + Expect(err).To(MatchError(agents.ErrInvalid)) + ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"skills.write", "memory.write", "skills.write"}}) + Expect(err).NotTo(HaveOccurred()) + Expect(ag.Builtins).To(Equal([]string{"skills.write", "memory.write"})) + ag.Builtins = nil + kept, err := r.svc.SaveAgent(ctx, alice, ag) + Expect(err).NotTo(HaveOccurred()) + Expect(kept.Builtins).To(Equal([]string{"skills.write", "memory.write"}), "a client that does not know the field must not narrow or widen") + ag.Builtins = []string{} + cleared, err := r.svc.SaveAgent(ctx, alice, ag) + Expect(err).NotTo(HaveOccurred()) + Expect(cleared.Builtins).To(BeEmpty()) + }) + + It("drops a grant of a newer server in an import and turns saved_agents on as the opt-in group", func() { + r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }) + ag, err := r.svc.ImportAgent(ctx, alice, agents.Agent{Name: "Imported", Model: "m", Builtins: []string{"memory.write", "future.grant", "saved_agents"}}, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(ag.Builtins).To(Equal([]string{"memory.write", "saved_agents"})) + Expect(ag.BuiltinsOn).To(ContainElement("saved_agents")) + }) + }) + + Describe("the grants an agent holds", func() { + It("are the granted ones the user may use now", func() { + r := newTrigRig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }) + ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"memory.write", "triggers.propose", "act.files"}}) + Expect(err).NotTo(HaveOccurred()) + held := r.svc.AgentGrants(ctx, alice, ag.ID) + Expect(held).To(ContainElements("memory.write", "triggers.propose", "act.files")) + Expect(r.svc.AgentGrants(ctx, alice, "missing")).To(BeEmpty()) + Expect(r.svc.AgentGrants(ctx, "bob", ag.ID)).To(BeEmpty(), "another user's agent") + }) + }) + + Describe("propose_change by grant and by kind of chat", func() { + for _, c := range grantCases { + c := c + Describe(c.grant, func() { + It("exists in a chat of an agent that holds it, and the proposal waits for the owner", func() { + r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{}) + ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{c.grant}}) + Expect(err).NotTo(HaveOccurred()) + rec, err := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID}) + Expect(err).NotTo(HaveOccurred()) + sent(r.svc, alice, rec.ID, "go") + idle(r.svc, alice, rec.ID) + res := r.h.ResultsFor("propose_change") + Expect(res).To(HaveLen(1)) + Expect(res[0].Err).To(BeNil(), "%+v", res) + p := onlyProposal(r) + Expect(p.Status).To(Equal(contract.ProposalPending)) + Expect(p.Agent).To(Equal(ag.ID)) + if c.file != "" { + Expect(p.Target).To(Equal(c.file)) + _, ok := readHome(r, c.file) + Expect(ok).To(BeFalse(), "the proposal wrote the file") + } + }) + + It("does not exist in a chat of an agent without it", func() { + r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{}) + ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID}) + sent(r.svc, alice, rec.ID, "go") + idle(r.svc, alice, rec.ID) + Expect(r.h.ToolNames(rec.ID)).NotTo(ContainElement("propose_change")) + list, _ := r.svc.Changes().List(ctx, alice) + Expect(list).To(BeEmpty()) + }) + + It("is not enough for the kinds of other grants", func() { + r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{}) + other := "triggers.propose" + if c.grant == other { + other = "act.files" + } + // memory.write is the grant of the memory kind, so any other + // case holds a different grant than the one the call needs. + held := "memory.write" + if c.grant == held { + held = "skills.write" + } + ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{held, other}}) + rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID}) + sent(r.svc, alice, rec.ID, "go") + idle(r.svc, alice, rec.ID) + res := r.h.ResultsFor("propose_change") + Expect(res).To(HaveLen(1)) + Expect(res[0].Err).To(HaveOccurred()) + Expect(res[0].Err.Error()).To(ContainSubstring("not given the right")) + list, _ := r.svc.Changes().List(ctx, alice) + Expect(list).To(BeEmpty()) + }) + + It("exists in a chat without an agent, which holds what the user may use", func() { + r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{}) + id := create(r.svc, alice) + sent(r.svc, alice, id, "go") + idle(r.svc, alice, id) + res := r.h.ResultsFor("propose_change") + Expect(res).To(HaveLen(1)) + if c.plainOK { + Expect(res[0].Err).To(BeNil(), "%+v", res) + Expect(onlyProposal(r).Kind).To(Equal(contract.ChangeKind(c.args["kind"].(string)))) + } else { + Expect(res[0].Err).To(HaveOccurred()) + } + }) + + It("is never held by an unattended run of an agent that has it", func() { + r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{}) + ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{c.grant}}) + tr := r.schedule(studio.TriggerInput{AgentID: ag.ID}) + r.due() + run := r.waitEnded(tr.ID) + Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("propose_change")) + Expect(r.h.ToolNames(run.ChatID)).NotTo(ContainElement("revert_change")) + }) + + It("is held by a connector chat of an agent that has it and refused when the source is a run that started by itself", func() { + r := changeShared(nil).rig(grantScript(c), studio.TriggerOptions{}) + ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{c.grant}}) + in := proposeInputOf(c, ag.ID) + in.Source = contract.ChangeSource{Kind: session.OriginConnector, ID: "b1", Chat: "c1"} + p, err := r.svc.Changes().Propose(ctx, in) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Source.Kind).To(Equal(session.OriginConnector)) + Expect(r.svc.Changes().Dismiss(ctx, alice, p.ID)).Error().NotTo(HaveOccurred()) + in.Source = contract.ChangeSource{Kind: session.OriginTrigger, ID: "t1", Chat: "c2"} + _, err = r.svc.Changes().Propose(ctx, in) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("started by itself")) + }) + }) + } + }) + + Describe("builtins.Chat.HoldsGrant", func() { + DescribeTable("the tool-exists rule", + func(c builtins.Chat, want bool) { Expect(c.HoldsGrant("skills.write")).To(Equal(want)) }, + Entry("a chat without an agent", builtins.Chat{}, true), + Entry("an agent without the grant", builtins.Chat{Agent: true, Grants: []string{"memory.write"}}, false), + Entry("an agent with the grant", builtins.Chat{Agent: true, Grants: []string{"skills.write"}}, true), + Entry("a connector chat of an agent with the grant", builtins.Chat{Agent: true, Origin: session.OriginConnector, Grants: []string{"skills.write"}}, true), + Entry("a trigger run of an agent with the grant", builtins.Chat{Agent: true, Origin: session.OriginTrigger, Grants: []string{"skills.write"}}, false), + Entry("a job run", builtins.Chat{Agent: true, Origin: session.OriginJob, Grants: []string{"skills.write"}}, false), + Entry("a sub-agent of an agent with the grant", builtins.Chat{Agent: true, SubAgent: true, Grants: []string{"skills.write"}}, false), + ) + }) + + Describe("the schema of propose_change", func() { + It("lists the kinds the chat holds and no others", func() { + r := changeShared(nil).rig(func(workspace.Actor, string) []studiotest.ToolStep { return nil }, studio.TriggerOptions{}) + ag, _ := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "A", Model: "m", Builtins: []string{"memory.write", "pages.write"}}) + rec, _ := r.svc.Create(ctx, alice, studio.CreateInput{AgentID: ag.ID}) + sent(r.svc, alice, rec.ID, "hi") + idle(r.svc, alice, rec.ID) + schema := r.h.ToolSchema(rec.ID, "propose_change") + Expect(schema).To(ContainSubstring(`"enum":["memory","page"]`)) + Expect(strings.Contains(schema, `"skill"`)).To(BeFalse()) + }) + }) +}) + +// proposeInputOf is the direct call of the case for an agent. +func proposeInputOf(c grantCase, agent string) studio.ProposeInput { + in := studio.ProposeInput{User: alice, Agent: agent, Kind: c.args["kind"].(string), Scope: c.args["scope"].(string), Summary: "s"} + in.Target, _ = c.args["target"].(string) + in.Content, _ = c.args["content"].(string) + if files, ok := c.args["files"].(map[string]string); ok { + b, _ := json.Marshal(map[string]any{"files": files}) + in.Content = string(b) + } + return in +} diff --git a/core/services/studio/grantscope.go b/core/services/studio/grantscope.go new file mode 100644 index 0000000000..d6a85024ce --- /dev/null +++ b/core/services/studio/grantscope.go @@ -0,0 +1,141 @@ +package studio + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/mudler/LocalAI/core/services/studio/builtins" + "github.com/mudler/LocalAI/core/services/studio/proposals" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// Builtin grants. An agent lists the grants the owner gave it (Builtins). What +// a chat of the agent holds is the intersection of that list with what the user +// may use at the turn, like the add-ons. A tool then exists in a chat when the +// grant is held, the chat did not switch its group off and the run's authority +// can have it (builtins.Chat.Holds). + +// ErrUnknownGrant means an agent names a builtin grant this server does not +// declare. +var ErrUnknownGrant = errors.New("studio: unknown builtin grant") + +// cleanGrants checks the grant names of an agent and removes repeats. With +// drop it silently leaves out a name the server does not know (an import from +// a newer server); otherwise it refuses the first one. A nil list stays nil. +func cleanGrants(names []string, drop bool) ([]string, error) { + if names == nil { + return nil, nil + } + out := make([]string, 0, len(names)) + for _, n := range names { + switch { + case !builtins.ValidGrant(n): + if !drop { + return nil, fmt.Errorf("%w: %q (GET /api/chats/agents/builtin-grants lists them)", ErrUnknownGrant, n) + } + case !slices.Contains(out, n): + out = append(out, n) + } + } + return out, nil +} + +// grantUsable says whether the user may use a grant on this server now: the +// server offers the feature behind it and the user's account has it. +func (s *Service) grantUsable(ctx context.Context, user, grant string) bool { + switch grant { + case builtins.GrantMemoryWrite, builtins.GrantProfileWrite, builtins.GrantSkillsWrite, builtins.GrantPagesWrite, builtins.GrantDashboardWrite: + if !s.d.Files.Available() || s.changes == nil { + return false + } + return s.changes.grantsAKind(grant) + case builtins.GrantTriggersPropose, builtins.GrantTriggersRun: + return s.triggers != nil && s.mayUseTriggers(ctx, user) == nil + case builtins.GrantFilesUpload, builtins.GrantActFiles: + return s.d.Files.Available() + case builtins.GrantSavedAgents: + return s.d.Agents != nil + } + return builtins.ValidGrant(grant) +} + +// usableGrants keeps the granted names the user may use, in the order of the +// declarations. +func (s *Service) usableGrants(ctx context.Context, user string, granted []string) []string { + var out []string + for _, g := range builtins.Grants() { + if slices.Contains(granted, g.Name) && s.grantUsable(ctx, user, g.Name) { + out = append(out, g.Name) + } + } + return out +} + +// AgentGrants returns the builtin grants that the saved agent holds now: what +// the owner granted, intersected with what the owner may use. A missing agent +// holds none. It is the answer for any gate that asks whether an agent has a +// right. +func (s *Service) AgentGrants(ctx context.Context, user, agentID string) []string { + if s.d.Agents == nil || agentID == "" { + return nil + } + ag, err := s.d.Agents.Get(ctx, principal(user), agentID) + if err != nil { + return nil + } + return s.usableGrants(ctx, principal(user), ag.Builtins) +} + +// chatGrants are the grants a chat of a saved agent holds, for the tools. +func (s *Service) chatGrants(ctx context.Context, rec session.ChatRecord) []string { + if rec.AgentID == "" { + return nil + } + if sc := s.scopeOf(ctx, rec); sc != nil { + return sc.builtinNames + } + return nil +} + +// BuiltinGrants lists the declared grants for the editor and the chat. +func (s *Service) BuiltinGrants() []builtins.Grant { return builtins.Grants() } + +// checkChangeGrant refuses a proposal of an agent that does not hold the +// grant of its kind, or a run that cannot hold rights (one that started by +// itself). A chat without an agent is the user's own and is not asked. +func (s *Service) checkChangeGrant(ctx context.Context, user, agentID string, src contract.ChangeSource, grant string) error { + if src.Kind != "" && src.Kind != session.OriginConnector { + return fmt.Errorf("%w: a run that started by itself cannot change this", proposals.ErrNotGranted) + } + if agentID == "" { + return nil + } + held := s.AgentGrants(ctx, user, agentID) + if grant == "" { + return nil + } + if !slices.Contains(held, grant) { + return fmt.Errorf("%w (%s)", proposals.ErrNotGranted, grant) + } + return nil +} + +// checkAnyChangeGrant is the check for a revert: any change grant will do, +// because an undo only restores an earlier text. +func (s *Service) checkAnyChangeGrant(ctx context.Context, user, agentID string, src contract.ChangeSource) error { + if src.Kind != "" && src.Kind != session.OriginConnector { + return fmt.Errorf("%w: a run that started by itself cannot change this", proposals.ErrNotGranted) + } + if agentID == "" { + return nil + } + for _, g := range s.AgentGrants(ctx, user, agentID) { + if s.changes.grantsAKind(g) { + return nil + } + } + return proposals.ErrNotGranted +} diff --git a/core/services/studio/hostbash.go b/core/services/studio/hostbash.go index 8a78b5de6f..b05fad3f25 100644 --- a/core/services/studio/hostbash.go +++ b/core/services/studio/hostbash.go @@ -367,7 +367,7 @@ func (s *Service) Builtins() *builtins.Registry { return s.builtins } func (s *Service) builtinsFor(ctx context.Context, a workspace.Actor, rec session.ChatRecord, subAgent bool) []builtins.Built { off, on := s.switchesOf(ctx, rec) chat := builtins.Chat{Actor: a, Origin: rec.Origin, OriginID: rec.OriginID, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: subAgent, - Admin: s.isAdmin(ctx, a.Principal), GroupsOff: off, GroupsOn: on, GoalActive: goalActive(rec) && !subAgent} + Admin: s.isAdmin(ctx, a.Principal), GroupsOff: off, GroupsOn: on, Grants: s.chatGrants(ctx, rec), GoalActive: goalActive(rec) && !subAgent} if rec.Origin == session.OriginConnector { // A connector chat acts for the sender of the turn, not for its owner: // the tools that need a right ask what that sender holds. diff --git a/core/services/studio/proposals/kinds.go b/core/services/studio/proposals/kinds.go index f2cc7c7d08..18f1303fb2 100644 --- a/core/services/studio/proposals/kinds.go +++ b/core/services/studio/proposals/kinds.go @@ -31,14 +31,49 @@ type Kind struct { Sensitive bool // MaxBytes bounds the content; zero means contract.MaxChangeBytes. MaxBytes int - // Path returns the file inside the home that the change writes. + // Path returns the file inside the home that the change writes. For a kind + // with Files it is the folder that holds the files. Path func(Aim) (string, error) + // Grant is the builtin grant that lets a saved agent propose this kind + // (memory.write for memory). Empty: no grant is needed. + Grant string + // Check judges the content before the proposal is stored and again when + // the owner confirms, against what the target holds then. Nil: any text + // that fits the limit. + Check func(CheckInput) error + // Files turns the content into the files the change writes, as paths + // relative to the folder Path returned. Nil: the content is the one file + // Path returned. + Files func(content string) ([]FileWrite, error) +} + +// FileWrite is one file of a change that writes several. +type FileWrite struct { + Path string // relative to the folder of the change + Content string +} + +// CheckInput is what Kind.Check judges. +type CheckInput struct { + Aim Aim + Path string // what Kind.Path returned + Content string + // Current is the text of the target now, for a kind with one file. + Current string + Exists bool + // Read reads another file of the owner's home by its home-relative path, + // for a rule that depends on a neighbour such as a locked skill of + // another layer. + Read func(rel string) (text string, ok bool, err error) } // Limit is the content bound of the kind. func (k Kind) Limit() int { - if k.MaxBytes <= 0 || k.MaxBytes > contract.MaxChangeBytes { + switch { + case k.MaxBytes <= 0: return contract.MaxChangeBytes + case k.MaxBytes > contract.MaxPageChangeBytes: + return contract.MaxPageChangeBytes } return k.MaxBytes } @@ -102,6 +137,9 @@ var ( idSafe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) ) +// IDSafe reports whether s can name a saved agent or a binding inside a path. +func IDSafe(s string) bool { return idSafe.MatchString(s) } + // ChannelDir names the memory folder of one connector conversation: the // binding and a short hash of the host chat. It uses no platform name. func ChannelDir(s contract.ChangeSource) string { @@ -110,7 +148,8 @@ func ChannelDir(s contract.ChangeSource) string { } var memoryKind = Kind{ - Name: contract.ChangeMemory, + Name: contract.ChangeMemory, + Grant: contract.CapMemoryWrite, Path: func(a Aim) (string, error) { if !topicFile.MatchString(a.Target) { return "", fmt.Errorf("%w: a memory target is a file name such as index.md or food.md", ErrBadTarget) @@ -135,6 +174,7 @@ var memoryKind = Kind{ var profileKind = Kind{ Name: contract.ChangeProfile, + Grant: contract.CapProfileWrite, Sensitive: true, MaxBytes: contract.MaxProfileBytes, Path: func(a Aim) (string, error) { diff --git a/core/services/studio/proposals/kinds_test.go b/core/services/studio/proposals/kinds_test.go index e3f9bb54eb..4712b3c877 100644 --- a/core/services/studio/proposals/kinds_test.go +++ b/core/services/studio/proposals/kinds_test.go @@ -78,3 +78,16 @@ var _ = Describe("the default gate", func() { Expect(err).To(MatchError(proposals.ErrNotGranted)) }) }) + +var _ = Describe("kind grants and limits", func() { + It("ties memory and profile to their grants and lets a page kind carry 256 KiB", func() { + kinds := proposals.NewKinds() + m, _ := kinds.Lookup("memory") + p, _ := kinds.Lookup("profile") + Expect(m.Grant).To(Equal("memory.write")) + Expect(p.Grant).To(Equal("profile.write")) + Expect((proposals.Kind{MaxBytes: contract.MaxPageChangeBytes}).Limit()).To(Equal(contract.MaxPageChangeBytes)) + Expect((proposals.Kind{MaxBytes: contract.MaxPageChangeBytes + 1}).Limit()).To(Equal(contract.MaxPageChangeBytes)) + Expect((proposals.Kind{}).Limit()).To(Equal(contract.MaxChangeBytes)) + }) +}) diff --git a/core/services/studio/shadowers.go b/core/services/studio/shadowers.go index 987d94acdb..1193fd48e1 100644 --- a/core/services/studio/shadowers.go +++ b/core/services/studio/shadowers.go @@ -120,7 +120,7 @@ func (s *Service) toolFallbacks(ctx context.Context, rec session.ChatRecord, too offList, onList := s.switchesOf(ctx, rec) off, on := set(offList), set(onList) c := builtins.Chat{Actor: s.chatActorFor(ctx, rec), Origin: rec.Origin, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: rec.Kind == session.KindSubAgent, - Admin: s.isAdmin(ctx, rec.Principal)} + Admin: s.isAdmin(ctx, rec.Principal), Grants: s.chatGrants(ctx, rec)} byID := map[string]builtins.Tool{} for _, t := range s.builtins.Tools() { byID[t.ID] = t diff --git a/core/services/studio/skills/parse.go b/core/services/studio/skills/parse.go index 763fa24aca..12fbcf2d6a 100644 --- a/core/services/studio/skills/parse.go +++ b/core/services/studio/skills/parse.go @@ -105,6 +105,20 @@ func Requires(data []byte) []string { return out } +// Locked reads the optional "locked" key of the frontmatter: the owner's mark +// that a skill is theirs to change and no agent may propose an edit of it. Only +// the value true (any case) locks; a file without frontmatter is not locked. +func Locked(data []byte) bool { + if len(data) > MaxFileSize { + return false + } + fm, ok := frontmatter(string(data)) + if !ok { + return false + } + return strings.EqualFold(strings.TrimSpace(keyValues(fm)["locked"]), "true") +} + // Clean drops invalid UTF-8, control characters and format characters from s, // turns every run of white space into one space and trims the ends. func Clean(s string) string { diff --git a/core/services/studio/skills/parse_test.go b/core/services/studio/skills/parse_test.go index 4af8e72791..503d8d107e 100644 --- a/core/services/studio/skills/parse_test.go +++ b/core/services/studio/skills/parse_test.go @@ -76,3 +76,16 @@ var _ = Describe("Requires", func() { Entry("no frontmatter", "Body only.\n", nil), ) }) + +var _ = Describe("Locked", func() { + DescribeTable("reads the owner's mark from the frontmatter", + func(text string, want bool) { Expect(Locked([]byte(text))).To(Equal(want)) }, + Entry("true", "---\nname: a\ndescription: d\nlocked: true\n---\nb\n", true), + Entry("quoted and upper case", "---\nname: a\ndescription: d\nlocked: \"True\"\n---\nb\n", true), + Entry("false", "---\nname: a\ndescription: d\nlocked: false\n---\nb\n", false), + Entry("absent", "---\nname: a\ndescription: d\n---\nb\n", false), + Entry("in the body only", "---\nname: a\ndescription: d\n---\nlocked: true\n", false), + Entry("no frontmatter", "locked: true\n", false), + Entry("yes is not true", "---\nname: a\ndescription: d\nlocked: yes\n---\nb\n", false), + ) +}) diff --git a/core/services/studio/studiotest/tools.go b/core/services/studio/studiotest/tools.go index 411294de12..fb5034c896 100644 --- a/core/services/studio/studiotest/tools.go +++ b/core/services/studio/studiotest/tools.go @@ -43,6 +43,7 @@ type ToolHarness struct { results []ToolResult sent map[string][]string tools map[string][]string + schemas map[string]map[string]string prompts map[string]string systems map[string]string resumes map[string][]byte @@ -104,6 +105,14 @@ func (h *ToolHarness) TurnPrompt(chat string) string { return h.prompts[chat] } +// ToolSchema returns the JSON schema of a tool the chat's session was last +// started with, or "". +func (h *ToolHarness) ToolSchema(chat, tool string) string { + h.mu.Lock() + defer h.mu.Unlock() + return h.schemas[chat][tool] +} + // Start implements session.Harness. func (h *ToolHarness) Start(_ context.Context, env session.HarnessEnv) (session.HarnessSession, error) { h.mu.Lock() @@ -115,6 +124,13 @@ func (h *ToolHarness) Start(_ context.Context, env session.HarnessEnv) (session. names[i] = t.Name } h.tools[env.Actor.ChatID] = names + if h.schemas == nil { + h.schemas = map[string]map[string]string{} + } + h.schemas[env.Actor.ChatID] = map[string]string{} + for _, t := range env.Tools { + h.schemas[env.Actor.ChatID][t.Name] = string(t.Schema) + } if h.prompts == nil { h.prompts = map[string]string{} } diff --git a/core/services/studio/webtools.go b/core/services/studio/webtools.go index 58e9add74d..7039836b36 100644 --- a/core/services/studio/webtools.go +++ b/core/services/studio/webtools.go @@ -176,7 +176,7 @@ func (s *Service) builtinGroups(ctx context.Context, rec session.ChatRecord, too off, on := set(offList), set(onList) a := s.chatActorFor(ctx, rec) c := builtins.Chat{Actor: a, Origin: rec.Origin, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: rec.Kind == session.KindSubAgent, - Admin: s.isAdmin(ctx, rec.Principal), GroupsOff: offList, GroupsOn: onList, GoalActive: goalActive(rec)} + Admin: s.isAdmin(ctx, rec.Principal), GroupsOff: offList, GroupsOn: onList, Grants: s.chatGrants(ctx, rec), GoalActive: goalActive(rec)} var out []BuiltinGroup byGroup := map[string]int{} for _, t := range s.builtins.Tools() { diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index cfbcf98398..dde7cbe7ad 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -2486,14 +2486,32 @@ How it works for an add-on author: The record is `contract.ChangeProposal` in `pkg/addons/contract` and is versioned (`v`: 1). Readers ignore fields they do not know. The kinds are -`memory`, `profile`, `skill`, `page` and `dashboard`; this host offers `memory` -and `profile` and other features register theirs. A memory change writes under +`memory`, `profile`, `skill`, `page` and `dashboard`; this host offers all five. +A memory change writes under `Memory/` (`Memory/.md` for scope `user`, `Memory/agents//` for `agent`, `Memory/channels/-/` for `channel`). A profile writes `LocalAI/agents//profile.md` and is at most 4 KiB. Other contents are at most 16 KiB. A target that is not a plain lower-case file name is refused, so a proposal cannot leave `Memory/`. +The other three kinds follow the same flow: + +- `skill`: the content is a full `SKILL.md` (name and description in the + frontmatter; the name must equal the target). Scope `user` writes + `.skills//SKILL.md`, scope `agent` writes + `LocalAI/agents//skills//SKILL.md`; no other layer is offered. + A skill with `locked: true` in its frontmatter cannot be changed by a + proposal, and a locked personal skill cannot be replaced by an agent skill of + the same name. The check runs again when the owner confirms. +- `page`: the target is the page name (lower-case letters, digits and dashes) + and the files are an object of paths to text, with an `index.html`. The paths + must stay inside the page folder, at most 32 files, all text, `page.json` + checked like the Pages screen reads it, 256 KiB in total. The owner's + confirmation writes `pages//` as one change set, so one undo removes it. +- `dashboard`: the content is the full `LocalAI/home.json`, validated against the + same schema as the Home settings. A file that breaks the schema is refused + when proposed and when edited at confirmation. + Limits and safety: | Rule | Value | @@ -2503,12 +2521,18 @@ Limits and safety: | Proposals of one agent (or of plain chats) in an hour | 5 | | Target changed after the proposal | the confirmation is refused (409) | | Run started by a trigger, a job or a page, and sub-agents | never offered the tools | -| Chat that runs a saved agent | refused until a grant allows it | +| Chat that runs a saved agent | refused until the owner grants the kind (see below) | -A grant plugs in as a `proposals.Gate` in the host: it is asked with the -action (`propose` or `revert`), the agent, the kind, the scope and the source -before a tool call acts. The default gate allows a plain chat and refuses a chat -that runs a saved agent, so an agent has no right until the owner gives it. +A saved agent proposes a kind only when its `builtins` list holds the grant of +that kind: `memory.write`, `profile.write`, `skills.write`, `pages.write` or +`dashboard.write`. The tool exists in a chat when the agent holds the grant, the +user may still use it and the chat did not switch the `changes` group off; its +schema lists only the kinds the chat holds. A plain chat holds what its user +may use. A run that started by itself never holds a grant. `GET +/api/chats/agents/builtin-grants` lists every grant with its title, description +and `sensitive` flag. A host can add a `proposals.Gate` on top: it is asked with +the action (`propose` or `revert`), the agent, the kind, the scope and the +source, after the grant check. Every step is audited (propose, refuse, confirm, dismiss, expire, stale, revert) with the source and a SHA-256 of the text, never the text. diff --git a/docs/content/features/chats-agents.md b/docs/content/features/chats-agents.md index 2734183ae0..f588a97ee0 100644 --- a/docs/content/features/chats-agents.md +++ b/docs/content/features/chats-agents.md @@ -244,6 +244,24 @@ assistant, a code reviewer, a daily digest, a writing editor). They grant nothing. `POST /api/chats/agents/templates/{id}/create` saves one as your own (`{"name": "..."}` is optional); then grant it tools with `PUT`. +### Builtin grants + +An agent can be given built-in capabilities in its `builtins` list. The list is +empty by default, no template or import turns a grant on, and a chat of the +agent holds only the grants you may use at that moment. +`GET /api/chats/agents/builtin-grants` returns `{grants: [{name, title, +description, sensitive}]}` for the editor. The names are `memory.write`, +`profile.write`, `skills.write`, `pages.write`, `dashboard.write`, +`triggers.propose`, `triggers.run`, `talkback.channels`, `talkback.new_channel`, +`files.upload`, `act.files`, `act.addons`, `act.send`, `act.spend` and +`saved_agents` (the opt-in group of that name; granting it also switches the +group on). A sensitive grant changes what the agent may do or reach later; the +editor marks it. The five `*.write` grants let the agent propose a memory note, +its profile, a skill, a page or a dashboard change with `propose_change`; you +confirm every proposal, and you can undo it. A write to the agent without the +`builtins` field keeps them, an empty list clears them and an unknown name is +refused (an import drops it). + ### Export and import ``` @@ -253,7 +271,7 @@ curl -X POST .../api/chats/agents/import -F file=@reviewer.json ``` The file holds the definition only (kind `localai-agent`): no ID, owner or -time. An agent with tool switches (`builtins_off`, `builtins_on`) is version 4, so that a server that does not know them refuses the file instead of giving the agent every tool. An agent without a folder or tool switches is version 1. An agent with one folder is +time. An agent with tool switches (`builtins_off`, `builtins_on`) is version 4, so that a server that does not know them refuses the file instead of giving the agent every tool. The `builtins` list is an extra field of any version; a server that does not know it ignores it, so the agent arrives without the grants. An agent without a folder or tool switches is version 1. An agent with one folder is version 2 and carries `folder` with the folder's path at export time and the mode, for example `{"path": "/Projects/Acme", "mode": "read"}` (the path is empty when the folder is missing, not chosen, or now outside your home). diff --git a/pkg/addons/contract/changeproposal.go b/pkg/addons/contract/changeproposal.go index 94816dcd47..c88e4dacd9 100644 --- a/pkg/addons/contract/changeproposal.go +++ b/pkg/addons/contract/changeproposal.go @@ -126,6 +126,9 @@ const ( MaxChangesPerHour = 5 // MaxChangeBytes is the largest content of a proposal. MaxChangeBytes = 16 << 10 + // MaxPageChangeBytes is the largest content of a page proposal: all the + // files of the page together. + MaxPageChangeBytes = 256 << 10 // MaxProfileBytes is the largest agent profile. MaxProfileBytes = 4 << 10 ) diff --git a/swagger/docs.go b/swagger/docs.go index d5102cfb28..8719ef4abc 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -4503,6 +4503,23 @@ const docTemplate = `{ } } }, + "/api/chats/agents/builtin-grants": { + "get": { + "description": "Each grant has a name (the value for builtins in an agent), a title, a description and sensitive. A sensitive grant changes what the agent may do or reach later (profile, skills, pages, dashboard, new channels, actions of unattended runs); none is on by default. A chat of the agent holds only the grants you may use at that moment.", + "tags": [ + "chats" + ], + "summary": "List the builtin grants of a saved agent.", + "responses": { + "200": { + "description": "The grants, in display order", + "schema": { + "$ref": "#/definitions/schema.ChatsBuiltinGrantList" + } + } + } + } + }, "/api/chats/agents/import": { "post": { "description": "Send the export file (version 1, 2 for an agent with a folder, or 3 for an agent with several) as the JSON body, or as the file field of a multipart form. A bare agent definition (the body of POST /api/chats/agents) is accepted too. The agent is always new: a name you already use gets a number, and a model this server does not have, or you may not use, is dropped with a warning. A folder is looked up by its path in your files; when you have no such folder the agent keeps the mode without a folder (folder state unset) and does not start until you choose one, and a warning says so.", @@ -4696,7 +4713,7 @@ const docTemplate = `{ }, "/api/chats/agents/{id}/export": { "get": { - "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", + "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. The builtins list (builtin grants) is an extra field of any version: a server that does not know it ignores it, so the agent comes back without the grants. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", "tags": [ "chats" ], @@ -18846,6 +18863,13 @@ const docTemplate = `{ "type": "string" } }, + "builtins": { + "description": "Builtins are the builtin capabilities granted to the agent, by the names\nthat GET /api/chats/agents/builtin-grants lists (memory.write,\nskills.write and so on). Empty grants none. A chat of the agent holds\nthe grants you may use at that moment.", + "type": "array", + "items": { + "type": "string" + } + }, "builtins_off": { "description": "BuiltinsOff are the groups of built-in tools (web, files, triggers...)\nthe agent's chats never have, and BuiltinsOn the opt-in groups (saved\nagents) they start with. The names are the ones GET /api/chats/{id}/tools\nlists. Empty for an agent that has every group a chat has by default.", "type": "array", @@ -19117,6 +19141,13 @@ const docTemplate = `{ "type": "string" } }, + "builtins": { + "description": "Builtins are the builtin grants of the agent (see ChatsAgent). A write\nwithout them keeps the stored ones; an empty list clears them; a name\nthe server does not declare is a 400, except in an import, which drops it.", + "type": "array", + "items": { + "type": "string" + } + }, "builtins_off": { "description": "BuiltinsOff and BuiltinsOn are the switches of the built-in tools of\nthe agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen\nthe agent); an empty list clears them.", "type": "array", @@ -19222,6 +19253,37 @@ const docTemplate = `{ } } }, + "schema.ChatsBuiltinGrant": { + "type": "object", + "properties": { + "description": { + "description": "Description says what the agent can do with the grant, in English.", + "type": "string" + }, + "name": { + "description": "Name is the value to put in an agent's builtins list.", + "type": "string" + }, + "sensitive": { + "description": "Sensitive grants change what the agent may do or reach later. They are\nnever on by default and the editor marks them.", + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, + "schema.ChatsBuiltinGrantList": { + "type": "object", + "properties": { + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsBuiltinGrant" + } + } + } + }, "schema.ChatsBuiltinGroup": { "type": "object", "properties": { diff --git a/swagger/swagger.json b/swagger/swagger.json index c74472be36..99e727f886 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -4500,6 +4500,23 @@ } } }, + "/api/chats/agents/builtin-grants": { + "get": { + "description": "Each grant has a name (the value for builtins in an agent), a title, a description and sensitive. A sensitive grant changes what the agent may do or reach later (profile, skills, pages, dashboard, new channels, actions of unattended runs); none is on by default. A chat of the agent holds only the grants you may use at that moment.", + "tags": [ + "chats" + ], + "summary": "List the builtin grants of a saved agent.", + "responses": { + "200": { + "description": "The grants, in display order", + "schema": { + "$ref": "#/definitions/schema.ChatsBuiltinGrantList" + } + } + } + } + }, "/api/chats/agents/import": { "post": { "description": "Send the export file (version 1, 2 for an agent with a folder, or 3 for an agent with several) as the JSON body, or as the file field of a multipart form. A bare agent definition (the body of POST /api/chats/agents) is accepted too. The agent is always new: a name you already use gets a number, and a model this server does not have, or you may not use, is dropped with a warning. A folder is looked up by its path in your files; when you have no such folder the agent keeps the mode without a folder (folder state unset) and does not start until you choose one, and a warning says so.", @@ -4693,7 +4710,7 @@ }, "/api/chats/agents/{id}/export": { "get": { - "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", + "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and carries folders [{path, mode}], the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder or switches is version 1. The builtins list (builtin grants) is an extra field of any version: a server that does not know it ignores it, so the agent comes back without the grants. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", "tags": [ "chats" ], @@ -18843,6 +18860,13 @@ "type": "string" } }, + "builtins": { + "description": "Builtins are the builtin capabilities granted to the agent, by the names\nthat GET /api/chats/agents/builtin-grants lists (memory.write,\nskills.write and so on). Empty grants none. A chat of the agent holds\nthe grants you may use at that moment.", + "type": "array", + "items": { + "type": "string" + } + }, "builtins_off": { "description": "BuiltinsOff are the groups of built-in tools (web, files, triggers...)\nthe agent's chats never have, and BuiltinsOn the opt-in groups (saved\nagents) they start with. The names are the ones GET /api/chats/{id}/tools\nlists. Empty for an agent that has every group a chat has by default.", "type": "array", @@ -19114,6 +19138,13 @@ "type": "string" } }, + "builtins": { + "description": "Builtins are the builtin grants of the agent (see ChatsAgent). A write\nwithout them keeps the stored ones; an empty list clears them; a name\nthe server does not declare is a 400, except in an import, which drops it.", + "type": "array", + "items": { + "type": "string" + } + }, "builtins_off": { "description": "BuiltinsOff and BuiltinsOn are the switches of the built-in tools of\nthe agent's chats (see ChatsAgent). A write without them keeps the stored ones (an older client cannot widen\nthe agent); an empty list clears them.", "type": "array", @@ -19219,6 +19250,37 @@ } } }, + "schema.ChatsBuiltinGrant": { + "type": "object", + "properties": { + "description": { + "description": "Description says what the agent can do with the grant, in English.", + "type": "string" + }, + "name": { + "description": "Name is the value to put in an agent's builtins list.", + "type": "string" + }, + "sensitive": { + "description": "Sensitive grants change what the agent may do or reach later. They are\nnever on by default and the editor marks them.", + "type": "boolean" + }, + "title": { + "type": "string" + } + } + }, + "schema.ChatsBuiltinGrantList": { + "type": "object", + "properties": { + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsBuiltinGrant" + } + } + } + }, "schema.ChatsBuiltinGroup": { "type": "object", "properties": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 4dec9eae3d..a2ee1e2805 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -3210,6 +3210,15 @@ definitions: items: type: string type: array + builtins: + description: |- + Builtins are the builtin capabilities granted to the agent, by the names + that GET /api/chats/agents/builtin-grants lists (memory.write, + skills.write and so on). Empty grants none. A chat of the agent holds + the grants you may use at that moment. + items: + type: string + type: array builtins_off: description: |- BuiltinsOff are the groups of built-in tools (web, files, triggers...) @@ -3427,6 +3436,14 @@ definitions: items: type: string type: array + builtins: + description: |- + Builtins are the builtin grants of the agent (see ChatsAgent). A write + without them keeps the stored ones; an empty list clears them; a name + the server does not declare is a 400, except in an import, which drops it. + items: + type: string + type: array builtins_off: description: |- BuiltinsOff and BuiltinsOn are the switches of the built-in tools of @@ -3514,6 +3531,29 @@ definitions: skipped: type: integer type: object + schema.ChatsBuiltinGrant: + properties: + description: + description: Description says what the agent can do with the grant, in English. + type: string + name: + description: Name is the value to put in an agent's builtins list. + type: string + sensitive: + description: |- + Sensitive grants change what the agent may do or reach later. They are + never on by default and the editor marks them. + type: boolean + title: + type: string + type: object + schema.ChatsBuiltinGrantList: + properties: + grants: + items: + $ref: '#/definitions/schema.ChatsBuiltinGrant' + type: array + type: object schema.ChatsBuiltinGroup: properties: description: @@ -14990,16 +15030,18 @@ paths: - chats /api/chats/agents/{id}/export: get: - description: Returns the definition as a JSON file (version, kind localai-agent, + description: 'Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with one folder is version 2 and carries folder {path, mode}; an agent with several is version 3 and - carries folders [{path, mode}], the folder's home-relative path now (empty + carries folders [{path, mode}], the folder''s home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent with switches of built-in tool groups (builtins_off, builtins_on) is version 4. An agent without a folder - or switches is version 1. POST /api/chats/agents/import reads it. This replaces - the old /api/agents/{name}/export. + or switches is version 1. The builtins list (builtin grants) is an extra field + of any version: a server that does not know it ignores it, so the agent comes + back without the grants. POST /api/chats/agents/import reads it. This replaces + the old /api/agents/{name}/export.' parameters: - description: the agent's id in: path @@ -15037,6 +15079,21 @@ paths: summary: Show what an agent has run. tags: - chats + /api/chats/agents/builtin-grants: + get: + description: Each grant has a name (the value for builtins in an agent), a title, + a description and sensitive. A sensitive grant changes what the agent may + do or reach later (profile, skills, pages, dashboard, new channels, actions + of unattended runs); none is on by default. A chat of the agent holds only + the grants you may use at that moment. + responses: + "200": + description: The grants, in display order + schema: + $ref: '#/definitions/schema.ChatsBuiltinGrantList' + summary: List the builtin grants of a saved agent. + tags: + - chats /api/chats/agents/import: post: consumes: