Skip to content

Commit b2d62be

Browse files
committed
fix(core): route local identity lookups through planner
1 parent 847f6f6 commit b2d62be

2 files changed

Lines changed: 277 additions & 0 deletions

File tree

packages/core/src/__tests__/message-runtime-stage1.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,120 @@ describe("runV5MessageRuntimeStage1", () => {
411411
}
412412
});
413413

414+
it("routes short chat-entity identity lookups through recall instead of simple guessing", async () => {
415+
const runtime = makeRuntime([
416+
JSON.stringify({
417+
processMessage: "RESPOND",
418+
plan: {
419+
contexts: ["simple"],
420+
reply: "Queuebot is just the same assistant you've seen before.",
421+
simple: true,
422+
requiresTool: false,
423+
},
424+
extract: {
425+
facts: [],
426+
relationships: [],
427+
addressedTo: [],
428+
},
429+
}),
430+
JSON.stringify({
431+
thought: "Identity lookup should be grounded in recalled chat context.",
432+
toolCalls: [],
433+
messageToUser:
434+
"I don't have enough recalled chat context to identify queuebot.",
435+
}),
436+
]);
437+
runtime.actions = [
438+
{
439+
name: "MESSAGE",
440+
contexts: ["messaging", "memory"],
441+
description: "Search and inspect stored conversation messages.",
442+
validate: async () => true,
443+
handler: async () => ({ success: true }),
444+
},
445+
] as never;
446+
runtime.composeState = vi.fn(async () => ({
447+
values: { availableContexts: "simple, general, memory, messaging" },
448+
data: {
449+
providers: {
450+
RECENT_MESSAGES: {
451+
text: "# Conversation Messages\nrecent provider text",
452+
providerName: "RECENT_MESSAGES",
453+
},
454+
},
455+
},
456+
text: "",
457+
})) as never;
458+
459+
const result = await runV5MessageRuntimeStage1({
460+
runtime,
461+
message: makeMessage({
462+
text: [
463+
"[Recent channel context]",
464+
"e2e: queuebot came up earlier in the channel.",
465+
"",
466+
"[Discord #general | NUBot test server] @e2e (Sat 05/23/2026 10:25 UTC): assistant (@1490833425802854491) who is queuebot?",
467+
].join("\n"),
468+
source: "discord",
469+
}),
470+
state: {
471+
values: { availableContexts: "simple, general, memory, messaging" },
472+
data: {},
473+
text: "",
474+
},
475+
responseId: "00000000-0000-0000-0000-000000000005" as UUID,
476+
});
477+
478+
expect(result.kind).toBe("planned_reply");
479+
const plannerCall = useModelCalls(runtime)[1]?.[1] as {
480+
messages?: Array<{ role?: string; content?: string | null }>;
481+
};
482+
const plannerUserContent = plannerCall.messages?.[1]?.content ?? "";
483+
expect(plannerUserContent).toContain("identity_lookup_policy");
484+
expect(plannerUserContent).toContain('"candidateActions":["MESSAGE"]');
485+
expect(plannerUserContent).toContain("routing through recall context");
486+
expect(plannerUserContent).not.toContain(
487+
"Queuebot is just the same assistant",
488+
);
489+
if (result.kind === "planned_reply") {
490+
expect(result.result.responseContent?.text).toBe(
491+
"I don't have enough recalled chat context to identify queuebot.",
492+
);
493+
}
494+
});
495+
496+
it("does not reroute ordinary public identity questions through recall", async () => {
497+
const runtime = makeRuntime([
498+
stage1Response({
499+
contexts: ["simple"],
500+
replyText: "Barack Obama is a former U.S. president.",
501+
extra: { requiresTool: false },
502+
}),
503+
]);
504+
505+
const result = await runV5MessageRuntimeStage1({
506+
runtime,
507+
message: makeMessage({
508+
text: "assistant (@1490833425802854491) who is obama?",
509+
source: "discord",
510+
}),
511+
state: {
512+
values: { availableContexts: "simple, general, memory, messaging" },
513+
data: {},
514+
text: "",
515+
},
516+
responseId: "00000000-0000-0000-0000-000000000005" as UUID,
517+
});
518+
519+
expect(result.kind).toBe("direct_reply");
520+
if (result.kind === "direct_reply") {
521+
expect(result.result.responseContent?.text).toBe(
522+
"Barack Obama is a former U.S. president.",
523+
);
524+
}
525+
expect(useModelCalls(runtime)).toHaveLength(1);
526+
});
527+
414528
it("does not treat the agent's own attachment ack as a user follow-up anchor", async () => {
415529
const runtime = makeRuntime([
416530
stage1Response({

packages/core/src/services/message.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2724,6 +2724,44 @@ const BUILTIN_RESPONSE_HANDLER_EVALUATORS: readonly ResponseHandlerEvaluator[] =
27242724
],
27252725
}),
27262726
},
2727+
{
2728+
name: "core.contextual_identity_lookup_requires_recall",
2729+
description:
2730+
"Routes short chat-entity identity lookups through memory/messaging instead of letting Stage 1 guess from the simple shortcut.",
2731+
priority: 20,
2732+
shouldRun: ({ message, messageHandler }) =>
2733+
!isSubAgentCompletionArtifact(message) &&
2734+
isSimpleMessageHandlerShortcut(messageHandler) &&
2735+
extractContextualIdentityLookupSubject(
2736+
getUserMessageText(message) ?? "",
2737+
) !== null,
2738+
evaluate: ({ runtime, message }) => {
2739+
const subject = extractContextualIdentityLookupSubject(
2740+
getUserMessageText(message) ?? "",
2741+
);
2742+
const messageAction = findRuntimeActionByNames(runtime, ["MESSAGE"]);
2743+
return {
2744+
setContexts: ["general", "memory", "messaging"],
2745+
...(messageAction
2746+
? {
2747+
addCandidateActions: [messageAction.name],
2748+
}
2749+
: {}),
2750+
clearReply: true,
2751+
addContextSlices: [
2752+
[
2753+
"identity_lookup_policy:",
2754+
`The current user is asking who/what "${subject ?? "the named subject"}" is in context.`,
2755+
"Ground the answer in recent messages, memory, or message-search results before identifying the subject.",
2756+
"If recalled context does not identify a chat-local subject, say that plainly instead of inventing a relationship; only use public knowledge when the subject is clearly a public entity.",
2757+
].join("\n"),
2758+
],
2759+
debug: [
2760+
`short identity lookup for "${subject ?? "unknown"}"; routing through recall context before answering`,
2761+
],
2762+
};
2763+
},
2764+
},
27272765
];
27282766

27292767
/**
@@ -3490,6 +3528,122 @@ function looksLikeCompleteDirectReply(replyText: string): boolean {
34903528
);
34913529
}
34923530

3531+
function isSimpleMessageHandlerShortcut(
3532+
messageHandler: MessageHandlerResult,
3533+
): boolean {
3534+
if (messageHandler.processMessage !== "RESPOND") return false;
3535+
if (messageHandler.plan.requiresTool === true) return false;
3536+
const contexts = messageHandler.plan.contexts ?? [];
3537+
const nonSimpleContexts = contexts.filter(
3538+
(context) => context !== SIMPLE_CONTEXT_ID,
3539+
);
3540+
return (
3541+
nonSimpleContexts.length === 0 &&
3542+
(messageHandler.plan.candidateActions?.length ?? 0) === 0
3543+
);
3544+
}
3545+
3546+
const CONTEXTUAL_IDENTITY_LOOKUP_PREFIXES = [
3547+
"who is ",
3548+
"who are ",
3549+
"who was ",
3550+
"who were ",
3551+
"who's ",
3552+
"whos ",
3553+
"what is ",
3554+
"what was ",
3555+
"what's ",
3556+
"whats ",
3557+
"tell me about ",
3558+
"do you know ",
3559+
"do you remember ",
3560+
"what do you know about ",
3561+
] as const;
3562+
3563+
const CONTEXTUAL_IDENTITY_PRONOUN_SUBJECTS = new Set([
3564+
"he",
3565+
"her",
3566+
"him",
3567+
"i",
3568+
"it",
3569+
"me",
3570+
"she",
3571+
"that",
3572+
"them",
3573+
"they",
3574+
"this",
3575+
"us",
3576+
"we",
3577+
"you",
3578+
]);
3579+
3580+
function removeLeadingPlatformAddress(text: string): string {
3581+
let cleaned = text
3582+
.replace(/<@!?\d+>/gu, " ")
3583+
.replace(/\s+/gu, " ")
3584+
.trim();
3585+
const displayAddressRe = /(?:^|[\s:])[^:\n]{0,96}\(@\d+\)\s+/gu;
3586+
let displayAddressEnd = -1;
3587+
for (
3588+
let displayAddressMatch = displayAddressRe.exec(cleaned);
3589+
displayAddressMatch !== null;
3590+
displayAddressMatch = displayAddressRe.exec(cleaned)
3591+
) {
3592+
displayAddressEnd = displayAddressRe.lastIndex;
3593+
}
3594+
if (displayAddressEnd > 0) {
3595+
cleaned = cleaned.slice(displayAddressEnd).trim();
3596+
}
3597+
return cleaned;
3598+
}
3599+
3600+
function cleanContextualLookupSubject(subject: string): string {
3601+
return subject
3602+
.replace(/[\s?.!]+$/gu, "")
3603+
.replace(/^["'`]+|["'`]+$/gu, "")
3604+
.replace(
3605+
/\s+(?:in|from|on)\s+(?:this\s+)?(?:chat|channel|thread|server)$/iu,
3606+
"",
3607+
)
3608+
.trim();
3609+
}
3610+
3611+
function isShortContextualLookupSubject(subject: string): boolean {
3612+
const normalized = subject.toLowerCase();
3613+
if (!normalized || CONTEXTUAL_IDENTITY_PRONOUN_SUBJECTS.has(normalized)) {
3614+
return false;
3615+
}
3616+
const words = normalized.split(/\s+/u).filter(Boolean);
3617+
if (words.length === 0 || words.length > 3) return false;
3618+
if (subject.length > 48) return false;
3619+
if (/^[0-9\s.,:+*/=()-]+$/u.test(subject)) return false;
3620+
const looksLikeLocalName =
3621+
/[@_\-0-9]/u.test(subject) ||
3622+
/\b(?:bot|agent|ai)\b/iu.test(subject) ||
3623+
/^[a-z][a-z0-9_-]{6,}$/u.test(subject);
3624+
return looksLikeLocalName;
3625+
}
3626+
3627+
function lastNonEmptyLine(text: string): string {
3628+
const lines = text.replace(/\r\n/g, "\n").split("\n");
3629+
for (let index = lines.length - 1; index >= 0; index -= 1) {
3630+
const line = lines[index]?.trim();
3631+
if (line) return line;
3632+
}
3633+
return text.trim();
3634+
}
3635+
3636+
function extractContextualIdentityLookupSubject(text: string): string | null {
3637+
const cleaned = removeLeadingPlatformAddress(lastNonEmptyLine(text));
3638+
const lower = cleaned.toLowerCase();
3639+
for (const prefix of CONTEXTUAL_IDENTITY_LOOKUP_PREFIXES) {
3640+
if (!lower.startsWith(prefix)) continue;
3641+
const subject = cleanContextualLookupSubject(cleaned.slice(prefix.length));
3642+
return isShortContextualLookupSubject(subject) ? subject : null;
3643+
}
3644+
return null;
3645+
}
3646+
34933647
function shouldPreferCompleteDirectReply(args: {
34943648
replyText: string;
34953649
currentMessageText: string;
@@ -5168,11 +5322,17 @@ export async function runV5MessageRuntimeStage1(args: {
51685322
preselectedActions: exposedPlannerActions,
51695323
actionSurface,
51705324
});
5325+
const responseHandlerContextSlices = stringArrayProperty(
5326+
(messageHandler.plan as { contextSlices?: unknown }).contextSlices,
5327+
);
51715328
const plannerContextWithDecision = appendContextEvent(plannerContext, {
51725329
id: `message-handler:${messageHandlerEndedAt}`,
51735330
type: "message_handler",
51745331
source: "message-service",
51755332
createdAt: messageHandlerEndedAt,
5333+
...(responseHandlerContextSlices.length > 0
5334+
? { content: responseHandlerContextSlices.join("\n\n") }
5335+
: {}),
51765336
metadata: {
51775337
processMessage: messageHandler.processMessage,
51785338
plan: {
@@ -5182,6 +5342,9 @@ export async function runV5MessageRuntimeStage1(args: {
51825342
: {}),
51835343
candidateActions: getMessageHandlerCandidateActions(messageHandler),
51845344
parentActionHints: getMessageHandlerParentActionHints(messageHandler),
5345+
...(responseHandlerContextSlices.length > 0
5346+
? { contextSlices: responseHandlerContextSlices }
5347+
: {}),
51855348
...(messageHandler.plan.reply !== undefined
51865349
? { reply: messageHandler.plan.reply }
51875350
: {}),

0 commit comments

Comments
 (0)