Skip to content

Commit 053987c

Browse files
committed
fix(compress): derive missing topics from the summary instead of erroring (#301)
'content[0] needs a topic' hard-failed compress calls whose entries had no topic and no top-level fallback — another retry dead-end of the same class as the acknowledgeRisk error. Topics are now optional: an entry without its own topic falls back to the top-level topic, then to one derived from the summary's first line (markdown headings stripped, capped at 80 chars), so search_context still has something useful to match on. - lib/compress/range-utils.ts: drop the topic throw; add deriveFallbackTopic; resolveRanges fills the fallback - lib/compress/types.ts, prompts: topic documented as fully optional - tests: the three throw-cases now assert validity; deriveFallbackTopic unit coverage (heading strip, length cap, empty summary)
1 parent 4bcd207 commit 053987c

5 files changed

Lines changed: 32 additions & 27 deletions

File tree

lib/compress/range-utils.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,17 @@ import type {
1212

1313
const BLOCK_PLACEHOLDER_REGEX = /\(b(\d+)\)|\{block_(\d+)\}/gi
1414

15-
export function validateArgs(args: CompressRangeToolArgs): void {
16-
const hasTopLevelTopic = typeof args.topic === "string" && args.topic.trim().length > 0
15+
/** #301 follow-up: topic is optional — derive a short searchable topic from the summary. */
16+
export function deriveFallbackTopic(summary: string): string {
17+
const firstLine =
18+
summary
19+
.split("\n")
20+
.map((line) => line.replace(/^#{1,6}\s*/, "").trim())
21+
.find((line) => line.length > 0) ?? "compressed range"
22+
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine
23+
}
1724

25+
export function validateArgs(args: CompressRangeToolArgs): void {
1826
if (!Array.isArray(args.content) || args.content.length === 0) {
1927
throw new Error("content is required and must be a non-empty array")
2028
}
@@ -35,12 +43,6 @@ export function validateArgs(args: CompressRangeToolArgs): void {
3543
throw new Error(`${prefix}.summary is required and must be a non-empty string`)
3644
}
3745

38-
const hasEntryTopic = typeof entry?.topic === "string" && entry.topic.trim().length > 0
39-
if (!hasEntryTopic && !hasTopLevelTopic) {
40-
throw new Error(
41-
`${prefix} needs a topic — provide ${prefix}.topic or the top-level topic`,
42-
)
43-
}
4446
}
4547
}
4648

@@ -55,7 +57,9 @@ export function resolveRanges(
5557
topic:
5658
typeof entry.topic === "string" && entry.topic.trim().length > 0
5759
? entry.topic.trim()
58-
: undefined,
60+
: typeof args.topic === "string" && args.topic.trim().length > 0
61+
? undefined
62+
: deriveFallbackTopic(entry.summary),
5963
startId: entry.startId.trim(),
6064
endId: entry.endId.trim(),
6165
summary: entry.summary,

lib/compress/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export interface CompressRangeEntry {
5252
}
5353

5454
export interface CompressRangeToolArgs {
55-
/** Fallback topic for entries without their own. Optional if every entry has one. */
55+
/** Fallback topic for entries without their own. Fully optional — missing topics are derived from the summary. */
5656
topic?: string
5757
content: CompressRangeEntry[]
5858
summaryMaxChars?: number

lib/prompts/compress-range.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Rules:
3535
BATCHING
3636
When multiple independent ranges are ready and their boundaries do not overlap, include all of them as separate entries in the \`content\` array of a single tool call. Each entry should have its own \`startId\`, \`endId\`, and \`summary\`.
3737
38-
When the ranges cover unrelated topics, give each entry its own \`topic\` for better summary quality — do not force unrelated content under a single shared topic. Omit the top-level \`topic\` when every entry has its own. Use the top-level \`topic\` only as a fallback when entries don't specify one.
38+
When the ranges cover unrelated topics, give each entry its own \`topic\` for better summary quality — do not force unrelated content under a single shared topic. The top-level \`topic\` is an optional fallback; when no topic is provided at all, one is derived from the summary's first line automatically.
3939
4040
\`\`\`
4141
compress({ content: [

lib/prompts/extensions/tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ THE FORMAT OF COMPRESS
2323
]
2424
}
2525
\`\`\`
26-
Each entry needs a topic — either its own or the top-level fallback.`
26+
Each entry MAY have a topic. Missing topics fall back to the top-level topic, then to an automatic one derived from the summary.`
2727

2828
export const MESSAGE_FORMAT_EXTENSION = `
2929
THE FORMAT OF COMPRESS

tests/batch-compress.test.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { join } from "node:path"
44
import { tmpdir } from "node:os"
55
import { mkdirSync } from "node:fs"
66
import { createCompressRangeTool } from "../lib/compress/range"
7-
import { validateArgs } from "../lib/compress/range-utils"
7+
import { validateArgs, deriveFallbackTopic } from "../lib/compress/range-utils"
88
import { createSessionState, type WithParts } from "../lib/state"
99
import type { PluginConfig } from "../lib/config"
1010
import { Logger } from "../lib/logger"
@@ -143,40 +143,41 @@ test("validateArgs: mixed — some entries have topic, others use fallback", ()
143143
assert.doesNotThrow(() => validateArgs(args))
144144
})
145145

146-
test("validateArgs: no topic at all — entry without topic and no fallback", () => {
146+
test("validateArgs: no topic at all — valid, topic is derived from the summary (#301)", () => {
147147
const args = {
148148
content: [
149149
{ startId: "m00001", endId: "m00003", summary: "..." },
150150
],
151151
}
152-
assert.throws(
153-
() => validateArgs(args as CompressRangeToolArgs),
154-
/content\[0\] needs a topic/,
155-
)
152+
assert.doesNotThrow(() => validateArgs(args as CompressRangeToolArgs))
156153
})
157154

158-
test("validateArgs: one entry without topic in a no-topical batch", () => {
155+
test("validateArgs: one entry without topic in a no-topical batch — valid (#301)", () => {
159156
const args = {
160157
content: [
161158
{ topic: "First", startId: "m00001", endId: "m00003", summary: "..." },
162159
{ startId: "m00004", endId: "m00006", summary: "..." },
163160
],
164161
}
165-
assert.throws(
166-
() => validateArgs(args as CompressRangeToolArgs),
167-
/content\[1\] needs a topic/,
168-
)
162+
assert.doesNotThrow(() => validateArgs(args as CompressRangeToolArgs))
169163
})
170164

171-
test("validateArgs: empty top-level topic with entry lacking topic", () => {
165+
test("validateArgs: empty top-level topic with entry lacking topic — valid (#301)", () => {
172166
const args = {
173167
topic: " ",
174168
content: [{ startId: "m00001", endId: "m00003", summary: "..." }],
175169
}
176-
assert.throws(
177-
() => validateArgs(args as CompressRangeToolArgs),
178-
/content\[0\] needs a topic/,
170+
assert.doesNotThrow(() => validateArgs(args as CompressRangeToolArgs))
171+
})
172+
173+
test("deriveFallbackTopic: uses first meaningful line, strips markdown headings, caps length", () => {
174+
assert.equal(
175+
deriveFallbackTopic("## API Gateway 设计\n\n详细内容……"),
176+
"API Gateway 设计",
179177
)
178+
const long = "A".repeat(120)
179+
assert.equal(deriveFallbackTopic(long), `${"A".repeat(77)}...`)
180+
assert.equal(deriveFallbackTopic(""), "compressed range")
180181
})
181182

182183

0 commit comments

Comments
 (0)