diff --git a/AGENTS.md b/AGENTS.md index 8f9d4fe4ef..7ea9dcfb73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -492,6 +492,8 @@ When adding code that needs a new string, decide up front which rule it falls un - `ManageWorkspacesModal` should require a confirmation step before removing either a saved workspace or a workspace parent; parent removals should mention the child-workspace impact, and tests should assert both the confirmation flow and that removing the selected workspace clears the launch selection. - In `useWorkspacesStore`, keep `clearWorkspaces()` scoped to literal workspaces only; use explicit helpers like `clearWorkspaceParents()` / `clearAll()` for broader resets so future callers do not accidentally wipe parent registrations. +- Default LLM model — `DEFAULT_SETTINGS.llm_model` (`"openhands/minimax-m2.7"`, defined in `src/services/settings.ts`) is the canonical frontend default. `buildConfiguredOpenHandsAgentSettings` in `src/api/agent-server-adapter.ts` **always** sends this value explicitly when the resolved `llm.model` is absent, empty, or whitespace-only — the frontend never relies on the agent-server SDK's own default (`gpt-5.5`). If you change the default model, update `DEFAULT_SETTINGS.llm_model` in `src/services/settings.ts` **and** the checklist in `specs/llm-defaults.md`. Spec: `@spec LLD-001`. + - Custom secrets are NOT auto-attached by the agent-server. `POST /api/conversations` only persists what the client sends in `request.secrets`; the persisted secrets store (`/api/settings/secrets`) is never read at conversation-start. `buildStartConversationRequestWithEncryptedSettings` enumerates `SecretsService.getSecrets()` and turns each entry into a `LookupSecret` whose `url` points back at `/api/settings/secrets/{name}` and whose `headers` carry `X-Session-API-Key` for auth. Pre-1.21.x agent-server SDKs would silently drop that header during validation when `secrets_encrypted=true` (the cipher in the validation context tried to `cipher.decrypt(plaintext_session_key)`, failed, and the validator removed the header — the conversation runtime then got 401s for every saved secret). The SDK fix preserves plaintext header values when decryption fails; if you still see saved secrets unavailable inside a conversation, verify the running agent-server bundles a `LookupSecret._validate_secrets` that falls back to plaintext on decrypt failure. - MCP page layout: MCP is a **top-level** nav entry at `/mcp` (rendered by `src/routes/mcp.tsx`), shown right below "Skills" in `src/components/features/sidebar/sidebar.tsx`. The legacy `/settings/mcp` route still works as a redirect via `src/routes/mcp-settings-redirect.tsx`, and `src/routes/mcp-settings.tsx` re-exports the new page so the published `MCPSettings` library symbol (in `src/components/settings/index.ts`) keeps the same shape. Marketplace catalog data and MCP logo mappings live in the MCP-capable entries from `@openhands/extensions/integrations`; the Slack API catalog option should point at `https://github.com/zencoderai/slack-mcp-server` and use `@zencoderai/slack-mcp-server`. Deprecated marketplace entries removed upstream (for example GitLab / Google Maps / Postgres / Puppeteer / SQLite) should disappear from the marketplace grid. The Installed section still needs to render and search arbitrary non-catalog custom servers via the raw server `name` / `command` fallback in `src/utils/mcp-marketplace-utils.ts` + `InstalledServerCard`. Tavily is a regular stdio MCP entry (`tavily-mcp` + `TAVILY_API_KEY`), not a special built-in sentinel anymore. Components are colocated under `src/components/features/mcp-page/` and reuse the existing `MCPServerForm` for the "Add custom server" / edit flow. diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts index 7ac02a11fa..15ac2fc08d 100644 --- a/__tests__/api/agent-server-adapter.test.ts +++ b/__tests__/api/agent-server-adapter.test.ts @@ -475,6 +475,80 @@ describe("buildStartConversationRequest", () => { }); }); }); + + // @spec LLD-001 — Frontend always sends its chosen default model + describe("llm.model fallback — frontend always sends its chosen default", () => { + type ModelPayload = { + agent_settings: Record & { llm: Record }; + }; + + function getModelFrom( + options: Parameters[0], + ): unknown { + return (buildStartConversationRequest(options) as unknown as ModelPayload) + .agent_settings.llm.model; + } + + it("uses the configured model when one is set", () => { + expect( + getModelFrom({ + settings: { + ...DEFAULT_SETTINGS, + agent_settings: { + ...DEFAULT_SETTINGS.agent_settings, + llm: { model: "anthropic/claude-opus-4-5" }, + }, + }, + }), + ).toBe("anthropic/claude-opus-4-5"); + }); + + // The agent-server returns '' when no model has been saved yet. + // Without this guard the empty string passes the old typeof check and + // the agent-server falls back to its own SDK default (gpt-5.5). + // SettingsValue includes scalars so inline literals are type-safe here. + it.each([ + ["undefined", { ...DEFAULT_SETTINGS.agent_settings, llm: {} }], + ["an empty string", { ...DEFAULT_SETTINGS.agent_settings, llm: { model: "" } }], + ["whitespace only", { ...DEFAULT_SETTINGS.agent_settings, llm: { model: " " } }], + // No llm key at all — SettingsValue accepts plain scalars. + ["absent (no llm block)", { schema_version: 1, agent_kind: "openhands", agent: "CodeActAgent" }], + // Mirrors a fresh user who skipped onboarding: server returns {}. + ["entirely empty", {}], + ])( + "falls back to DEFAULT_SETTINGS.llm_model when agent_settings.llm.model is %s", + (_, agentSettings) => { + expect( + getModelFrom({ + settings: { + ...DEFAULT_SETTINGS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + agent_settings: agentSettings as any, + }, + }), + ).toBe(DEFAULT_SETTINGS.llm_model); + }, + ); + + // encryptedAgentSettings overrides settings.agent_settings at conversation + // start; if the encrypted payload has no model set the frontend default + // must still be sent explicitly. + it.each([ + ["carries an empty model", { llm: { model: "" } }], + ["is empty", {}], + ])( + "falls back to DEFAULT_SETTINGS.llm_model when encryptedAgentSettings %s", + (_, encryptedAgentSettings) => { + expect( + getModelFrom({ + settings: DEFAULT_SETTINGS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + encryptedAgentSettings: encryptedAgentSettings as any, + }), + ).toBe(DEFAULT_SETTINGS.llm_model); + }, + ); + }); }); describe("getDefaultConversationTitle", () => { diff --git a/specs/llm-defaults.md b/specs/llm-defaults.md new file mode 100644 index 0000000000..111e5fe115 --- /dev/null +++ b/specs/llm-defaults.md @@ -0,0 +1,9 @@ +# LLM Defaults Specs + +--- + +### LLD-001: Frontend always sends its chosen default model +- [x] When the agent-server returns an absent or empty `llm.model` (e.g. because the user has never saved settings), the frontend adapter shall substitute `DEFAULT_SETTINGS.llm_model` (`"openhands/minimax-m2.7"`) before sending the conversation-start request. +- [x] The frontend shall never rely on the agent-server SDK's own default model (`gpt-5.5`); it shall always send an explicit model value. +- [x] Whitespace-only model strings shall be treated as absent and fall back to the default. +- [x] The same guard applies when LLM settings arrive via `encryptedAgentSettings` (the conversation-start encrypted payload path). diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts index 6f7a3081ac..075cf39431 100644 --- a/src/api/agent-server-adapter.ts +++ b/src/api/agent-server-adapter.ts @@ -593,7 +593,9 @@ function buildConfiguredOpenHandsAgentSettings( const llm = toRecord(agentSettings.llm); llm.model = - typeof llm.model === "string" ? llm.model : DEFAULT_SETTINGS.llm_model; + typeof llm.model === "string" && llm.model.trim().length > 0 + ? llm.model + : DEFAULT_SETTINGS.llm_model; const apiKey = normalizeSecretString(llm.api_key); if (apiKey) {