Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ public void interrupt(InterruptSource source) {
*
* @param ctx the runtime context identifying the session to interrupt
*/
@Override
public void interrupt(RuntimeContext ctx) {
interrupt(ctx, null);
}
Expand All @@ -721,6 +722,7 @@ public void interrupt(RuntimeContext ctx) {
* @param ctx the runtime context identifying the session to interrupt
* @param msg optional user message to attach to the interrupt signal
*/
@Override
public void interrupt(RuntimeContext ctx, Msg msg) {
String uid = ctx != null ? ctx.getUserId() : null;
String sid = ctx != null ? ctx.getSessionId() : null;
Expand Down
52 changes: 46 additions & 6 deletions agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,61 @@ default String getDescription() {

/**
* Interrupt the current agent execution.
* This method sets an interrupt flag that will be checked by the agent at appropriate
* checkpoints during execution. The interruption is cooperative and may not take effect
* immediately.
*
* @deprecated since 2.0.0, use {@link #interrupt(RuntimeContext)} with explicit runtime
* context to ensure the interruption targets the correct session slot.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
void interrupt();

/**
* Interrupt the current agent execution with a user message.
* This method sets an interrupt flag and associates a user message with the interruption.
* The interruption is cooperative and may not take effect immediately.
*
* @param msg User message associated with the interruption
* @deprecated since 2.0.0, use {@link #interrupt(RuntimeContext, Msg)} with explicit runtime
* context to ensure the interruption targets the correct session slot.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
void interrupt(Msg msg);

/**
* Interrupt the agent execution for the session identified by the given {@link RuntimeContext}.
* This is the session-aware variant of {@link #interrupt()} that targets a specific
* {@code (userId, sessionId)} session instead of a hardcoded default session.
*
* <p>When called from a middleware or tool that holds both {@code Agent agent} and
* {@code RuntimeContext ctx}, this method ensures the interruption targets the current
* in-flight call's session rather than a different session.
*
* <p>Default implementation delegates to {@link #interrupt()} for backward compatibility.
* Subclasses that maintain per-session state (e.g., {@link io.agentscope.core.ReActAgent})
* should override this to trigger the interrupt signal on the correct session slot.
*
* @param ctx the runtime context identifying the session to interrupt; if {@code null} or
* {@code ctx.getSessionId()} is {@code null}/{@code blank}, implementations may
* fall back to a default session
*/
default void interrupt(RuntimeContext ctx) {
interrupt();
}

/**
* Interrupt the agent execution for the session identified by the given {@link RuntimeContext}
* with an associated user message.
*
* <p>This is the session-aware variant of {@link #interrupt(Msg)} that targets a specific
* {@code (userId, sessionId)} session instead of a hardcoded default session.
*
* <p>Default implementation delegates to {@link #interrupt(Msg)} for backward compatibility.
* Subclasses that maintain per-session state (e.g., {@link io.agentscope.core.ReActAgent})
* should override this to trigger the interrupt signal on the correct session slot.
*
* @param ctx the runtime context identifying the session to interrupt
* @param msg optional user message to associate with the interruption
*/
default void interrupt(RuntimeContext ctx, Msg msg) {
interrupt(msg);
}

/**
* Returns the agent's runtime {@link io.agentscope.core.state.AgentState}, or {@code null} if
* this agent type does not maintain one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -440,28 +440,44 @@ public static void removeSystemHook(Hook hook) {
systemHooks.remove(hook);
}

/** @deprecated Subclasses should implement per-session interrupt via RuntimeContext. */
@Deprecated
/**
* @deprecated since 2.0.0, use {@link #interrupt(RuntimeContext)} with explicit runtime context
* to ensure the interruption targets the correct session slot.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
@Override
public void interrupt() {}

/** @deprecated Subclasses should implement per-session interrupt via RuntimeContext. */
@Deprecated
/**
* @deprecated since 2.0.0, use {@link #interrupt(RuntimeContext, Msg)} with explicit runtime
* context to ensure the interruption targets the correct session slot.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
@Override
public void interrupt(Msg msg) {}

/** @deprecated Subclasses should implement per-session interrupt via RuntimeContext. */
@Deprecated
/**
* @deprecated since 2.0.0, use {@link #interrupt(RuntimeContext)} with explicit runtime
* context to ensure the interruption targets the correct session slot.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
public void interrupt(InterruptSource source) {}

/** @deprecated No longer needed; ReActAgent uses per-session InterruptControl. */
@Deprecated
/**
* @deprecated since 2.0.0; no longer needed. ReActAgent uses per-session
* {@link io.agentscope.core.interruption.InterruptControl} on its per-call
* {@link io.agentscope.core.state.AgentState}.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
protected Mono<Void> checkInterruptedAsync() {
return Mono.empty();
}

/** @deprecated No-op; per-session interrupt state is managed by AgentState.interruptControl(). */
@Deprecated
/**
* @deprecated since 2.0.0; no-op. Per-session interrupt state is managed by
* {@link io.agentscope.core.interruption.InterruptControl}.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
protected void resetInterruptFlag() {}

private InterruptContext createInterruptContext() {
Expand Down Expand Up @@ -502,14 +518,21 @@ private Function<Throwable, Mono<Msg>> createErrorHandler(Msg... originalArgs) {
};
}

/** @deprecated No-op stub. */
@Deprecated
/**
* @deprecated since 2.0.0; returns a detached flag that is never read. Use
* {@link io.agentscope.core.interruption.InterruptControl#isInterrupted()} on the
* session's {@link io.agentscope.core.state.AgentState} instead.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
protected AtomicBoolean getInterruptFlag() {
return new AtomicBoolean(false);
}

/** @deprecated Returns USER. Per-session interrupt source is on AgentState.interruptControl(). */
@Deprecated
/**
* @deprecated since 2.0.0; always returns {@link InterruptSource#USER}. Per-session
* interrupt source is on {@link io.agentscope.core.interruption.InterruptControl}.
*/
@Deprecated(since = "2.0.0", forRemoval = true)
protected InterruptSource getInterruptSource() {
return InterruptSource.USER;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.core.agent;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;

import com.fasterxml.jackson.databind.JsonNode;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

/**
* Backward-compatibility contract for the {@link Agent#interrupt(RuntimeContext)} and
* {@link Agent#interrupt(RuntimeContext, Msg)} default methods.
*
* <p>Agent implementations that predate the session-aware overloads must continue to work: when
* they do not override the new {@code interrupt(RuntimeContext, ...)} methods, calls MUST fall
* through to the legacy {@link Agent#interrupt()} / {@link Agent#interrupt(Msg)} methods
* unchanged.
*/
@DisplayName("Agent default interrupt(RuntimeContext) — legacy fallback")
class AgentDefaultInterruptTest {

/**
* A minimal {@link Agent} implementation that only overrides the legacy interrupt methods.
* The two {@code interrupt(RuntimeContext, ...)} methods are inherited from the interface's
* default implementations and must delegate here.
*/
private static final class RecordingAgent implements Agent {
final AtomicInteger legacyNoArgCalls = new AtomicInteger();
final AtomicInteger legacyMsgCalls = new AtomicInteger();
final AtomicReference<Msg> lastLegacyMsg = new AtomicReference<>();

@Override
public String getAgentId() {
return "recording";
}

@Override
public String getName() {
return "recording";
}

@Override
public void interrupt() {
legacyNoArgCalls.incrementAndGet();
}

@Override
public void interrupt(Msg msg) {
legacyMsgCalls.incrementAndGet();
lastLegacyMsg.set(msg);
}

@Override
public Mono<Msg> call(List<Msg> msgs) {
return Mono.empty();
}

@Override
public Mono<Msg> call(List<Msg> msgs, Class<?> structuredModel) {
return Mono.empty();
}

@Override
public Mono<Msg> call(List<Msg> msgs, JsonNode schema) {
return Mono.empty();
}

@Override
public Flux<Event> stream(List<Msg> msgs, StreamOptions options) {
return Flux.empty();
}

@Override
public Flux<Event> stream(List<Msg> msgs, StreamOptions options, Class<?> structuredModel) {
return Flux.empty();
}

@Override
public Flux<Event> stream(List<Msg> msgs, StreamOptions options, JsonNode schema) {
return Flux.empty();
}

@Override
public Mono<Void> observe(Msg msg) {
return Mono.empty();
}

@Override
public Mono<Void> observe(List<Msg> msgs) {
return Mono.empty();
}
}

@Test
@DisplayName("interrupt(RuntimeContext) falls back to legacy interrupt() by default")
void defaultInterruptWithContext_delegatesToLegacyNoArg() {
RecordingAgent agent = new RecordingAgent();
RuntimeContext ctx = RuntimeContext.builder().userId("u1").sessionId("sessA").build();

agent.interrupt(ctx);

assertEquals(1, agent.legacyNoArgCalls.get(), "default must delegate to interrupt()");
assertEquals(0, agent.legacyMsgCalls.get(), "no-arg default must not touch interrupt(Msg)");
}

@Test
@DisplayName("interrupt(RuntimeContext, Msg) falls back to legacy interrupt(Msg) by default")
void defaultInterruptWithContextAndMsg_delegatesToLegacyWithMsg() {
RecordingAgent agent = new RecordingAgent();
RuntimeContext ctx = RuntimeContext.builder().userId("u1").sessionId("sessA").build();
Msg attached =
Msg.builder()
.name("user")
.role(MsgRole.USER)
.content(TextBlock.builder().text("please stop").build())
.build();

agent.interrupt(ctx, attached);

assertEquals(0, agent.legacyNoArgCalls.get(), "msg default must not touch interrupt()");
assertEquals(1, agent.legacyMsgCalls.get(), "default must delegate to interrupt(Msg)");
assertSame(attached, agent.lastLegacyMsg.get(), "attached message must be forwarded");
}

@Test
@DisplayName("null RuntimeContext still delegates to the legacy method")
void defaultInterrupt_toleratesNullContext() {
RecordingAgent agent = new RecordingAgent();

agent.interrupt((RuntimeContext) null);
agent.interrupt(null, null);

assertEquals(1, agent.legacyNoArgCalls.get());
assertEquals(1, agent.legacyMsgCalls.get());
}
}
Loading
Loading