FN-6305: allow title summarization for long descriptions
Title summarization now accepts oversized task descriptions while bounding the model prompt. - Remove the 2000-character validation ceiling for summarize-title requests. - Truncate long descriptions only at model-input time with a dedicated exported cap. - Update route/API docs and tests for long-description behavior. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-6305-title-summarize-any-length.md | 5 +++ packages/core/src/__tests__/ai-summarize.test.ts | 43 ++++++++++++++++++---- packages/core/src/ai-summarize.ts | 34 +++++++++++------ packages/core/src/index.ts | 1 + packages/dashboard/app/api/legacy.ts | 2 +- .../src/__tests__/routes-planning.test.ts | 25 +++++++++++++ packages/dashboard/src/routes.ts | 2 +- 7 files changed, 92 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-6305 Fusion-Task-Lineage: 69a83173-ac85-489f-b4cc-d1a70b6d25eb
This commit is contained in:
5
.changeset/fn-6305-title-summarize-any-length.md
Normal file
5
.changeset/fn-6305-title-summarize-any-length.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Title summarization now accepts descriptions of any length by truncating the model input to a bounded prompt instead of rejecting descriptions over 2000 characters.
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT,
|
MERGE_COMMIT_SUMMARIZE_SYSTEM_PROMPT,
|
||||||
COMMIT_BODY_SYSTEM_PROMPT,
|
COMMIT_BODY_SYSTEM_PROMPT,
|
||||||
MAX_DESCRIPTION_LENGTH,
|
MAX_DESCRIPTION_LENGTH,
|
||||||
|
MAX_TITLE_SUMMARIZE_INPUT_LENGTH,
|
||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
MAX_TITLE_LENGTH,
|
MAX_TITLE_LENGTH,
|
||||||
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
||||||
@@ -53,6 +54,7 @@ describe("ai-summarize", () => {
|
|||||||
it("should have correct length limits", () => {
|
it("should have correct length limits", () => {
|
||||||
expect(MIN_DESCRIPTION_LENGTH).toBe(201);
|
expect(MIN_DESCRIPTION_LENGTH).toBe(201);
|
||||||
expect(MAX_DESCRIPTION_LENGTH).toBe(2000);
|
expect(MAX_DESCRIPTION_LENGTH).toBe(2000);
|
||||||
|
expect(MAX_TITLE_SUMMARIZE_INPUT_LENGTH).toBe(4000);
|
||||||
expect(MAX_TITLE_LENGTH).toBe(60);
|
expect(MAX_TITLE_LENGTH).toBe(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,21 +91,21 @@ describe("ai-summarize", () => {
|
|||||||
expect(() => validateDescription(desc)).toThrow("at least 201 characters");
|
expect(() => validateDescription(desc)).toThrow("at least 201 characters");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw for description too long", () => {
|
|
||||||
const desc = "a".repeat(2001);
|
|
||||||
expect(() => validateDescription(desc)).toThrow(ValidationError);
|
|
||||||
expect(() => validateDescription(desc)).toThrow("not exceed 2000 characters");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept description at minimum boundary", () => {
|
it("should accept description at minimum boundary", () => {
|
||||||
const desc = "a".repeat(201);
|
const desc = "a".repeat(201);
|
||||||
expect(validateDescription(desc)).toBe(desc);
|
expect(validateDescription(desc)).toBe(desc);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should accept description at maximum boundary", () => {
|
it("should accept description at historical maximum boundary", () => {
|
||||||
const desc = "a".repeat(2000);
|
const desc = "a".repeat(2000);
|
||||||
expect(validateDescription(desc)).toBe(desc);
|
expect(validateDescription(desc)).toBe(desc);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should accept descriptions longer than the historical maximum", () => {
|
||||||
|
const desc = "a".repeat(5000);
|
||||||
|
expect(() => validateDescription(desc)).not.toThrow();
|
||||||
|
expect(validateDescription(desc)).toBe(desc);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Rate Limiting ──────────────────────────────────────────────────────────
|
// ── Rate Limiting ──────────────────────────────────────────────────────────
|
||||||
@@ -206,6 +208,33 @@ describe("ai-summarize", () => {
|
|||||||
expect(prompt.mock.calls[0][0]).toContain("Do not call any tools");
|
expect(prompt.mock.calls[0][0]).toContain("Do not call any tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("summarizes long descriptions with bounded prompt input", async () => {
|
||||||
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
getFnAgentMock.mockResolvedValue(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
session: {
|
||||||
|
prompt,
|
||||||
|
dispose: vi.fn(),
|
||||||
|
state: {
|
||||||
|
messages: [
|
||||||
|
{ role: "assistant", content: "Summarize long description" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const description = "a".repeat(MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "tail".repeat(250);
|
||||||
|
|
||||||
|
const title = await summarizeTitle(description, "/tmp");
|
||||||
|
|
||||||
|
expect(title).toBe("Summarize long description");
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(1);
|
||||||
|
const promptText = prompt.mock.calls[0][0] as string;
|
||||||
|
expect(promptText).toContain("…(truncated)");
|
||||||
|
expect(promptText).not.toContain("tail");
|
||||||
|
expect(promptText.length).toBeLessThanOrEqual(MAX_TITLE_SUMMARIZE_INPUT_LENGTH + 250);
|
||||||
|
});
|
||||||
|
|
||||||
it("strips chatty preamble + markdown from AI response (FN-3057 regression)", async () => {
|
it("strips chatty preamble + markdown from AI response (FN-3057 regression)", async () => {
|
||||||
// Reproduces the FN-3057 incident: model wrote a chat-style reply
|
// Reproduces the FN-3057 incident: model wrote a chat-style reply
|
||||||
// ("Created **FN-3058** with the full spec…") that was sliced mid-word
|
// ("Created **FN-3058** with the full spec…") that was sliced mid-word
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* Features:
|
* Features:
|
||||||
* - Rate limiting per IP (10 requests per hour)
|
* - Rate limiting per IP (10 requests per hour)
|
||||||
* - Dynamic import of @fusion/engine for AI agent creation
|
* - Dynamic import of @fusion/engine for AI agent creation
|
||||||
* - Text length validation (201-2000 characters)
|
* - Text length validation (minimum 201 characters; model input is truncated)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
|
import { getFnAgent, type AgentMessage } from "./ai-engine-loader.js";
|
||||||
@@ -32,9 +32,21 @@ Your ONLY job is to create a concise title (max 60 characters) that summarizes t
|
|||||||
- Maximum 60 characters
|
- Maximum 60 characters
|
||||||
- Focus on the main goal or deliverable of the task`;
|
- Focus on the main goal or deliverable of the task`;
|
||||||
|
|
||||||
/** Maximum description length in characters */
|
/**
|
||||||
|
* Historical maximum accepted description length in characters.
|
||||||
|
*
|
||||||
|
* @deprecated Title summarization now accepts descriptions of any length;
|
||||||
|
* use MAX_TITLE_SUMMARIZE_INPUT_LENGTH for the bounded model-input cap.
|
||||||
|
*/
|
||||||
export const MAX_DESCRIPTION_LENGTH = 2000;
|
export const MAX_DESCRIPTION_LENGTH = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum input length for title summarization. Descriptions can be very large;
|
||||||
|
* we truncate before sending so the prompt stays bounded while preserving the
|
||||||
|
* long-input API behavior.
|
||||||
|
*/
|
||||||
|
export const MAX_TITLE_SUMMARIZE_INPUT_LENGTH = 4000;
|
||||||
|
|
||||||
/** Minimum description length for summarization in characters */
|
/** Minimum description length for summarization in characters */
|
||||||
export const MIN_DESCRIPTION_LENGTH = 201;
|
export const MIN_DESCRIPTION_LENGTH = 201;
|
||||||
|
|
||||||
@@ -178,17 +190,13 @@ export function validateDescription(description: unknown): string {
|
|||||||
throw new ValidationError("description must be a string");
|
throw new ValidationError("description must be a string");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate description length
|
// Validate description length floor. There is intentionally no upper bound:
|
||||||
|
// runTitleSummarizer truncates model input before prompting.
|
||||||
if (description.length < MIN_DESCRIPTION_LENGTH) {
|
if (description.length < MIN_DESCRIPTION_LENGTH) {
|
||||||
throw new ValidationError(
|
throw new ValidationError(
|
||||||
`description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`
|
`description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (description.length > MAX_DESCRIPTION_LENGTH) {
|
|
||||||
throw new ValidationError(
|
|
||||||
`description must not exceed ${MAX_DESCRIPTION_LENGTH} characters`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
@@ -246,12 +254,16 @@ async function runTitleSummarizer(
|
|||||||
// Wrap the user-supplied description in a delimiter so the model treats it
|
// Wrap the user-supplied description in a delimiter so the model treats it
|
||||||
// as content to summarize, not as instructions to follow. Belt-and-suspenders
|
// as content to summarize, not as instructions to follow. Belt-and-suspenders
|
||||||
// alongside the system-prompt guardrails and the engine's readonly tool
|
// alongside the system-prompt guardrails and the engine's readonly tool
|
||||||
// isolation.
|
// isolation. Truncate before prompt construction so arbitrarily long task
|
||||||
|
// descriptions cannot produce unbounded model input.
|
||||||
|
const truncatedDescription = description.length > MAX_TITLE_SUMMARIZE_INPUT_LENGTH
|
||||||
|
? description.slice(0, MAX_TITLE_SUMMARIZE_INPUT_LENGTH) + "\n…(truncated)"
|
||||||
|
: description;
|
||||||
const wrappedPrompt =
|
const wrappedPrompt =
|
||||||
"Summarize the following task description into a title (≤60 chars). " +
|
"Summarize the following task description into a title (≤60 chars). " +
|
||||||
"Output ONLY the title text on a single line. Do not call any tools.\n\n" +
|
"Output ONLY the title text on a single line. Do not call any tools.\n\n" +
|
||||||
"<description>\n" +
|
"<description>\n" +
|
||||||
description +
|
truncatedDescription +
|
||||||
"\n</description>";
|
"\n</description>";
|
||||||
await agentResult.session.prompt(wrappedPrompt);
|
await agentResult.session.prompt(wrappedPrompt);
|
||||||
|
|
||||||
@@ -317,7 +329,7 @@ async function runTitleSummarizer(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Summarize a task description into a concise title using AI.
|
* Summarize a task description into a concise title using AI.
|
||||||
* @param description - The task description to summarize (must be 201-2000 chars)
|
* @param description - The task description to summarize (must be >200 chars; model input is truncated)
|
||||||
* @param rootDir - Project root directory for AI agent context
|
* @param rootDir - Project root directory for AI agent context
|
||||||
* @param provider - Optional AI model provider (e.g., "anthropic")
|
* @param provider - Optional AI model provider (e.g., "anthropic")
|
||||||
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
|
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
|
||||||
|
|||||||
@@ -1006,6 +1006,7 @@ export {
|
|||||||
MAX_COMMIT_SUBJECT_LENGTH,
|
MAX_COMMIT_SUBJECT_LENGTH,
|
||||||
DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS,
|
DEFAULT_COMMIT_SUBJECT_TIMEOUT_MS,
|
||||||
MAX_DESCRIPTION_LENGTH,
|
MAX_DESCRIPTION_LENGTH,
|
||||||
|
MAX_TITLE_SUMMARIZE_INPUT_LENGTH,
|
||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
MAX_TITLE_LENGTH,
|
MAX_TITLE_LENGTH,
|
||||||
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
MAX_MERGE_COMMIT_SUMMARY_LENGTH,
|
||||||
|
|||||||
@@ -6439,7 +6439,7 @@ export interface SummarizeTitleResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Summarize a task description into a concise title using AI.
|
/** Summarize a task description into a concise title using AI.
|
||||||
* @param description - The task description to summarize (must be 201-2000 chars)
|
* @param description - The task description to summarize (must be >200 chars; model input is truncated)
|
||||||
* @param provider - Optional AI model provider (e.g., "anthropic")
|
* @param provider - Optional AI model provider (e.g., "anthropic")
|
||||||
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
|
* @param modelId - Optional AI model ID (e.g., "claude-sonnet-4-5")
|
||||||
* @param projectId - Optional project ID for scoped settings resolution
|
* @param projectId - Optional project ID for scoped settings resolution
|
||||||
|
|||||||
@@ -3757,6 +3757,31 @@ describe("POST /api/ai/summarize-title", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts descriptions longer than 2000 characters", async () => {
|
||||||
|
const fusionCore = await import("@fusion/core");
|
||||||
|
const summarizeTitleSpy = vi
|
||||||
|
.spyOn(fusionCore, "summarizeTitle")
|
||||||
|
.mockResolvedValueOnce("Generated title");
|
||||||
|
|
||||||
|
const description = "x".repeat(5000);
|
||||||
|
const res = await REQUEST(
|
||||||
|
buildApp(),
|
||||||
|
"POST",
|
||||||
|
"/api/ai/summarize-title",
|
||||||
|
JSON.stringify({ description }),
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ title: "Generated title" });
|
||||||
|
expect(summarizeTitleSpy).toHaveBeenCalledWith(
|
||||||
|
description,
|
||||||
|
"/test/project",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits structured diagnostics for unexpected summarize failures", async () => {
|
it("emits structured diagnostics for unexpected summarize failures", async () => {
|
||||||
const diagnostics = captureDiagnostics();
|
const diagnostics = captureDiagnostics();
|
||||||
const fusionCore = await import("@fusion/core");
|
const fusionCore = await import("@fusion/core");
|
||||||
|
|||||||
@@ -1848,6 +1848,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
* Returns: { title: string }
|
* Returns: { title: string }
|
||||||
*
|
*
|
||||||
* Generates a concise title (≤60 characters) from descriptions longer than 200 characters.
|
* Generates a concise title (≤60 characters) from descriptions longer than 200 characters.
|
||||||
|
* Long descriptions are accepted; core truncates model input before prompting.
|
||||||
* Rate limited: 10 requests per hour per IP
|
* Rate limited: 10 requests per hour per IP
|
||||||
*/
|
*/
|
||||||
router.post("/ai/summarize-title", async (req, res) => {
|
router.post("/ai/summarize-title", async (req, res) => {
|
||||||
@@ -1863,7 +1864,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
summarizeTitle,
|
summarizeTitle,
|
||||||
validateDescription,
|
validateDescription,
|
||||||
MIN_DESCRIPTION_LENGTH,
|
MIN_DESCRIPTION_LENGTH,
|
||||||
MAX_DESCRIPTION_LENGTH: _MAX_DESCRIPTION_LENGTH,
|
|
||||||
RateLimitError: _RateLimitError4,
|
RateLimitError: _RateLimitError4,
|
||||||
ValidationError: _ValidationError2,
|
ValidationError: _ValidationError2,
|
||||||
AiServiceError: _AiServiceError2,
|
AiServiceError: _AiServiceError2,
|
||||||
|
|||||||
Reference in New Issue
Block a user