mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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:
co-authored by
multica-agent
Multica Eve
parent
802f0894e0
commit
b5477490e4
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 エンドポイント",
|
||||
|
||||
@@ -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 端点",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user