Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export interface QueueEntry {
position?: number;
/** F175: skill hint for connector triggers — flows through as promptTags on execution */
suggestedSkill?: string;
/** True only for connector wakes backed by verified external callback/tracking coverage. */
eventDrivenExternalWaitCoverage?: boolean;
callerTraceContext?: CallerTraceContext;
/** Explicit A2A trigger message for stream reply threading. */
a2aTriggerMessageId?: string;
Expand Down Expand Up @@ -183,6 +185,9 @@ export class InvocationQueue {
if (input.sourceCategory && !existing.sourceCategory) {
existing.sourceCategory = input.sourceCategory;
}
if (input.eventDrivenExternalWaitCoverage) {
existing.eventDrivenExternalWaitCoverage = true;
}
}
const position = q.findIndex((entry) => entry.id === existing.id);
return {
Expand Down Expand Up @@ -222,6 +227,7 @@ export class InvocationQueue {
sourceCategory: input.sourceCategory,
continuationKey: input.continuationKey,
suggestedSkill: input.suggestedSkill,
eventDrivenExternalWaitCoverage: input.eventDrivenExternalWaitCoverage,
callerTraceContext: input.callerTraceContext,
a2aTriggerMessageId: input.a2aTriggerMessageId,
position: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,8 @@ export class QueueProcessor {
// #949 P1-1: Connector-sourced queue entries have no ball-pass expectation.
// A2A/agent entries still get the verdict-pass handoff guard.
verdictPassWarningEnabled: entry.source !== 'connector',
// Only policy-backed connector wakes prove a future callback/tracking path.
eventDrivenExternalWaitCoverage: entry.eventDrivenExternalWaitCoverage === true,
},
)) {
if (controller.signal.aborted) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,8 @@ export class AgentRouter {
/** #949 P2: Whether verdict-without-pass warning fires at route end.
* true/undefined = warn (default). false = suppress for connector-sourced flows only. */
verdictPassWarningEnabled?: boolean;
/** Whether event-driven external waits are backed by verified callback/tracking coverage. */
eventDrivenExternalWaitCoverage?: boolean;
},
): AsyncIterable<AgentMessage> {
const cleanMessage = stripIntentTags(message);
Expand Down Expand Up @@ -1619,6 +1621,9 @@ export class AgentRouter {
...(options?.verdictPassWarningEnabled !== undefined
? { verdictPassWarningEnabled: options.verdictPassWarningEnabled }
: {}),
...(options?.eventDrivenExternalWaitCoverage !== undefined
? { eventDrivenExternalWaitCoverage: options.eventDrivenExternalWaitCoverage }
: {}),
};

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface ValidationInput {
readonly structuredTargetCats: readonly string[];
/** Roster handle whitelist (from cat-config). Non-roster @ mentions are ignored. */
readonly rosterHandles: readonly string[];
/** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */
readonly hasEventDrivenExternalWaitCoverage?: boolean;
}

export type ValidationResult =
Expand All @@ -37,6 +39,50 @@ export type ValidationResult =
const MARKDOWN_LINE_PREFIX_RE = /^(?:(?:>\s*)|(?:[-*+]\s+)|(?:\d+[.)]\s+))+/;
const URL_RE = /https?:\/\/[^\s)\]]+/g;
const FENCED_CODE_RE = /```[\s\S]*?```/g;
const EVENT_DRIVEN_EXTERNAL_WAIT_RE =
/^(?:(?:[-*+]\s+)|(?:\d+[.)]\s+))?External Wait\s*:\s*event-driven\s*\((?!\s*\))[^)\r\n]+\)\s*$/i;
const CAT_SIGNATURE_LINE_RE = /^\s*\[(?:[^[\]\n]+\/[^[\]\n]+|[^[\]\n]+🐾)\]\s*$/u;

/**
* Strip trailing cat-signature paragraphs so final-slot checks land on the last
* content paragraph. Body bracket tokens like `[Phase B]` are preserved because
* they do not match the slashed-or-paw signature shape.
*/
export function stripTrailingCatSignatures(text: string): string {
if (!text) return text;
const lines = text.split(/\r?\n/);
let lastContentIdx = lines.length - 1;
while (lastContentIdx >= 0) {
const line = lines[lastContentIdx] ?? '';
if (line.trim() === '' || CAT_SIGNATURE_LINE_RE.test(line)) {
lastContentIdx--;
continue;
}
break;
}
if (lastContentIdx < 0) return '';
return lines.slice(0, lastContentIdx + 1).join('\n');
}

function selectFinalRoutingSlot(text: string, options: { stripUrls: boolean }): string {
if (!text) return '';

const noFence = text.replace(FENCED_CODE_RE, '');

const noQuote = noFence
.split(/\r?\n/)
.filter((line) => !/^\s*>/.test(line))
.join('\n');

const slotSource = options.stripUrls ? noQuote.replace(URL_RE, '') : noQuote;

const paragraphs = slotSource
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter((p) => p.length > 0);

return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : '';
}

/**
* Extract final routing slot = structurally-stripped last non-empty paragraph.
Expand All @@ -53,23 +99,26 @@ const FENCED_CODE_RE = /```[\s\S]*?```/g;
* later (via optional param), this function's signature can be extended.
*/
export function finalRoutingSlot(text: string): string {
if (!text) return '';

const noFence = text.replace(FENCED_CODE_RE, '');

const noQuote = noFence
.split(/\r?\n/)
.filter((line) => !/^\s*>/.test(line))
.join('\n');
return selectFinalRoutingSlot(text, { stripUrls: true });
}

const noUrl = noQuote.replace(URL_RE, '');
function finalRoutingSlotPreservingUrls(text: string): string {
return selectFinalRoutingSlot(text, { stripUrls: false });
}

const paragraphs = noUrl
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter((p) => p.length > 0);
function slotHasEventDrivenExternalWaitExit(slot: string): boolean {
if (!slot) return false;
return slot.split(/\r?\n/).some((line) => EVENT_DRIVEN_EXTERNAL_WAIT_RE.test(line.trim()));
}

return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : '';
/**
* True iff the final routing slot contains the documented structural 2b external-wait exit.
*
* This is deliberately a slot-template check, not a natural-language intent classifier.
*/
export function hasEventDrivenExternalWaitExit(text: string | undefined): boolean {
if (!text) return false;
return slotHasEventDrivenExternalWaitExit(finalRoutingSlotPreservingUrls(stripTrailingCatSignatures(text)));
}

/**
Expand Down Expand Up @@ -132,6 +181,7 @@ export function findInlineMentionsInSlot(slot: string, rosterHandles: readonly s
* - legitimate line-start @mention present
* - hold_ball tool call present
* - structured MCP routing (targetCats / multi_mention targets) present
* - structural 2b external wait slot present with verified callback coverage
* - no inline @handle inside final routing slot
*
* Returns `invalid_route_syntax` when NONE of the above AND slot has inline @handle.
Expand All @@ -142,6 +192,8 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult
if (input.structuredTargetCats.length > 0) return { kind: 'ok' };

const slot = finalRoutingSlot(input.text);
if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return { kind: 'ok' };

const inlineMentions = findInlineMentionsInSlot(slot, input.rosterHandles);
if (inlineMentions.length === 0) return { kind: 'ok' };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* KD-8 safe:只看"有无机械出口信号",零意图分类器。
*/

import { hasEventDrivenExternalWaitExit } from '../final-routing-slot.js';

/** Routing-tool substrings that count as a legitimate exit (持球/群发传球). */
const ROUTING_TOOL_SUBSTRINGS = ['hold_ball', 'multi_mention'] as const;

Expand All @@ -23,6 +25,8 @@ function hasRoutingToolCall(toolNames: readonly string[]): boolean {
}

export interface RoutingExitInput {
/** Stored output text. Only the final routing slot is inspected for structural external-wait exits. */
readonly text?: string;
/** Line-start @cat mentions parsed this turn (parseA2AMentions). */
readonly lineStartMentions: readonly string[];
/** Tool names invoked this turn (scan for hold_ball / multi_mention). */
Expand All @@ -31,18 +35,22 @@ export interface RoutingExitInput {
readonly structuredTargetCats: readonly string[];
/** Line-start @co-creator / @co-creator escalation to co-creator. */
readonly hasCoCreatorLineStartMention?: boolean;
/** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */
readonly hasEventDrivenExternalWaitCoverage?: boolean;
}

/**
* True iff the turn has a legitimate routing exit (传球 / 持球 / 升级).
* Mirrors the suppression set of evaluateVoidHold + F177-G hook
* (line-start @, hold_ball, multi_mention, targetCats, co-creator).
* (line-start @, hold_ball, multi_mention, targetCats, co-creator, structural
* 2b external wait slot with verified callback coverage).
*/
export function hasValidRoutingExit(input: RoutingExitInput): boolean {
if (input.lineStartMentions.length > 0) return true;
if (input.structuredTargetCats.length > 0) return true;
if (input.hasCoCreatorLineStartMention) return true;
if (hasRoutingToolCall(input.toolNames)) return true;
if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return true;
return false;
}

Expand Down Expand Up @@ -74,6 +82,7 @@ export const REMEDIAL_PROMPT =
'请只补一个出口,不要重做刚才的工作:\n' +
'- 传球:另起一行,行首独立写 @句柄(如 @opus48)\n' +
'- 持球等外部条件:调用 cat_cafe_hold_ball\n' +
'- 事件驱动外部等待(已有结构化回调 + EYES>0):另起一行写 External Wait: event-driven (<id>)\n' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original content for event-driven remedials

When a first pass has useful text but no exit, this new remedial option asks the model to return only External Wait: event-driven (<id>); however route-serial still treats only @... remedials as route-only, so an event-driven remedial is handled as replacement content and the persisted/visible message becomes just the wait line, discarding the original work the guard was supposed to patch. Please extend the route-only normalization/preservation path for this structural template before teaching the remedial prompt to emit it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3 as part of the current-head preservation fix. Event-driven external-wait remedials are included in route-only normalization, so the first-pass content is preserved instead of replaced by the bare wait line. Focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original text for event-driven remedials

When a guarded cat first produces useful content but no route exit, this new prompt can now make the remedial call answer with only External Wait: event-driven (...). route-serial still recognizes only one-line @... remedials as route-only via normalizeRouteOnlyRemedialText, so this new exit is treated as replacement text rather than an exit patch, causing the original first-pass content to be discarded and the persisted/visible message to become just the bare external-wait line. Please either include this template in the route-only preservation path or avoid offering it as a “只补一个出口” remedial option.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3. External Wait: event-driven (...) remedials are now route-only patches: route-serial preserves the first-pass visible text, validates downstream guards with the routing patch, and no longer emits guard/syntax/void-hold hints for the preserved turn. Red/green focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

'- 升级co-creator:另起一行行首写 @co-creator';

export function buildRemedialPrompt(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ export interface RouteOptions {
* Separate from frustrationAutoIssueEligible because A2A/multi-mention callbacks
* suppress frustration issues but still need verdict-pass handoff guards. */
verdictPassWarningEnabled?: boolean | undefined;
/** Whether `External Wait: event-driven (...)` may count as a routing exit.
* Must be true only when the caller has verified callback/tracking coverage for
* the external id; text alone does not create a wake-up. */
eventDrivenExternalWaitCoverage?: boolean | undefined;
}

export interface IncrementalContextResult {
Expand Down
Loading
Loading