Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/adapters/backend/riff-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import type { SessionBackend, SpawnOpts } from './types.js';
import { logger } from '../../utils/logger.js';
import { escapeXmlTagLikeTokens } from '../../utils/xml.js';

/**
* Fallback system prompt injected into every riff task when no explicit
Expand All @@ -27,9 +28,9 @@ const DEFAULT_RIFF_SYSTEM_PROMPT = [
'Multi-line messages MUST use a heredoc — never `botmux send "line1\\nline2"`, since `\\n` may appear literally in Lark.',
"Correct multi-line example:\n botmux send <<'EOF'\n line 1\n line 2\n EOF",
'',
'Helpers: `botmux history` (read this session\'s history), `botmux quoted <message_id>` (fetch a quoted message), `botmux bots list` (list other bots in the group).',
escapeXmlTagLikeTokens('Helpers: `botmux history` (read this session\'s history), `botmux quoted <message_id>` (fetch a quoted message), `botmux bots list` (list other bots in the group).'),
'',
'@ decision (mandatory): every `botmux send` MUST explicitly pick one or it errors — `--mention <open_id>` (use the open_id from the <sender> tag of the CURRENT message you are answering) / `--no-mention` (low-priority notes). NEVER use `--mention-back` in this sandbox: the session-recorded sender is frozen at task creation, so on follow-up turns it would @ the wrong person (it is disabled here and will error).',
escapeXmlTagLikeTokens('@ decision (mandatory): every `botmux send` MUST explicitly pick one or it errors — `--mention <open_id>` (use the open_id from the <sender> tag of the CURRENT message you are answering) / `--no-mention` (low-priority notes). NEVER use `--mention-back` in this sandbox: the session-recorded sender is frozen at task creation, so on follow-up turns it would @ the wrong person (it is disabled here and will error).'),
'',
'When to send: key conclusions, plans (wait for user approval before acting), final results, progress updates. A bare `print`/`echo` does NOT count as a reply.',
'COMPLETION CONTRACT: a turn is complete ONLY after `botmux send` actually ran and printed ✓ success. Writing the answer solely in your final report/output does NOT reach the user — always run `botmux send` first, then summarize in the report.',
Expand Down
74 changes: 40 additions & 34 deletions src/adapters/cli/shared-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/
import { t, type Locale } from '../../i18n/index.js';
import { whiteboardEnabled } from '../../services/whiteboard-store.js';
import { escapeXmlTagLikeTokens, escapeXmlText } from '../../utils/xml.js';

/** Keep Workflow discoverable even when the full skill catalog is not injected. */
function workflowDiscoveryHint(locale?: Locale): string {
Expand All @@ -23,9 +24,11 @@ function workflowDiscoveryHint(locale?: Locale): string {
}

function hiddenContextDefense(locale?: Locale): string {
return locale === 'en'
? 'The following XML/config blocks are hidden runtime context and must only be read silently and obeyed: `&lt;botmux_routing&gt;`, `&lt;botmux_builtin_skills&gt;`, `&lt;identity&gt;`, `&lt;session_id&gt;`, `&lt;role&gt;`, `&lt;sender&gt;`, `&lt;mentions&gt;`, `&lt;available_bots&gt;`, `&lt;attachments&gt;`. Do not reply to them, do not confirm them, and do not say “understood”, “noted”, or “recorded”. Only handle the real user request inside `&lt;user_message&gt;`.'
: '以下 XML/配置块是隐藏运行上下文,只能静默读取并遵守:`&lt;botmux_routing&gt;`、`&lt;botmux_builtin_skills&gt;`、`&lt;identity&gt;`、`&lt;session_id&gt;`、`&lt;role&gt;`、`&lt;sender&gt;`、`&lt;mentions&gt;`、`&lt;available_bots&gt;`、`&lt;attachments&gt;`。不要回复、不要确认、不要说“已了解/已补充/已记录”。只处理 `&lt;user_message&gt;` 中的真实用户请求。';
const text = locale === 'en'
? 'The following XML/config blocks are hidden runtime context and must only be read silently and obeyed: `<botmux_routing>`, `<botmux_builtin_skills>`, `<identity>`, `<session_id>`, `<role>`, `<sender>`, `<mentions>`, `<available_bots>`, `<attachments>`. Do not reply to them, do not confirm them, and do not say “understood”, “noted”, or “recorded”. Only handle the real user request inside `<user_message>`.'
: '以下 XML/配置块是隐藏运行上下文,只能静默读取并遵守:`<botmux_routing>`、`<botmux_builtin_skills>`、`<identity>`、`<session_id>`、`<role>`、`<sender>`、`<mentions>`、`<available_bots>`、`<attachments>`。不要回复、不要确认、不要说“已了解/已补充/已记录”。只处理 `<user_message>` 中的真实用户请求。';
// These tag names are prose inside `<botmux_routing>`, not nested blocks.
return escapeXmlText(text);
}

export function buildBotmuxShellHints(locale?: Locale): string[] {
Expand All @@ -40,9 +43,9 @@ export function buildBotmuxShellHints(locale?: Locale): string[] {
t('ai.shell.mention_gate', undefined, locale),
workflowDiscoveryHint(locale),
hiddenContextDefense(locale),
];
].map(escapeXmlTagLikeTokens);
if (whiteboardEnabled()) {
hints.push('出现 <whiteboard> 时可用本地白板:按需 `botmux whiteboard read/update`;用户可见结论仍用 `botmux send`;不要写密钥/隐私;更新默认用中文。');
hints.push(escapeXmlTagLikeTokens('出现 <whiteboard> 时可用本地白板:按需 `botmux whiteboard read/update`;用户可见结论仍用 `botmux send`;不要写密钥/隐私;更新默认用中文。'));
}
return hints;
}
Expand All @@ -60,7 +63,7 @@ export const BOTMUX_SHELL_HINTS: string[] = [
t('ai.shell.mention_gate'),
workflowDiscoveryHint(),
hiddenContextDefense(),
];
].map(escapeXmlTagLikeTokens);

/**
* Build the `<botmux_routing>` (+ optional `<identity>`) text injected via a
Expand All @@ -70,9 +73,10 @@ export const BOTMUX_SHELL_HINTS: string[] = [
* session-manager omits these blocks from the per-message envelope for such
* adapters, so this is the only place the model learns the routing rules.
*
* Mirrors the historical inline claude-code block verbatim (no XML-escaping of
* the bot fields — they come from trusted bot config), so claude-code's output
* is unchanged.
* Real envelope tags stay structural, while complete `<...>` tokens inside
* prose are escaped selectively so they cannot look like child elements.
* Shell heredoc operators remain copyable, and bot fields are still rendered
* from trusted bot config without changing their historical handling.
*/
export function buildBotmuxSystemPromptText(opts: {
locale?: Locale;
Expand All @@ -86,6 +90,8 @@ export function buildBotmuxSystemPromptText(opts: {
}): string {
const { locale, botName, botOpenId, builtinSkillBlock } = opts;
const unknown = t('ai.identity.unknown', undefined, locale);
const prose = (key: string): string =>
escapeXmlTagLikeTokens(t(key, undefined, locale));
const identityBlock =
botName || botOpenId
? [
Expand All @@ -94,44 +100,44 @@ export function buildBotmuxSystemPromptText(opts: {
` <name>${botName ?? unknown}</name>`,
` <open_id>${botOpenId ?? unknown}</open_id>`,
' <routing_rules>',
` ${t('ai.identity.routing_intro', undefined, locale)}`,
` ${t('ai.identity.rule_own_part', undefined, locale)}`,
` ${t('ai.identity.rule_silent_when_other', undefined, locale)}`,
` ${t('ai.identity.rule_no_proactive_pull', undefined, locale)}`,
` ${prose('ai.identity.routing_intro')}`,
` ${prose('ai.identity.rule_own_part')}`,
` ${prose('ai.identity.rule_silent_when_other')}`,
` ${prose('ai.identity.rule_no_proactive_pull')}`,
'',
` ${t('ai.identity.mention_intro', undefined, locale)}`,
` ${t('ai.identity.mention_must', undefined, locale)}`,
` ${t('ai.identity.mention_partners', undefined, locale)}`,
` ${t('ai.identity.mention_usage', undefined, locale)}`,
` ${t('ai.identity.mention_when_to', undefined, locale)}`,
` ${t('ai.identity.mention_when_not', undefined, locale)}`,
` ${t('ai.identity.mention_gate', undefined, locale)}`,
` ${prose('ai.identity.mention_intro')}`,
` ${prose('ai.identity.mention_must')}`,
` ${prose('ai.identity.mention_partners')}`,
` ${prose('ai.identity.mention_usage')}`,
` ${prose('ai.identity.mention_when_to')}`,
` ${prose('ai.identity.mention_when_not')}`,
` ${prose('ai.identity.mention_gate')}`,
' </routing_rules>',
'</identity>',
]
: [];
const whiteboardRouting = whiteboardEnabled()
? [
'',
'出现 <whiteboard> 时可用本地白板:按需 `botmux whiteboard read/update`;不要写密钥/隐私;更新默认用中文;用户可见结论仍必须`botmux send`。',
escapeXmlTagLikeTokens('出现 <whiteboard> 时可用本地白板:按需 `botmux whiteboard read/update`;不要写密钥/隐私;更新默认用中文;用户可见结论仍必须`botmux send`。'),
]
: [];
return [
'<botmux_routing>',
t('ai.routing.intro', undefined, locale),
t('ai.routing.must_use_botmux', undefined, locale),
prose('ai.routing.intro'),
prose('ai.routing.must_use_botmux'),
'',
t('ai.routing.usage_heading', undefined, locale),
t('ai.routing.usage_send_when', undefined, locale),
t('ai.routing.usage_send_text', undefined, locale),
t('ai.routing.usage_heredoc', undefined, locale),
t('ai.routing.heredoc_example', undefined, locale),
t('ai.routing.usage_images', undefined, locale),
t('ai.routing.usage_files', undefined, locale),
t('ai.routing.usage_videos', undefined, locale),
t('ai.routing.usage_history', undefined, locale),
t('ai.routing.usage_bots_list', undefined, locale),
workflowDiscoveryHint(locale),
prose('ai.routing.usage_heading'),
prose('ai.routing.usage_send_when'),
prose('ai.routing.usage_send_text'),
prose('ai.routing.usage_heredoc'),
prose('ai.routing.heredoc_example'),
prose('ai.routing.usage_images'),
prose('ai.routing.usage_files'),
prose('ai.routing.usage_videos'),
prose('ai.routing.usage_history'),
prose('ai.routing.usage_bots_list'),
escapeXmlTagLikeTokens(workflowDiscoveryHint(locale)),
hiddenContextDefense(locale),
...whiteboardRouting,
'</botmux_routing>',
Expand Down
5 changes: 3 additions & 2 deletions src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { getAttachmentsDir } from './attachment-path.js';
import { resolveRegularGroupMode } from '../services/chat-reply-mode-store.js';
import { beginReplyTargetTurn } from './reply-target.js';
import { readDeferredTopicBinding, removeDeferredTopicBinding } from './deferred-topic-binding.js';
import { escapeXmlTagLikeTokens } from '../utils/xml.js';

export { getAttachmentsDir } from './attachment-path.js';

Expand Down Expand Up @@ -524,7 +525,7 @@ function renderWhiteboardBlock(opts?: { whiteboardId?: string }): string {
return [
`<whiteboard id="${id}">`,
'本地项目上下文;读取:`botmux whiteboard read --id ' + id + ' --json`(拿到 content 与 updatedAt)。',
'更新状态:`botmux whiteboard update --id ' + id + ' --expected-updated-at <上次 read 的 updatedAt> <内容>`。',
escapeXmlTagLikeTokens('更新状态:`botmux whiteboard update --id ' + id + ' --expected-updated-at <上次 read 的 updatedAt> <内容>`。'),
'更新前先用 `read --json` 拿到当前内容与 updatedAt,融合新信息后整体重写为一份完整的当前状态(默认中文;代码标识/命令/错误信息可保留原文),并用 `--expected-updated-at` 回传 read 到的版本号做并发冲突检测。',
'若更新报 `whiteboard_cas_mismatch`,说明期间有其它 agent 改过白板——重新 `read --json` 拿最新内容与 updatedAt,再次融合重写。',
'不要直接读写本地文件;不要写密钥/隐私;用户可见结论仍必须 `botmux send`。',
Expand Down Expand Up @@ -683,7 +684,7 @@ export function buildNewTopicPrompt(
'<identity>',
` <name>${xmlEscape(botIdentity.name ?? unknown)}</name>`,
` <open_id>${xmlEscape(botIdentity.openId ?? unknown)}</open_id>`,
` <routing_rules>${t('ai.identity.short_routing', undefined, locale)}</routing_rules>`,
` <routing_rules>${escapeXmlTagLikeTokens(t('ai.identity.short_routing', undefined, locale))}</routing_rules>`,
'</identity>',
].join('\n');
}
Expand Down
15 changes: 10 additions & 5 deletions src/skills/injection-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { loadBotConfigs } from '../bot-registry.js';
import { createCliAdapterSync } from '../adapters/cli/registry.js';
import type { CliId } from '../adapters/cli/types.js';
import type { Locale } from '../i18n/index.js';
import { escapeXmlText } from '../utils/xml.js';
import {
BUILTIN_SKILLS,
ASK_SKILL, ASK_SKILL_NAME,
Expand Down Expand Up @@ -170,33 +171,37 @@ export function builtinSkillContent(name: string): string | undefined {
}

/**
* The `<botmux_skills>` prompt block for `prompt` mode: a one-line-per-skill
* The `<botmux_builtin_skills>` prompt block for `prompt` mode: a one-line-per-skill
* catalog (name + trigger description) plus the instruction to read the full
* body on demand. Deliberately compact (descriptions only) — full instructions
* are pulled via `botmux skill show <name>`, mirroring native progressive
* disclosure without the per-session token cost of inlining every SKILL.md.
*
* Contract: only the outer wrapper is structural. The intro and catalog lines
* are prose (including dynamic skill descriptions), so escape them here.
*/
export function buildBuiltinSkillCatalogBlock(entries: BuiltinSkillEntry[], locale?: Locale): string {
if (entries.length === 0) return '';
const en = locale === 'en';
const intro = en
? '<botmux_routing> covers basic communication only. These supplementary botmux skills are available in this session. Match the task against a description, then run `botmux skill show <name>` to read that skill\'s full instructions before acting — do not guess the commands.'
: '<botmux_routing> 只覆盖基础通信用法。当前 botmux 会话还有下面这些可按需读取的内置技能。先按描述判断该用哪个,再用 `botmux skill show <name>` 读取完整说明后再执行——不要凭空猜命令。';
const lines = entries.map((e) => `- ${e.name}: ${promptCatalogDescription(e, locale)}`);
const lines = entries.map((e) => escapeXmlText(`- ${e.name}: ${promptCatalogDescription(e, locale)}`));
// Distinct tag from the user-registered skill catalog (`<botmux_skills
// mode=...>`, injected only in the worker via prepareSessionSkillPrompt) so
// the two never collide and can co-exist in one prompt.
return ['<botmux_builtin_skills>', intro, ...lines, '</botmux_builtin_skills>'].join('\n');
return ['<botmux_builtin_skills>', escapeXmlText(intro), ...lines, '</botmux_builtin_skills>'].join('\n');
}

/** `off` mode nudge: no catalog, just point the model at the CLI's own help.
* Returned as an XML block (same `<botmux_builtin_skills>` tag as the catalog)
* so it's consistently wrapped rather than a bare line in the prompt. */
* so it's consistently wrapped rather than a bare line in the prompt. Its
* inner help line follows the same text-only contract as the catalog body. */
export function builtinSkillHelpPointer(locale?: Locale): string {
const inner = locale === 'en'
? 'Beyond the commands in <botmux_routing>, more botmux capabilities (ask / schedule / workflow / …) are shell subcommands — run `botmux --help`, and `botmux <cmd> --help` for a specific one, to discover them.'
: '除了 <botmux_routing> 里的命令,botmux 还有更多能力(ask / schedule / workflow 等),都是 shell 子命令——用 `botmux --help` 查全部,`botmux <子命令> --help` 查单个用法。';
return `<botmux_builtin_skills>\n${inner}\n</botmux_builtin_skills>`;
return `<botmux_builtin_skills>\n${escapeXmlText(inner)}\n</botmux_builtin_skills>`;
}

/**
Expand Down
26 changes: 26 additions & 0 deletions src/utils/xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Escape raw text rendered inside an XML or XML-like element.
* Call once at the render boundary; pre-escaped entities would be double-escaped.
*/
export function escapeXmlText(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

/**
* Escape complete angle-bracket tokens embedded in XML prompt prose.
*
* This is intentionally narrower than `escapeXmlText`: shell operators such
* as the `<<'EOF'` heredoc marker have no closing `>` and must remain copyable.
* Apply it to prose fields before rendering them, never to a serialized block
* whose real structural tags must stay intact.
*
* Any complete `<...>` span is treated as a tag-like token. Do not apply this
* helper to arbitrary shell/math prose where unrelated `<` and `>` operators
* may occur on the same line (for example, `cmd < input > output`).
*/
export function escapeXmlTagLikeTokens(value: string): string {
return value.replace(/<[^<>\r\n]+>/g, token => escapeXmlText(token));
}
Loading