Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,18 @@
]
},
"minContextLimit": {
"description": "Soft lower threshold for reminder nudges. Below this, turn/iteration reminders are off (compression is less likely). At or above this, reminders are on. Accepts number or \"X%\" of the model context window.",
"default": 50000,
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^\\d+(?:\\.\\d+)?%$"
}
]
"type": ["number", "string"],
"pattern": "^[0-9]+%$",
"default": "45%",
"description": "Lower threshold: contextual turn nudge stops appearing below this limit"
},
"model": {
"type": "string",
"description": "Provider/Model ID to use for generating compression summaries instead of the active model (e.g. 'anthropic/claude-3-haiku-20240307')"
},
"agent": {
"type": "string",
"description": "Agent to use when prompting the custom compression model (e.g. 'dcp-compressor')"
},
"modelMaxLimits": {
"description": "Per-model override for maxContextLimit by exact provider/model key. If set, this takes priority over the global maxContextLimit.",
Expand Down
34 changes: 30 additions & 4 deletions lib/compress/message.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { tool } from "@opencode-ai/plugin"
import type { ToolContext } from "./types"
import { countTokens } from "../token-utils"
import { MESSAGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
import { getMessageFormatExtension } from "../prompts/extensions/tool"
import { formatIssues, formatResult, resolveMessages, validateArgs } from "./message-utils"
import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline"
import { finalizeSession, prepareSession, generateDelegatedSummary, resolveCompressionDelegate, formatPartForDelegatedCompression, type NotificationEntry } from "./pipeline"
import { INTERNAL_COMPRESSION_SYSTEM_PROMPT } from "../prompts/system"
import { appendProtectedPromptInfo, appendProtectedTools } from "./protected-content"
import {
allocateBlockId,
Expand Down Expand Up @@ -31,6 +32,7 @@ function buildSchema() {
.describe("Short label (3-5 words) for this one message summary"),
summary: tool.schema
.string()
.optional()
.describe("Complete technical summary replacing that one message"),
}),
)
Expand All @@ -42,8 +44,10 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
ctx.prompts.reload()
const runtimePrompts = ctx.prompts.getRuntimePrompts()

const delegate = resolveCompressionDelegate(ctx.config)
const delegated = delegate.enabled
return tool({
description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
description: runtimePrompts.compressMessage + getMessageFormatExtension(delegated),
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressMessageToolArgs
Expand Down Expand Up @@ -77,8 +81,30 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
}> = []

for (const plan of plans) {
let initialSummary = plan.entry.summary || ""

if (delegated && delegate) {
const rawText = plan.selection.messageIds
.map((id) => {
const msg = searchContext.rawMessagesById.get(id)
return msg ? `[${msg.info.role}] ${msg.parts?.map(formatPartForDelegatedCompression).join("\n")}` : ""
})
.filter(Boolean)
.join("\n\n")

initialSummary = await generateDelegatedSummary(
ctx.client,
ctx.logger,
delegate,
INTERNAL_COMPRESSION_SYSTEM_PROMPT,
`Please summarize the following messages:\n\n${rawText}`
)
} else if (!initialSummary) {
throw new Error("Summary is required when delegated compression is disabled.")
}

const summaryWithPromptInfo = appendProtectedPromptInfo(
plan.entry.summary,
initialSummary,
plan.selection,
searchContext,
ctx.state,
Expand Down
187 changes: 187 additions & 0 deletions lib/compress/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,193 @@ export async function prepareSession(
}
}

const TOOL_PAYLOAD_LIMIT = 3000
const TRUNCATION_MARKER = "\n...[truncated]...\n"

function stringifyForCompression(value: unknown): string {
if (typeof value === "string") return value
if (value === undefined) return ""

try {
const seen = new WeakSet<object>()

return JSON.stringify(
value,
(_key, val) => {
if (typeof val === "bigint") return val.toString()
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return "[Circular]"
seen.add(val)
}
return val
},
2,
)
} catch {
return String(value)
}
}

function truncateMiddle(value: string, limit = TOOL_PAYLOAD_LIMIT): string {
if (value.length <= limit) return value

const available = Math.max(0, limit - TRUNCATION_MARKER.length)
const headLength = Math.ceil(available / 2)
const tailLength = Math.floor(available / 2)

return `${value.slice(0, headLength)}${TRUNCATION_MARKER}${value.slice(value.length - tailLength)}`
}

export function formatPartForDelegatedCompression(part: any): string {
if (!part || typeof part !== "object") return ""

if (part.type === "text") {
return typeof part.text === "string" ? part.text : stringifyForCompression(part.text)
}

if (part.type === "tool") {
const state = part.state && typeof part.state === "object" ? part.state : {}
const status = typeof state.status === "string" ? state.status : "unknown"
const toolName = typeof part.tool === "string" ? part.tool : "unknown"
const args = stringifyForCompression(state.input ?? {})

if (status === "completed") {
return `[Tool: ${toolName} status=completed]\nargs: ${args}\noutput:\n${truncateMiddle(
stringifyForCompression(state.output),
)}`
}

if (status === "error") {
return `[Tool: ${toolName} status=error]\nargs: ${args}\nerror:\n${truncateMiddle(
stringifyForCompression(state.error),
)}`
}

return `[Tool: ${toolName} status=${status}]\nargs: ${args}`
}

if (typeof part.prompt === "string") {
return part.prompt
}

return ""
}

export type CompressionDelegate =
| {
enabled: false
}
| {
enabled: true
agent?: string
model?: {
providerID: string
modelID: string
}
}

export function resolveCompressionDelegate(config: any): CompressionDelegate {
if (config.compress.agent) {
return {
enabled: true,
agent: config.compress.agent,
model: config.compress.model
? {
providerID: config.compress.model.split("/")[0],
modelID: config.compress.model.split("/").slice(1).join("/"),
}
: undefined,
}
}

if (config.compress.model) {
return {
enabled: true,
model: {
providerID: config.compress.model.split("/")[0],
modelID: config.compress.model.split("/").slice(1).join("/"),
},
}
}

return { enabled: false }
}

export async function generateDelegatedSummary(
client: any,
logger: any,
delegate: CompressionDelegate & { enabled: true },
systemPrompt: string,
rawText: string
): Promise<string> {
let helperSession: any | undefined

try {
helperSession = await client.session.create({
body: { title: "DCP Compression helper" }
})

const internalSessionIdsModule = await import("../state")
internalSessionIdsModule.INTERNAL_SESSION_IDS.add(helperSession.data.id || helperSession.id)

const body: any = {
system: systemPrompt,
tools: {
compress: false,
bash: false,
edit: false,
write: false,
read: false,
webfetch: false,
},
parts: [{ type: "text", text: rawText }],
}

if (delegate.model) {
body.model = delegate.model
}
if (delegate.agent) {
body.agent = delegate.agent
}

const response = await client.session.prompt({
path: { id: helperSession.data.id || helperSession.id },
body,
})

if (!response?.data) {
throw new Error("No response data from compression model")
}

const info = response.data.info
if (info?.error) {
throw new Error(`Compression model error: ${JSON.stringify(info.error)}`)
}

const parts = response.data.parts || []
const textParts = parts.filter((p: any) => p.type === "text").map((p: any) => p.text)

if (textParts.length === 0) {
throw new Error("Compression model returned empty text")
}

return textParts.join("\n")
} finally {
if (helperSession) {
const helperId = helperSession.data?.id || helperSession.id
if (helperId) {
try {
await client.session.delete({ path: { id: helperId } })
} catch (err: any) {
logger.warn("Failed to delete DCP helper session", { error: err.message })
}
const internalSessionIdsModule = await import("../state")
internalSessionIdsModule.INTERNAL_SESSION_IDS.delete(helperId)
}
}
}
}

export async function finalizeSession(
ctx: ToolContext,
toolCtx: RunContext,
Expand Down
36 changes: 31 additions & 5 deletions lib/compress/range.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { tool } from "@opencode-ai/plugin"
import type { ToolContext } from "./types"
import { countTokens } from "../token-utils"
import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline"
import { getRangeFormatExtension } from "../prompts/extensions/tool"
import { finalizeSession, prepareSession, generateDelegatedSummary, resolveCompressionDelegate, formatPartForDelegatedCompression, type NotificationEntry } from "./pipeline"
import { INTERNAL_COMPRESSION_SYSTEM_PROMPT } from "../prompts/system"
import {
appendProtectedPromptInfo,
appendProtectedTools,
Expand Down Expand Up @@ -44,6 +45,7 @@ function buildSchema() {
.describe("Message or block ID marking the end of range (e.g. m0012, b5)"),
summary: tool.schema
.string()
.optional()
.describe("Complete technical summary replacing all content in range"),
}),
)
Expand All @@ -57,8 +59,10 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
ctx.prompts.reload()
const runtimePrompts = ctx.prompts.getRuntimePrompts()

const delegate = resolveCompressionDelegate(ctx.config)
const delegated = delegate.enabled
return tool({
description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
description: runtimePrompts.compressRange + getRangeFormatExtension(delegated),
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressRangeToolArgs
Expand Down Expand Up @@ -87,7 +91,29 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
let totalCompressedMessages = 0

for (const plan of resolvedPlans) {
const parsedPlaceholders = parseBlockPlaceholders(plan.entry.summary)
let initialSummary = plan.entry.summary || ""

if (delegated && delegate) {
const rawText = plan.selection.messageIds
.map((id) => {
const msg = searchContext.rawMessagesById.get(id)
return msg ? `[${msg.info.role}] ${msg.parts?.map(formatPartForDelegatedCompression).join("\n")}` : ""
})
.filter(Boolean)
.join("\n\n")

initialSummary = await generateDelegatedSummary(
ctx.client,
ctx.logger,
delegate,
INTERNAL_COMPRESSION_SYSTEM_PROMPT,
`Please summarize the following conversation range:\n\n${rawText}`
)
} else if (!initialSummary) {
throw new Error("Summary is required when delegated compression is disabled.")
}

const parsedPlaceholders = parseBlockPlaceholders(initialSummary)
const missingBlockIds = validateSummaryPlaceholders(
parsedPlaceholders,
plan.selection.requiredBlockIds,
Expand All @@ -97,7 +123,7 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
)

const injected = injectBlockPlaceholders(
plan.entry.summary,
initialSummary,
parsedPlaceholders,
searchContext.summaryByBlockId,
plan.selection.startReference,
Expand Down
4 changes: 2 additions & 2 deletions lib/compress/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface ToolContext {
export interface CompressRangeEntry {
startId: string
endId: string
summary: string
summary?: string
}

export interface CompressRangeToolArgs {
Expand All @@ -25,7 +25,7 @@ export interface CompressRangeToolArgs {
export interface CompressMessageEntry {
messageId: string
topic: string
summary: string
summary?: string
}

export interface CompressMessageToolArgs {
Expand Down
Loading