From d5bffcdeabb0a26104f41f37e898cf9189c1dee9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 12 Mar 2026 23:30:58 +0000 Subject: [PATCH] feat: add fast mode toggle for OpenAI models --- CHANGELOG.md | 1 + docs/concepts/model-providers.md | 2 + docs/concepts/session.md | 2 +- docs/providers/openai.md | 40 +++++++ docs/tools/slash-commands.md | 4 +- docs/tools/thinking.md | 17 ++- docs/web/control-ui.md | 2 +- docs/web/tui.md | 3 +- src/acp/translator.session-rate-limit.test.ts | 71 ++++++++++++ src/acp/translator.ts | 17 ++- src/agents/fast-mode.ts | 50 ++++++++ .../pi-embedded-runner-extraparams.test.ts | 76 ++++++++++++ src/agents/pi-embedded-runner/extra-params.ts | 8 ++ .../openai-stream-wrappers.ts | 109 ++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 5 +- src/agents/pi-embedded-runner/run/params.ts | 1 + src/auto-reply/commands-registry.data.ts | 16 +++ src/auto-reply/commands-registry.test.ts | 11 ++ ...irective.directive-behavior.e2e-harness.ts | 3 +- ...rrent-verbose-level-verbose-has-no.test.ts | 53 +++++++-- src/auto-reply/reply.directive.parse.test.ts | 16 ++- src/auto-reply/reply/commands-core.ts | 2 + src/auto-reply/reply/commands-session.ts | 54 ++++++++- src/auto-reply/reply/commands-status.ts | 12 ++ .../reply/directive-handling.fast-lane.ts | 18 ++- .../reply/directive-handling.impl.ts | 50 ++++++++ .../reply/directive-handling.levels.ts | 5 + .../reply/directive-handling.params.ts | 1 + .../reply/directive-handling.parse.ts | 16 ++- src/auto-reply/reply/directives.ts | 19 +++ .../reply/get-reply-directives-apply.ts | 3 + .../reply/get-reply-directives-utils.ts | 3 + src/auto-reply/reply/get-reply-directives.ts | 13 +++ .../reply/get-reply-inline-actions.ts | 14 ++- src/auto-reply/reply/get-reply-run.ts | 7 ++ src/auto-reply/status.test.ts | 21 ++++ src/auto-reply/status.ts | 5 +- src/auto-reply/thinking.ts | 18 +++ src/commands/agent/types.ts | 2 + src/commands/status.summary.ts | 4 + src/commands/status.types.ts | 1 + src/config/sessions/types.ts | 1 + src/gateway/protocol/schema/sessions.ts | 1 + src/gateway/server-methods/agent.ts | 1 + src/gateway/server-methods/chat.ts | 1 + src/gateway/server-node-events.ts | 1 + src/gateway/session-reset-service.ts | 1 + src/gateway/session-utils.ts | 1 + src/gateway/session-utils.types.ts | 1 + src/gateway/sessions-patch.test.ts | 31 +++++ src/gateway/sessions-patch.ts | 14 +++ src/tui/commands.ts | 8 ++ src/tui/gateway-chat.ts | 2 + src/tui/tui-command-handlers.ts | 21 ++++ src/tui/tui-session-actions.ts | 5 + src/tui/tui-types.ts | 1 + src/tui/tui.ts | 2 + .../chat/slash-command-executor.node.test.ts | 38 ++++++ ui/src/ui/chat/slash-command-executor.ts | 44 +++++++ ui/src/ui/chat/slash-commands.node.test.ts | 7 ++ ui/src/ui/chat/slash-commands.ts | 9 ++ ui/src/ui/controllers/sessions.ts | 4 + ui/src/ui/types.ts | 2 + ui/src/ui/views/sessions.test.ts | 26 ++++- ui/src/ui/views/sessions.ts | 28 ++++- 66 files changed, 990 insertions(+), 36 deletions(-) create mode 100644 src/agents/fast-mode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a6515638991..ab0a4385a58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi - Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev. - Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular. +- OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across `/fast`, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping. ### Fixes diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 80819b87414..3a081c29416 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -47,6 +47,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`) - OpenAI priority processing can be enabled via `agents.defaults.models["openai/"].params.serviceTier` +- OpenAI fast mode can be enabled per model via `agents.defaults.models["/"].params.fastMode` ```json5 { @@ -78,6 +79,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) +- Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*` - Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw. ```json5 diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 6c9010d2c11..5c60655858e 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -281,7 +281,7 @@ Runtime override (owner only): - `openclaw status` — shows store path and recent sessions. - `openclaw sessions --json` — dumps every entry (filter with `--active `). - `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). -- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). +- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/fast/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). - Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 4683f061546..b9e4e9f08f1 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -165,6 +165,46 @@ pass that field through on direct `openai/*` Responses requests. Supported values are `auto`, `default`, `flex`, and `priority`. +### OpenAI fast mode + +OpenClaw exposes a shared fast-mode toggle for both `openai/*` and +`openai-codex/*` sessions: + +- Chat/UI: `/fast status|on|off` +- Config: `agents.defaults.models["/"].params.fastMode` + +When fast mode is enabled, OpenClaw applies a low-latency OpenAI profile: + +- `reasoning.effort = "low"` when the payload does not already specify reasoning +- `text.verbosity = "low"` when the payload does not already specify verbosity +- `service_tier = "priority"` for direct `openai/*` Responses calls to `api.openai.com` + +Example: + +```json5 +{ + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + fastMode: true, + }, + }, + "openai-codex/gpt-5.4": { + params: { + fastMode: true, + }, + }, + }, + }, + }, +} +``` + +Session overrides win over config. Clearing the session override in the Sessions UI +returns the session to the configured default. + ### OpenAI Responses server-side compaction For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d792398f1fa..e0a9f1aa365 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -14,7 +14,7 @@ The host-only bash chat command uses `! ` (with `/bash ` as an alias). There are two related systems: - **Commands**: standalone `/...` messages. -- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`. +- **Directives**: `/think`, `/fast`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`. - Directives are stripped from the message before the model sees it. - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. @@ -102,6 +102,7 @@ Text + native (when enabled): - `/send on|off|inherit` (owner-only) - `/reset` or `/new [model]` (optional model hint; remainder is passed through) - `/think ` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) +- `/fast status|on|off` (omitting the arg shows the current effective fast-mode state) - `/verbose on|full|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals) @@ -130,6 +131,7 @@ Notes: - Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`). - ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. +- `/fast on|off` persists a session override. Use the Sessions UI `inherit` option to clear it and fall back to config defaults. - Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 9a2fdc87ea6..9fe989332f4 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -1,7 +1,7 @@ --- -summary: "Directive syntax for /think + /verbose and how they affect model reasoning" +summary: "Directive syntax for /think, /fast, /verbose, and reasoning visibility" read_when: - - Adjusting thinking or verbose directive parsing or defaults + - Adjusting thinking, fast-mode, or verbose directive parsing or defaults title: "Thinking Levels" --- @@ -42,6 +42,19 @@ title: "Thinking Levels" - **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime. +## Fast mode (/fast) + +- Levels: `on|off`. +- Directive-only message toggles a session fast-mode override and replies `Fast mode enabled.` / `Fast mode disabled.`. +- Send `/fast` (or `/fast status`) with no mode to see the current effective fast-mode state. +- OpenClaw resolves fast mode in this order: + 1. Inline/directive-only `/fast on|off` + 2. Session override + 3. Per-model config: `agents.defaults.models["/"].params.fastMode` + 4. Fallback: `off` +- For `openai/*`, fast mode applies the OpenAI fast profile: `service_tier=priority` when supported, plus low reasoning effort and low text verbosity. +- For `openai-codex/*`, fast mode applies the same low-latency profile on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths. + ## Verbose directives (/verbose or /v) - Levels: `on` (minimal) | `full` | `off` (default). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 59e9c0c226b..73487cc0eae 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -75,7 +75,7 @@ The Control UI can localize itself on first load based on your browser locale, a - Stream tool calls + live tool output cards in Chat (agent events) - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) -- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) +- Sessions: list + per-session thinking/fast/verbose/reasoning overrides (`sessions.list`, `sessions.patch`) - Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) diff --git a/docs/web/tui.md b/docs/web/tui.md index 0c09cb1f877..d1869821d68 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -37,7 +37,7 @@ Use `--password` if your Gateway uses password auth. - Header: connection URL, current agent, current session. - Chat log: user messages, assistant replies, system notices, tool cards. - Status line: connection/run state (connecting, running, streaming, idle, error). -- Footer: connection state + agent + session + model + think/verbose/reasoning + token counts + deliver. +- Footer: connection state + agent + session + model + think/fast/verbose/reasoning + token counts + deliver. - Input: text editor with autocomplete. ## Mental model: agents + sessions @@ -92,6 +92,7 @@ Core: Session controls: - `/think ` +- `/fast ` - `/verbose ` - `/reasoning ` - `/usage ` diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index d0f774678a9..554dc87e2b8 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -645,6 +645,77 @@ describe("acp setSessionConfigOption bridge behavior", () => { sessionStore.clearAllSessionsForTest(); }); + it("updates fast mode ACP config options through gateway session patches", async () => { + const sessionStore = createInMemorySessionStore(); + const connection = createAcpConnection(); + const sessionUpdate = connection.__sessionUpdateMock; + const request = vi.fn(async (method: string, params?: unknown) => { + if (method === "sessions.list") { + return { + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: { + modelProvider: null, + model: null, + contextTokens: null, + }, + sessions: [ + { + key: "fast-session", + kind: "direct", + updatedAt: Date.now(), + thinkingLevel: "minimal", + modelProvider: "openai", + model: "gpt-5.4", + fastMode: true, + }, + ], + }; + } + if (method === "sessions.patch") { + expect(params).toEqual({ + key: "fast-session", + fastMode: true, + }); + } + return { ok: true }; + }) as GatewayClient["request"]; + const agent = new AcpGatewayAgent(connection, createAcpGateway(request), { + sessionStore, + }); + + await agent.loadSession(createLoadSessionRequest("fast-session")); + sessionUpdate.mockClear(); + + const result = await agent.setSessionConfigOption( + createSetSessionConfigOptionRequest("fast-session", "fast_mode", "on"), + ); + + expect(result.configOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "fast_mode", + currentValue: "on", + }), + ]), + ); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: "fast-session", + update: { + sessionUpdate: "config_option_update", + configOptions: expect.arrayContaining([ + expect.objectContaining({ + id: "fast_mode", + currentValue: "on", + }), + ]), + }, + }); + + sessionStore.clearAllSessionsForTest(); + }); + it("rejects non-string ACP config option values", async () => { const sessionStore = createInMemorySessionStore(); const connection = createAcpConnection(); diff --git a/src/acp/translator.ts b/src/acp/translator.ts index bb52db7b26b..b5a6802d07b 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -53,6 +53,7 @@ import { ACP_AGENT_INFO, type AcpServerOptions } from "./types.js"; // Maximum allowed prompt size (2MB) to prevent DoS via memory exhaustion (CWE-400, GHSA-cxpw-2g23-2vgw) const MAX_PROMPT_BYTES = 2 * 1024 * 1024; const ACP_THOUGHT_LEVEL_CONFIG_ID = "thought_level"; +const ACP_FAST_MODE_CONFIG_ID = "fast_mode"; const ACP_VERBOSE_LEVEL_CONFIG_ID = "verbose_level"; const ACP_REASONING_LEVEL_CONFIG_ID = "reasoning_level"; const ACP_RESPONSE_USAGE_CONFIG_ID = "response_usage"; @@ -88,6 +89,7 @@ type GatewaySessionPresentationRow = Pick< | "derivedTitle" | "updatedAt" | "thinkingLevel" + | "fastMode" | "modelProvider" | "model" | "verboseLevel" @@ -209,6 +211,13 @@ function buildSessionPresentation(params: { currentValue: currentModeId, values: availableLevelIds, }), + buildSelectConfigOption({ + id: ACP_FAST_MODE_CONFIG_ID, + name: "Fast mode", + description: "Controls whether OpenAI sessions use the Gateway fast-mode profile.", + currentValue: row.fastMode ? "on" : "off", + values: ["off", "on"], + }), buildSelectConfigOption({ id: ACP_VERBOSE_LEVEL_CONFIG_ID, name: "Tool verbosity", @@ -925,6 +934,7 @@ export class AcpGatewayAgent implements Agent { thinkingLevel: session.thinkingLevel, modelProvider: session.modelProvider, model: session.model, + fastMode: session.fastMode, verboseLevel: session.verboseLevel, reasoningLevel: session.reasoningLevel, responseUsage: session.responseUsage, @@ -940,7 +950,7 @@ export class AcpGatewayAgent implements Agent { value: string | boolean, ): { overrides: Partial; - patch: Record; + patch: Record; } { if (typeof value !== "string") { throw new Error( @@ -953,6 +963,11 @@ export class AcpGatewayAgent implements Agent { patch: { thinkingLevel: value }, overrides: { thinkingLevel: value }, }; + case ACP_FAST_MODE_CONFIG_ID: + return { + patch: { fastMode: value === "on" }, + overrides: { fastMode: value === "on" }, + }; case ACP_VERBOSE_LEVEL_CONFIG_ID: return { patch: { verboseLevel: value }, diff --git a/src/agents/fast-mode.ts b/src/agents/fast-mode.ts new file mode 100644 index 00000000000..bae3d5d300a --- /dev/null +++ b/src/agents/fast-mode.ts @@ -0,0 +1,50 @@ +import { normalizeFastMode } from "../auto-reply/thinking.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions.js"; + +export type FastModeState = { + enabled: boolean; + source: "session" | "config" | "default"; +}; + +function resolveConfiguredFastModeRaw(params: { + cfg: OpenClawConfig | undefined; + provider: string; + model: string; +}): unknown { + const modelKey = `${params.provider}/${params.model}`; + const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey]; + return modelConfig?.params?.fastMode ?? modelConfig?.params?.fast_mode; +} + +export function resolveConfiguredFastMode(params: { + cfg: OpenClawConfig | undefined; + provider: string; + model: string; +}): boolean { + return ( + normalizeFastMode( + resolveConfiguredFastModeRaw(params) as string | boolean | null | undefined, + ) ?? false + ); +} + +export function resolveFastModeState(params: { + cfg: OpenClawConfig | undefined; + provider: string; + model: string; + sessionEntry?: Pick | undefined; +}): FastModeState { + const sessionOverride = normalizeFastMode(params.sessionEntry?.fastMode); + if (sessionOverride !== undefined) { + return { enabled: sessionOverride, source: "session" }; + } + + const configuredRaw = resolveConfiguredFastModeRaw(params); + const configured = normalizeFastMode(configuredRaw as string | boolean | null | undefined); + if (configured !== undefined) { + return { enabled: configured, source: "config" }; + } + + return { enabled: false, source: "default" }; +} diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 04ada5e9ba6..468d62f9911 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -204,6 +204,7 @@ describe("applyExtraParamsToAgent", () => { | Model<"openai-completions">; options?: SimpleStreamOptions; cfg?: Record; + extraParamsOverride?: Record; payload?: Record; }) { const payload = params.payload ?? { store: false }; @@ -217,6 +218,7 @@ describe("applyExtraParamsToAgent", () => { params.cfg as Parameters[1], params.applyProvider, params.applyModelId, + params.extraParamsOverride, ); const context: Context = { messages: [] }; void agent.streamFn?.(params.model, context, params.options ?? {}); @@ -1627,6 +1629,80 @@ describe("applyExtraParamsToAgent", () => { expect(payload.service_tier).toBe("default"); }); + it("injects fast-mode payload defaults for direct OpenAI Responses", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + fastMode: true, + }, + }, + }, + }, + }, + }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + } as unknown as Model<"openai-responses">, + payload: { + store: false, + }, + }); + expect(payload.reasoning).toEqual({ effort: "low" }); + expect(payload.text).toEqual({ verbosity: "low" }); + expect(payload.service_tier).toBe("priority"); + }); + + it("preserves caller-provided OpenAI payload fields when fast mode is enabled", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai", + applyModelId: "gpt-5.4", + extraParamsOverride: { fastMode: true }, + model: { + api: "openai-responses", + provider: "openai", + id: "gpt-5.4", + baseUrl: "https://api.openai.com/v1", + } as unknown as Model<"openai-responses">, + payload: { + reasoning: { effort: "medium" }, + text: { verbosity: "high" }, + service_tier: "default", + }, + }); + expect(payload.reasoning).toEqual({ effort: "medium" }); + expect(payload.text).toEqual({ verbosity: "high" }); + expect(payload.service_tier).toBe("default"); + }); + + it("applies fast-mode defaults for openai-codex responses without service_tier", () => { + const payload = runResponsesPayloadMutationCase({ + applyProvider: "openai-codex", + applyModelId: "gpt-5.4", + extraParamsOverride: { fastMode: true }, + model: { + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.4", + baseUrl: "https://chatgpt.com/backend-api", + } as unknown as Model<"openai-codex-responses">, + payload: { + store: false, + }, + }); + expect(payload.reasoning).toEqual({ effort: "low" }); + expect(payload.text).toEqual({ verbosity: "low" }); + expect(payload).not.toHaveProperty("service_tier"); + }); + it("does not inject service_tier for non-openai providers", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "azure-openai-responses", diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 56ee8946cbd..230b6f1c853 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -22,8 +22,10 @@ import { import { createCodexDefaultTransportWrapper, createOpenAIDefaultTransportWrapper, + createOpenAIFastModeWrapper, createOpenAIResponsesContextManagementWrapper, createOpenAIServiceTierWrapper, + resolveOpenAIFastMode, resolveOpenAIServiceTier, } from "./openai-stream-wrappers.js"; import { @@ -437,6 +439,12 @@ export function applyExtraParamsToAgent( // upstream model-ID heuristics for Gemini 3.1 variants. agent.streamFn = createGoogleThinkingPayloadWrapper(agent.streamFn, thinkingLevel); + const openAIFastMode = resolveOpenAIFastMode(merged); + if (openAIFastMode) { + log.debug(`applying OpenAI fast mode for ${provider}/${modelId}`); + agent.streamFn = createOpenAIFastModeWrapper(agent.streamFn); + } + const openAIServiceTier = resolveOpenAIServiceTier(merged); if (openAIServiceTier) { log.debug(`applying OpenAI service_tier=${openAIServiceTier} for ${provider}/${modelId}`); diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index c9bc2304f97..d0b483e83ec 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -4,6 +4,7 @@ import { streamSimple } from "@mariozechner/pi-ai"; import { log } from "./logger.js"; type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; +type OpenAIReasoningEffort = "low" | "medium" | "high"; const OPENAI_RESPONSES_APIS = new Set(["openai-responses"]); const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai", "azure-openai-responses"]); @@ -168,6 +169,89 @@ export function resolveOpenAIServiceTier( return normalized; } +function normalizeOpenAIFastMode(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase(); + if ( + normalized === "on" || + normalized === "true" || + normalized === "yes" || + normalized === "1" || + normalized === "fast" + ) { + return true; + } + if ( + normalized === "off" || + normalized === "false" || + normalized === "no" || + normalized === "0" || + normalized === "normal" + ) { + return false; + } + return undefined; +} + +export function resolveOpenAIFastMode( + extraParams: Record | undefined, +): boolean | undefined { + const raw = extraParams?.fastMode ?? extraParams?.fast_mode; + const normalized = normalizeOpenAIFastMode(raw); + if (raw !== undefined && normalized === undefined) { + const rawSummary = typeof raw === "string" ? raw : typeof raw; + log.warn(`ignoring invalid OpenAI fast mode param: ${rawSummary}`); + } + return normalized; +} + +function resolveFastModeReasoningEffort(modelId: unknown): OpenAIReasoningEffort { + if (typeof modelId !== "string") { + return "low"; + } + const normalized = modelId.trim().toLowerCase(); + // Keep fast mode broadly compatible across GPT-5 family variants by using + // the lowest shared non-disabled effort that current transports accept. + if (normalized.startsWith("gpt-5")) { + return "low"; + } + return "low"; +} + +function applyOpenAIFastModePayloadOverrides(params: { + payloadObj: Record; + model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown }; +}): void { + if (params.payloadObj.reasoning === undefined) { + params.payloadObj.reasoning = { + effort: resolveFastModeReasoningEffort(params.model.id), + }; + } + + const existingText = params.payloadObj.text; + if (existingText === undefined) { + params.payloadObj.text = { verbosity: "low" }; + } else if (existingText && typeof existingText === "object" && !Array.isArray(existingText)) { + const textObj = existingText as Record; + if (textObj.verbosity === undefined) { + textObj.verbosity = "low"; + } + } + + if ( + params.model.provider === "openai" && + params.payloadObj.service_tier === undefined && + isOpenAIPublicApiBaseUrl(params.model.baseUrl) + ) { + params.payloadObj.service_tier = "priority"; + } +} + export function createOpenAIResponsesContextManagementWrapper( baseStreamFn: StreamFn | undefined, extraParams: Record | undefined, @@ -203,6 +287,31 @@ export function createOpenAIResponsesContextManagementWrapper( }; } +export function createOpenAIFastModeWrapper(baseStreamFn: StreamFn | undefined): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + return (model, context, options) => { + if ( + (model.api !== "openai-responses" && model.api !== "openai-codex-responses") || + (model.provider !== "openai" && model.provider !== "openai-codex") + ) { + return underlying(model, context, options); + } + const originalOnPayload = options?.onPayload; + return underlying(model, context, { + ...options, + onPayload: (payload) => { + if (payload && typeof payload === "object") { + applyOpenAIFastModePayloadOverrides({ + payloadObj: payload as Record, + model, + }); + } + return originalOnPayload?.(payload, model); + }, + }); + }; +} + export function createOpenAIServiceTierWrapper( baseStreamFn: StreamFn | undefined, serviceTier: OpenAIServiceTier, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 7db6e2f61c8..dce7ff919d4 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -892,6 +892,7 @@ export async function runEmbeddedPiAgent( agentId: workspaceResolution.agentId, legacyBeforeAgentStartResult, thinkLevel, + fastMode: params.fastMode, verboseLevel: params.verboseLevel, reasoningLevel: params.reasoningLevel, toolResultFormat: resolvedToolResultFormat, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6c4aaf44110..3457fdf0161 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1930,7 +1930,10 @@ export async function runEmbeddedAttempt( params.config, params.provider, params.modelId, - params.streamParams, + { + ...params.streamParams, + fastMode: params.fastMode, + }, params.thinkLevel, sessionAgentId, ); diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index bf65515ce46..ba69d991dd9 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -79,6 +79,7 @@ export type RunEmbeddedPiAgentParams = { authProfileId?: string; authProfileIdSource?: "auto" | "user"; thinkLevel?: ThinkLevel; + fastMode?: boolean; verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; toolResultFormat?: ToolResultFormat; diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 6a2bf205ffd..c499f03c526 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -597,6 +597,22 @@ function buildChatCommands(): ChatCommandDefinition[] { ], argsMenu: "auto", }), + defineChatCommand({ + key: "fast", + nativeName: "fast", + description: "Toggle fast mode.", + textAlias: "/fast", + category: "options", + args: [ + { + name: "mode", + description: "status, on, or off", + type: "string", + choices: ["status", "on", "off"], + }, + ], + argsMenu: "auto", + }), defineChatCommand({ key: "reasoning", nativeName: "reasoning", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index daff7304726..326211560ee 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -198,6 +198,17 @@ describe("commands registry", () => { ]); }); + it("registers fast mode as a first-class options command", () => { + const fast = listChatCommands().find((command) => command.key === "fast"); + expect(fast).toMatchObject({ + nativeName: "fast", + textAliases: ["/fast"], + category: "options", + }); + const modeArg = fast?.args?.find((arg) => arg.name === "mode"); + expect(modeArg?.choices).toEqual(["status", "on", "off"]); + }); + it("detects known text commands", () => { const detection = getCommandDetection(); expect(detection.exact.has("/commands")).toBe(true); diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts index 9908bad1653..0d7c2f9c936 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts @@ -4,6 +4,7 @@ import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.j import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { loadSessionStore } from "../config/sessions.js"; +import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; export { loadModelCatalog } from "../agents/model-catalog.js"; export { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; @@ -134,7 +135,7 @@ export function assertElevatedOffStatusReply(text: string | undefined) { export function installDirectiveBehaviorE2EHooks() { beforeEach(() => { - vi.mocked(runEmbeddedPiAgent).mockReset(); + runEmbeddedPiAgentMock.mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue(DEFAULT_TEST_MODEL_CATALOG); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index 2e6f63df210..a35f9b1bd1f 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -1,5 +1,5 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { loadSessionStore } from "../config/sessions.js"; import { @@ -10,10 +10,10 @@ import { makeRestrictedElevatedDisabledConfig, makeWhatsAppDirectiveConfig, replyText, - runEmbeddedPiAgent, sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; import { getReplyFromConfig } from "./reply.js"; const COMMAND_MESSAGE_BASE = { @@ -126,6 +126,18 @@ describe("directive behavior", () => { it("reports current directive defaults when no arguments are provided", async () => { await withTempHome(async (home) => { + const fastText = await runCommand(home, "/fast", { + defaults: { + models: { + "anthropic/claude-opus-4-5": { + params: { fastMode: true }, + }, + }, + }, + }); + expect(fastText).toContain("Current fast mode: on (config)"); + expect(fastText).toContain("Options: on, off."); + const verboseText = await runCommand(home, "/verbose", { defaults: { verboseDefault: "on" }, }); @@ -158,7 +170,28 @@ describe("directive behavior", () => { expect(execText).toContain( "Options: host=sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=.", ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); + }); + }); + it("persists fast toggles across /status and /fast", async () => { + await withTempHome(async (home) => { + const storePath = sessionStorePath(home); + + const onText = await runCommand(home, "/fast on"); + expect(onText).toContain("Fast mode enabled"); + expect(loadSessionStore(storePath)["agent:main:main"]?.fastMode).toBe(true); + + const statusText = await runCommand(home, "/status"); + const optionsLine = statusText?.split("\n").find((line) => line.trim().startsWith("⚙️")); + expect(optionsLine).toContain("Fast: on"); + + const offText = await runCommand(home, "/fast off"); + expect(offText).toContain("Fast mode disabled"); + expect(loadSessionStore(storePath)["agent:main:main"]?.fastMode).toBe(false); + + const fastText = await runCommand(home, "/fast"); + expect(fastText).toContain("Current fast mode: off"); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("persists elevated toggles across /status and /elevated", async () => { @@ -181,7 +214,7 @@ describe("directive behavior", () => { const store = loadSessionStore(storePath); expect(store["agent:main:main"]?.elevatedLevel).toBe("on"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("enforces per-agent elevated restrictions and status visibility", async () => { @@ -217,7 +250,7 @@ describe("directive behavior", () => { ); const statusText = replyText(statusRes); expect(statusText).not.toContain("elevated"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("applies per-agent allowlist requirements before allowing elevated", async () => { @@ -245,7 +278,7 @@ describe("directive behavior", () => { const allowedText = replyText(allowedRes); expect(allowedText).toContain("Elevated mode set to ask"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("handles runtime warning, invalid level, and multi-directive elevated inputs", async () => { @@ -280,7 +313,7 @@ describe("directive behavior", () => { expect(text).toContain(snippet); } } - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("persists queue overrides and reset behavior", async () => { @@ -317,12 +350,12 @@ describe("directive behavior", () => { expect(entry?.queueDebounceMs).toBeUndefined(); expect(entry?.queueCap).toBeUndefined(); expect(entry?.queueDrop).toBeUndefined(); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("strips inline elevated directives from the user text (does not persist session override)", async () => { await withTempHome(async (home) => { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + runEmbeddedPiAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1, @@ -346,7 +379,7 @@ describe("directive behavior", () => { const store = loadSessionStore(storePath); expect(store["agent:main:main"]?.elevatedLevel).toBeUndefined(); - const calls = vi.mocked(runEmbeddedPiAgent).mock.calls; + const calls = runEmbeddedPiAgentMock.mock.calls; expect(calls.length).toBeGreaterThan(0); const call = calls[0]?.[0]; expect(call?.prompt).toContain("hello there"); diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index bbaa3f0d0fc..6d0b484511c 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -8,7 +8,7 @@ import { extractThinkDirective, extractVerboseDirective, } from "./reply.js"; -import { extractStatusDirective } from "./reply/directives.js"; +import { extractFastDirective, extractStatusDirective } from "./reply/directives.js"; describe("directive parsing", () => { it("ignores verbose directive inside URL", () => { @@ -49,6 +49,12 @@ describe("directive parsing", () => { expect(res.reasoningLevel).toBe("stream"); }); + it("matches fast directive", () => { + const res = extractFastDirective("/fast on please"); + expect(res.hasDirective).toBe(true); + expect(res.fastMode).toBe(true); + }); + it("matches elevated with leading space", () => { const res = extractElevatedDirective(" please /elevated on now"); expect(res.hasDirective).toBe(true); @@ -106,6 +112,14 @@ describe("directive parsing", () => { expect(res.cleaned).toBe(""); }); + it("matches fast with no argument", () => { + const res = extractFastDirective("/fast:"); + expect(res.hasDirective).toBe(true); + expect(res.fastMode).toBeUndefined(); + expect(res.rawLevel).toBeUndefined(); + expect(res.cleaned).toBe(""); + }); + it("matches reasoning with no argument", () => { const res = extractReasoningDirective("/reasoning:"); expect(res.hasDirective).toBe(true); diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 894724bcfb0..ca67bbc3549 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -26,6 +26,7 @@ import { handlePluginCommand } from "./commands-plugin.js"; import { handleAbortTrigger, handleActivationCommand, + handleFastCommand, handleRestartCommand, handleSessionCommand, handleSendPolicyCommand, @@ -176,6 +177,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise { + if (!allowTextCommands) { + return null; + } + const normalized = params.command.commandBodyNormalized; + if (normalized !== "/fast" && !normalized.startsWith("/fast ")) { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /fast from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + + const rawArgs = normalized === "/fast" ? "" : normalized.slice("/fast".length).trim(); + const rawMode = rawArgs.toLowerCase(); + if (!rawMode || rawMode === "status") { + const state = resolveFastModeState({ + cfg: params.cfg, + provider: params.provider, + model: params.model, + sessionEntry: params.sessionEntry, + }); + const suffix = + state.source === "config" ? " (config)" : state.source === "default" ? " (default)" : ""; + return { + shouldContinue: false, + reply: { text: `⚙️ Current fast mode: ${state.enabled ? "on" : "off"}${suffix}.` }, + }; + } + + const nextMode = normalizeFastMode(rawMode); + if (nextMode === undefined) { + return { + shouldContinue: false, + reply: { text: "⚙️ Usage: /fast status|on|off" }, + }; + } + + if (params.sessionEntry && params.sessionStore && params.sessionKey) { + params.sessionEntry.fastMode = nextMode; + await persistSessionEntry(params); + } + + return { + shouldContinue: false, + reply: { text: `⚙️ Fast mode ${nextMode ? "enabled" : "disabled"}.` }, + }; +}; + export const handleSessionCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; diff --git a/src/auto-reply/reply/commands-status.ts b/src/auto-reply/reply/commands-status.ts index 50d007321c4..f802a7c6050 100644 --- a/src/auto-reply/reply/commands-status.ts +++ b/src/auto-reply/reply/commands-status.ts @@ -3,6 +3,7 @@ import { resolveDefaultAgentId, resolveSessionAgentId, } from "../../agents/agent-scope.js"; +import { resolveFastModeState } from "../../agents/fast-mode.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js"; import { @@ -40,6 +41,7 @@ export async function buildStatusReply(params: { model: string; contextTokens: number; resolvedThinkLevel?: ThinkLevel; + resolvedFastMode?: boolean; resolvedVerboseLevel: VerboseLevel; resolvedReasoningLevel: ReasoningLevel; resolvedElevatedLevel?: ElevatedLevel; @@ -60,6 +62,7 @@ export async function buildStatusReply(params: { model, contextTokens, resolvedThinkLevel, + resolvedFastMode, resolvedVerboseLevel, resolvedReasoningLevel, resolvedElevatedLevel, @@ -160,6 +163,14 @@ export async function buildStatusReply(params: { }) : selectedModelAuth; const agentDefaults = cfg.agents?.defaults ?? {}; + const effectiveFastMode = + resolvedFastMode ?? + resolveFastModeState({ + cfg, + provider, + model, + sessionEntry, + }).enabled; const statusText = buildStatusMessage({ config: cfg, agent: { @@ -181,6 +192,7 @@ export async function buildStatusReply(params: { sessionStorePath: storePath, groupActivation, resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), + resolvedFast: effectiveFastMode, resolvedVerbose: resolvedVerboseLevel, resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts index 43f58adcca3..4635c4073f8 100644 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ b/src/auto-reply/reply/directive-handling.fast-lane.ts @@ -48,12 +48,17 @@ export async function applyInlineDirectivesFastLane( } const agentCfg = params.agentCfg; - const { currentThinkLevel, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel } = - await resolveCurrentDirectiveLevels({ - sessionEntry, - agentCfg, - resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), - }); + const { + currentThinkLevel, + currentFastMode, + currentVerboseLevel, + currentReasoningLevel, + currentElevatedLevel, + } = await resolveCurrentDirectiveLevels({ + sessionEntry, + agentCfg, + resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), + }); const directiveAck = await handleDirectiveOnly({ cfg, @@ -77,6 +82,7 @@ export async function applyInlineDirectivesFastLane( initialModelLabel: params.initialModelLabel, formatModelSwitchEvent, currentThinkLevel, + currentFastMode, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 979304dfb1b..a994a3ccea6 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -3,6 +3,7 @@ import { resolveAgentDir, resolveSessionAgentId, } from "../../agents/agent-scope.js"; +import { resolveFastModeState } from "../../agents/fast-mode.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import type { OpenClawConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; @@ -78,6 +79,7 @@ export async function handleDirectiveOnly( initialModelLabel, formatModelSwitchEvent, currentThinkLevel, + currentFastMode, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, @@ -131,6 +133,15 @@ export async function handleDirectiveOnly( const resolvedProvider = modelSelection?.provider ?? provider; const resolvedModel = modelSelection?.model ?? model; + const fastModeState = resolveFastModeState({ + cfg: params.cfg, + provider: resolvedProvider, + model: resolvedModel, + sessionEntry, + }); + const effectiveFastMode = directives.fastMode ?? currentFastMode ?? fastModeState.enabled; + const effectiveFastModeSource = + directives.fastMode !== undefined ? "session" : fastModeState.source; if (directives.hasThinkDirective && !directives.thinkLevel) { // If no argument was provided, show the current level @@ -158,6 +169,25 @@ export async function handleDirectiveOnly( text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on, full.`, }; } + if (directives.hasFastDirective && directives.fastMode === undefined) { + if (!directives.rawFastMode) { + const sourceSuffix = + effectiveFastModeSource === "config" + ? " (config)" + : effectiveFastModeSource === "default" + ? " (default)" + : ""; + return { + text: withOptions( + `Current fast mode: ${effectiveFastMode ? "on" : "off"}${sourceSuffix}.`, + "on, off", + ), + }; + } + return { + text: `Unrecognized fast mode "${directives.rawFastMode}". Valid levels: on, off.`, + }; + } if (directives.hasReasoningDirective && !directives.reasoningLevel) { if (!directives.rawReasoningLevel) { const level = currentReasoningLevel ?? "off"; @@ -279,11 +309,18 @@ export async function handleDirectiveOnly( directives.elevatedLevel !== undefined && elevatedEnabled && elevatedAllowed; + const fastModeChanged = + directives.hasFastDirective && + directives.fastMode !== undefined && + directives.fastMode !== currentFastMode; let reasoningChanged = directives.hasReasoningDirective && directives.reasoningLevel !== undefined; if (directives.hasThinkDirective && directives.thinkLevel) { sessionEntry.thinkingLevel = directives.thinkLevel; } + if (directives.hasFastDirective && directives.fastMode !== undefined) { + sessionEntry.fastMode = directives.fastMode; + } if (shouldDowngradeXHigh) { sessionEntry.thinkingLevel = "high"; } @@ -380,6 +417,13 @@ export async function handleDirectiveOnly( : `Thinking level set to ${directives.thinkLevel}.`, ); } + if (directives.hasFastDirective && directives.fastMode !== undefined) { + parts.push( + directives.fastMode + ? formatDirectiveAck("Fast mode enabled.") + : formatDirectiveAck("Fast mode disabled."), + ); + } if (directives.hasVerboseDirective && directives.verboseLevel) { parts.push( directives.verboseLevel === "off" @@ -459,6 +503,12 @@ export async function handleDirectiveOnly( if (directives.hasQueueDirective && directives.dropPolicy) { parts.push(formatDirectiveAck(`Queue drop set to ${directives.dropPolicy}.`)); } + if (fastModeChanged) { + enqueueSystemEvent(`Fast mode ${sessionEntry.fastMode ? "enabled" : "disabled"}.`, { + sessionKey, + contextKey: `fast:${sessionEntry.fastMode ? "on" : "off"}`, + }); + } const ack = parts.join(" ").trim(); if (!ack && directives.hasStatusDirective) { return undefined; diff --git a/src/auto-reply/reply/directive-handling.levels.ts b/src/auto-reply/reply/directive-handling.levels.ts index ee7b1108e83..b62e77c3501 100644 --- a/src/auto-reply/reply/directive-handling.levels.ts +++ b/src/auto-reply/reply/directive-handling.levels.ts @@ -3,6 +3,7 @@ import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from ".. export async function resolveCurrentDirectiveLevels(params: { sessionEntry?: { thinkingLevel?: unknown; + fastMode?: unknown; verboseLevel?: unknown; reasoningLevel?: unknown; elevatedLevel?: unknown; @@ -15,6 +16,7 @@ export async function resolveCurrentDirectiveLevels(params: { resolveDefaultThinkingLevel: () => Promise; }): Promise<{ currentThinkLevel: ThinkLevel | undefined; + currentFastMode: boolean | undefined; currentVerboseLevel: VerboseLevel | undefined; currentReasoningLevel: ReasoningLevel; currentElevatedLevel: ElevatedLevel | undefined; @@ -24,6 +26,8 @@ export async function resolveCurrentDirectiveLevels(params: { (await params.resolveDefaultThinkingLevel()) ?? (params.agentCfg?.thinkingDefault as ThinkLevel | undefined); const currentThinkLevel = resolvedDefaultThinkLevel; + const currentFastMode = + typeof params.sessionEntry?.fastMode === "boolean" ? params.sessionEntry.fastMode : undefined; const currentVerboseLevel = (params.sessionEntry?.verboseLevel as VerboseLevel | undefined) ?? (params.agentCfg?.verboseDefault as VerboseLevel | undefined); @@ -34,6 +38,7 @@ export async function resolveCurrentDirectiveLevels(params: { (params.agentCfg?.elevatedDefault as ElevatedLevel | undefined); return { currentThinkLevel, + currentFastMode, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, diff --git a/src/auto-reply/reply/directive-handling.params.ts b/src/auto-reply/reply/directive-handling.params.ts index af6f0ff0d6d..fd64e379d0c 100644 --- a/src/auto-reply/reply/directive-handling.params.ts +++ b/src/auto-reply/reply/directive-handling.params.ts @@ -32,6 +32,7 @@ export type HandleDirectiveOnlyCoreParams = { export type HandleDirectiveOnlyParams = HandleDirectiveOnlyCoreParams & { currentThinkLevel?: ThinkLevel; + currentFastMode?: boolean; currentVerboseLevel?: VerboseLevel; currentReasoningLevel?: ReasoningLevel; currentElevatedLevel?: ElevatedLevel; diff --git a/src/auto-reply/reply/directive-handling.parse.ts b/src/auto-reply/reply/directive-handling.parse.ts index b09d5c553bc..81265b52809 100644 --- a/src/auto-reply/reply/directive-handling.parse.ts +++ b/src/auto-reply/reply/directive-handling.parse.ts @@ -6,6 +6,7 @@ import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./ import { extractElevatedDirective, extractExecDirective, + extractFastDirective, extractReasoningDirective, extractStatusDirective, extractThinkDirective, @@ -23,6 +24,9 @@ export type InlineDirectives = { hasVerboseDirective: boolean; verboseLevel?: VerboseLevel; rawVerboseLevel?: string; + hasFastDirective: boolean; + fastMode?: boolean; + rawFastMode?: string; hasReasoningDirective: boolean; reasoningLevel?: ReasoningLevel; rawReasoningLevel?: string; @@ -80,12 +84,18 @@ export function parseInlineDirectives( rawLevel: rawVerboseLevel, hasDirective: hasVerboseDirective, } = extractVerboseDirective(thinkCleaned); + const { + cleaned: fastCleaned, + fastMode, + rawLevel: rawFastMode, + hasDirective: hasFastDirective, + } = extractFastDirective(verboseCleaned); const { cleaned: reasoningCleaned, reasoningLevel, rawLevel: rawReasoningLevel, hasDirective: hasReasoningDirective, - } = extractReasoningDirective(verboseCleaned); + } = extractReasoningDirective(fastCleaned); const { cleaned: elevatedCleaned, elevatedLevel, @@ -151,6 +161,9 @@ export function parseInlineDirectives( hasVerboseDirective, verboseLevel, rawVerboseLevel, + hasFastDirective, + fastMode, + rawFastMode, hasReasoningDirective, reasoningLevel, rawReasoningLevel, @@ -201,6 +214,7 @@ export function isDirectiveOnly(params: { if ( !directives.hasThinkDirective && !directives.hasVerboseDirective && + !directives.hasFastDirective && !directives.hasReasoningDirective && !directives.hasElevatedDirective && !directives.hasExecDirective && diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts index e0bda738b6d..96a4dbecb2e 100644 --- a/src/auto-reply/reply/directives.ts +++ b/src/auto-reply/reply/directives.ts @@ -2,6 +2,7 @@ import { escapeRegExp } from "../../utils.js"; import type { NoticeLevel, ReasoningLevel } from "../thinking.js"; import { type ElevatedLevel, + normalizeFastMode, normalizeElevatedLevel, normalizeNoticeLevel, normalizeReasoningLevel, @@ -124,6 +125,24 @@ export function extractVerboseDirective(body?: string): { }; } +export function extractFastDirective(body?: string): { + cleaned: string; + fastMode?: boolean; + rawLevel?: string; + hasDirective: boolean; +} { + if (!body) { + return { cleaned: "", hasDirective: false }; + } + const extracted = extractLevelDirective(body, ["fast"], normalizeFastMode); + return { + cleaned: extracted.cleaned, + fastMode: extracted.level, + rawLevel: extracted.rawLevel, + hasDirective: extracted.hasDirective, + }; +} + export function extractNoticeDirective(body?: string): { cleaned: string; noticeLevel?: NoticeLevel; diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 4232171a82b..fa02e00f6b4 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -150,6 +150,7 @@ export async function applyInlineDirectiveOverrides(params: { } const { currentThinkLevel: resolvedDefaultThinkLevel, + currentFastMode, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, @@ -162,6 +163,7 @@ export async function applyInlineDirectiveOverrides(params: { const directiveReply = await handleDirectiveOnly({ ...createDirectiveHandlingBase(), currentThinkLevel, + currentFastMode, currentVerboseLevel, currentReasoningLevel, currentElevatedLevel, @@ -201,6 +203,7 @@ export async function applyInlineDirectiveOverrides(params: { const hasAnyDirective = directives.hasThinkDirective || + directives.hasFastDirective || directives.hasVerboseDirective || directives.hasReasoningDirective || directives.hasElevatedDirective || diff --git a/src/auto-reply/reply/get-reply-directives-utils.ts b/src/auto-reply/reply/get-reply-directives-utils.ts index 02c60a31fac..d507d71d86b 100644 --- a/src/auto-reply/reply/get-reply-directives-utils.ts +++ b/src/auto-reply/reply/get-reply-directives-utils.ts @@ -26,6 +26,9 @@ export function clearInlineDirectives(cleaned: string): InlineDirectives { hasVerboseDirective: false, verboseLevel: undefined, rawVerboseLevel: undefined, + hasFastDirective: false, + fastMode: undefined, + rawFastMode: undefined, hasReasoningDirective: false, reasoningLevel: undefined, rawReasoningLevel: undefined, diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts index a14798d8048..37eef3fb9b8 100644 --- a/src/auto-reply/reply/get-reply-directives.ts +++ b/src/auto-reply/reply/get-reply-directives.ts @@ -1,4 +1,5 @@ import type { ExecToolDefaults } from "../../agents/bash-tools.js"; +import { resolveFastModeState } from "../../agents/fast-mode.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import type { SkillCommandSpec } from "../../agents/skills.js"; @@ -37,6 +38,7 @@ export type ReplyDirectiveContinuation = { elevatedFailures: Array<{ gate: string; key: string }>; defaultActivation: ReturnType; resolvedThinkLevel: ThinkLevel | undefined; + resolvedFastMode: boolean; resolvedVerboseLevel: VerboseLevel | undefined; resolvedReasoningLevel: ReasoningLevel; resolvedElevatedLevel: ElevatedLevel; @@ -228,6 +230,7 @@ export async function resolveReplyDirectives(params: { const hasInlineDirective = parsedDirectives.hasThinkDirective || parsedDirectives.hasVerboseDirective || + parsedDirectives.hasFastDirective || parsedDirectives.hasReasoningDirective || parsedDirectives.hasElevatedDirective || parsedDirectives.hasExecDirective || @@ -260,6 +263,7 @@ export async function resolveReplyDirectives(params: { ...parsedDirectives, hasThinkDirective: false, hasVerboseDirective: false, + hasFastDirective: false, hasReasoningDirective: false, hasStatusDirective: false, hasModelDirective: false, @@ -340,6 +344,14 @@ export async function resolveReplyDirectives(params: { const defaultActivation = defaultGroupActivation(requireMention); const resolvedThinkLevel = directives.thinkLevel ?? (sessionEntry?.thinkingLevel as ThinkLevel | undefined); + const resolvedFastMode = + directives.fastMode ?? + resolveFastModeState({ + cfg, + provider, + model, + sessionEntry, + }).enabled; const resolvedVerboseLevel = directives.verboseLevel ?? @@ -479,6 +491,7 @@ export async function resolveReplyDirectives(params: { elevatedFailures, defaultActivation, resolvedThinkLevel: resolvedThinkLevelWithDefault, + resolvedFastMode, resolvedVerboseLevel, resolvedReasoningLevel, resolvedElevatedLevel, diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index e133585411a..c312e1144e4 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -30,8 +30,13 @@ import type { createModelSelectionState } from "./model-selection.js"; import { extractInlineSimpleCommand } from "./reply-inline.js"; import type { TypingController } from "./typing.js"; -const builtinSlashCommands = (() => { - return listReservedChatSlashCommandNames([ +let builtinSlashCommands: Set | null = null; + +function getBuiltinSlashCommands(): Set { + if (builtinSlashCommands) { + return builtinSlashCommands; + } + builtinSlashCommands = listReservedChatSlashCommandNames([ "think", "verbose", "reasoning", @@ -41,7 +46,8 @@ const builtinSlashCommands = (() => { "status", "queue", ]); -})(); + return builtinSlashCommands; +} function resolveSlashCommandName(commandBodyNormalized: string): string | null { const trimmed = commandBodyNormalized.trim(); @@ -163,7 +169,7 @@ export async function handleInlineActions(params: { allowTextCommands && slashCommandName !== null && // `/skill …` needs the full skill command list. - (slashCommandName === "skill" || !builtinSlashCommands.has(slashCommandName)); + (slashCommandName === "skill" || !getBuiltinSlashCommands().has(slashCommandName)); const skillCommands = shouldLoadSkillCommands && params.skillCommands ? params.skillCommands diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index dceac522eca..760c42aed1a 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; import type { ExecToolDefaults } from "../../agents/bash-tools.js"; +import { resolveFastModeState } from "../../agents/fast-mode.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, @@ -509,6 +510,12 @@ export async function runPreparedReply( authProfileId, authProfileIdSource, thinkLevel: resolvedThinkLevel, + fastMode: resolveFastModeState({ + cfg, + provider, + model, + sessionEntry, + }).enabled, verboseLevel: resolvedVerboseLevel, reasoningLevel: resolvedReasoningLevel, elevatedLevel: resolvedElevatedLevel, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index e58f03e0c13..b416c1e3ef7 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -113,6 +113,23 @@ describe("buildStatusMessage", () => { expect(normalized).toContain("Reasoning: on"); }); + it("shows fast mode when enabled", () => { + const text = buildStatusMessage({ + agent: { + model: "openai/gpt-5.4", + }, + sessionEntry: { + sessionId: "fast", + updatedAt: 0, + fastMode: true, + }, + sessionKey: "agent:main:main", + queue: { mode: "collect", depth: 0 }, + }); + + expect(normalizeTestText(text)).toContain("Fast: on"); + }); + it("notes channel model overrides in status output", () => { const text = buildStatusMessage({ config: { @@ -708,6 +725,10 @@ describe("buildHelpMessage", () => { expect(text).not.toContain("/config"); expect(text).not.toContain("/debug"); }); + + it("includes /fast in help output", () => { + expect(buildHelpMessage()).toContain("/fast on|off"); + }); }); describe("buildCommandsMessagePaginated", () => { diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index d4c5e0c18bb..1b7aa2a87ec 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -77,6 +77,7 @@ type StatusArgs = { sessionStorePath?: string; groupActivation?: "mention" | "always"; resolvedThink?: ThinkLevel; + resolvedFast?: boolean; resolvedVerbose?: VerboseLevel; resolvedReasoning?: ReasoningLevel; resolvedElevated?: ElevatedLevel; @@ -510,6 +511,7 @@ export function buildStatusMessage(args: StatusArgs): string { args.resolvedThink ?? args.sessionEntry?.thinkingLevel ?? args.agent?.thinkingDefault ?? "off"; const verboseLevel = args.resolvedVerbose ?? args.sessionEntry?.verboseLevel ?? args.agent?.verboseDefault ?? "off"; + const fastMode = args.resolvedFast ?? args.sessionEntry?.fastMode ?? false; const reasoningLevel = args.resolvedReasoning ?? args.sessionEntry?.reasoningLevel ?? "off"; const elevatedLevel = args.resolvedElevated ?? @@ -556,6 +558,7 @@ export function buildStatusMessage(args: StatusArgs): string { const optionParts = [ `Runtime: ${runtime.label}`, `Think: ${thinkLevel}`, + fastMode ? "Fast: on" : null, verboseLabel, reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null, elevatedLabel, @@ -728,7 +731,7 @@ export function buildHelpMessage(cfg?: OpenClawConfig): string { lines.push(" /new | /reset | /compact [instructions] | /stop"); lines.push(""); - const optionParts = ["/think ", "/model ", "/verbose on|off"]; + const optionParts = ["/think ", "/model ", "/fast on|off", "/verbose on|off"]; if (isCommandFlagEnabled(cfg, "config")) { optionParts.push("/config"); } diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index faaf5e39b13..639db68eafb 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -218,6 +218,24 @@ export function resolveResponseUsageMode(raw?: string | null): UsageDisplayLevel return normalizeUsageDisplay(raw) ?? "off"; } +// Normalize fast-mode flags used to toggle low-latency model behavior. +export function normalizeFastMode(raw?: string | boolean | null): boolean | undefined { + if (typeof raw === "boolean") { + return raw; + } + if (!raw) { + return undefined; + } + const key = raw.toLowerCase(); + if (["off", "false", "no", "0", "disable", "disabled", "normal"].includes(key)) { + return false; + } + if (["on", "true", "yes", "1", "enable", "enabled", "fast"].includes(key)) { + return true; + } + return undefined; +} + // Normalize elevated flags used to toggle elevated bash permissions. export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | undefined { if (!raw) { diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index 18931aad4bf..66d0209bdfb 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -15,6 +15,8 @@ export type AgentStreamParams = { /** Provider stream params override (best-effort). */ temperature?: number; maxTokens?: number; + /** Provider fast-mode override (best-effort). */ + fastMode?: boolean; }; export type AgentRunContext = { diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index 79a51f0d9d3..b84bada07ff 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -36,6 +36,9 @@ const buildFlags = (entry?: SessionEntry): string[] => { if (typeof verbose === "string" && verbose.length > 0) { flags.push(`verbose:${verbose}`); } + if (typeof entry?.fastMode === "boolean") { + flags.push(entry.fastMode ? "fast" : "fast:off"); + } const reasoning = entry?.reasoningLevel; if (typeof reasoning === "string" && reasoning.length > 0) { flags.push(`reasoning:${reasoning}`); @@ -170,6 +173,7 @@ export async function getStatusSummary( updatedAt, age, thinkingLevel: entry?.thinkingLevel, + fastMode: entry?.fastMode, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, elevatedLevel: entry?.elevatedLevel, diff --git a/src/commands/status.types.ts b/src/commands/status.types.ts index ec157b3488a..de680f1665f 100644 --- a/src/commands/status.types.ts +++ b/src/commands/status.types.ts @@ -8,6 +8,7 @@ export type SessionStatus = { updatedAt: number | null; age: number | null; thinkingLevel?: string; + fastMode?: boolean; verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 0ae44b2db7a..4ba9b336127 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -100,6 +100,7 @@ export type SessionEntry = { abortCutoffTimestamp?: number; chatType?: SessionChatType; thinkingLevel?: string; + fastMode?: boolean; verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 30595c15698..743700b9a48 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -52,6 +52,7 @@ export const SessionsPatchParamsSchema = Type.Object( key: NonEmptyString, label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + fastMode: Type.Optional(Type.Union([Type.Boolean(), Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), responseUsage: Type.Optional( diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 98466f91044..ee08425b7fd 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -379,6 +379,7 @@ export const agentHandlers: GatewayRequestHandlers = { sessionId, updatedAt: now, thinkingLevel: entry?.thinkingLevel, + fastMode: entry?.fastMode, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, systemSent: entry?.systemSent, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 857868c59a5..909d933ae81 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -980,6 +980,7 @@ export const chatHandlers: GatewayRequestHandlers = { sessionId, messages: bounded.messages, thinkingLevel, + fastMode: entry?.fastMode, verboseLevel, }); }, diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 169b0040297..b36ca9aca50 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -166,6 +166,7 @@ async function touchSessionStore(params: { sessionId: params.sessionId, updatedAt: params.now, thinkingLevel: params.entry?.thinkingLevel, + fastMode: params.entry?.fastMode, verboseLevel: params.entry?.verboseLevel, reasoningLevel: params.entry?.reasoningLevel, systemSent: params.entry?.systemSent, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 5646a975489..8ef4a999936 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -326,6 +326,7 @@ export async function performGatewaySessionReset(params: { systemSent: false, abortedLastRun: false, thinkingLevel: currentEntry?.thinkingLevel, + fastMode: currentEntry?.fastMode, verboseLevel: currentEntry?.verboseLevel, reasoningLevel: currentEntry?.reasoningLevel, responseUsage: currentEntry?.responseUsage, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 8867d17a460..591799879b9 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -929,6 +929,7 @@ export function listSessionsFromStore(params: { systemSent: entry?.systemSent, abortedLastRun: entry?.abortedLastRun, thinkingLevel: entry?.thinkingLevel, + fastMode: entry?.fastMode, verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, elevatedLevel: entry?.elevatedLevel, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 80873b0000c..200df4459e9 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -32,6 +32,7 @@ export type GatewaySessionRow = { systemSent?: boolean; abortedLastRun?: boolean; thinkingLevel?: string; + fastMode?: boolean; verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index 79e332f23ba..478e360ecaf 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -149,6 +149,37 @@ describe("gateway sessions patch", () => { expect(entry.reasoningLevel).toBeUndefined(); }); + test("persists fastMode=false (does not clear)", async () => { + const entry = expectPatchOk( + await runPatch({ + patch: { key: MAIN_SESSION_KEY, fastMode: false }, + }), + ); + expect(entry.fastMode).toBe(false); + }); + + test("persists fastMode=true", async () => { + const entry = expectPatchOk( + await runPatch({ + patch: { key: MAIN_SESSION_KEY, fastMode: true }, + }), + ); + expect(entry.fastMode).toBe(true); + }); + + test("clears fastMode when patch sets null", async () => { + const store: Record = { + [MAIN_SESSION_KEY]: { fastMode: true } as SessionEntry, + }; + const entry = expectPatchOk( + await runPatch({ + store, + patch: { key: MAIN_SESSION_KEY, fastMode: null }, + }), + ); + expect(entry.fastMode).toBeUndefined(); + }); + test("persists elevatedLevel=off (does not clear)", async () => { const entry = expectPatchOk( await runPatch({ diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 66010e4745c..18b542302f6 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -11,6 +11,7 @@ import { formatThinkingLevels, formatXHighModelHint, normalizeElevatedLevel, + normalizeFastMode, normalizeReasoningLevel, normalizeThinkLevel, normalizeUsageDisplay, @@ -252,6 +253,19 @@ export async function applySessionsPatchToStore(params: { } } + if ("fastMode" in patch) { + const raw = patch.fastMode; + if (raw === null) { + delete next.fastMode; + } else if (raw !== undefined) { + const normalized = normalizeFastMode(raw); + if (normalized === undefined) { + return invalid("invalid fastMode (use true or false)"); + } + next.fastMode = normalized; + } + } + if ("verboseLevel" in patch) { const raw = patch.verboseLevel; const parsed = parseVerboseOverride(raw); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 039f213032e..8d074920a48 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -4,6 +4,7 @@ import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thi import type { OpenClawConfig } from "../config/types.js"; const VERBOSE_LEVELS = ["on", "off"]; +const FAST_LEVELS = ["status", "on", "off"]; const REASONING_LEVELS = ["on", "off"]; const ELEVATED_LEVELS = ["on", "off", "ask", "full"]; const ACTIVATION_LEVELS = ["mention", "always"]; @@ -52,6 +53,7 @@ export function parseCommand(input: string): ParsedCommand { export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] { const thinkLevels = listThinkingLevelLabels(options.provider, options.model); const verboseCompletions = createLevelCompletion(VERBOSE_LEVELS); + const fastCompletions = createLevelCompletion(FAST_LEVELS); const reasoningCompletions = createLevelCompletion(REASONING_LEVELS); const usageCompletions = createLevelCompletion(USAGE_FOOTER_LEVELS); const elevatedCompletions = createLevelCompletion(ELEVATED_LEVELS); @@ -76,6 +78,11 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman .filter((v) => v.startsWith(prefix.toLowerCase())) .map((value) => ({ value, label: value })), }, + { + name: "fast", + description: "Set fast mode on/off", + getArgumentCompletions: fastCompletions, + }, { name: "verbose", description: "Set verbose on/off", @@ -142,6 +149,7 @@ export function helpText(options: SlashCommandOptions = {}): string { "/session (or /sessions)", "/model (or /models)", `/think <${thinkLevels}>`, + "/fast ", "/verbose ", "/reasoning ", "/usage ", diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 313d87b690d..ed6c3479d05 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -79,6 +79,7 @@ export type GatewaySessionList = { Pick< SessionInfo, | "thinkingLevel" + | "fastMode" | "verboseLevel" | "reasoningLevel" | "model" @@ -92,6 +93,7 @@ export type GatewaySessionList = { key: string; sessionId?: string; updatedAt?: number | null; + fastMode?: boolean; sendPolicy?: string; responseUsage?: ResponseUsageMode; label?: string; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index ced4f99b7e7..dd5113a17af 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -345,6 +345,27 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem(`verbose failed: ${String(err)}`); } break; + case "fast": + if (!args || args === "status") { + chatLog.addSystem(`fast mode: ${state.sessionInfo.fastMode ? "on" : "off"}`); + break; + } + if (args !== "on" && args !== "off") { + chatLog.addSystem("usage: /fast "); + break; + } + try { + const result = await client.patchSession({ + key: state.currentSessionKey, + fastMode: args === "on", + }); + chatLog.addSystem(`fast mode ${args === "on" ? "enabled" : "disabled"}`); + applySessionInfoFromPatch(result); + await refreshSessionInfo(); + } catch (err) { + chatLog.addSystem(`fast failed: ${String(err)}`); + } + break; case "reasoning": if (!args) { chatLog.addSystem("usage: /reasoning "); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 55a4074fd19..406b584599f 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -165,6 +165,9 @@ export function createSessionActions(context: SessionActionContext) { if (entry?.thinkingLevel !== undefined) { next.thinkingLevel = entry.thinkingLevel; } + if (entry?.fastMode !== undefined) { + next.fastMode = entry.fastMode; + } if (entry?.verboseLevel !== undefined) { next.verboseLevel = entry.verboseLevel; } @@ -286,10 +289,12 @@ export function createSessionActions(context: SessionActionContext) { messages?: unknown[]; sessionId?: string; thinkingLevel?: string; + fastMode?: boolean; verboseLevel?: string; }; state.currentSessionId = typeof record.sessionId === "string" ? record.sessionId : null; state.sessionInfo.thinkingLevel = record.thinkingLevel ?? state.sessionInfo.thinkingLevel; + state.sessionInfo.fastMode = record.fastMode ?? state.sessionInfo.fastMode; state.sessionInfo.verboseLevel = record.verboseLevel ?? state.sessionInfo.verboseLevel; const showTools = (state.sessionInfo.verboseLevel ?? "off") !== "off"; chatLog.clearAll(); diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index e0af351d462..0f780b0a6bb 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -28,6 +28,7 @@ export type ResponseUsageMode = "on" | "off" | "tokens" | "full"; export type SessionInfo = { thinkingLevel?: string; + fastMode?: boolean; verboseLevel?: string; reasoningLevel?: string; model?: string; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 28ea21d85fb..e1eae539f50 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -752,6 +752,7 @@ export async function runTui(opts: TuiOptions) { : "unknown"; const tokens = formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null); const think = sessionInfo.thinkingLevel ?? "off"; + const fast = sessionInfo.fastMode === true; const verbose = sessionInfo.verboseLevel ?? "off"; const reasoning = sessionInfo.reasoningLevel ?? "off"; const reasoningLabel = @@ -761,6 +762,7 @@ export async function runTui(opts: TuiOptions) { `session ${sessionLabel}`, modelLabel, think !== "off" ? `think ${think}` : null, + fast ? "fast" : null, verbose !== "off" ? `verbose ${verbose}` : null, reasoningLabel, tokens, diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index ca30fdc54d5..d08c62b97d9 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -378,4 +378,42 @@ describe("executeSlashCommand directives", () => { expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off."); expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); }); + + it("reports the current fast mode for bare /fast", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [row("agent:main:main", { fastMode: true })], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "fast", + "", + ); + + expect(result.content).toBe("Current fast mode: on.\nOptions: status, on, off."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); + + it("patches fast mode for /fast on", async () => { + const request = vi.fn().mockResolvedValue({ ok: true }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "fast", + "on", + ); + + expect(result.content).toBe("Fast mode enabled."); + expect(request).toHaveBeenCalledWith("sessions.patch", { + key: "agent:main:main", + fastMode: true, + }); + }); }); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index e44d51430ad..d595bbab8d8 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -63,6 +63,8 @@ export async function executeSlashCommand( return await executeModel(client, sessionKey, args); case "think": return await executeThink(client, sessionKey, args); + case "fast": + return await executeFast(client, sessionKey, args); case "verbose": return await executeVerbose(client, sessionKey, args); case "export": @@ -252,6 +254,44 @@ async function executeVerbose( } } +async function executeFast( + client: GatewayBrowserClient, + sessionKey: string, + args: string, +): Promise { + const rawMode = args.trim().toLowerCase(); + + if (!rawMode || rawMode === "status") { + try { + const session = await loadCurrentSession(client, sessionKey); + return { + content: formatDirectiveOptions( + `Current fast mode: ${resolveCurrentFastMode(session)}.`, + "status, on, off", + ), + }; + } catch (err) { + return { content: `Failed to get fast mode: ${String(err)}` }; + } + } + + if (rawMode !== "on" && rawMode !== "off") { + return { + content: `Unrecognized fast mode "${args.trim()}". Valid levels: status, on, off.`, + }; + } + + try { + await client.request("sessions.patch", { key: sessionKey, fastMode: rawMode === "on" }); + return { + content: `Fast mode ${rawMode === "on" ? "enabled" : "disabled"}.`, + action: "refresh", + }; + } catch (err) { + return { content: `Failed to set fast mode: ${String(err)}` }; + } +} + async function executeUsage( client: GatewayBrowserClient, sessionKey: string, @@ -534,6 +574,10 @@ function resolveCurrentThinkingLevel( }); } +function resolveCurrentFastMode(session: GatewaySessionRow | undefined): "on" | "off" { + return session?.fastMode === true ? "on" : "off"; +} + function fmtTokens(n: number): string { if (n >= 1_000_000) { return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts index cb07109df9f..eb5f62c62ee 100644 --- a/ui/src/ui/chat/slash-commands.node.test.ts +++ b/ui/src/ui/chat/slash-commands.node.test.ts @@ -23,4 +23,11 @@ describe("parseSlashCommand", () => { args: "full", }); }); + + it("parses fast commands", () => { + expect(parseSlashCommand("/fast:on")).toMatchObject({ + command: { name: "fast" }, + args: "on", + }); + }); }); diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts index 4327d8e765b..e7b4cd98178 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/ui/chat/slash-commands.ts @@ -88,6 +88,15 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [ executeLocal: true, argOptions: ["on", "off", "full"], }, + { + name: "fast", + description: "Toggle fast mode", + args: "", + icon: "zap", + category: "model", + executeLocal: true, + argOptions: ["status", "on", "off"], + }, // ── Tools ── { diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 9421a656081..c1d2f44d20c 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -63,6 +63,7 @@ export async function patchSession( patch: { label?: string | null; thinkingLevel?: string | null; + fastMode?: boolean | null; verboseLevel?: string | null; reasoningLevel?: string | null; }, @@ -77,6 +78,9 @@ export async function patchSession( if ("thinkingLevel" in patch) { params.thinkingLevel = patch.thinkingLevel; } + if ("fastMode" in patch) { + params.fastMode = patch.fastMode; + } if ("verboseLevel" in patch) { params.verboseLevel = patch.verboseLevel; } diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 3627d0a6ea0..17ff4293afa 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -379,6 +379,7 @@ export type GatewaySessionRow = { systemSent?: boolean; abortedLastRun?: boolean; thinkingLevel?: string; + fastMode?: boolean; verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; @@ -396,6 +397,7 @@ export type SessionsPatchResult = SessionsPatchResultBase<{ sessionId: string; updatedAt?: number; thinkingLevel?: string; + fastMode?: boolean; verboseLevel?: string; reasoningLevel?: string; elevatedLevel?: string; diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 1fa65450589..fe650fef8fb 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -60,7 +60,7 @@ describe("sessions view", () => { await Promise.resolve(); const selects = container.querySelectorAll("select"); - const verbose = selects[1] as HTMLSelectElement | undefined; + const verbose = selects[2] as HTMLSelectElement | undefined; expect(verbose?.value).toBe("full"); expect(Array.from(verbose?.options ?? []).some((option) => option.value === "full")).toBe(true); }); @@ -83,10 +83,32 @@ describe("sessions view", () => { await Promise.resolve(); const selects = container.querySelectorAll("select"); - const reasoning = selects[2] as HTMLSelectElement | undefined; + const reasoning = selects[3] as HTMLSelectElement | undefined; expect(reasoning?.value).toBe("custom-mode"); expect( Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"), ).toBe(true); }); + + it("renders explicit fast mode without falling back to inherit", async () => { + const container = document.createElement("div"); + render( + renderSessions( + buildProps( + buildResult({ + key: "agent:main:main", + kind: "direct", + updatedAt: Date.now(), + fastMode: true, + }), + ), + ), + container, + ); + await Promise.resolve(); + + const selects = container.querySelectorAll("select"); + const fast = selects[1] as HTMLSelectElement | undefined; + expect(fast?.value).toBe("on"); + }); }); diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index bb1bef96d38..2620ec35acf 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -37,6 +37,7 @@ export type SessionsProps = { patch: { label?: string | null; thinkingLevel?: string | null; + fastMode?: boolean | null; verboseLevel?: string | null; reasoningLevel?: string | null; }, @@ -52,6 +53,11 @@ const VERBOSE_LEVELS = [ { value: "on", label: "on" }, { value: "full", label: "full" }, ] as const; +const FAST_LEVELS = [ + { value: "", label: "inherit" }, + { value: "on", label: "on" }, + { value: "off", label: "off" }, +] as const; const REASONING_LEVELS = ["", "off", "on", "stream"] as const; const PAGE_SIZES = [10, 25, 50, 100] as const; @@ -306,6 +312,7 @@ export function renderSessions(props: SessionsProps) { ${sortHeader("updated", "Updated")} ${sortHeader("tokens", "Tokens")} Thinking + Fast Verbose Reasoning @@ -316,7 +323,7 @@ export function renderSessions(props: SessionsProps) { paginated.length === 0 ? html` - + No sessions found. @@ -390,6 +397,8 @@ function renderRow( const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider); const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking); const thinkLevels = withCurrentOption(resolveThinkLevelOptions(row.modelProvider), thinking); + const fastMode = row.fastMode === true ? "on" : row.fastMode === false ? "off" : ""; + const fastLevels = withCurrentLabeledOption(FAST_LEVELS, fastMode); const verbose = row.verboseLevel ?? ""; const verboseLevels = withCurrentLabeledOption(VERBOSE_LEVELS, verbose); const reasoning = row.reasoningLevel ?? ""; @@ -465,6 +474,23 @@ function renderRow( )} + + +