feat(FN-3877): add section-aware prompt compaction for triage task specific

The merge adds section-aware prompt compaction for task triage (reducing spec payload size by intelligently trimming planning context), enriches the Settings modal with GitHub tracking configuration, and improves the engine's self-healing behavior.

Fusion-Task-Id: FN-3877
This commit is contained in:
Fusion
2026-05-09 12:20:06 -07:00
committed by gsxdsm
parent 4b6a14974b
commit 37913bcf8c
3 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Triage: progressively compact large optional sections (subtask guidance, attachments, existing spec, user comments) of the spec prompt when the model's context window overflows, in addition to the existing project-memory compaction. Fixes failures on small-context models such as local vLLM Qwen3-30B (issue Runfusion/Fusion#62, FN-3877).

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { __testOnlyPromptCompaction, promptWithFallback } from "../pi.js";
const OVERFLOW_ERROR = "This model's maximum context length is 32768 tokens. However, your messages resulted in 45870 tokens.";
function makeSession(promptImpl: (input: string) => Promise<void>) {
return {
prompt: vi.fn(promptImpl),
compact: vi.fn(async () => null),
} as any;
}
describe("compactLargePromptSections", () => {
it("returns null for prompts without recognized headings", () => {
const prompt = "## Task\n\nhello";
expect(__testOnlyPromptCompaction.compactLargePromptSections(prompt)).toBeNull();
});
it("returns null when sections are below budget", () => {
const prompt = "## User Comments\n\n- **[2026-05-09]** short";
expect(__testOnlyPromptCompaction.compactLargePromptSections(prompt)).toBeNull();
});
it("shortens oversized Subtask Consideration but keeps other sections", () => {
const big = "a".repeat(3000);
const prompt = `## Subtask Consideration\n\n${big}\n\n## Task\n\nkeep me`;
const out = __testOnlyPromptCompaction.compactLargePromptSections(prompt);
expect(out).toContain("## Task\n\nkeep me");
expect(out).toContain("Section trimmed");
expect(out?.length).toBeLessThan(prompt.length);
});
it("shortens attachment fenced bodies but preserves image bullets", () => {
const big = "b".repeat(5000);
const prompt = `## Attachments\n\n- **image.png** (image/png) — included as image below\n### file.txt (text/plain)\n\n\
\`\`\`\n${big}\n\`\`\``;
const out = __testOnlyPromptCompaction.compactLargePromptSections(prompt);
expect(out).toContain("- **image.png** (image/png) — included as image below");
expect(out).toContain("attachment body trimmed");
});
it("shortens Existing Specification while keeping structure", () => {
const big = "c".repeat(8000);
const prompt = `## Existing Specification\n\n\`\`\`markdown\n${big}\n\`\`\``;
const out = __testOnlyPromptCompaction.compactLargePromptSections(prompt);
expect(out).toContain("## Existing Specification");
expect(out).toContain("existing specification middle trimmed");
});
it("collapses older User Comments while keeping latest", () => {
const comments = Array.from({ length: 40 }, (_, i) => `- **[2026-05-${String(i + 1).padStart(2, "0")}]** ${"x".repeat(120)}`).join("\n");
const prompt = `## User Comments\n\n${comments}`;
const out = __testOnlyPromptCompaction.compactLargePromptSections(prompt);
expect(out).toContain("earlier comments trimmed");
expect(out).toContain("2026-05-40");
});
});
describe("promptWithFallback section compaction", () => {
it("recovers via section compaction on context overflow", async () => {
const big = "z".repeat(5000);
const prompt = `## Existing Specification\n\n${big}`;
let first = true;
const session = makeSession(async () => {
if (first) {
first = false;
throw new Error(OVERFLOW_ERROR);
}
});
await expect(promptWithFallback(session, prompt)).resolves.toBeUndefined();
expect(session.prompt).toHaveBeenCalledTimes(2);
expect(session.prompt.mock.calls[1][0].length).toBeLessThan(prompt.length);
});
it("throws original overflow error when no strategy recovers", async () => {
const prompt = "## Task\n\nsmall";
const session = makeSession(async () => {
throw new Error(OVERFLOW_ERROR);
});
session.compact = vi.fn(async () => null);
await expect(promptWithFallback(session, prompt)).rejects.toThrow(OVERFLOW_ERROR);
});
});

View File

@@ -241,6 +241,17 @@ export async function promptWithFallback(session: AgentSession, prompt: string,
} }
} }
const promptSectionRetry = await retryWithCompactedPromptSections(session, prompt, options);
if (promptSectionRetry.recovered) {
return;
}
if (promptSectionRetry.error) {
const retryMessage = promptSectionRetry.error instanceof Error ? promptSectionRetry.error.message : String(promptSectionRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptSectionRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction"); piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(session); await flushMemoryBeforeSessionCompaction(session);
const compactResult = await compactSessionContext(session); const compactResult = await compactSessionContext(session);
@@ -285,6 +296,10 @@ export const COMPACTION_FALLBACK_INSTRUCTIONS = [
].join(" "); ].join(" ");
const MAX_COMPACTED_PROMPT_MEMORY_CHARS = 8_000; const MAX_COMPACTED_PROMPT_MEMORY_CHARS = 8_000;
const MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS = 1_200;
const MAX_COMPACTED_ATTACHMENTS_CHARS = 4_000;
const MAX_COMPACTED_EXISTING_SPEC_CHARS = 4_000;
const MAX_COMPACTED_USER_COMMENTS_CHARS = 2_000;
function compactMarkdownMemorySection(sectionBody: string): string { function compactMarkdownMemorySection(sectionBody: string): string {
const lines = sectionBody.split("\n"); const lines = sectionBody.split("\n");
@@ -347,6 +362,158 @@ function compactPromptMemory(prompt: string): string | null {
return changed && compactedPrompt.length < prompt.length ? compactedPrompt : null; return changed && compactedPrompt.length < prompt.length ? compactedPrompt : null;
} }
function trimSubtaskSectionBody(body: string): string {
const paragraphs = body
.trim()
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const rawFirstParagraph = paragraphs[0] ?? "Subtask guidance omitted for context limits.";
const maxFirstParagraphChars = Math.max(200, MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS - 200);
const firstParagraph = rawFirstParagraph.length > maxFirstParagraphChars
? `${rawFirstParagraph.slice(0, maxFirstParagraphChars)}`
: rawFirstParagraph;
return [
firstParagraph,
"",
"Follow the project's standard subtask split rules.",
].join("\n");
}
function compactAttachmentSectionBody(body: string): string {
if (body.length <= MAX_COMPACTED_ATTACHMENTS_CHARS) {
return body.trim();
}
const lines = body.split("\n");
const kept: string[] = [];
let inFence = false;
let fenceHasContent = false;
for (const line of lines) {
if (/^```/.test(line.trim())) {
if (!inFence) {
inFence = true;
fenceHasContent = false;
continue;
}
inFence = false;
if (fenceHasContent) {
kept.push("```", "_... attachment body trimmed ..._", "```");
}
continue;
}
if (inFence) {
fenceHasContent = true;
continue;
}
kept.push(line);
}
return kept.join("\n").trim();
}
function compactExistingSpecificationSectionBody(body: string): string {
const trimmed = body.trim();
if (trimmed.length <= MAX_COMPACTED_EXISTING_SPEC_CHARS) {
return trimmed;
}
const head = trimmed.slice(0, Math.floor(MAX_COMPACTED_EXISTING_SPEC_CHARS / 2));
const tail = trimmed.slice(-Math.floor(MAX_COMPACTED_EXISTING_SPEC_CHARS / 2));
return `${head}\n\n_... existing specification middle trimmed ..._\n\n${tail}`;
}
function compactUserCommentsSectionBody(body: string): string {
const trimmed = body.trim();
if (trimmed.length <= MAX_COMPACTED_USER_COMMENTS_CHARS) {
return trimmed;
}
const lines = trimmed.split("\n");
const commentLines = lines.filter((line) => /^- \*\*\[[^\]]+\]\*\*/.test(line));
const staticLines = lines.filter((line) => !/^- \*\*\[[^\]]+\]\*\*/.test(line));
const sortedComments = [...commentLines].sort((a, b) => {
const aDate = a.match(/^- \*\*\[([^\]]+)\]\*\*/)?.[1] ?? "";
const bDate = b.match(/^- \*\*\[([^\]]+)\]\*\*/)?.[1] ?? "";
return bDate.localeCompare(aDate);
});
const kept: string[] = [];
let used = staticLines.join("\n").length;
for (const line of sortedComments) {
const next = used + line.length + 1;
if (next > MAX_COMPACTED_USER_COMMENTS_CHARS) {
break;
}
kept.push(line);
used = next;
}
const trimmedCount = Math.max(0, commentLines.length - kept.length);
return [
...staticLines,
"",
...kept,
...(trimmedCount > 0 ? ["", `_... ${trimmedCount} earlier comments trimmed ..._`] : []),
].join("\n").trim();
}
function compactLargePromptSections(prompt: string): string | null {
const sectionPattern = /(^|\n)(## (?:Subtask Consideration|Subtask Breakdown Requested|Attachments|Existing Specification|User Comments)\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
let changed = false;
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix: string, heading: string, body: string) => {
const headingName = heading.trim().replace(/^##\s+/, "");
const trimmedBody = body.trim();
const maxByHeading: Record<string, number> = {
"Subtask Consideration": MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS,
"Subtask Breakdown Requested": MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS,
Attachments: MAX_COMPACTED_ATTACHMENTS_CHARS,
"Existing Specification": MAX_COMPACTED_EXISTING_SPEC_CHARS,
"User Comments": MAX_COMPACTED_USER_COMMENTS_CHARS,
};
const maxChars = maxByHeading[headingName] ?? MAX_COMPACTED_PROMPT_MEMORY_CHARS;
if (trimmedBody.length <= maxChars) {
return match;
}
let compactedBody = trimmedBody;
if (headingName === "Subtask Consideration" || headingName === "Subtask Breakdown Requested") {
compactedBody = trimSubtaskSectionBody(trimmedBody);
} else if (headingName === "Attachments") {
compactedBody = compactAttachmentSectionBody(trimmedBody);
} else if (headingName === "Existing Specification") {
compactedBody = compactExistingSpecificationSectionBody(trimmedBody);
} else if (headingName === "User Comments") {
compactedBody = compactUserCommentsSectionBody(trimmedBody);
}
const finalBody = [
compactedBody,
"",
`<!-- Section trimmed from ${trimmedBody.length} characters to fit context window. -->`,
].join("\n").trim();
if (finalBody.length >= trimmedBody.length) {
return match;
}
changed = true;
return `${prefix}${heading}${finalBody}`;
});
return changed && compactedPrompt.length < prompt.length ? compactedPrompt : null;
}
export const __testOnlyPromptCompaction = {
compactLargePromptSections,
};
async function retryWithCompactedPromptMemory( async function retryWithCompactedPromptMemory(
session: AgentSession, session: AgentSession,
prompt: string, prompt: string,
@@ -372,6 +539,31 @@ async function retryWithCompactedPromptMemory(
} }
} }
async function retryWithCompactedPromptSections(
session: AgentSession,
prompt: string,
options?: unknown,
): Promise<{ recovered: boolean; error?: unknown }> {
const compactedPrompt = compactLargePromptSections(prompt);
if (!compactedPrompt) {
return { recovered: false };
}
piLog.log(
`promptWithFallback: retrying with compacted prompt sections (${prompt.length}${compactedPrompt.length} chars)`,
);
try {
await promptSessionAndCheck(session, compactedPrompt, options);
piLog.log("promptWithFallback: prompt completed after section compaction");
return { recovered: true };
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
piLog.error(`promptWithFallback: retry after section compaction failed: ${errorMessage}`);
return { recovered: false, error: err };
}
}
async function flushMemoryBeforeSessionCompaction(session: AgentSession): Promise<void> { async function flushMemoryBeforeSessionCompaction(session: AgentSession): Promise<void> {
if ((session as any).__fusionMemoryAppendAvailable !== true) { if ((session as any).__fusionMemoryAppendAvailable !== true) {
return; return;
@@ -1610,6 +1802,17 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
} }
} }
const promptSectionRetry = await retryWithCompactedPromptSections(activeSession, prompt, promptOptions);
if (promptSectionRetry.recovered) {
return;
}
if (promptSectionRetry.error) {
const retryMessage = promptSectionRetry.error instanceof Error ? promptSectionRetry.error.message : String(promptSectionRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptSectionRetry.error;
}
}
piLog.warn("promptWithFallback: context limit error — attempting auto-compaction"); piLog.warn("promptWithFallback: context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(activeSession); await flushMemoryBeforeSessionCompaction(activeSession);
const compactResult = await compactSessionContext(activeSession); const compactResult = await compactSessionContext(activeSession);
@@ -1664,6 +1867,17 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
} }
} }
const promptSectionRetry = await retryWithCompactedPromptSections(fallbackSession, prompt, promptOptions);
if (promptSectionRetry.recovered) {
return;
}
if (promptSectionRetry.error) {
const retryMessage = promptSectionRetry.error instanceof Error ? promptSectionRetry.error.message : String(promptSectionRetry.error);
if (!isContextLimitError(retryMessage)) {
throw promptSectionRetry.error;
}
}
piLog.warn("promptWithFallback: fallback session context limit error — attempting auto-compaction"); piLog.warn("promptWithFallback: fallback session context limit error — attempting auto-compaction");
await flushMemoryBeforeSessionCompaction(fallbackSession); await flushMemoryBeforeSessionCompaction(fallbackSession);
const compactResult = await compactSessionContext(fallbackSession); const compactResult = await compactSessionContext(fallbackSession);