From 7b30fb56bbb13a0a0a0e19cf3e2ccc362bb1f8b6 Mon Sep 17 00:00:00 2001 From: LearningGp Date: Fri, 30 Jan 2026 13:52:51 +0800 Subject: [PATCH 1/3] feat(plan-notebook): add Human-in-the-Loop (HITL) support for plan execution - Add pause/resume/stop API endpoints in ChatController - Implement stop request and paused state tracking in AgentService - Add hook to pause after plan tool execution when stop requested - Add control buttons (Stop/Continue) and paused indicator in frontend - Handle [PAUSED] marker in SSE stream for UI state management Change-Id: Ib6d1c15ed99f97917d016003e757fb03ba00ecbc Co-developed-by: Qoder CLI --- .../controller/ChatController.java | 51 +++ .../plannotebook/service/AgentService.java | 118 ++++++- .../src/main/resources/static/index.html | 295 ++++++++++++++++-- 3 files changed, 420 insertions(+), 44 deletions(-) diff --git a/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/controller/ChatController.java b/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/controller/ChatController.java index f3b7c6a09..21f78be7d 100644 --- a/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/controller/ChatController.java +++ b/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/controller/ChatController.java @@ -16,6 +16,7 @@ package io.agentscope.examples.plannotebook.controller; import io.agentscope.examples.plannotebook.service.AgentService; +import java.util.Map; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -51,6 +52,56 @@ public Flux chat( return agentService.chat(sessionId, message); } + /** + * Resume agent execution after user review. + * This endpoint is called when user clicks "Continue" button after reviewing/modifying the plan. + * + * @param sessionId Session ID (optional, defaults to "default") + * @return Flux of streaming text chunks + */ + @GetMapping(path = "/resume", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux resume(@RequestParam(defaultValue = "default") String sessionId) { + return agentService.resume(sessionId); + } + + /** + * Get the current pause state of the agent. + * + * @param sessionId Session ID (optional, defaults to "default", reserved for future multi-session support) + * @return Map containing isPaused boolean + */ + @GetMapping("/paused") + public Map isPaused(@RequestParam(defaultValue = "default") String sessionId) { + return Map.of("isPaused", agentService.isPaused()); + } + + /** + * Request the agent to stop after the next plan tool execution. + * The agent will continue running until a plan-related tool is executed, then pause. + * + * @param sessionId Session ID (optional, defaults to "default", reserved for future multi-session support) + * @return Map containing stopRequested status + */ + @PostMapping("/stop") + public Map requestStop( + @RequestParam(defaultValue = "default") String sessionId) { + agentService.requestStop(); + return Map.of( + "stopRequested", true, "message", "Will pause after next plan tool execution"); + } + + /** + * Get the current stop requested state. + * + * @param sessionId Session ID (optional, defaults to "default", reserved for future multi-session support) + * @return Map containing stopRequested boolean + */ + @GetMapping("/stop-requested") + public Map isStopRequested( + @RequestParam(defaultValue = "default") String sessionId) { + return Map.of("stopRequested", agentService.isStopRequested()); + } + /** * Health check endpoint. */ diff --git a/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/service/AgentService.java b/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/service/AgentService.java index a526ded88..c1a69129e 100644 --- a/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/service/AgentService.java +++ b/agentscope-examples/plan-notebook/src/main/java/io/agentscope/examples/plannotebook/service/AgentService.java @@ -16,6 +16,7 @@ package io.agentscope.examples.plannotebook.service; import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Event; import io.agentscope.core.agent.EventType; import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.formatter.dashscope.DashScopeChatFormatter; @@ -23,6 +24,7 @@ import io.agentscope.core.hook.HookEvent; import io.agentscope.core.hook.PostActingEvent; import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.message.GenerateReason; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; @@ -31,6 +33,7 @@ import io.agentscope.core.tool.Toolkit; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -67,6 +70,12 @@ public class AgentService implements InitializingBean { private InMemoryMemory memory; private Toolkit toolkit; + // Track if agent is paused waiting for user to continue + private final AtomicBoolean isPaused = new AtomicBoolean(false); + + // Track if user has requested to stop (will pause on next plan tool execution) + private final AtomicBoolean stopRequested = new AtomicBoolean(false); + public AgentService(PlanService planService) { this.planService = planService; } @@ -91,7 +100,7 @@ private void initializeAgent() { PlanNotebook planNotebook = PlanNotebook.builder().build(); planService.setPlanNotebook(planNotebook); - // Create hook to detect plan changes + // Create hook to detect plan changes and pause for user review when stop is requested Hook planChangeHook = new Hook() { @Override @@ -101,6 +110,14 @@ public Mono onEvent(T event) { if (PLAN_TOOL_NAMES.contains(toolName)) { // Broadcast plan change planService.broadcastPlanChange(); + // Only stop if user has requested it + if (stopRequested.compareAndSet(true, false)) { + log.info( + "Plan tool '{}' executed, pausing for user review", + toolName); + isPaused.set(true); + postActing.stopAgent(); + } } } return Mono.just(event); @@ -132,38 +149,105 @@ public Mono onEvent(T event) { * Send a message to the agent and get streaming response. */ public Flux chat(String sessionId, String message) { + // Clear paused state when user sends a new message + isPaused.set(false); + Msg userMsg = Msg.builder() .role(MsgRole.USER) .content(TextBlock.builder().text(message).build()) .build(); - StreamOptions streamOptions = - StreamOptions.builder() - .eventTypes(EventType.REASONING, EventType.TOOL_RESULT) - .incremental(true) - .build(); + return agent.stream(userMsg, createStreamOptions()) + .subscribeOn(Schedulers.boundedElastic()) + .map(this::mapEventToString) + .filter(text -> text != null && !text.isEmpty()); + } + + /** + * Resume agent execution after user review. + * This is called when user clicks "Continue" button after reviewing/modifying the plan. + */ + public Flux resume(String sessionId) { + if (!isPaused.get()) { + log.warn("Tried to resume but agent is not paused"); + return Flux.just("Agent is not paused."); + } + + log.info("Resuming agent execution after user review"); + isPaused.set(false); - return agent.stream(userMsg, streamOptions) + // Resume by calling agent.stream() with no input message + return agent.stream(createStreamOptions()) .subscribeOn(Schedulers.boundedElastic()) - .filter(event -> !event.isLast()) - .map( - event -> { - List textBlocks = - event.getMessage().getContentBlocks(TextBlock.class); - if (!textBlocks.isEmpty()) { - return textBlocks.get(0).getText(); - } - return ""; - }) + .map(this::mapEventToString) .filter(text -> text != null && !text.isEmpty()); } + private StreamOptions createStreamOptions() { + return StreamOptions.builder() + .eventTypes(EventType.REASONING, EventType.TOOL_RESULT, EventType.AGENT_RESULT) + .incremental(true) + .build(); + } + + /** + * Map a stream event to a string for SSE output. + */ + private String mapEventToString(Event event) { + // Handle AGENT_RESULT events (agent execution ended) + if (event.getType() == EventType.AGENT_RESULT) { + Msg msg = event.getMessage(); + if (msg != null && msg.getGenerateReason() == GenerateReason.ACTING_STOP_REQUESTED) { + isPaused.set(true); + return "[PAUSED]"; + } + // Normal completion - content already streamed via REASONING chunks + return ""; + } + + // Skip final accumulated messages in incremental mode to avoid duplicate output + if (event.isLast()) { + return ""; + } + + List textBlocks = event.getMessage().getContentBlocks(TextBlock.class); + if (!textBlocks.isEmpty()) { + return textBlocks.get(0).getText(); + } + return ""; + } + + /** + * Check if the agent is currently paused. + */ + public boolean isPaused() { + return isPaused.get(); + } + + /** + * Request the agent to stop after the next plan tool execution. + * This sets a flag that will cause the agent to pause after executing any plan-related tool. + */ + public void requestStop() { + log.info("User requested stop - will pause after next plan tool execution"); + stopRequested.set(true); + } + + /** + * Check if a stop has been requested. + */ + public boolean isStopRequested() { + return stopRequested.get(); + } + /** * Reset the agent, clearing all conversations and plans. */ public void reset() { log.info("Resetting agent and clearing all data"); + isPaused.set(false); + stopRequested.set(false); FileToolMock.clearStorage(); initializeAgent(); planService.broadcastPlanChange(); diff --git a/agentscope-examples/plan-notebook/src/main/resources/static/index.html b/agentscope-examples/plan-notebook/src/main/resources/static/index.html index 2023407f0..80e33045d 100644 --- a/agentscope-examples/plan-notebook/src/main/resources/static/index.html +++ b/agentscope-examples/plan-notebook/src/main/resources/static/index.html @@ -204,6 +204,51 @@ cursor: not-allowed; } + /* Control buttons next to Send */ + .control-btn { + padding: 12px 20px; + border: none; + border-radius: 24px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s, transform 0.1s; + display: none; + } + + .control-btn:hover { + opacity: 0.9; + } + + .control-btn:active { + transform: scale(0.98); + } + + .control-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .control-btn.stop { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + } + + .control-btn.waiting { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + } + + .control-btn.continue { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + animation: pulse 2s infinite; + } + + .control-btn.visible { + display: inline-block; + } + /* Plan Panel */ .plan-panel { flex: 0 0 45%; @@ -455,12 +500,13 @@ /* Plan Actions */ .plan-actions { - padding: 16px 20px; + padding: 12px 20px; background: white; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; flex-wrap: wrap; + align-items: center; } .action-btn { @@ -488,6 +534,44 @@ opacity: 0.9; } + .action-btn.continue { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border: none; + color: white; + animation: pulse 2s infinite; + } + + .action-btn.continue:hover { + opacity: 0.9; + } + + @keyframes pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } + 50% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); } + } + + /* Paused status indicator */ + .paused-indicator { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border-radius: 6px; + color: #92400e; + font-size: 0.8rem; + font-weight: 500; + white-space: nowrap; + } + + .paused-indicator.hidden { + display: none; + } + + .paused-icon { + font-size: 0.9rem; + } + /* Modal */ .modal-overlay { position: fixed; @@ -632,6 +716,7 @@

πŸ“‹ PlanNotebook

+ @@ -650,6 +735,10 @@

Current Plan

+
@@ -756,13 +845,107 @@

Create New Plan