fix(gateway): honor session title model routing (#111757)

Route automatic dashboard titles through the effective session model and auth profile while preserving explicit cross-provider utility ownership and the existing generic title fallback.
This commit is contained in:
Peter Steinberger
2026-07-20 02:19:57 -07:00
committed by GitHub
parent 9056c43368
commit 473f2aca33
8 changed files with 657 additions and 57 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ Related model-config surfaces:
- `agents.defaults.models` stores aliases and per-model settings. Adding an entry does not restrict model overrides.
- `agents.defaults.modelPolicy.allow` is the optional override allowlist. Use exact refs or trailing prefix wildcards such as `provider/*` and `provider/namespace/*`; omit it or set `[]` to allow any model. Per-agent `agents.list[].modelPolicy.allow` replaces the default policy for that agent.
- `agents.defaults.utilityModel` is an optional lower-cost model for short internal tasks such as generated dashboard session titles, supported channel thread/topic titles, and progress narration. Per-agent `agents.list[].utilityModel` overrides it. When unset, OpenClaw uses the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`), otherwise the agent's primary model; set it to an empty string to disable utility routing. Generated title tasks retry once with the primary model when a distinct utility model fails. Utility tasks are separate model calls and may send bounded task content to the selected model provider.
- `agents.defaults.utilityModel` is an optional lower-cost model for short internal tasks such as generated dashboard session titles, supported channel thread/topic titles, and progress narration. Per-agent `agents.list[].utilityModel` overrides it. When unset, OpenClaw uses the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`), otherwise the agent's primary model; set it to an empty string to disable utility routing. Generated titles retry once with the primary model when a distinct utility model fails. For dashboard titles, automatic utility derivation and the regular fallback follow the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. An empty utility model skips only the alternate small-model route, not dashboard title generation. Utility tasks are separate model calls and may send bounded task content to the selected model provider.
- `agents.defaults.imageModel` is used only when the primary model cannot accept images.
- `agents.defaults.pdfModel` is used by the `pdf` tool. If unset, the tool falls back to `imageModel`, then the resolved session/default model.
- `agents.defaults.imageGenerationModel`, `musicGenerationModel`, and `videoGenerationModel` back the shared media-generation tools. If unset, each tool infers an auth-backed provider default: current default provider first, then the remaining registered providers for that capability in provider-id order. Set `agents.defaults.mediaGenerationAutoProviderFallback: false` to disable that cross-provider inference while keeping explicit fallbacks.
+2 -2
View File
@@ -401,7 +401,7 @@ Time format in system prompt. Default: `auto` (OS preference).
- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
- String form sets only the primary model.
- Object form sets primary plus ordered failover models.
- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). When unset, OpenClaw derives the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`); title tasks otherwise use the agent's primary model, and narration stays off. If a distinct utility model cannot prepare or complete a generated title, OpenClaw retries that title once with the primary model. Set `utilityModel: ""` to disable utility routing entirely. `agents.list[].utilityModel` overrides the default (an empty per-agent value disables it for that agent), and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements.
- `utilityModel`: optional `provider/model` ref or alias for short internal tasks. It currently powers generated Control UI session titles, Telegram DM topic titles, Discord auto-thread titles, and [progress-draft narration](/concepts/progress-drafts#narrated-status). When unset, OpenClaw derives the primary provider's declared small-model default when one exists (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`); title tasks otherwise use the agent's primary model, and narration stays off. If a distinct utility model cannot prepare or complete a generated title, OpenClaw retries that title once with the primary model. For dashboard titles, automatic utility derivation and the regular fallback use the effective session provider and auth profile; an explicit utility model keeps its configured provider/auth. Set `utilityModel: ""` to skip the alternate utility route; dashboard title generation still proceeds directly to the regular session model. `agents.list[].utilityModel` overrides the default, and an operation-specific model override wins over both. Utility tasks make separate model calls and send task-specific content to the selected model provider. Dashboard title generation sends at most the first 1,000 characters of the first non-command message; narration sends the inbound request plus compact redacted tool summaries. Choose a provider that matches your cost and data-handling requirements.
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
- Used by the `image` tool path as its vision-model config when the active model cannot accept images. Native-vision models receive loaded image bytes directly instead.
- Also used as fallback routing when the selected/default model cannot accept image input.
@@ -1048,7 +1048,7 @@ for provider examples and precedence.
- `id`: stable agent id (required).
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
- `model`: string form sets a strict per-agent primary with no model fallback; object form `{ primary }` is also strict unless you add `fallbacks`. Use `{ primary, fallbacks: [...] }` to opt that agent into fallback, or `{ primary, fallbacks: [] }` to make strict behavior explicit. Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
- `utilityModel`: optional per-agent override for short internal tasks such as generated session and thread titles. Falls back to `agents.defaults.utilityModel`, then the primary provider's declared small-model default, then this agent's primary model. An empty string disables utility routing for this agent.
- `utilityModel`: optional per-agent override for short internal tasks such as generated session and thread titles. Falls back to `agents.defaults.utilityModel`, then the effective session provider's declared small-model default. Dashboard titles retry once with the effective regular session model. An empty string skips the alternate utility route for this agent without disabling dashboard title generation.
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
- `tts`: optional per-agent text-to-speech overrides. The block deep-merges over `messages.tts`, so keep shared provider credentials and fallback policy in `messages.tts` and set only persona-specific values such as provider, voice, model, style, or auto mode here.
- `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills.
+8 -3
View File
@@ -100,15 +100,20 @@ describe("resolveUtilityModelRefForAgent", () => {
);
});
it("prefers a caller-resolved primary provider over re-derivation", () => {
it("prefers caller-resolved session provider and profile context", () => {
const cfg = {
agents: { defaults: { model: "anthropic/claude-fable-5@personal" } },
} as OpenClawConfig;
expect(
resolveUtilityModelRefForAgent({
cfg: {} as OpenClawConfig,
cfg,
agentId: "main",
primaryProvider: "OpenAI",
primaryModelRef: "openai/gpt-5.5@work",
metadataSnapshot,
}),
).toBe("openai/gpt-5.6-luna");
).toBe("openai/gpt-5.6-luna@work");
});
it("returns undefined for providers without a declared default", () => {
+9 -3
View File
@@ -65,14 +65,17 @@ function resolveProviderDefaultUtilityModelRef(params: {
/**
* The utility model ref to use for the agent, or undefined when utility
* routing is disabled or no default exists. Derivation uses the agent's
* primary provider, so usable auth is already established by construction.
* routing is disabled or no default exists. Callers with a session-specific
* selection pass both primary fields so automatic routing keeps that session's
* provider and auth owner.
*/
export function resolveUtilityModelRefForAgent(params: {
cfg: OpenClawConfig;
agentId: string;
/** Pass when the caller already resolved the primary provider. */
primaryProvider?: string;
/** Pass with primaryProvider to carry a session-specific auth profile. */
primaryModelRef?: string;
metadataSnapshot?: PluginMetadataSnapshot;
}): string | undefined {
const setting = readUtilityModelSetting(params.cfg, params.agentId);
@@ -99,7 +102,10 @@ export function resolveUtilityModelRefForAgent(params: {
// The derived default shares the primary's provider, so a trailing auth
// profile on the primary ref must carry over; otherwise profile-isolated
// setups would route utility calls through default credentials.
const primaryRef = resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) ?? "";
const primaryRef =
params.primaryModelRef?.trim() ||
resolveAgentEffectiveModelPrimary(params.cfg, params.agentId) ||
"";
const primaryProfile = primaryRef ? splitTrailingAuthProfile(primaryRef)?.profile : undefined;
return primaryProfile ? `${derived}@${primaryProfile}` : derived;
}
@@ -4,15 +4,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const completeWithPreparedSimpleCompletionModel = vi.hoisted(() => vi.fn());
const logVerbose = vi.hoisted(() => vi.fn());
const prepareSimpleCompletionModelForAgent = vi.hoisted(() => vi.fn());
const resolveSimpleCompletionSelectionForAgent = vi.hoisted(() => vi.fn());
vi.mock("../../agents/simple-completion-runtime.js", () => ({
completeWithPreparedSimpleCompletionModel,
prepareSimpleCompletionModelForAgent,
resolveSimpleCompletionSelectionForAgent,
}));
vi.mock("../../globals.js", () => ({ logVerbose }));
import { generateConversationLabel } from "./conversation-label-generator.js";
import {
generateConversationLabel,
generateConversationLabelWithFallback,
} from "./conversation-label-generator.js";
function firstCompletionArgs() {
const call = completeWithPreparedSimpleCompletionModel.mock.calls.at(0);
@@ -321,3 +326,305 @@ describe("generateConversationLabel", () => {
).resolves.toBeNull();
});
});
describe("generateConversationLabelWithFallback", () => {
const params = {
userMessage: "Need help with invoices",
prompt: "Generate a label",
cfg: {},
agentId: "billing",
utilityModelRef: "openai/gpt-mini@work",
regularModelRef: "openai/gpt-main@work",
preferredProfile: "work",
};
beforeEach(() => {
completeWithPreparedSimpleCompletionModel.mockReset();
logVerbose.mockReset();
prepareSimpleCompletionModelForAgent.mockReset();
resolveSimpleCompletionSelectionForAgent.mockReset();
resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => {
const [model, profileId] = modelRef.split("@");
const slash = model.indexOf("/");
return {
provider: model.slice(0, slash),
modelId: model.slice(slash + 1),
profileId,
agentDir: "/tmp/openclaw-agent",
};
});
prepareSimpleCompletionModelForAgent.mockImplementation(async ({ modelRef }) => {
const [model] = modelRef.split("@");
const slash = model.indexOf("/");
return {
selection: {
provider: model.slice(0, slash),
modelId: model.slice(slash + 1),
profileId: "work",
agentDir: "/tmp/openclaw-agent",
},
model: {
provider: model.slice(0, slash),
id: model.slice(slash + 1),
maxTokens: 8192,
},
auth: { apiKey: "resolved-key", mode: "api-key" },
};
});
completeWithPreparedSimpleCompletionModel.mockResolvedValue({
content: [{ type: "text", text: "Utility title" }],
});
});
afterEach(() => {
vi.useRealTimers();
});
it("uses the utility candidate once with the selected auth owner", async () => {
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Utility title");
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledWith({
cfg: {},
agentId: "billing",
agentDir: undefined,
modelRef: "openai/gpt-mini@work",
bindAuthOwner: true,
useAsyncModelResolution: true,
allowMissingApiKeyModes: ["aws-sdk"],
});
});
it("locks an inherited profile onto a same-provider utility ref", async () => {
await expect(
generateConversationLabelWithFallback({
...params,
utilityModelRef: "openai/gpt-mini",
}),
).resolves.toBe("Utility title");
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({
modelRef: "openai/gpt-mini@work",
bindAuthOwner: true,
});
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
"preferredProfile",
);
});
it("does not force the regular profile onto a cross-provider utility model", async () => {
await expect(
generateConversationLabelWithFallback({
...params,
utilityModelRef: "anthropic/claude-haiku-4-5",
}),
).resolves.toBe("Utility title");
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toEqual({
cfg: {},
agentId: "billing",
agentDir: undefined,
modelRef: "anthropic/claude-haiku-4-5",
bindAuthOwner: true,
useAsyncModelResolution: true,
allowMissingApiKeyModes: ["aws-sdk"],
});
});
it("does not inherit profiles across logical providers sharing one runtime", async () => {
resolveSimpleCompletionSelectionForAgent.mockImplementation(({ modelRef }) => ({
provider: modelRef.startsWith("anthropic/") ? "anthropic" : "openai",
runtimeProvider: "openai",
modelId: modelRef.split("/").slice(1).join("/"),
agentDir: "/tmp/openclaw-agent",
}));
await expect(
generateConversationLabelWithFallback({
...params,
utilityModelRef: "anthropic/claude-haiku-4-5",
}),
).resolves.toBe("Utility title");
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
"preferredProfile",
);
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe(
"anthropic/claude-haiku-4-5",
);
});
it("falls back when utility preparation fails", async () => {
prepareSimpleCompletionModelForAgent.mockResolvedValueOnce({ error: "missing auth" });
completeWithPreparedSimpleCompletionModel.mockResolvedValueOnce({
content: [{ type: "text", text: "Regular title" }],
});
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title");
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2);
expect(prepareSimpleCompletionModelForAgent.mock.calls[1]?.[0]?.modelRef).toBe(
"openai/gpt-main@work",
);
});
it.each([
{
name: "error stop reason",
first: { content: [], stopReason: "error", errorMessage: "utility failed" },
},
{ name: "empty output", first: { content: [] } },
])("falls back after utility $name", async ({ first }) => {
completeWithPreparedSimpleCompletionModel
.mockResolvedValueOnce(first)
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title");
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(2);
});
it("falls back when utility output fails operation-specific normalization", async () => {
completeWithPreparedSimpleCompletionModel
.mockResolvedValueOnce({ content: [{ type: "text", text: "Title:" }] })
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
await expect(
generateConversationLabelWithFallback({
...params,
normalizeLabel: (label) => (label === "Title:" ? null : label),
}),
).resolves.toBe("Regular title");
});
it("falls back after a utility completion exception", async () => {
completeWithPreparedSimpleCompletionModel
.mockRejectedValueOnce(new Error("transport failed"))
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
await expect(generateConversationLabelWithFallback(params)).resolves.toBe("Regular title");
});
it("falls back after the utility attempt times out", async () => {
vi.useFakeTimers();
completeWithPreparedSimpleCompletionModel
.mockImplementationOnce(
({ options }) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => reject(new Error("aborted")));
}),
)
.mockResolvedValueOnce({ content: [{ type: "text", text: "Regular title" }] });
const generated = generateConversationLabelWithFallback(params);
await vi.advanceTimersByTimeAsync(15_000);
await expect(generated).resolves.toBe("Regular title");
});
it("returns null when both explicit candidates fail", async () => {
prepareSimpleCompletionModelForAgent
.mockResolvedValueOnce({ error: "utility auth failed" })
.mockResolvedValueOnce({ error: "regular auth failed" });
await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull();
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2);
expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled();
});
it("skips a regular candidate that resolves to the same model and profile", async () => {
resolveSimpleCompletionSelectionForAgent.mockReturnValue({
provider: "openai",
modelId: "same-model",
profileId: "work",
agentDir: "/tmp/openclaw-agent",
});
completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] });
await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull();
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce();
});
it("deduplicates candidates after asynchronous preparation resolves them identically", async () => {
prepareSimpleCompletionModelForAgent.mockResolvedValue({
selection: {
provider: "openai",
modelId: "resolved-same-model",
profileId: "work",
agentDir: "/tmp/openclaw-agent",
},
model: { provider: "openai", id: "resolved-same-model", maxTokens: 8192 },
auth: { apiKey: "resolved-key", mode: "api-key" },
});
completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] });
await expect(generateConversationLabelWithFallback(params)).resolves.toBeNull();
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2);
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce();
});
it("inherits the regular profile for unresolved same-provider utility refs", async () => {
resolveSimpleCompletionSelectionForAgent.mockReturnValue(null);
await expect(
generateConversationLabelWithFallback({
...params,
utilityModelRef: "openai/gpt-mini",
}),
).resolves.toBe("Utility title");
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).toMatchObject({
modelRef: "openai/gpt-mini@work",
bindAuthOwner: true,
});
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
"preferredProfile",
);
});
it("does not inherit the regular profile for unresolved cross-provider utility refs", async () => {
resolveSimpleCompletionSelectionForAgent.mockReturnValue(null);
await expect(
generateConversationLabelWithFallback({
...params,
utilityModelRef: "anthropic/claude-haiku-4-5",
}),
).resolves.toBe("Utility title");
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]).not.toHaveProperty(
"preferredProfile",
);
});
it("deduplicates identical raw refs when selection resolution is unavailable", async () => {
resolveSimpleCompletionSelectionForAgent.mockReturnValue(null);
completeWithPreparedSimpleCompletionModel.mockResolvedValue({ content: [] });
await expect(
generateConversationLabelWithFallback({
...params,
utilityModelRef: params.regularModelRef,
}),
).resolves.toBeNull();
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
});
it("uses the regular candidate directly when no utility model is available", async () => {
const { utilityModelRef: _utilityModelRef, ...regularOnlyParams } = params;
await expect(generateConversationLabelWithFallback(regularOnlyParams)).resolves.toBe(
"Utility title",
);
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledOnce();
expect(prepareSimpleCompletionModelForAgent.mock.calls[0]?.[0]?.modelRef).toBe(
"openai/gpt-main@work",
);
});
});
@@ -1,9 +1,11 @@
// Generates short labels for sessions from conversation context.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { splitTrailingAuthProfile } from "../../agents/model-ref-profile.js";
import {
completeWithPreparedSimpleCompletionModel,
prepareSimpleCompletionModelForAgent,
resolveSimpleCompletionSelectionForAgent,
} from "../../agents/simple-completion-runtime.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
@@ -19,6 +21,12 @@ const TIMEOUT_MS = 15_000;
type PreparedLabelModel = Awaited<ReturnType<typeof prepareSimpleCompletionModelForAgent>>;
type ReadyLabelModel = Extract<PreparedLabelModel, { model: unknown }>;
type LabelModelPhase = "utility" | "primary fallback";
type ConversationLabelAttempt = {
modelRef?: string;
useUtilityModel?: boolean;
preferredProfile?: string;
bindAuthOwner?: boolean;
};
/** Inputs for generating a short conversation label from the configured utility model. */
export type ConversationLabelParams = {
@@ -30,6 +38,13 @@ export type ConversationLabelParams = {
maxLength?: number;
};
type ConversationLabelFallbackParams = ConversationLabelParams & {
utilityModelRef?: string;
regularModelRef: string;
preferredProfile?: string;
normalizeLabel?: (label: string) => string | null;
};
function isTextContentBlock(block: { type: string }): block is TextContent {
return block.type === "text";
}
@@ -48,6 +63,12 @@ function extractSimpleCompletionError(result: {
return result.errorMessage?.trim() || "unknown error";
}
function resolveMaxLabelLength(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: DEFAULT_MAX_LABEL_LENGTH;
}
function logLabelFailure(phase: LabelModelPhase, message: string): void {
const prefix = phase === "utility" ? "" : `${phase} `;
logVerbose(`conversation-label-generator: ${prefix}${message}`);
@@ -57,7 +78,7 @@ async function prepareLabelModel(params: {
cfg: OpenClawConfig;
agentId: string;
agentDir?: string;
useUtilityModel: boolean;
attempt: ConversationLabelAttempt;
phase: LabelModelPhase;
}): Promise<PreparedLabelModel | null> {
try {
@@ -65,7 +86,16 @@ async function prepareLabelModel(params: {
cfg: params.cfg,
agentId: params.agentId,
agentDir: params.agentDir,
useUtilityModel: params.useUtilityModel,
...(params.attempt.modelRef ? { modelRef: params.attempt.modelRef } : {}),
...(params.attempt.useUtilityModel !== undefined
? { useUtilityModel: params.attempt.useUtilityModel }
: {}),
...(params.attempt.preferredProfile
? { preferredProfile: params.attempt.preferredProfile }
: {}),
...(params.attempt.bindAuthOwner !== undefined
? { bindAuthOwner: params.attempt.bindAuthOwner }
: {}),
useAsyncModelResolution: true,
allowMissingApiKeyModes: ["aws-sdk"],
});
@@ -95,6 +125,50 @@ function selectedLabelModelsMatch(
);
}
function resolveAttemptSelection(params: {
cfg: OpenClawConfig;
agentId: string;
agentDir?: string;
attempt: ConversationLabelAttempt;
}) {
return resolveSimpleCompletionSelectionForAgent({
cfg: params.cfg,
agentId: params.agentId,
agentDir: params.agentDir,
...(params.attempt.modelRef ? { modelRef: params.attempt.modelRef } : {}),
...(params.attempt.useUtilityModel !== undefined
? { useUtilityModel: params.attempt.useUtilityModel }
: {}),
});
}
function resolveRawModelProvider(modelRef: string | undefined): string | undefined {
const model = splitTrailingAuthProfile(modelRef?.trim() ?? "").model;
const separator = model.indexOf("/");
const provider = separator > 0 ? model.slice(0, separator).trim().toLowerCase() : "";
return provider || undefined;
}
function resolveAttemptKey(params: {
cfg: OpenClawConfig;
agentId: string;
agentDir?: string;
attempt: ConversationLabelAttempt;
}): string {
const selection = resolveAttemptSelection(params);
if (selection) {
return [
"resolved",
selection.provider,
selection.runtimeProvider ?? "",
selection.modelId,
selection.profileId ?? params.attempt.preferredProfile ?? "",
].join("\0");
}
const rawRef = splitTrailingAuthProfile(params.attempt.modelRef?.trim() ?? "");
return ["raw", rawRef.model, rawRef.profile ?? params.attempt.preferredProfile ?? ""].join("\0");
}
async function completeLabel(params: {
prepared: ReadyLabelModel;
cfg: OpenClawConfig;
@@ -142,12 +216,7 @@ async function completeLabel(params: {
.map((block) => block.text)
.join("")
.trim();
if (!text) {
return null;
}
return truncateUtf16Safe(text, params.maxLength) || null;
return text ? truncateUtf16Safe(text, params.maxLength) || null : null;
} catch (err) {
logLabelFailure(params.phase, `completion failed: ${String(err)}`);
return null;
@@ -161,18 +230,13 @@ export async function generateConversationLabel(
params: ConversationLabelParams,
): Promise<string | null> {
const { userMessage, prompt, cfg, agentId, agentDir } = params;
const maxLength =
typeof params.maxLength === "number" &&
Number.isFinite(params.maxLength) &&
params.maxLength > 0
? Math.floor(params.maxLength)
: DEFAULT_MAX_LABEL_LENGTH;
const maxLength = resolveMaxLabelLength(params.maxLength);
const resolvedAgentId = agentId ?? resolveDefaultAgentId(cfg);
const utilityPrepared = await prepareLabelModel({
cfg,
agentId: resolvedAgentId,
agentDir,
useUtilityModel: true,
attempt: { useUtilityModel: true },
phase: "utility",
});
const utilityCompletionAttempted = Boolean(utilityPrepared && !("error" in utilityPrepared));
@@ -194,7 +258,7 @@ export async function generateConversationLabel(
cfg,
agentId: resolvedAgentId,
agentDir,
useUtilityModel: false,
attempt: { useUtilityModel: false },
phase: "primary fallback",
});
if (
@@ -213,3 +277,94 @@ export async function generateConversationLabel(
phase: "primary fallback",
});
}
/** Tries an explicit utility model once, then the regular model once when needed. */
export async function generateConversationLabelWithFallback(
params: ConversationLabelFallbackParams,
): Promise<string | null> {
const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg);
const regularAttempt: ConversationLabelAttempt = {
modelRef: params.regularModelRef,
...(params.preferredProfile ? { preferredProfile: params.preferredProfile } : {}),
bindAuthOwner: true,
};
const utilityRef = params.utilityModelRef?.trim();
let utilityAttempt: ConversationLabelAttempt | undefined;
if (utilityRef) {
const candidate: ConversationLabelAttempt = { modelRef: utilityRef, bindAuthOwner: true };
const utilitySelection = resolveAttemptSelection({
cfg: params.cfg,
agentId,
agentDir: params.agentDir,
attempt: candidate,
});
const regularSelection = resolveAttemptSelection({
cfg: params.cfg,
agentId,
agentDir: params.agentDir,
attempt: regularAttempt,
});
const utilityAuthProvider = utilitySelection?.provider ?? resolveRawModelProvider(utilityRef);
const regularAuthProvider =
regularSelection?.provider ?? resolveRawModelProvider(params.regularModelRef);
const utilityRawProfile = splitTrailingAuthProfile(utilityRef).profile;
const inheritsRegularProfile =
params.preferredProfile &&
!utilitySelection?.profileId &&
!utilityRawProfile &&
utilityAuthProvider &&
utilityAuthProvider === regularAuthProvider;
utilityAttempt = inheritsRegularProfile
? { modelRef: `${utilityRef}@${params.preferredProfile}`, bindAuthOwner: true }
: candidate;
}
const attempts: ConversationLabelAttempt[] = [
...(utilityAttempt ? [utilityAttempt] : []),
regularAttempt,
];
const seen = new Set<string>();
const maxLength = resolveMaxLabelLength(params.maxLength);
let previousCompletedModel: PreparedLabelModel | null = null;
for (const attempt of attempts) {
const key = resolveAttemptKey({
cfg: params.cfg,
agentId,
agentDir: params.agentDir,
attempt,
});
if (seen.has(key)) {
continue;
}
seen.add(key);
const phase = attempt === regularAttempt ? "primary fallback" : "utility";
const prepared = await prepareLabelModel({
cfg: params.cfg,
agentId,
agentDir: params.agentDir,
attempt,
phase,
});
if (!prepared || "error" in prepared) {
continue;
}
if (previousCompletedModel && selectedLabelModelsMatch(previousCompletedModel, prepared)) {
continue;
}
previousCompletedModel = prepared;
const label = await completeLabel({
prepared,
cfg: params.cfg,
userMessage: params.userMessage,
prompt: params.prompt,
maxLength,
phase,
});
if (label) {
const normalized = params.normalizeLabel ? params.normalizeLabel(label) : label;
if (normalized) {
return normalized;
}
}
}
return null;
}
+104 -28
View File
@@ -1,17 +1,23 @@
// Dashboard title tests cover eligibility, normalization, and guarded persistence.
// Dashboard title tests cover eligibility, routing, normalization, and guarded persistence.
import { beforeEach, describe, expect, it, vi } from "vitest";
const generateConversationLabel = vi.hoisted(() => vi.fn());
const generateConversationLabelWithFallback = vi.hoisted(() => vi.fn());
const resolveUtilityModelRefForAgent = vi.hoisted(() => vi.fn());
const updateSessionEntry = vi.hoisted(() => vi.fn());
vi.mock("../agents/utility-model.js", () => ({ resolveUtilityModelRefForAgent }));
vi.mock("../auto-reply/reply/conversation-label-generator.js", () => ({
generateConversationLabel,
generateConversationLabelWithFallback,
}));
vi.mock("../config/sessions/session-accessor.js", () => ({ updateSessionEntry }));
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { maybeGenerateDashboardSessionTitle } from "./dashboard-session-title.js";
const cfg = {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
} as OpenClawConfig;
const baseEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
@@ -19,7 +25,7 @@ const baseEntry: SessionEntry = {
function titleParams(entry: SessionEntry | undefined = baseEntry) {
return {
cfg: {},
cfg,
agentId: "main",
entry,
sessionId: "session-1",
@@ -38,21 +44,32 @@ function mockSessionUpdate(current: SessionEntry): void {
describe("maybeGenerateDashboardSessionTitle", () => {
beforeEach(() => {
generateConversationLabel.mockReset();
generateConversationLabelWithFallback.mockReset();
resolveUtilityModelRefForAgent.mockReset();
updateSessionEntry.mockReset();
generateConversationLabel.mockResolvedValue("Release Planning");
generateConversationLabelWithFallback.mockResolvedValue("Release Planning");
resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna");
mockSessionUpdate(baseEntry);
});
it("generates and persists a dashboard display name", async () => {
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
expect(generateConversationLabel).toHaveBeenCalledWith({
expect(resolveUtilityModelRefForAgent).toHaveBeenCalledWith({
cfg,
agentId: "main",
primaryProvider: "openai",
primaryModelRef: "openai/gpt-5.5",
});
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith({
userMessage: "Help me plan the release",
prompt:
"Generate a concise session title (3-6 words, max 60 characters) from the user's first message. Use the same language as the message. No emoji. Return only the title.",
cfg: {},
cfg,
agentId: "main",
utilityModelRef: "openai/gpt-5.6-luna",
regularModelRef: "openai/gpt-5.5",
normalizeLabel: expect.any(Function),
maxLength: 60,
});
expect(updateSessionEntry).toHaveBeenCalledWith(
@@ -65,22 +82,77 @@ describe("maybeGenerateDashboardSessionTitle", () => {
{ requireWriteSuccess: true },
);
const update = updateSessionEntry.mock.calls[0]?.[1];
expect(await update?.({ ...baseEntry })).toEqual({
displayName: "Release Planning",
});
expect(await update?.({ ...baseEntry })).toEqual({ displayName: "Release Planning" });
});
it("does not treat dashboard sender identity as an explicit session name", async () => {
const senderEntry: SessionEntry = {
it("routes both attempts through the effective session model and auth profile", async () => {
const entry = {
...baseEntry,
origin: { label: "Peter", provider: "webchat", chatType: "direct" },
providerOverride: "anthropic",
modelOverride: "claude-fable-5",
authProfileOverride: "work",
};
mockSessionUpdate(senderEntry);
resolveUtilityModelRefForAgent.mockReturnValue("anthropic/claude-haiku-4-5@work");
mockSessionUpdate(entry);
await expect(maybeGenerateDashboardSessionTitle(titleParams(senderEntry))).resolves.toBe(true);
await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true);
const update = updateSessionEntry.mock.calls[0]?.[1];
expect(await update?.({ ...senderEntry })).toEqual({ displayName: "Release Planning" });
expect(resolveUtilityModelRefForAgent).toHaveBeenCalledWith({
cfg,
agentId: "main",
primaryProvider: "anthropic",
primaryModelRef: "anthropic/claude-fable-5@work",
});
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith(
expect.objectContaining({
utilityModelRef: "anthropic/claude-haiku-4-5@work",
regularModelRef: "anthropic/claude-fable-5@work",
preferredProfile: "work",
}),
);
});
it("preserves the configured primary auth profile for explicit utility models", async () => {
const profiledCfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5@personal" },
utilityModel: "openai/gpt-5.6-luna",
},
},
} as OpenClawConfig;
resolveUtilityModelRefForAgent.mockReturnValue("openai/gpt-5.6-luna");
await expect(
maybeGenerateDashboardSessionTitle({ ...titleParams(), cfg: profiledCfg }),
).resolves.toBe(true);
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith(
expect.objectContaining({
utilityModelRef: "openai/gpt-5.6-luna",
regularModelRef: "openai/gpt-5.5@personal",
preferredProfile: "personal",
}),
);
});
it("goes directly to the regular model when utility routing is disabled", async () => {
resolveUtilityModelRefForAgent.mockReturnValue(undefined);
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
expect(generateConversationLabelWithFallback).toHaveBeenCalledWith(
expect.not.objectContaining({ utilityModelRef: expect.anything() }),
);
});
it("treats creator attribution as metadata rather than an explicit title", async () => {
const entry = { ...baseEntry, origin: { label: "Peter" } };
mockSessionUpdate(entry);
await expect(maybeGenerateDashboardSessionTitle(titleParams(entry))).resolves.toBe(true);
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
});
it("keeps utility title prompt input on a UTF-16 boundary", async () => {
@@ -91,14 +163,16 @@ describe("maybeGenerateDashboardSessionTitle", () => {
}),
).resolves.toBe(true);
expect(generateConversationLabel.mock.calls[0]?.[0]?.userMessage).toBe("m".repeat(999));
expect(generateConversationLabelWithFallback.mock.calls[0]?.[0]?.userMessage).toBe(
"m".repeat(999),
);
});
it.each([
['```text\n"Release Planning"\n```', "Release Planning"],
["Title: Release planning ", "Release planning"],
])("normalizes generated title wrappers", async (generated, expected) => {
generateConversationLabel.mockResolvedValue(generated);
generateConversationLabelWithFallback.mockResolvedValue(generated);
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
@@ -107,7 +181,7 @@ describe("maybeGenerateDashboardSessionTitle", () => {
});
it("keeps persisted titles on a UTF-16 boundary", async () => {
generateConversationLabel.mockResolvedValue(`${"a".repeat(59)}🚀tail`);
generateConversationLabelWithFallback.mockResolvedValue(`${"a".repeat(59)}🚀tail`);
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(true);
@@ -119,15 +193,17 @@ describe("maybeGenerateDashboardSessionTitle", () => {
["non-dashboard session", { sessionKey: "agent:main:main" }],
["slash command", { userMessage: "/status" }],
["manual label", { entry: { ...baseEntry, label: "My release" } }],
["manual display name", { entry: { ...baseEntry, displayName: "My release" } }],
["channel subject", { entry: { ...baseEntry, subject: "Release room" } }],
["persisted display name", { entry: { ...baseEntry, displayName: "My release" } }],
["group subject", { entry: { ...baseEntry, subject: "Release team" } }],
["channel name", { entry: { ...baseEntry, groupChannel: "releases" } }],
["space name", { entry: { ...baseEntry, space: "Engineering" } }],
["existing session history", { entry: { ...baseEntry, systemSent: true } }],
])("skips %s", async (_name, override) => {
await expect(
maybeGenerateDashboardSessionTitle({ ...titleParams(), ...override }),
).resolves.toBe(false);
expect(generateConversationLabel).not.toHaveBeenCalled();
expect(generateConversationLabelWithFallback).not.toHaveBeenCalled();
expect(updateSessionEntry).not.toHaveBeenCalled();
});
@@ -136,7 +212,7 @@ describe("maybeGenerateDashboardSessionTitle", () => {
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(false);
expect(generateConversationLabel).toHaveBeenCalledOnce();
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
});
it("does not write into a reset session generation", async () => {
@@ -144,12 +220,12 @@ describe("maybeGenerateDashboardSessionTitle", () => {
await expect(maybeGenerateDashboardSessionTitle(titleParams())).resolves.toBe(false);
expect(generateConversationLabel).toHaveBeenCalledOnce();
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
});
it("deduplicates concurrent title requests for one session generation", async () => {
let resolveLabel!: (value: string) => void;
generateConversationLabel.mockReturnValue(
generateConversationLabelWithFallback.mockReturnValue(
new Promise<string>((resolve) => {
resolveLabel = resolve;
}),
@@ -160,6 +236,6 @@ describe("maybeGenerateDashboardSessionTitle", () => {
resolveLabel("Release Planning");
await expect(first).resolves.toBe(true);
expect(generateConversationLabel).toHaveBeenCalledOnce();
expect(generateConversationLabelWithFallback).toHaveBeenCalledOnce();
});
});
+54 -3
View File
@@ -1,6 +1,10 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
// Dashboard session titles use the shared utility-model completion path.
import { generateConversationLabel } from "../auto-reply/reply/conversation-label-generator.js";
import { resolveAgentEffectiveModelPrimary } from "../agents/agent-scope.js";
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
import { resolveSessionModelRef } from "../agents/session-model-ref.js";
import { resolveUtilityModelRefForAgent } from "../agents/utility-model.js";
import { generateConversationLabelWithFallback } from "../auto-reply/reply/conversation-label-generator.js";
import { updateSessionEntry } from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -16,7 +20,13 @@ const DASHBOARD_SESSION_TITLE_PROMPT =
const dashboardTitleRequests = new Set<string>();
function hasExplicitSessionName(entry: SessionEntry | undefined): boolean {
return Boolean(entry?.label?.trim() || entry?.displayName?.trim() || entry?.subject?.trim());
return Boolean(
entry?.label?.trim() ||
entry?.displayName?.trim() ||
entry?.subject?.trim() ||
entry?.groupChannel?.trim() ||
entry?.space?.trim(),
);
}
function isDashboardSessionKey(sessionKey: string): boolean {
@@ -33,6 +43,27 @@ export function isDashboardSessionTitleCandidate(params: {
);
}
function resolveDashboardTitleAuthProfile(params: {
cfg: OpenClawConfig;
agentId: string;
entry: SessionEntry | undefined;
regularProvider: string;
}): string | undefined {
const sessionProfile = params.entry?.authProfileOverride?.trim();
if (sessionProfile) {
return sessionProfile;
}
const configuredRef = resolveAgentEffectiveModelPrimary(params.cfg, params.agentId)?.trim();
const configuredProfile = configuredRef
? splitTrailingAuthProfile(configuredRef).profile
: undefined;
if (!configuredProfile) {
return undefined;
}
const configuredModel = resolveSessionModelRef(params.cfg, undefined, params.agentId);
return configuredModel.provider === params.regularProvider ? configuredProfile : undefined;
}
function normalizeDashboardSessionTitle(raw: string): string | null {
const firstLine = raw
.replace(/\r/g, "")
@@ -75,11 +106,31 @@ export async function maybeGenerateDashboardSessionTitle(params: {
}
dashboardTitleRequests.add(requestKey);
try {
const generated = await generateConversationLabel({
const regularModel = resolveSessionModelRef(params.cfg, params.entry, params.agentId);
const preferredProfile = resolveDashboardTitleAuthProfile({
cfg: params.cfg,
agentId: params.agentId,
entry: params.entry,
regularProvider: regularModel.provider,
});
const regularModelRef = `${regularModel.provider}/${regularModel.model}${
preferredProfile ? `@${preferredProfile}` : ""
}`;
const utilityModelRef = resolveUtilityModelRefForAgent({
cfg: params.cfg,
agentId: params.agentId,
primaryProvider: regularModel.provider,
primaryModelRef: regularModelRef,
});
const generated = await generateConversationLabelWithFallback({
userMessage: truncateUtf16Safe(sourceText, DASHBOARD_SESSION_TITLE_SOURCE_MAX_CHARS),
prompt: DASHBOARD_SESSION_TITLE_PROMPT,
cfg: params.cfg,
agentId: params.agentId,
...(utilityModelRef ? { utilityModelRef } : {}),
regularModelRef,
...(preferredProfile ? { preferredProfile } : {}),
normalizeLabel: normalizeDashboardSessionTitle,
maxLength: DASHBOARD_SESSION_TITLE_MAX_CHARS,
});
const displayName = generated ? normalizeDashboardSessionTitle(generated) : null;