11import { resolveUserTimezone } from "../../agents/date-time.js" ;
2+ import {
3+ escapeInternalRuntimeContextDelimiters ,
4+ INTERNAL_RUNTIME_CONTEXT_BEGIN ,
5+ INTERNAL_RUNTIME_CONTEXT_END ,
6+ } from "../../agents/internal-runtime-context.js" ;
27import type { OpenClawConfig } from "../../config/types.openclaw.js" ;
38import { buildChannelSummary } from "../../infra/channel-summary.js" ;
49import {
@@ -11,32 +16,74 @@ import {
1116 consumeSelectedSystemEventEntries ,
1217 peekSystemEventEntries ,
1318 type SystemEvent ,
19+ type SystemEventAudience ,
1420} from "../../infra/system-events.js" ;
1521import {
1622 normalizeLowercaseStringOrEmpty ,
1723 normalizeOptionalString ,
1824} from "../../shared/string-coerce.js" ;
1925
20- function selectGenericSystemEvents ( events : readonly SystemEvent [ ] ) : SystemEvent [ ] {
21- return events . filter ( ( event ) => ! isExecCompletionEvent ( event . text ) ) ;
26+ // Exclude user-facing exec-completion events so the heartbeat path can
27+ // consume them via its own relay surface. `audience: "internal"` events
28+ // always belong on this generic drain (which routes them to the
29+ // INTERNAL_RUNTIME_CONTEXT wrap) regardless of text shape — otherwise an
30+ // exec-shaped internal event (e.g. cron output literally starting with
31+ // "Exec finished...") falls into a no-consumer hole: this filter would
32+ // strand it for the heartbeat path, but the heartbeat exec/consume
33+ // selectors skip internal events. The audience field is the source of
34+ // truth for routing; text-shape only matters for user-facing events.
35+ //
36+ // `isHeartbeat=true` callers leave `audience: "internal"` events queued.
37+ // Heartbeat replies use `buildReplyPromptEnvelopeBase`'s fixed transcript
38+ // prompt and do not preserve `systemEventBlocks` on the prompt body, so
39+ // draining (= consuming) an internal event during a heartbeat would
40+ // silently drop the wrapped runtime-context block before the next
41+ // non-heartbeat user turn can see it. Wait for the regular reply turn,
42+ // which DOES carry `systemEventBlocks` and therefore actually delivers
43+ // the wrapped context to the model.
44+ function selectGenericSystemEvents (
45+ events : readonly SystemEvent [ ] ,
46+ isHeartbeat : boolean ,
47+ ) : SystemEvent [ ] {
48+ const selected : SystemEvent [ ] = [ ] ;
49+ for ( const event of events ) {
50+ if ( event . audience === "internal" ) {
51+ if ( ! isHeartbeat ) {
52+ selected . push ( event ) ;
53+ }
54+ continue ;
55+ }
56+ if ( ! isExecCompletionEvent ( event . text ) ) {
57+ selected . push ( event ) ;
58+ }
59+ }
60+ return selected ;
2261}
2362
24- function compactSystemEvent ( line : string ) : string | null {
63+ function compactSystemEvent ( line : string , audience : SystemEventAudience ) : string | null {
2564 const trimmed = line . trim ( ) ;
2665 if ( ! trimmed ) {
2766 return null ;
2867 }
29- const lower = normalizeLowercaseStringOrEmpty ( trimmed ) ;
30- if ( lower . includes ( "reason periodic" ) ) {
31- return null ;
32- }
33- // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat".
34- // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this.
35- if ( lower . startsWith ( "read heartbeat.md" ) ) {
36- return null ;
37- }
38- if ( lower . includes ( "heartbeat poll" ) || lower . includes ( "heartbeat wake" ) ) {
39- return null ;
68+ // Heartbeat-noise filters keep user-facing relay prompts clean. They do
69+ // NOT apply to audience: "internal" events — those go through the
70+ // wrap-on-drain path and never reach a user-facing surface, so the
71+ // filter would silently drop the event after consumption (same
72+ // no-consumer hole class as the exec-shape filter). The Node:
73+ // transformation below is a sanitizer that runs for both audiences.
74+ if ( audience !== "internal" ) {
75+ const lower = normalizeLowercaseStringOrEmpty ( trimmed ) ;
76+ if ( lower . includes ( "reason periodic" ) ) {
77+ return null ;
78+ }
79+ // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat".
80+ // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this.
81+ if ( lower . startsWith ( "read heartbeat.md" ) ) {
82+ return null ;
83+ }
84+ if ( lower . includes ( "heartbeat poll" ) || lower . includes ( "heartbeat wake" ) ) {
85+ return null ;
86+ }
4087 }
4188 if ( trimmed . startsWith ( "Node:" ) ) {
4289 return trimmed . replace ( / · l a s t i n p u t [ ^ · ] + / i, "" ) . trim ( ) ;
@@ -89,27 +136,52 @@ export async function drainFormattedSystemEvents(params: {
89136 sessionKey : string ;
90137 isMainSession : boolean ;
91138 isNewSession : boolean ;
139+ isHeartbeat ?: boolean ;
92140} ) : Promise < string | undefined > {
93141 const summaryLines : string [ ] = [ ] ;
94- const systemLines : string [ ] = [ ] ;
142+ const userFacingLines : string [ ] = [ ] ;
143+ const internalLines : string [ ] = [ ] ;
95144 // Exec completions have a dedicated heartbeat prompt; leave those entries queued
96145 // so the heartbeat path can consume and deliver them.
97146 const queued = consumeSelectedSystemEventEntries (
98147 params . sessionKey ,
99- selectGenericSystemEvents ( peekSystemEventEntries ( params . sessionKey ) ) ,
148+ selectGenericSystemEvents (
149+ peekSystemEventEntries ( params . sessionKey ) ,
150+ params . isHeartbeat === true ,
151+ ) ,
100152 ) ;
101153 for ( const event of queued ) {
102- const compacted = compactSystemEvent ( event . text ) ;
154+ const audience : SystemEventAudience = event . audience ?? "user-facing" ;
155+ const compacted = compactSystemEvent ( event . text , audience ) ;
103156 if ( ! compacted ) {
104157 continue ;
105158 }
106159 const timestamp = `[${ formatSystemEventTimestamp ( event . ts , params . cfg ) } ]` ;
160+ const target = audience === "internal" ? internalLines : userFacingLines ;
107161 let index = 0 ;
108162 for ( const subline of compacted . split ( "\n" ) ) {
109- systemLines . push ( `System: ${ index === 0 ? `${ timestamp } ` : "" } ${ subline } ` ) ;
163+ target . push ( `System: ${ index === 0 ? `${ timestamp } ` : "" } ${ subline } ` ) ;
110164 index += 1 ;
111165 }
112166 }
167+ const systemLines : string [ ] = [ ...userFacingLines ] ;
168+ if ( internalLines . length > 0 ) {
169+ // Wrap internal-audience events in the runtime-context delimiters so the
170+ // agent runtime sees the content but user-facing surfaces strip it via
171+ // the existing stripInternalRuntimeContext consumers. Framing matches
172+ // `formatAgentInternalEventsForPrompt` in `src/agents/internal-events.ts`
173+ // (BEGIN, header, blank line, body, END): the header line tells the
174+ // model the wrapped block is runtime context, not user-authored, so it
175+ // should not echo the contents back into its reply.
176+ systemLines . push ( INTERNAL_RUNTIME_CONTEXT_BEGIN ) ;
177+ systemLines . push ( "OpenClaw runtime context (internal):" ) ;
178+ systemLines . push (
179+ "This context is runtime-generated, not user-authored. Keep internal details private." ,
180+ ) ;
181+ systemLines . push ( "" ) ;
182+ systemLines . push ( escapeInternalRuntimeContextDelimiters ( internalLines . join ( "\n" ) ) ) ;
183+ systemLines . push ( INTERNAL_RUNTIME_CONTEXT_END ) ;
184+ }
113185 if ( params . isMainSession && params . isNewSession ) {
114186 const summary = await buildChannelSummary ( params . cfg ) ;
115187 if ( summary . length > 0 ) {
0 commit comments