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:
5
.changeset/FN-3877-triage-spec-compaction.md
Normal file
5
.changeset/FN-3877-triage-spec-compaction.md
Normal 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).
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user