Skip to content

Commit 200653b

Browse files
authored
fix(gateway): generate dashboard titles despite sender metadata (#111613)
* fix(gateway): keep sender identity out of session titles * docs(changelog): note dashboard session title fix * test(gateway): type dashboard sender fixture * docs: keep session title release note in PR
1 parent 0af934c commit 200653b

9 files changed

Lines changed: 265 additions & 43 deletions

docs/concepts/models.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Related model-config surfaces:
5757

5858
- `agents.defaults.models` stores aliases and per-model settings. Adding an entry does not restrict model overrides.
5959
- `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.
60-
- `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. Utility tasks are separate model calls and may send bounded task content to the selected model provider.
60+
- `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.
6161
- `agents.defaults.imageModel` is used only when the primary model cannot accept images.
6262
- `agents.defaults.pdfModel` is used by the `pdf` tool. If unset, the tool falls back to `imageModel`, then the resolved session/default model.
6363
- `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.

docs/gateway/config-agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ Time format in system prompt. Default: `auto` (OS preference).
401401
- `model`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
402402
- String form sets only the primary model.
403403
- Object form sets primary plus ordered failover models.
404-
- `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 fall back to the agent's primary model, and narration stays off. 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.
404+
- `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.
405405
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
406406
- 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.
407407
- Also used as fallback routing when the selected/default model cannot accept image input.

docs/web/control-ui.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ The macOS app keeps its native link-browser sidebar for links clicked in the das
391391
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
392392
- Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat).
393393
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
394-
- The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names are never replaced. Set `agents.defaults.utilityModel` (or `agents.list[].utilityModel`) to route this separate model call to a lower-cost model. Expanding another agent section browses that agent's sessions without leaving the open chat.
394+
- The sidebar lists every loaded active session by agent section and pinned/channel/work/custom/Chats buckets with a single New Session action that opens the draft dialog. Opening a visible row moves only the highlight. Sessions can be dropped onto Pinned to pin them, or onto a custom group or Chats to move them; custom groups are collapsible and drag-reorderable, group names and order sync through the gateway, and collapsed state stays in the browser. A new dashboard session asynchronously gets a concise generated title from its first non-command message; explicit names and authenticated sender identity remain separate, so account names are never used as generated titles. Set `agents.defaults.utilityModel` (or `agents.list[].utilityModel`) to route this separate model call to a lower-cost model; if that distinct model fails, title generation retries once with the primary model. Expanding another agent section browses that agent's sessions without leaving the open chat.
395395
- Thread search lives in the command palette (⌘K, or the Search field at the top of the sidebar): typing a query follows a bounded number of matching pages across agents, filters internal child/cron rows, and lists visible matches next to navigation commands. The Threads page keeps the exhaustive searchable list with filters.
396396
- Each sidebar row keeps direct pin access plus a full context menu for unread state, rename, fork, grouping, archive, and delete. Multi-selected rows (Cmd/Ctrl-click, Shift-click for ranges) get a batch menu covering unread state, grouping, archive, and delete; batch archive/delete stays disabled unless every selected session is archivable. An active run and an agent's main session cannot be archived. Archiving or deleting the currently selected session switches Chat back to that agent's main session.
397397
- In the macOS app, the OpenClaw mark uses the otherwise-empty native titlebar strip next to the window controls instead of consuming a sidebar row.

src/auto-reply/reply/conversation-label-generator.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,99 @@ describe("generateConversationLabel", () => {
164164
expect(completeWithPreparedSimpleCompletionModel).not.toHaveBeenCalled();
165165
});
166166

167+
it("falls back to the primary model when utility model preparation fails", async () => {
168+
prepareSimpleCompletionModelForAgent
169+
.mockResolvedValueOnce({
170+
error: 'No API key resolved for provider "openai".',
171+
selection: {
172+
provider: "openai",
173+
modelId: "gpt-5.6-luna",
174+
agentDir: "/tmp/openclaw-agent",
175+
},
176+
})
177+
.mockResolvedValueOnce({
178+
selection: {
179+
provider: "openai",
180+
modelId: "gpt-5.6-sol",
181+
agentDir: "/tmp/openclaw-agent",
182+
},
183+
model: { provider: "openai", id: "gpt-5.6-sol", maxTokens: 8192 },
184+
auth: { apiKey: "test-api-key", mode: "api-key" },
185+
});
186+
187+
await expect(
188+
generateConversationLabel({
189+
userMessage: "Need help with invoices",
190+
prompt: "Generate a label",
191+
cfg: {},
192+
}),
193+
).resolves.toBe("Topic label");
194+
195+
expect(prepareSimpleCompletionModelForAgent).toHaveBeenNthCalledWith(
196+
2,
197+
expect.objectContaining({ useUtilityModel: false }),
198+
);
199+
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce();
200+
});
201+
202+
it("falls back to the primary model when the utility completion fails", async () => {
203+
prepareSimpleCompletionModelForAgent
204+
.mockResolvedValueOnce({
205+
selection: {
206+
provider: "openai",
207+
modelId: "gpt-5.6-luna",
208+
agentDir: "/tmp/openclaw-agent",
209+
},
210+
model: { provider: "openai", id: "gpt-5.6-luna", maxTokens: 8192 },
211+
auth: { apiKey: "test-api-key", mode: "oauth" },
212+
})
213+
.mockResolvedValueOnce({
214+
selection: {
215+
provider: "openai",
216+
modelId: "gpt-5.6-sol",
217+
agentDir: "/tmp/openclaw-agent",
218+
},
219+
model: { provider: "openai", id: "gpt-5.6-sol", maxTokens: 8192 },
220+
auth: { apiKey: "test-api-key", mode: "oauth" },
221+
});
222+
completeWithPreparedSimpleCompletionModel
223+
.mockResolvedValueOnce({
224+
content: [],
225+
stopReason: "error",
226+
errorMessage: "utility unavailable",
227+
})
228+
.mockResolvedValueOnce({ content: [{ type: "text", text: "Primary title" }] });
229+
230+
await expect(
231+
generateConversationLabel({
232+
userMessage: "Need help with invoices",
233+
prompt: "Generate a label",
234+
cfg: {},
235+
}),
236+
).resolves.toBe("Primary title");
237+
238+
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(2);
239+
});
240+
241+
it("does not call the same primary model twice when utility routing resolves to it", async () => {
242+
completeWithPreparedSimpleCompletionModel.mockResolvedValue({
243+
content: [],
244+
stopReason: "error",
245+
errorMessage: "primary unavailable",
246+
});
247+
248+
await expect(
249+
generateConversationLabel({
250+
userMessage: "Need help with invoices",
251+
prompt: "Generate a label",
252+
cfg: {},
253+
}),
254+
).resolves.toBeNull();
255+
256+
expect(prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(2);
257+
expect(completeWithPreparedSimpleCompletionModel).toHaveBeenCalledOnce();
258+
});
259+
167260
it("logs completion errors instead of treating them as empty labels", async () => {
168261
completeWithPreparedSimpleCompletionModel.mockResolvedValue({
169262
content: [],

src/auto-reply/reply/conversation-label-generator.ts

Lines changed: 122 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const DEFAULT_MAX_LABEL_LENGTH = 128;
1616
const CONVERSATION_LABEL_MAX_TOKENS = 4_096;
1717
const TIMEOUT_MS = 15_000;
1818

19+
type PreparedLabelModel = Awaited<ReturnType<typeof prepareSimpleCompletionModelForAgent>>;
20+
type ReadyLabelModel = Extract<PreparedLabelModel, { model: unknown }>;
21+
type LabelModelPhase = "utility" | "primary fallback";
22+
1923
/** Inputs for generating a short conversation label from the configured utility model. */
2024
export type ConversationLabelParams = {
2125
userMessage: string;
@@ -44,64 +48,92 @@ function extractSimpleCompletionError(result: {
4448
return result.errorMessage?.trim() || "unknown error";
4549
}
4650

47-
/** Generates a bounded human-readable label for a session, or null on failure. */
48-
export async function generateConversationLabel(
49-
params: ConversationLabelParams,
50-
): Promise<string | null> {
51-
const { userMessage, prompt, cfg, agentId, agentDir } = params;
52-
const maxLength =
53-
typeof params.maxLength === "number" &&
54-
Number.isFinite(params.maxLength) &&
55-
params.maxLength > 0
56-
? Math.floor(params.maxLength)
57-
: DEFAULT_MAX_LABEL_LENGTH;
58-
let prepared: Awaited<ReturnType<typeof prepareSimpleCompletionModelForAgent>>;
51+
function logLabelFailure(phase: LabelModelPhase, message: string): void {
52+
const prefix = phase === "utility" ? "" : `${phase} `;
53+
logVerbose(`conversation-label-generator: ${prefix}${message}`);
54+
}
55+
56+
async function prepareLabelModel(params: {
57+
cfg: OpenClawConfig;
58+
agentId: string;
59+
agentDir?: string;
60+
useUtilityModel: boolean;
61+
phase: LabelModelPhase;
62+
}): Promise<PreparedLabelModel | null> {
5963
try {
60-
prepared = await prepareSimpleCompletionModelForAgent({
61-
cfg,
62-
agentId: agentId ?? resolveDefaultAgentId(cfg),
63-
agentDir,
64-
useUtilityModel: true,
64+
const prepared = await prepareSimpleCompletionModelForAgent({
65+
cfg: params.cfg,
66+
agentId: params.agentId,
67+
agentDir: params.agentDir,
68+
useUtilityModel: params.useUtilityModel,
6569
useAsyncModelResolution: true,
6670
allowMissingApiKeyModes: ["aws-sdk"],
6771
});
72+
if ("error" in prepared) {
73+
logLabelFailure(params.phase, prepared.error);
74+
}
75+
return prepared;
6876
} catch (err) {
69-
logVerbose(`conversation-label-generator: model preparation failed: ${String(err)}`);
70-
return null;
71-
}
72-
if ("error" in prepared) {
73-
logVerbose(`conversation-label-generator: ${prepared.error}`);
77+
logLabelFailure(params.phase, `model preparation failed: ${String(err)}`);
7478
return null;
7579
}
80+
}
7681

82+
function selectedLabelModelsMatch(
83+
first: PreparedLabelModel | null,
84+
second: PreparedLabelModel | null,
85+
): boolean {
86+
const firstSelection = first && "selection" in first ? first.selection : undefined;
87+
const secondSelection = second && "selection" in second ? second.selection : undefined;
88+
return Boolean(
89+
firstSelection &&
90+
secondSelection &&
91+
firstSelection.provider === secondSelection.provider &&
92+
firstSelection.runtimeProvider === secondSelection.runtimeProvider &&
93+
firstSelection.modelId === secondSelection.modelId &&
94+
firstSelection.profileId === secondSelection.profileId,
95+
);
96+
}
97+
98+
async function completeLabel(params: {
99+
prepared: ReadyLabelModel;
100+
cfg: OpenClawConfig;
101+
userMessage: string;
102+
prompt: string;
103+
maxLength: number;
104+
phase: LabelModelPhase;
105+
}): Promise<string | null> {
77106
const controller = new AbortController();
78107
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
79108
try {
80-
const maxTokens = Math.min(CONVERSATION_LABEL_MAX_TOKENS, Math.floor(prepared.model.maxTokens));
109+
const maxTokens = Math.min(
110+
CONVERSATION_LABEL_MAX_TOKENS,
111+
Math.floor(params.prepared.model.maxTokens),
112+
);
81113
// Label generation should never block normal reply handling for long.
82114
const result = await completeWithPreparedSimpleCompletionModel({
83-
model: prepared.model,
84-
auth: prepared.auth,
85-
cfg,
115+
model: params.prepared.model,
116+
auth: params.prepared.auth,
117+
cfg: params.cfg,
86118
context: {
87-
systemPrompt: prompt,
119+
systemPrompt: params.prompt,
88120
messages: [
89121
{
90122
role: "user",
91-
content: userMessage,
123+
content: params.userMessage,
92124
timestamp: Date.now(),
93125
},
94126
],
95127
},
96128
options: {
97129
maxTokens,
98-
...(isCodexSimpleCompletionModel(prepared.model) ? {} : { temperature: 0.3 }),
130+
...(isCodexSimpleCompletionModel(params.prepared.model) ? {} : { temperature: 0.3 }),
99131
signal: controller.signal,
100132
},
101133
});
102134
const errorMessage = extractSimpleCompletionError(result);
103135
if (errorMessage) {
104-
logVerbose(`conversation-label-generator: completion failed: ${errorMessage}`);
136+
logLabelFailure(params.phase, `completion failed: ${errorMessage}`);
105137
return null;
106138
}
107139

@@ -115,11 +147,69 @@ export async function generateConversationLabel(
115147
return null;
116148
}
117149

118-
return truncateUtf16Safe(text, maxLength) || null;
150+
return truncateUtf16Safe(text, params.maxLength) || null;
119151
} catch (err) {
120-
logVerbose(`conversation-label-generator: completion failed: ${String(err)}`);
152+
logLabelFailure(params.phase, `completion failed: ${String(err)}`);
121153
return null;
122154
} finally {
123155
clearTimeout(timeout);
124156
}
125157
}
158+
159+
/** Generates a bounded human-readable label for a session, or null on failure. */
160+
export async function generateConversationLabel(
161+
params: ConversationLabelParams,
162+
): Promise<string | null> {
163+
const { userMessage, prompt, cfg, agentId, agentDir } = params;
164+
const maxLength =
165+
typeof params.maxLength === "number" &&
166+
Number.isFinite(params.maxLength) &&
167+
params.maxLength > 0
168+
? Math.floor(params.maxLength)
169+
: DEFAULT_MAX_LABEL_LENGTH;
170+
const resolvedAgentId = agentId ?? resolveDefaultAgentId(cfg);
171+
const utilityPrepared = await prepareLabelModel({
172+
cfg,
173+
agentId: resolvedAgentId,
174+
agentDir,
175+
useUtilityModel: true,
176+
phase: "utility",
177+
});
178+
const utilityCompletionAttempted = Boolean(utilityPrepared && !("error" in utilityPrepared));
179+
if (utilityPrepared && !("error" in utilityPrepared)) {
180+
const label = await completeLabel({
181+
prepared: utilityPrepared,
182+
cfg,
183+
userMessage,
184+
prompt,
185+
maxLength,
186+
phase: "utility",
187+
});
188+
if (label) {
189+
return label;
190+
}
191+
}
192+
193+
const primaryPrepared = await prepareLabelModel({
194+
cfg,
195+
agentId: resolvedAgentId,
196+
agentDir,
197+
useUtilityModel: false,
198+
phase: "primary fallback",
199+
});
200+
if (
201+
!primaryPrepared ||
202+
"error" in primaryPrepared ||
203+
(utilityCompletionAttempted && selectedLabelModelsMatch(utilityPrepared, primaryPrepared))
204+
) {
205+
return null;
206+
}
207+
return await completeLabel({
208+
prepared: primaryPrepared,
209+
cfg,
210+
userMessage,
211+
prompt,
212+
maxLength,
213+
phase: "primary fallback",
214+
});
215+
}

0 commit comments

Comments
 (0)