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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Heartbeat agents now detect deictic follow-ups ("create it", "yeah do that") in room threads and either echo the resolved referent before acting or post a single structured clarification reply with inferred options. A new `room:ambiguity:branch` run-audit event records which branch was taken for future tuning.

View File

@@ -544,6 +544,7 @@ When debugging agent execution issues (agents stuck on "starting"), check these
14. **`[retry-burned] retry-burned { taskId, agentId, role, category, attempt, total, breakdown }`** — Unified retry-burn telemetry and retry-cap circuit-breaker context 14. **`[retry-burned] retry-burned { taskId, agentId, role, category, attempt, total, breakdown }`** — Unified retry-burn telemetry and retry-cap circuit-breaker context
15. **`Worktree init command failed (first test run will likely fail): ...` task log entries** — FN-4834: when `worktreeInitCommand` fails, diagnostic stderr is now written to the entry `outcome` (stdout/spawnError fallback), so dashboard logs preserve init failure details without rerunning. 15. **`Worktree init command failed (first test run will likely fail): ...` task log entries** — FN-4834: when `worktreeInitCommand` fails, diagnostic stderr is now written to the entry `outcome` (stdout/spawnError fallback), so dashboard logs preserve init failure details without rerunning.
16. **`[executor] FN-XXX: fn_task_done refused (<class>) — <reason>`** — FN-4851 refusal diagnostics for `summary-claims-incomplete`, `bulk-step-completion-without-review`, and `pending-code-review-revise`; all three consume the shared `taskDoneRetryCount` budget and escalate to `in-review` once retries are exhausted. 16. **`[executor] FN-XXX: fn_task_done refused (<class>) — <reason>`** — FN-4851 refusal diagnostics for `summary-claims-incomplete`, `bulk-step-completion-without-review`, and `pending-code-review-revise`; all three consume the shared `taskDoneRetryCount` budget and escalate to `in-review` once retries are exhausted.
17. **`[room-ambiguity] agent=<id> run=<id> room=<id> messageId=<id> branch=<resolved|clarification> candidates=<n>`** — Heartbeat room-thread ambiguity classifier trace for deictic follow-ups, including the deterministic branch and candidate count.
### Semaphore Resilience ### Semaphore Resilience
@@ -667,6 +668,7 @@ The run-audit system records every mutation performed by the engine across four
- **Git** — worktree:create, commit:create, merge:resolve, etc. - **Git** — worktree:create, commit:create, merge:resolve, etc.
- **Filesystem** — file:write, prompt:write, attachment:create, secret:env-write, secret:env-write-skipped, secret:env-cleanup, secret:env-cleanup-skipped, etc. - **Filesystem** — file:write, prompt:write, attachment:create, secret:env-write, secret:env-write-skipped, secret:env-cleanup, secret:env-cleanup-skipped, etc.
- **Sandbox** — backend lifecycle (`sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`) from `SandboxBackend` wiring in executor/merger/routine-runner. - **Sandbox** — backend lifecycle (`sandbox:prepare`, `sandbox:run`, `sandbox:failure`, `sandbox:fallback`) from `SandboxBackend` wiring in executor/merger/routine-runner.
- **Database (`room:ambiguity:branch`)** — per-deictic room-message telemetry tagging ambiguity branch selection (`resolved` vs `clarification`) with room/message IDs, candidate count, and cue metadata (no plaintext content).
Events are tied to specific run IDs for end-to-end traceability. See [docs/architecture.md](./docs/architecture.md) for the audit API reference. Events are tied to specific run IDs for end-to-end traceability. See [docs/architecture.md](./docs/architecture.md) for the audit API reference.

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 process unread/pending messages before any other action; reply with
reply_to_message_id when answering. If Pending Room Messages are present, 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. 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 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 highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else. 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 process unread/pending messages before any other action; reply with
reply_to_message_id when answering. If Pending Room Messages are present, 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. 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 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 highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else. 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)"); 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 () => { it("registers fn_post_room_message and posts through the real ChatStore under restrictive policy", async () => {
harness = await createHarness({ harness = await createHarness({
presetId: "approval-required", 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 { buildSessionSkillContextSync } from "./session-skill-context.js";
import type { AgentReflectionService } from "./agent-reflection.js"; import type { AgentReflectionService } from "./agent-reflection.js";
import { trimPromptMd, trimTaskDescription, trimTriggeringComments } from "./heartbeat-prompt-trim.js"; import { trimPromptMd, trimTaskDescription, trimTriggeringComments } from "./heartbeat-prompt-trim.js";
import { detectDeicticReference, extractAntecedentCandidates, renderAmbiguityPromptBlock, scoreReferentConfidence } from "./room-ambiguity.js";
const promptSizeLog = createLogger("prompt-size"); 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. - 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: 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. - 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. - Reference room message IDs when replying so humans can trace context.
5. After processing messages, continue with your normal heartbeat duties. 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 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 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. - 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. 4. After processing messages, continue with your ambient work.
Example flow: 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 process unread/pending messages before any other action; reply with
reply_to_message_id when answering. If Pending Room Messages are present, 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. 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 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 highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else. 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 process unread/pending messages before any other action; reply with
reply_to_message_id when answering. If Pending Room Messages are present, 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. 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 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 highest-priority change for this heartbeat. If you were woken by a comment
or a message, acknowledge it before doing anything else. or a message, acknowledge it before doing anything else.
@@ -872,6 +878,66 @@ export class HeartbeatMonitor {
return lines; 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<{ private async getPendingRoomMessages(agent: Agent, sinceIso: string): Promise<{
entries: Array<{ room: ChatRoom; messages: ChatRoomMessage[] }>; entries: Array<{ room: ChatRoom; messages: ChatRoomMessage[] }>;
total: number; total: number;
@@ -2400,6 +2466,12 @@ export class HeartbeatMonitor {
pendingRoomMessages.entries, pendingRoomMessages.entries,
pendingRoomMessages.truncatedCount, pendingRoomMessages.truncatedCount,
); );
const roomAmbiguityNoticesLines = await this.getRoomAmbiguityNoticesSection(
agent,
run.id,
pendingRoomMessages.entries,
audit,
);
// Fetch unread messages when messageStore is available (for all trigger types) // Fetch unread messages when messageStore is available (for all trigger types)
if (this.messageStore) { if (this.messageStore) {
@@ -2585,6 +2657,7 @@ export class HeartbeatMonitor {
...candidateLines, ...candidateLines,
...pendingMessagesLines, ...pendingMessagesLines,
...pendingRoomMessagesLines, ...pendingRoomMessagesLines,
...roomAmbiguityNoticesLines,
"", "",
"Your soul, instructions, and memory are already loaded in the system prompt.", "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.", "Focus on work that benefits the project without requiring a specific task context.",
@@ -2672,6 +2745,7 @@ export class HeartbeatMonitor {
...trimTriggeringComments(triggeringCommentLines, promptTemplate), ...trimTriggeringComments(triggeringCommentLines, promptTemplate),
...pendingMessagesLines, ...pendingMessagesLines,
...pendingRoomMessagesLines, ...pendingRoomMessagesLines,
...roomAmbiguityNoticesLines,
...(reportsHealthSection ? ["", reportsHealthSection] : []), ...(reportsHealthSection ? ["", reportsHealthSection] : []),
"", "",
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.", "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:create:denied"
| "agent:delete:requested" | "agent:delete:requested"
| "agent:delete:approved" | "agent:delete:approved"
| "agent:delete:denied"; | "agent:delete:denied"
| "room:ambiguity:branch";
// ── Filesystem mutation types ───────────────────────────────────────────────── // ── Filesystem mutation types ─────────────────────────────────────────────────