2025-11-07 02:03:02 +08:00
import { streamText , type ModelMessage , type StreamTextResult , type Tool as AITool } from "ai"
2025-09-13 17:46:14 +08:00
import { Session } from "."
import { Identifier } from "../id/id"
import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { defer } from "../util/defer"
import { MessageV2 } from "./message-v2"
import { SystemPrompt } from "./system"
import { Bus } from "../bus"
2025-10-27 03:50:41 +08:00
import z from "zod"
2025-09-13 17:46:14 +08:00
import type { ModelsDev } from "../provider/models"
import { SessionPrompt } from "./prompt"
2025-09-13 17:55:04 +08:00
import { Flag } from "../flag/flag"
2025-09-16 16:52:34 +08:00
import { Token } from "../util/token"
import { Log } from "../util/log"
2025-10-19 00:49:29 +08:00
import { SessionLock } from "./lock"
2025-10-22 00:08:21 +08:00
import { ProviderTransform } from "@/provider/transform"
2025-10-23 07:31:36 +08:00
import { SessionRetry } from "./retry"
2025-10-29 01:09:41 +08:00
import { Config } from "@/config/config"
2025-09-13 17:46:14 +08:00
export namespace SessionCompaction {
2025-09-16 16:52:34 +08:00
const log = Log . create ( { service : "session.compaction" } )
2025-09-13 17:46:14 +08:00
export const Event = {
Compacted : Bus.event (
"session.compacted" ,
z . object ( {
sessionID : z.string ( ) ,
} ) ,
) ,
}
2025-11-08 09:59:02 +08:00
export function isOverflow ( input : { tokens : MessageV2.Assistant [ "tokens" ] ; model : ModelsDev.Model } ) {
2025-09-13 17:55:04 +08:00
if ( Flag . OPENCODE_DISABLE_AUTOCOMPACT ) return false
2025-09-13 17:59:18 +08:00
const context = input . model . limit . context
if ( context === 0 ) return false
2025-09-13 17:46:14 +08:00
const count = input . tokens . input + input . tokens . cache . read + input . tokens . output
2025-11-08 09:59:02 +08:00
const output = Math . min ( input . model . limit . output , SessionPrompt . OUTPUT_TOKEN_MAX ) || SessionPrompt . OUTPUT_TOKEN_MAX
2025-09-13 17:59:18 +08:00
const usable = context - output
2025-09-13 17:53:03 +08:00
return count > usable
2025-09-13 17:46:14 +08:00
}
2025-09-17 15:07:24 +08:00
export const PRUNE_MINIMUM = 20 _000
export const PRUNE_PROTECT = 40 _000
2025-10-23 07:31:36 +08:00
const MAX_RETRIES = 10
2025-09-17 15:07:24 +08:00
2025-09-16 16:52:34 +08:00
// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune ( input : { sessionID : string } ) {
if ( Flag . OPENCODE_DISABLE_PRUNE ) return
log . info ( "pruning" )
2025-11-07 02:20:13 +08:00
const msgs = await Session . messages ( { sessionID : input.sessionID } )
2025-09-16 16:52:34 +08:00
let total = 0
let pruned = 0
const toPrune = [ ]
2025-09-17 15:07:24 +08:00
let turns = 0
2025-09-16 16:52:34 +08:00
2025-09-17 15:07:24 +08:00
loop : for ( let msgIndex = msgs . length - 1 ; msgIndex >= 0 ; msgIndex -- ) {
2025-09-16 16:52:34 +08:00
const msg = msgs [ msgIndex ]
2025-09-17 15:07:24 +08:00
if ( msg . info . role === "user" ) turns ++
if ( turns < 2 ) continue
if ( msg . info . role === "assistant" && msg . info . summary ) break loop
2025-09-16 16:52:34 +08:00
for ( let partIndex = msg . parts . length - 1 ; partIndex >= 0 ; partIndex -- ) {
const part = msg . parts [ partIndex ]
if ( part . type === "tool" )
if ( part . state . status === "completed" ) {
if ( part . state . time . compacted ) break loop
const estimate = Token . estimate ( part . state . output )
total += estimate
2025-09-17 15:07:24 +08:00
if ( total > PRUNE_PROTECT ) {
2025-09-16 16:52:34 +08:00
pruned += estimate
toPrune . push ( part )
}
}
}
}
log . info ( "found" , { pruned , total } )
2025-09-17 15:07:24 +08:00
if ( pruned > PRUNE_MINIMUM ) {
2025-09-16 16:52:34 +08:00
for ( const part of toPrune ) {
if ( part . state . status === "completed" ) {
part . state . time . compacted = Date . now ( )
await Session . updatePart ( part )
}
}
log . info ( "pruned" , { count : toPrune.length } )
}
}
2025-11-08 09:59:02 +08:00
export async function run ( input : { sessionID : string ; providerID : string ; modelID : string ; signal? : AbortSignal } ) {
2025-10-19 00:49:29 +08:00
if ( ! input . signal ) SessionLock . assertUnlocked ( input . sessionID )
2025-11-08 09:59:02 +08:00
await using lock = input . signal === undefined ? SessionLock . acquire ( { sessionID : input.sessionID } ) : undefined
2025-10-19 00:49:29 +08:00
const signal = input . signal ? ? lock ! . signal
2025-09-13 17:46:14 +08:00
await Session . update ( input . sessionID , ( draft ) = > {
draft . time . compacting = Date . now ( )
} )
await using _ = defer ( async ( ) = > {
await Session . update ( input . sessionID , ( draft ) = > {
draft . time . compacting = undefined
} )
} )
2025-11-08 03:46:58 +08:00
const toSummarize = await MessageV2 . filterCompacted ( MessageV2 . stream ( input . sessionID ) )
2025-09-13 17:46:14 +08:00
const model = await Provider . getModel ( input . providerID , input . modelID )
const system = [
. . . SystemPrompt . summarize ( model . providerID ) ,
. . . ( await SystemPrompt . environment ( ) ) ,
. . . ( await SystemPrompt . custom ( ) ) ,
]
const msg = ( await Session . updateMessage ( {
id : Identifier.ascending ( "message" ) ,
role : "assistant" ,
2025-10-23 03:01:13 +08:00
parentID : toSummarize.findLast ( ( m ) = > m . info . role === "user" ) ? . info . id ! ,
2025-09-13 17:46:14 +08:00
sessionID : input.sessionID ,
mode : "build" ,
path : {
cwd : Instance.directory ,
root : Instance.worktree ,
} ,
2025-11-01 03:07:36 +08:00
summary : true ,
2025-09-13 17:46:14 +08:00
cost : 0 ,
tokens : {
output : 0 ,
input : 0 ,
reasoning : 0 ,
cache : { read : 0 , write : 0 } ,
} ,
modelID : input.modelID ,
providerID : model.providerID ,
time : {
created : Date.now ( ) ,
} ,
} ) ) as MessageV2 . Assistant
2025-10-19 00:49:29 +08:00
2025-10-16 02:44:16 +08:00
const part = ( await Session . updatePart ( {
type : "text" ,
sessionID : input.sessionID ,
messageID : msg.id ,
id : Identifier.ascending ( "part" ) ,
text : "" ,
time : {
start : Date.now ( ) ,
} ,
} ) ) as MessageV2 . TextPart
2025-10-23 07:31:36 +08:00
const doStream = ( ) = >
streamText ( {
// set to 0, we handle loop
maxRetries : 0 ,
model : model.language ,
2025-11-08 09:59:02 +08:00
providerOptions : ProviderTransform.providerOptions ( model . npm , model . providerID , model . info . options ) ,
2025-10-31 12:35:26 +08:00
headers : model.info.headers ,
2025-10-23 07:31:36 +08:00
abortSignal : signal ,
onError ( error ) {
log . error ( "stream error" , {
error ,
} )
2025-09-13 17:46:14 +08:00
} ,
2025-10-28 03:03:30 +08:00
tools : model.info.tool_call ? { } : undefined ,
2025-10-23 07:31:36 +08:00
messages : [
. . . system . map (
( x ) : ModelMessage = > ( {
role : "system" ,
content : x ,
} ) ,
) ,
. . . MessageV2 . toModelMessage ( toSummarize ) ,
{
role : "user" ,
content : [
{
type : "text" ,
text : "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next." ,
} ,
] ,
} ,
] ,
} )
2025-10-16 02:44:16 +08:00
2025-10-23 07:31:36 +08:00
// TODO: reduce duplication between compaction.ts & prompt.ts
const process = async (
stream : StreamTextResult < Record < string , AITool > , never > ,
retries : { count : number ; max : number } ,
) = > {
let shouldRetry = false
try {
for await ( const value of stream . fullStream ) {
signal . throwIfAborted ( )
switch ( value . type ) {
case "text-delta" :
part . text += value . text
if ( value . providerMetadata ) part . metadata = value . providerMetadata
2025-10-28 13:08:30 +08:00
if ( part . text )
await Session . updatePart ( {
part ,
delta : value.text ,
} )
2025-10-23 07:31:36 +08:00
continue
case "text-end" : {
part . text = part . text . trimEnd ( )
part . time = {
start : Date.now ( ) ,
end : Date.now ( ) ,
}
if ( value . providerMetadata ) part . metadata = value . providerMetadata
await Session . updatePart ( part )
continue
2025-10-19 00:49:29 +08:00
}
2025-10-23 07:31:36 +08:00
case "finish-step" : {
const usage = Session . getUsage ( {
model : model.info ,
usage : value.usage ,
metadata : value.providerMetadata ,
} )
msg . cost += usage . cost
msg . tokens = usage . tokens
await Session . updateMessage ( msg )
continue
}
case "error" :
throw value . error
default :
continue
2025-10-19 00:49:29 +08:00
}
}
2025-10-23 07:31:36 +08:00
} catch ( e ) {
log . error ( "compaction error" , {
error : e ,
} )
const error = MessageV2 . fromError ( e , { providerID : input.providerID } )
2025-11-08 09:59:02 +08:00
if ( retries . count < retries . max && MessageV2 . APIError . isInstance ( error ) && error . data . isRetryable ) {
2025-10-23 07:31:36 +08:00
shouldRetry = true
await Session . updatePart ( {
id : Identifier.ascending ( "part" ) ,
messageID : msg.id ,
sessionID : msg.sessionID ,
type : "retry" ,
attempt : retries.count + 1 ,
time : {
created : Date.now ( ) ,
2025-10-19 00:49:29 +08:00
} ,
2025-10-23 07:31:36 +08:00
error ,
} )
} else {
msg . error = error
Bus . publish ( Session . Event . Error , {
sessionID : msg.sessionID ,
error : msg.error ,
} )
}
}
2025-11-08 03:46:58 +08:00
const parts = await MessageV2 . parts ( msg . id )
2025-10-23 07:31:36 +08:00
return {
info : msg ,
parts ,
shouldRetry ,
}
}
let stream = doStream ( )
2025-10-29 01:09:41 +08:00
const cfg = await Config . get ( )
const maxRetries = cfg . experimental ? . chatMaxRetries ? ? MAX_RETRIES
2025-10-23 07:31:36 +08:00
let result = await process ( stream , {
count : 0 ,
2025-10-29 01:09:41 +08:00
max : maxRetries ,
2025-10-23 07:31:36 +08:00
} )
if ( result . shouldRetry ) {
2025-11-10 01:46:58 +08:00
const start = Date . now ( )
2025-10-29 01:09:41 +08:00
for ( let retry = 1 ; retry < maxRetries ; retry ++ ) {
2025-11-08 09:59:02 +08:00
const lastRetryPart = result . parts . findLast ( ( p ) : p is MessageV2 . RetryPart = > p . type === "retry" )
2025-10-23 07:31:36 +08:00
if ( lastRetryPart ) {
2025-11-10 01:46:58 +08:00
const delayMs = SessionRetry . getBoundedDelay ( {
error : lastRetryPart.error ,
attempt : retry ,
startTime : start ,
} )
if ( ! delayMs ) {
break
}
2025-10-23 07:31:36 +08:00
log . info ( "retrying with backoff" , {
attempt : retry ,
delayMs ,
2025-11-10 01:46:58 +08:00
elapsed : Date.now ( ) - start ,
2025-10-23 07:31:36 +08:00
} )
const stop = await SessionRetry . sleep ( delayMs , signal )
. then ( ( ) = > false )
. catch ( ( error ) = > {
if ( error instanceof DOMException && error . name === "AbortError" ) {
const err = new MessageV2 . AbortedError (
{ message : error.message } ,
{
cause : error ,
} ,
) . toObject ( )
result . info . error = err
Bus . publish ( Session . Event . Error , {
sessionID : result.info.sessionID ,
error : result.info.error ,
} )
return true
}
throw error
} )
if ( stop ) break
}
stream = doStream ( )
result = await process ( stream , {
count : retry ,
2025-10-29 01:09:41 +08:00
max : maxRetries ,
2025-10-23 07:31:36 +08:00
} )
if ( ! result . shouldRetry ) {
2025-10-19 00:49:29 +08:00
break
2025-10-23 07:31:36 +08:00
}
2025-10-16 02:44:16 +08:00
}
}
2025-09-13 17:46:14 +08:00
2025-10-19 00:49:29 +08:00
msg . time . completed = Date . now ( )
2025-10-23 07:31:36 +08:00
if (
! msg . error ||
( MessageV2 . AbortedError . isInstance ( msg . error ) &&
2025-11-08 09:59:02 +08:00
result . parts . some ( ( part ) : part is MessageV2 . TextPart = > part . type === "text" && part . text . length > 0 ) )
2025-10-23 07:31:36 +08:00
) {
2025-10-19 00:49:29 +08:00
msg . summary = true
Bus . publish ( Event . Compacted , {
sessionID : input.sessionID ,
} )
}
await Session . updateMessage ( msg )
2025-09-13 17:46:14 +08:00
return {
info : msg ,
2025-10-23 07:31:36 +08:00
parts : result.parts ,
2025-09-13 17:46:14 +08:00
}
}
}