feat(FN-4861): merge fusion/fn-4861

This commit is contained in:
gsxdsm
2026-05-17 02:08:01 -07:00
parent 2f2198a622
commit ee4652a492
8 changed files with 573 additions and 2 deletions

View File

@@ -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.

View File

@@ -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",

View 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");
});
});
});

View File

@@ -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.",

View 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;
}

View File

@@ -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 ─────────────────────────────────────────────────