feat(FN-4861): merge fusion/fn-4861
This commit is contained in:
@@ -108,6 +108,8 @@ exports[`agent-heartbeat procedure templates > keeps strict no-task procedure st
|
||||
process unread/pending messages before any other action; reply with
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
@@ -144,6 +146,8 @@ exports[`agent-heartbeat procedure templates > keeps strict task procedure stabl
|
||||
process unread/pending messages before any other action; reply with
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
|
||||
@@ -188,6 +188,101 @@ describe("heartbeat room messages", () => {
|
||||
expect(sessionCapture.prompt).toContain("(10 more truncated)");
|
||||
});
|
||||
|
||||
it("adds resolved room ambiguity notice and emits resolved audit branch", async () => {
|
||||
harness = await createHarness();
|
||||
const room = harness.chatStore.createRoom({ name: "ambiguity-resolved", memberAgentIds: [harness.agentId] });
|
||||
|
||||
harness.chatStore.addRoomMessage(room.id, {
|
||||
role: "user",
|
||||
content: "we should create a follow-up task to capture the secrets-sync regression",
|
||||
});
|
||||
const deicticMessage = harness.chatStore.addRoomMessage(room.id, { role: "user", content: "Yeah create it" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("Room Ambiguity Notices:");
|
||||
expect(sessionCapture.prompt).toContain("Resolved Referent: capture the secrets-sync regression");
|
||||
expect(sessionCapture.prompt).toContain("echo this exact subject in your reply");
|
||||
|
||||
const auditEvents = harness.taskStore.getRunAuditEvents({ runId: run!.id });
|
||||
const branchEvent = auditEvents.find((event) => event.mutationType === "room:ambiguity:branch" && event.target === deicticMessage.id);
|
||||
expect(branchEvent?.metadata).toMatchObject({
|
||||
branch: "resolved",
|
||||
candidateCount: 1,
|
||||
roomId: room.id,
|
||||
agentId: harness.agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds clarification room ambiguity notice and emits clarification audit branch", async () => {
|
||||
harness = await createHarness();
|
||||
const room = harness.chatStore.createRoom({ name: "ambiguity-clarify", memberAgentIds: [harness.agentId] });
|
||||
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "we should create a task for FN-1234" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "let's add a docs task" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "could we file a flaky-test task" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "/clear" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "done" });
|
||||
const deicticMessage = harness.chatStore.addRoomMessage(room.id, { role: "user", content: "Yeah create it" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
const run = await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
expect(sessionCapture.prompt).toContain("Room Ambiguity Notices:");
|
||||
expect(sessionCapture.prompt).toContain("Do NOT create a task or spawn work");
|
||||
expect(sessionCapture.prompt).toContain(`Use reply_to_message_id = ${deicticMessage.id}`);
|
||||
expect(sessionCapture.prompt).toContain("FN-1234");
|
||||
expect(sessionCapture.prompt).toContain("docs task");
|
||||
|
||||
const auditEvents = harness.taskStore.getRunAuditEvents({ runId: run!.id });
|
||||
const branchEvent = auditEvents.find((event) => event.mutationType === "room:ambiguity:branch" && event.target === deicticMessage.id);
|
||||
expect(branchEvent?.metadata).toMatchObject({
|
||||
branch: "clarification",
|
||||
roomId: room.id,
|
||||
agentId: harness.agentId,
|
||||
});
|
||||
});
|
||||
|
||||
it("locks low-confidence contract against duplicate task creation instructions", async () => {
|
||||
harness = await createHarness();
|
||||
const room = harness.chatStore.createRoom({ name: "ambiguity-contract", memberAgentIds: [harness.agentId] });
|
||||
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "we should create a task for FN-1234" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "let's add a docs task" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "could we file a flaky-test task" });
|
||||
harness.chatStore.addRoomMessage(room.id, { role: "user", content: "Yeah create it" });
|
||||
|
||||
const monitor = new HeartbeatMonitor({
|
||||
store: harness.agentStore,
|
||||
taskStore: harness.taskStore,
|
||||
rootDir: harness.rootDir,
|
||||
chatStore: harness.chatStore,
|
||||
});
|
||||
|
||||
await monitor.executeHeartbeat({ agentId: harness.agentId, source: "timer" as any });
|
||||
|
||||
const postTool = sessionCapture.customTools.find((tool) => tool.name === "fn_post_room_message");
|
||||
const createTool = sessionCapture.customTools.find((tool) => tool.name === "fn_task_create");
|
||||
expect(postTool).toBeDefined();
|
||||
expect(createTool).toBeDefined();
|
||||
|
||||
expect(sessionCapture.prompt).toContain("Do NOT create a task or spawn work");
|
||||
expect(sessionCapture.prompt).not.toContain("Resolved Referent:");
|
||||
});
|
||||
|
||||
it("registers fn_post_room_message and posts through the real ChatStore under restrictive policy", async () => {
|
||||
harness = await createHarness({
|
||||
presetId: "approval-required",
|
||||
|
||||
151
packages/engine/src/__tests__/room-ambiguity.test.ts
Normal file
151
packages/engine/src/__tests__/room-ambiguity.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { ChatRoomMessage } from "@fusion/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectDeicticReference,
|
||||
extractAntecedentCandidates,
|
||||
renderAmbiguityPromptBlock,
|
||||
scoreReferentConfidence,
|
||||
type AntecedentCandidate,
|
||||
} from "../room-ambiguity.js";
|
||||
|
||||
function roomMessage(id: string, content: string, senderAgentId: string | null = "agent-1"): ChatRoomMessage {
|
||||
return {
|
||||
id,
|
||||
roomId: "room-1",
|
||||
role: "user",
|
||||
content,
|
||||
thinkingOutput: null,
|
||||
metadata: null,
|
||||
senderAgentId,
|
||||
mentions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("room-ambiguity", () => {
|
||||
describe("detectDeicticReference", () => {
|
||||
it.each(["Yeah create it", "sure, do that", "ok make it"])(
|
||||
"detects positive deictic confirmation: %s",
|
||||
(content) => {
|
||||
const result = detectDeicticReference(content);
|
||||
expect(result.isDeictic).toBe(true);
|
||||
expect(result.cues.length).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"create a task for FN-4861",
|
||||
"it should be a draft",
|
||||
"create it as a triage task",
|
||||
])("rejects non-deictic/grounded message: %s", (content) => {
|
||||
const result = detectDeicticReference(content);
|
||||
expect(result).toEqual({ isDeictic: false, cues: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAntecedentCandidates", () => {
|
||||
it("extracts FN ids and quoted titles from recent messages", () => {
|
||||
const candidates = extractAntecedentCandidates([
|
||||
roomMessage("m1", "Can we file FN-4861?"),
|
||||
roomMessage("m2", 'Let\'s create a task for "secrets-sync regression follow-up"'),
|
||||
]);
|
||||
|
||||
expect(candidates.map((candidate) => candidate.summary)).toEqual([
|
||||
"secrets-sync regression follow-up",
|
||||
"FN-4861",
|
||||
]);
|
||||
expect(candidates[0]?.sourceMessageId).toBe("m2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreReferentConfidence", () => {
|
||||
it("returns high for exactly one recent candidate", () => {
|
||||
const candidates: AntecedentCandidate[] = [
|
||||
{
|
||||
summary: "secrets sync regression",
|
||||
sourceMessageId: "m3",
|
||||
sourceSenderId: "agent-2",
|
||||
sourceIndexFromEnd: 2,
|
||||
},
|
||||
];
|
||||
|
||||
expect(scoreReferentConfidence(candidates)).toEqual({
|
||||
confidence: "high",
|
||||
resolved: candidates[0],
|
||||
candidates,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns low for multiple candidates", () => {
|
||||
const candidates: AntecedentCandidate[] = [
|
||||
{
|
||||
summary: "FN-1111",
|
||||
sourceMessageId: "m3",
|
||||
sourceSenderId: "agent-2",
|
||||
sourceIndexFromEnd: 1,
|
||||
},
|
||||
{
|
||||
summary: "docs task",
|
||||
sourceMessageId: "m4",
|
||||
sourceSenderId: "agent-3",
|
||||
sourceIndexFromEnd: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(scoreReferentConfidence(candidates)).toEqual({ confidence: "low", candidates });
|
||||
});
|
||||
|
||||
it("returns low for zero candidates", () => {
|
||||
expect(scoreReferentConfidence([])).toEqual({ confidence: "low", candidates: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderAmbiguityPromptBlock", () => {
|
||||
it("renders high-confidence resolved prompt block", () => {
|
||||
const lines = renderAmbiguityPromptBlock(
|
||||
{
|
||||
confidence: "high",
|
||||
resolved: {
|
||||
summary: "secrets sync regression",
|
||||
sourceMessageId: "m5",
|
||||
sourceSenderId: "agent-2",
|
||||
sourceIndexFromEnd: 0,
|
||||
},
|
||||
},
|
||||
{ id: "m6" },
|
||||
);
|
||||
|
||||
expect(lines[0]).toContain("Resolved Referent: secrets sync regression");
|
||||
expect(lines[0]).toContain("from message m5 by agent-2");
|
||||
expect(lines[0]).toContain("echo this exact subject");
|
||||
});
|
||||
|
||||
it("renders low-confidence clarification prompt block with options", () => {
|
||||
const lines = renderAmbiguityPromptBlock(
|
||||
{
|
||||
confidence: "low",
|
||||
candidates: [
|
||||
{
|
||||
summary: "FN-1234",
|
||||
sourceMessageId: "m1",
|
||||
sourceSenderId: "agent-1",
|
||||
sourceIndexFromEnd: 1,
|
||||
},
|
||||
{
|
||||
summary: "docs task",
|
||||
sourceMessageId: "m2",
|
||||
sourceSenderId: null,
|
||||
sourceIndexFromEnd: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "m9" },
|
||||
);
|
||||
|
||||
expect(lines[0]).toContain("Do NOT create a task or spawn work");
|
||||
expect(lines[1]).toContain("1. FN-1234");
|
||||
expect(lines[2]).toContain("2. docs task");
|
||||
expect(lines[3]).toContain("Use reply_to_message_id = m9");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ import type { AgentActionGateContext } from "./agent-action-gate.js";
|
||||
import { buildSessionSkillContextSync } from "./session-skill-context.js";
|
||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||
import { trimPromptMd, trimTaskDescription, trimTriggeringComments } from "./heartbeat-prompt-trim.js";
|
||||
import { detectDeicticReference, extractAntecedentCandidates, renderAmbiguityPromptBlock, scoreReferentConfidence } from "./room-ambiguity.js";
|
||||
|
||||
const promptSizeLog = createLogger("prompt-size");
|
||||
|
||||
@@ -390,6 +391,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
|
||||
- If ownership is clear and an agent is available, delegate using fn_delegate_task.
|
||||
4. If a Pending Room Messages section is present, review it too:
|
||||
- Use fn_post_room_message only when the room content is relevant to your role, soul, or identity.
|
||||
- If a Room Ambiguity Notices section is present, follow it exactly: echo resolved referents before acting, and under clarification notices do not create tasks.
|
||||
- Reference room message IDs when replying so humans can trace context.
|
||||
5. After processing messages, continue with your normal heartbeat duties.
|
||||
|
||||
@@ -472,7 +474,7 @@ When you are woken by an incoming message (source includes "wake-on-message"), y
|
||||
- If the message is informational, acknowledge it and respond via fn_send_message when appropriate.
|
||||
- If the message requests work, create a follow-up task with fn_task_create.
|
||||
- If the request has a clear owner and fn_delegate_task is available, delegate it directly.
|
||||
3. If a Pending Room Messages section is present, review it too and use fn_post_room_message only when the room content is relevant to your role or identity.
|
||||
3. If a Pending Room Messages section is present, review it too and use fn_post_room_message only when the room content is relevant to your role or identity; if Room Ambiguity Notices are present, follow their resolve/clarify branch instructions exactly.
|
||||
4. After processing messages, continue with your ambient work.
|
||||
|
||||
Example flow:
|
||||
@@ -503,6 +505,8 @@ export const HEARTBEAT_PROCEDURE_STRICT = `## Heartbeat Procedure (run every tic
|
||||
process unread/pending messages before any other action; reply with
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
@@ -601,6 +605,8 @@ export const HEARTBEAT_NO_TASK_PROCEDURE_STRICT = `## Heartbeat Procedure (run e
|
||||
process unread/pending messages before any other action; reply with
|
||||
reply_to_message_id when answering. If Pending Room Messages are present,
|
||||
review them in the prompt and use fn_post_room_message only when relevant.
|
||||
When Room Ambiguity Notices appear, follow the resolve/clarify branch and do
|
||||
not create tasks under clarification notices.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
@@ -872,6 +878,66 @@ export class HeartbeatMonitor {
|
||||
return lines;
|
||||
}
|
||||
|
||||
private async getRoomAmbiguityNoticesSection(
|
||||
agent: Agent,
|
||||
runId: string,
|
||||
entries: Array<{ room: ChatRoom; messages: ChatRoomMessage[] }>,
|
||||
audit: ReturnType<typeof createRunAuditor>,
|
||||
): Promise<string[]> {
|
||||
if (!this.chatStore || entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const message of entry.messages) {
|
||||
const detection = detectDeicticReference(message.content);
|
||||
if (!detection.isDeictic) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const roomTimeline = this.chatStore.getRoomMessages(entry.room.id, { limit: 100 });
|
||||
const messageIndex = roomTimeline.findIndex((roomMessage) => roomMessage.id === message.id);
|
||||
if (messageIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
const recentMessages = roomTimeline.slice(Math.max(0, messageIndex - 15), messageIndex);
|
||||
const candidates = extractAntecedentCandidates(recentMessages);
|
||||
const decision = scoreReferentConfidence(candidates);
|
||||
const branch = decision.confidence === "high" ? "resolved" : "clarification";
|
||||
const promptBlock = renderAmbiguityPromptBlock({ ...decision, candidates }, message);
|
||||
|
||||
if (lines.length === 0) {
|
||||
lines.push("", "Room Ambiguity Notices:");
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`- [room: ${entry.room.name} (${entry.room.id})] [message: ${message.id}] [branch: ${branch}]`,
|
||||
...promptBlock.map((line) => ` - ${line}`),
|
||||
);
|
||||
|
||||
await audit.database({
|
||||
type: "room:ambiguity:branch",
|
||||
target: message.id,
|
||||
metadata: {
|
||||
roomId: entry.room.id,
|
||||
agentId: agent.id,
|
||||
branch,
|
||||
candidateCount: candidates.length,
|
||||
cues: detection.cues,
|
||||
},
|
||||
});
|
||||
|
||||
heartbeatLog.log(
|
||||
`[room-ambiguity] agent=${agent.id} run=${runId} room=${entry.room.id} messageId=${message.id} branch=${branch} candidates=${candidates.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private async getPendingRoomMessages(agent: Agent, sinceIso: string): Promise<{
|
||||
entries: Array<{ room: ChatRoom; messages: ChatRoomMessage[] }>;
|
||||
total: number;
|
||||
@@ -2400,6 +2466,12 @@ export class HeartbeatMonitor {
|
||||
pendingRoomMessages.entries,
|
||||
pendingRoomMessages.truncatedCount,
|
||||
);
|
||||
const roomAmbiguityNoticesLines = await this.getRoomAmbiguityNoticesSection(
|
||||
agent,
|
||||
run.id,
|
||||
pendingRoomMessages.entries,
|
||||
audit,
|
||||
);
|
||||
|
||||
// Fetch unread messages when messageStore is available (for all trigger types)
|
||||
if (this.messageStore) {
|
||||
@@ -2585,6 +2657,7 @@ export class HeartbeatMonitor {
|
||||
...candidateLines,
|
||||
...pendingMessagesLines,
|
||||
...pendingRoomMessagesLines,
|
||||
...roomAmbiguityNoticesLines,
|
||||
"",
|
||||
"Your soul, instructions, and memory are already loaded in the system prompt.",
|
||||
"Focus on work that benefits the project without requiring a specific task context.",
|
||||
@@ -2672,6 +2745,7 @@ export class HeartbeatMonitor {
|
||||
...trimTriggeringComments(triggeringCommentLines, promptTemplate),
|
||||
...pendingMessagesLines,
|
||||
...pendingRoomMessagesLines,
|
||||
...roomAmbiguityNoticesLines,
|
||||
...(reportsHealthSection ? ["", reportsHealthSection] : []),
|
||||
"",
|
||||
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.",
|
||||
|
||||
239
packages/engine/src/room-ambiguity.ts
Normal file
239
packages/engine/src/room-ambiguity.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import type { ChatRoomMessage } from "@fusion/core";
|
||||
|
||||
export interface DeicticDetectionResult {
|
||||
isDeictic: boolean;
|
||||
cues: string[];
|
||||
}
|
||||
|
||||
export interface AntecedentCandidate {
|
||||
summary: string;
|
||||
sourceMessageId: string;
|
||||
sourceSenderId: string | null;
|
||||
sourceIndexFromEnd: number;
|
||||
}
|
||||
|
||||
export interface ExtractAntecedentOptions {
|
||||
maxCandidates?: number;
|
||||
lookbackChars?: number;
|
||||
}
|
||||
|
||||
export interface ReferentConfidenceDecision {
|
||||
confidence: "high" | "low";
|
||||
resolved?: AntecedentCandidate;
|
||||
candidates?: AntecedentCandidate[];
|
||||
}
|
||||
|
||||
const CONFIRMATION_PATTERNS: ReadonlyArray<[string, RegExp]> = [
|
||||
["yes", /\byes\b/i],
|
||||
["yeah", /\byeah\b/i],
|
||||
["yep", /\byep\b/i],
|
||||
["sure", /\bsure\b/i],
|
||||
["ok", /\bok\b/i],
|
||||
["okay", /\bokay\b/i],
|
||||
["do it", /\bdo\s+it\b/i],
|
||||
["go ahead", /\bgo\s+ahead\b/i],
|
||||
["please", /\bplease\b/i],
|
||||
];
|
||||
|
||||
const DEICTIC_PATTERNS: ReadonlyArray<[string, RegExp]> = [
|
||||
["it", /\bit\b/i],
|
||||
["that", /\bthat\b/i],
|
||||
["this", /\bthis\b/i],
|
||||
["that one", /\bthat\s+one\b/i],
|
||||
["the one", /\bthe\s+one\b/i],
|
||||
];
|
||||
|
||||
const DEICTIC_IMPERATIVE_PATTERNS: ReadonlyArray<[string, RegExp]> = [
|
||||
["create it", /\bcreate\s+it\b/i],
|
||||
["make it", /\bmake\s+it\b/i],
|
||||
["do that", /\bdo\s+that\b/i],
|
||||
["start that", /\bstart\s+that\b/i],
|
||||
["add it", /\badd\s+it\b/i],
|
||||
["file it", /\bfile\s+it\b/i],
|
||||
];
|
||||
|
||||
const PROPOSAL_SUMMARY_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/\b(?:we should|let'?s|lets|could we|please|can we)\s+(?:create|add|file|start|open|track)\s+(.+)/i,
|
||||
/\b(?:create|add|file|start|open|track)\s+(.+)/i,
|
||||
];
|
||||
|
||||
const QUOTED_TITLE_PATTERN = /"([^"]{3,120})"|'([^']{3,120})'/g;
|
||||
const TASK_ID_PATTERN = /\bFN-\d{1,6}\b/gi;
|
||||
const DEICTIC_NOUN_FOLLOWUP_PATTERN = /\b(?:it|that|this)\s+(?:as|for|to)\b/i;
|
||||
|
||||
const DEFAULT_MAX_CANDIDATES = 3;
|
||||
const DEFAULT_LOOKBACK_CHARS = 1200;
|
||||
const MAX_MESSAGE_WINDOW = 15;
|
||||
|
||||
function normalizeMessageContent(content: string): string {
|
||||
return content.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeSummary(summary: string): string {
|
||||
return summary
|
||||
.replace(/["'`]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function truncateSummary(summary: string, max = 140): string {
|
||||
if (summary.length <= max) {
|
||||
return summary;
|
||||
}
|
||||
return `${summary.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function cleanCandidateText(value: string): string {
|
||||
return value
|
||||
.replace(/^["']+|["']+$/g, "")
|
||||
.replace(/^(?:a|an)\s+/i, "")
|
||||
.replace(/^(?:follow[-\s]?up|docs?|documentation|flaky[-\s]?test)\s+task\s+(?:for|to)\s+/i, "")
|
||||
.replace(/^task\s+(?:for|to)\s+/i, "")
|
||||
.replace(/[.?!,;:]+$/g, "")
|
||||
.replace(/^["']+|["']+$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function detectDeicticReference(content: string): DeicticDetectionResult {
|
||||
const normalized = normalizeMessageContent(content);
|
||||
if (!normalized || normalized.length > 200) {
|
||||
return { isDeictic: false, cues: [] };
|
||||
}
|
||||
|
||||
if (DEICTIC_NOUN_FOLLOWUP_PATTERN.test(normalized)) {
|
||||
return { isDeictic: false, cues: [] };
|
||||
}
|
||||
|
||||
const cues = new Set<string>();
|
||||
|
||||
for (const [cue, pattern] of CONFIRMATION_PATTERNS) {
|
||||
if (pattern.test(normalized)) {
|
||||
cues.add(cue);
|
||||
}
|
||||
}
|
||||
|
||||
let hasDeictic = false;
|
||||
for (const [cue, pattern] of DEICTIC_PATTERNS) {
|
||||
if (pattern.test(normalized)) {
|
||||
hasDeictic = true;
|
||||
cues.add(cue);
|
||||
}
|
||||
}
|
||||
|
||||
let hasImperative = false;
|
||||
for (const [cue, pattern] of DEICTIC_IMPERATIVE_PATTERNS) {
|
||||
if (pattern.test(normalized)) {
|
||||
hasImperative = true;
|
||||
cues.add(cue);
|
||||
}
|
||||
}
|
||||
|
||||
const hasConfirmation = CONFIRMATION_PATTERNS.some(([, pattern]) => pattern.test(normalized));
|
||||
const isDeictic = hasImperative || (hasConfirmation && hasDeictic);
|
||||
|
||||
return {
|
||||
isDeictic,
|
||||
cues: isDeictic ? Array.from(cues) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function collectSummariesFromMessage(content: string): string[] {
|
||||
const summaries: string[] = [];
|
||||
const normalized = normalizeMessageContent(content);
|
||||
|
||||
for (const match of normalized.matchAll(TASK_ID_PATTERN)) {
|
||||
summaries.push(match[0].toUpperCase());
|
||||
}
|
||||
|
||||
for (const pattern of PROPOSAL_SUMMARY_PATTERNS) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match?.[1]) {
|
||||
summaries.push(cleanCandidateText(match[1]));
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of normalized.matchAll(QUOTED_TITLE_PATTERN)) {
|
||||
const value = match[1] ?? match[2];
|
||||
if (value) {
|
||||
summaries.push(cleanCandidateText(value));
|
||||
}
|
||||
}
|
||||
|
||||
return summaries.map((summary) => truncateSummary(summary)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function extractAntecedentCandidates(
|
||||
recentMessages: ChatRoomMessage[],
|
||||
opts: ExtractAntecedentOptions = {},
|
||||
): AntecedentCandidate[] {
|
||||
const maxCandidates = opts.maxCandidates ?? DEFAULT_MAX_CANDIDATES;
|
||||
const lookbackChars = opts.lookbackChars ?? DEFAULT_LOOKBACK_CHARS;
|
||||
|
||||
const deduped = new Map<string, AntecedentCandidate>();
|
||||
const messageWindow = recentMessages.slice(-MAX_MESSAGE_WINDOW);
|
||||
let charsScanned = 0;
|
||||
|
||||
for (let idx = messageWindow.length - 1; idx >= 0; idx -= 1) {
|
||||
const message = messageWindow[idx];
|
||||
charsScanned += message.content.length;
|
||||
const summaries = collectSummariesFromMessage(message.content);
|
||||
const sourceIndexFromEnd = messageWindow.length - 1 - idx;
|
||||
|
||||
for (const summary of summaries) {
|
||||
const normalizedSummary = normalizeSummary(summary);
|
||||
if (!normalizedSummary || deduped.has(normalizedSummary)) {
|
||||
continue;
|
||||
}
|
||||
deduped.set(normalizedSummary, {
|
||||
summary,
|
||||
sourceMessageId: message.id,
|
||||
sourceSenderId: message.senderAgentId,
|
||||
sourceIndexFromEnd,
|
||||
});
|
||||
}
|
||||
|
||||
if (deduped.size >= maxCandidates || charsScanned >= lookbackChars) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(deduped.values()).slice(0, maxCandidates);
|
||||
}
|
||||
|
||||
export function scoreReferentConfidence(candidates: AntecedentCandidate[]): ReferentConfidenceDecision {
|
||||
if (candidates.length !== 1) {
|
||||
return { confidence: "low", candidates };
|
||||
}
|
||||
|
||||
const resolved = candidates[0];
|
||||
if (resolved.sourceIndexFromEnd <= 4) {
|
||||
return { confidence: "high", resolved, candidates };
|
||||
}
|
||||
|
||||
return { confidence: "low", candidates };
|
||||
}
|
||||
|
||||
export function renderAmbiguityPromptBlock(
|
||||
decision: ReferentConfidenceDecision,
|
||||
deicticMessage: Pick<ChatRoomMessage, "id">,
|
||||
): string[] {
|
||||
if (decision.confidence === "high" && decision.resolved) {
|
||||
return [
|
||||
`Resolved Referent: ${decision.resolved.summary} (from message ${decision.resolved.sourceMessageId} by ${decision.resolved.sourceSenderId ?? "unknown"}). Before calling fn_task_create or fn_post_room_message, echo this exact subject in your reply so a human can correct it.`,
|
||||
];
|
||||
}
|
||||
|
||||
const lowConfidenceLines = [
|
||||
"Do NOT create a task or spawn work. Reply once with fn_post_room_message asking which referent applies, and include the inferred options below.",
|
||||
];
|
||||
|
||||
for (const [index, candidate] of (decision.candidates ?? []).slice(0, 3).entries()) {
|
||||
lowConfidenceLines.push(
|
||||
`${index + 1}. ${candidate.summary} (from message ${candidate.sourceMessageId} by ${candidate.sourceSenderId ?? "unknown"})`,
|
||||
);
|
||||
}
|
||||
|
||||
lowConfidenceLines.push(`Use reply_to_message_id = ${deicticMessage.id}.`);
|
||||
return lowConfidenceLines;
|
||||
}
|
||||
@@ -186,7 +186,8 @@ export type DatabaseMutationType =
|
||||
| "agent:create:denied"
|
||||
| "agent:delete:requested"
|
||||
| "agent:delete:approved"
|
||||
| "agent:delete:denied";
|
||||
| "agent:delete:denied"
|
||||
| "room:ambiguity:branch";
|
||||
|
||||
// ── Filesystem mutation types ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user