44 type ConversationMessageRecord ,
55 type ConversationMessageRepository ,
66 type ConversationSummaryRecord ,
7- type ConversationSummaryRepository
7+ type ConversationSummaryRepository ,
8+ type RunEventRecord ,
9+ type RunEventRepository
810} from "@datafoundry/metadata" ;
911import { createHash } from "node:crypto" ;
1012
@@ -53,6 +55,7 @@ export type ConversationMemoryServiceInput = {
5355 memoryBridge ?: ConversationMemoryBridge | undefined ;
5456 policy ?: ConversationMemoryWindowPolicy | undefined ;
5557 repository : ConversationMessageRepository ;
58+ runEvents ?: RunEventRepository | undefined ;
5659 sessionId : string ;
5760 summarizer ?: ConversationSummarizer | undefined ;
5861 summaryRepository ?: ConversationSummaryRepository | undefined ;
@@ -83,6 +86,7 @@ type BuildConversationMessagesInput = {
8386 runId : string ;
8487 runInput : RunAgentInput ;
8588 summary ?: ConversationSummaryRecord | undefined ;
89+ toolCheckpoints ?: Map < string , string > | undefined ;
8690 historyLimit ?: number ;
8791 maxMessageChars ?: number ;
8892} ;
@@ -143,6 +147,14 @@ export class ConversationMemoryService {
143147 session_id : this . input . sessionId
144148 } ) ;
145149 const effectiveHistory = summary ? history . filter ( ( record ) => record . position > summary . to_position ) : history ;
150+ const toolCheckpoints = this . input . runEvents
151+ ? buildToolCheckpointsForHistory ( {
152+ history : effectiveHistory ,
153+ maxChars : policy . maxMessageChars ,
154+ runEvents : this . input . runEvents ,
155+ userId : this . input . userId
156+ } )
157+ : undefined ;
146158 return buildConversationMemoryMessagesWithReport ( {
147159 compactMemorySource : this . input . compactMemorySource ,
148160 currentUserText : input . currentUserText ,
@@ -152,6 +164,7 @@ export class ConversationMemoryService {
152164 runId : input . runId ,
153165 runInput : input . runInput ,
154166 summary,
167+ toolCheckpoints,
155168 tokenCounter : this . tokenCounter
156169 } ) ;
157170 }
@@ -253,7 +266,7 @@ export const buildConversationMemoryMessagesWithReport = (
253266 const currentUserMessageId = lastUserMessage ( input . runInput ) ?. id ?? `${ input . runId } :user` ;
254267 const messages : Message [ ] = selected . summary ? [ summaryToMessage ( selected . summary , policy . maxMessageChars ) ] : [ ] ;
255268 messages . push (
256- ...normalizeHistoryMessagePairs ( selected . history , policy . maxMessageChars )
269+ ...normalizeHistoryMessagePairs ( selected . history , policy . maxMessageChars , input . toolCheckpoints )
257270 ) ;
258271
259272 messages . push ( {
@@ -510,28 +523,188 @@ const ORPHANED_USER_MESSAGE_PLACEHOLDER =
510523
511524const normalizeHistoryMessagePairs = (
512525 records : ConversationMessageRecord [ ] ,
513- maxMessageChars : number
526+ maxMessageChars : number ,
527+ toolCheckpoints ?: Map < string , string > | undefined
514528) : Message [ ] => {
515529 const result : Message [ ] = [ ] ;
530+ const consumedCheckpointRunIds = new Set < string > ( ) ;
531+ const consumeToolCheckpoint = ( runId : string ) : string | undefined => {
532+ if ( consumedCheckpointRunIds . has ( runId ) ) {
533+ return undefined ;
534+ }
535+ consumedCheckpointRunIds . add ( runId ) ;
536+ return toolCheckpoints ?. get ( runId ) ;
537+ } ;
516538 for ( let i = 0 ; i < records . length ; i ++ ) {
517539 const cur = records [ i ] ! ;
518540 const next = records [ i + 1 ] as ConversationMessageRecord | undefined ;
541+ const checkpoint = cur . role === "assistant" ? consumeToolCheckpoint ( cur . run_id ) : undefined ;
519542 result . push ( {
520543 id : `memory:${ cur . id } ` ,
521544 role : cur . role ,
522- content : boundText ( cur . content_text , maxMessageChars )
545+ content : boundText ( appendToolCheckpoint ( cur . content_text , checkpoint ) , maxMessageChars )
523546 } ) ;
524547 if ( cur . role === "user" && ( ! next || next . role === "user" ) ) {
548+ const orphanedCheckpoint = consumeToolCheckpoint ( cur . run_id ) ;
525549 result . push ( {
526550 id : `memory:${ cur . id } :error-placeholder` ,
527551 role : "assistant" ,
528- content : ORPHANED_USER_MESSAGE_PLACEHOLDER
552+ content : boundText (
553+ appendToolCheckpoint ( ORPHANED_USER_MESSAGE_PLACEHOLDER , orphanedCheckpoint ) ,
554+ maxMessageChars
555+ )
529556 } ) ;
530557 }
531558 }
532559 return result ;
533560} ;
534561
562+ const appendToolCheckpoint = ( content : string , checkpoint : string | undefined ) : string =>
563+ checkpoint ? `${ content } \n\n${ checkpoint } ` : content ;
564+
565+ const buildToolCheckpointsForHistory = ( input : {
566+ history : ConversationMessageRecord [ ] ;
567+ maxChars : number ;
568+ runEvents : RunEventRepository ;
569+ userId : string ;
570+ } ) : Map < string , string > => {
571+ const runIds = [ ...new Set ( input . history . map ( ( record ) => record . run_id ) ) ] ;
572+ const checkpoints = new Map < string , string > ( ) ;
573+ for ( const runId of runIds ) {
574+ const events = input . runEvents . listByRun ( { user_id : input . userId , run_id : runId } ) ;
575+ const checkpoint = toolCheckpointText ( runId , events , input . maxChars ) ;
576+ if ( checkpoint ) {
577+ checkpoints . set ( runId , checkpoint ) ;
578+ }
579+ }
580+ return checkpoints ;
581+ } ;
582+
583+ type ToolCheckpointCall = {
584+ args ?: unknown ;
585+ result ?: unknown ;
586+ resultSeq ?: number ;
587+ status : "completed" | "failed" ;
588+ toolCallId : string ;
589+ toolName ?: string ;
590+ } ;
591+
592+ const toolCheckpointText = (
593+ runId : string ,
594+ events : RunEventRecord [ ] ,
595+ maxChars : number
596+ ) : string | undefined => {
597+ const calls = new Map < string , Partial < ToolCheckpointCall > & { toolCallId : string } > ( ) ;
598+ for ( const eventRecord of events ) {
599+ const event = parseJsonObject ( eventRecord . payload_json ) ;
600+ const type = stringValue ( event . type ) ;
601+ const toolCallId = stringValue ( event . toolCallId ) ;
602+ if ( ! type || ! toolCallId ) {
603+ continue ;
604+ }
605+ const existing = calls . get ( toolCallId ) ?? { toolCallId } ;
606+ const toolName = stringValue ( event . toolCallName ) ?? existing . toolName ;
607+ if ( type === EventType . TOOL_CALL_START || type === EventType . TOOL_CALL_END ) {
608+ calls . set ( toolCallId , {
609+ ...existing ,
610+ ...( toolName ? { toolName } : { } ) ,
611+ ...( existing . args !== undefined ? { args : existing . args } : { } ) ,
612+ ...( existing . args === undefined && toolCallArgs ( event ) !== undefined ? { args : toolCallArgs ( event ) } : { } )
613+ } ) ;
614+ continue ;
615+ }
616+ if ( type === EventType . TOOL_CALL_RESULT ) {
617+ calls . set ( toolCallId , {
618+ ...existing ,
619+ ...( toolName ? { toolName } : { } ) ,
620+ result : event . content ,
621+ resultSeq : eventRecord . seq ,
622+ status : isToolResultError ( event . content ) ? "failed" : "completed"
623+ } ) ;
624+ }
625+ }
626+
627+ const completed = [ ...calls . values ( ) ]
628+ . filter ( ( call ) : call is ToolCheckpointCall => call . result !== undefined && call . status !== undefined )
629+ . sort ( ( a , b ) => ( a . resultSeq ?? 0 ) - ( b . resultSeq ?? 0 ) ) ;
630+ if ( completed . length === 0 ) {
631+ return undefined ;
632+ }
633+
634+ const maxPayloadChars = Math . max ( 500 , Math . floor ( maxChars / Math . max ( completed . length , 1 ) ) - 400 ) ;
635+ const blocks = completed . map ( ( call ) => [
636+ `<tool_checkpoint run_id="${ escapeAttribute ( runId ) } " tool_call_id="${ escapeAttribute ( call . toolCallId ) } "`
637+ + `${ call . toolName ? ` tool_name="${ escapeAttribute ( call . toolName ) } "` : "" } `
638+ + ` status="${ call . status } ">` ,
639+ "Completed tool observation from a previous run. Reuse this result when it answers the current task; repeat the tool only if the result is stale or insufficient." ,
640+ call . args !== undefined ? `args: ${ boundedSerializedValue ( call . args , Math . min ( 1200 , maxPayloadChars ) ) } ` : undefined ,
641+ `result: ${ boundedSerializedValue ( call . result , maxPayloadChars ) } ` ,
642+ "</tool_checkpoint>"
643+ ] . filter ( ( line ) : line is string => line !== undefined ) . join ( "\n" ) ) ;
644+
645+ return boundText ( blocks . join ( "\n\n" ) , maxChars ) ;
646+ } ;
647+
648+ const toolCallArgs = ( event : Record < string , unknown > ) : unknown => {
649+ if ( event . args !== undefined ) {
650+ return event . args ;
651+ }
652+ if ( event . input !== undefined ) {
653+ return event . input ;
654+ }
655+ if ( typeof event . argsText === "string" ) {
656+ return parseJsonValue ( event . argsText ) ?? event . argsText ;
657+ }
658+ return undefined ;
659+ } ;
660+
661+ const boundedSerializedValue = ( value : unknown , maxChars : number ) : string =>
662+ escapeTaggedText ( boundText ( serializeToolValue ( value ) , maxChars ) ) ;
663+
664+ const serializeToolValue = ( value : unknown ) : string => {
665+ if ( typeof value === "string" ) {
666+ return value ;
667+ }
668+ try {
669+ return JSON . stringify ( value ) ;
670+ } catch {
671+ return String ( value ) ;
672+ }
673+ } ;
674+
675+ const parseJsonObject = ( value : string ) : Record < string , unknown > => {
676+ const parsed = parseJsonValue ( value ) ;
677+ return isRecord ( parsed ) ? parsed : { } ;
678+ } ;
679+
680+ const parseJsonValue = ( value : string ) : unknown => {
681+ try {
682+ return JSON . parse ( value ) as unknown ;
683+ } catch {
684+ return undefined ;
685+ }
686+ } ;
687+
688+ const isToolResultError = ( value : unknown ) : boolean =>
689+ isRecord ( value )
690+ && ( value . error === true
691+ || typeof value . error === "string"
692+ || typeof value . errorMessage === "string"
693+ || value . isError === true ) ;
694+
695+ const escapeAttribute = ( value : string ) : string =>
696+ value
697+ . replaceAll ( "&" , "&" )
698+ . replaceAll ( "\"" , """ )
699+ . replaceAll ( "<" , "<" )
700+ . replaceAll ( ">" , ">" ) ;
701+
702+ const escapeTaggedText = ( value : string ) : string =>
703+ value
704+ . replaceAll ( "&" , "&" )
705+ . replaceAll ( "<" , "<" )
706+ . replaceAll ( ">" , ">" ) ;
707+
535708const selectBudgetedHistory = ( input : {
536709 currentUserText : string ;
537710 history : ConversationMessageRecord [ ] ;
@@ -653,6 +826,9 @@ const readEventField = (event: BaseEvent, key: string): unknown => (event as Rec
653826
654827const stringValue = ( value : unknown ) : string | undefined => ( typeof value === "string" ? value : undefined ) ;
655828
829+ const isRecord = ( value : unknown ) : value is Record < string , unknown > =>
830+ typeof value === "object" && value !== null && ! Array . isArray ( value ) ;
831+
656832const conversationRole = ( value : unknown ) : AssistantDraft [ "role" ] | undefined => {
657833 if ( value === "assistant" || value === "user" || value === "system" || value === "developer" ) {
658834 return value ;
0 commit comments