diff --git a/.env.example b/.env.example
index 38f6449..d9c019a 100644
--- a/.env.example
+++ b/.env.example
@@ -49,6 +49,10 @@ LAZYBOY_MEMORY_ENABLED=true
LAZYBOY_MEMORY_MODEL_CACHE=./data/fastembed
LAZYBOY_MEMORY_TOP_K=8
LAZYBOY_MEMORY_BYTE_BUDGET=6000
+# 一次模型請求裡,系統提示 + 工具 schema + 對話(含網頁快照)的字元上限。
+# 超過就先把較舊的畫面紀錄收成摘要,再丟掉更舊的輪次,避免整份任務因
+# context 爆掉而失敗。預設對 128k 視窗留有餘地;不要開到比模型窗口還大。
+LAZYBOY_MODEL_CONTEXT_CHARS=200000
# Hourly cleanup of diagnostics; conversations and current memories are retained.
LAZYBOY_EVENT_RETENTION_DAYS=30
diff --git a/CHANGELOG.md b/CHANGELOG.md
index befa2df..1f2a97d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@ All notable changes to LazyBoy are documented here.
## [Unreleased]
+Long computer-use runs no longer die when the prompt creeps a few dozen tokens
+over the model's context window. Stale browser snapshots are stubbed, oversized
+tool dumps are capped, and an `exceed_context_size` response compact-retries
+the same turn instead of failing the job.
+
+Each model provider keeps its own API key, model, and endpoint in the
+database. Switching xAI → OpenCode Go no longer wipes the xAI key, and the
+settings field shows dots instead of the secret.
+
Sign in with your own account. The shared install token is gone, and so are the
model keys in the environment.
@@ -24,9 +33,20 @@ model keys in the environment.
- Removed: the token login screen, `LAZYBOY_APP_TOKEN`, and the rule that a
non-loopback bind required a 32-character token. `SANDBOX_SUPERVISOR_TOKEN`
and `LAZYBOY_VAULT_KEY` are still required in `.env`.
+- **Context window fitting.** Long computer-use runs stub stale browser
+ snapshots, cap tool dumps, and compact-retry on `exceed_context_size`
+ instead of failing the job 42 tokens over a 128k window. Tune with
+ `LAZYBOY_MODEL_CONTEXT_CHARS`.
+- **Model keys stay put.** Each provider keeps its own key, model, and base
+ URL. Switching provider no longer clears the previous key; the form shows
+ dots and never echoes the token.
### 繁體中文
+長時間操作電腦時,舊的網頁快照不再把模型上下文塞爆:較舊的觀察會收成摘要,工具輸出有上限,模型回 `exceed_context_size` 時會壓縮後重試同一輪,而不是整份任務失敗。
+
+每個供應商的 API 金鑰、模型與端點會各自記住。從 xAI 換到 OpenCode Go 不會清掉另一家的金鑰;設定畫面只顯示圓點,真正的 token 留在資料庫。
+
改成自己的帳號登入:共享的安裝 token 拿掉了,環境變數裡的模型金鑰也拿掉了。
- **在 App 裡註冊與登入**:密碼以 PBKDF2-HMAC-SHA256(120,000 輪、每組密碼各自的
@@ -41,6 +61,11 @@ model keys in the environment.
用 IP 或 `localhost` 不用設定。
- 移除:token 登入畫面、`LAZYBOY_APP_TOKEN`,以及「綁非 loopback 需 32 字元 token」
的規則。`.env` 仍需要 `SANDBOX_SUPERVISOR_TOKEN` 與 `LAZYBOY_VAULT_KEY`。
+- **模型上下文配額。** 長時間操作電腦時,較舊的網頁快照會收成摘要、工具輸出有上限,
+ 模型回 `exceed_context_size` 時會壓縮後重試同一輪,而不是只超出幾十個 token 就
+ 整份任務失敗。可用 `LAZYBOY_MODEL_CONTEXT_CHARS` 調整。
+- **模型金鑰會記住。** 每個供應商的金鑰、模型與端點各自存在資料庫。從 xAI 換到
+ OpenCode Go 不會清掉 xAI 的金鑰;畫面上只顯示圓點,真正的 token 不會再出現。
## [v0.1.0-alpha] - 2026-09-09
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 98a7892..273ad92 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -22,12 +22,13 @@ import { clockTime, dayLabel, sameDay } from "./chat-time";
import { HANDOFF_MS, VEIL_FADE_MS, handoffRemaining, keepScreenUrl, nextVeil, viewOnlyFor, viewerPath, type Veil } from "./handoff";
import { Avatar, AvatarLookProvider, AvatarStack, BLOBATAR_BACKGROUNDS, BLOBATAR_EXPRESSIONS, BLOBATAR_SHAPES, DEFAULT_LOOK, persistBlobatarShape, readAvatarLooks, resolveBlobatarShape, writeAvatarLook, type AvatarBackground, type AvatarExpression, type AvatarLook } from "./avatar";
import { dateLocale, getLocale, listJoin, setLocale, t, useLocale, type MessageKey } from "./i18n";
-import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, ModelProviderId, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings, WorkspaceSettings } from "./types";
+import type { AvatarShape, Bot, ComputerMode, ComputerStatus, FileSkill, McpCatalogEntry, McpServer, McpTransport, MemoryItem, MemoryStatus, Message, MessageFile, Playbook, PlaybookInput, PlaybookStep, Room, RoomMember, Session, TaughtSkill, VoiceSettings } from "./types";
import { ChatMarkdown, MentionText, copyText } from "./markdown";
import { RunProbe, errorActions, errorTitle } from "./run-monitor";
import { ScheduleEditor, ScheduleList, cronFromPreset, defaultCronPreset, presetFromCron, scheduleWhen, type CronPreset, type ScheduleItem } from "./schedule";
import { CallOverlay, PhoneIcon } from "./call";
import { VoiceSettingsDialog } from "./voice-settings";
+import { ModelSettingsDialog } from "./model-settings";
const blankComputer:ComputerStatus={botId:"",mode:"team",state:"stopped",sharedInput:true,controlHolder:"none",takeoverRequested:false,busyBotName:null,busySessionId:null,busyRunId:null,busyStep:null,usingComputer:false,waitingRunId:null,waitingSessionId:null,queuedRuns:0,display:null,profileMode:"per-bot",screenAvailable:false};
const SESSION_STORE="lazyboy.sessionByBot";
@@ -1086,67 +1087,6 @@ function WorkspaceSettingsDialog({name,setName,showHidden,setShowHidden,rightCol
}
-function ModelSettingsDialog({close}:{close:()=>void}){
- const[settings,setSettings]=useState(null);
- const[provider,setProvider]=useState("xai");
- const[modelId,setModelId]=useState("");
- const[baseUrl,setBaseUrl]=useState("");
- const[apiKey,setApiKey]=useState("");
- const[clearKey,setClearKey]=useState(false);
- const[models,setModels]=useState<{id:string;name:string}[]>([]);
- const[busy,setBusy]=useState(false);
- const[error,setError]=useState("");
- const current=settings?.providers.find(item=>item.id===provider);
- // The stored key belongs to the provider it was entered for; the server drops
- // it on a provider switch, so the form must stop claiming one is stored.
- const keyKept=Boolean(settings?.apiKeySet)&&provider===settings?.provider;
- useEffect(()=>{api("/api/workspace/settings").then(value=>{setSettings(value);setProvider(value.provider);setModelId(value.modelId);setBaseUrl(value.baseUrl);setModels(value.models)}).catch(e=>setError(e instanceof Error?localizeError(e.message):t("loadFailed")))},[]);
- async function loadModels(nextProvider:ModelProviderId,nextBaseUrl:string){
- if(nextProvider==="openai-compatible"&&!nextBaseUrl.trim()){setModels([]);return}
- try{
- const query=new URLSearchParams({provider:nextProvider});
- if(nextBaseUrl.trim())query.set("baseUrl",nextBaseUrl.trim());
- const result=await api<{models:{id:string;name:string}[]}>(`/api/workspace/models?${query}`);
- setModels(result.models);
- setModelId(current=>result.models.some(item=>item.id===current)?current:result.models[0]?.id||current);
- }catch{if(nextProvider==="openai-compatible")setModels([])}
- }
- function pickProvider(id:ModelProviderId){
- setProvider(id);
- const info=settings?.providers.find(item=>item.id===id);
- if(id==="openai-compatible"){
- setModels([]);
- setBaseUrl(current=>current.includes("opencode.ai")||current.includes("api.x.ai")?"":current);
- if(info?.defaultModel)setModelId(info.defaultModel);else setModelId("");
- return;
- }
- if(info?.defaultBaseUrl)setBaseUrl(info.defaultBaseUrl);
- if(info?.defaultModel)setModelId(info.defaultModel);
- void loadModels(id,info?.defaultBaseUrl||"");
- }
- return
-}
function AboutDialog({close}:{close:()=>void}){
const[health,setHealth]=useState<"ok"|"bad"|"…">("…");
useEffect(()=>{fetch("/api/health").then(r=>r.ok?setHealth("ok"):setHealth("bad")).catch(()=>setHealth("bad"))},[]);
diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts
index 2e70b12..73b8910 100644
--- a/apps/web/src/locales/en.ts
+++ b/apps/web/src/locales/en.ts
@@ -322,7 +322,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
collapseRightSidebar: "Collapse right sidebar",
workspaceSettingsHint: "Avatar, memory, and MCP live in the right-hand tabs. This only changes how this workspace is shown.",
modelSettings: "Models",
- modelSettingsHint: "This provider and model apply to bots that don’t have their own.",
+ modelSettingsHint: "Each provider keeps its own key, model, and endpoint. The key stays on the server; this screen only shows dots.",
modelProvider: "Model provider",
providerXai: "xAI",
providerOpencodeGo: "OpenCode Go",
@@ -332,7 +332,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
providerOpenaiCompatibleHint: "Self-hosted vLLM, Ollama, LiteLLM, or another OpenAI-compatible endpoint.",
apiKey: "API key",
apiKeyPlaceholder: "sk-…",
- apiKeyStored: "A key is saved. Leave blank to keep it.",
+ apiKeyStored: "Saved. Dots only — the real key stays on the server.",
clearApiKey: "Clear saved key",
modelId: "Model",
modelIdPlaceholder: "For example: qwen2.5 or llama3.1",
@@ -423,7 +423,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
loginPasswordTooLong: "That password is too long.",
loginPasswordCharacters: "The password contains characters we can’t use.",
modelKeyNotSet: "This workspace has no API key yet — paste one below before an agent can work.",
- modelKeyNeededForProvider: "You switched provider, so the stored key no longer applies. Paste a key for this provider, or agents will not be able to start after saving.",
+ modelKeyNeededForProvider: "No key saved for this provider yet. Paste one, or agents will not be able to start after saving.",
agentComputer: "Agent computer",
url: "URL",
stdio: "stdio",
@@ -568,6 +568,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
errorTitleNetwork: "Can’t reach the model",
errorTitleComputerGone: "Computer is gone",
errorTitleLeaseLost: "Work picked up elsewhere",
+ errorTitleContextOverflow: "Ran out of model context",
errorTitleUnknown: "Something broke",
errorChip: "Task failed",
errorRetry: "Retry",
diff --git a/apps/web/src/locales/zh-TW.ts b/apps/web/src/locales/zh-TW.ts
index 240798d..76cc487 100644
--- a/apps/web/src/locales/zh-TW.ts
+++ b/apps/web/src/locales/zh-TW.ts
@@ -104,12 +104,12 @@ export const zhTW = {
phoneAccessDescription: "同一區網的手機用瀏覽器打開這個網址,再用同一個存取 token 登入。", localAddressWarning: "這是本機位址,手機打不開。請改成這台電腦的區網 IP,例如 http://192.168.x.x:3101。",
close: "關閉", copied: "已複製", copyUrl: "複製網址", workspaceName: "工作區名稱", showHiddenBots: "顯示已隱藏的機器人", collapseRightSidebar: "收合右側欄",
workspaceSettingsHint: "機器人外觀、記憶與 MCP 外掛在右側分頁。這裡只改這個工作區的顯示。",
- modelSettings: "模型", modelSettingsHint: "這裡的供應商與模型會套用到還沒單獨指定模型的機器人。",
+ modelSettings: "模型", modelSettingsHint: "每個供應商的金鑰、模型與端點會各自記住。金鑰留在伺服器,這裡只顯示圓點。",
modelProvider: "模型供應商", providerXai: "xAI", providerOpencodeGo: "OpenCode Go", providerOpenaiCompatible: "OpenAI 相容",
providerXaiHint: "Grok。金鑰從 console.x.ai 取得。",
providerOpencodeGoHint: "OpenCode Go 訂閱。金鑰從 opencode.ai/auth 取得。",
providerOpenaiCompatibleHint: "自架 vLLM、Ollama、LiteLLM 或其他 OpenAI 相容端點。",
- apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存金鑰,留空表示沿用。",
+ apiKey: "API 金鑰", apiKeyPlaceholder: "sk-…", apiKeyStored: "已儲存。只顯示圓點,真正的金鑰留在伺服器。",
clearApiKey: "清除已存金鑰",
modelId: "模型", modelIdPlaceholder: "例如:qwen2.5 或 llama3.1",
reloadModels: "重新載入模型列表",
@@ -148,7 +148,7 @@ export const zhTW = {
loginUsernameTooShort: "帳號至少 3 個字元。", loginUsernameTooLong: "帳號最多 32 個字元。", loginUsernameCharacters: "帳號只能用字母、數字與 . _ -。", loginUsernameEdges: "帳號必須以字母或數字開頭與結尾。",
loginPasswordTooShort: "密碼至少 8 個字元。", loginPasswordTooLong: "密碼太長了。", loginPasswordCharacters: "密碼含有無法使用的字元。",
modelKeyNotSet: "這個工作區還沒有 API 金鑰,貼上金鑰後 agent 才能開始工作。",
- modelKeyNeededForProvider: "換了供應商,原本存的金鑰不能沿用;請貼上這個供應商的金鑰,否則儲存後 agent 會無法開始。",
+ modelKeyNeededForProvider: "這個供應商還沒存過金鑰。請貼上,否則儲存後 agent 會無法開始。",
agentComputer: "Agent 電腦", url: "URL", stdio: "stdio", http: "HTTP", sse: "SSE",
argumentsPlaceholder: "-y @modelcontextprotocol/server-github", environmentVariablesPlaceholder: "GITHUB_TOKEN=…",
urlPlaceholder: "https://mcp.example.com/mcp", headersPlaceholder: "Authorization=Bearer …",
@@ -242,6 +242,7 @@ export const zhTW = {
errorTitleNetwork: "連不到模型",
errorTitleComputerGone: "電腦不見了",
errorTitleLeaseLost: "工作被另一邊接手",
+ errorTitleContextOverflow: "對話超過模型能記住的長度",
errorTitleUnknown: "出錯了",
errorChip: "任務失敗",
errorRetry: "重試",
diff --git a/apps/web/src/model-key.ts b/apps/web/src/model-key.ts
new file mode 100644
index 0000000..cef9ad7
--- /dev/null
+++ b/apps/web/src/model-key.ts
@@ -0,0 +1,58 @@
+/** Mask shown in the API key field when a key is already stored. The real
+ * token never leaves the server; this is only so the field looks filled. */
+export const STORED_API_KEY_MASK = "••••••••";
+
+export type ProviderDraft = {
+ modelId: string;
+ baseUrl: string;
+ apiKey: string;
+ keyEdited: boolean;
+ clearKey: boolean;
+};
+
+export function emptyProviderDraft(modelId = "", baseUrl = ""): ProviderDraft {
+ return { modelId, baseUrl, apiKey: "", keyEdited: false, clearKey: false };
+}
+
+export function storedApiKeyFieldValue(
+ stored: boolean,
+ draft: Pick,
+): string {
+ if (stored && !draft.keyEdited && !draft.clearKey) return STORED_API_KEY_MASK;
+ return draft.apiKey;
+}
+
+export function applyApiKeyInput(
+ showingMask: boolean,
+ next: string,
+): Pick {
+ if (showingMask) {
+ const value = next.split("•").join("");
+ return { apiKey: value, keyEdited: value.length > 0, clearKey: false };
+ }
+ return { apiKey: next, keyEdited: true, clearKey: false };
+}
+
+/** What to send on save. `apiKey: null` means keep the stored secret. */
+export function apiKeySavePayload(
+ draft: Pick,
+): { apiKey: string | null; clearApiKey: boolean } {
+ if (draft.clearKey) return { apiKey: "", clearApiKey: true };
+ if (!draft.keyEdited) return { apiKey: null, clearApiKey: false };
+ const value = draft.apiKey.trim();
+ if (!value || value === STORED_API_KEY_MASK) return { apiKey: null, clearApiKey: false };
+ return { apiKey: value, clearApiKey: false };
+}
+
+export function providerHasStoredKey(
+ settings: {
+ apiKeySet: boolean;
+ provider: string;
+ providers?: { id: string; apiKeySet?: boolean }[];
+ } | null,
+ provider: string,
+): boolean {
+ const listed = settings?.providers?.find((item) => item.id === provider);
+ if (listed && typeof listed.apiKeySet === "boolean") return listed.apiKeySet;
+ return Boolean(settings?.apiKeySet && settings.provider === provider);
+}
diff --git a/apps/web/src/model-settings.tsx b/apps/web/src/model-settings.tsx
new file mode 100644
index 0000000..20d48c6
--- /dev/null
+++ b/apps/web/src/model-settings.tsx
@@ -0,0 +1,211 @@
+import { useEffect, useState } from "react";
+import { api } from "./api";
+import { t } from "./i18n";
+import { X } from "./animated-icons";
+import {
+ applyApiKeyInput,
+ apiKeySavePayload,
+ emptyProviderDraft,
+ providerHasStoredKey,
+ storedApiKeyFieldValue,
+ type ProviderDraft,
+} from "./model-key";
+import type { ModelProviderId, WorkspaceSettings } from "./types";
+
+export function ModelSettingsDialog({ close }: { close: () => void }) {
+ const [settings, setSettings] = useState(null);
+ const [provider, setProvider] = useState("xai");
+ const [drafts, setDrafts] = useState>>({});
+ const [models, setModels] = useState<{ id: string; name: string }[]>([]);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+
+ const current = settings?.providers.find((item) => item.id === provider);
+ const draft = drafts[provider] ?? emptyProviderDraft();
+ const stored = providerHasStoredKey(settings, provider);
+ const showingMask = stored && !draft.keyEdited && !draft.clearKey;
+ const fieldValue = storedApiKeyFieldValue(stored, draft);
+ const needsKey = current?.needsKey ?? provider !== "openai-compatible";
+ const hasTypedKey = draft.keyEdited && Boolean(draft.apiKey.trim());
+ const missingKey = needsKey && !stored && !hasTypedKey && !draft.clearKey;
+
+ useEffect(() => {
+ api("/api/workspace/settings")
+ .then((value) => {
+ setSettings(value);
+ setProvider(value.provider);
+ setModels(value.models);
+ const next: Partial> = {};
+ for (const item of value.providers) {
+ const active = item.id === value.provider;
+ next[item.id] = emptyProviderDraft(
+ active ? value.modelId : item.modelId || item.defaultModel || "",
+ active ? value.baseUrl : item.baseUrl || item.defaultBaseUrl || "",
+ );
+ }
+ if (!next[value.provider]) {
+ next[value.provider] = emptyProviderDraft(value.modelId, value.baseUrl);
+ }
+ setDrafts(next);
+ })
+ .catch((err) => setError(err instanceof Error ? err.message : t("loadFailed")));
+ }, []);
+
+ function patchDraft(id: ModelProviderId, patch: Partial) {
+ setDrafts((currentDrafts) => {
+ const previous = currentDrafts[id] ?? emptyProviderDraft();
+ return { ...currentDrafts, [id]: { ...previous, ...patch } };
+ });
+ }
+
+ async function loadModels(nextProvider: ModelProviderId, nextBaseUrl: string) {
+ if (nextProvider === "openai-compatible" && !nextBaseUrl.trim()) {
+ setModels([]);
+ return;
+ }
+ try {
+ const query = new URLSearchParams({ provider: nextProvider });
+ if (nextBaseUrl.trim()) query.set("baseUrl", nextBaseUrl.trim());
+ const result = await api<{ models: { id: string; name: string }[] }>(`/api/workspace/models?${query}`);
+ setModels(result.models);
+ setDrafts((currentDrafts) => {
+ const previous = currentDrafts[nextProvider] ?? emptyProviderDraft();
+ if (previous.modelId.trim()) return currentDrafts;
+ const modelId = result.models[0]?.id || previous.modelId;
+ return { ...currentDrafts, [nextProvider]: { ...previous, modelId } };
+ });
+ } catch {
+ if (nextProvider === "openai-compatible") setModels([]);
+ }
+ }
+
+ function pickProvider(id: ModelProviderId) {
+ setProvider(id);
+ const info = settings?.providers.find((item) => item.id === id);
+ setDrafts((currentDrafts) => {
+ if (currentDrafts[id]) return currentDrafts;
+ return {
+ ...currentDrafts,
+ [id]: emptyProviderDraft(info?.defaultModel || "", info?.defaultBaseUrl || ""),
+ };
+ });
+ const existing = drafts[id];
+ const baseUrl = existing?.baseUrl ?? info?.baseUrl ?? info?.defaultBaseUrl ?? "";
+ if (id === "openai-compatible" && !baseUrl.trim()) {
+ setModels([]);
+ return;
+ }
+ void loadModels(id, baseUrl);
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/run-monitor.tsx b/apps/web/src/run-monitor.tsx
index 3d44233..2a749df 100644
--- a/apps/web/src/run-monitor.tsx
+++ b/apps/web/src/run-monitor.tsx
@@ -33,6 +33,7 @@ const ACTIONS: Record = {
network: ["retry", "settings"],
computer_gone: ["screen", "retry"],
lease_lost: ["retry"],
+ context_overflow: ["retry"],
unknown: ["retry"],
};
@@ -48,6 +49,7 @@ const TITLES: Record = {
network: "errorTitleNetwork",
computer_gone: "errorTitleComputerGone",
lease_lost: "errorTitleLeaseLost",
+ context_overflow: "errorTitleContextOverflow",
unknown: "errorTitleUnknown",
};
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index 45afd7e..c44472e 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -43,7 +43,17 @@ export interface VoiceSettings {
models: { id: string; name: string }[];
voices: { id: string; name: string }[];
}
-export interface WorkspaceProvider { id:ModelProviderId; name:string; needsBaseUrl:boolean; needsKey:boolean; defaultBaseUrl:string|null; defaultModel:string|null }
+export interface WorkspaceProvider {
+ id: ModelProviderId;
+ name: string;
+ needsBaseUrl: boolean;
+ needsKey: boolean;
+ defaultBaseUrl: string | null;
+ defaultModel: string | null;
+ apiKeySet: boolean;
+ modelId: string;
+ baseUrl: string;
+}
export interface WorkspaceModel { id:string; name:string }
export interface WorkspaceSettings { provider:ModelProviderId; modelId:string; baseUrl:string; apiKeySet:boolean; providers:WorkspaceProvider[]; models:WorkspaceModel[] }
diff --git a/crates/api/src/context_fit.rs b/crates/api/src/context_fit.rs
new file mode 100644
index 0000000..4a37bb0
--- /dev/null
+++ b/crates/api/src/context_fit.rs
@@ -0,0 +1,453 @@
+//! Fit an in-run conversation into the model's context window.
+//!
+//! Computer-use runs have no turn quota, so every browser snapshot and shell
+//! dump stays in `history` until the provider rejects the prompt. The latest
+//! observation must stay readable (element ids live there). Older page dumps
+//! are stubbed, long parts are capped, then the oldest turns are dropped.
+
+use rig_core::completion::message::{
+ AssistantContent, Message, ReasoningContent, ToolResultContent, UserContent,
+};
+
+/// Marker written into a stubbed observation so we do not stub twice.
+const STUB_MARK: &str = "…(earlier page dump omitted; use the latest observation)";
+const TRUNCATED_MARK: &str = "\n…(truncated)";
+
+/// Conservative stand-in for one high-detail screenshot in the char budget.
+const IMAGE_CHARS: usize = 8_000;
+
+/// Latest history messages that stay "hot": capped, never stubbed.
+const KEEP_RECENT: usize = 6;
+/// Cap for a single text part on the latest turns / pending.
+pub const HOT_PART: usize = 24 * 1024;
+/// Cap for stubbed stale observations.
+const STUB_PART: usize = 700;
+/// Cap for other stale text (assistant thinking).
+const COLD_PART: usize = 2 * 1024;
+
+/// Default payload budget in bytes (preamble + tool schemas + messages).
+/// ~2 bytes/token is conservative for mixed CJK; English is cheaper, so this
+/// leaves headroom under a 128k-token window after output and images.
+const DEFAULT_BUDGET: usize = 200_000;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct FitReport {
+ pub compacted: bool,
+ pub dropped: usize,
+ pub chars_before: usize,
+ pub chars_after: usize,
+}
+
+/// Bytes we are willing to send as text (+ image stand-ins) in one request.
+pub fn char_budget() -> usize {
+ std::env::var("LAZYBOY_MODEL_CONTEXT_CHARS")
+ .ok()
+ .and_then(|value| value.trim().parse().ok())
+ .filter(|value: &usize| *value >= 32_000)
+ .unwrap_or(DEFAULT_BUDGET)
+}
+
+pub fn is_context_overflow(error: &str) -> bool {
+ let text = error.to_lowercase();
+ text.contains("exceed_context_size")
+ || text.contains("exceeds the available context size")
+ || text.contains("context_length_exceeded")
+ || text.contains("maximum context length")
+ || text.contains("prompt is too long")
+ || text.contains("context window")
+ || (text.contains("too many tokens") && text.contains("context"))
+}
+
+/// `n_prompt_tokens` / `n_ctx` when the provider included them in the body.
+pub fn parse_context_window(error: &str) -> Option<(u64, u64)> {
+ let prompt = json_u64(error, "n_prompt_tokens")?;
+ let ctx = json_u64(error, "n_ctx")?;
+ (prompt > 0 && ctx > 0).then_some((prompt, ctx))
+}
+
+/// After an overflow, how many chars we should aim to send next.
+pub fn overflow_char_budget(error: &str, chars_now: usize) -> usize {
+ if let Some((prompt, ctx)) = parse_context_window(error)
+ && prompt > 0
+ {
+ let ratio = (ctx as f64 * 0.85) / prompt as f64;
+ return ((chars_now as f64 * ratio) as usize).clamp(32_000, chars_now.saturating_sub(1));
+ }
+ chars_now.saturating_mul(3) / 4
+}
+
+pub fn truncate_chars(text: &str, max_bytes: usize) -> String {
+ if text.len() <= max_bytes {
+ return text.to_string();
+ }
+ let keep = max_bytes.saturating_sub(TRUNCATED_MARK.len()).max(1);
+ let mut cut = keep.min(text.len());
+ while cut > 0 && !text.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ let mut out = text[..cut].to_string();
+ out.push_str(TRUNCATED_MARK);
+ out
+}
+
+pub fn cap_message_parts(message: &mut Message, max_bytes: usize) {
+ for_each_text_mut(message, |text| {
+ if text.len() > max_bytes {
+ *text = truncate_chars(text, max_bytes);
+ }
+ });
+}
+
+pub fn estimate_payload_chars(
+ preamble: &str,
+ history: &[Message],
+ pending: &Message,
+ defs_chars: usize,
+) -> usize {
+ preamble.len()
+ + defs_chars
+ + history.iter().map(message_chars).sum::()
+ + message_chars(pending)
+}
+
+/// Shrink `history` and `pending` until they fit `budget` (or `char_budget()`).
+pub fn fit_model_context(
+ history: &mut Vec,
+ pending: &mut Message,
+ preamble: &str,
+ defs_chars: usize,
+ budget: Option,
+) -> FitReport {
+ let budget = budget.unwrap_or_else(char_budget);
+ let chars_before = estimate_payload_chars(preamble, history, pending, defs_chars);
+ let mut dropped = 0;
+ let mut compacted = false;
+
+ compacted |= stub_and_cap(history);
+ compacted |= cap_message_parts_all(history, pending, HOT_PART, COLD_PART);
+
+ while estimate_payload_chars(preamble, history, pending, defs_chars) > budget
+ && history.len() > KEEP_RECENT
+ {
+ history.remove(0);
+ dropped += 1;
+ compacted = true;
+ }
+ while estimate_payload_chars(preamble, history, pending, defs_chars) > budget
+ && history.len() > 2
+ {
+ history.remove(0);
+ dropped += 1;
+ compacted = true;
+ }
+ if estimate_payload_chars(preamble, history, pending, defs_chars) > budget {
+ let tight = HOT_PART / 2;
+ compacted |= cap_message_parts_all(history, pending, tight, tight.min(COLD_PART));
+ }
+ while estimate_payload_chars(preamble, history, pending, defs_chars) > budget
+ && history.len() > 1
+ {
+ history.remove(0);
+ dropped += 1;
+ compacted = true;
+ }
+
+ FitReport {
+ compacted,
+ dropped,
+ chars_before,
+ chars_after: estimate_payload_chars(preamble, history, pending, defs_chars),
+ }
+}
+
+fn stub_and_cap(history: &mut [Message]) -> bool {
+ let hot_from = history.len().saturating_sub(KEEP_RECENT);
+ let mut changed = false;
+ for (index, message) in history.iter_mut().enumerate() {
+ if index >= hot_from {
+ continue;
+ }
+ changed |= stub_observation_message(message);
+ cap_message_parts(message, COLD_PART);
+ }
+ changed
+}
+
+fn cap_message_parts_all(
+ history: &mut [Message],
+ pending: &mut Message,
+ hot: usize,
+ cold: usize,
+) -> bool {
+ let hot_from = history.len().saturating_sub(KEEP_RECENT);
+ let mut changed = false;
+ for (index, message) in history.iter_mut().enumerate() {
+ let before = message_chars(message);
+ cap_message_parts(message, if index >= hot_from { hot } else { cold });
+ changed |= message_chars(message) != before;
+ }
+ let before = message_chars(pending);
+ cap_message_parts(pending, hot);
+ changed || message_chars(pending) != before
+}
+
+fn stub_observation_message(message: &mut Message) -> bool {
+ let mut changed = false;
+ for_each_text_mut(message, |text| {
+ if looks_like_observation(text) && !text.contains(STUB_MARK) {
+ *text = stub_observation(text);
+ changed = true;
+ }
+ });
+ changed
+}
+
+fn looks_like_observation(text: &str) -> bool {
+ text.contains("Clickable page elements:")
+ || text.contains("Clickable controls")
+ || text.contains("Clickable windows")
+ || text.contains("Visible text:\n")
+ || text.contains("computer observed")
+}
+
+fn stub_observation(text: &str) -> String {
+ let head: Vec<&str> = text.lines().take(4).collect();
+ let stub = format!("{}\n{STUB_MARK}", head.join("\n"));
+ if stub.len() > STUB_PART {
+ truncate_chars(&stub, STUB_PART)
+ } else {
+ stub
+ }
+}
+
+fn message_chars(message: &Message) -> usize {
+ let mut total = 0;
+ match message {
+ Message::System { content } => total += content.len(),
+ Message::User { content } => {
+ for part in content {
+ match part {
+ UserContent::Text(text) => total += text.text.len(),
+ UserContent::ToolResult(result) => {
+ for item in &result.content {
+ match item {
+ ToolResultContent::Text(text) => total += text.text.len(),
+ ToolResultContent::Image(_) => total += IMAGE_CHARS,
+ ToolResultContent::Json { value } => {
+ total += value.to_string().len();
+ }
+ }
+ }
+ }
+ UserContent::Image(_) => total += IMAGE_CHARS,
+ UserContent::Document(doc) => {
+ total += serde_json::to_string(doc).map(|s| s.len()).unwrap_or(0);
+ }
+ _ => {}
+ }
+ }
+ }
+ Message::Assistant { content, .. } => {
+ for part in content {
+ match part {
+ AssistantContent::Text(text) => total += text.text.len(),
+ AssistantContent::ToolCall(call) => {
+ total +=
+ call.function.name.len() + call.function.arguments.to_string().len();
+ }
+ AssistantContent::Reasoning(reasoning) => {
+ for item in &reasoning.content {
+ match item {
+ ReasoningContent::Text { text, .. }
+ | ReasoningContent::Summary(text) => total += text.len(),
+ ReasoningContent::Encrypted(data) => total += data.len(),
+ ReasoningContent::Redacted { data } => total += data.len(),
+ }
+ }
+ }
+ AssistantContent::Image(_) => total += IMAGE_CHARS,
+ }
+ }
+ }
+ }
+ total
+}
+
+fn for_each_text_mut(message: &mut Message, mut visit: impl FnMut(&mut String)) {
+ match message {
+ Message::System { content } => visit(content),
+ Message::User { content } => {
+ for part in content {
+ match part {
+ UserContent::Text(text) => visit(&mut text.text),
+ UserContent::ToolResult(result) => {
+ for item in &mut result.content {
+ if let ToolResultContent::Text(text) = item {
+ visit(&mut text.text);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ Message::Assistant { content, .. } => {
+ for part in content {
+ match part {
+ AssistantContent::Text(text) => visit(&mut text.text),
+ AssistantContent::Reasoning(reasoning) => {
+ for item in &mut reasoning.content {
+ match item {
+ ReasoningContent::Text { text, .. }
+ | ReasoningContent::Summary(text) => visit(text),
+ _ => {}
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+}
+
+fn json_u64(error: &str, key: &str) -> Option {
+ let needle = format!("\"{key}\":");
+ let rest = error.split(&needle).nth(1)?.trim_start();
+ let digits: String = rest.chars().take_while(|ch| ch.is_ascii_digit()).collect();
+ digits.parse().ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use rig_core::completion::message::{AssistantContent, Message, UserContent};
+
+ fn user(text: &str) -> Message {
+ Message::User {
+ content: vec![UserContent::text(text)],
+ }
+ }
+
+ fn assistant(text: &str) -> Message {
+ Message::Assistant {
+ id: None,
+ content: vec![AssistantContent::text(text)],
+ }
+ }
+
+ fn observation(page: &str, dump: &str) -> String {
+ format!(
+ "browser snapshot\nPage: {page} https://example/{page}\nClickable page elements: [1] Go\nVisible text:\n{dump}"
+ )
+ }
+
+ #[test]
+ fn the_gmail_overflow_error_is_detected_and_parsed() {
+ let error = r#"ProviderResponseError: status 400 Bad Request: {"error":{"code":400,"message":"request (132117 tokens) exceeds the available context size (131072 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":132117,"n_ctx":131072}}"#;
+ assert!(is_context_overflow(error));
+ assert_eq!(parse_context_window(error), Some((132117, 131072)));
+ let next = overflow_char_budget(error, 200_000);
+ assert!(next < 200_000);
+ assert!(next >= 32_000);
+ }
+
+ #[test]
+ fn stale_page_dumps_are_stubbed_and_the_latest_stays() {
+ let latest = observation("inbox", &"unread ".repeat(2000));
+ let mut history = Vec::new();
+ history.push(user(&observation("github", &"readme ".repeat(2000))));
+ history.push(assistant("click 73"));
+ for turn in 0..8 {
+ history.push(user(&observation(
+ "mid",
+ &format!("step {turn} ").repeat(200),
+ )));
+ history.push(assistant("next"));
+ }
+ history.push(user(&observation("oauth", &"consent ".repeat(2000))));
+ history.push(assistant("continue"));
+ let mut pending = user(&latest);
+ let report = fit_model_context(&mut history, &mut pending, "sys", 0, Some(80_000));
+ assert!(report.compacted);
+ let Message::User { content } = &history[0] else {
+ panic!("expected user");
+ };
+ let UserContent::Text(text) = &content[0] else {
+ panic!("expected text");
+ };
+ assert!(text.text.contains(STUB_MARK), "{}", text.text);
+ assert!(!text.text.contains("readme readme"));
+ let Message::User { content } = &pending else {
+ panic!("pending");
+ };
+ let UserContent::Text(text) = &content[0] else {
+ panic!("pending text");
+ };
+ assert!(text.text.contains("unread unread"));
+ assert!(!text.text.contains(STUB_MARK));
+ }
+
+ #[test]
+ fn a_72_turn_trace_fits_a_128k_class_budget() {
+ let dump = observation("page", &"x".repeat(4_000));
+ let mut history = Vec::new();
+ for turn in 0..72 {
+ history.push(user(&format!("{dump} turn {turn}")));
+ history.push(assistant("next"));
+ }
+ let mut pending = user(&observation("now", &"y".repeat(3_000)));
+ let before = estimate_payload_chars("preamble", &history, &pending, 10_000);
+ assert!(before > 200_000, "fixture should start oversized: {before}");
+ let report = fit_model_context(
+ &mut history,
+ &mut pending,
+ "preamble",
+ 10_000,
+ Some(200_000),
+ );
+ assert!(report.compacted);
+ assert!(report.chars_after <= 200_000);
+ let Message::User { content } = &history[0] else {
+ panic!("expected user");
+ };
+ let UserContent::Text(text) = &content[0] else {
+ panic!("expected text");
+ };
+ assert!(
+ text.text.contains(STUB_MARK),
+ "oldest dumps should be stubbed"
+ );
+ let Message::User { content } = &pending else {
+ panic!("pending");
+ };
+ let UserContent::Text(text) = &content[0] else {
+ panic!("text");
+ };
+ assert!(text.text.contains("yyyy"));
+ assert!(!text.text.contains(STUB_MARK));
+ }
+
+ #[test]
+ fn tool_result_dumps_are_capped() {
+ let mut message = Message::User {
+ content: vec![UserContent::tool_result(
+ "c1",
+ "browser",
+ vec![ToolResultContent::text(
+ "Clickable page elements:\n".to_string() + &"z".repeat(80_000),
+ )],
+ )],
+ };
+ cap_message_parts(&mut message, HOT_PART);
+ assert!(message_chars(&message) <= HOT_PART + TRUNCATED_MARK.len());
+ }
+
+ #[test]
+ fn truncate_respects_char_boundaries() {
+ let text = "你好世界".repeat(20);
+ let out = truncate_chars(&text, 20);
+ assert!(out.ends_with("…(truncated)"));
+ assert!(out.is_char_boundary(out.len() - TRUNCATED_MARK.len()));
+ }
+}
diff --git a/crates/api/src/db.rs b/crates/api/src/db.rs
index da7ad68..219e0eb 100644
--- a/crates/api/src/db.rs
+++ b/crates/api/src/db.rs
@@ -137,6 +137,16 @@ pub struct SpaceRow {
pub voice_api_key: Option,
}
+#[derive(Debug, Clone, FromRow)]
+#[allow(dead_code)]
+pub struct SpaceModelProviderRow {
+ pub space_id: String,
+ pub provider: String,
+ pub model_id: String,
+ pub base_url: Option,
+ pub api_key: Option,
+}
+
impl Db {
/// Register a person.
///
@@ -345,6 +355,40 @@ impl Db {
.await
}
+ pub async fn space_model_provider(
+ &self,
+ actor: &Actor,
+ provider: &str,
+ ) -> Result