feat: add dynamic template variables to messages.responsePrefix (#923)
Adds support for template variables in `messages.responsePrefix` that
resolve dynamically at runtime with the actual model used (including
after fallback).
Supported variables (case-insensitive):
- {model} - short model name (e.g., "claude-opus-4-5", "gpt-4o")
- {modelFull} - full model identifier (e.g., "anthropic/claude-opus-4-5")
- {provider} - provider name (e.g., "anthropic", "openai")
- {thinkingLevel} or {think} - thinking level ("high", "low", "off")
- {identity.name} or {identityName} - agent identity name
Example: "[{model} | think:{thinkingLevel}]" → "[claude-opus-4-5 | think:high]"
Variables show the actual model used after fallback, not the intended
model. Unresolved variables remain as literal text.
Implementation:
- New module: src/auto-reply/reply/response-prefix-template.ts
- Template interpolation in normalize-reply.ts via context provider
- onModelSelected callback in agent-runner-execution.ts
- Updated all 6 provider message handlers (web, signal, discord,
telegram, slack, imessage)
- 27 unit tests covering all variables and edge cases
- Documentation in docs/gateway/configuration.md and JSDoc
Fixes #923
This commit is contained in:
@@ -125,6 +125,14 @@ export async function runAgentTurnWithFallback(params: {
|
||||
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
|
||||
),
|
||||
run: (provider, model) => {
|
||||
// Notify that model selection is complete (including after fallback).
|
||||
// This allows responsePrefix template interpolation with the actual model.
|
||||
params.opts?.onModelSelected?.({
|
||||
provider,
|
||||
model,
|
||||
thinkLevel: params.followupRun.run.thinkLevel,
|
||||
});
|
||||
|
||||
if (isCliProvider(provider, params.followupRun.run.config)) {
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
resolveResponsePrefixTemplate,
|
||||
type ResponsePrefixContext,
|
||||
} from "./response-prefix-template.js";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
/** Context for template variable interpolation in responsePrefix */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
onHeartbeatStrip?: () => void;
|
||||
stripHeartbeat?: boolean;
|
||||
silentToken?: string;
|
||||
@@ -36,13 +42,18 @@ export function normalizeReplyPayload(
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
// Resolve template variables in responsePrefix if context is provided
|
||||
const effectivePrefix = opts.responsePrefixContext
|
||||
? resolveResponsePrefixTemplate(opts.responsePrefix, opts.responsePrefixContext)
|
||||
: opts.responsePrefix;
|
||||
|
||||
if (
|
||||
opts.responsePrefix &&
|
||||
effectivePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
!text.startsWith(effectivePrefix)
|
||||
) {
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
text = `${effectivePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { HumanDelayConfig } from "../../config/types.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import type { ResponsePrefixContext } from "./response-prefix-template.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
@@ -33,6 +34,11 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
export type ReplyDispatcherOptions = {
|
||||
deliver: ReplyDispatchDeliverer;
|
||||
responsePrefix?: string;
|
||||
/** Static context for response prefix template interpolation. */
|
||||
responsePrefixContext?: ResponsePrefixContext;
|
||||
/** Dynamic context provider for response prefix template interpolation.
|
||||
* Called at normalization time, after model selection is complete. */
|
||||
responsePrefixContextProvider?: () => ResponsePrefixContext;
|
||||
onHeartbeatStrip?: () => void;
|
||||
onIdle?: () => void;
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
@@ -61,10 +67,17 @@ export type ReplyDispatcher = {
|
||||
|
||||
function normalizeReplyPayloadInternal(
|
||||
payload: ReplyPayload,
|
||||
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
|
||||
opts: Pick<
|
||||
ReplyDispatcherOptions,
|
||||
"responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
|
||||
>,
|
||||
): ReplyPayload | null {
|
||||
// Prefer dynamic context provider over static context
|
||||
const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext;
|
||||
|
||||
return normalizeReplyPayload(payload, {
|
||||
responsePrefix: opts.responsePrefix,
|
||||
responsePrefixContext: prefixContext,
|
||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||
});
|
||||
}
|
||||
|
||||
181
src/auto-reply/reply/response-prefix-template.test.ts
Normal file
181
src/auto-reply/reply/response-prefix-template.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
extractShortModelName,
|
||||
hasTemplateVariables,
|
||||
resolveResponsePrefixTemplate,
|
||||
} from "./response-prefix-template.js";
|
||||
|
||||
describe("resolveResponsePrefixTemplate", () => {
|
||||
it("returns undefined for undefined template", () => {
|
||||
expect(resolveResponsePrefixTemplate(undefined, {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns template as-is when no variables present", () => {
|
||||
expect(resolveResponsePrefixTemplate("[Claude]", {})).toBe("[Claude]");
|
||||
});
|
||||
|
||||
it("resolves {model} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model}]", {
|
||||
model: "gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2]");
|
||||
});
|
||||
|
||||
it("resolves {modelFull} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{modelFull}]", {
|
||||
modelFull: "openai-codex/gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[openai-codex/gpt-5.2]");
|
||||
});
|
||||
|
||||
it("resolves {provider} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{provider}]", {
|
||||
provider: "anthropic",
|
||||
});
|
||||
expect(result).toBe("[anthropic]");
|
||||
});
|
||||
|
||||
it("resolves {thinkingLevel} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("think:{thinkingLevel}", {
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
expect(result).toBe("think:high");
|
||||
});
|
||||
|
||||
it("resolves {think} as alias for thinkingLevel", () => {
|
||||
const result = resolveResponsePrefixTemplate("think:{think}", {
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
expect(result).toBe("think:low");
|
||||
});
|
||||
|
||||
it("resolves {identity.name} variable", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{identity.name}]", {
|
||||
identityName: "Clawdbot",
|
||||
});
|
||||
expect(result).toBe("[Clawdbot]");
|
||||
});
|
||||
|
||||
it("resolves {identityName} as alias", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{identityName}]", {
|
||||
identityName: "Clawdbot",
|
||||
});
|
||||
expect(result).toBe("[Clawdbot]");
|
||||
});
|
||||
|
||||
it("resolves multiple variables", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", {
|
||||
model: "claude-opus-4-5",
|
||||
thinkingLevel: "high",
|
||||
});
|
||||
expect(result).toBe("[claude-opus-4-5 | think:high]");
|
||||
});
|
||||
|
||||
it("leaves unresolved variables as-is", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model}]", {});
|
||||
expect(result).toBe("[{model}]");
|
||||
});
|
||||
|
||||
it("leaves unrecognized variables as-is", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{unknownVar}]", {
|
||||
model: "gpt-5.2",
|
||||
});
|
||||
expect(result).toBe("[{unknownVar}]");
|
||||
});
|
||||
|
||||
it("handles case insensitivity", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{MODEL} | {ThinkingLevel}]", {
|
||||
model: "gpt-5.2",
|
||||
thinkingLevel: "low",
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2 | low]");
|
||||
});
|
||||
|
||||
it("handles mixed resolved and unresolved variables", () => {
|
||||
const result = resolveResponsePrefixTemplate("[{model} | {provider}]", {
|
||||
model: "gpt-5.2",
|
||||
// provider not provided
|
||||
});
|
||||
expect(result).toBe("[gpt-5.2 | {provider}]");
|
||||
});
|
||||
|
||||
it("handles complex template with all variables", () => {
|
||||
const result = resolveResponsePrefixTemplate(
|
||||
"[{identity.name}] {provider}/{model} (think:{thinkingLevel})",
|
||||
{
|
||||
identityName: "Clawdbot",
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
);
|
||||
expect(result).toBe("[Clawdbot] anthropic/claude-opus-4-5 (think:high)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractShortModelName", () => {
|
||||
it("strips provider prefix", () => {
|
||||
expect(extractShortModelName("openai/gpt-5.2")).toBe("gpt-5.2");
|
||||
expect(extractShortModelName("anthropic/claude-opus-4-5")).toBe("claude-opus-4-5");
|
||||
expect(extractShortModelName("openai-codex/gpt-5.2-codex")).toBe("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
it("strips date suffix", () => {
|
||||
expect(extractShortModelName("claude-opus-4-5-20251101")).toBe("claude-opus-4-5");
|
||||
expect(extractShortModelName("gpt-5.2-20250115")).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
it("strips -latest suffix", () => {
|
||||
expect(extractShortModelName("gpt-5.2-latest")).toBe("gpt-5.2");
|
||||
expect(extractShortModelName("claude-sonnet-latest")).toBe("claude-sonnet");
|
||||
});
|
||||
|
||||
it("handles model without provider", () => {
|
||||
expect(extractShortModelName("gpt-5.2")).toBe("gpt-5.2");
|
||||
expect(extractShortModelName("claude-opus-4-5")).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("handles full path with provider and date suffix", () => {
|
||||
expect(extractShortModelName("anthropic/claude-opus-4-5-20251101")).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("preserves version numbers that look like dates but are not", () => {
|
||||
// Date suffix must be exactly 8 digits at the end
|
||||
expect(extractShortModelName("model-v1234567")).toBe("model-v1234567");
|
||||
expect(extractShortModelName("model-123456789")).toBe("model-123456789");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasTemplateVariables", () => {
|
||||
it("returns false for undefined", () => {
|
||||
expect(hasTemplateVariables(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(hasTemplateVariables("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for static prefix", () => {
|
||||
expect(hasTemplateVariables("[Claude]")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when template variables present", () => {
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
expect(hasTemplateVariables("{provider}")).toBe(true);
|
||||
expect(hasTemplateVariables("prefix {thinkingLevel} suffix")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for multiple variables", () => {
|
||||
expect(hasTemplateVariables("[{model} | {provider}]")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles consecutive calls correctly (regex lastIndex reset)", () => {
|
||||
// First call
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
// Second call should still work
|
||||
expect(hasTemplateVariables("[{model}]")).toBe(true);
|
||||
// Static string should return false
|
||||
expect(hasTemplateVariables("[Claude]")).toBe(false);
|
||||
});
|
||||
});
|
||||
97
src/auto-reply/reply/response-prefix-template.ts
Normal file
97
src/auto-reply/reply/response-prefix-template.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Template interpolation for response prefix.
|
||||
*
|
||||
* Supports variables like `{model}`, `{provider}`, `{thinkingLevel}`, etc.
|
||||
* Variables are case-insensitive and unresolved ones remain as literal text.
|
||||
*/
|
||||
|
||||
export type ResponsePrefixContext = {
|
||||
/** Short model name (e.g., "gpt-5.2", "claude-opus-4-5") */
|
||||
model?: string;
|
||||
/** Full model ID including provider (e.g., "openai-codex/gpt-5.2") */
|
||||
modelFull?: string;
|
||||
/** Provider name (e.g., "openai-codex", "anthropic") */
|
||||
provider?: string;
|
||||
/** Current thinking level (e.g., "high", "low", "off") */
|
||||
thinkingLevel?: string;
|
||||
/** Agent identity name */
|
||||
identityName?: string;
|
||||
};
|
||||
|
||||
// Regex pattern for template variables: {variableName} or {variable.name}
|
||||
const TEMPLATE_VAR_PATTERN = /\{([a-zA-Z][a-zA-Z0-9.]*)\}/g;
|
||||
|
||||
/**
|
||||
* Interpolate template variables in a response prefix string.
|
||||
*
|
||||
* @param template - The template string with `{variable}` placeholders
|
||||
* @param context - Context object with values for interpolation
|
||||
* @returns The interpolated string, or undefined if template is undefined
|
||||
*
|
||||
* @example
|
||||
* resolveResponsePrefixTemplate("[{model} | think:{thinkingLevel}]", {
|
||||
* model: "gpt-5.2",
|
||||
* thinkingLevel: "high"
|
||||
* })
|
||||
* // Returns: "[gpt-5.2 | think:high]"
|
||||
*/
|
||||
export function resolveResponsePrefixTemplate(
|
||||
template: string | undefined,
|
||||
context: ResponsePrefixContext,
|
||||
): string | undefined {
|
||||
if (!template) return undefined;
|
||||
|
||||
return template.replace(TEMPLATE_VAR_PATTERN, (match, varName: string) => {
|
||||
const normalizedVar = varName.toLowerCase();
|
||||
|
||||
switch (normalizedVar) {
|
||||
case "model":
|
||||
return context.model ?? match;
|
||||
case "modelfull":
|
||||
return context.modelFull ?? match;
|
||||
case "provider":
|
||||
return context.provider ?? match;
|
||||
case "thinkinglevel":
|
||||
case "think":
|
||||
return context.thinkingLevel ?? match;
|
||||
case "identity.name":
|
||||
case "identityname":
|
||||
return context.identityName ?? match;
|
||||
default:
|
||||
// Leave unrecognized variables as-is
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract short model name from a full model string.
|
||||
*
|
||||
* Strips:
|
||||
* - Provider prefix (e.g., "openai/" from "openai/gpt-5.2")
|
||||
* - Date suffixes (e.g., "-20251101" from "claude-opus-4-5-20251101")
|
||||
* - Common version suffixes (e.g., "-latest")
|
||||
*
|
||||
* @example
|
||||
* extractShortModelName("openai-codex/gpt-5.2") // "gpt-5.2"
|
||||
* extractShortModelName("claude-opus-4-5-20251101") // "claude-opus-4-5"
|
||||
* extractShortModelName("gpt-5.2-latest") // "gpt-5.2"
|
||||
*/
|
||||
export function extractShortModelName(fullModel: string): string {
|
||||
// Strip provider prefix
|
||||
const slash = fullModel.lastIndexOf("/");
|
||||
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
||||
|
||||
// Strip date suffixes (YYYYMMDD format)
|
||||
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template string contains any template variables.
|
||||
*/
|
||||
export function hasTemplateVariables(template: string | undefined): boolean {
|
||||
if (!template) return false;
|
||||
// Reset lastIndex since we're using a global regex
|
||||
TEMPLATE_VAR_PATTERN.lastIndex = 0;
|
||||
return TEMPLATE_VAR_PATTERN.test(template);
|
||||
}
|
||||
@@ -5,6 +5,13 @@ export type BlockReplyContext = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
/** Context passed to onModelSelected callback with actual model used. */
|
||||
export type ModelSelectedContext = {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkLevel: string | undefined;
|
||||
};
|
||||
|
||||
export type GetReplyOptions = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onTypingController?: (typing: TypingController) => void;
|
||||
@@ -13,6 +20,9 @@ export type GetReplyOptions = {
|
||||
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
|
||||
onBlockReply?: (payload: ReplyPayload, context?: BlockReplyContext) => Promise<void> | void;
|
||||
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
|
||||
/** Called when the actual model is selected (including after fallback).
|
||||
* Use this to get model/provider/thinkLevel for responsePrefix template interpolation. */
|
||||
onModelSelected?: (ctx: ModelSelectedContext) => void;
|
||||
disableBlockStreaming?: boolean;
|
||||
/** Timeout for block reply delivery (ms). */
|
||||
blockReplyTimeoutMs?: number;
|
||||
|
||||
Reference in New Issue
Block a user