diff --git a/src/agents/atlas/utils.ts b/src/agents/atlas/utils.ts index b83055671..3bed073b9 100644 --- a/src/agents/atlas/utils.ts +++ b/src/agents/atlas/utils.ts @@ -8,21 +8,22 @@ import type { CategoryConfig } from "../../config/schema" import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants" +import { truncateDescription } from "../../shared/truncate-description" export const getCategoryDescription = (name: string, userCategories?: Record) => userCategories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks" export function buildAgentSelectionSection(agents: AvailableAgent[]): string { - if (agents.length === 0) { - return `##### Option B: Use AGENT directly (for specialized experts) + if (agents.length === 0) { + return `##### Option B: Use AGENT directly (for specialized experts) -No agents available.` - } + No agents available.` + } - const rows = agents.map((a) => { - const shortDesc = a.description.split(".")[0] || a.description - return `| \`${a.name}\` | ${shortDesc} |` - }) + const rows = agents.map((a) => { + const shortDesc = truncateDescription(a.description) + return `| \`${a.name}\` | ${shortDesc} |` + }) return `##### Option B: Use AGENT directly (for specialized experts) @@ -59,16 +60,16 @@ export function buildSkillsSection(skills: AvailableSkill[]): string { const builtinSkills = skills.filter((s) => s.location === "plugin") const customSkills = skills.filter((s) => s.location !== "plugin") - const builtinRows = builtinSkills.map((s) => { - const shortDesc = s.description.split(".")[0] || s.description - return `| \`${s.name}\` | ${shortDesc} |` - }) + const builtinRows = builtinSkills.map((s) => { + const shortDesc = truncateDescription(s.description) + return `| \`${s.name}\` | ${shortDesc} |` + }) - const customRows = customSkills.map((s) => { - const shortDesc = s.description.split(".")[0] || s.description - const source = s.location === "project" ? "project" : "user" - return `| \`${s.name}\` | ${shortDesc} | ${source} |` - }) + const customRows = customSkills.map((s) => { + const shortDesc = truncateDescription(s.description) + const source = s.location === "project" ? "project" : "user" + return `| \`${s.name}\` | ${shortDesc} | ${source} |` + }) const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills, "**") @@ -121,10 +122,10 @@ export function buildDecisionMatrix(agents: AvailableAgent[], userCategories?: R `| ${getCategoryDescription(name, userCategories)} | \`category="${name}", load_skills=[...]\` |` ) - const agentRows = agents.map((a) => { - const shortDesc = a.description.split(".")[0] || a.description - return `| ${shortDesc} | \`agent="${a.name}"\` |` - }) + const agentRows = agents.map((a) => { + const shortDesc = truncateDescription(a.description) + return `| ${shortDesc} | \`agent="${a.name}"\` |` + }) return `##### Decision Matrix diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index f6bf6e477..c70da062e 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -1,4 +1,5 @@ import type { AgentPromptMetadata, BuiltinAgentName } from "./types" +import { truncateDescription } from "../shared/truncate-description" export interface AvailableAgent { name: BuiltinAgentName @@ -205,16 +206,16 @@ export function buildCategorySkillsDelegationGuide(categories: AvailableCategory const builtinSkills = skills.filter((s) => s.location === "plugin") const customSkills = skills.filter((s) => s.location !== "plugin") - const builtinRows = builtinSkills.map((s) => { - const desc = s.description.split(".")[0] || s.description - return `| \`${s.name}\` | ${desc} |` - }) + const builtinRows = builtinSkills.map((s) => { + const desc = truncateDescription(s.description) + return `| \`${s.name}\` | ${desc} |` + }) - const customRows = customSkills.map((s) => { - const desc = s.description.split(".")[0] || s.description - const source = s.location === "project" ? "project" : "user" - return `| \`${s.name}\` | ${desc} | ${source} |` - }) + const customRows = customSkills.map((s) => { + const desc = truncateDescription(s.description) + const source = s.location === "project" ? "project" : "user" + return `| \`${s.name}\` | ${desc} | ${source} |` + }) const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills) diff --git a/src/shared/index.ts b/src/shared/index.ts index 44d6b1f58..d42be5a75 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -42,3 +42,4 @@ export * from "./model-suggestion-retry" export * from "./opencode-server-auth" export * from "./port-utils" export * from "./safe-create-hook" +export * from "./truncate-description" diff --git a/src/shared/truncate-description.test.ts b/src/shared/truncate-description.test.ts new file mode 100644 index 000000000..7645de1cc --- /dev/null +++ b/src/shared/truncate-description.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "bun:test" +import { truncateDescription } from "./truncate-description" + +describe("truncateDescription", () => { + it("returns description unchanged when under max length", () => { + // given + const description = "This is a short description" + + // when + const result = truncateDescription(description) + + // then + expect(result).toBe(description) + }) + + it("truncates to 120 characters by default and appends ellipsis", () => { + // given + const description = "This is a very long description that exceeds the default maximum length of 120 characters and should be truncated with an ellipsis at the end" + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(120) // 117 chars + "..." + expect(result).toEndWith("...") + expect(result).toBe(description.slice(0, 117) + "...") + }) + + it("respects custom max length parameter", () => { + // given + const description = "This is a description that is longer than fifty characters" + const maxLength = 50 + + // when + const result = truncateDescription(description, maxLength) + + // then + expect(result.length).toBe(50) // 47 chars + "..." + expect(result).toEndWith("...") + expect(result).toBe(description.slice(0, 47) + "...") + }) + + it("handles empty string", () => { + // given + const description = "" + + // when + const result = truncateDescription(description) + + // then + expect(result).toBe("") + }) + + it("handles exactly max length without truncation", () => { + // given + const description = "a".repeat(120) + + // when + const result = truncateDescription(description) + + // then + expect(result).toBe(description) + expect(result).not.toEndWith("...") + }) + + it("handles description with periods correctly", () => { + // given + const description = "First sentence. Second sentence. Third sentence that is very long and continues beyond the normal truncation point with even more text to ensure it exceeds 120 characters." + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(120) + expect(result).toContain("First sentence. Second sentence.") + expect(result).toEndWith("...") + }) + + it("handles description with URLs correctly", () => { + // given + const description = "Check out https://example.com/very/long/path/that/contains/many/segments for more information about this feature and its capabilities" + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(120) + expect(result).toStartWith("Check out https://example.com") + expect(result).toEndWith("...") + }) + + it("handles description with version numbers correctly", () => { + // given + const description = "Version 1.2.3 of the library includes many improvements and bug fixes that make it more stable and performant with additional enhancements" + + // when + const result = truncateDescription(description) + + // then + expect(result.length).toBe(120) + expect(result).toStartWith("Version 1.2.3") + expect(result).toEndWith("...") + }) +}) diff --git a/src/shared/truncate-description.ts b/src/shared/truncate-description.ts new file mode 100644 index 000000000..72fa3d08d --- /dev/null +++ b/src/shared/truncate-description.ts @@ -0,0 +1,11 @@ +export function truncateDescription(description: string, maxLength: number = 120): string { + if (!description) { + return description + } + + if (description.length <= maxLength) { + return description + } + + return description.slice(0, maxLength - 3) + "..." +} diff --git a/src/tools/delegate-task/constants.ts b/src/tools/delegate-task/constants.ts index 603a63c67..2bc8f95fd 100644 --- a/src/tools/delegate-task/constants.ts +++ b/src/tools/delegate-task/constants.ts @@ -1,8 +1,9 @@ import type { CategoryConfig } from "../../config/schema" import type { - AvailableCategory, - AvailableSkill, -} from "../../agents/dynamic-agent-prompt-builder" + AvailableCategory, + AvailableSkill, + } from "../../agents/dynamic-agent-prompt-builder" +import { truncateDescription } from "../../shared/truncate-description" export const VISUAL_CATEGORY_PROMPT_APPEND = ` You are working on VISUAL/UI tasks. @@ -492,13 +493,12 @@ function renderPlanAgentCategoryRows(categories: AvailableCategory[]): string[] } function renderPlanAgentSkillRows(skills: AvailableSkill[]): string[] { - const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name)) - return sorted.map((skill) => { - const firstSentence = skill.description.split(".")[0] || skill.description - const domain = firstSentence.trim() || skill.name - return `| \`${skill.name}\` | ${domain} |` - }) -} + const sorted = [...skills].sort((a, b) => a.name.localeCompare(b.name)) + return sorted.map((skill) => { + const domain = truncateDescription(skill.description).trim() || skill.name + return `| \`${skill.name}\` | ${domain} |` + }) + } export function buildPlanAgentSkillsSection( categories: AvailableCategory[] = [],