mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 02:11:23 +00:00
Channels: add thread-aware model overrides
This commit is contained in:
@@ -136,6 +136,7 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma
|
||||
command: params.command,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionKey: params.sessionKey,
|
||||
parentSessionKey: params.ctx.ParentSessionKey,
|
||||
sessionScope: params.sessionScope,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function buildStatusReply(params: {
|
||||
command: CommandContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey: string;
|
||||
parentSessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
storePath?: string;
|
||||
provider: string;
|
||||
@@ -51,6 +52,7 @@ export async function buildStatusReply(params: {
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey,
|
||||
sessionScope,
|
||||
storePath,
|
||||
provider,
|
||||
@@ -173,6 +175,7 @@ export async function buildStatusReply(params: {
|
||||
agentId: statusAgentId,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey,
|
||||
sessionScope,
|
||||
sessionStorePath: storePath,
|
||||
groupActivation,
|
||||
|
||||
@@ -168,6 +168,7 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey: ctx.ParentSessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -277,6 +277,7 @@ export async function handleInlineActions(params: {
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
parentSessionKey: ctx.ParentSessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { resolveModelRefFromString } from "../../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js";
|
||||
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
|
||||
import { type OpenClawConfig, loadConfig } from "../../config/config.js";
|
||||
import { applyLinkUnderstanding } from "../../link-understanding/apply.js";
|
||||
import { applyMediaUnderstanding } from "../../media-understanding/apply.js";
|
||||
@@ -179,6 +180,36 @@ export async function getReplyFromConfig(
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
const channelModelOverride = resolveChannelModelOverride({
|
||||
cfg,
|
||||
channel:
|
||||
groupResolution?.channel ??
|
||||
sessionEntry.channel ??
|
||||
sessionEntry.origin?.provider ??
|
||||
(typeof finalized.OriginatingChannel === "string"
|
||||
? finalized.OriginatingChannel
|
||||
: undefined) ??
|
||||
finalized.Provider,
|
||||
groupId: groupResolution?.id ?? sessionEntry.groupId,
|
||||
groupChannel: sessionEntry.groupChannel ?? sessionCtx.GroupChannel ?? finalized.GroupChannel,
|
||||
groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject,
|
||||
parentSessionKey: sessionCtx.ParentSessionKey,
|
||||
});
|
||||
const hasSessionModelOverride = Boolean(
|
||||
sessionEntry.modelOverride?.trim() || sessionEntry.providerOverride?.trim(),
|
||||
);
|
||||
if (!hasResolvedHeartbeatModelOverride && !hasSessionModelOverride && channelModelOverride) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: channelModelOverride.model,
|
||||
defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (resolved) {
|
||||
provider = resolved.ref.provider;
|
||||
model = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
|
||||
const directiveResult = await resolveReplyDirectives({
|
||||
ctx: finalized,
|
||||
cfg,
|
||||
|
||||
@@ -71,36 +71,6 @@ describe("buildInboundMetaSystemPrompt", () => {
|
||||
const payload = parseInboundMetaPayload(prompt);
|
||||
expect(payload["sender_id"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes discord channel topics only for new sessions", () => {
|
||||
const prompt = buildInboundMetaSystemPrompt({
|
||||
OriginatingTo: "discord:channel:123",
|
||||
OriginatingChannel: "discord",
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: "group",
|
||||
ChannelTopic: " Shipping updates ",
|
||||
IsNewSession: "true",
|
||||
} as TemplateContext);
|
||||
|
||||
const payload = parseInboundMetaPayload(prompt);
|
||||
expect(payload["channel_topic"]).toBe("Shipping updates");
|
||||
});
|
||||
|
||||
it("omits discord channel topics for existing sessions", () => {
|
||||
const prompt = buildInboundMetaSystemPrompt({
|
||||
OriginatingTo: "discord:channel:123",
|
||||
OriginatingChannel: "discord",
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: "group",
|
||||
ChannelTopic: "Shipping updates",
|
||||
IsNewSession: "false",
|
||||
} as TemplateContext);
|
||||
|
||||
const payload = parseInboundMetaPayload(prompt);
|
||||
expect(payload["channel_topic"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInboundUserContextPrefix", () => {
|
||||
|
||||
@@ -13,15 +13,8 @@ function safeTrim(value: unknown): string | undefined {
|
||||
export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
|
||||
const chatType = normalizeChatType(ctx.ChatType);
|
||||
const isDirect = !chatType || chatType === "direct";
|
||||
const isNewSession = ctx.IsNewSession === "true";
|
||||
const originatingChannel = safeTrim(ctx.OriginatingChannel);
|
||||
const surface = safeTrim(ctx.Surface);
|
||||
const provider = safeTrim(ctx.Provider);
|
||||
const isDiscord =
|
||||
provider === "discord" || surface === "discord" || originatingChannel === "discord";
|
||||
|
||||
// Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.)
|
||||
// unless explicitly opted into for new-session context (e.g. Discord channel topics).
|
||||
// Keep system metadata strictly free of attacker-controlled strings (sender names, group subjects, etc.).
|
||||
// Those belong in the user-role "untrusted context" blocks.
|
||||
// Per-message identifiers (message_id, reply_to_id, sender_id) are also excluded here: they change
|
||||
// on every turn and would bust prefix-based prompt caches on local model providers. They are
|
||||
@@ -30,27 +23,25 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
|
||||
// Resolve channel identity: prefer explicit channel, then surface, then provider.
|
||||
// For webchat/Hub Chat sessions (when Surface is 'webchat' or undefined with no real channel),
|
||||
// omit the channel field entirely rather than falling back to an unrelated provider.
|
||||
let channelValue = originatingChannel ?? surface;
|
||||
let channelValue = safeTrim(ctx.OriginatingChannel) ?? safeTrim(ctx.Surface);
|
||||
if (!channelValue) {
|
||||
// Only fall back to Provider if it represents a real messaging channel.
|
||||
// For webchat/internal sessions, ctx.Provider may be unrelated (e.g., the user's configured
|
||||
// default channel), so skip it to avoid incorrect runtime labels like "channel=whatsapp".
|
||||
const provider = safeTrim(ctx.Provider);
|
||||
// Check if provider is "webchat" or if we're in an internal/webchat context
|
||||
if (provider !== "webchat" && surface !== "webchat") {
|
||||
if (provider !== "webchat" && ctx.Surface !== "webchat") {
|
||||
channelValue = provider;
|
||||
}
|
||||
// Otherwise leave channelValue undefined (no channel label)
|
||||
}
|
||||
|
||||
const channelTopic = isNewSession && isDiscord ? safeTrim(ctx.ChannelTopic) : undefined;
|
||||
|
||||
const payload = {
|
||||
schema: "openclaw.inbound_meta.v1",
|
||||
chat_id: safeTrim(ctx.OriginatingTo),
|
||||
channel: channelValue,
|
||||
channel_topic: channelTopic,
|
||||
provider,
|
||||
surface,
|
||||
provider: safeTrim(ctx.Provider),
|
||||
surface: safeTrim(ctx.Surface),
|
||||
chat_type: chatType ?? (isDirect ? "direct" : undefined),
|
||||
flags: {
|
||||
is_group_chat: !isDirect ? true : undefined,
|
||||
|
||||
@@ -90,6 +90,36 @@ describe("buildStatusMessage", () => {
|
||||
expect(normalized).toContain("Queue: collect");
|
||||
});
|
||||
|
||||
it("notes channel model overrides in status output", () => {
|
||||
const text = buildStatusMessage({
|
||||
config: {
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
discord: {
|
||||
"123": "openai/gpt-4.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
agent: {
|
||||
model: "openai/gpt-4.1",
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId: "abc",
|
||||
updatedAt: 0,
|
||||
channel: "discord",
|
||||
groupId: "123",
|
||||
},
|
||||
sessionKey: "agent:main:discord:channel:123",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
});
|
||||
const normalized = normalizeTestText(text);
|
||||
|
||||
expect(normalized).toContain("Model: openai/gpt-4.1");
|
||||
expect(normalized).toContain("channel override");
|
||||
});
|
||||
|
||||
it("uses per-agent sandbox config when config and session key are provided", () => {
|
||||
const text = buildStatusMessage({
|
||||
config: {
|
||||
|
||||
@@ -2,10 +2,15 @@ import fs from "node:fs";
|
||||
import { lookupContextTokens } from "../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveModelAuthMode } from "../agents/model-auth.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js";
|
||||
import { resolveChannelModelOverride } from "../channels/model-overrides.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveMainSessionKey,
|
||||
@@ -66,6 +71,7 @@ type StatusArgs = {
|
||||
agentId?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
sessionStorePath?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
@@ -531,7 +537,46 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
state: entry,
|
||||
});
|
||||
const selectedAuthLabel = selectedAuthLabelValue ? ` · 🔑 ${selectedAuthLabelValue}` : "";
|
||||
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}`;
|
||||
const channelModelNote = (() => {
|
||||
if (!args.config || !entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.modelOverride?.trim() || entry.providerOverride?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const channelOverride = resolveChannelModelOverride({
|
||||
cfg: args.config,
|
||||
channel: entry.channel ?? entry.origin?.provider,
|
||||
groupId: entry.groupId,
|
||||
groupChannel: entry.groupChannel,
|
||||
groupSubject: entry.subject,
|
||||
parentSessionKey: args.parentSessionKey,
|
||||
});
|
||||
if (!channelOverride) {
|
||||
return undefined;
|
||||
}
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: args.config,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
const resolvedOverride = resolveModelRefFromString({
|
||||
raw: channelOverride.model,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolvedOverride) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
resolvedOverride.ref.provider !== selectedProvider ||
|
||||
resolvedOverride.ref.model !== selectedModel
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return "channel override";
|
||||
})();
|
||||
const modelNote = channelModelNote ? ` · ${channelModelNote}` : "";
|
||||
const modelLine = `🧠 Model: ${selectedModelLabel}${selectedAuthLabel}${modelNote}`;
|
||||
const showFallbackAuth = activeAuthLabelValue && activeAuthLabelValue !== selectedAuthLabelValue;
|
||||
const fallbackLine = fallbackState.active
|
||||
? `↪️ Fallback: ${activeModelLabel}${
|
||||
|
||||
@@ -98,8 +98,6 @@ export type MsgContext = {
|
||||
GroupSubject?: string;
|
||||
/** Human label for channel-like group conversations (e.g. #general, #support). */
|
||||
GroupChannel?: string;
|
||||
/** Channel topic/description (trusted metadata for new session context). */
|
||||
ChannelTopic?: string;
|
||||
GroupSpace?: string;
|
||||
GroupMembers?: string;
|
||||
GroupSystemPrompt?: string;
|
||||
|
||||
Reference in New Issue
Block a user