Skip to content
Draft
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
4 changes: 2 additions & 2 deletions lib/compress/message-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class SoftIssue extends Error {
}
}

export function validateArgs(args: CompressMessageToolArgs): void {
export function validateArgs(args: CompressMessageToolArgs, delegated = false): void {
if (typeof args.topic !== "string" || args.topic.trim().length === 0) {
throw new Error("topic is required and must be a non-empty string")
}
Expand All @@ -48,7 +48,7 @@ export function validateArgs(args: CompressMessageToolArgs): void {
throw new Error(`${prefix}.topic is required and must be a non-empty string`)
}

if (typeof entry?.summary !== "string" || entry.summary.trim().length === 0) {
if (!delegated && (typeof entry?.summary !== "string" || entry.summary.trim().length === 0)) {
throw new Error(`${prefix}.summary is required and must be a non-empty string`)
}
}
Expand Down
36 changes: 31 additions & 5 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,12 +44,14 @@ 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
validateArgs(input)
validateArgs(input, delegated)
const callId =
typeof (toolCtx as unknown as { callID?: unknown }).callID === "string"
? (toolCtx as unknown as { callID: string }).callID
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
4 changes: 2 additions & 2 deletions lib/compress/range-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {

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

export function validateArgs(args: CompressRangeToolArgs): void {
export function validateArgs(args: CompressRangeToolArgs, delegated = false): void {
if (typeof args.topic !== "string" || args.topic.trim().length === 0) {
throw new Error("topic is required and must be a non-empty string")
}
Expand All @@ -32,7 +32,7 @@ export function validateArgs(args: CompressRangeToolArgs): void {
throw new Error(`${prefix}.endId is required and must be a non-empty string`)
}

if (typeof entry?.summary !== "string" || entry.summary.trim().length === 0) {
if (!delegated && (typeof entry?.summary !== "string" || entry.summary.trim().length === 0)) {
throw new Error(`${prefix}.summary is required and must be a non-empty string`)
}
}
Expand Down
Loading
Loading