From 373210c2a5dac4d24f92887e560ab2c0b82aeef6 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Sun, 25 Oct 2026 09:43:00 +0100 Subject: [PATCH] Make the skill runbook limits a general setting The longest runbook a skill may save and how much of it load_skill hands back are now set on Settings > General (skill_body_max_chars, skill_body_shown_chars), defaulting to AppData.SkillBodyMaxChars and SkillBodyShownChars. Saving, confirming and proposing a skill check the install's limit; a skill saved before it was lowered can still be toggled. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- src/bots-queue/run-bot-reply.ts | 2 + src/bots-queue/run-bot-task.ts | 2 + src/data/app-data.ts | 6 +- src/dict/skill-dict.ts | 8 + .../db/app-settings/grab-skill-limits.ts | 53 +++++++ .../db/extensions/assert-skill-body-fits.ts | 20 +++ .../db/extensions/grab-skills-overview.ts | 5 +- .../backend/db/extensions/save-skill.ts | 10 +- .../bot/extensions/propose-skill-tool.ts | 5 +- .../tools/skills/open-bot-skills.test.ts | 29 ++-- src/harness/tools/skills/open-bot-skills.ts | 12 +- .../(functions)/grab-settings-server-props.ts | 2 + .../general/(partials)/skill-limits-form.tsx | 137 ++++++++++++++++++ .../(sections)/skill-limits-section.tsx | 25 ++++ src/pages/admin/settings/general/index.tsx | 2 + .../skills/(partials)/skill-modal.tsx | 11 +- .../settings/skills/(partials)/skill-row.tsx | 7 +- .../(sections)/skills-settings-section.tsx | 2 + src/pages/api/admin/settings/skill-limits.ts | 57 ++++++++ src/schemas/extension-schema.ts | 15 +- src/types/index.ts | 35 +++++ 22 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 src/functions/backend/db/app-settings/grab-skill-limits.ts create mode 100644 src/functions/backend/db/extensions/assert-skill-body-fits.ts create mode 100644 src/pages/admin/settings/general/(partials)/skill-limits-form.tsx create mode 100644 src/pages/admin/settings/general/(sections)/skill-limits-section.tsx create mode 100644 src/pages/api/admin/settings/skill-limits.ts diff --git a/AGENTS.md b/AGENTS.md index 0e1a210..da8e818 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,7 +201,7 @@ Every folder on the data volume, browsable read-only: a bot's own, a workspace's - **Tools:** `HarnessTool` (built per run with its bot bound): own tools validate with a zod `schema`; tools described elsewhere pass a JSON schema as `parameters` and validate themselves. `run-tool-call.ts` reports bad calls and failures back to the model as `Error: ...`, and caps every tool call at `AppData.HarnessToolTimeoutMs` (aborted, its result handed back as an error the model can work around). Bot tools in `src/harness/tools/storage/`: `list_files`, `read_file`, `write_file`, `search_files`, `move_file`, `delete_file`, `ingest_file`, `copy_file`, `generate_image`, `make_pdf` (`grabBotFileTools`, every run, see Bot files and folders). Bot tools in `src/harness/tools/bot/`: `update_memory`, `update_instructions` (`grabBotTools`, every run), `start_task` and, for the coordinator with workers, `delegate_task` (`grabBotChatTools`, chat replies only; a task handed over in the coordinator's own chat runs in the worker's own chat unless it names a `workspace_id`, which puts it in that workspace: the worker's workspace instructions and memory, the workspace's context, files and computers apply, and it needs membership, added first via `addBotToWorkspace` when missing; handed over from inside a workspace it always runs there and a different `workspace_id` is refused; the job's `requested_by_bot_id` is the coordinator, and `relayJobToRequester` posts the worker's result or failure in its conversation, pushed as `relay` by `pushBotNotice`), `ask_user_to_take_over` (see Take-over), plus for the coordinator `create_workspace` and `update_workspace` (a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace), `create_bot` (add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat, `add_bot_to_workspace` (put an existing worker in one of the user's workspaces) and `remove_bot_from_workspace` (take it out, keeping its conversation there), `assign_computer` (grant or revoke a worker's direct access, `add/remove-bot-to/from-computer`; access through a workspace stays and the result says so), `update_bot` (change a worker's name, role or core instructions, `src/functions/backend/db/bots/update-bot.ts`), `find_bot` and `find_workspace` (look the crew, or the live workspaces, up by id, name, role/description or instructions/context), `delete_bot` (a worker with its conversations, tasks, schedules and workspace places; the coordinator can't go) and `delete_workspace` (its conversations, tasks, schedules and links; its bots and computers stay). In its own chat the coordinator also keeps the install's extensions (`grabCoordinatorExtensionTools`, `src/harness/tools/bot/extensions/`, super admin runs only, each call checked again by `assertSuperAdminRun`): `list_extensions` (every skill, MCP server and custom tool as a line with who gets it, or one in full; literal header and env values show as `(set)`, `describeTemplateMap`, and secret env values are redacted), `delete_extension`, `set_extension_access` (the complete bot and workspace lists, an omitted one kept), `update_skill` (on/off, all_bots) and `update_mcp_server` / `update_custom_tool` for what can't change where anything is sent (name, description, left-out tools, arguments, on/off, all_bots; a tool can be tried with `test_args`). Adding a skill, MCP server or tool, or changing a skill's name, slug, description or runbook, a server's URL or headers or a tool's kind or config, is never done by a bot: `propose_skill` / `propose_mcp_server` / `propose_custom_tool` store it in `extension_proposals` (`src/functions/backend/db/extension-proposals/`, pending for `AppData.ExtensionProposalTtlMs`; a new proposal for the same one in the same session marks the older `replaced`) with the bot's background check (`research` plus http(s) `sources`, both required; for a skill its review of the runbook, sources optional), and the reply carries `[proposal:ID]` (`src/utils/proposal-ref.ts`, linked by `linkComputerRefs`, added by `withTakeOverRefs` if the bot left it out, flagging needs-you). Its chip (`(partials)/proposal-ref-chip.tsx`) opens `proposal-card.tsx`, where everything above the background check is worked out by the app from the config Confirm would save (`grabExtensionProposalView`: the host, `literalUrlHost`; every `{{env.KEY}}` and where it goes, `extensionEnvUses`; whether each is saved and in which scope, `grabProposalEnvs`; the details, `describeProposalDetails`; for a skill what its runbook points bots at, links, hosts, tool names and anything about secrets, `findSkillMentions`, and what changed against the saved one, `diffLines`, `describeSkillProposal`), never from what the bot says. Confirm and Reject (`api/admin/extension-proposals/confirm-proposal`, `reject-proposal`, `get-proposal`, super admin only, `window.confirm` first) decide it once (`claimExtensionProposal`, pending and unexpired only), and confirming checks it again (`checkProposalData`: remote servers only, a URL whose host no template decides, no envs in a prompt tool) before saving it with the settings pages' own helpers, switched off while a key it sends is missing (the outcome names the keys to add), and connecting a server that's on. The card then sends the outcome to the bot as the user's message (`proposalDecisionMessage`). Bots never set envs or ask for their values in chat. Stdio MCP servers are never added or repointed by a bot: they run a command in the app's own container, which holds the docker socket. A skill goes through the card because its runbook steers every bot that loads it: a step telling bots to upload files or read `~/.cobble/env` sends nothing itself but has them do it with the tools they have. `search_computers` (`src/harness/tools/computer/search-computers-tool.ts`, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (`run-computer-command.ts`), each output under its `[computer:ID]`, secrets redacted, capped by `AppData.ComputerSearch*`. - **MCP:** `src/harness/mcp/`: `connectMcpServer` (Streamable HTTP, SSE fallback), `grabMcpTools` (each server tool becomes a `HarnessTool` with its JSON schema), `mcpResultToText` (text as is, images read by the vision harness, `isError` thrown). Computers are reached this way: `openComputerTools` (`src/harness/tools/computer/`) connects to the computer's AIO sandbox at `grabComputerMcpUrl` (the `MAIN_COMPUTER_URL` app setting for the main computer, for a dev server outside docker, e.g. `http://localhost:8170`, else `http://:/mcp`, or its IP address when the hostname can't be reached), leaves out `ComputerMcpExcludedTools`, and sets the computer's envs (see Envs). - **MCPs and custom tools (extensions):** installs add their own MCP servers (`mcp_servers`, Settings, under MCPs) and tools (`custom_tools`, Settings, under Tools). A bot gets the ones given to it (`bot_mcp_servers`, `bot_custom_tools`) and, in a workspace, that workspace's (`workspace_mcp_servers`, `workspace_custom_tools`); `botExtensionsSql` (`src/utils/bot-extensions-sql.ts`, tested) resolves a run's, `grabBotExtensions` reads them. `all_bots` ("every bot by default") only decides what bots are given: turning it on gives it to every bot (`attachToAllBots`, the coordinator included) and every new bot gets it (`attachDefaultExtensions`, from `createBot` and `ensureCoordinatorBot`); turning it off takes it from no one, and a bot that drops one keeps it dropped (`src/scripts/attach-every-bot-extensions.ts`, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator, `assertBotExtensionManager`) give and take them per bot on the bot's settings page and under its chat's composer (`composer-tools.tsx`: one "N tools" button (`composer-tools-modal.tsx`) counting everything it can use there, which opens a modal listing them (a workspace's not removable) with the add picker, so ten of them don't fill the composer; beside it `composer-computer-button.tsx` opens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages share `settings/(partials)/extensions-group.tsx`, all through `useExtensions` (`src/hooks/`), `grabBotExtensionsOverview` / `grabWorkspaceExtensionsOverview` (`bot_extensions` / `workspace_extensions` page props, both on `grabExtensionsOverview`) and `api/admin/bots/` and `api/admin/workspaces/` `add-extension`, `remove-extension` (`setBotExtension`, `setWorkspaceExtension`); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (`setExtensionAccess`, an admin never touches the coordinator's or `all_bots`). Helpers in `src/functions/backend/db/extensions/`, API in `api/admin/mcps/` and `api/admin/tools/`, zod in `src/schemas/extension-schema.ts`. - - **Skills** are the third extension kind (`skills`, Settings, under Skills; `bot_skills` / `workspace_skills`, same `all_bots` rules, `ExtensionTables.kind = "skill"`). A skill is a runbook: `name`, a unique `slug` a bot loads it by, a one-line `description` (when it applies) and the markdown `body`, capped by `AppData.SkillBodyMaxChars`. Unlike an MCP server or a tool it has no `status`/`last_error`: nothing connects or runs, and it never reports a failure (`ExtensionRow` and `ComposerToolRow` key the state line off `kind == "skill"`). A run sees only the slug and description; `openBotSkills` (`src/harness/tools/skills/`, fed by the `skills` that `grabBotExtensions` already reads) adds one `load_skill` tool that returns the body, cut at `AppData.SkillBodyShownChars` with a line saying so, and throws the valid slugs for an unknown one. Slugs are checked by `checkSkillSlug` (`src/utils/check-skill-slug.ts`, tested); `load_skill` is in `ReservedToolNames` so a custom tool can't shadow it. The settings page is `api/admin/skills/` (`save-skill`, `delete-skill` super admin only, `set-skill-access`), `grabSkillsOverview`; `deleteSkill` deletes the two link tables itself, since foreign keys are off outside the server process. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (the bot's own beat the workspace's, which beat general; app settings never, no need to be set on computers); secret values are redacted from every result. + - **Skills** are the third extension kind (`skills`, Settings, under Skills; `bot_skills` / `workspace_skills`, same `all_bots` rules, `ExtensionTables.kind = "skill"`). A skill is a runbook: `name`, a unique `slug` a bot loads it by, a one-line `description` (when it applies) and the markdown `body`, capped by the install's `skill_body_max_chars` app setting (Settings > General, `grabSkillBodyMaxChars` in `app-settings/grab-skill-limits.ts`, default `AppData.SkillBodyMaxChars`, never past `AppData.SkillBodyLimitMax`; the request schemas hold only that ceiling, and `assertSkillBodyFits` checks the limit in `saveSkill` and `propose_skill` for a new or changed runbook only, so a skill saved before the limit was lowered can still be toggled). Unlike an MCP server or a tool it has no `status`/`last_error`: nothing connects or runs, and it never reports a failure (`ExtensionRow` and `ComposerToolRow` key the state line off `kind == "skill"`). A run sees only the slug and description; `openBotSkills` (`src/harness/tools/skills/`, fed by the `skills` that `grabBotExtensions` already reads) adds one `load_skill` tool that returns the body, cut at the `skill_body_shown_chars` app setting (`grabSkillBodyShownChars`, default `AppData.SkillBodyShownChars`, passed in as `shown_chars` by both runners) with a line saying so, and throws the valid slugs for an unknown one. Slugs are checked by `checkSkillSlug` (`src/utils/check-skill-slug.ts`, tested); `load_skill` is in `ReservedToolNames` so a custom tool can't shadow it. The settings page is `api/admin/skills/` (`save-skill`, `delete-skill` super admin only, `set-skill-access`), `grabSkillsOverview`; `deleteSkill` deletes the two link tables itself, since foreign keys are off outside the server process. `openBotExtensions` (`src/harness/tools/extensions/`) turns them into harness tools for chat replies and tasks (built-ins win a name clash; `close()` in the runners' `finally`; `note` tells the bot about a server it can't use). Templates (`fillTemplate`, `src/utils/fill-template.ts`): `{{args.x}}` and `{{env.KEY}}`, envs from `grabRunEnvs` (the bot's own beat the workspace's, which beat general; app settings never, no need to be set on computers); secret values are redacted from every result. - **Custom tools** (`CustomToolKinds` in `src/dict/custom-tool-dict.ts`): a name (`checkCustomToolName`: snake_case, never a built-in's, `ReservedToolNames`), a description, a JSON-schema `parameters` and a `config` per kind, built by `buildCustomTool` (`src/harness/tools/custom/`): `http` (`runHttpTool`: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON), `script` (`runScriptTool`: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON in `COBBLE_ARGS` and on stdin; left out without a computer), `prompt` (`runPromptTool`: one `generateText` turn with the bot's models). Super admins try one from the page (`testCustomTool`, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (`draft`, nothing recorded), from the Test panel in its modal (`custom-tool-test-panel.tsx`). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool's `status`, `last_error` and `last_checked_at` (`recordCustomToolRun`); editing resets it to `untested`. - **MCP servers** (`McpTransports` in `src/dict/mcp-dict.ts`): `remote` (URL plus templated headers, `connectMcpServer`) or `stdio` (a command run with the host's shell, `hostShell`, in `DATA_DIR/mcp/` (`grabMcpDir`), after its `setup_command`, which `runMcpSetup` reruns whenever it changes (marker file) or on Reinstall; its env is only its own `env` map plus the shell basics, `grabMcpStdioEnv`, never the app's). Their tools are listed into `tools_cache` (`syncMcpServer`: on save, Refresh, and hourly for remote ones by the `sync-mcp-servers` cron) and named `__` (`mcpToolName`); the modal's Test connection checks the form as it is, saved or not, outside the pool (`testMcpDraft`, `testMcpServer`, `api/admin/mcps/test-mcp`, a new stdio server's setup runs in `mcp/0`); a run that can't connect, or connects again after failing, updates its `status` too (`recordMcpServerConnection`); runs build tools from the cache (`mcpHarnessTool`) and connect on the first call (`openMcpServer`). stdio servers live in a per-process pool (`src/harness/mcp/mcp-stdio-pool.ts`), stopped after `AppData.McpStdioIdleMs` idle, on save or delete. - **Check before and after acting:** every reply and task gets `ActionCheckNote` (`src/data/harness-prompts.ts`): look for an existing record before creating one (update it instead), and verify every action took effect before saying it's done. The tools back it: `update_memory` and `update_instructions` read the save back (`checkSavedText`, `grabBotSavedText`) and fail if it didn't take; `create_bot` refuses a name already on the crew, `create_schedule` a twin of one of the bot's schedules (`findDuplicateCronJob`: same place and same title, or same timing and brief), and `start_task`/`delegate_task` a task with the same title still open in that session (`grabOpenBotTask`); `create_workspace` already refuses a name in use. @@ -232,7 +232,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - Routines, Files, Usage, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings. A page carries `super_admin_only` or a `can_view` rule (Usage is `canManageCrew`), and `canSeeRailItem` (`src/utils/rbac/can-see-rail-item.ts`, tested) decides both the popover and the aside, so a page is never offered in one and hidden in the other - `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import `, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer workspaces, bot and workspace files, MCP installs; an older computer's moved-aside `computers/-home-` is left out) into `BACKUP_ARCHIVE_DIR` as `cobble-data-.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. A whole-data archive or restore takes far longer than a request may stay silent (Bun's default `idleTimeout` is 10s, a proxy cuts at about 100s), so its route only starts it (`startDataBackupRun`, `data-backup-run.ts`, which refuses while anything holds the lock and holds it for the run, so the writer and the restore take no lock of their own) and answers with the run; the page follows it (`use-data-backup-run`, `DataBackupRunContext`) by polling `api/admin/backups/data-status`, reloading once an archive is done, and after a restore once the status comes from a new process (`boot_id`). The upload is still saved within its request. Database snapshots stay synchronous. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/` - `/admin/settings/database` - super admin only (listed in `SettingsPages`), a read-only browser of the install's SQLite database, no writes anywhere in it. `grabDatabaseTables` reads the catalog once (every table and its columns, SQLite's own `sqlite_*` tables and sqlite-vec's `bot_file_vectors_*` shadow tables left out) and adds each table's `COUNT(*)` and its size from `dbstat` (index pages folded back onto their table). `grabTableRecords` then returns one page (`AppData.DatabaseBrowserPageSize`, 50) of the open table's records newest key first, every value already cut in SQL at `AppData.DatabaseBrowserCellMaxChars` (400) so a table of long texts never ships a megabyte of JSON, and a `truncated` flag per cell; the search matches over the concatenation of every column's text (`buildSearchClause`: every word, or the whole phrase), which is what also works on a virtual table, where `MATCH` and `LIKE` over a vector blob do not. A record's key is its single primary key column, else its rowid, else nothing (a virtual table with no primary key reads as plain cells); clicking a cell's row opens `grabTableRecord` for that key, which returns the record whole up to `AppData.DatabaseBrowserValueMaxChars` (20000), the answer to a cell cut at three lines. `clampPage` puts a page past the end on the last one. The page's state (table, search, mode, page) lives in the address through `databaseHref`, so a table or a search can be linked to; the open table's first page comes with the page props and anything else loads through the API. Helpers in `src/functions/backend/database/` (`build-table-catalog-sql`, `build-table-sizes-sql`, `build-records-sql`, `build-search-clause`, `resolve-table-key`, `build-record-row`, `quote-sql-identifier`), API `api/admin/database/records` and `record` (GET only, `grabSuperAdminUser`), zod in `src/schemas/database-schema.ts`, page under `src/pages/admin/settings/database/` -- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column that opens with the breadcrumb trail, `src/components/general/admin-breadcrumbs.tsx`: twui's `Breadcrumbs` reading `Home`, the sections and the page off `pageProps.url.pathname`, the last crumb named by the page's own `title` so a record reads `Scout` rather than `3` and `/admin/logs` reads `Activity`; `hasBreadcrumbTrail` drops it only on the crew of bots, which is home, and nothing outside `/admin` gets one). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry +- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default), and the skill limits (`skill-limits-section.tsx`, `skill-limits-form.tsx`) saving `api/admin/settings/skill-limits` into the `skill_body_max_chars` and `skill_body_shown_chars` app settings (`grabSkillLimitsOverview`, `skill_limits` page prop and `SkillsOverviewPayload.limits` for the skill form's hint, 0 clearing one back to its default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column that opens with the breadcrumb trail, `src/components/general/admin-breadcrumbs.tsx`: twui's `Breadcrumbs` reading `Home`, the sections and the page off `pageProps.url.pathname`, the last crumb named by the page's own `title` so a record reads `Scout` rather than `3` and `/admin/logs` reads `Activity`; `hasBreadcrumbTrail` drops it only on the crew of bots, which is home, and nothing outside `/admin` gets one). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). diff --git a/src/bots-queue/run-bot-reply.ts b/src/bots-queue/run-bot-reply.ts index d6fbf51..ba0c587 100644 --- a/src/bots-queue/run-bot-reply.ts +++ b/src/bots-queue/run-bot-reply.ts @@ -1,4 +1,5 @@ import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; +import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings/grab-skill-limits"; import { AppData } from "@/src/data/app-data"; import { ActionCheckNote, @@ -204,6 +205,7 @@ export default async function runBotReply({ */ const skills = openBotSkills({ skills: extensions.skills, + shown_chars: await grabSkillBodyShownChars(), taken: [...tools, ...extensions.tools].map((tool) => tool.name), }); diff --git a/src/bots-queue/run-bot-task.ts b/src/bots-queue/run-bot-task.ts index 15535c3..465b5f0 100644 --- a/src/bots-queue/run-bot-task.ts +++ b/src/bots-queue/run-bot-task.ts @@ -1,4 +1,5 @@ import grabBotRunPowers from "@/src/functions/backend/db/bot-types/grab-bot-run-powers"; +import { grabSkillBodyShownChars } from "@/src/functions/backend/db/app-settings/grab-skill-limits"; import grabWorkspaceAdminTools from "@/src/harness/tools/bot/grab-workspace-admin-tools"; import workspaceAdminsNote from "./workspace-admins-note"; import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db"; @@ -207,6 +208,7 @@ export default async function runBotTask({ skills = openBotSkills({ skills: extensions.skills, + shown_chars: await grabSkillBodyShownChars(), taken: [...tools, ...extensions.tools].map((tool) => tool.name), }); diff --git a/src/data/app-data.ts b/src/data/app-data.ts index 0e16e8f..20b9887 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -309,10 +309,14 @@ export const AppData = { /** * Skills (Settings, under Skills): how long one's body may be, and how * much of it `load_skill` hands back in one call. A runbook past that - * is worth splitting into two skills. + * is worth splitting into two skills. These are the defaults: the + * install sets its own on Settings > General (`skill_body_max_chars`, + * `skill_body_shown_chars`, `grab-skill-limits.ts`), never past + * `SkillBodyLimitMax`. */ SkillBodyMaxChars: 60000, SkillBodyShownChars: 30000, + SkillBodyLimitMax: 1000000, SkillDescriptionMaxChars: 300, /** * How long the super admin has to confirm an MCP server or tool the diff --git a/src/dict/skill-dict.ts b/src/dict/skill-dict.ts index 5aec266..3c69538 100644 --- a/src/dict/skill-dict.ts +++ b/src/dict/skill-dict.ts @@ -14,3 +14,11 @@ export const LoadSkillToolName = "load_skill"; * The slug format, for the settings form and its hint. */ export const SkillSlugPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +/** + * The `app_settings` keys for the longest runbook a skill may save, and + * how much of it `load_skill` hands back at once (Settings > General). + * Unset, `AppData.SkillBodyMaxChars` and `AppData.SkillBodyShownChars`. + */ +export const SkillBodyMaxCharsSettingKey = "skill_body_max_chars"; +export const SkillBodyShownCharsSettingKey = "skill_body_shown_chars"; diff --git a/src/functions/backend/db/app-settings/grab-skill-limits.ts b/src/functions/backend/db/app-settings/grab-skill-limits.ts new file mode 100644 index 0000000..a59f0dc --- /dev/null +++ b/src/functions/backend/db/app-settings/grab-skill-limits.ts @@ -0,0 +1,53 @@ +import { AppData } from "@/src/data/app-data"; +import { SkillBodyMaxCharsSettingKey, SkillBodyShownCharsSettingKey } from "@/src/dict/skill-dict"; +import readStepLimit from "@/src/utils/read-step-limit"; +import type { SkillLimitsOverview } from "@/src/types"; +import grabAppSetting from "./grab-app-setting"; + +/** + * The longest runbook a skill may save: the install's own limit + * (`skill_body_max_chars`, Settings > General), else + * `AppData.SkillBodyMaxChars`. + */ +export async function grabSkillBodyMaxChars() { + return readStepLimit({ + saved: await grabAppSetting({ key: SkillBodyMaxCharsSettingKey }), + fallback: AppData.SkillBodyMaxChars, + max: AppData.SkillBodyLimitMax, + }); +} + +/** + * How much of a runbook `load_skill` hands back in one call: the install's + * own limit (`skill_body_shown_chars`, Settings > General), else + * `AppData.SkillBodyShownChars`. + */ +export async function grabSkillBodyShownChars() { + return readStepLimit({ + saved: await grabAppSetting({ key: SkillBodyShownCharsSettingKey }), + fallback: AppData.SkillBodyShownChars, + max: AppData.SkillBodyLimitMax, + }); +} + +/** + * Both limits with their defaults, for the settings pages. + */ +export default async function grabSkillLimitsOverview() { + const [body_max_chars, body_shown_chars] = await Promise.all([ + grabSkillBodyMaxChars(), + grabSkillBodyShownChars(), + ]); + + const overview: SkillLimitsOverview = { + body_max_chars, + body_shown_chars, + body_max_default: AppData.SkillBodyMaxChars, + body_shown_default: AppData.SkillBodyShownChars, + max: AppData.SkillBodyLimitMax, + }; + + return overview; +} + +export type { SkillLimitsOverview }; diff --git a/src/functions/backend/db/extensions/assert-skill-body-fits.ts b/src/functions/backend/db/extensions/assert-skill-body-fits.ts new file mode 100644 index 0000000..623e9be --- /dev/null +++ b/src/functions/backend/db/extensions/assert-skill-body-fits.ts @@ -0,0 +1,20 @@ +import { grabSkillBodyMaxChars } from "../app-settings/grab-skill-limits"; +import AppError from "@/src/utils/app-error"; + +type Params = { + body: string; +}; + +/** + * Refuses a runbook longer than the install's limit (Settings > General). + * The request schemas only hold the ceiling, since the limit is a setting. + */ +export default async function assertSkillBodyFits({ body }: Params) { + const max = await grabSkillBodyMaxChars(); + + if (body.length > max) { + throw new AppError( + `Keep the runbook under ${max} characters (it has ${body.length}). A super admin can raise the limit on Settings > General.`, + ); + } +} diff --git a/src/functions/backend/db/extensions/grab-skills-overview.ts b/src/functions/backend/db/extensions/grab-skills-overview.ts index dc56c97..9dd49e6 100644 --- a/src/functions/backend/db/extensions/grab-skills-overview.ts +++ b/src/functions/backend/db/extensions/grab-skills-overview.ts @@ -1,6 +1,7 @@ import BunSQLite from "@moduletrace/bun-sqlite"; import type { BUN_SQLITE_COBBLE_SKILLS } from "@/db/types/db"; import type { SkillsOverviewPayload } from "@/src/types"; +import grabSkillLimitsOverview from "../app-settings/grab-skill-limits"; import grabCrewRole from "../access/grab-crew-role"; import grabExtensionAccess from "./grab-extension-access"; import grabExtensionTargets from "./grab-extension-targets"; @@ -17,14 +18,16 @@ type Params = { */ export default async function grabSkillsOverview({ user_id }: Params): Promise { const role = await grabCrewRole({ user_id }); - const [rows, access, targets] = await Promise.all([ + const [rows, access, targets, limits] = await Promise.all([ BunSQLite.sql({ sql: `SELECT * FROM skills ORDER BY name COLLATE NOCASE ASC, id ASC` }), grabExtensionAccess({ kind: "skill" }), grabExtensionTargets({ role }), + grabSkillLimitsOverview(), ]); return { ...targets, + limits, skills: (rows.payload || []).map((row) => { const { all_bots, ...skill } = parseSkillRow({ row }); diff --git a/src/functions/backend/db/extensions/save-skill.ts b/src/functions/backend/db/extensions/save-skill.ts index e4a3341..4b6713f 100644 --- a/src/functions/backend/db/extensions/save-skill.ts +++ b/src/functions/backend/db/extensions/save-skill.ts @@ -3,6 +3,7 @@ import attachToAllBots from "./attach-to-all-bots"; import type { BUN_SQLITE_COBBLE_SKILLS, BunSQLiteTables } from "@/db/types/db"; import type { SaveSkillRequest } from "@/src/schemas/extension-schema"; import AppError from "@/src/utils/app-error"; +import assertSkillBodyFits from "./assert-skill-body-fits"; import grabSkill from "./grab-skill"; type Params = { @@ -15,7 +16,9 @@ type Params = { /** * Adds or edits a skill and returns its id. Slugs are unique: a bot loads a - * skill by its slug, so two skills can't answer to the same one. + * skill by its slug, so two skills can't answer to the same one. A new or + * changed runbook must fit the install's limit; one saved before the limit + * was lowered keeps saving while its runbook is left as it is. */ export default async function saveSkill({ user_id, data }: Params) { const clash = ( @@ -38,6 +41,9 @@ export default async function saveSkill({ user_id, data }: Params) { if (data.skill_id) { const skill = await grabSkill({ skill_id: data.skill_id }); + + if (data.body != skill.body) await assertSkillBodyFits({ body: data.body }); + const res = await BunSQLite.update({ table: "skills", targetId: skill.id, @@ -51,6 +57,8 @@ export default async function saveSkill({ user_id, data }: Params) { return skill.id; } + await assertSkillBodyFits({ body: data.body }); + const res = await BunSQLite.insert({ table: "skills", data: [{ ...fields, user_id: Number(user_id) }], diff --git a/src/harness/tools/bot/extensions/propose-skill-tool.ts b/src/harness/tools/bot/extensions/propose-skill-tool.ts index 1d05e18..b8be6a5 100644 --- a/src/harness/tools/bot/extensions/propose-skill-tool.ts +++ b/src/harness/tools/bot/extensions/propose-skill-tool.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { AppData } from "@/src/data/app-data"; import createExtensionProposal from "@/src/functions/backend/db/extension-proposals/create-extension-proposal"; import grabExtensionProposalView from "@/src/functions/backend/db/extension-proposals/grab-extension-proposal-view"; +import assertSkillBodyFits from "@/src/functions/backend/db/extensions/assert-skill-body-fits"; import grabSkill from "@/src/functions/backend/db/extensions/grab-skill"; import { SaveSkillRequestSchema } from "@/src/schemas/extension-schema"; import parseWithSchema from "@/src/utils/parse-with-schema"; @@ -51,7 +52,7 @@ export default function proposeSkillTool({ user_id, bot_id, session_id, workspac name: z.string().trim().max(80).optional(), slug: z.string().trim().max(80).optional(), description: z.string().trim().max(AppData.SkillDescriptionMaxChars).optional(), - body: z.string().max(AppData.SkillBodyMaxChars).optional().describe("The runbook, markdown"), + body: z.string().max(AppData.SkillBodyLimitMax).optional().describe("The runbook, markdown"), enabled: z.boolean().optional(), all_bots: z.boolean().optional(), ...SkillReviewFields, @@ -73,6 +74,8 @@ export default function proposeSkillTool({ user_id, bot_id, session_id, workspac }, }); + if (data.body != current?.body) await assertSkillBodyFits({ body: data.body }); + const id = await createExtensionProposal({ user_id, bot_id, diff --git a/src/harness/tools/skills/open-bot-skills.test.ts b/src/harness/tools/skills/open-bot-skills.test.ts index 8027c73..c009a90 100644 --- a/src/harness/tools/skills/open-bot-skills.test.ts +++ b/src/harness/tools/skills/open-bot-skills.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { AppData } from "@/src/data/app-data"; import type { SkillRecord } from "@/src/types"; import openBotSkills from "./open-bot-skills"; +const shown_chars = 30000; + function skill(overrides: Partial & { slug: string }): SkillRecord { return { id: 1, @@ -17,18 +18,18 @@ function skill(overrides: Partial & { slug: string }): SkillRecord describe("openBotSkills", () => { test("no skills means no tool and no note", () => { - expect(openBotSkills({ skills: [] })).toEqual({ tools: [], note: "" }); + expect(openBotSkills({ skills: [], shown_chars })).toEqual({ tools: [], note: "" }); }); test("a disabled skill is left out entirely", () => { - const opened = openBotSkills({ skills: [skill({ slug: "seo", enabled: false })] }); + const opened = openBotSkills({ skills: [skill({ slug: "seo", enabled: false })], shown_chars }); expect(opened.tools).toHaveLength(0); expect(opened.note).toBe(""); }); test("one tool, named load_skill, whose description lists the slugs", () => { - const opened = openBotSkills({ skills: [skill({ slug: "seo" }), skill({ slug: "inbox", id: 2 })] }); + const opened = openBotSkills({ skills: [skill({ slug: "seo" }), skill({ slug: "inbox", id: 2 })], shown_chars }); expect(opened.tools).toHaveLength(1); expect(opened.tools[0]!.name).toBe("load_skill"); @@ -38,21 +39,21 @@ describe("openBotSkills", () => { }); test("a built-in that took the name wins", () => { - expect(openBotSkills({ skills: [skill({ slug: "seo" })], taken: ["load_skill"] })).toEqual({ + expect(openBotSkills({ skills: [skill({ slug: "seo" })], taken: ["load_skill"], shown_chars })).toEqual({ tools: [], note: "", }); }); test("the tool returns the body of the slug it's given", async () => { - const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "1. Pull rankings" })] }); + const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "1. Pull rankings" })], shown_chars }); const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) }); expect(output).toBe("1. Pull rankings"); }); test("an unknown slug fails with the slugs it does have", async () => { - const opened = openBotSkills({ skills: [skill({ slug: "seo" })] }); + const opened = openBotSkills({ skills: [skill({ slug: "seo" })], shown_chars }); await expect(opened.tools[0]!.run!({ args: { slug: "nope" }, signal: AbortSignal.timeout(1000) })).rejects.toThrow( /no skill called "nope".*seo/, @@ -61,16 +62,24 @@ describe("openBotSkills", () => { test("a long body is cut at the limit and says so", async () => { const opened = openBotSkills({ - skills: [skill({ slug: "seo", body: "x".repeat(AppData.SkillBodyShownChars + 500) })], + skills: [skill({ slug: "seo", body: "x".repeat(shown_chars + 500) })], + shown_chars, }); const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) }); expect(output).toContain("Cut at"); - expect(output.length).toBeLessThan(AppData.SkillBodyShownChars + 200); + expect(output.length).toBeLessThan(shown_chars + 200); + }); + + test("the cut follows the install's limit", async () => { + const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "x".repeat(1500) })], shown_chars: 1000 }); + const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) }); + + expect(output).toContain("Cut at 1000 characters"); }); test("an empty body says it is empty rather than returning nothing", async () => { - const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "" })] }); + const opened = openBotSkills({ skills: [skill({ slug: "seo", body: "" })], shown_chars }); const output = await opened.tools[0]!.run!({ args: { slug: "seo" }, signal: AbortSignal.timeout(1000) }); expect(output).toContain("is empty"); diff --git a/src/harness/tools/skills/open-bot-skills.ts b/src/harness/tools/skills/open-bot-skills.ts index 3558dfb..431af20 100644 --- a/src/harness/tools/skills/open-bot-skills.ts +++ b/src/harness/tools/skills/open-bot-skills.ts @@ -1,5 +1,4 @@ import { z } from "zod"; -import { AppData } from "@/src/data/app-data"; import { SkillsNote } from "@/src/data/harness-prompts"; import { LoadSkillToolName } from "@/src/dict/skill-dict"; import type { SkillRecord } from "@/src/types"; @@ -10,6 +9,11 @@ type Params = { * The skills the run reaches (`grabBotExtensions`). */ skills: SkillRecord[]; + /** + * How much of a runbook `load_skill` hands back at once + * (`grabSkillBodyShownChars`). + */ + shown_chars: number; /** * Names the run's built-in tools already use: they win. */ @@ -30,7 +34,7 @@ export type OpenBotSkills = { * which ones exist. A skill's body costs nothing until the bot asks for it, * so a run that never needs a long runbook never pays for it. */ -export default function openBotSkills({ skills, taken = [] }: Params): OpenBotSkills { +export default function openBotSkills({ skills, shown_chars, taken = [] }: Params): OpenBotSkills { const enabled = skills.filter((skill) => skill.enabled); if (!enabled.length || taken.includes(LoadSkillToolName)) return { tools: [], note: "" }; @@ -57,9 +61,9 @@ export default function openBotSkills({ skills, taken = [] }: Params): OpenBotSk if (!body) return `The skill ${skill.name} (${skill.slug}) is empty.`; - if (body.length <= AppData.SkillBodyShownChars) return body; + if (body.length <= shown_chars) return body; - return `${body.slice(0, AppData.SkillBodyShownChars)}\n\n[Cut at ${AppData.SkillBodyShownChars} characters. The skill continues: ask the user to split it if you need the rest.]`; + return `${body.slice(0, shown_chars)}\n\n[Cut at ${shown_chars} characters. The skill continues: ask the user to split it if you need the rest.]`; }, }; diff --git a/src/pages/admin/settings/(functions)/grab-settings-server-props.ts b/src/pages/admin/settings/(functions)/grab-settings-server-props.ts index 00a5069..835958f 100644 --- a/src/pages/admin/settings/(functions)/grab-settings-server-props.ts +++ b/src/pages/admin/settings/(functions)/grab-settings-server-props.ts @@ -6,6 +6,7 @@ import grabUserPasswordHash from "@/src/functions/backend/auth/grab-user-passwor import type { PagePropsType } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; import grabRunLimitsOverview from "@/src/functions/backend/db/app-settings/grab-run-limits"; +import grabSkillLimitsOverview from "@/src/functions/backend/db/app-settings/grab-skill-limits"; import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview"; import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append"; import runningVersion from "@/src/utils/running-version"; @@ -47,6 +48,7 @@ export default async function grabSettingsServerProps({ req }: Params) { storage_quota: await grabStorageQuotaOverview().catch(() => null), system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null), run_limits: await grabRunLimitsOverview().catch(() => null), + skill_limits: await grabSkillLimitsOverview().catch(() => null), running_version: runningVersion({ env: process.env }), }, }; diff --git a/src/pages/admin/settings/general/(partials)/skill-limits-form.tsx b/src/pages/admin/settings/general/(partials)/skill-limits-form.tsx new file mode 100644 index 0000000..b82916f --- /dev/null +++ b/src/pages/admin/settings/general/(partials)/skill-limits-form.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import Form from "@moduletrace/twui/form/Form"; +import Input from "@moduletrace/twui/form/Input"; +import Stack from "@moduletrace/twui/layout/Stack"; +import type { SkillLimitsOverview } from "@/src/types"; +import SettingsSaveBar from "../../(partials)/settings-save-bar"; + +type Props = { + limits?: SkillLimitsOverview | null; +}; + +/** + * The longest runbook a skill may save and how much of it a bot reads at + * once, saved on its own without a reload. Zero puts the code's default + * back for that one. + */ +export default function SkillLimitsForm({ limits }: Props) { + const [overview, setOverview] = useState(limits || undefined); + const [dirty, setDirty] = useState(false); + const [body_max_chars, setBodyMaxChars] = useState(String(limits?.body_max_chars ?? "")); + const [body_shown_chars, setBodyShownChars] = useState(String(limits?.body_shown_chars ?? "")); + /** + * The inputs only read `defaultValue` when they mount, so discarding or + * saving remounts them with the saved numbers. + */ + const [form_key, setFormKey] = useState(0); + const [loading, setLoading] = useState(false); + const [status, setStatus] = useState<{ + msg?: string; + error?: boolean; + success?: boolean; + }>(); + + const save = async () => { + if (!window.confirm("Save the skill limits?")) return; + + setLoading(true); + setStatus(undefined); + + const res = await fetchApi<{}, APIResponseObject>( + "/api/admin/settings/skill-limits", + { + method: "POST", + body: { + body_max_chars: Number(body_max_chars) || 0, + body_shown_chars: Number(body_shown_chars) || 0, + }, + }, + ).catch(() => null); + + setLoading(false); + + if (!res?.success || !res.singleRes) { + setStatus({ + error: true, + msg: res?.msg || `Couldn't save the limits.`, + }); + return; + } + + setOverview(res.singleRes); + setBodyMaxChars(String(res.singleRes.body_max_chars)); + setBodyShownChars(String(res.singleRes.body_shown_chars)); + setFormKey((key) => key + 1); + setDirty(false); + setStatus({ + success: true, + msg: `Saved: runbooks up to ${res.singleRes.body_max_chars} characters, ${res.singleRes.body_shown_chars} read at once.`, + }); + }; + + const discard = () => { + setBodyMaxChars(String(overview?.body_max_chars ?? "")); + setBodyShownChars(String(overview?.body_shown_chars ?? "")); + setFormKey((key) => key + 1); + setDirty(false); + setStatus(undefined); + }; + + return ( +
{ + event.preventDefault(); + save().catch(() => {}); + }} + className="w-full gap-3! items-stretch" + > + + { + setBodyMaxChars(event.target.value); + setDirty(true); + setStatus(undefined); + }} + suffix={`characters`} + info={`How long a skill's runbook may be when it's saved or proposed. A skill already longer keeps working until its runbook is edited. 0 uses the default (${overview?.body_max_default ?? ""}).`} + /> + { + setBodyShownChars(event.target.value); + setDirty(true); + setStatus(undefined); + }} + suffix={`characters`} + info={`How much of a runbook a bot gets when it loads the skill; past that it's cut and the bot is told so. All of it goes into the model's context. 0 uses the default (${overview?.body_shown_default ?? ""}).`} + /> + + + + ); +} diff --git a/src/pages/admin/settings/general/(sections)/skill-limits-section.tsx b/src/pages/admin/settings/general/(sections)/skill-limits-section.tsx new file mode 100644 index 0000000..582f3a8 --- /dev/null +++ b/src/pages/admin/settings/general/(sections)/skill-limits-section.tsx @@ -0,0 +1,25 @@ +import { useContext } from "react"; +import { AppContext } from "@/src/pages/__root"; +import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user"; +import SettingsGroup from "../../(partials)/settings-group"; +import SkillLimitsForm from "../(partials)/skill-limits-form"; + +/** + * How long a skill's runbook may be, and how much of it a bot reads at + * once. Only the super admin sees it, since it changes every skill. + */ +export default function SkillLimitsSection() { + const { pageProps } = useContext(AppContext); + + if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null; + + return ( + + + + ); +} diff --git a/src/pages/admin/settings/general/index.tsx b/src/pages/admin/settings/general/index.tsx index c269deb..3ebc7fd 100644 --- a/src/pages/admin/settings/general/index.tsx +++ b/src/pages/admin/settings/general/index.tsx @@ -2,6 +2,7 @@ import AppearanceSection from "./(sections)/appearance-section"; import NotificationsSection from "./(sections)/notifications-section"; import PromptSection from "./(sections)/prompt-section"; import RunLimitsSection from "./(sections)/run-limits-section"; +import SkillLimitsSection from "./(sections)/skill-limits-section"; import StorageSection from "./(sections)/storage-section"; import VersionSection from "./(sections)/version-section"; import SettingsShell from "../(partials)/settings-shell"; @@ -17,6 +18,7 @@ export default function AdminSettingsGeneralPage() { + diff --git a/src/pages/admin/settings/skills/(partials)/skill-modal.tsx b/src/pages/admin/settings/skills/(partials)/skill-modal.tsx index 5c61994..e0b0349 100644 --- a/src/pages/admin/settings/skills/(partials)/skill-modal.tsx +++ b/src/pages/admin/settings/skills/(partials)/skill-modal.tsx @@ -6,15 +6,18 @@ import Form from "@moduletrace/twui/form/Form"; import Input from "@moduletrace/twui/form/Input"; import Textarea from "@moduletrace/twui/form/Textarea"; import CodeEditor from "@/src/components/general/code-editor"; -import { AppData } from "@/src/data/app-data"; import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; -import type { PublicSkill, SkillsOverviewPayload } from "@/src/types"; +import type { PublicSkill, SkillLimitsOverview, SkillsOverviewPayload } from "@/src/types"; type Props = { /** * Edits this skill; adds one when not given. */ skill?: PublicSkill; + /** + * The install's runbook limits, for the hint. + */ + limits: SkillLimitsOverview; onSaved: (overview: SkillsOverviewPayload, msg: string) => void; onCancel: () => void; }; @@ -24,7 +27,7 @@ type Props = { * loads it by, the one line that tells the bot when it applies, and the * runbook itself in markdown. */ -export default function SkillModal({ skill, onSaved, onCancel }: Props) { +export default function SkillModal({ skill, limits, onSaved, onCancel }: Props) { const [name, setName] = useState(skill?.name || ""); const [slug, setSlug] = useState(skill?.slug || ""); const [description, setDescription] = useState(skill?.description || ""); @@ -103,7 +106,7 @@ export default function SkillModal({ skill, onSaved, onCancel }: Props) { className="h-80" title="Runbook" content={body} - info={`Markdown. The steps, the checks, and what good looks like. A bot reads it only when it loads the skill, up to ${AppData.SkillBodyShownChars} characters of it at a time.`} + info={`Markdown. The steps, the checks, and what good looks like. Up to ${limits.body_max_chars} characters. A bot reads it only when it loads the skill, up to ${limits.body_shown_chars} characters of it at a time.`} onChange={(value) => setBody(value)} /> diff --git a/src/pages/admin/settings/skills/(partials)/skill-row.tsx b/src/pages/admin/settings/skills/(partials)/skill-row.tsx index b407166..654ae0b 100644 --- a/src/pages/admin/settings/skills/(partials)/skill-row.tsx +++ b/src/pages/admin/settings/skills/(partials)/skill-row.tsx @@ -3,7 +3,7 @@ import Stack from "@moduletrace/twui/layout/Stack"; import { BookOpen } from "lucide-react"; import IconTile from "@/src/components/general/icon-tile"; import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api"; -import type { ExtensionTargets, PublicSkill, SkillsOverviewPayload } from "@/src/types"; +import type { ExtensionTargets, PublicSkill, SkillLimitsOverview, SkillsOverviewPayload } from "@/src/types"; import ExtensionAccessModal from "../../(partials)/extension-access-modal"; import SettingsListRow from "../../(partials)/settings-list-row"; import SettingsRowAction from "../../(partials)/settings-row-action"; @@ -13,6 +13,7 @@ import SkillModal from "./skill-modal"; type Props = { skill: PublicSkill; targets: ExtensionTargets; + limits: SkillLimitsOverview; onChanged: (overview: SkillsOverviewPayload, msg: string) => void; onError: (msg: string) => void; }; @@ -21,7 +22,7 @@ type Props = { * One skill: its slug and when it applies, and who gets it. Edit and Access * open in a modal. */ -export default function SkillRow({ skill, targets, onChanged, onError }: Props) { +export default function SkillRow({ skill, targets, limits, onChanged, onError }: Props) { const [open, setOpen] = useState<"edit" | "access">(); const [busy, setBusy] = useState(false); @@ -73,7 +74,7 @@ export default function SkillRow({ skill, targets, onChanged, onError }: Props) ) : null} {open == "edit" ? ( - setOpen(undefined)} /> + setOpen(undefined)} /> ) : null} {open == "access" ? ( diff --git a/src/pages/admin/settings/skills/(sections)/skills-settings-section.tsx b/src/pages/admin/settings/skills/(sections)/skills-settings-section.tsx index 4283750..bd310a4 100644 --- a/src/pages/admin/settings/skills/(sections)/skills-settings-section.tsx +++ b/src/pages/admin/settings/skills/(sections)/skills-settings-section.tsx @@ -41,6 +41,7 @@ export default function SkillsSettingsSection() { > {adding ? ( { setAdding(false); changed(next, msg); @@ -54,6 +55,7 @@ export default function SkillsSettingsSection() { key={`${skill.id}-${version}`} skill={skill} targets={overview} + limits={overview.limits} onChanged={changed} onError={failed} /> diff --git a/src/pages/api/admin/settings/skill-limits.ts b/src/pages/api/admin/settings/skill-limits.ts new file mode 100644 index 0000000..5090d78 --- /dev/null +++ b/src/pages/api/admin/settings/skill-limits.ts @@ -0,0 +1,57 @@ +import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user"; +import grabSkillLimitsOverview, { + type SkillLimitsOverview, +} from "@/src/functions/backend/db/app-settings/grab-skill-limits"; +import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting"; +import logEvent, { grabRequestIP } from "@/src/functions/backend/logger"; +import { SkillBodyMaxCharsSettingKey, SkillBodyShownCharsSettingKey } from "@/src/dict/skill-dict"; +import { SaveSkillLimitsRequestSchema } from "@/src/schemas/extension-schema"; +import AppError from "@/src/utils/app-error"; +import parseWithSchema from "@/src/utils/parse-with-schema"; +import safeErrorMessage from "@/src/utils/safe-error-message"; +import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types"; + +/** + * Super admin only: the longest runbook a skill may save, and how much of + * it `load_skill` hands back at once. Zero puts the code's default back for + * that one. + */ +export const handler: BunextAPIRouteHandler> = async ({ + req, + body, +}) => { + try { + if (req.method != "POST") throw new AppError(`Method not allowed`); + + const user = await grabSuperAdminUser({ req }); + + if (!user?.id) throw new AppError(`Only a super admin can change the skill limits.`); + + const { body_max_chars, body_shown_chars } = parseWithSchema({ + schema: SaveSkillLimitsRequestSchema, + data: body, + }); + + await saveAppSetting({ + key: SkillBodyMaxCharsSettingKey, + value: body_max_chars > 0 ? String(body_max_chars) : null, + }); + await saveAppSetting({ + key: SkillBodyShownCharsSettingKey, + value: body_shown_chars > 0 ? String(body_shown_chars) : null, + }); + + await logEvent({ + title: "Skill limits changed", + category: "system", + user_id: user.id, + plain_text: `User #${user.id} set the skill runbook limit to ${body_max_chars || "default"} characters, ${body_shown_chars || "default"} read at once.`, + metadata: { body_max_chars, body_shown_chars }, + ip_address: grabRequestIP(req), + }); + + return { success: true, singleRes: await grabSkillLimitsOverview() }; + } catch (error: unknown) { + return { success: false, msg: safeErrorMessage({ error }) }; + } +}; diff --git a/src/schemas/extension-schema.ts b/src/schemas/extension-schema.ts index 47f75cd..63a64d1 100644 --- a/src/schemas/extension-schema.ts +++ b/src/schemas/extension-schema.ts @@ -193,11 +193,24 @@ export const SaveSkillRequestSchema = z.object({ .string() .trim() .min(1, "Write the runbook") - .max(AppData.SkillBodyMaxChars, `Keep the runbook under ${AppData.SkillBodyMaxChars} characters`), + /** + * Only the ceiling: the install's own limit is a setting, checked by + * `assertSkillBodyFits` when a runbook is saved or proposed. + */ + .max(AppData.SkillBodyLimitMax, `Keep the runbook under ${AppData.SkillBodyLimitMax} characters`), enabled: z.boolean().default(true), all_bots: z.boolean().default(false), }); +/** + * The install's skill limits (Settings > General). Zero puts the code's + * default back for that one. + */ +export const SaveSkillLimitsRequestSchema = z.object({ + body_max_chars: z.coerce.number().int().min(0).max(AppData.SkillBodyLimitMax), + body_shown_chars: z.coerce.number().int().min(0).max(AppData.SkillBodyLimitMax), +}); + export const SkillIdRequestSchema = z.object({ skill_id: Id, }); diff --git a/src/types/index.ts b/src/types/index.ts index e71d126..6737631 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -803,8 +803,38 @@ export type McpServersOverviewPayload = ExtensionTargets & { servers: PublicMcpServer[]; }; +/** + * The install's skill limits (Settings > General, `grab-skill-limits.ts`). + */ +export type SkillLimitsOverview = { + /** + * The longest runbook a skill may save right now. + */ + body_max_chars: number; + /** + * How much of a runbook `load_skill` hands back right now. + */ + body_shown_chars: number; + /** + * The longest runbook when the install saved no limit. + */ + body_max_default: number; + /** + * What `load_skill` hands back when the install saved no limit. + */ + body_shown_default: number; + /** + * The most the settings page accepts for either. + */ + max: number; +}; + export type SkillsOverviewPayload = ExtensionTargets & { skills: PublicSkill[]; + /** + * The install's runbook limits, for the skill form's hints. + */ + limits: SkillLimitsOverview; }; export type CustomToolsOverviewPayload = ExtensionTargets & { @@ -1403,6 +1433,11 @@ export type PagePropsType = { reply_default: number; max: number; } | null; + /** + * The longest runbook a skill may save and how much of it a bot reads + * at once, with the code's defaults (the General settings page). + */ + skill_limits?: SkillLimitsOverview | null; /** * The release the server is running, for the General settings page. */