MUL-7376: fix(openclaw): preserve agent identity with persistent model overrides (#9223)

* fix(openclaw): carry per-run model override on the run input (INFRA-71)

Reimplement MUL-7376 (#8419) on this fork and address the review points.

Resolve runtime_config.model_override once, at claim time, and carry it on the run input (model_override); the daemon no longer decodes it from mutable agent config.

OpenClaw keeps agent.model as the registered agent id (--agent) and the override as the underlying model (--model); arbitrary --model from custom_args stays blocked. Usage attribution falls back runtime model -> per-run override -> unknown, never the agent id.

Round-trip model_override through the runtime-config schema, serializer, equality check and the runtime-config tab (i18n in all locales). Docs updated.

Co-authored-by: multica-agent <[email protected]>

* docs(openclaw): clarify persistent agent model overrides

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
Co-authored-by: Multica Eve <[email protected]>
This commit is contained in:
Basuotian
2026-10-26 13:41:04 +08:00
committed by GitHub
co-authored by multica-agent Multica Eve
parent 802f0894e0
commit b5477490e4
20 changed files with 291 additions and 29 deletions
+1 -1
View File
@@ -390,7 +390,7 @@ Agent-specific overrides:
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_OPENCLAW_MODEL` | Select the registered OpenClaw agent ID used by default; the agent's `runtime_config.model_override` persistently overrides its underlying model for all newly claimed runs until changed or cleared |
| `MULTICA_OPENCLAW_CLI_TIMEOUT` | Deadline for each `openclaw config ...` call during task preparation (default 30s; accepts `45s` or `45`). Raise it when the local CLI is slow to start; the daemon also reads it from `backends.openclaw.cli_timeout` in the CLI config |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
+1 -1
View File
@@ -342,7 +342,7 @@ Agent-specific overrides:
| `MULTICA_CODEARTS_PATH` | Custom path to the `codearts` launcher or binary |
| `MULTICA_CODEARTS_MODEL` | Override the CodeArts model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_OPENCLAW_MODEL` | Select the registered OpenClaw agent ID used by default; the agent's `runtime_config.model_override` persistently overrides its underlying model for all newly claimed runs until changed or cleared |
| `MULTICA_OPENCLAW_CLI_TIMEOUT` | Deadline for each `openclaw config ...` call during task preparation (default 30s; accepts `45s` or `45`). Raise it when the local CLI is slow to start; the daemon also reads it from `backends.openclaw.cli_timeout` in the CLI config |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
OPENCLAW_GATEWAY_TOKEN_MASK,
openclawRuntimeConfigEquals,
parseOpenclawRuntimeConfig,
serializeOpenclawRuntimeConfig,
} from "./openclaw-runtime-config";
@@ -60,4 +62,70 @@ describe("serializeOpenclawRuntimeConfig", () => {
},
});
});
it("emits the agent model override alongside the routing config", () => {
expect(
serializeOpenclawRuntimeConfig({
mode: "gateway",
gateway: { host: "gw.internal", port: 18789 },
modelOverride: "openrouter/@preset/agent-reasoning",
}),
).toEqual({
mode: "gateway",
model_override: "openrouter/@preset/agent-reasoning",
gateway: { host: "gw.internal", port: 18789 },
});
});
it("omits an empty model override", () => {
expect(serializeOpenclawRuntimeConfig({ mode: "local" })).toEqual({
mode: "local",
});
});
});
describe("parseOpenclawRuntimeConfig", () => {
it("reads and trims a persisted model override", () => {
expect(
parseOpenclawRuntimeConfig({
mode: "local",
model_override: " openrouter/@preset/agent-reasoning ",
}),
).toEqual({ mode: "local", modelOverride: "openrouter/@preset/agent-reasoning" });
});
it("drops a non-string or blank model override", () => {
expect(parseOpenclawRuntimeConfig({ model_override: 7 })).toEqual({});
expect(parseOpenclawRuntimeConfig({ model_override: " " })).toEqual({});
});
it("keeps the model override through a gateway round-trip so saving gateway settings cannot drop it", () => {
const original = parseOpenclawRuntimeConfig({
mode: "gateway",
gateway: { host: "gw.internal", port: 18789 },
model_override: "openrouter/@preset/agent-reasoning",
});
const resaved = parseOpenclawRuntimeConfig(
serializeOpenclawRuntimeConfig(original),
);
expect(resaved.modelOverride).toBe("openrouter/@preset/agent-reasoning");
expect(openclawRuntimeConfigEquals(original, resaved)).toBe(true);
});
});
describe("openclawRuntimeConfigEquals", () => {
it("detects a changed model override", () => {
expect(
openclawRuntimeConfigEquals(
{ modelOverride: "a" },
{ modelOverride: "b" },
),
).toBe(false);
expect(
openclawRuntimeConfigEquals(
{ modelOverride: "a" },
{ modelOverride: "a" },
),
).toBe(true);
});
});
@@ -17,6 +17,10 @@ export interface OpenclawGatewayPin {
export interface OpenclawRuntimeConfig {
mode?: OpenclawRoutingMode;
gateway?: OpenclawGatewayPin;
// Persistent agent-level model override. `agent.model` selects the OpenClaw
// identity; this setting selects the underlying LLM for all runs claimed
// until it is changed or cleared. Each claim receives the current value.
modelOverride?: string;
}
// Sentinel the API substitutes for a non-empty `gateway.token` on every read.
@@ -38,6 +42,9 @@ export function parseOpenclawRuntimeConfig(
if (root.mode === "local" || root.mode === "gateway") {
out.mode = root.mode;
}
if (typeof root.model_override === "string" && root.model_override.trim() !== "") {
out.modelOverride = root.model_override.trim();
}
if (root.gateway && typeof root.gateway === "object" && !Array.isArray(root.gateway)) {
const gw = root.gateway as Record<string, unknown>;
const pin: OpenclawGatewayPin = {};
@@ -59,6 +66,7 @@ export function serializeOpenclawRuntimeConfig(
): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (cfg.mode) out.mode = cfg.mode;
if (cfg.modelOverride) out.model_override = cfg.modelOverride;
if (cfg.gateway) {
const gw: Record<string, unknown> = {};
if (cfg.gateway.host) gw.host = cfg.gateway.host;
@@ -84,6 +92,7 @@ export function openclawRuntimeConfigEquals(
b: OpenclawRuntimeConfig,
): boolean {
if ((a.mode ?? "local") !== (b.mode ?? "local")) return false;
if ((a.modelOverride ?? "") !== (b.modelOverride ?? "")) return false;
const aGw = a.gateway ?? {};
const bGw = b.gateway ?? {};
if ((aGw.host ?? "") !== (bGw.host ?? "")) return false;
@@ -25,6 +25,9 @@ import { useT } from "../../../i18n";
// fail-soft default — but the union accepts it as a defensive belt.
interface FormState {
mode: OpenclawRoutingMode;
// Persistent agent model override. Empty uses the model bound to the
// selected OpenClaw agent. A string keeps the input controlled while typing.
modelOverride: string;
host: string;
port: string;
token: string;
@@ -42,6 +45,7 @@ function configToForm(cfg: OpenclawRuntimeConfig): FormState {
const masked = cfg.gateway?.token === OPENCLAW_GATEWAY_TOKEN_MASK;
return {
mode: cfg.mode ?? "local",
modelOverride: cfg.modelOverride ?? "",
host: cfg.gateway?.host ?? "",
port: cfg.gateway?.port ? String(cfg.gateway.port) : "",
// Never display the mask sentinel in the input — that would let users
@@ -55,6 +59,9 @@ function configToForm(cfg: OpenclawRuntimeConfig): FormState {
function formToConfig(state: FormState): OpenclawRuntimeConfig {
const cfg: OpenclawRuntimeConfig = { mode: state.mode };
if (state.modelOverride.trim() !== "") {
cfg.modelOverride = state.modelOverride.trim();
}
if (state.mode === "gateway") {
const gw: NonNullable<OpenclawRuntimeConfig["gateway"]> = {};
if (state.host.trim() !== "") gw.host = state.host.trim();
@@ -179,6 +186,26 @@ export function RuntimeConfigTab({
</p>
</fieldset>
<fieldset className="space-y-2">
<Label htmlFor="openclaw-model-override" className="text-caption font-medium">
{t(($) => $.tab_body.runtime_config.model_override_label)}
</Label>
<Input
id="openclaw-model-override"
value={state.modelOverride}
onChange={(e) =>
setState((s) => ({ ...s, modelOverride: e.target.value }))
}
placeholder={t(
($) => $.tab_body.runtime_config.model_override_placeholder,
)}
className="font-mono text-caption"
/>
<p className="text-caption text-muted-foreground">
{t(($) => $.tab_body.runtime_config.model_override_hint)}
</p>
</fieldset>
<fieldset
className={`space-y-3 rounded-md border p-3 ${isGateway ? "" : "opacity-50"}`}
disabled={!isGateway}
@@ -295,6 +322,7 @@ export function RuntimeConfigTab({
function formEquals(a: FormState, b: FormState): boolean {
return (
a.mode === b.mode &&
a.modelOverride === b.modelOverride &&
a.host === b.host &&
a.port === b.port &&
a.token === b.token &&
+3
View File
@@ -716,6 +716,9 @@
"mode_label": "Routing mode",
"mode_local": "Local",
"mode_gateway": "Gateway",
"model_override_label": "Underlying model override",
"model_override_hint": "Applies to all runs claimed by this agent until changed or cleared. Clearing uses the model bound to the selected OpenClaw agent; its identity stays the same.",
"model_override_placeholder": "openrouter/@preset/agent-reasoning",
"mode_local_hint": "Run on this computer.",
"mode_gateway_hint": "The daemon dials an OpenClaw Gateway. Leave the endpoint fields blank to inherit them from the daemon host's `~/.openclaw/openclaw.json`.",
"gateway_legend": "Gateway endpoint",
+3
View File
@@ -716,6 +716,9 @@
"mode_label": "Mode de routage",
"mode_local": "Local",
"mode_gateway": "Passerelle",
"model_override_label": "Surcharge du modèle sous-jacent",
"model_override_hint": "S'applique à toutes les exécutions prises en charge par cet agent jusqu'à modification ou effacement. Videz ce champ pour utiliser le modèle lié à l'agent OpenClaw sélectionné, sans changer son identité.",
"model_override_placeholder": "openrouter/@preset/agent-reasoning",
"mode_local_hint": "Exécuter sur cet ordinateur.",
"mode_gateway_hint": "Le daemon compose vers une passerelle OpenClaw. Laissez les champs de point d'accès vides pour les hériter du `~/.openclaw/openclaw.json` de l'hôte du daemon.",
"gateway_legend": "Point d'accès de la passerelle",
+3
View File
@@ -542,6 +542,9 @@
"mode_label": "ルーティングモード",
"mode_local": "Local",
"mode_gateway": "Gateway",
"model_override_label": "基底モデルのオーバーライド",
"model_override_hint": "変更またはクリアするまで、このエージェントが受け取るすべての実行に適用されます。空欄にすると、同じ OpenClaw エージェントのまま、紐づくモデルに戻ります。",
"model_override_placeholder": "openrouter/@preset/agent-reasoning",
"mode_local_hint": "このコンピュータで実行します。",
"mode_gateway_hint": "daemon は OpenClaw Gateway にダイヤルします。エンドポイントフィールドを空欄のままにすると daemon ホストの `~/.openclaw/openclaw.json` から継承します。",
"gateway_legend": "Gateway エンドポイント",
+3
View File
@@ -550,6 +550,9 @@
"mode_label": "라우팅 모드",
"mode_local": "Local",
"mode_gateway": "Gateway",
"model_override_label": "기본 모델 재정의",
"model_override_hint": "변경하거나 비울 때까지 이 에이전트가 가져오는 모든 실행에 적용됩니다. 비우면 OpenClaw 에이전트의 정체성은 그대로 유지하면서 연결된 모델을 사용합니다.",
"model_override_placeholder": "openrouter/@preset/agent-reasoning",
"mode_local_hint": "이 컴퓨터에서 실행합니다.",
"mode_gateway_hint": "daemon이 OpenClaw Gateway에 다이얼합니다. 엔드포인트 필드를 비워두면 daemon 호스트의 `~/.openclaw/openclaw.json`에서 상속받습니다.",
"gateway_legend": "Gateway 엔드포인트",
@@ -699,6 +699,9 @@
"mode_label": "路由模式",
"mode_local": "Local",
"mode_gateway": "Gateway",
"model_override_label": "底层模型覆盖",
"model_override_hint": "应用于此智能体此后领取的所有运行,直到修改或清空。清空后使用所选 OpenClaw 智能体绑定的模型,其身份保持不变。",
"model_override_placeholder": "openrouter/@preset/agent-reasoning",
"mode_local_hint": "在本机运行。",
"mode_gateway_hint": "守护进程连接 OpenClaw Gateway。端点字段留空则继承守护进程宿主 `~/.openclaw/openclaw.json` 里的配置。",
"gateway_legend": "Gateway 端点",
+2 -2
View File
@@ -166,7 +166,7 @@ func init() {
agentCreateCmd.Flags().String("model", "", "Model identifier (e.g. claude-sonnet-4-6, openai/gpt-4o). Prefer this over passing --model in --custom-args.")
agentCreateCmd.Flags().String("thinking-level", "", "Reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex values come from the runtime model catalog). The set is runtime/model-specific; malformed values are rejected server-side and the daemon validates the exact model/level pair. Some runtimes (e.g. hermes) expose no reasoning control and reject every value. Empty = runtime default.")
agentCreateCmd.Flags().String("service-tier", "", "Codex execution speed: empty = inherit local Codex configuration; default = explicit Standard when supported by the daemon's installed Codex CLI; a catalog tier such as priority = explicit Fast.")
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
agentCreateCmd.Flags().String("custom-args", "", "Custom CLI arguments as JSON array. For model selection prefer --model; provider adapters may reserve and filter their own model flags. OpenClaw agent model overrides use runtime_config.model_override and apply to all newly claimed runs until changed or cleared.")
agentCreateCmd.Flags().String("custom-env", "", "Custom environment variables as JSON object, e.g. '{\"KEY\":\"value\"}'. Treated as secret material — never logged by the CLI, but values passed on the command line are visible to shell history and 'ps'; prefer --custom-env-stdin or --custom-env-file for real secrets. Pass '{}' to set an empty map.")
agentCreateCmd.Flags().Bool("custom-env-stdin", false, "Read the --custom-env JSON object from stdin. Keeps secrets out of shell history and 'ps'. Mutually exclusive with --custom-env and --custom-env-file.")
agentCreateCmd.Flags().String("custom-env-file", "", "Read the --custom-env JSON object from a file path (suggested mode: 0600). Mutually exclusive with --custom-env and --custom-env-stdin.")
@@ -190,7 +190,7 @@ func init() {
agentUpdateCmd.Flags().String("model", "", "New model identifier. Pass an empty string to clear and fall back to the runtime default.")
agentUpdateCmd.Flags().String("thinking-level", "", "New reasoning/effort level for the agent's runtime (e.g. Claude: low|medium|high|xhigh|max; Codex values come from the runtime model catalog). The set is runtime/model-specific; malformed values are rejected server-side and the daemon validates the exact model/level pair. Some runtimes (e.g. hermes) expose no reasoning control and reject every value. Pass an empty string to clear and fall back to the runtime default.")
agentUpdateCmd.Flags().String("service-tier", "", "New Codex execution speed: default = explicit Standard when supported by the daemon's installed Codex CLI; a catalog tier such as priority = explicit Fast. Pass an empty string to clear and inherit local Codex configuration.")
agentUpdateCmd.Flags().String("custom-args", "", "New custom CLI arguments as JSON array. For model selection prefer --model; some providers (codex app-server, openclaw) reject --model in custom_args.")
agentUpdateCmd.Flags().String("custom-args", "", "New custom CLI arguments as JSON array. For model selection prefer --model; provider adapters may reserve and filter their own model flags. OpenClaw agent model overrides use runtime_config.model_override and apply to all newly claimed runs until changed or cleared.")
// custom_env is intentionally NOT part of `agent update`. Use
// `multica agent env set <id>` — that path admits the agent owner or a
// workspace owner/admin, denies agent actors, and writes a persisted
+5
View File
@@ -8008,8 +8008,12 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// task dispatch.
var openclawMode string
var openclawGateway execenv.OpenclawGatewayPin
var openclawModelOverride string
if task.Agent != nil && provider == "openclaw" {
openclawMode, openclawGateway = decodeOpenclawRuntimeConfig(task.Agent.RuntimeConfig, d.logger)
// Use the server's claim-time snapshot of the agent's persistent model
// override throughout preparation and execution.
openclawModelOverride = strings.TrimSpace(task.ModelOverride)
}
var agentEnvOverrides map[string]string
var agentCustomArgs []string
@@ -8780,6 +8784,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
ThinkingLevel: thinkingLevel,
ServiceTier: serviceTier,
OpenclawMode: openclawMode,
OpenclawModelOverride: openclawModelOverride,
ClaudeSettingsPath: env.ClaudeSettingsPath,
QwenpawWorkspace: env.QwenpawWorkspace,
}
+1
View File
@@ -108,6 +108,7 @@ type Task struct {
ProjectWorkflow *ProjectWorkflowData `json:"project_workflow,omitempty"`
ThreadName string `json:"thread_name,omitempty"` // semantic title for provider-native session/thread history
Agent *AgentData `json:"agent,omitempty"`
ModelOverride string `json:"model_override,omitempty"` // claim-time snapshot of the agent's persistent OpenClaw model override
ConnectedApps []ConnectedAppData `json:"connected_apps,omitempty"` // per-run app capabilities mounted through runtime MCP overlays
Repos []RepoData `json:"repos,omitempty"`
ProjectID string `json:"project_id,omitempty"` // active project for this task, when present
+22
View File
@@ -300,6 +300,23 @@ func preserveMaskedGatewayToken(incoming any, persistedRuntimeConfig []byte) {
gw["token"] = prev.Gateway.Token
}
// openclawModelOverride reads the agent's persistent underlying-model override
// from runtime_config. Every claim snapshots the current setting until it is
// changed or cleared. Absent, malformed, or whitespace-only values return "".
func openclawModelOverride(raw []byte) string {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
var rc struct {
ModelOverride string `json:"model_override"`
}
if err := json.Unmarshal(trimmed, &rc); err != nil {
return ""
}
return strings.TrimSpace(rc.ModelOverride)
}
// RepoData holds repository information included in claim responses so the
// daemon can set up worktrees for each workspace repo.
type RepoData struct {
@@ -435,6 +452,11 @@ type AgentTaskResponse struct {
CreatedAt string `json:"created_at"`
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
// ModelOverride snapshots the agent's persistent OpenClaw model override at
// claim time. Later config edits affect subsequent claims, not this payload.
// Mirrors internal/daemon/types.go with the same JSON name; empty for other
// providers.
ModelOverride string `json:"model_override,omitempty"`
// PriorSessionResumeUnavailable is set when a more recent Codex session was
// withheld because its rollout was missing (MUL-5305); PriorSessionID (if
// any) is then an older fallback, and the daemon surfaces the continuity gap
+6
View File
@@ -3001,6 +3001,12 @@ func (h *Handler) buildClaimedTaskResponse(r *http.Request, task *db.AgentTaskQu
RuntimeConfig: runtimeConfig,
DisabledRuntimeSkills: disabledRuntimeSkillsFor(agent.DisabledRuntimeSkills, runtimeID, runtime.Provider),
}
// Snapshot the agent's persistent OpenClaw model override for this claim.
// It applies to every claim until changed or cleared; later edits do not
// affect payloads already delivered to the daemon.
if runtime.Provider == "openclaw" {
resp.ModelOverride = openclawModelOverride(agent.RuntimeConfig)
}
// System agents carry a product-owned instruction layer that ships with
// this binary instead of being copied into their row at creation. That
// is what makes it hot-updatable: editing the embedded file and
@@ -0,0 +1,28 @@
package handler
import "testing"
func TestOpenclawModelOverride(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
raw string
want string
}{
{name: "absent payload", raw: "", want: ""},
{name: "null payload", raw: "null", want: ""},
{name: "empty object", raw: "{}", want: ""},
{name: "trimmed override", raw: `{"mode":"gateway","model_override":" openrouter/@preset/agent-reasoning "}`, want: "openrouter/@preset/agent-reasoning"},
{name: "whitespace-only override", raw: `{"model_override":" "}`, want: ""},
{name: "malformed json", raw: `{"model_override":`, want: ""},
{name: "unrelated keys are ignored", raw: `{"model_override":"m","other":1}`, want: "m"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := openclawModelOverride([]byte(tc.raw)); got != tc.want {
t.Errorf("openclawModelOverride(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
@@ -209,10 +209,17 @@ to clear. `agent copy` still carries the source value and has no override flag.
`model` is a first-class persisted column the daemon reads directly.
`custom_args` are normally raw provider CLI args. The CLI help notes that some
providers (codex app-server, openclaw) reject `--model` inside `custom_args` —
but that is documented CLI guidance, not a server-enforced invariant; nothing
on the create path inspects `custom_args` for a model flag. Provider
backends may consume protocol selectors before launch:
providers reject `--model` inside `custom_args`; provider backends filter flags
they manage directly. For OpenClaw, `model` selects the registered OpenClaw
agent ID (for example `main`). The optional `runtime_config.model_override` is
a persistent agent-level setting: it selects the underlying model for every
run claimed by this agent until the setting is changed or cleared. It does not
create another OpenClaw agent or split its identity, workspace, sessions, or
memory. Clear the setting to use the model bound to the selected OpenClaw agent.
Each claim snapshots the current setting; changing it does not affect runs
already claimed. The daemon emits native `--agent <model>` and
`--model <model_override>` flags separately.
Provider backends may also consume other protocol selectors before launch:
- Pi filters `--thinking` because the first-class `thinking_level` field owns
that flag and must be its only source.
+6
View File
@@ -129,6 +129,12 @@ type ExecOptions struct {
// ignore this field, mirroring ThinkingLevel's renderer-side fall-through
// pattern. See issue #3260.
OpenclawMode string
// OpenclawModelOverride is the claim-time snapshot of the agent's persistent
// underlying-model override. The setting applies to all newly claimed runs
// until changed or cleared. OpenClaw receives it as `--model <id>` alongside
// `--agent <id>`, preserving the identity, workspace, sessions, and memory
// selected by Model. Other backends ignore this field.
OpenclawModelOverride string
// ClaudeSettingsPath is a daemon-owned, task-local settings file passed
// through Claude Code's --settings flag. It currently carries restrictive
// runtime-skill overrides only; other providers ignore it.
+36 -19
View File
@@ -41,7 +41,7 @@ var openclawBlockedArgs = map[string]blockedArgMode{
"--json": blockedStandalone, // JSON output for daemon communication
"--session-id": blockedWithValue, // managed by daemon for session resumption
"--message": blockedWithValue, // prompt is set by daemon
"--model": blockedWithValue, // openclaw agent does not accept --model; model is bound at registration via `openclaw agents add/update --model`
"--model": blockedWithValue, // managed by OpenclawModelOverride; never accept an untracked duplicate from custom_args
"--system-prompt": blockedWithValue, // openclaw agent does not accept --system-prompt; instructions are injected into --message
}
@@ -192,21 +192,16 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
b.cfg.Logger.Info("openclaw finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
// Build usage map. Prefer the model openclaw reported in
// `meta.agentMeta.model` (the actual LLM, e.g. `deepseek-chat`).
// Fall back to opts.Model — which for openclaw is the agent name
// passed via `--agent`, not a real model identifier — only when
// the runtime didn't surface its own model. Last resort is the
// daemon's `unknown` placeholder.
// `meta.agentMeta.model` (the actual LLM, e.g. `deepseek-chat`), then the
// agent model override snapshotted for this claim. Never fall
// back to opts.Model: for the openclaw backend that is the OpenClaw
// *agent* name passed via `--agent`, not a model identifier, and
// attributing usage to it would key the dashboard on the agent id. Last
// resort is the daemon's `unknown` placeholder.
var usage map[string]TokenUsage
u := scanResult.usage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 || u.CacheWriteTokens > 0 {
model := scanResult.model
if model == "" {
model = opts.Model
}
if model == "" {
model = "unknown"
}
model := openclawUsageModel(scanResult.model, opts.OpenclawModelOverride)
usage = map[string]TokenUsage{model: u}
}
@@ -225,12 +220,12 @@ func (b *openclawBackend) Execute(ctx context.Context, prompt string, opts ExecO
// buildOpenclawArgs assembles the argv for a one-shot `openclaw agent` invocation.
//
// The CLI only accepts --local, --json, --session-id, --timeout, --message (and
// flags like --agent / --channel that users pass through CustomArgs). Notably
// it does NOT accept --model or --system-prompt — model is bound at agent
// registration time via `openclaw agents add/update --model`, and instructions
// must be injected inline into --message because openclaw loads AGENTS.md from
// its own workspace directory, not from cwd.
// The CLI accepts separate --agent and --model flags. Multica intentionally
// keeps those concerns separate: opts.Model selects the registered OpenClaw
// agent (identity / workspace / session state), while opts.OpenclawModelOverride
// carries the agent's persistent underlying-model setting as of this claim.
// Instructions must still be injected inline into --message because openclaw
// loads AGENTS.md from its own workspace directory, not from cwd.
//
// Routing (issue #3260): `openclaw agent` defaults to Gateway routing; --local
// is the embedded-mode opt-in. The daemon historically forced --local so every
@@ -261,6 +256,12 @@ func buildOpenclawArgs(prompt, sessionID string, opts ExecOptions, logger *slog.
if opts.Model != "" && !customArgsContains(customArgs, "--agent") {
args = append(args, "--agent", opts.Model)
}
// The saved agent model override arrives in the claim payload; --model
// stays blocked in custom_args. Emit it after --agent to keep the OpenClaw
// agent identity and the underlying model independently addressable.
if opts.OpenclawModelOverride != "" {
args = append(args, "--model", opts.OpenclawModelOverride)
}
args = append(args, customArgs...)
if opts.SystemPrompt != "" {
@@ -282,6 +283,22 @@ func customArgsContains(args []string, flag string) bool {
return false
}
// openclawUsageModel picks the model key for a run's usage record. It prefers
// the model openclaw reported in the result, then the agent model override
// snapshotted for this claim, and finally the daemon's `unknown`
// placeholder. It deliberately never falls back to the OpenClaw agent identity
// (ExecOptions.Model): that value is a registered OpenClaw agent id, not a
// model, so keying usage on it would misattribute tokens on the dashboard.
func openclawUsageModel(reportedModel, modelOverride string) string {
if m := strings.TrimSpace(reportedModel); m != "" {
return m
}
if m := strings.TrimSpace(modelOverride); m != "" {
return m
}
return "unknown"
}
// checkOpenclawVersion runs `<execPath> --version` and returns a
// user-facing error when the installed openclaw is older than
// minOpenclawVersion. The returned error becomes the task's failure
+52 -2
View File
@@ -964,6 +964,53 @@ func TestBuildOpenclawArgsMapsModelToAgent(t *testing.T) {
}
}
func TestBuildOpenclawArgsKeepsAgentAndModelOverrideSeparate(t *testing.T) {
t.Parallel()
// The registered OpenClaw agent (--agent) keeps identity/workspace/session
// state while the agent model override (--model) selects the underlying LLM.
// Both must be emitted, in that order, and never collapsed into one flag.
args := buildOpenclawArgs("task", "ses-model-override", ExecOptions{
Model: "main",
OpenclawModelOverride: "openrouter/@preset/agent-reasoning",
}, slog.Default())
agentIdx := indexOf(args, "--agent")
if agentIdx == -1 || agentIdx+1 >= len(args) || args[agentIdx+1] != "main" {
t.Fatalf("expected --agent main, got: %v", args)
}
modelIdx := indexOf(args, "--model")
if modelIdx == -1 || modelIdx+1 >= len(args) || args[modelIdx+1] != "openrouter/@preset/agent-reasoning" {
t.Fatalf("expected managed agent model override, got: %v", args)
}
if modelIdx <= agentIdx {
t.Errorf("expected the model override after agent selection, got: %v", args)
}
}
func TestOpenclawUsageModelFallback(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
reported string
override string
want string
}{
{name: "prefers the runtime-reported model", reported: "deepseek-chat", override: "openrouter/@preset/agent-reasoning", want: "deepseek-chat"},
{name: "falls back to the agent model override", reported: "", override: "openrouter/@preset/agent-reasoning", want: "openrouter/@preset/agent-reasoning"},
{name: "trims whitespace", reported: " ", override: " openrouter/@preset/agent-reasoning ", want: "openrouter/@preset/agent-reasoning"},
{name: "falls back to unknown, never the agent id", reported: "", override: "", want: "unknown"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := openclawUsageModel(tc.reported, tc.override); got != tc.want {
t.Errorf("openclawUsageModel(%q, %q) = %q, want %q", tc.reported, tc.override, got, tc.want)
}
})
}
}
func TestBuildOpenclawArgsCustomAgentWinsOverModel(t *testing.T) {
t.Parallel()
@@ -1044,6 +1091,7 @@ func TestBuildOpenclawArgsFiltersBlockedCustomArgs(t *testing.T) {
// Users must not be able to re-introduce the banned flags via custom_args —
// they would crash `openclaw agent` just like the direct forward did.
args := buildOpenclawArgs("task", "ses-6", ExecOptions{
OpenclawModelOverride: "openrouter/@preset/agent-long",
CustomArgs: []string{
"--agent", "research-bot",
"--model", "gpt-4o",
@@ -1053,8 +1101,10 @@ func TestBuildOpenclawArgsFiltersBlockedCustomArgs(t *testing.T) {
},
}, slog.Default())
if idx := indexOf(args, "--model"); idx != -1 {
t.Errorf("--model should be filtered from custom_args: %v", args)
if count := countOccurrences(args, "--model"); count != 1 {
t.Errorf("expected only the managed --model override, got: %v", args)
} else if idx := indexOf(args, "--model"); idx+1 >= len(args) || args[idx+1] != "openrouter/@preset/agent-long" {
t.Errorf("custom --model should be filtered in favor of the managed override: %v", args)
}
if idx := indexOf(args, "--system-prompt"); idx != -1 {
t.Errorf("--system-prompt should be filtered from custom_args: %v", args)