openclaw/src/gateway/sessions-patch.ts

360 lines
11 KiB
TypeScript
Raw Normal View History

import { randomUUID } from "node:crypto";
2026-02-18 01:29:02 +00:00
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import { resolveAllowedModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
2026-01-07 17:17:38 -08:00
formatThinkingLevels,
formatXHighModelHint,
normalizeElevatedLevel,
normalizeReasoningLevel,
normalizeThinkLevel,
normalizeUsageDisplay,
2026-01-07 17:17:38 -08:00
supportsXHighThinking,
} from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js";
import { parseSessionLabel } from "../sessions/session-label.js";
import {
ErrorCodes,
type ErrorShape,
errorShape,
type SessionsPatchParams,
} from "./protocol/index.js";
function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
}
2026-01-18 06:11:38 +00:00
function normalizeExecHost(raw: string): "sandbox" | "gateway" | "node" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return undefined;
}
function normalizeExecSecurity(raw: string): "deny" | "allowlist" | "full" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return undefined;
}
function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined {
const normalized = raw.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized;
}
return undefined;
}
export async function applySessionsPatchToStore(params: {
2026-01-30 03:15:10 +01:00
cfg: OpenClawConfig;
store: Record<string, SessionEntry>;
storeKey: string;
patch: SessionsPatchParams;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
const { cfg, store, storeKey, patch } = params;
const now = Date.now();
const parsedAgent = parseAgentSessionKey(storeKey);
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
const resolvedDefault = resolveDefaultModelForAgent({ cfg, agentId: sessionAgentId });
const existing = store[storeKey];
const next: SessionEntry = existing
? {
...existing,
updatedAt: Math.max(existing.updatedAt ?? 0, now),
}
: { sessionId: randomUUID(), updatedAt: now };
if ("spawnedBy" in patch) {
const raw = patch.spawnedBy;
if (raw === null) {
if (existing?.spawnedBy) {
return invalid("spawnedBy cannot be cleared once set");
}
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
return invalid("invalid spawnedBy: empty");
}
if (!isSubagentSessionKey(storeKey)) {
return invalid("spawnedBy is only supported for subagent:* sessions");
}
if (existing?.spawnedBy && existing.spawnedBy !== trimmed) {
return invalid("spawnedBy cannot be changed once set");
}
next.spawnedBy = trimmed;
}
}
Agents: add nested subagent orchestration controls and reduce subagent token waste (#14447) * Agents: add subagent orchestration controls * Agents: add subagent orchestration controls (WIP uncommitted changes) * feat(subagents): add depth-based spawn gating for sub-sub-agents * feat(subagents): tool policy, registry, and announce chain for nested agents * feat(subagents): system prompt, docs, changelog for nested sub-agents * fix(subagents): prevent model fallback override, show model during active runs, and block context overflow fallback Bug 1: When a session has an explicit model override (e.g., gpt/openai-codex), the fallback candidate logic in resolveFallbackCandidates silently appended the global primary model (opus) as a backstop. On reinjection/steer with a transient error, the session could fall back to opus which has a smaller context window and crash. Fix: when storedModelOverride is set, pass fallbacksOverride ?? [] instead of undefined, preventing the implicit primary backstop. Bug 2: Active subagents showed 'model n/a' in /subagents list because resolveModelDisplay only read entry.model/modelProvider (populated after run completes). Fix: fall back to modelOverride/providerOverride fields which are populated at spawn time via sessions.patch. Bug 3: Context overflow errors (prompt too long, context_length_exceeded) could theoretically escape runEmbeddedPiAgent and be treated as failover candidates in runWithModelFallback, causing a switch to a model with a smaller context window. Fix: in runWithModelFallback, detect context overflow errors via isLikelyContextOverflowError and rethrow them immediately instead of trying the next model candidate. * fix(subagents): track spawn depth in session store and fix announce routing for nested agents * Fix compaction status tracking and dedupe overflow compaction triggers * fix(subagents): enforce depth block via session store and implement cascade kill * fix: inject group chat context into system prompt * fix(subagents): always write model to session store at spawn time * Preserve spawnDepth when agent handler rewrites session entry * fix(subagents): suppress announce on steer-restart * fix(subagents): fallback spawned session model to runtime default * fix(subagents): enforce spawn depth when caller key resolves by sessionId * feat(subagents): implement active-first ordering for numeric targets and enhance task display - Added a test to verify that subagents with numeric targets follow an active-first list ordering. - Updated `resolveSubagentTarget` to sort subagent runs based on active status and recent activity. - Enhanced task display in command responses to prevent truncation of long task descriptions. - Introduced new utility functions for compacting task text and managing subagent run states. * fix(subagents): show model for active runs via run record fallback When the spawned model matches the agent's default model, the session store's override fields are intentionally cleared (isDefault: true). The model/modelProvider fields are only populated after the run completes. This left active subagents showing 'model n/a'. Fix: store the resolved model on SubagentRunRecord at registration time, and use it as a fallback in both display paths (subagents tool and /subagents command) when the session store entry has no model info. Changes: - SubagentRunRecord: add optional model field - registerSubagentRun: accept and persist model param - sessions-spawn-tool: pass resolvedModel to registerSubagentRun - subagents-tool: pass run record model as fallback to resolveModelDisplay - commands-subagents: pass run record model as fallback to resolveModelDisplay * feat(chat): implement session key resolution and reset on sidebar navigation - Added functions to resolve the main session key and reset chat state when switching sessions from the sidebar. - Updated the `renderTab` function to handle session key changes when navigating to the chat tab. - Introduced a test to verify that the session resets to "main" when opening chat from the sidebar navigation. * fix: subagent timeout=0 passthrough and fallback prompt duplication Bug 1: runTimeoutSeconds=0 now means 'no timeout' instead of applying 600s default - sessions-spawn-tool: default to undefined (not 0) when neither timeout param is provided; use != null check so explicit 0 passes through to gateway - agent.ts: accept 0 as valid timeout (resolveAgentTimeoutMs already handles 0 → MAX_SAFE_TIMEOUT_MS) Bug 2: model fallback no longer re-injects the original prompt as a duplicate - agent.ts: track fallback attempt index; on retries use a short continuation message instead of the full original prompt since the session file already contains it from the first attempt - Also skip re-sending images on fallback retries (already in session) * feat(subagents): truncate long task descriptions in subagents command output - Introduced a new utility function to format task previews, limiting their length to improve readability. - Updated the command handler to use the new formatting function, ensuring task descriptions are truncated appropriately. - Adjusted related tests to verify that long task descriptions are now truncated in the output. * refactor(subagents): update subagent registry path resolution and improve command output formatting - Replaced direct import of STATE_DIR with a utility function to resolve the state directory dynamically. - Enhanced the formatting of command output for active and recent subagents, adding separators for better readability. - Updated related tests to reflect changes in command output structure. * fix(subagent): default sessions_spawn to no timeout when runTimeoutSeconds omitted The previous fix (75a791106) correctly handled the case where runTimeoutSeconds was explicitly set to 0 ("no timeout"). However, when models omit the parameter entirely (which is common since the schema marks it as optional), runTimeoutSeconds resolved to undefined. undefined flowed through the chain as: sessions_spawn → timeout: undefined (since undefined != null is false) → gateway agent handler → agentCommand opts.timeout: undefined → resolveAgentTimeoutMs({ overrideSeconds: undefined }) → DEFAULT_AGENT_TIMEOUT_SECONDS (600s = 10 minutes) This caused subagents to be killed at exactly 10 minutes even though the user's intent (via TOOLS.md) was for subagents to run without a timeout. Fix: default runTimeoutSeconds to 0 (no timeout) when neither runTimeoutSeconds nor timeoutSeconds is provided by the caller. Subagent spawns are long-running by design and should not inherit the 600s agent-command default timeout. * fix(subagent): accept timeout=0 in agent-via-gateway path (second 600s default) * fix: thread timeout override through getReplyFromConfig dispatch path getReplyFromConfig called resolveAgentTimeoutMs({ cfg }) with no override, always falling back to the config default (600s). Add timeoutOverrideSeconds to GetReplyOptions and pass it through as overrideSeconds so callers of the dispatch chain can specify a custom timeout (0 = no timeout). This complements the existing timeout threading in agentCommand and the cron isolated-agent runner, which already pass overrideSeconds correctly. * feat(model-fallback): normalize OpenAI Codex model references and enhance fallback handling - Added normalization for OpenAI Codex model references, specifically converting "gpt-5.3-codex" to "openai-codex" before execution. - Updated the `resolveFallbackCandidates` function to utilize the new normalization logic. - Enhanced tests to verify the correct behavior of model normalization and fallback mechanisms. - Introduced a new test case to ensure that the normalization process works as expected for various input formats. * feat(tests): add unit tests for steer failure behavior in openclaw-tools - Introduced a new test file to validate the behavior of subagents when steer replacement dispatch fails. - Implemented tests to ensure that the announce behavior is restored correctly and that the suppression reason is cleared as expected. - Enhanced the subagent registry with a new function to clear steer restart suppression. - Updated related components to support the new test scenarios. * fix(subagents): replace stop command with kill in slash commands and documentation - Updated the `/subagents` command to replace `stop` with `kill` for consistency in controlling sub-agent runs. - Modified related documentation to reflect the change in command usage. - Removed legacy timeoutSeconds references from the sessions-spawn-tool schema and tests to streamline timeout handling. - Enhanced tests to ensure correct behavior of the updated commands and their interactions. * feat(tests): add unit tests for readLatestAssistantReply function - Introduced a new test file for the `readLatestAssistantReply` function to validate its behavior with various message scenarios. - Implemented tests to ensure the function correctly retrieves the latest assistant message and handles cases where the latest message has no text. - Mocked the gateway call to simulate different message histories for comprehensive testing. * feat(tests): enhance subagent kill-all cascade tests and announce formatting - Added a new test to verify that the `kill-all` command cascades through ended parents to active descendants in subagents. - Updated the subagent announce formatting tests to reflect changes in message structure, including the replacement of "Findings:" with "Result:" and the addition of new expectations for message content. - Improved the handling of long findings and stats in the announce formatting logic to ensure concise output. - Refactored related functions to enhance clarity and maintainability in the subagent registry and tools. * refactor(subagent): update announce formatting and remove unused constants - Modified the subagent announce formatting to replace "Findings:" with "Result:" and adjusted related expectations in tests. - Removed constants for maximum announce findings characters and summary words, simplifying the announcement logic. - Updated the handling of findings to retain full content instead of truncating, ensuring more informative outputs. - Cleaned up unused imports in the commands-subagents file to enhance code clarity. * feat(tests): enhance billing error handling in user-facing text - Added tests to ensure that normal text mentioning billing plans is not rewritten, preserving user context. - Updated the `isBillingErrorMessage` and `sanitizeUserFacingText` functions to improve handling of billing-related messages. - Introduced new test cases for various scenarios involving billing messages to ensure accurate processing and output. - Enhanced the subagent announce flow to correctly manage active descendant runs, preventing premature announcements. * feat(subagent): enhance workflow guidance and auto-announcement clarity - Added a new guideline in the subagent system prompt to emphasize trust in push-based completion, discouraging busy polling for status updates. - Updated documentation to clarify that sub-agents will automatically announce their results, improving user understanding of the workflow. - Enhanced tests to verify the new guidance on avoiding polling loops and to ensure the accuracy of the updated prompts. * fix(cron): avoid announcing interim subagent spawn acks * chore: clean post-rebase imports * fix(cron): fall back to child replies when parent stays interim * fix(subagents): make active-run guidance advisory * fix(subagents): update announce flow to handle active descendants and enhance test coverage - Modified the announce flow to defer announcements when active descendant runs are present, ensuring accurate status reporting. - Updated tests to verify the new behavior, including scenarios where no fallback requester is available and ensuring proper handling of finished subagents. - Enhanced the announce formatting to include an `expectFinal` flag for better clarity in the announcement process. * fix(subagents): enhance announce flow and formatting for user updates - Updated the announce flow to provide clearer instructions for user updates based on active subagent runs and requester context. - Refactored the announcement logic to improve clarity and ensure internal context remains private. - Enhanced tests to verify the new message expectations and formatting, including updated prompts for user-facing updates. - Introduced a new function to build reply instructions based on session context, improving the overall announcement process. * fix: resolve prep blockers and changelog placement (#14447) (thanks @tyler6204) * fix: restore cron delivery-plan import after rebase (#14447) (thanks @tyler6204) * fix: resolve test failures from rebase conflicts (#14447) (thanks @tyler6204) * fix: apply formatting after rebase (#14447) (thanks @tyler6204)
2026-02-14 22:03:45 -08:00
if ("spawnDepth" in patch) {
const raw = patch.spawnDepth;
if (raw === null) {
if (typeof existing?.spawnDepth === "number") {
return invalid("spawnDepth cannot be cleared once set");
}
} else if (raw !== undefined) {
if (!isSubagentSessionKey(storeKey)) {
return invalid("spawnDepth is only supported for subagent:* sessions");
}
const numeric = Number(raw);
if (!Number.isInteger(numeric) || numeric < 0) {
return invalid("invalid spawnDepth (use an integer >= 0)");
}
const normalized = numeric;
if (typeof existing?.spawnDepth === "number" && existing.spawnDepth !== normalized) {
return invalid("spawnDepth cannot be changed once set");
}
next.spawnDepth = normalized;
}
}
if ("label" in patch) {
const raw = patch.label;
if (raw === null) {
delete next.label;
} else if (raw !== undefined) {
const parsed = parseSessionLabel(raw);
if (!parsed.ok) {
return invalid(parsed.error);
}
for (const [key, entry] of Object.entries(store)) {
if (key === storeKey) {
continue;
}
if (entry?.label === parsed.label) {
return invalid(`label already in use: ${parsed.label}`);
}
}
next.label = parsed.label;
}
}
if ("thinkingLevel" in patch) {
const raw = patch.thinkingLevel;
if (raw === null) {
Fix: Honor `/think off` for reasoning-capable models Problem: When users execute `/think off`, they still receive `reasoning_content` from models configured with `reasoning: true` (e.g., GLM-4.7, GLM-4.6, Kimi K2.5, MiniMax-M2.1). Expected: `/think off` should completely disable reasoning content. Actual: Reasoning content is still returned. Root Cause: The directive handlers delete `sessionEntry.thinkingLevel` when user executes `/think off`. This causes the thinking level to become undefined, and the system falls back to `resolveThinkingDefault()`, which checks the model catalog and returns "low" for reasoning-capable models, ignoring the user's explicit intent. Why We Must Persist "off" (Design Rationale): 1. **Model-dependent defaults**: Unlike other directives where "off" means use a global default, `thinkingLevel` has model-dependent defaults: - Reasoning-capable models (GLM-4.7, etc.) → default "low" - Other models → default "off" 2. **Existing pattern**: The codebase already follows this pattern for `elevatedLevel`, which persists "off" explicitly to override defaults that may be "on". The comment explains: "Persist 'off' explicitly so `/elevated off` actually overrides defaults." 3. **User intent**: When a user explicitly executes `/think off`, they want to disable thinking regardless of the model's capabilities. Deleting the field breaks this intent by falling back to the model's default. Solution: Persist "off" value instead of deleting the field in all internal directive handlers: - `src/auto-reply/reply/directive-handling.impl.ts`: Directive-only messages - `src/auto-reply/reply/directive-handling.persist.ts`: Inline directives - `src/commands/agent.ts`: CLI command-line flags Gateway API Backward Compatibility: The original implementation incorrectly mapped `null` to "off" in `sessions-patch.ts` for consistency with internal handlers. This was a breaking change because: - Previously, `null` cleared the override (deleted the field) - API clients lost the ability to "clear to default" via `null` - This contradicts standard JSON semantics where `null` means "no value" Restored original null semantics in `src/gateway/sessions-patch.ts`: - `null` → delete field, fall back to model default (clear override) - `"off"` → persist explicit override - Other values → normalize and persist This ensures backward compatibility for API clients while fixing the `/think off` issue in internal handlers. Signed-off-by: Liu Yuan <namei.unix@gmail.com>
2026-02-07 13:55:33 +08:00
// Clear the override and fall back to model default
delete next.thinkingLevel;
} else if (raw !== undefined) {
const normalized = normalizeThinkLevel(String(raw));
if (!normalized) {
const hintProvider = existing?.providerOverride?.trim() || resolvedDefault.provider;
const hintModel = existing?.modelOverride?.trim() || resolvedDefault.model;
return invalid(
2026-01-07 17:17:38 -08:00
`invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
);
}
Fix: Honor `/think off` for reasoning-capable models Problem: When users execute `/think off`, they still receive `reasoning_content` from models configured with `reasoning: true` (e.g., GLM-4.7, GLM-4.6, Kimi K2.5, MiniMax-M2.1). Expected: `/think off` should completely disable reasoning content. Actual: Reasoning content is still returned. Root Cause: The directive handlers delete `sessionEntry.thinkingLevel` when user executes `/think off`. This causes the thinking level to become undefined, and the system falls back to `resolveThinkingDefault()`, which checks the model catalog and returns "low" for reasoning-capable models, ignoring the user's explicit intent. Why We Must Persist "off" (Design Rationale): 1. **Model-dependent defaults**: Unlike other directives where "off" means use a global default, `thinkingLevel` has model-dependent defaults: - Reasoning-capable models (GLM-4.7, etc.) → default "low" - Other models → default "off" 2. **Existing pattern**: The codebase already follows this pattern for `elevatedLevel`, which persists "off" explicitly to override defaults that may be "on". The comment explains: "Persist 'off' explicitly so `/elevated off` actually overrides defaults." 3. **User intent**: When a user explicitly executes `/think off`, they want to disable thinking regardless of the model's capabilities. Deleting the field breaks this intent by falling back to the model's default. Solution: Persist "off" value instead of deleting the field in all internal directive handlers: - `src/auto-reply/reply/directive-handling.impl.ts`: Directive-only messages - `src/auto-reply/reply/directive-handling.persist.ts`: Inline directives - `src/commands/agent.ts`: CLI command-line flags Gateway API Backward Compatibility: The original implementation incorrectly mapped `null` to "off" in `sessions-patch.ts` for consistency with internal handlers. This was a breaking change because: - Previously, `null` cleared the override (deleted the field) - API clients lost the ability to "clear to default" via `null` - This contradicts standard JSON semantics where `null` means "no value" Restored original null semantics in `src/gateway/sessions-patch.ts`: - `null` → delete field, fall back to model default (clear override) - `"off"` → persist explicit override - Other values → normalize and persist This ensures backward compatibility for API clients while fixing the `/think off` issue in internal handlers. Signed-off-by: Liu Yuan <namei.unix@gmail.com>
2026-02-07 13:55:33 +08:00
next.thinkingLevel = normalized;
}
}
if ("verboseLevel" in patch) {
const raw = patch.verboseLevel;
const parsed = parseVerboseOverride(raw);
if (!parsed.ok) {
return invalid(parsed.error);
}
applyVerboseOverride(next, parsed.value);
}
if ("reasoningLevel" in patch) {
const raw = patch.reasoningLevel;
if (raw === null) {
delete next.reasoningLevel;
} else if (raw !== undefined) {
const normalized = normalizeReasoningLevel(String(raw));
if (!normalized) {
return invalid('invalid reasoningLevel (use "on"|"off"|"stream")');
}
if (normalized === "off") {
delete next.reasoningLevel;
} else {
next.reasoningLevel = normalized;
}
}
}
if ("responseUsage" in patch) {
const raw = patch.responseUsage;
if (raw === null) {
delete next.responseUsage;
} else if (raw !== undefined) {
const normalized = normalizeUsageDisplay(String(raw));
if (!normalized) {
return invalid('invalid responseUsage (use "off"|"tokens"|"full")');
}
if (normalized === "off") {
delete next.responseUsage;
} else {
next.responseUsage = normalized;
}
}
}
if ("elevatedLevel" in patch) {
const raw = patch.elevatedLevel;
if (raw === null) {
delete next.elevatedLevel;
} else if (raw !== undefined) {
const normalized = normalizeElevatedLevel(String(raw));
if (!normalized) {
return invalid('invalid elevatedLevel (use "on"|"off"|"ask"|"full")');
}
2026-01-10 05:23:29 +01:00
// Persist "off" explicitly so patches can override defaults.
next.elevatedLevel = normalized;
}
}
2026-01-18 06:11:38 +00:00
if ("execHost" in patch) {
const raw = patch.execHost;
if (raw === null) {
delete next.execHost;
} else if (raw !== undefined) {
const normalized = normalizeExecHost(String(raw));
if (!normalized) {
return invalid('invalid execHost (use "sandbox"|"gateway"|"node")');
}
2026-01-18 06:11:38 +00:00
next.execHost = normalized;
}
}
if ("execSecurity" in patch) {
const raw = patch.execSecurity;
if (raw === null) {
delete next.execSecurity;
} else if (raw !== undefined) {
const normalized = normalizeExecSecurity(String(raw));
if (!normalized) {
return invalid('invalid execSecurity (use "deny"|"allowlist"|"full")');
}
2026-01-18 06:11:38 +00:00
next.execSecurity = normalized;
}
}
if ("execAsk" in patch) {
const raw = patch.execAsk;
if (raw === null) {
delete next.execAsk;
} else if (raw !== undefined) {
const normalized = normalizeExecAsk(String(raw));
if (!normalized) {
return invalid('invalid execAsk (use "off"|"on-miss"|"always")');
}
2026-01-18 06:11:38 +00:00
next.execAsk = normalized;
}
}
if ("execNode" in patch) {
const raw = patch.execNode;
if (raw === null) {
delete next.execNode;
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
return invalid("invalid execNode: empty");
}
2026-01-18 06:11:38 +00:00
next.execNode = trimmed;
}
}
if ("model" in patch) {
const raw = patch.model;
if (raw === null) {
applyModelOverrideToSessionEntry({
entry: next,
selection: {
provider: resolvedDefault.provider,
model: resolvedDefault.model,
isDefault: true,
},
});
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) {
return invalid("invalid model: empty");
}
if (!params.loadGatewayModelCatalog) {
return {
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, "model catalog unavailable"),
};
}
const catalog = await params.loadGatewayModelCatalog();
const resolved = resolveAllowedModelRef({
cfg,
catalog,
raw: trimmed,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
});
if ("error" in resolved) {
return invalid(resolved.error);
}
const isDefault =
resolved.ref.provider === resolvedDefault.provider &&
resolved.ref.model === resolvedDefault.model;
applyModelOverrideToSessionEntry({
entry: next,
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
},
});
}
}
2026-01-07 17:17:38 -08:00
if (next.thinkingLevel === "xhigh") {
const effectiveProvider = next.providerOverride ?? resolvedDefault.provider;
const effectiveModel = next.modelOverride ?? resolvedDefault.model;
if (!supportsXHighThinking(effectiveProvider, effectiveModel)) {
if ("thinkingLevel" in patch) {
return invalid(`thinkingLevel "xhigh" is only supported for ${formatXHighModelHint()}`);
2026-01-07 17:17:38 -08:00
}
next.thinkingLevel = "high";
}
}
if ("sendPolicy" in patch) {
const raw = patch.sendPolicy;
if (raw === null) {
delete next.sendPolicy;
} else if (raw !== undefined) {
const normalized = normalizeSendPolicy(String(raw));
if (!normalized) {
return invalid('invalid sendPolicy (use "allow"|"deny")');
}
next.sendPolicy = normalized;
}
}
if ("groupActivation" in patch) {
const raw = patch.groupActivation;
if (raw === null) {
delete next.groupActivation;
} else if (raw !== undefined) {
const normalized = normalizeGroupActivation(String(raw));
if (!normalized) {
return invalid('invalid groupActivation (use "mention"|"always")');
}
next.groupActivation = normalized;
}
}
store[storeKey] = next;
return { ok: true, entry: next };
}