From a6aca82b8b20af6611f3ef00d4da11a4e868dcc4 Mon Sep 17 00:00:00 2001 From: Clawdia Date: Mon, 20 Jul 2026 19:53:29 -0700 Subject: [PATCH 1/2] fix: add gateway-safe default model unpin --- README.md | 32 ++++--- SKILL.md | 60 ++++++++---- scripts/switch.sh | 238 +++++++++++++++++++++++++++++++++++++++++----- scripts/test.sh | 203 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 478 insertions(+), 55 deletions(-) create mode 100755 scripts/test.sh diff --git a/README.md b/README.md index 8117c56..8d918c5 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ # shell-swap -Provider/model-**agnostic** mass model switch for OpenClaw. Resolves the target -against the live config alias map and stamps a consistent `{model, provider}` -pair across config + every agent session store in one command. +Provider/model-**agnostic** mass model switch for OpenClaw. It can safely +unpin sessions to the configured default through Gateway (no restart), or +hard-pin an explicit non-default model for maintenance workflows. ## Included • `SKILL.md` — OpenClaw skill definition • `scripts/switch.sh` — the switch script +• `scripts/test.sh` — hermetic regression tests ## Usage ```bash -exec scripts/switch.sh [--agent NAME] [--all-agents] [--crons] [--dry-run] +exec scripts/switch.sh [--set-primary] [--agent NAME] [--all-agents] [--crons] [--dry-run] exec scripts/switch.sh --think LEVEL [--fast MODE] [--agent NAME] [--dry-run] exec scripts/switch.sh --fast MODE [--agent NAME] [--dry-run] ``` `` is resolved against `agents.defaults.models` in the live config: +• **default/reset/unpin** — clear model overrides so config primary + fallbacks win • **alias** — any configured alias (`opus`, `gpt`, `minimax`, `grok-4.3`, `kimi`, …) • **provider/model** — full key (`anthropic/claude-opus-4-8`, `venice/grok-4-20`) • **raw id** — any `provider/model` not yet in the allowlist (passthrough) @@ -28,6 +30,9 @@ map to multiple providers, so the provider can't be guessed. ```bash exec scripts/switch.sh opus --dry-run +exec scripts/switch.sh default +exec scripts/switch.sh sol # unpins when sol is already the configured primary +exec scripts/switch.sh opus --set-primary # explicit config change, then unpin exec scripts/switch.sh anthropic/claude-opus-4-8 exec scripts/switch.sh minimax --agent exec scripts/switch.sh --think high @@ -37,14 +42,19 @@ exec scripts/switch.sh --think default --fast default ## What it updates -1. `~/.openclaw/openclaw.json` — `agents.defaults.model.primary` when a model - target is provided and the run is not scoped to one agent (allowlist + - fallbacks left untouched) -2. `~/.openclaw/agents/*/sessions/sessions.json` — `model`, `modelOverride`, +1. `~/.openclaw/openclaw.json` — only with explicit `--set-primary`; the + default command never mutates config +2. Default/reset uses Gateway `sessions.patch {model:null}` to remove model + overrides and stale `liveModelSwitchPending` state. Active sessions are + deferred and must be retried once idle. No Gateway restart is needed. +3. Exact non-default targets update `model`, `modelOverride`, `modelProvider`, `providerOverride`, `modelOverrideSource`; strips stale - fallback fields. Model + provider are set together so they never diverge. -3. Session overrides — `--think` / `--fast` patch warm sessions through Gateway + fallback fields. These are `source=user` hard pins and disable configured + model fallback for those sessions. +4. Session overrides — `--think` / `--fast` patch warm sessions through Gateway by default, or edit `sessions.json` directly with `--session-mode offline` -4. (opt-in `--crons`) legacy `~/.openclaw/cron/jobs.json` — `payload.model` +5. (opt-in `--crons`) legacy `~/.openclaw/cron/jobs.json` — `payload.model` Backups are written before modification (`*.bak`). + +Run tests with `/opt/homebrew/bin/bash scripts/test.sh` on macOS. diff --git a/SKILL.md b/SKILL.md index 167914a..a54739e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,25 +1,26 @@ --- name: shell-swap -description: "Admin tool to mass-switch every OpenClaw session and the default model to ANY provider/model. Provider- and model-agnostic. Use when asked to change model, switch lanes, set the default model, do a fleet-wide model change, or \"shell swap\"." +description: "Admin tool to safely unpin or mass-switch OpenClaw session models. Config mutation is explicit-only; default/reset uses Gateway model:null so configured fallbacks keep working without a restart." --- # Shell Swap -Provider/model-**agnostic** mass model switch. Resolves the target against the -**live config alias map** (`agents.defaults.models`) — the single source of -truth — then stamps a consistent `{model, provider}` pair across config + every -agent session store. No hardcoded alias table; works for any model the config -knows about (Anthropic, OpenAI, Venice, xAI, OpenRouter, NVIDIA, Ollama, …). +Provider/model-**agnostic** model administration. The safe default operation +unpins sessions through Gateway so they inherit the configured primary and its +fallback chain. Exact non-default hard pins remain available, but are explicit +`source=user` selections and therefore disable configured model fallback. ## Usage ```bash -exec scripts/switch.sh [--agent NAME] [--all-agents] [--crons] [--dry-run] +exec scripts/switch.sh [--set-primary] [--agent NAME] [--all-agents] [--crons] [--dry-run] exec scripts/switch.sh --think LEVEL [--fast MODE] [--agent NAME] [--dry-run] exec scripts/switch.sh --fast MODE [--agent NAME] [--dry-run] ``` `` may be: +- **default/reset/unpin** — call Gateway `sessions.patch` with `model:null` so + sessions inherit config and stale `liveModelSwitchPending` is removed - **alias** — any alias defined in `agents.defaults.models` (e.g. `opus`, `gpt`, `minimax`, `grok-4.3`, `kimi`) - **provider/model** — a full config key (e.g. `anthropic/claude-opus-4-8`, `venice/grok-4-20`) - **raw id** — any `provider/model` not yet in the allowlist (agnostic passthrough) @@ -35,16 +36,21 @@ Session override flags: `default` clears the session override so config/provider defaults win. - `--fast MODE` sets or clears direct session `fastMode` overrides. Modes: `on|off|auto|default`. `default` clears the session override. -- `--session-mode gateway|offline` controls how `--think` / `--fast` are +- `--set-primary` explicitly changes `agents.defaults.model.primary`. Without + it, shell-swap does not modify `openclaw.json`. +- `--session-mode gateway|offline` controls how reset / `--think` / `--fast` are written. Default is `gateway`, which calls Gateway `sessions.patch` and updates warm in-memory sessions without a restart. `offline` edits `sessions.json` directly and is for maintenance when Gateway is down. ### What it does -1. When a model target is provided, updates `agents.defaults.model.primary` in - `openclaw.json` to the full id -2. For every agent session store (`agents/*/sessions/sessions.json`): +1. Leaves `openclaw.json` unchanged unless `--set-primary` is supplied. +2. For `default`, or a target equal to the configured primary, calls Gateway + `sessions.patch {model:null}` for every idle selected session. This removes + model overrides, fallback-origin state, and `liveModelSwitchPending` without + a restart. Active sessions are deferred; rerun once they are idle. +3. For an exact non-default target, updates every selected session store: - sets `model` and `modelOverride` → the resolved model id - sets `modelProvider` and `providerOverride` → the resolved provider - sets `modelOverrideSource` → `user` @@ -57,14 +63,15 @@ Session override flags: are preserved when switching **into** a codex lane (provider resolves to `agentRuntime.id == "codex"`). - model and provider are stamped **together**, so they can never diverge -3. When `--think` / `--fast` is provided: +4. When `--think` / `--fast` is provided: - default `gateway` mode patches every selected session through Gateway `sessions.patch` so warm sessions update without restart - `offline` mode edits direct session-entry fields in `sessions.json` - invalid provider/model combinations are rejected by Gateway in warm-safe mode and reported; those sessions are left unchanged -4. Optionally (`--crons`) rewrites `payload.model` in a legacy `cron/jobs.json` -5. Backs up each modified file (`*.bak`) and reports per-store change counts +5. Optionally (`--crons`) rewrites `payload.model` in a legacy `cron/jobs.json` + only for exact pins; reset/unpin does not create cron model pins. +6. Backs up directly modified files (`*.bak`) and reports change counts. ### What it does NOT touch @@ -87,6 +94,12 @@ Session override flags: # Switch the whole fleet to opus (resolves to claude-cli/claude-opus-4-8) exec scripts/switch.sh opus +# Safely inherit the configured primary + fallbacks (no restart/config edit) +exec scripts/switch.sh default + +# Explicitly change the configured primary, then unpin sessions to inherit it +exec scripts/switch.sh opus --set-primary + # Any provider, by alias exec scripts/switch.sh minimax # -> venice/minimax-m25 exec scripts/switch.sh grok-4.3 # -> openrouter/x-ai/grok-4.3 @@ -131,11 +144,9 @@ exec scripts/switch.sh --think off --session-mode offline leaves the global config primary unchanged; an unknown agent name aborts with no changes. `--agent current` (or `--current-agent`) targets the active agent via `OPENCLAW_MCP_AGENT_ID`. -- **Tests:** `bash scripts/test.sh` runs a hermetic regression suite - covering resolution, agentRuntime provider, the schema-scoped walk, `auto` - preservation, divergence repair, provenance, scoping, atomicity, backups, - pre-validation, dry-run, and session override modes. Run it before changing - the script. +- **Tests:** `/opt/homebrew/bin/bash scripts/test.sh` runs a hermetic regression + suite covering no-config-by-default behavior, Gateway null-unpin, active-run + deferral, pending-flag cleanup, explicit config mutation, and offline safety. - **Thinking compatibility:** Gateway `sessions.patch` validates the selected level against the session's effective provider/model profile. For example, `--think max` can be rejected on an OpenAI session whose current profile only @@ -143,13 +154,22 @@ exec scripts/switch.sh --think off --session-mode offline and leaves that session unchanged. Existing stored unsupported levels may be remapped by OpenClaw at runtime, but the warm-safe Gateway path does not force invalid values into live sessions. -- **Restart scope (warm vs cold):** file-surgery edits the on-disk store. A +- **Restart scope (warm vs cold):** Gateway reset via `model:null` updates warm + state and explicitly deletes pending live-switch state; idle sessions do not + require a restart. Active sessions are not patched because their later + snapshot merge could win the race; rerun after they become idle. File-surgery + edits the on-disk store. A **cold** session (a persisted row not currently loaded in the gateway's memory) reads the new override when it next hydrates — no restart. A **warm** session (held in gateway memory) keeps its in-memory copy and can rewrite the file, so a config-primary change or warm-session switch may need a gateway restart to take effect. Cold sessions are the easy case; warm sessions are the reason a restart is sometimes required. +- **Pending semantics:** a non-null `sessions.patch` can set + `liveModelSwitchPending` even if the session is idle at patch time, and that + flag can fire on a later user turn. Shell-swap therefore uses the native + null-reset path for default-equivalent targets and never manufactures the + flag online. Offline reset may delete it only while Gateway is stopped. - **When to prefer the native path instead:** for a single session or a live switch with **no restart**, use the gateway-native surfaces — `/model`, the model picker, `session_status(model=…)`, or `sessions.patch`. They write the diff --git a/scripts/switch.sh b/scripts/switch.sh index e9619b4..1a2c501 100755 --- a/scripts/switch.sh +++ b/scripts/switch.sh @@ -4,8 +4,9 @@ # The target is resolved against the LIVE config alias map # (agents.defaults.models) — the single source of truth — so it never goes # stale and works for ANY provider/model the config knows about. The resolved -# {model, provider} pair is stamped together across config + every session -# store, so model and provider can never diverge (the bug that broke routing). +# {model, provider} pair is stamped together for exact pins, while default +# targets use Gateway model:null so sessions inherit config and fallbacks. +# openclaw.json is only changed with explicit --set-primary. # # Provider is the resolved model entry's agentRuntime.id when set, otherwise the # first path segment of the config key. This matters: anthropic/claude-opus-4-6 @@ -13,7 +14,7 @@ # not "anthropic". Deriving provider from the key prefix alone would re-create # the divergence bug. # -# Usage: switch.sh [] [--think LEVEL] [--fast MODE] [--agent NAME] [--all-agents] [--crons] [--dry-run] +# Usage: switch.sh [] [--set-primary] [--think LEVEL] [--fast MODE] [--agent NAME] [--all-agents] [--crons] [--dry-run] set -euo pipefail @@ -25,11 +26,13 @@ AGENTS_DIR="${AGENTS_DIR:-$OPENCLAW_DIR/agents}" usage() { cat <<'EOF' Usage: - switch.sh [--agent NAME] [--all-agents] [--crons] [--dry-run] + switch.sh [--set-primary] [--agent NAME] [--all-agents] [--crons] [--dry-run] switch.sh --think LEVEL [--fast MODE] [--agent NAME] [--dry-run] switch.sh --fast MODE [--agent NAME] [--dry-run] is resolved against the live config alias map and may be: + - default : clear model/provider overrides through Gateway sessions.patch + so sessions inherit the configured primary + fallback policy - alias : any alias defined in agents.defaults.models (e.g. opus, gpt, minimax, grok-4.3) - provider/model : a full config key (e.g. anthropic/claude-opus-4-8, venice/grok-4-20) - raw id : any "provider/model" not yet in config (agnostic passthrough) @@ -38,19 +41,22 @@ Bare model names (no "/" and not a known alias) are rejected: the same model can map to multiple providers, so the provider cannot be guessed safely. Flags: + --set-primary Explicitly update agents.defaults.model.primary in openclaw.json. + Without this flag, shell-swap NEVER changes openclaw.json. --think LEVEL Set the session thinking override on every selected session. LEVEL: off|minimal|low|medium|high|xhigh|adaptive|max|default. "default" clears the session override so config/provider defaults win. --fast MODE Set the session fast-mode override on every selected session. MODE: on|off|auto|default. "default" clears the session override. --session-mode MODE - How --think/--fast are written: "gateway" (default; warm-session - safe via sessions.patch) or "offline" (direct sessions.json repair). + How reset/--think/--fast are written: "gateway" (default; + warm-session safe via sessions.patch) or "offline" (direct + sessions.json repair; only allowed while Gateway is stopped). --agent NAME Only rewrite this agent's session store; leaves the global config primary untouched (scoped switch). Errors on unknown agent. Use "--agent current" for the active agent (OPENCLAW_MCP_AGENT_ID). --current-agent Shorthand for "--agent current" - --all-agents Rewrite every agent's session store + the config primary (default) + --all-agents Rewrite every agent's session store (default) --crons Also rewrite cron payload.model values, if a legacy cron/jobs.json exists --dry-run Show what would change without writing files -h, --help Show this help @@ -66,11 +72,14 @@ THINK_MODE="" FAST_MODE_SET=0 FAST_MODE="" SESSION_MODE="gateway" +SET_PRIMARY=0 +RESET_MODEL=0 while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=1 ;; --crons|-c) TOUCH_CRONS=1 ;; + --set-primary) SET_PRIMARY=1 ;; --think|--thinking|-t) shift [[ $# -gt 0 ]] || { echo "[shell-swap] --think requires a level" >&2; exit 1; } @@ -117,6 +126,19 @@ case "$SESSION_MODE" in *) echo "[shell-swap] --session-mode must be gateway or offline, got '$SESSION_MODE'" >&2; exit 1 ;; esac +case "$(printf '%s' "$TARGET" | tr '[:upper:]' '[:lower:]')" in + default|inherit|clear|reset|unpin) RESET_MODEL=1 ;; +esac + +if [[ "$RESET_MODEL" -eq 1 && "$SET_PRIMARY" -eq 1 ]]; then + echo "[shell-swap] --set-primary cannot be combined with target '$TARGET'; pass an explicit model target." >&2 + exit 1 +fi +if [[ "$SET_PRIMARY" -eq 1 && -n "$AGENT_FILTER" ]]; then + echo "[shell-swap] --set-primary cannot be scoped with --agent; choose the fleet scope explicitly." >&2 + exit 1 +fi + normalize_think() { local key key="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr '_' '-' | xargs)" @@ -167,8 +189,22 @@ fi # --- Resolve target -> canonical {full_id, provider, model_id} from LIVE config --- # provider = resolved entry's agentRuntime.id if set, else first path segment. +CONFIG_PRIMARY="$(python3 - "$CONFIG" <<'PYEOF' +import json, sys +with open(sys.argv[1]) as f: + data = json.load(f) +primary = data.get("agents", {}).get("defaults", {}).get("model", {}).get("primary") +if not isinstance(primary, str) or not primary.strip(): + sys.stderr.write("[shell-swap] agents.defaults.model.primary is missing or invalid\n") + sys.exit(2) +print(primary.strip()) +PYEOF +)" || exit $? + if [[ -n "$TARGET" ]]; then - RESOLVED="$(python3 - "$CONFIG" "$TARGET" <<'PYEOF' + RESOLVE_TARGET="$TARGET" + [[ "$RESET_MODEL" -eq 1 ]] && RESOLVE_TARGET="$CONFIG_PRIMARY" + RESOLVED="$(python3 - "$CONFIG" "$RESOLVE_TARGET" <<'PYEOF' import json, sys config_path, target = sys.argv[1], sys.argv[2] @@ -222,6 +258,14 @@ PYEOF FULL_ID="$(sed -n '1p' <<<"$RESOLVED")" PROVIDER="$(sed -n '2p' <<<"$RESOLVED")" MODEL_ID="$(sed -n '3p' <<<"$RESOLVED")" + + # A global primary change is completed by unpinning sessions, not by writing + # redundant source=user overrides. Likewise, selecting the already-configured + # primary means "inherit default" unless the operator explicitly asks for an + # exact no-fallback pin. + if [[ "$SET_PRIMARY" -eq 1 || "$FULL_ID" == "$CONFIG_PRIMARY" ]]; then + RESET_MODEL=1 + fi else FULL_ID="" PROVIDER="" @@ -233,16 +277,23 @@ if [[ -n "$TARGET" ]]; then echo "[shell-swap] Resolved full id : $FULL_ID" echo "[shell-swap] Session model : $MODEL_ID" echo "[shell-swap] Session provider : $PROVIDER" + echo "[shell-swap] Config primary : $CONFIG_PRIMARY" + if [[ "$RESET_MODEL" -eq 1 ]]; then + echo "[shell-swap] Session action : UNPIN via sessions.patch model:null" + else + echo "[shell-swap] Session action : exact source=user pin (config fallbacks disabled)" + fi else echo "[shell-swap] Target input : " fi if [[ -n "$AGENT_FILTER" ]]; then - echo "[shell-swap] Agent scope : $AGENT_FILTER (config primary NOT changed)" + echo "[shell-swap] Agent scope : $AGENT_FILTER" elif [[ -z "$TARGET" ]]; then echo "[shell-swap] Agent scope : ALL agents (session overrides only)" else - echo "[shell-swap] Agent scope : ALL agents (+ config primary)" + echo "[shell-swap] Agent scope : ALL agents" fi +[[ "$SET_PRIMARY" -eq 1 ]] && echo "[shell-swap] Config mutation : ENABLED (--set-primary)" || echo "[shell-swap] Config mutation : disabled" if [[ "$THINK_MODE_SET" -eq 1 || "$FAST_MODE_SET" -eq 1 ]]; then [[ "$THINK_MODE_SET" -eq 1 ]] && echo "[shell-swap] Thinking override: $THINK_VALUE" [[ "$FAST_MODE_SET" -eq 1 ]] && echo "[shell-swap] Fast override : $FAST_VALUE" @@ -317,13 +368,13 @@ def write_atomic(path, data): raise ' -# --- 1. config: primary model only (skipped for scoped --agent runs) --- +# --- 1. config: primary model only (explicit opt-in) --- echo "" echo "=== openclaw.json ===" if [[ -z "$TARGET" ]]; then echo " skipped (no model target; session overrides only)" -elif [[ -n "$AGENT_FILTER" ]]; then - echo " skipped (scoped --agent run; primary left as-is)" +elif [[ "$SET_PRIMARY" -eq 0 ]]; then + echo " skipped (config changes require --set-primary)" else [[ "$DRY_RUN" -eq 0 ]] && cp "$CONFIG" "$CONFIG.bak" python3 - "$CONFIG" "$FULL_ID" "$DRY_RUN" </dev/null 2>&1; then + echo "[shell-swap] Refusing offline model reset while Gateway is running. Use gateway mode, or stop Gateway first." >&2 + return 8 + fi + for sessions_file in "${STORES[@]}"; do + [[ "$DRY_RUN" -eq 0 ]] && cp "$sessions_file" "$sessions_file.bak" + python3 - "$sessions_file" "$DRY_RUN" <&1)"; then + echo "[shell-swap] Cannot query Gateway session activity: $list_out" >&2 + return 7 + fi + local active_keys + active_keys="$(python3 -c 'import json,sys; d=json.load(sys.stdin); print("\n".join(s["key"] for s in d.get("sessions",[]) if s.get("hasActiveRun") is True and isinstance(s.get("key"),str)))' <<<"$list_out")" + local attempted=0 ok=0 failed=0 deferred=0 + local failure_log="" + + while IFS= read -r key; do + [[ -n "$key" ]] || continue + if grep -Fqx -- "$key" <<<"$active_keys"; then + deferred=$((deferred + 1)) + failure_log="${failure_log} ${key}: active run; deferred (rerun when idle)"$'\n' + continue + fi + attempted=$((attempted + 1)) + if [[ "$DRY_RUN" -eq 1 ]]; then + ok=$((ok + 1)) + continue + fi + local params out + params="$(python3 -c 'import json,sys; print(json.dumps({"key":sys.argv[1],"model":None},separators=(",",":")))' "$key")" + if out="$(openclaw gateway call sessions.patch --params "$params" --json --timeout 10000 2>&1)" && + python3 -c 'import json,sys; sys.exit(0 if json.load(sys.stdin).get("ok") else 1)' <<<"$out" 2>/dev/null; then + ok=$((ok + 1)) + else + failed=$((failed + 1)) + failure_log="${failure_log} ${key}: ${out}"$'\n' + fi + done < <(python3 - "${STORES[@]}" <<'PYEOF' +import json, sys +for path in sys.argv[1:]: + with open(path) as f: + data = json.load(f) + if isinstance(data, dict): + for key in data: + print(key) +PYEOF +) + + echo " sessions.patch model:null attempted=$attempted ok=$ok failed=$failed deferred=$deferred" + if [[ "$DRY_RUN" -eq 0 && "$failed" -eq 0 && "$deferred" -eq 0 ]]; then + local verify_out + if ! verify_out="$(python3 - "${STORES[@]}" <<'PYEOF' +import json, sys +bad_fields = ( + "providerOverride", "modelOverride", "modelOverrideSource", + "modelOverrideFallbackOriginProvider", "modelOverrideFallbackOriginModel", + "liveModelSwitchPending", +) +bad = [] +for path in sys.argv[1:]: + with open(path) as f: + data = json.load(f) + for key, entry in data.items() if isinstance(data, dict) else []: + if not isinstance(entry, dict): + continue + leftovers = [field for field in bad_fields if field in entry] + if leftovers: + bad.append(f"{key}: {','.join(leftovers)}") +if bad: + print("\n".join(bad)) + sys.exit(1) +print("verified selected session stores") +PYEOF +)"; then + failed=$((failed + 1)) + failure_log="${failure_log} postcondition failed: ${verify_out}"$'\n' + else + echo " postcondition: $verify_out" + fi + fi + if [[ "$failed" -gt 0 || "$deferred" -gt 0 ]]; then + printf '%s' "$failure_log" | sed -n '1,25p' + echo "[shell-swap] Reset incomplete; no active session was mutated. Rerun after deferred sessions become idle." >&2 + return 7 + fi +} + patch_session_overrides_gateway() { local failed=0 local attempted=0 @@ -554,9 +729,18 @@ PYEOF echo "" echo "=== sessions ===" if [[ -n "$TARGET" ]]; then - for store in "${STORES[@]}"; do - rewrite_sessions "$store" - done + if [[ "$RESET_MODEL" -eq 1 ]]; then + if [[ "$SESSION_MODE" == "gateway" ]]; then + patch_model_reset_gateway + else + reset_models_offline + fi + else + echo " WARNING: exact source=user pins disable configured model fallbacks." + for store in "${STORES[@]}"; do + rewrite_sessions "$store" + done + fi fi if [[ "$THINK_MODE_SET" -eq 1 || "$FAST_MODE_SET" -eq 1 ]]; then @@ -576,6 +760,8 @@ echo "" echo "=== cron/jobs.json ===" if [[ -z "$TARGET" ]]; then echo " skipped (no model target; session overrides only)" +elif [[ "$RESET_MODEL" -eq 1 ]]; then + echo " skipped (reset/unpin does not write a cron model pin)" elif [[ "$TOUCH_CRONS" -eq 0 ]]; then echo " skipped (pass --crons to enable)" elif [[ -f "$CRON" ]]; then @@ -607,5 +793,9 @@ echo "" if [[ "$DRY_RUN" -eq 1 ]]; then echo "[shell-swap] Dry run complete. No files modified." else - echo "[shell-swap] Done. Sessions set to $FULL_ID (model=$MODEL_ID provider=$PROVIDER)." + if [[ "$RESET_MODEL" -eq 1 ]]; then + echo "[shell-swap] Done. Sessions unpinned to inherit $FULL_ID and configured fallbacks." + else + echo "[shell-swap] Done. Sessions hard-pinned to $FULL_ID (model=$MODEL_ID provider=$PROVIDER)." + fi fi diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..83e38f0 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SWITCH="$ROOT/scripts/switch.sh" +BASH5="${BASH5:-/opt/homebrew/bin/bash}" +[[ -x "$BASH5" ]] || BASH5="$(command -v bash)" + +pass=0 +fail() { echo "not ok - $*" >&2; exit 1; } +ok() { pass=$((pass + 1)); echo "ok $pass - $*"; } + +FIXTURE="$(mktemp -d "${TMPDIR:-/tmp}/shell-swap-test.XXXXXX")" +trap 'rm -rf "$FIXTURE"' EXIT +mkdir -p "$FIXTURE/bin" "$FIXTURE/oc/agents/main/sessions" "$FIXTURE/oc/cron" + +cat >"$FIXTURE/oc/openclaw.json" <<'JSON' +{ + "agents": { + "defaults": { + "model": { + "primary": "openai/gpt-5.6-sol", + "fallbacks": ["claude-cli/claude-opus-4-6"] + }, + "models": { + "openai/gpt-5.6-sol": {"alias": "sol", "agentRuntime": {"id": "codex"}}, + "claude-cli/claude-opus-4-8": {"alias": "opus", "agentRuntime": {"id": "claude-cli"}} + } + } + } +} +JSON + +write_sessions() { + cat >"$FIXTURE/oc/agents/main/sessions/sessions.json" <<'JSON' +{ + "agent:main:telegram:group:1": { + "sessionId": "parent", + "model": "gpt-5.5", + "modelProvider": "openai", + "modelOverride": "gpt-5.5", + "providerOverride": "openai", + "modelOverrideSource": "user", + "modelOverrideFallbackOriginProvider": "openai", + "modelOverrideFallbackOriginModel": "gpt-5.6-sol", + "liveModelSwitchPending": true + }, + "agent:main:telegram:group:1:topic:2": { + "sessionId": "topic", + "model": "claude-opus-4-8", + "modelProvider": "claude-cli", + "modelOverride": "claude-opus-4-8", + "providerOverride": "claude-cli", + "modelOverrideSource": "user", + "liveModelSwitchPending": true + }, + "agent:main:slack:channel:active": { + "sessionId": "active", + "model": "gpt-5.5", + "modelProvider": "openai", + "modelOverride": "gpt-5.5", + "providerOverride": "openai", + "modelOverrideSource": "user", + "liveModelSwitchPending": true + } +} +JSON +} +write_sessions + +cat >"$FIXTURE/bin/openclaw" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +STORE="${FAKE_STORE:?}" +if [[ "$*" == *"gateway call health"* ]]; then + [[ "${FAKE_GATEWAY_RUNNING:-1}" == "1" ]] + exit +fi +if [[ "$*" == *"gateway call sessions.list"* ]]; then + python3 - "$STORE" "${FAKE_ACTIVE_KEY:-}" <<'PY' +import json, sys +with open(sys.argv[1]) as f: data = json.load(f) +active = sys.argv[2] +print(json.dumps({"sessions": [{"key": k, "hasActiveRun": k == active} for k in data], "ok": True})) +PY + exit +fi +if [[ "$*" == *"gateway call sessions.patch"* ]]; then + params="" + while [[ $# -gt 0 ]]; do + [[ "$1" == "--params" ]] && { params="$2"; break; } + shift + done + python3 - "$STORE" "$params" <<'PY' +import json, os, sys, tempfile +path, raw = sys.argv[1], sys.argv[2] +p = json.loads(raw) +with open(path) as f: data = json.load(f) +entry = data[p["key"]] +if "model" in p and p["model"] is None: + for field in ( + "providerOverride", "modelOverride", "modelOverrideSource", + "modelOverrideFallbackOriginProvider", "modelOverrideFallbackOriginModel", + "fallbackNoticeSelectedModel", "fallbackNoticeActiveModel", + "fallbackNoticeReason", "liveModelSwitchPending", "model", "modelProvider", + "contextTokens", "contextBudgetStatus", + ): + entry.pop(field, None) +fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".test-") +with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) +os.replace(tmp, path) +print(json.dumps({"ok": True})) +PY + exit +fi +echo "unexpected fake openclaw call: $*" >&2 +exit 1 +SH +chmod +x "$FIXTURE/bin/openclaw" + +run_swap() { + PATH="$FIXTURE/bin:$PATH" \ + OPENCLAW_DIR="$FIXTURE/oc" \ + CONFIG="$FIXTURE/oc/openclaw.json" \ + AGENTS_DIR="$FIXTURE/oc/agents" \ + CRON="$FIXTURE/oc/cron/jobs.json" \ + FAKE_STORE="$FIXTURE/oc/agents/main/sessions/sessions.json" \ + "$BASH5" "$SWITCH" "$@" +} + +config_hash_before="$(shasum -a 256 "$FIXTURE/oc/openclaw.json" | awk '{print $1}')" +set +e +FAKE_ACTIVE_KEY="agent:main:slack:channel:active" run_swap default >"$FIXTURE/active.out" 2>&1 +rc=$? +set -e +[[ "$rc" -eq 7 ]] || fail "active session reset should exit 7, got $rc" +python3 - "$FIXTURE/oc/agents/main/sessions/sessions.json" <<'PY' || fail "idle sessions were not unpinned" +import json, sys +with open(sys.argv[1]) as f: d=json.load(f) +bad=("providerOverride","modelOverride","modelOverrideSource","liveModelSwitchPending") +assert all(not any(k in d[s] for k in bad) for s in ("agent:main:telegram:group:1","agent:main:telegram:group:1:topic:2")) +assert d["agent:main:slack:channel:active"]["liveModelSwitchPending"] is True +PY +ok "gateway reset unpins idle parent/topic and defers active sessions" + +run_swap default >"$FIXTURE/reset.out" +python3 - "$FIXTURE/oc/agents/main/sessions/sessions.json" <<'PY' || fail "reset postcondition" +import json, sys +with open(sys.argv[1]) as f: d=json.load(f) +bad=("providerOverride","modelOverride","modelOverrideSource","modelOverrideFallbackOriginProvider","modelOverrideFallbackOriginModel","liveModelSwitchPending") +assert all(not any(k in e for k in bad) for e in d.values()) +PY +[[ "$(shasum -a 256 "$FIXTURE/oc/openclaw.json" | awk '{print $1}')" == "$config_hash_before" ]] || fail "default reset changed config" +ok "model:null reset clears pending/override state without changing config" + +write_sessions +run_swap sol >"$FIXTURE/default-equivalent.out" +python3 - "$FIXTURE/oc/agents/main/sessions/sessions.json" <<'PY' || fail "default-equivalent target did not unpin" +import json, sys +with open(sys.argv[1]) as f: d=json.load(f) +assert all("modelOverride" not in e and "liveModelSwitchPending" not in e for e in d.values()) +PY +ok "configured-primary alias normalizes to null unpin" + +write_sessions +run_swap opus >"$FIXTURE/pin.out" +[[ "$(shasum -a 256 "$FIXTURE/oc/openclaw.json" | awk '{print $1}')" == "$config_hash_before" ]] || fail "exact pin changed config without --set-primary" +grep -q 'exact source=user pins disable configured model fallbacks' "$FIXTURE/pin.out" || fail "exact pin warning missing" +python3 - "$FIXTURE/oc/agents/main/sessions/sessions.json" <<'PY' || fail "exact pin fields" +import json, sys +with open(sys.argv[1]) as f: d=json.load(f) +assert all(e.get("model") == "claude-opus-4-8" and e.get("modelOverride") == "claude-opus-4-8" for e in d.values()) +assert all(e.get("modelProvider") == "claude-cli" and e.get("providerOverride") == "claude-cli" for e in d.values()) +assert all(e.get("modelOverrideSource") == "user" for e in d.values()) +PY +ok "non-default exact pin leaves config untouched and warns about fallbacks" + +write_sessions +run_swap opus --set-primary >"$FIXTURE/set-primary.out" +python3 - "$FIXTURE/oc/openclaw.json" "$FIXTURE/oc/agents/main/sessions/sessions.json" <<'PY' || fail "explicit primary switch" +import json, sys +with open(sys.argv[1]) as f: c=json.load(f) +with open(sys.argv[2]) as f: d=json.load(f) +assert c["agents"]["defaults"]["model"]["primary"] == "claude-cli/claude-opus-4-8" +assert all("modelOverride" not in e and "liveModelSwitchPending" not in e for e in d.values()) +PY +ok "--set-primary changes config explicitly then unpins sessions" + +write_sessions +set +e +FAKE_GATEWAY_RUNNING=1 run_swap default --session-mode offline >"$FIXTURE/offline-running.out" 2>&1 +rc=$? +set -e +[[ "$rc" -eq 8 ]] || fail "offline reset should refuse running Gateway, got $rc" +FAKE_GATEWAY_RUNNING=0 run_swap default --session-mode offline >"$FIXTURE/offline-stopped.out" +python3 - "$FIXTURE/oc/agents/main/sessions/sessions.json" <<'PY' || fail "offline cleanup" +import json, sys +with open(sys.argv[1]) as f: d=json.load(f) +assert all("liveModelSwitchPending" not in e and "modelOverride" not in e for e in d.values()) +PY +ok "offline reset requires stopped Gateway and clears stale pending flags" + +echo "1..$pass" From f897d3529a4d22908397c7063991c58fbd6dc000 Mon Sep 17 00:00:00 2001 From: Clawdia Date: Thu, 23 Jul 2026 01:05:30 -0700 Subject: [PATCH 2/2] feat: add explicit same-primary hard pin --- README.md | 6 ++++-- SKILL.md | 23 ++++++++++++++++------- scripts/switch.sh | 23 ++++++++++++++++++++--- scripts/test.sh | 27 +++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8d918c5..03dc786 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ hard-pin an explicit non-default model for maintenance workflows. ## Usage ```bash -exec scripts/switch.sh [--set-primary] [--agent NAME] [--all-agents] [--crons] [--dry-run] +exec scripts/switch.sh [--set-primary|--pin-exact] [--agent NAME] [--all-agents] [--crons] [--dry-run] exec scripts/switch.sh --think LEVEL [--fast MODE] [--agent NAME] [--dry-run] exec scripts/switch.sh --fast MODE [--agent NAME] [--dry-run] ``` @@ -32,6 +32,7 @@ map to multiple providers, so the provider can't be guessed. exec scripts/switch.sh opus --dry-run exec scripts/switch.sh default exec scripts/switch.sh sol # unpins when sol is already the configured primary +exec scripts/switch.sh sol --pin-exact # intentionally disables fallbacks exec scripts/switch.sh opus --set-primary # explicit config change, then unpin exec scripts/switch.sh anthropic/claude-opus-4-8 exec scripts/switch.sh minimax --agent @@ -47,7 +48,8 @@ exec scripts/switch.sh --think default --fast default 2. Default/reset uses Gateway `sessions.patch {model:null}` to remove model overrides and stale `liveModelSwitchPending` state. Active sessions are deferred and must be retried once idle. No Gateway restart is needed. -3. Exact non-default targets update `model`, `modelOverride`, +3. Exact non-default targets—and a configured-primary target paired with + `--pin-exact`—update `model`, `modelOverride`, `modelProvider`, `providerOverride`, `modelOverrideSource`; strips stale fallback fields. These are `source=user` hard pins and disable configured model fallback for those sessions. diff --git a/SKILL.md b/SKILL.md index a54739e..75de8f3 100644 --- a/SKILL.md +++ b/SKILL.md @@ -13,7 +13,7 @@ fallback chain. Exact non-default hard pins remain available, but are explicit ## Usage ```bash -exec scripts/switch.sh [--set-primary] [--agent NAME] [--all-agents] [--crons] [--dry-run] +exec scripts/switch.sh [--set-primary|--pin-exact] [--agent NAME] [--all-agents] [--crons] [--dry-run] exec scripts/switch.sh --think LEVEL [--fast MODE] [--agent NAME] [--dry-run] exec scripts/switch.sh --fast MODE [--agent NAME] [--dry-run] ``` @@ -38,6 +38,9 @@ Session override flags: Modes: `on|off|auto|default`. `default` clears the session override. - `--set-primary` explicitly changes `agents.defaults.model.primary`. Without it, shell-swap does not modify `openclaw.json`. +- `--pin-exact` preserves intentional hard-pin semantics even when the explicit + target equals the configured primary. It writes `source=user` and therefore + disables configured fallbacks. It cannot be combined with `--set-primary`. - `--session-mode gateway|offline` controls how reset / `--think` / `--fast` are written. Default is `gateway`, which calls Gateway `sessions.patch` and updates warm in-memory sessions without a restart. `offline` edits @@ -46,11 +49,13 @@ Session override flags: ### What it does 1. Leaves `openclaw.json` unchanged unless `--set-primary` is supplied. -2. For `default`, or a target equal to the configured primary, calls Gateway - `sessions.patch {model:null}` for every idle selected session. This removes - model overrides, fallback-origin state, and `liveModelSwitchPending` without - a restart. Active sessions are deferred; rerun once they are idle. -3. For an exact non-default target, updates every selected session store: +2. For `default`, or a target equal to the configured primary without + `--pin-exact`, calls Gateway `sessions.patch {model:null}` for every idle + selected session. This removes model overrides, fallback-origin state, and + `liveModelSwitchPending` without a restart. Active sessions are deferred; + rerun once they are idle. +3. For an exact non-default target—or a configured-primary target paired with + `--pin-exact`—updates every selected session store: - sets `model` and `modelOverride` → the resolved model id - sets `modelProvider` and `providerOverride` → the resolved provider - sets `modelOverrideSource` → `user` @@ -97,6 +102,9 @@ exec scripts/switch.sh opus # Safely inherit the configured primary + fallbacks (no restart/config edit) exec scripts/switch.sh default +# Intentionally pin the configured primary and disable fallbacks +exec scripts/switch.sh sol --pin-exact + # Explicitly change the configured primary, then unpin sessions to inherit it exec scripts/switch.sh opus --set-primary @@ -146,7 +154,8 @@ exec scripts/switch.sh --think off --session-mode offline via `OPENCLAW_MCP_AGENT_ID`. - **Tests:** `/opt/homebrew/bin/bash scripts/test.sh` runs a hermetic regression suite covering no-config-by-default behavior, Gateway null-unpin, active-run - deferral, pending-flag cleanup, explicit config mutation, and offline safety. + deferral, pending-flag cleanup, same-primary `--pin-exact`, explicit config + mutation, and offline safety. - **Thinking compatibility:** Gateway `sessions.patch` validates the selected level against the session's effective provider/model profile. For example, `--think max` can be rejected on an OpenAI session whose current profile only diff --git a/scripts/switch.sh b/scripts/switch.sh index 1a2c501..2836b25 100755 --- a/scripts/switch.sh +++ b/scripts/switch.sh @@ -14,7 +14,7 @@ # not "anthropic". Deriving provider from the key prefix alone would re-create # the divergence bug. # -# Usage: switch.sh [] [--set-primary] [--think LEVEL] [--fast MODE] [--agent NAME] [--all-agents] [--crons] [--dry-run] +# Usage: switch.sh [] [--set-primary|--pin-exact] [--think LEVEL] [--fast MODE] [--agent NAME] [--all-agents] [--crons] [--dry-run] set -euo pipefail @@ -26,7 +26,7 @@ AGENTS_DIR="${AGENTS_DIR:-$OPENCLAW_DIR/agents}" usage() { cat <<'EOF' Usage: - switch.sh [--set-primary] [--agent NAME] [--all-agents] [--crons] [--dry-run] + switch.sh [--set-primary|--pin-exact] [--agent NAME] [--all-agents] [--crons] [--dry-run] switch.sh --think LEVEL [--fast MODE] [--agent NAME] [--dry-run] switch.sh --fast MODE [--agent NAME] [--dry-run] @@ -43,6 +43,8 @@ can map to multiple providers, so the provider cannot be guessed safely. Flags: --set-primary Explicitly update agents.defaults.model.primary in openclaw.json. Without this flag, shell-swap NEVER changes openclaw.json. + --pin-exact Hard-pin the explicit target with source=user, even when it + equals the configured primary. Disables configured fallbacks. --think LEVEL Set the session thinking override on every selected session. LEVEL: off|minimal|low|medium|high|xhigh|adaptive|max|default. "default" clears the session override so config/provider defaults win. @@ -73,6 +75,7 @@ FAST_MODE_SET=0 FAST_MODE="" SESSION_MODE="gateway" SET_PRIMARY=0 +PIN_EXACT=0 RESET_MODEL=0 while [[ $# -gt 0 ]]; do @@ -80,6 +83,7 @@ while [[ $# -gt 0 ]]; do --dry-run) DRY_RUN=1 ;; --crons|-c) TOUCH_CRONS=1 ;; --set-primary) SET_PRIMARY=1 ;; + --pin-exact) PIN_EXACT=1 ;; --think|--thinking|-t) shift [[ $# -gt 0 ]] || { echo "[shell-swap] --think requires a level" >&2; exit 1; } @@ -134,6 +138,18 @@ if [[ "$RESET_MODEL" -eq 1 && "$SET_PRIMARY" -eq 1 ]]; then echo "[shell-swap] --set-primary cannot be combined with target '$TARGET'; pass an explicit model target." >&2 exit 1 fi +if [[ "$PIN_EXACT" -eq 1 && -z "$TARGET" ]]; then + echo "[shell-swap] --pin-exact requires an explicit model target." >&2 + exit 1 +fi +if [[ "$PIN_EXACT" -eq 1 && "$RESET_MODEL" -eq 1 ]]; then + echo "[shell-swap] --pin-exact cannot be combined with target '$TARGET'; pass an explicit model target." >&2 + exit 1 +fi +if [[ "$PIN_EXACT" -eq 1 && "$SET_PRIMARY" -eq 1 ]]; then + echo "[shell-swap] --pin-exact and --set-primary express conflicting session intent; choose one." >&2 + exit 1 +fi if [[ "$SET_PRIMARY" -eq 1 && -n "$AGENT_FILTER" ]]; then echo "[shell-swap] --set-primary cannot be scoped with --agent; choose the fleet scope explicitly." >&2 exit 1 @@ -263,7 +279,7 @@ PYEOF # redundant source=user overrides. Likewise, selecting the already-configured # primary means "inherit default" unless the operator explicitly asks for an # exact no-fallback pin. - if [[ "$SET_PRIMARY" -eq 1 || "$FULL_ID" == "$CONFIG_PRIMARY" ]]; then + if [[ "$SET_PRIMARY" -eq 1 || ( "$FULL_ID" == "$CONFIG_PRIMARY" && "$PIN_EXACT" -eq 0 ) ]]; then RESET_MODEL=1 fi else @@ -294,6 +310,7 @@ else echo "[shell-swap] Agent scope : ALL agents" fi [[ "$SET_PRIMARY" -eq 1 ]] && echo "[shell-swap] Config mutation : ENABLED (--set-primary)" || echo "[shell-swap] Config mutation : disabled" +[[ "$PIN_EXACT" -eq 1 ]] && echo "[shell-swap] Pin intent : EXPLICIT (--pin-exact; fallbacks disabled)" if [[ "$THINK_MODE_SET" -eq 1 || "$FAST_MODE_SET" -eq 1 ]]; then [[ "$THINK_MODE_SET" -eq 1 ]] && echo "[shell-swap] Thinking override: $THINK_VALUE" [[ "$FAST_MODE_SET" -eq 1 ]] && echo "[shell-swap] Fast override : $FAST_VALUE" diff --git a/scripts/test.sh b/scripts/test.sh index 83e38f0..84f0d00 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -162,6 +162,33 @@ assert all("modelOverride" not in e and "liveModelSwitchPending" not in e for e PY ok "configured-primary alias normalizes to null unpin" +write_sessions +run_swap sol --pin-exact >"$FIXTURE/primary-pin.out" +[[ "$(shasum -a 256 "$FIXTURE/oc/openclaw.json" | awk '{print $1}')" == "$config_hash_before" ]] || fail "same-primary exact pin changed config" +grep -q 'EXPLICIT (--pin-exact; fallbacks disabled)' "$FIXTURE/primary-pin.out" || fail "same-primary exact pin intent missing" +python3 - "$FIXTURE/oc/agents/main/sessions/sessions.json" <<'PY' || fail "same-primary exact pin fields" +import json, sys +with open(sys.argv[1]) as f: d=json.load(f) +assert all(e.get("model") == "gpt-5.6-sol" and e.get("modelOverride") == "gpt-5.6-sol" for e in d.values()) +assert all(e.get("modelProvider") == "codex" and e.get("providerOverride") == "codex" for e in d.values()) +assert all(e.get("modelOverrideSource") == "user" for e in d.values()) +PY +ok "--pin-exact preserves intentional same-as-primary hard pin semantics" + +for invalid_args in \ + "--pin-exact" \ + "default --pin-exact" \ + "sol --pin-exact --set-primary" +do + set +e + # shellcheck disable=SC2086 + run_swap $invalid_args >"$FIXTURE/pin-conflict.out" 2>&1 + rc=$? + set -e + [[ "$rc" -ne 0 ]] || fail "invalid --pin-exact combination succeeded: $invalid_args" +done +ok "--pin-exact rejects missing, reset, and --set-primary conflicts" + write_sessions run_swap opus >"$FIXTURE/pin.out" [[ "$(shasum -a 256 "$FIXTURE/oc/openclaw.json" | awk '{print $1}')" == "$config_hash_before" ]] || fail "exact pin changed config without --set-primary"