Skip to content

Commit 8349c79

Browse files
committed
Enhance PlanNotebook with model context inspection feature and UI improvements
1 parent 9174548 commit 8349c79

3 files changed

Lines changed: 526 additions & 8 deletions

File tree

agentscope-examples/plan-notebook/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A Spring Boot web application demonstrating the PlanNotebook component of AgentS
77
PlanNotebook is an interactive example that showcases how to integrate the AgentScope Java SDK's planning capabilities into a web application. It features:
88

99
- **SSE Streaming**: Real-time agent response streaming via Server-Sent Events
10+
- **Model context inspection**: Each LLM call emits a `ctx` SSE payload (system + memory as sent); the UI shows JSON, a flat transcript, and a line diff vs the previous call in the same reply
1011
- **Plan Management**: Create, update, and track plans with subtasks
1112
- **Interactive Web UI**: Built-in web interface for chat and plan visualization
1213
- **REST API**: Comprehensive API for programmatic plan management

agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/service/AgentService.java

Lines changed: 206 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import io.agentscope.core.hook.Hook;
2525
import io.agentscope.core.hook.HookEvent;
2626
import io.agentscope.core.hook.PostActingEvent;
27+
import io.agentscope.core.hook.PreReasoningEvent;
28+
import io.agentscope.core.hook.PreSummaryEvent;
2729
import io.agentscope.core.memory.InMemoryMemory;
2830
import io.agentscope.core.message.ContentBlock;
2931
import io.agentscope.core.message.GenerateReason;
@@ -44,12 +46,14 @@
4446
import java.nio.file.Files;
4547
import java.nio.file.Path;
4648
import java.nio.file.Paths;
49+
import java.util.ArrayList;
4750
import java.util.LinkedHashMap;
4851
import java.util.List;
4952
import java.util.Map;
5053
import java.util.Set;
5154
import java.util.concurrent.ConcurrentLinkedQueue;
5255
import java.util.concurrent.atomic.AtomicBoolean;
56+
import java.util.concurrent.atomic.AtomicInteger;
5357
import org.slf4j.Logger;
5458
import org.slf4j.LoggerFactory;
5559
import 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

Comments
 (0)