Skip to content
Merged
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
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,91 @@ 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").
* Chinese 请 uses negative lookbehind to exclude compounds (邀请 = invite, 申请 = apply).
*/
const BEFORE_HANDOFF_RE = /(?:ready\s+for|交接给?|转给|(?<![邀申敬])请|帮)\s*$/i;
/**
* Action patterns immediately AFTER @mention (e.g. "@xxx review").
* English verbs use (?![a-z]) to reject continuations ("reviewed", "checklist").
* Chinese verbs use negative lookahead to exclude completion suffixes (过/了/完/好/掉).
*/
const AFTER_HANDOFF_RE =
/^\s*(?:(?:review|check|fix|merge)(?![a-z])|(?:确认|处理|来处理|来看)(?![过了完好掉])|看一?下|帮忙|请(?![教示假求问]))/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;
}
// Already-seen cat: don't claim the line — let other cats still be scanned.
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 { extractContextEvalSignals } from './context-eval.js';
import { buildBriefingMessage } from './format-briefing.js';
Expand Down Expand Up @@ -667,6 +667,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