Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
6 changes: 6 additions & 0 deletions docs/features/F055-a2a-mcp-structured-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ created: 2026-03-04
- 不改 thread participation 逻辑
- 不做 7B 意图识别(成本高、CJK 准确率不够、双通道已够兜底)

### 相关改进(#417

- 协议明确区分:inline `@xx` = semantic only,line-start `@xx` = actionable handoff
- `callback-tools.ts` post_message 描述已统一为"行首 @猫名",避免误导
- `detectInlineActionMentions()` 补上 write-side feedback,句中 action-like @ 不再静默丢弃

---

## Acceptance Criteria
Expand Down
37 changes: 16 additions & 21 deletions docs/features/F064-a2a-exit-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ F064 的核心动机是修复 A2A 协作中的“链条终止盲区”:该 @
### Phase B(运行时注入)
- [x] AC-B1: 非 parallel 且 a2aEnabled 时注入出口检查提示。
- [x] AC-B2: `mentionRoutingFeedback` read-side 注入与测试覆盖完成。
- [ ] AC-B3: write-side 自动回写尚未接入(列为已知债务并保留后续方案)。
- [x] AC-B3: write-side 自动回写已部分接入(#417: serial response path via route-serial; callback/post_message path 尚未覆盖)。

## Dependencies

Expand Down Expand Up @@ -127,30 +127,25 @@ Maine Coon(GPT-5.2) 在协作场景中反复出现两种极端:
- 历史事件:Maine Coon mention spam 事件(Anti-Mention-Spam 规则起源)
- 相关 Feature:F046 Anti-Drift Protocol、F055 A2A MCP Structured Routing

## Known Debt: `mentionRoutingFeedback` write-side 未接入
## Partially Resolved Debt: `mentionRoutingFeedback` write-side (#417)

**状态**:read-side 已完成(F064 PR #227),write-side 未实现
**状态**:serial response path 已接入;callback/post_message path 尚未覆盖

**现状**:
- `buildInvocationContext()` 已能渲染 `mentionRoutingFeedback`(如果有值的话)→ 提醒猫"上次 @ 没生效"
- `ThreadStore.setMentionRoutingFeedback()` 接口已存在(in-memory + Redis 两个实现都有)
- **但没有任何代码在检测到"句中 @ 未路由"时调用 `setMentionRoutingFeedback()`**
**已接入路径(route-serial)**:
- `a2a-mentions.ts` 新增 `detectInlineActionMentions()`:邻近性检测句中 `@pattern` + 紧邻动作词
- `route-serial.ts` 在 `parseA2AMentions()` 之后调用检测,命中时写入 `setMentionRoutingFeedback()`
- 下次该猫被唤起时,read-side 渲染为 `[路由提醒]`,消费后自动清除(one-shot)
- `callback-tools.ts` post_message 描述已统一为"行首 @猫名"

**为什么没一起修**:
1. write-side 需要在 `routeSerial` 完成后分析猫的回复——检测"句中有 `@xxx` 但不在行首"→ 写入 feedback。当前 `a2a-mentions.ts` 只解析行首 @,不检测句中 @,需要扩展解析逻辑
2. **误报风险高**:叙述性提及(如"Ragdoll已经完成了 @opus 的建议")不应触发反馈,但简单的正则很难区分"想 @ 但格式错"和"单纯的叙述性提及"。如果误报频繁,反而会引发 mention spam(猫收到"你上次 @ 没生效"→ 补一个行首 @ → 实际上不需要对方行动)
3. F064 的核心目标是**主动预防**(出口检查),write-side feedback 是**被动纠正**,优先级低
**未覆盖路径**:
- `callbacks.ts` 的 post_message callback 路径仍只做行首 mention 解析,未调用 write-side feedback
- 后续如需完整覆盖,需在 callback 写路径也接入 `detectInlineActionMentions`

**未来接入建议**:
- 在 `routeSerial` 的猫回复完成后,用 `a2a-mentions.ts` 的扩展版本检测"句中有 @pattern 但不在行首"的情况
- 只有同时满足以下条件才写入 feedback:(a) 句中有 @pattern (b) 同段有动作词(请/帮/review/确认等)(c) 不在代码块或引用块中
- 写入后设 TTL(如 1 次调用后过期),避免反复提醒

**相关代码位置**:
- Read-side: `SystemPromptBuilder.ts:384-388`
- Store interface: `ThreadStore.ts:129-137`
- 路由入口: `route-serial.ts:120-149`
- 解析器: `a2a-mentions.ts`(需扩展)
**误报控制(邻近性检测)**:
- 动作词必须紧邻 @mention 前后(BEFORE: ready for/请/帮/交接给/转给;AFTER: review/check/确认/处理 等)
- 整行有动作词但不紧邻 @mention 不触发(如"请按 @codex 之前的建议继续处理")
- 纯叙述性提及(如"之前 @codex 提出的方案不错")不会触发
- feedback 是 one-shot(consumeMentionRoutingFeedback 读后即删),不会反复提醒

## 愿景守护签收表

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export interface A2AMentionAnalysis {
readonly suppressed: SuppressedA2AMention[];
}

/** #417: Inline @mention paired with action words — missed handoff candidate. */
export interface InlineActionMention {
readonly catId: CatId;
readonly lineText: string;
}

/** @deprecated Mode is ignored — line-start mentions always route regardless of mode. */
export type MentionActionabilityMode = 'strict' | 'relaxed';

Expand Down Expand Up @@ -118,3 +124,87 @@ export function analyzeA2AMentions(

return { mentions: found, suppressed: [] };
}

/**
* #417: Detect inline @mentions paired with action words — missed handoff candidates.
* Used for write-side feedback only, NOT for routing.
*
* Conditions (all must hold):
* 1. @pattern appears mid-line (not at line start)
* 2. Action keyword immediately adjacent to @mention (proximity-based, not whole-line)
* 3. Not inside a fenced code block or blockquote
* 4. Target cat was not already routed via line-start mention
* 5. Not a self-mention
*/

/** Action patterns that appear immediately BEFORE @mention (e.g. "Ready for @xxx"). */
const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|请|帮)\s*$/i;
/**
* Action patterns immediately AFTER @mention (e.g. "@xxx review").
* Chinese verbs use negative lookahead to exclude completion suffixes (过/了/完/好/掉),
* which turn commands into narrative: "@codex 处理过" ≠ "@codex 处理一下".
*/
const AFTER_HANDOFF_RE =
/^\s*(?:review|check|fix|merge|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请(?![教示假求问]))/i;

export function detectInlineActionMentions(
text: string,
currentCatId?: CatId,
routedMentions?: CatId[],
): InlineActionMention[] {
if (!text) return [];

const stripped = text.replace(/```[\s\S]*?```/g, '');
const allConfigs = Object.keys(catRegistry.getAllConfigs()).length > 0 ? catRegistry.getAllConfigs() : CAT_CONFIGS;

const entries: MentionPatternEntry[] = [];
for (const [id, config] of Object.entries(allConfigs)) {
if (currentCatId && id === currentCatId) continue;
if (!isCatAvailable(id)) continue;
for (const pattern of config.mentionPatterns) {
entries.push({ catId: id as CatId, pattern: pattern.toLowerCase() });
}
}
entries.sort((a, b) => b.pattern.length - a.pattern.length);

const routedSet = new Set(routedMentions ?? []);
const found: InlineActionMention[] = [];
const seen = new Set<string>();

for (const rawLine of stripped.split(/\r?\n/)) {
const trimmed = rawLine.trimStart();
const normalized = trimmed.toLowerCase();
if (normalized.startsWith('@') || normalized.startsWith('>')) continue;

let lineMatched = false;
for (const entry of entries) {
if (lineMatched) break;
// Scan ALL occurrences of this pattern in the line (not just first indexOf hit).
// Fixes: "之前 @codex 提过意见,现在 Ready for @codex review" must find the second one.
let searchFrom = 0;
while (searchFrom < normalized.length) {
const idx = normalized.indexOf(entry.pattern, searchFrom);
if (idx < 0) break;
searchFrom = idx + 1;
// Left boundary: @ must not be preceded by word-like chars (avoids "foo@codex")
if (idx > 0 && HANDLE_CONTINUATION_RE.test(normalized[idx - 1]!)) continue;
const charAfter = normalized[idx + entry.pattern.length];
const isBoundary = !charAfter || TOKEN_BOUNDARY_RE.test(charAfter) || !HANDLE_CONTINUATION_RE.test(charAfter);
if (!isBoundary) continue;
// Already routed via line-start: skip this entry but keep scanning other cats on same line.
if (routedSet.has(entry.catId)) break;
const before = normalized.slice(0, idx);
const after = normalized.slice(idx + entry.pattern.length);
if (!BEFORE_HANDOFF_RE.test(before) && !AFTER_HANDOFF_RE.test(after)) continue;
if (!seen.has(entry.catId)) {
seen.add(entry.catId);
found.push({ catId: entry.catId, lineText: rawLine.trim() });
}
lineMatched = true;
break;
}
}
}

return found;
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { invokeSingleCat } from '../invocation/invoke-single-cat.js';
import { buildMcpCallbackInstructions, needsMcpInjection } from '../invocation/McpPromptInjector.js';
import { getRichBlockBuffer } from '../invocation/RichBlockBuffer.js';
import { resolveDefaultClaudeMcpServerPath } from '../providers/ClaudeAgentService.js';
import { getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js';
import { detectInlineActionMentions, getMaxA2ADepth, parseA2AMentions } from '../routing/a2a-mentions.js';
import { registerWorklist, unregisterWorklist } from '../routing/WorklistRegistry.js';
import { buildBriefingMessage } from './format-briefing.js';
import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js';
Expand Down Expand Up @@ -654,6 +654,25 @@ export async function* routeSerial(
// Line-start @mention = always actionable (no keyword gate)
a2aMentions = parseA2AMentions(storedContent, catId);

// #417 / F064 AC-B3: Write-side feedback for inline action-like @mentions
if (deps.invocationDeps.threadStore) {
const inlineHits = detectInlineActionMentions(storedContent, catId, a2aMentions);
if (inlineHits.length > 0) {
try {
await deps.invocationDeps.threadStore.setMentionRoutingFeedback(threadId, catId, {
sourceTimestamp: Date.now(),
items: inlineHits.map((m) => ({ targetCatId: m.catId, reason: 'inline_action' as const })),
});
log.info(
{ catId: catId as string, threadId, targets: inlineHits.map((h) => h.catId) },
'Inline action @mention detected — wrote routing feedback',
);
} catch {
/* best-effort */
}
}
}

// F079 Phase 2: Vote interception — extract [VOTE:xxx] from cat response
const votedOption = extractVoteFromText(storedContent);
if (votedOption && deps.invocationDeps.threadStore) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface ThreadMemoryV1 {
artifacts?: string[];
}

export type MentionRoutingSuppressionReason = 'no_action' | 'cross_paragraph';
export type MentionRoutingSuppressionReason = 'no_action' | 'cross_paragraph' | 'inline_action';
export type MentionActionabilityMode = 'strict' | 'relaxed';

export interface ThreadMentionRoutingFeedbackItem {
Expand Down
Loading
Loading