Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile.base
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ WORKDIR /openclaw
ARG OPENCLAW_GIT_REF=main
RUN git clone --depth 1 --branch "${OPENCLAW_GIT_REF}" https://github.com/openclaw/openclaw.git .

# Patch: fix heartbeat model override ignored (#56788)
COPY patches/apply-heartbeat-model-fix.sh /tmp/apply-heartbeat-model-fix.sh
RUN bash /tmp/apply-heartbeat-model-fix.sh

# Patch: relax version requirements for packages using workspace protocol.
RUN set -eux; \
find ./extensions -name 'package.json' -type f | while read -r f; do \
Expand Down
166 changes: 166 additions & 0 deletions patches/apply-heartbeat-model-fix.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Fix: heartbeat model override ignored (#56788)
# Applies changes from upstream PRs #57094 and #57076 (both unmerged as of 2026-04-05).
#
# Root causes (4 loss points in the model resolution chain):
# 1. runtime-system.ts strips heartbeat.model when forwarding to runHeartbeatOnceInternal
# 2. live-model-switch.ts ignores caller-provided defaults, uses config default instead
# 3. model-fallback.ts swallows LiveSessionModelSwitchError as candidate failure
# 4. get-reply.ts unconditionally overwrites heartbeat model after directive resolution
set -euo pipefail

echo "[patch] Applying heartbeat model override fix (#56788)..."
echo "[patch] Based on upstream PRs #57094 + #57076"

# ── Fix 1: runtime-system.ts — pass model field through (#57076) ────────────
FILE="src/plugins/runtime/runtime-system.ts"
if [ -f "$FILE" ]; then
perl -i -pe 's/heartbeat: heartbeat \? \{ target: heartbeat\.target \} : undefined/heartbeat: heartbeat ? { target: heartbeat.target, model: heartbeat.model } : undefined/' "$FILE"
echo "[patch] Fixed $FILE"
else
echo "[patch] WARNING: $FILE not found"
fi

# ── Fix 1b: types-core.ts — add model to heartbeat type (#57076) ───────────
FILE="src/plugins/runtime/types-core.ts"
if [ -f "$FILE" ]; then
perl -i -pe 's/heartbeat\?: \{ target\?: string \}/heartbeat?: { target?: string; model?: string }/' "$FILE"
echo "[patch] Fixed $FILE"
else
echo "[patch] WARNING: $FILE not found"
fi

# ── Fix 2: live-model-switch.ts — prefer caller-provided defaults (#57076) ──
FILE="src/agents/live-model-switch.ts"
if [ -f "$FILE" ]; then
perl -0777 -i -pe 's{
const\s+defaultModelRef\s*=\s*agentId\s*\n\s*\?\s*resolveDefaultModelForAgent\(\{\s*\n\s*cfg,\s*\n\s*agentId,\s*\n\s*\}\)\s*\n\s*:\s*\{\s*provider:\s*params\.defaultProvider,\s*model:\s*params\.defaultModel\s*\};
}{ const defaultModelRef =
params.defaultProvider \&\& params.defaultModel
? { provider: params.defaultProvider, model: params.defaultModel }
: agentId
? resolveDefaultModelForAgent({ cfg, agentId })
: { provider: params.defaultProvider, model: params.defaultModel };}xms' "$FILE"
echo "[patch] Fixed $FILE"
else
echo "[patch] WARNING: $FILE not found"
fi

# ── Fix 3: model-fallback.ts — rethrow LiveSessionModelSwitchError (#57094) ─
FILE="src/agents/model-fallback.ts"
if [ -f "$FILE" ]; then
# Use node for complex multi-site patching — safer than nested perl
node -e '
const fs = require("fs");
let code = fs.readFileSync(process.argv[1], "utf8");

// 3a: Add isLiveSessionModelSwitchError export after the log line
const checkFn = `

/**
* Structural check for LiveSessionModelSwitchError that works across
* module-boundary duplicates where instanceof would fail.
*/
export function isLiveSessionModelSwitchError(err) {
return (
typeof err === "object" &&
err !== null &&
err.name === "LiveSessionModelSwitchError" &&
typeof err.provider === "string" &&
typeof err.model === "string"
);
}`;

code = code.replace(
/const log = createSubsystemLogger\("model-fallback"\);/,
`const log = createSubsystemLogger("model-fallback");${checkFn}`
);

// 3b: Add rethrowLiveSwitch to runFallbackCandidate params and catch block
code = code.replace(
/async function runFallbackCandidate<T>\(params: \{\n(\s+run: ModelFallbackRunFn<T>;\n\s+provider: string;\n\s+model: string;\n\s+options\?: ModelFallbackRunOptions;)\n\}/,
`async function runFallbackCandidate<T>(params: {\n$1\n rethrowLiveSwitch?: boolean;\n})`
);

// Add rethrow before the normalize line
code = code.replace(
/( \} catch \(err\) \{\n)( \/\/ Normalize abort-wrapped rate-limit errors)/,
`$1 if (params.rethrowLiveSwitch && isLiveSessionModelSwitchError(err)) {\n throw err;\n }\n$2`
);

// 3c: Add rethrowLiveSwitch to runFallbackAttempt params and passthrough
code = code.replace(
/async function runFallbackAttempt<T>\(params: \{\n(\s+run: ModelFallbackRunFn<T>;\n\s+provider: string;\n\s+model: string;\n\s+attempts: FallbackAttempt\[\];\n\s+options\?: ModelFallbackRunOptions;)\n\}/,
`async function runFallbackAttempt<T>(params: {\n$1\n rethrowLiveSwitch?: boolean;\n})`
);

code = code.replace(
/const runResult = await runFallbackCandidate\(\{\n\s+run: params\.run,\n\s+provider: params\.provider,\n\s+model: params\.model,\n\s+options: params\.options,\n\s+\}\);/,
`const runResult = await runFallbackCandidate({\n run: params.run,\n provider: params.provider,\n model: params.model,\n options: params.options,\n rethrowLiveSwitch: params.rethrowLiveSwitch,\n });`
);

// 3d: Add rethrowLiveSwitch to runWithModelFallback signature
code = code.replace(
"onError?: ModelFallbackErrorHandler;\n}): Promise<ModelFallbackRunResult<T>>",
"onError?: ModelFallbackErrorHandler;\n rethrowLiveSwitch?: boolean;\n}): Promise<ModelFallbackRunResult<T>>"
);

// 3e: Pass rethrowLiveSwitch in first runFallbackAttempt call (with options: runOptions)
code = code.replace(
/const attemptRun = await runFallbackAttempt\(\{\n\s+run: params\.run,\n\s+\.\.\.candidate,\n\s+attempts,\n\s+options: runOptions,\n\s+\}\);/,
`const attemptRun = await runFallbackAttempt({\n run: params.run,\n ...candidate,\n attempts,\n options: runOptions,\n rethrowLiveSwitch: params.rethrowLiveSwitch,\n });`
);

fs.writeFileSync(process.argv[1], code, "utf8");
' "$FILE"
echo "[patch] Fixed $FILE"
else
echo "[patch] WARNING: $FILE not found"
fi

# ── Fix 4: agent-runner-execution.ts — structural check + rethrowLiveSwitch (#57094) ─
FILE="src/auto-reply/reply/agent-runner-execution.ts"
if [ -f "$FILE" ]; then
node -e '
const fs = require("fs");
let code = fs.readFileSync(process.argv[1], "utf8");

// 4a: Replace import — remove LiveSessionModelSwitchError, add isLiveSessionModelSwitchError
code = code.replace(
/import \{ LiveSessionModelSwitchError \} from "\.\.\/\.\.\/agents\/live-model-switch-error\.js";\n/,
""
);
code = code.replace(
/import \{ runWithModelFallback, isFallbackSummaryError \} from "\.\.\/\.\.\/agents\/model-fallback\.js";/,
`import {\n runWithModelFallback,\n isFallbackSummaryError,\n isLiveSessionModelSwitchError,\n} from "../../agents/model-fallback.js";`
);

// 4b: Pass rethrowLiveSwitch: true to runWithModelFallback
code = code.replace(
/\.\.\.resolveModelFallbackOptions\(params\.followupRun\.run\),\n(\s+)runId,/,
`...resolveModelFallbackOptions(params.followupRun.run),\n$1runId,\n$1rethrowLiveSwitch: true,`
);

// 4c: Replace instanceof check with structural check
code = code.replace(
/if \(err instanceof LiveSessionModelSwitchError\) \{/g,
"if (isLiveSessionModelSwitchError(err)) {"
);

fs.writeFileSync(process.argv[1], code, "utf8");
' "$FILE"
echo "[patch] Fixed $FILE"
else
echo "[patch] WARNING: $FILE not found"
fi

# ── Fix 5: get-reply.ts — guard post-directive model overwrite (#57076) ─────
FILE="src/auto-reply/reply/get-reply.ts"
if [ -f "$FILE" ]; then
perl -0777 -i -pe 's{(\} = directiveResult\.result;\n)( provider = resolvedProvider;\n model = resolvedModel;)}{$1 if (!hasResolvedHeartbeatModelOverride) \{\n provider = resolvedProvider;\n model = resolvedModel;\n \}}s' "$FILE"
echo "[patch] Fixed $FILE"
else
echo "[patch] WARNING: $FILE not found"
fi

echo "[patch] Heartbeat model override fix applied (5 files, 4 root causes)."