44import io .github .huskyagent .application .agent .ClarifyContext ;
55import io .github .huskyagent .application .agent .TextEvent ;
66import io .github .huskyagent .application .runtime .AgentRuntimeExecutor ;
7+ import io .github .huskyagent .application .runtime .RunCancelledException ;
8+ import io .github .huskyagent .application .runtime .RunHandle ;
79import io .github .huskyagent .application .runtime .RuntimeCallbacks ;
810import io .github .huskyagent .application .runtime .RuntimeExecutionRequest ;
911import io .github .huskyagent .application .session .GraphCacheKey ;
@@ -73,6 +75,7 @@ public class ReActAgentApp implements AgentRuntimeExecutor {
7375 private final MultimodalMessageBuilder multimodalMessageBuilder ;
7476 private final DynamicPromptSnapshotCache dynamicPromptSnapshotCache ;
7577 private final ToolCallbackFactory toolCallbackFactory ;
78+ private final io .github .huskyagent .application .runtime .SessionRunCoordinator runCoordinator ;
7679
7780 /**
7881 * Caches compiled graphs by runtime-policy fingerprint so scenes, principals,
@@ -103,6 +106,13 @@ public ChatResult execute(RuntimeScope scope, AgentInput input, RuntimeCallbacks
103106 @ Override
104107 public ChatResult execute (RuntimeScope scope , AgentInput input , RuntimeCallbacks callbacks ,
105108 RuntimeExecutionRequest .PersistenceMode persistenceMode ) {
109+ return execute (scope , input , callbacks , persistenceMode , null );
110+ }
111+
112+ @ Override
113+ public ChatResult execute (RuntimeScope scope , AgentInput input , RuntimeCallbacks callbacks ,
114+ RuntimeExecutionRequest .PersistenceMode persistenceMode ,
115+ RunHandle runHandle ) {
106116 RuntimeCallbacks executionCallbacks = callbacks != null ? callbacks : RuntimeCallbacks .NOOP ;
107117 RuntimeExecutionRequest .PersistenceMode mode = persistenceMode != null
108118 ? persistenceMode
@@ -121,14 +131,21 @@ public ChatResult execute(RuntimeScope scope, AgentInput input, RuntimeCallbacks
121131 currentCheckpointId (graph , sid ));
122132 }
123133 String turnId = UUID .randomUUID ().toString ();
124- RunnableConfig config = buildConfig (sid , turnId , scope );
134+ RunnableConfig config = buildConfig (sid , turnId , scope , runHandle );
125135 try {
126136 return runWithInterruptLoop (scope , graph , config , buildInputs (input ), sid ,
127- executionCallbacks , stateless );
137+ executionCallbacks , stateless , runHandle );
128138 } finally {
129139 dynamicPromptSnapshotCache .clearTurn (sid , turnId );
130140 }
141+ } catch (RunCancelledException e ) {
142+ log .info ("Graph execution cancelled: sessionId={}" , sid );
143+ return ChatResult .cancelled (sid , e .getMessage ());
131144 } catch (Exception e ) {
145+ if (isCancellation (e , runHandle )) {
146+ log .info ("Graph execution cancelled: sessionId={}" , sid );
147+ return ChatResult .cancelled (sid , "Run cancelled" );
148+ }
132149 log .error ("Graph execution failed: sessionId={}" , sid , e );
133150 return ChatResult .failure (e .getMessage ());
134151 }
@@ -159,16 +176,19 @@ private ChatResult runWithInterruptLoop(
159176 Map <String , Object > inputs ,
160177 String sessionId ,
161178 RuntimeCallbacks callbacks ,
162- boolean stateless ) throws Exception {
179+ boolean stateless ,
180+ RunHandle runHandle ) throws Exception {
163181
164182 Map <String , Object > currentInputs = inputs ;
165183 ReActAgentState finalState = null ;
166184 int resumeCount = 0 ;
167185 boolean hasModelOutput = false ;
168186
169187 while (true ) {
188+ throwIfCancelled (runHandle );
170189 var generator = graph .stream (currentInputs , config );
171190 for (NodeOutput <ReActAgentState > step : generator ) {
191+ throwIfCancelled (runHandle );
172192 finalState = step .state ();
173193 if (AgentGraph .NODE_MODEL .equals (step .node ())) {
174194 hasModelOutput = true ;
@@ -179,6 +199,7 @@ private ChatResult runWithInterruptLoop(
179199 log .debug ("[loop] graphResult type={}" , graphResult .type ());
180200
181201 if (!graphResult .isInterruptionMetadata ()) break ;
202+ throwIfCancelled (runHandle );
182203
183204 if (resumeCount ++ > 50 ) {
184205 log .warn ("Interrupt resume loop exceeded the limit; forcing exit" );
@@ -196,15 +217,41 @@ private ChatResult runWithInterruptLoop(
196217 callbacks .approval (scope , buildApprovalContext (
197218 graph , config , sessionId , metadata , finalState ));
198219 }
220+ throwIfCancelled (runHandle );
199221 currentInputs = null ;
200222 }
201223
224+ throwIfCancelled (runHandle );
202225 if (!stateless ) {
203226 recordProviderTokenUsage (sessionId , finalState );
204227 compactActiveCheckpointIfNeeded (scope , graph , config , finalState );
205228 }
206229 return handleFinalState (sessionId , finalState , hasModelOutput , stateless );
207230 }
231+ private void throwIfCancelled (RunHandle runHandle ) {
232+ if (runHandle != null && (Thread .currentThread ().isInterrupted () || runCoordinator .isCancelled (runHandle ))) {
233+ throw new RunCancelledException (runHandle .sessionId ());
234+ }
235+ }
236+
237+ private boolean isCancellation (Throwable error , RunHandle runHandle ) {
238+ return runHandle != null
239+ && (runCoordinator .isCancelled (runHandle )
240+ || Thread .currentThread ().isInterrupted ()
241+ || hasCause (error , InterruptedException .class )
242+ || hasCause (error , java .util .concurrent .CancellationException .class ));
243+ }
244+
245+ private boolean hasCause (Throwable error , Class <? extends Throwable > type ) {
246+ Throwable current = error ;
247+ while (current != null ) {
248+ if (type .isInstance (current )) {
249+ return true ;
250+ }
251+ current = current .getCause ();
252+ }
253+ return false ;
254+ }
208255
209256
210257 private CompiledGraph <ReActAgentState > buildStatelessGraph (RuntimeScope scope ) throws Exception {
@@ -278,17 +325,21 @@ private void initSession(String sessionId) {
278325 }
279326 }
280327
281- private RunnableConfig buildConfig (String sessionId , String turnId , RuntimeScope scope ) {
282- return RunnableConfig .builder ()
328+ private RunnableConfig buildConfig (String sessionId , String turnId , RuntimeScope scope , RunHandle runHandle ) {
329+ var builder = RunnableConfig .builder ()
283330 .threadId (sessionId )
284331 .putMetadata (DYNAMIC_PROMPT_TURN_ID_METADATA , turnId )
285- .putMetadata (RequestToolContext .METADATA_KEY , buildRequestToolContext (sessionId , scope ))
332+ .putMetadata (RequestToolContext .METADATA_KEY , buildRequestToolContext (sessionId , scope , runHandle != null ? runHandle . toDomain () : null ))
286333 .putMetadata ("channelIdentity" , scope .getChannelIdentity ())
287- .putMetadata ("principal" , scope .getPrincipal ())
288- .build ();
334+ .putMetadata ("principal" , scope .getPrincipal ());
335+ if (runHandle != null ) {
336+ builder .putMetadata (RunHandle .METADATA_KEY , runHandle );
337+ }
338+ return builder .build ();
289339 }
290340
291- private RequestToolContext buildRequestToolContext (String sessionId , RuntimeScope scope ) {
341+ private RequestToolContext buildRequestToolContext (String sessionId , RuntimeScope scope ,
342+ io .github .huskyagent .domain .runtime .RunHandle runHandle ) {
292343 var runtimePolicy = scope .getRuntimePolicy ();
293344 var capabilityView = runtimePolicy .getCapabilityView ();
294345 var toolDefinitions = capabilityView .getVisibleTools ();
@@ -300,7 +351,9 @@ private RequestToolContext buildRequestToolContext(String sessionId, RuntimeScop
300351 capabilityView .getVisibleSkillNames (),
301352 capabilityView .getVisiblePromptSections ());
302353 return RequestToolContext .of (toolDefinitions ,
303- toolCallbackFactory .build (toolDefinitions , sessionId , executionContext ));
354+ toolCallbackFactory .build (toolDefinitions , sessionId , executionContext ),
355+ runHandle ,
356+ runCoordinator );
304357 }
305358
306359 private Map <String , Object > buildInputs (AgentInput input ) {
0 commit comments