Channels: add thread-aware model overrides

This commit is contained in:
Shadow
2026-02-20 19:26:25 -06:00
committed by GitHub
parent ee8dd40509
commit f555835b09
53 changed files with 1379 additions and 1398 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -168,6 +168,7 @@ export async function applyInlineDirectiveOverrides(params: {
command,
sessionEntry,
sessionKey,
parentSessionKey: ctx.ParentSessionKey,
sessionScope,
provider,
model,

View File

@@ -277,6 +277,7 @@ export async function handleInlineActions(params: {
command,
sessionEntry,
sessionKey,
parentSessionKey: ctx.ParentSessionKey,
sessionScope,
provider,
model,

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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,