2424import io .agentscope .core .hook .Hook ;
2525import io .agentscope .core .hook .HookEvent ;
2626import io .agentscope .core .hook .PostActingEvent ;
27+ import io .agentscope .core .hook .PreReasoningEvent ;
28+ import io .agentscope .core .hook .PreSummaryEvent ;
2729import io .agentscope .core .memory .InMemoryMemory ;
2830import io .agentscope .core .message .ContentBlock ;
2931import io .agentscope .core .message .GenerateReason ;
4446import java .nio .file .Files ;
4547import java .nio .file .Path ;
4648import java .nio .file .Paths ;
49+ import java .util .ArrayList ;
4750import java .util .LinkedHashMap ;
4851import java .util .List ;
4952import java .util .Map ;
5053import java .util .Set ;
5154import java .util .concurrent .ConcurrentLinkedQueue ;
5255import java .util .concurrent .atomic .AtomicBoolean ;
56+ import java .util .concurrent .atomic .AtomicInteger ;
5357import org .slf4j .Logger ;
5458import org .slf4j .LoggerFactory ;
5559import org .springframework .beans .factory .InitializingBean ;
@@ -68,6 +72,12 @@ public class AgentService implements InitializingBean {
6872
6973 private static final ObjectMapper SSE_JSON = new ObjectMapper ();
7074
75+ /** Max length of the flattened transcript in each {@code ctx} SSE payload (large tool outputs). */
76+ private static final int CTX_FLAT_MAX_CHARS = 48_000 ;
77+
78+ /** Max length per text-like field inside structured {@code messages} in {@code ctx} payloads. */
79+ private static final int CTX_FIELD_MAX_CHARS = 8_000 ;
80+
7181 private static final Set <String > PLAN_TOOL_NAMES =
7282 Set .of (
7383 "create_plan" ,
@@ -111,6 +121,15 @@ public class AgentService implements InitializingBean {
111121 private final ConcurrentLinkedQueue <Map <String , Object >> pendingToolInputs =
112122 new ConcurrentLinkedQueue <>();
113123
124+ /**
125+ * Serialized {@code ctx} lines captured after all hooks adjust {@link PreReasoningEvent} /
126+ * {@link PreSummaryEvent} input. Drained before each streamed {@link Event} is mapped.
127+ */
128+ private final ConcurrentLinkedQueue <String > pendingContextSseLines =
129+ new ConcurrentLinkedQueue <>();
130+
131+ private final AtomicInteger contextSeq = new AtomicInteger (0 );
132+
114133 public AgentService (PlanService planService ) {
115134 this .planService = planService ;
116135 }
@@ -129,6 +148,8 @@ public void afterPropertiesSet() throws Exception {
129148
130149 private void initializeAgent () {
131150 pendingToolInputs .clear ();
151+ pendingContextSseLines .clear ();
152+ contextSeq .set (0 );
132153 memory = new InMemoryMemory ();
133154 toolkit = new Toolkit ();
134155 toolkit .registerTool (new FileToolMock ());
@@ -174,6 +195,28 @@ public <T extends HookEvent> Mono<T> onEvent(T event) {
174195 }
175196 };
176197
198+ /*
199+ * Low priority: run after other hooks so the payload matches what the model will receive.
200+ */
201+ Hook promptCaptureHook =
202+ new Hook () {
203+ @ Override
204+ public int priority () {
205+ return 1000 ;
206+ }
207+
208+ @ Override
209+ public <T extends HookEvent > Mono <T > onEvent (T event ) {
210+ if (event instanceof PreReasoningEvent pre ) {
211+ offerContextLine (
212+ "reasoning" , pre .getModelName (), pre .getInputMessages ());
213+ } else if (event instanceof PreSummaryEvent sum ) {
214+ offerContextLine ("summary" , sum .getModelName (), sum .getInputMessages ());
215+ }
216+ return Mono .just (event );
217+ }
218+ };
219+
177220 agent =
178221 ReActAgent .builder ()
179222 .name ("PlanAgent" )
@@ -196,6 +239,7 @@ public <T extends HookEvent> Mono<T> onEvent(T event) {
196239 .toolkit (toolkit )
197240 .maxIters (50 )
198241 .hook (planChangeHook )
242+ .hook (promptCaptureHook )
199243 .planNotebook (planNotebook )
200244 .build ();
201245 }
@@ -207,17 +251,18 @@ public Flux<String> chat(String sessionId, String message) {
207251 // Clear paused state when user sends a new message
208252 isPaused .set (false );
209253 pendingToolInputs .clear ();
254+ pendingContextSseLines .clear ();
255+ contextSeq .set (0 );
210256
211257 Msg userMsg =
212258 Msg .builder ()
213259 .role (MsgRole .USER )
214260 .content (TextBlock .builder ().text (message ).build ())
215261 .build ();
216262
217- return agent .stream (userMsg , createStreamOptions ())
218- .subscribeOn (Schedulers .boundedElastic ())
219- .map (this ::mapEventToString )
220- .filter (text -> text != null && !text .isEmpty ());
263+ return attachPendingContext (
264+ agent .stream (userMsg , createStreamOptions ())
265+ .subscribeOn (Schedulers .boundedElastic ()));
221266 }
222267
223268 /**
@@ -228,12 +273,12 @@ public Flux<String> resume(String sessionId) {
228273 if (isPaused .compareAndSet (true , false )) {
229274 log .info ("Resuming agent execution after user review" );
230275 pendingToolInputs .clear ();
276+ pendingContextSseLines .clear ();
277+ contextSeq .set (0 );
231278
232279 // Resume by calling agent.stream() with no input message
233- return agent .stream (createStreamOptions ())
234- .subscribeOn (Schedulers .boundedElastic ())
235- .map (this ::mapEventToString )
236- .filter (text -> text != null && !text .isEmpty ());
280+ return attachPendingContext (
281+ agent .stream (createStreamOptions ()).subscribeOn (Schedulers .boundedElastic ()));
237282 } else {
238283 log .warn ("Tried to resume but agent is not paused or already resuming" );
239284 return Flux .just ("Agent is not paused or is already resuming." );
@@ -248,6 +293,159 @@ private StreamOptions createStreamOptions() {
248293 .build ();
249294 }
250295
296+ private Flux <String > attachPendingContext (Flux <Event > events ) {
297+ return events .concatMap (
298+ event -> {
299+ List <String > prefix = drainPendingContextLines ();
300+ String mapped = mapEventToString (event );
301+ boolean hasPrefix = !prefix .isEmpty ();
302+ boolean hasBody = mapped != null && !mapped .isEmpty ();
303+ if (!hasPrefix && !hasBody ) {
304+ return Flux .empty ();
305+ }
306+ Flux <String > head = Flux .fromIterable (prefix );
307+ return hasBody ? head .concatWith (Flux .just (mapped )) : head ;
308+ });
309+ }
310+
311+ private List <String > drainPendingContextLines () {
312+ List <String > out = new ArrayList <>();
313+ String line ;
314+ while ((line = pendingContextSseLines .poll ()) != null ) {
315+ out .add (line );
316+ }
317+ return out ;
318+ }
319+
320+ private void offerContextLine (String phase , String modelName , List <Msg > messages ) {
321+ try {
322+ int seq = contextSeq .incrementAndGet ();
323+ Map <String , Object > root = new LinkedHashMap <>();
324+ root .put ("t" , "ctx" );
325+ root .put ("phase" , phase );
326+ root .put ("seq" , seq );
327+ root .put ("model" , modelName != null ? modelName : "" );
328+ List <Map <String , Object >> rows = new ArrayList <>();
329+ for (Msg m : messages ) {
330+ rows .add (msgToDebugRow (m ));
331+ }
332+ root .put ("messages" , rows );
333+ String flat = truncateIfNeeded (buildFlatTranscript (messages ), CTX_FLAT_MAX_CHARS );
334+ root .put ("flat" , flat );
335+ pendingContextSseLines .offer (SSE_JSON .writeValueAsString (root ));
336+ } catch (Exception e ) {
337+ log .warn ("Failed to serialize model context for SSE: {}" , e .getMessage ());
338+ }
339+ }
340+
341+ private static Map <String , Object > msgToDebugRow (Msg m ) {
342+ Map <String , Object > row = new LinkedHashMap <>();
343+ row .put ("role" , m .getRole ().name ());
344+ if (m .getName () != null ) {
345+ row .put ("name" , m .getName ());
346+ }
347+ List <Object > parts = new ArrayList <>();
348+ for (ContentBlock b : m .getContent ()) {
349+ parts .add (contentBlockToDebugMap (b ));
350+ }
351+ row .put ("content" , parts );
352+ return row ;
353+ }
354+
355+ private static Object contentBlockToDebugMap (ContentBlock b ) {
356+ if (b instanceof TextBlock tb ) {
357+ return Map .of (
358+ "type" ,
359+ "text" ,
360+ "text" ,
361+ truncateIfNeeded (
362+ tb .getText () != null ? tb .getText () : "" , CTX_FIELD_MAX_CHARS ));
363+ }
364+ if (b instanceof ThinkingBlock th ) {
365+ return Map .of (
366+ "type" ,
367+ "thinking" ,
368+ "thinking" ,
369+ truncateIfNeeded (
370+ th .getThinking () != null ? th .getThinking () : "" , CTX_FIELD_MAX_CHARS ));
371+ }
372+ if (b instanceof ToolUseBlock tu ) {
373+ Map <String , Object > m = new LinkedHashMap <>();
374+ m .put ("type" , "tool_use" );
375+ m .put ("id" , tu .getId ());
376+ m .put ("name" , tu .getName ());
377+ m .put ("input" , tu .getInput () != null ? tu .getInput () : Map .of ());
378+ String raw = tu .getContent ();
379+ if (raw != null && !raw .isEmpty ()) {
380+ m .put ("raw" , truncateIfNeeded (raw , CTX_FIELD_MAX_CHARS ));
381+ }
382+ return m ;
383+ }
384+ if (b instanceof ToolResultBlock tr ) {
385+ return Map .of (
386+ "type" ,
387+ "tool_result" ,
388+ "name" ,
389+ tr .getName () != null ? tr .getName () : "" ,
390+ "output" ,
391+ truncateIfNeeded (flattenToolOutput (tr ), CTX_FIELD_MAX_CHARS ));
392+ }
393+ return Map .of (
394+ "type" , "other" , "repr" , truncateIfNeeded (String .valueOf (b ), CTX_FIELD_MAX_CHARS ));
395+ }
396+
397+ private static String buildFlatTranscript (List <Msg > messages ) {
398+ StringBuilder sb = new StringBuilder ();
399+ for (Msg m : messages ) {
400+ sb .append ("--- " ).append (m .getRole ().name ());
401+ if (m .getName () != null ) {
402+ sb .append (" (" ).append (m .getName ()).append (')' );
403+ }
404+ sb .append (" ---\n " );
405+ for (ContentBlock b : m .getContent ()) {
406+ appendContentBlockForFlat (sb , b );
407+ }
408+ sb .append ('\n' );
409+ }
410+ return sb .toString ();
411+ }
412+
413+ private static void appendContentBlockForFlat (StringBuilder sb , ContentBlock o ) {
414+ if (o instanceof TextBlock tb ) {
415+ sb .append (tb .getText () != null ? tb .getText () : "" );
416+ } else if (o instanceof ThinkingBlock th ) {
417+ sb .append (th .getThinking () != null ? th .getThinking () : "" );
418+ } else if (o instanceof ToolUseBlock tu ) {
419+ sb .append ("[tool_use " )
420+ .append (tu .getName ())
421+ .append (" id=" )
422+ .append (tu .getId ())
423+ .append ("] " );
424+ sb .append (tu .getInput () != null ? tu .getInput ().toString () : "{}" );
425+ String raw = tu .getContent ();
426+ if (raw != null && !raw .isEmpty ()) {
427+ sb .append (" raw=" ).append (raw );
428+ }
429+ sb .append ('\n' );
430+ } else if (o instanceof ToolResultBlock tr ) {
431+ sb .append ("[tool_result " ).append (tr .getName ()).append ("]\n " );
432+ sb .append (flattenToolOutput (tr ));
433+ sb .append ('\n' );
434+ } else {
435+ sb .append (o );
436+ }
437+ }
438+
439+ private static String truncateIfNeeded (String s , int max ) {
440+ if (s == null ) {
441+ return "" ;
442+ }
443+ if (s .length () <= max ) {
444+ return s ;
445+ }
446+ return s .substring (0 , max ) + "\n ...(truncated)" ;
447+ }
448+
251449 /**
252450 * Maps a stream event to one SSE line: a JSON object {@code {t: kind, ...}} so the UI can
253451 * interleave thinking, answer text, and tool results, then render Markdown.
0 commit comments