Skip to content
115 changes: 108 additions & 7 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
import io.agentscope.core.event.AllToolsDeniedEvent;
import io.agentscope.core.event.ConfirmResult;
import io.agentscope.core.event.ExceedMaxItersEvent;
import io.agentscope.core.event.ModelCallAttemptRole;
import io.agentscope.core.event.ModelCallEndEvent;
import io.agentscope.core.event.ModelCallStartEvent;
import io.agentscope.core.event.ModelFallbackActivatedEvent;
import io.agentscope.core.event.RequestStopEvent;
import io.agentscope.core.event.RequireUserConfirmEvent;
import io.agentscope.core.event.TextBlockDeltaEvent;
Expand Down Expand Up @@ -84,6 +86,7 @@
import io.agentscope.core.middleware.MiddlewareChain;
import io.agentscope.core.middleware.ModelCallInput;
import io.agentscope.core.middleware.ReasoningInput;
import io.agentscope.core.model.AttemptEventContext;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.ChatUsage;
import io.agentscope.core.model.ExecutionConfig;
Expand Down Expand Up @@ -136,7 +139,9 @@
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
Expand Down Expand Up @@ -2121,7 +2126,9 @@ Flux<AgentEvent> reasoningStream(
rc,
MiddlewareBase::onModelCall,
modelCallCore)
.apply(new ModelCallInput(messages, tools, options, modelForCall()));
.apply(
new ModelCallInput(
messages, tools, options, modelForCall(this::publishEvent)));
}

private Flux<AgentEvent> modelCallStream(
Expand All @@ -2130,8 +2137,13 @@ private Flux<AgentEvent> modelCallStream(
String replyId = UUID.randomUUID().toString().replace("-", "");
ModelCallBlockLifecycle blockLifecycle = new ModelCallBlockLifecycle(replyId);

// Inject AttemptEventContext into GenerateOptions so that Provider's
// applyTimeoutAndRetry() picks up the emitter and emits attempt lifecycle events.
GenerateOptions optionsWithTracking =
injectAttemptTracking(mci.options(), replyId, mci.model(), this::publishEvent);

Flux<AgentEvent> modelEvents =
mci.model().stream(mci.messages(), mci.tools(), mci.options())
mci.model().stream(mci.messages(), mci.tools(), optionsWithTracking)
.concatMap(chunk -> checkInterrupted().thenReturn(chunk))
.concatMap(
chunk ->
Expand Down Expand Up @@ -3054,7 +3066,7 @@ protected Mono<Msg> summarizing() {
List<Msg> messageList = prepareSummaryMessages();
GenerateOptions generateOptions = buildGenerateOptions();
ReasoningContext context = new ReasoningContext(getName());
Model summaryModel = modelForCall();
Model summaryModel = modelForCall(this::publishEvent);
publishEvent(new ExceedMaxItersEvent("", maxIters, maxIters));

return hookDispatcher
Expand Down Expand Up @@ -3484,29 +3496,50 @@ protected GenerateOptions buildGenerateOptions() {
return baseOptions != null ? baseOptions : GenerateOptions.builder().build();
}

private Model modelForCall() {
private Model modelForCall(Consumer<AgentEvent> eventEmitter) {
Model fallbackModel = modelConfig.fallbackModel();
if (fallbackModel == null) {
return model;
}

AtomicReference<Model> activeModel = new AtomicReference<>(model);
AtomicInteger primaryAttemptCount = new AtomicInteger(0);
return new Model() {
@Override
public Flux<ChatResponse> stream(
List<Msg> messages, List<ToolSchema> tools, GenerateOptions options) {
Flux<ChatResponse> primaryFlux = model.stream(messages, tools, options);
// Count each subscription to the primary model as one attempt,
// including retries applied by ModelUtils.applyTimeoutAndRetry
// inside the provider transport.
Flux<ChatResponse> primaryFlux =
model.stream(messages, tools, options)
.doOnSubscribe(sub -> primaryAttemptCount.incrementAndGet());
return primaryFlux.switchOnFirst(
(signal, flux) -> {
if (signal.isOnError()) {
Throwable error = signal.getThrowable();
activeModel.set(fallbackModel);
int failedAttempts = primaryAttemptCount.get();
// Extract replyId from the ExecutionConfig if present
String replyId = extractReplyId(options);
log.warn(
"Primary model {} failed, switching to fallback {}",
"Primary model {} failed after {} attempt(s), switching to"
+ " fallback {}",
model.getModelName(),
failedAttempts,
fallbackModel.getModelName(),
error);
return fallbackModel.stream(messages, tools, options);
eventEmitter.accept(
new ModelFallbackActivatedEvent(
replyId,
failedAttempts,
model.getModelName(),
fallbackModel.getModelName()));
// Inject FALLBACK role into options for the fallback model
GenerateOptions fallbackOptions =
injectFallbackAttemptTracking(
options, replyId, eventEmitter);
return fallbackModel.stream(messages, tools, fallbackOptions);
}
return flux;
});
Expand All @@ -3529,6 +3562,74 @@ public int getContextWindowSize() {
};
}

/**
* Injects an {@link AttemptEventContext} with PRIMARY role into the given GenerateOptions
* so that the Provider's {@code ModelUtils.applyTimeoutAndRetry()} emits attempt lifecycle
* events through the retry pipeline.
*/
private GenerateOptions injectAttemptTracking(
GenerateOptions options,
String replyId,
Model targetModel,
Consumer<AgentEvent> emitter) {
boolean hasFallback = modelConfig != null && modelConfig.fallbackModel() != null;
AttemptEventContext ctx =
new AttemptEventContext(
emitter, replyId, ModelCallAttemptRole.PRIMARY, hasFallback);
ExecutionConfig existingExec = options != null ? options.getExecutionConfig() : null;
ExecutionConfig execWithTracking;
if (existingExec != null) {
execWithTracking =
ExecutionConfig.mergeConfigs(
ExecutionConfig.builder().attemptEventContext(ctx).build(),
existingExec);
} else {
execWithTracking = ExecutionConfig.builder().attemptEventContext(ctx).build();
}
if (options != null) {
return GenerateOptions.mergeOptions(
GenerateOptions.builder().executionConfig(execWithTracking).build(), options);
}
return GenerateOptions.builder().executionConfig(execWithTracking).build();
}

/**
* Injects an {@link AttemptEventContext} with FALLBACK role into the given GenerateOptions
* for the fallback model after primary exhaustion.
*/
private GenerateOptions injectFallbackAttemptTracking(
GenerateOptions options, String replyId, Consumer<AgentEvent> emitter) {
AttemptEventContext ctx =
new AttemptEventContext(emitter, replyId, ModelCallAttemptRole.FALLBACK, false);
ExecutionConfig existingExec = options != null ? options.getExecutionConfig() : null;
ExecutionConfig execWithTracking;
if (existingExec != null) {
execWithTracking =
ExecutionConfig.mergeConfigs(
ExecutionConfig.builder().attemptEventContext(ctx).build(),
existingExec);
} else {
execWithTracking = ExecutionConfig.builder().attemptEventContext(ctx).build();
}
if (options != null) {
return GenerateOptions.mergeOptions(
GenerateOptions.builder().executionConfig(execWithTracking).build(), options);
}
return GenerateOptions.builder().executionConfig(execWithTracking).build();
}

/**
* Extracts the replyId from the AttemptEventContext embedded in GenerateOptions.
*/
private String extractReplyId(GenerateOptions options) {
if (options != null
&& options.getExecutionConfig() != null
&& options.getExecutionConfig().getAttemptEventContext() != null) {
return options.getExecutionConfig().getAttemptEventContext().getReplyId();
}
return null;
}

@Override
protected Mono<Msg> handleInterrupt(InterruptContext context, Msg... originalArgs) {
return Mono.deferContextual(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
@JsonSubTypes.Type(value = AgentResultEvent.class, name = "AGENT_RESULT"),
@JsonSubTypes.Type(value = ModelCallStartEvent.class, name = "MODEL_CALL_START"),
@JsonSubTypes.Type(value = ModelCallEndEvent.class, name = "MODEL_CALL_END"),
@JsonSubTypes.Type(value = ModelCallAttemptStartEvent.class, name = "MODEL_ATTEMPT_START"),
@JsonSubTypes.Type(value = ModelCallAttemptFailedEvent.class, name = "MODEL_ATTEMPT_FAILED"),
@JsonSubTypes.Type(
value = ModelFallbackActivatedEvent.class,
name = "MODEL_FALLBACK_ACTIVATED"),
@JsonSubTypes.Type(value = ModelCallAttemptEndEvent.class, name = "MODEL_ATTEMPT_END"),
@JsonSubTypes.Type(value = TextBlockStartEvent.class, name = "TEXT_BLOCK_START"),
@JsonSubTypes.Type(value = TextBlockDeltaEvent.class, name = "TEXT_BLOCK_DELTA"),
@JsonSubTypes.Type(value = TextBlockEndEvent.class, name = "TEXT_BLOCK_END"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public enum AgentEventType {
@JsonAlias({"MODEL_CALL_ENDED"})
MODEL_CALL_END("MODEL_CALL_END"),

MODEL_ATTEMPT_START("MODEL_ATTEMPT_START"),
MODEL_ATTEMPT_FAILED("MODEL_ATTEMPT_FAILED"),
MODEL_FALLBACK_ACTIVATED("MODEL_FALLBACK_ACTIVATED"),
MODEL_ATTEMPT_END("MODEL_ATTEMPT_END"),

TEXT_BLOCK_START("TEXT_BLOCK_START"),
TEXT_BLOCK_DELTA("TEXT_BLOCK_DELTA"),
TEXT_BLOCK_END("TEXT_BLOCK_END"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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.event;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.agentscope.core.model.ChatUsage;

/**
* Emitted when a model call attempt completes successfully.
*
* <p>Carries per-attempt usage and latency. The {@link #getUsage()} field may be {@code null}
* when the provider does not report usage for the attempt.
*/
public class ModelCallAttemptEndEvent extends AgentEvent {

private final String replyId;
private final int attemptIndex;
private final boolean success;
private final ChatUsage usage;
private final long latencyMs;
private final ModelCallAttemptRole role;

@JsonCreator
public ModelCallAttemptEndEvent(
@JsonProperty("id") String id,
@JsonProperty("createdAt") String createdAt,
@JsonProperty("replyId") String replyId,
@JsonProperty("attemptIndex") int attemptIndex,
@JsonProperty("success") boolean success,
@JsonProperty("usage") ChatUsage usage,
@JsonProperty("latencyMs") long latencyMs,
@JsonProperty("role") ModelCallAttemptRole role) {
super(id, createdAt);
this.replyId = replyId;
this.attemptIndex = attemptIndex;
this.success = success;
this.usage = usage;
this.latencyMs = latencyMs;
this.role = role;
}

public ModelCallAttemptEndEvent(
String replyId,
int attemptIndex,
boolean success,
ChatUsage usage,
long latencyMs,
ModelCallAttemptRole role) {
this.replyId = replyId;
this.attemptIndex = attemptIndex;
this.success = success;
this.usage = usage;
this.latencyMs = latencyMs;
this.role = role;
}

@Override
public AgentEventType getType() {
return AgentEventType.MODEL_ATTEMPT_END;
}

public String getReplyId() {
return replyId;
}

public int getAttemptIndex() {
return attemptIndex;
}

public boolean isSuccess() {
return success;
}

public ChatUsage getUsage() {
return usage;
}

public long getLatencyMs() {
return latencyMs;
}

public ModelCallAttemptRole getRole() {
return role;
}
}
Loading
Loading