From 023a7e0b2b1820b9a20f022c8b20574dc3d41a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E5=85=89c?= <2533995180@qq.com> Date: Tue, 14 Jul 2026 21:33:40 +0800 Subject: [PATCH 1/7] [feature] expose typed events --- .../java/io/agentscope/core/ReActAgent.java | 108 +- .../io/agentscope/core/event/AgentEvent.java | 6 + .../agentscope/core/event/AgentEventType.java | 5 + .../core/event/ModelCallAttemptEndEvent.java | 99 ++ .../event/ModelCallAttemptFailedEvent.java | 155 +++ .../ModelCallAttemptFailureCategory.java | 43 + .../core/event/ModelCallAttemptRole.java | 34 + .../event/ModelCallAttemptStartEvent.java | 99 ++ .../event/ModelFallbackActivatedEvent.java | 78 ++ .../core/model/ExecutionConfig.java | 47 + .../core/model/ModelCallAttemptTracker.java | 176 +++ .../model/ModelCallFailureClassifier.java | 198 ++++ .../io/agentscope/core/model/ModelUtils.java | 83 +- .../model/ModelCallAttemptTrackerTest.java | 196 ++++ ...delCallAttemptTrackerVerificationTest.java | 1038 +++++++++++++++++ .../model/ModelCallFailureClassifierTest.java | 188 +++ 16 files changed, 2542 insertions(+), 11 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptEndEvent.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailedEvent.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailureCategory.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptRole.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptStartEvent.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/ModelFallbackActivatedEvent.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/model/ModelCallAttemptTracker.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerVerificationTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/model/ModelCallFailureClassifierTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 0bb6b49af7..459b8c7beb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -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; @@ -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; @@ -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; @@ -2118,7 +2123,9 @@ Flux reasoningStream( rc, MiddlewareBase::onModelCall, modelCallCore) - .apply(new ModelCallInput(messages, tools, options, modelForCall())) + .apply( + new ModelCallInput( + messages, tools, options, modelForCall(this::publishEvent))) .doOnNext(this::publishEvent); } @@ -2128,8 +2135,13 @@ private Flux 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 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 -> @@ -3052,7 +3064,7 @@ protected Mono summarizing() { List 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 @@ -3482,13 +3494,14 @@ protected GenerateOptions buildGenerateOptions() { return baseOptions != null ? baseOptions : GenerateOptions.builder().build(); } - private Model modelForCall() { + private Model modelForCall(Consumer eventEmitter) { Model fallbackModel = modelConfig.fallbackModel(); if (fallbackModel == null) { return model; } AtomicReference activeModel = new AtomicReference<>(model); + AtomicInteger primaryAttemptCount = new AtomicInteger(0); return new Model() { @Override public Flux stream( @@ -3499,12 +3512,27 @@ public Flux stream( 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; }); @@ -3527,6 +3555,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 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 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 handleInterrupt(InterruptContext context, Msg... originalArgs) { return Mono.deferContextual( diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java index 22e0678404..78898289af 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java @@ -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"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java index 69cd2f046f..013f2648c2 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java @@ -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"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptEndEvent.java new file mode 100644 index 0000000000..742fefc0b4 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptEndEvent.java @@ -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. + * + *

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; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailedEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailedEvent.java new file mode 100644 index 0000000000..02d1d86409 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailedEvent.java @@ -0,0 +1,155 @@ +/* + * 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; + +/** + * Emitted when a model call attempt fails. + * + *

Carries a structured {@link ModelCallAttemptFailureCategory} and sanitized error details + * without exposing API keys, credentials, or raw response bodies. + */ +public class ModelCallAttemptFailedEvent extends AgentEvent { + + private final String replyId; + private final int attemptIndex; + private final int maxAttempts; + private final ModelCallAttemptFailureCategory failureCategory; + private final boolean retryable; + private final ModelCallAttemptNextAction nextAction; + private final String errorCode; + private final String errorMessage; + private final ModelCallAttemptRole role; + + @JsonCreator + public ModelCallAttemptFailedEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("replyId") String replyId, + @JsonProperty("attemptIndex") int attemptIndex, + @JsonProperty("maxAttempts") int maxAttempts, + @JsonProperty("failureCategory") ModelCallAttemptFailureCategory failureCategory, + @JsonProperty("retryable") boolean retryable, + @JsonProperty("nextAction") ModelCallAttemptNextAction nextAction, + @JsonProperty("errorCode") String errorCode, + @JsonProperty("errorMessage") String errorMessage, + @JsonProperty("role") ModelCallAttemptRole role) { + super(id, createdAt); + this.replyId = replyId; + this.attemptIndex = attemptIndex; + this.maxAttempts = maxAttempts; + this.failureCategory = failureCategory; + this.retryable = retryable; + this.nextAction = nextAction; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.role = role; + } + + public ModelCallAttemptFailedEvent( + String replyId, + int attemptIndex, + int maxAttempts, + ModelCallAttemptFailureCategory failureCategory, + boolean retryable, + ModelCallAttemptNextAction nextAction, + String errorCode, + String errorMessage, + ModelCallAttemptRole role) { + this.replyId = replyId; + this.attemptIndex = attemptIndex; + this.maxAttempts = maxAttempts; + this.failureCategory = failureCategory; + this.retryable = retryable; + this.nextAction = nextAction; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.role = role; + } + + /** + * Backward-compatible constructor without nextAction and maxAttempts. + * + *

Computes {@code nextAction} from {@code retryable} and {@code role}: + * retryable → RETRY, not retryable with FALLBACK role or fallback available → FALLBACK, + * otherwise → FAIL. + * + * @deprecated Use the full constructor with maxAttempts and nextAction instead. + */ + @Deprecated + public ModelCallAttemptFailedEvent( + String replyId, + int attemptIndex, + ModelCallAttemptFailureCategory failureCategory, + boolean retryable, + String errorCode, + String errorMessage, + ModelCallAttemptRole role) { + this.replyId = replyId; + this.attemptIndex = attemptIndex; + this.maxAttempts = 0; + this.failureCategory = failureCategory; + this.retryable = retryable; + this.nextAction = + retryable ? ModelCallAttemptNextAction.RETRY : ModelCallAttemptNextAction.FAIL; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.role = role; + } + + @Override + public AgentEventType getType() { + return AgentEventType.MODEL_ATTEMPT_FAILED; + } + + public String getReplyId() { + return replyId; + } + + public int getAttemptIndex() { + return attemptIndex; + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public ModelCallAttemptFailureCategory getFailureCategory() { + return failureCategory; + } + + public boolean isRetryable() { + return retryable; + } + + public ModelCallAttemptNextAction getNextAction() { + return nextAction; + } + + public String getErrorCode() { + return errorCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public ModelCallAttemptRole getRole() { + return role; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailureCategory.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailureCategory.java new file mode 100644 index 0000000000..0aa4406ce8 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptFailureCategory.java @@ -0,0 +1,43 @@ +/* + * 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; + +/** + * Classifies the failure reason of a model call attempt. + * + *

Used by {@link ModelCallAttemptFailedEvent} to report structured failure information + * without exposing raw error details or credentials. + */ +public enum ModelCallAttemptFailureCategory { + RATE_LIMIT("rate_limit"), + TIMEOUT("timeout"), + AUTHENTICATION("authentication"), + AUTHORIZATION("authorization"), + NETWORK("network"), + PROVIDER_5XX("provider_5xx"), + INVALID_REQUEST("invalid_request"), + UNKNOWN("unknown"); + + private final String value; + + ModelCallAttemptFailureCategory(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptRole.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptRole.java new file mode 100644 index 0000000000..ee73762158 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptRole.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** + * Identifies whether a model call attempt targets the primary or fallback model. + */ +public enum ModelCallAttemptRole { + PRIMARY("primary"), + FALLBACK("fallback"); + + private final String value; + + ModelCallAttemptRole(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptStartEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptStartEvent.java new file mode 100644 index 0000000000..2ed4712575 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptStartEvent.java @@ -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; + +/** + * Emitted when a model call attempt begins. + * + *

Each retry within a logical model call produces a new {@code MODEL_ATTEMPT_START} event + * with an incremented {@link #getAttemptIndex()}. When a fallback model is configured and + * activated, its attempts continue the index sequence with {@link ModelCallAttemptRole#FALLBACK}. + */ +public class ModelCallAttemptStartEvent extends AgentEvent { + + private final String replyId; + private final int attemptIndex; + private final int maxAttempts; + private final String provider; + private final String modelName; + private final ModelCallAttemptRole role; + + @JsonCreator + public ModelCallAttemptStartEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("replyId") String replyId, + @JsonProperty("attemptIndex") int attemptIndex, + @JsonProperty("maxAttempts") int maxAttempts, + @JsonProperty("provider") String provider, + @JsonProperty("modelName") String modelName, + @JsonProperty("role") ModelCallAttemptRole role) { + super(id, createdAt); + this.replyId = replyId; + this.attemptIndex = attemptIndex; + this.maxAttempts = maxAttempts; + this.provider = provider; + this.modelName = modelName; + this.role = role; + } + + public ModelCallAttemptStartEvent( + String replyId, + int attemptIndex, + int maxAttempts, + String provider, + String modelName, + ModelCallAttemptRole role) { + this.replyId = replyId; + this.attemptIndex = attemptIndex; + this.maxAttempts = maxAttempts; + this.provider = provider; + this.modelName = modelName; + this.role = role; + } + + @Override + public AgentEventType getType() { + return AgentEventType.MODEL_ATTEMPT_START; + } + + public String getReplyId() { + return replyId; + } + + public int getAttemptIndex() { + return attemptIndex; + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public String getProvider() { + return provider; + } + + public String getModelName() { + return modelName; + } + + public ModelCallAttemptRole getRole() { + return role; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelFallbackActivatedEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelFallbackActivatedEvent.java new file mode 100644 index 0000000000..b3308db606 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelFallbackActivatedEvent.java @@ -0,0 +1,78 @@ +/* + * 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; + +/** + * Emitted when the primary model is exhausted and the fallback model is activated. + * + *

This event is only produced when a {@code fallbackModel} is configured via + * {@link io.agentscope.core.ReActAgent.Builder#fallbackModel} and the primary model + * has exhausted all retry attempts. + */ +public class ModelFallbackActivatedEvent extends AgentEvent { + + private final String replyId; + private final int failedAttemptCount; + private final String fromModel; + private final String toModel; + + @JsonCreator + public ModelFallbackActivatedEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("replyId") String replyId, + @JsonProperty("failedAttemptCount") int failedAttemptCount, + @JsonProperty("fromModel") String fromModel, + @JsonProperty("toModel") String toModel) { + super(id, createdAt); + this.replyId = replyId; + this.failedAttemptCount = failedAttemptCount; + this.fromModel = fromModel; + this.toModel = toModel; + } + + public ModelFallbackActivatedEvent( + String replyId, int failedAttemptCount, String fromModel, String toModel) { + this.replyId = replyId; + this.failedAttemptCount = failedAttemptCount; + this.fromModel = fromModel; + this.toModel = toModel; + } + + @Override + public AgentEventType getType() { + return AgentEventType.MODEL_FALLBACK_ACTIVATED; + } + + public String getReplyId() { + return replyId; + } + + public int getFailedAttemptCount() { + return failedAttemptCount; + } + + public String getFromModel() { + return fromModel; + } + + public String getToModel() { + return toModel; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java b/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java index 1b269cc6ad..6ee4f21ec3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ExecutionConfig.java @@ -70,6 +70,16 @@ public class ExecutionConfig { /** Predicate to determine if an error should trigger a retry. */ private final Predicate retryOn; + /** + * Runtime-only context for emitting attempt lifecycle events. + * + *

This field is not serialized and does not participate in JSON + * round-trips or config merging. It is set by the caller (e.g., ReActAgent) + * and read by {@link ModelUtils#applyTimeoutAndRetry} to wire the attempt + * tracker into the retry pipeline. + */ + private final AttemptEventContext attemptEventContext; + /** * Predicate that determines if an error should be retried. * @@ -170,6 +180,7 @@ private ExecutionConfig(Builder builder) { this.maxBackoff = builder.maxBackoff; this.backoffMultiplier = builder.backoffMultiplier; this.retryOn = builder.retryOn; + this.attemptEventContext = builder.attemptEventContext; } /** @@ -226,6 +237,19 @@ public Predicate getRetryOn() { return retryOn; } + /** + * Gets the runtime attempt event context. + * + *

This field is not serialized or merged. It carries the event emitter, + * reply ID, and attempt role from the caller (e.g., ReActAgent) into the + * model transport pipeline. + * + * @return the attempt event context, or null if not set + */ + public AttemptEventContext getAttemptEventContext() { + return attemptEventContext; + } + /** * Creates a new builder for ExecutionConfig. * @@ -287,6 +311,14 @@ public static ExecutionConfig mergeConfigs(ExecutionConfig primary, ExecutionCon : fallback.backoffMultiplier); builder.retryOn(primary.retryOn != null ? primary.retryOn : fallback.retryOn); + // Propagate the runtime-only attempt event context (primary takes precedence). + // This field is not serialized but MUST be propagated through merges so that + // AttemptEventContext injected by ReActAgent reaches ModelUtils.applyTimeoutAndRetry. + builder.attemptEventContext( + primary.attemptEventContext != null + ? primary.attemptEventContext + : fallback.attemptEventContext); + return builder.build(); } @@ -298,6 +330,7 @@ public static class Builder { private Duration maxBackoff; private Double backoffMultiplier; private Predicate retryOn; + private AttemptEventContext attemptEventContext; /** * Sets the timeout duration for a single execution. @@ -376,6 +409,20 @@ public Builder retryOn(Predicate retryOn) { return this; } + /** + * Sets the runtime attempt event context for emitting attempt lifecycle events. + * + *

This field is not serialized or merged. It carries the event emitter, + * reply ID, and attempt role from the caller into the model transport pipeline. + * + * @param attemptEventContext the attempt event context, or null to disable tracking + * @return this builder instance + */ + public Builder attemptEventContext(AttemptEventContext attemptEventContext) { + this.attemptEventContext = attemptEventContext; + return this; + } + /** * Builds a new ExecutionConfig instance. * diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallAttemptTracker.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallAttemptTracker.java new file mode 100644 index 0000000000..6ae5b0d81b --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallAttemptTracker.java @@ -0,0 +1,176 @@ +/* + * 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.model; + +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallAttemptEndEvent; +import io.agentscope.core.event.ModelCallAttemptFailedEvent; +import io.agentscope.core.event.ModelCallAttemptNextAction; +import io.agentscope.core.event.ModelCallAttemptRole; +import io.agentscope.core.event.ModelCallAttemptStartEvent; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import reactor.core.publisher.Flux; + +/** + * Wraps a {@link Flux}{@code <}{@link ChatResponse}{@code >} with attempt lifecycle tracking + * that emits typed {@link AgentEvent}s. + * + *

This tracker is designed to be applied between the source flux and the retry operator + * ({@code Retry.backoff} / {@code Retry.fixedDelay}). When the retry operator resubscribes after + * a failure, the {@code doOnSubscribe}/{@code doOnError}/{@code doOnComplete} operators in this + * wrapper fire again, producing a new set of lifecycle events for each attempt. + * + *

The events emitted are: + *

    + *
  • {@link ModelCallAttemptStartEvent} — before each attempt subscription + *
  • {@link ModelCallAttemptFailedEvent} — when an attempt fails (error signal) + *
  • {@link ModelCallAttemptEndEvent} — when an attempt succeeds (complete signal) + *
+ * + *

Placement: Apply this tracker after timeout but before retry: + *

{@code
+ * flux.timeout(timeout, ...)
+ *    // tracker goes here — inside the retry pipeline
+ *    .transform(f -> ModelCallAttemptTracker.wrap(f, emitter, replyId, ...))
+ *    .retryWhen(retrySpec)
+ * }
+ */ +public final class ModelCallAttemptTracker { + + private ModelCallAttemptTracker() {} + + /** + * Wraps a response flux with attempt lifecycle tracking. + * + * @param source the raw response flux (with timeout already applied, before retry) + * @param emitter the event emitter to receive attempt lifecycle events + * @param replyId the logical call/reply identifier + * @param maxAttempts the configured maximum attempts for this call + * @param provider the provider name (e.g., "openai", "anthropic") + * @param modelName the model name + * @param role primary or fallback role + * @param hasFallback whether a fallback model is configured + * @return a wrapped flux that emits attempt lifecycle side-effects + */ + public static Flux wrap( + Flux source, + Consumer emitter, + String replyId, + int maxAttempts, + String provider, + String modelName, + ModelCallAttemptRole role, + boolean hasFallback) { + + AtomicInteger attemptCounter = new AtomicInteger(0); + AtomicLong startTime = new AtomicLong(); + AtomicReference lastUsage = new AtomicReference<>(); + + return source.doOnSubscribe( + sub -> { + int attempt = attemptCounter.incrementAndGet(); + startTime.set(System.currentTimeMillis()); + emitter.accept( + new ModelCallAttemptStartEvent( + replyId, + attempt, + maxAttempts, + provider, + modelName, + role)); + }) + .doOnNext( + chunk -> { + if (chunk.getUsage() != null) { + lastUsage.set(chunk.getUsage()); + } + }) + .doOnError( + error -> { + int attempt = attemptCounter.get(); + boolean retryable = ModelCallFailureClassifier.isRetryable(error); + ModelCallAttemptNextAction nextAction = + computeNextAction( + attempt, maxAttempts, retryable, role, hasFallback); + emitter.accept( + new ModelCallAttemptFailedEvent( + replyId, + attempt, + maxAttempts, + ModelCallFailureClassifier.classify(error), + retryable, + nextAction, + ModelCallFailureClassifier.sanitizeErrorCode(error), + ModelCallFailureClassifier.sanitizeMessage(error), + role)); + }) + .doOnComplete( + () -> { + int attempt = attemptCounter.get(); + long latency = System.currentTimeMillis() - startTime.get(); + emitter.accept( + new ModelCallAttemptEndEvent( + replyId, + attempt, + true, + lastUsage.get(), + latency, + role)); + }); + } + + /** + * Backward-compatible wrap without hasFallback (assumes no fallback). + */ + public static Flux wrap( + Flux source, + Consumer emitter, + String replyId, + int maxAttempts, + String provider, + String modelName, + ModelCallAttemptRole role) { + return wrap(source, emitter, replyId, maxAttempts, provider, modelName, role, false); + } + + /** + * Computes the next action after a failed attempt. + * + *

Logic: + *

    + *
  • If the error is retryable and there are remaining attempts → RETRY
  • + *
  • If retries exhausted but fallback is available → FALLBACK
  • + *
  • Otherwise → FAIL
  • + *
+ */ + static ModelCallAttemptNextAction computeNextAction( + int attemptIndex, + int maxAttempts, + boolean retryable, + ModelCallAttemptRole role, + boolean hasFallback) { + if (retryable && attemptIndex < maxAttempts) { + return ModelCallAttemptNextAction.RETRY; + } + if (role == ModelCallAttemptRole.PRIMARY && hasFallback) { + return ModelCallAttemptNextAction.FALLBACK; + } + return ModelCallAttemptNextAction.FAIL; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java new file mode 100644 index 0000000000..5e5dd5738e --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java @@ -0,0 +1,198 @@ +/* + * 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.model; + +import io.agentscope.core.event.ModelCallAttemptFailureCategory; +import io.agentscope.core.model.transport.HttpTransportException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.util.concurrent.TimeoutException; + +/** + * Classifies model call exceptions into structured failure categories for attempt events. + * + *

All classification is based on exception type and HTTP status code. No raw response + * bodies, credential data, or sensitive headers are exposed. + */ +public final class ModelCallFailureClassifier { + + private ModelCallFailureClassifier() {} + + /** + * Classifies the given throwable into a failure category. + * + * @param error the exception to classify + * @return the failure category, never null + */ + public static ModelCallAttemptFailureCategory classify(Throwable error) { + if (error == null) { + return ModelCallAttemptFailureCategory.UNKNOWN; + } + + // Check cause chain recursively (max 10 levels to avoid loops) + return classifyInternal(error, 0); + } + + private static ModelCallAttemptFailureCategory classifyInternal(Throwable error, int depth) { + if (error == null || depth > 10) { + return ModelCallAttemptFailureCategory.UNKNOWN; + } + + if (error instanceof HttpTransportException hte) { + return classifyHttpTransport(hte); + } + + if (error instanceof ModelHttpException mhe) { + return classifyModelHttp(mhe); + } + + if (error instanceof TimeoutException || error instanceof SocketTimeoutException) { + return ModelCallAttemptFailureCategory.TIMEOUT; + } + + if (error instanceof ConnectException) { + return ModelCallAttemptFailureCategory.NETWORK; + } + + if (error instanceof IOException) { + return ModelCallAttemptFailureCategory.NETWORK; + } + + if (error instanceof ModelException) { + String message = error.getMessage(); + if (message != null && message.contains("timeout")) { + return ModelCallAttemptFailureCategory.TIMEOUT; + } + return ModelCallAttemptFailureCategory.UNKNOWN; + } + + // Recurse into cause + Throwable cause = error.getCause(); + if (cause != null && cause != error) { + return classifyInternal(cause, depth + 1); + } + + return ModelCallAttemptFailureCategory.UNKNOWN; + } + + private static ModelCallAttemptFailureCategory classifyHttpTransport( + HttpTransportException hte) { + Integer status = hte.getStatusCode(); + if (status == null) { + return ModelCallAttemptFailureCategory.NETWORK; + } + return classifyStatusCode(status); + } + + private static ModelCallAttemptFailureCategory classifyModelHttp(ModelHttpException mhe) { + Integer status = mhe.getStatusCode(); + if (status == null) { + return ModelCallAttemptFailureCategory.UNKNOWN; + } + return classifyStatusCode(status); + } + + private static ModelCallAttemptFailureCategory classifyStatusCode(int status) { + return switch (status) { + case 401 -> ModelCallAttemptFailureCategory.AUTHENTICATION; + case 403 -> ModelCallAttemptFailureCategory.AUTHORIZATION; + case 429 -> ModelCallAttemptFailureCategory.RATE_LIMIT; + default -> { + if (status >= 400 && status < 500) { + yield ModelCallAttemptFailureCategory.INVALID_REQUEST; + } + if (status >= 500 && status < 600) { + yield ModelCallAttemptFailureCategory.PROVIDER_5XX; + } + yield ModelCallAttemptFailureCategory.UNKNOWN; + } + }; + } + + /** + * Returns whether the given error is retryable based on its failure category. + * + * @param error the exception to check + * @return true if the error should trigger a retry + */ + public static boolean isRetryable(Throwable error) { + return ExecutionConfig.RETRYABLE_ERRORS.test(error); + } + + /** + * Extracts a sanitized error code string from the exception (HTTP status code or + * exception class name). No credential or response body data is included. + * + * @param error the exception + * @return a short, safe error code string + */ + public static String sanitizeErrorCode(Throwable error) { + if (error == null) { + return "unknown"; + } + if (error instanceof HttpTransportException hte && hte.getStatusCode() != null) { + return "HTTP_" + hte.getStatusCode(); + } + if (error instanceof ModelHttpException mhe && mhe.getStatusCode() != null) { + return "HTTP_" + mhe.getStatusCode(); + } + return error.getClass().getSimpleName(); + } + + /** + * Extracts a sanitized, short error message suitable for event payloads. + * + *

The message contains only the exception's top-level message with no response + * bodies, headers, or credential data. For {@link HttpTransportException}, the + * response body is stripped even if {@code getMessage()} includes it. Truncated + * to 200 characters. + * + * @param error the exception + * @return a safe, truncated error message + */ + public static String sanitizeMessage(Throwable error) { + if (error == null) { + return "unknown error"; + } + // For HttpTransportException, use only the base message without response body + String message; + if (error instanceof HttpTransportException) { + message = error.getMessage(); + // HttpTransportException.getMessage() appends " | Response body: ..." — strip it + if (message != null) { + int sep = message.indexOf(" | Response body: "); + if (sep > 0) { + message = message.substring(0, sep); + } + } + } else { + message = error.getMessage(); + } + if (message == null || message.isEmpty()) { + return error.getClass().getSimpleName(); + } + // Take only the first line and truncate + int newline = message.indexOf('\n'); + if (newline > 0) { + message = message.substring(0, newline); + } + if (message.length() > 200) { + message = message.substring(0, 200); + } + return message; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java index c8f0cf3cd8..0d7356b75a 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelUtils.java @@ -15,7 +15,10 @@ */ package io.agentscope.core.model; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallAttemptRole; import java.time.Duration; +import java.util.function.Consumer; import java.util.function.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -72,6 +75,56 @@ public static Flux applyTimeoutAndRetry( GenerateOptions defaultOptions, String modelName, String provider) { + // Try to read attempt event context from the merged ExecutionConfig + GenerateOptions effectiveOptions = GenerateOptions.mergeOptions(options, defaultOptions); + AttemptEventContext ctx = null; + if (effectiveOptions != null && effectiveOptions.getExecutionConfig() != null) { + ctx = effectiveOptions.getExecutionConfig().getAttemptEventContext(); + } + if (ctx != null && ctx.isComplete()) { + return applyTimeoutAndRetry( + responseFlux, + options, + defaultOptions, + modelName, + provider, + ctx.getEmitter(), + ctx.getReplyId(), + 0, + ctx.getRole()); + } + return applyTimeoutAndRetry( + responseFlux, options, defaultOptions, modelName, provider, null, null, 0, null); + } + + /** + * Applies timeout, retry, and optional attempt tracking to a model response Flux. + * + *

When {@code attemptEmitter} is non-null, attempt lifecycle events are emitted + * between the timeout operator and the retry operator so that each retry + * resubscription triggers a fresh set of lifecycle callbacks. + * + * @param responseFlux the original response Flux to enhance + * @param options generation options containing timeout and retry config (may be null) + * @param defaultOptions default options to use if options is null + * @param modelName the name of the model for error messages and logging + * @param provider the provider name (e.g., "dashscope", "openai") for error messages + * @param attemptEmitter event emitter for attempt lifecycle events, or null to skip tracking + * @param replyId logical call/reply identifier, required when attemptEmitter is non-null + * @param maxAttempts configured maximum attempts, used for attempt event metadata + * @param role primary or fallback role, required when attemptEmitter is non-null + * @return wrapped Flux with timeout, retry, and optional attempt tracking applied + */ + public static Flux applyTimeoutAndRetry( + Flux responseFlux, + GenerateOptions options, + GenerateOptions defaultOptions, + String modelName, + String provider, + Consumer attemptEmitter, + String replyId, + int maxAttempts, + ModelCallAttemptRole role) { // Merge options: per-request options (primary) override default options (fallback) GenerateOptions effectiveOptions = GenerateOptions.mergeOptions(options, defaultOptions); @@ -93,9 +146,29 @@ public static Flux applyTimeoutAndRetry( LOG.debug("Applied timeout: {} for model: {}", timeout, modelName); } + // Apply attempt tracking between timeout and retry so that each retry + // resubscription triggers fresh lifecycle callbacks. + if (attemptEmitter != null && replyId != null) { + Integer effectiveMaxAttempts = execConfig.getMaxAttempts(); + int effectiveMax = + effectiveMaxAttempts != null ? effectiveMaxAttempts : maxAttempts; + AttemptEventContext trackingCtx = execConfig.getAttemptEventContext(); + boolean hasFallback = trackingCtx != null && trackingCtx.hasFallback(); + responseFlux = + ModelCallAttemptTracker.wrap( + responseFlux, + attemptEmitter, + replyId, + effectiveMax, + provider, + modelName, + role, + hasFallback); + } + // Apply retry if configured (maxAttempts > 1 means retry is enabled) - Integer maxAttempts = execConfig.getMaxAttempts(); - if (maxAttempts != null && maxAttempts > 1) { + Integer retryMaxAttempts = execConfig.getMaxAttempts(); + if (retryMaxAttempts != null && retryMaxAttempts > 1) { Duration initialBackoff = execConfig.getInitialBackoff(); Duration maxBackoff = execConfig.getMaxBackoff(); Predicate retryOn = execConfig.getRetryOn(); @@ -112,7 +185,7 @@ public static Flux applyTimeoutAndRetry( } Retry retrySpec = - Retry.backoff(maxAttempts - 1, initialBackoff) + Retry.backoff(retryMaxAttempts - 1, initialBackoff) .maxBackoff(maxBackoff) .jitter(0.5) .filter(retryOn) @@ -122,14 +195,14 @@ public static Flux applyTimeoutAndRetry( "Retrying model request (attempt {}/{}) due" + " to: {}", signal.totalRetriesInARow() + 1, - maxAttempts - 1, + retryMaxAttempts - 1, signal.failure().getMessage(), signal.failure())); responseFlux = responseFlux.retryWhen(retrySpec); LOG.debug( "Applied retry config: maxAttempts={}, initialBackoff={} for model: {}", - maxAttempts, + retryMaxAttempts, initialBackoff, modelName); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerTest.java new file mode 100644 index 0000000000..ff3b582c05 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerTest.java @@ -0,0 +1,196 @@ +/* + * 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.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallAttemptEndEvent; +import io.agentscope.core.event.ModelCallAttemptFailedEvent; +import io.agentscope.core.event.ModelCallAttemptFailureCategory; +import io.agentscope.core.event.ModelCallAttemptRole; +import io.agentscope.core.event.ModelCallAttemptStartEvent; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +class ModelCallAttemptTrackerTest { + + private static final String REPLY_ID = "test-reply-1"; + private static final String PROVIDER = "openai"; + private static final String MODEL = "gpt-4o"; + + @Test + void successfulAttemptEmitsStartAndEnd() { + List events = new ArrayList<>(); + + Flux source = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY_ID, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(tracked).expectNextCount(1).verifyComplete(); + + assertEquals(2, events.size()); + assertInstanceOf(ModelCallAttemptStartEvent.class, events.get(0)); + assertInstanceOf(ModelCallAttemptEndEvent.class, events.get(1)); + + ModelCallAttemptStartEvent start = (ModelCallAttemptStartEvent) events.get(0); + assertEquals(REPLY_ID, start.getReplyId()); + assertEquals(1, start.getAttemptIndex()); + assertEquals(3, start.getMaxAttempts()); + assertEquals(PROVIDER, start.getProvider()); + assertEquals(MODEL, start.getModelName()); + assertEquals(ModelCallAttemptRole.PRIMARY, start.getRole()); + + ModelCallAttemptEndEvent end = (ModelCallAttemptEndEvent) events.get(1); + assertEquals(REPLY_ID, end.getReplyId()); + assertEquals(1, end.getAttemptIndex()); + assertTrue(end.isSuccess()); + assertEquals(ModelCallAttemptRole.PRIMARY, end.getRole()); + assertTrue(end.getLatencyMs() >= 0); + } + + @Test + void failedAttemptEmitsStartAndFailed() { + List events = new ArrayList<>(); + + RuntimeException error = new RuntimeException("model call failed"); + + Flux source = Flux.error(error); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY_ID, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(tracked).verifyError(RuntimeException.class); + + assertEquals(2, events.size()); + assertInstanceOf(ModelCallAttemptStartEvent.class, events.get(0)); + assertInstanceOf(ModelCallAttemptFailedEvent.class, events.get(1)); + + ModelCallAttemptFailedEvent failed = (ModelCallAttemptFailedEvent) events.get(1); + assertEquals(REPLY_ID, failed.getReplyId()); + assertEquals(1, failed.getAttemptIndex()); + assertEquals(ModelCallAttemptFailureCategory.UNKNOWN, failed.getFailureCategory()); + assertFalse(failed.isRetryable()); + assertEquals(ModelCallAttemptRole.PRIMARY, failed.getRole()); + assertNotNull(failed.getErrorMessage()); + } + + @Test + void retryEmitsMultipleAttemptEvents() { + List events = new ArrayList<>(); + AtomicInteger callCount = new AtomicInteger(0); + + // First call fails, second succeeds + Flux source = + Flux.defer( + () -> { + if (callCount.incrementAndGet() == 1) { + return Flux.error( + new io.agentscope.core.model.transport + .HttpTransportException("rate limited", 429, "")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + // Apply tracking BEFORE retry (simulating what ModelUtils does) + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY_ID, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY); + + // Apply retry on top of tracked flux + Flux withRetry = + tracked.retryWhen( + reactor.util.retry.Retry.backoff(2, Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .filter(e -> true)); + + StepVerifier.create(withRetry).expectNextCount(1).verifyComplete(); + + // Should have: START(1), FAILED(1), START(2), END(2) = 4 events + assertEquals(4, events.size()); + + ModelCallAttemptStartEvent start1 = (ModelCallAttemptStartEvent) events.get(0); + assertEquals(1, start1.getAttemptIndex()); + + ModelCallAttemptFailedEvent failed1 = (ModelCallAttemptFailedEvent) events.get(1); + assertEquals(1, failed1.getAttemptIndex()); + assertEquals(ModelCallAttemptFailureCategory.RATE_LIMIT, failed1.getFailureCategory()); + + ModelCallAttemptStartEvent start2 = (ModelCallAttemptStartEvent) events.get(2); + assertEquals(2, start2.getAttemptIndex()); + + ModelCallAttemptEndEvent end2 = (ModelCallAttemptEndEvent) events.get(3); + assertEquals(2, end2.getAttemptIndex()); + assertTrue(end2.isSuccess()); + } + + @Test + void fallbackRoleIsPassedThrough() { + List events = new ArrayList<>(); + + Flux source = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY_ID, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.FALLBACK); + + StepVerifier.create(tracked).expectNextCount(1).verifyComplete(); + + assertEquals(2, events.size()); + assertEquals( + ModelCallAttemptRole.FALLBACK, + ((ModelCallAttemptStartEvent) events.get(0)).getRole()); + assertEquals( + ModelCallAttemptRole.FALLBACK, + ((ModelCallAttemptEndEvent) events.get(1)).getRole()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerVerificationTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerVerificationTest.java new file mode 100644 index 0000000000..391eadb5e7 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptTrackerVerificationTest.java @@ -0,0 +1,1038 @@ +/* + * 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.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventType; +import io.agentscope.core.event.ModelCallAttemptEndEvent; +import io.agentscope.core.event.ModelCallAttemptFailedEvent; +import io.agentscope.core.event.ModelCallAttemptFailureCategory; +import io.agentscope.core.event.ModelCallAttemptRole; +import io.agentscope.core.event.ModelCallAttemptStartEvent; +import io.agentscope.core.event.ModelFallbackActivatedEvent; +import io.agentscope.core.model.transport.HttpTransportException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +/** + * Tests that verify every concrete requirement from the feature specification is satisfied. + * + *

Each test method name maps to a specific requirement or validation scenario from the spec. + */ +class ModelCallAttemptTrackerVerificationTest { + + private static final String REPLY = "r1"; + private static final String PROVIDER = "openai"; + private static final String MODEL = "gpt-4o"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // ======================================================================== + // REQ: Events come from Core/model transport, not product-host log parsing + // ======================================================================== + + @Test + void req_eventsOriginateInsideModelUtilsPipeline() { + // Proof: ModelUtils.applyTimeoutAndRetry with emitter actually emits events + // through the Flux pipeline, not through logging. + List emitted = new ArrayList<>(); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .build()) + .build(); + + Flux raw = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 1, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + assertFalse( + emitted.isEmpty(), + "Events must be emitted through the Consumer callback, not logs"); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, emitted.get(0).getType()); + } + + @Test + void req_eventsOriginateInsideRetryPipelineOnResubscribe() { + // Proof: When retry resubscribes, the tracker fires new START/FAILED pairs. + // This proves events originate inside the retry pipeline in ModelUtils. + List emitted = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + int n = calls.incrementAndGet(); + if (n <= 2) { + return Flux.error( + new HttpTransportException("rate limited", 429, "")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + // Attempt 1: START + FAILED, Attempt 2: START + FAILED, Attempt 3: START + END = 6 + assertEquals( + 6, + emitted.size(), + "Each retry must produce its own START/FAILED pair through the pipeline"); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, emitted.get(0).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_FAILED, emitted.get(1).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, emitted.get(2).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_FAILED, emitted.get(3).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, emitted.get(4).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_END, emitted.get(5).getType()); + } + + // ======================================================================== + // REQ: No API keys, authorization headers, raw credential values, + // or sensitive response bodies in events + // ======================================================================== + + @Test + void req_noSecretsInSanitizedErrorCode() { + HttpTransportException ex = + new HttpTransportException("Error", 429, "Bearer sk-secret-api-key-12345"); + String code = ModelCallFailureClassifier.sanitizeErrorCode(ex); + assertFalse(code.contains("sk-"), "Error code must not contain API key: " + code); + assertFalse(code.contains("Bearer"), "Error code must not contain auth header: " + code); + assertEquals("HTTP_429", code); + } + + @Test + void req_noSecretsInSanitizedMessage() { + // Response body with API key must be stripped from message + HttpTransportException ex = + new HttpTransportException( + "generic error", + 401, + "{\"error\":{\"message\":\"Invalid API key: sk-proj-abc123def456\"}}"); + String msg = ModelCallFailureClassifier.sanitizeMessage(ex); + assertFalse(msg.contains("sk-proj"), "Message must not contain API key from body: " + msg); + assertFalse( + msg.contains("Invalid API key"), "Message must not contain body content: " + msg); + assertTrue(msg.contains("generic error"), "Safe base message should be preserved: " + msg); + } + + @Test + void req_noSecretsInFailedEventPayload() throws Exception { + HttpTransportException ex = + new HttpTransportException( + "Auth failed", 401, "secret-body-content-with-bearer-token-abc123"); + ModelCallAttemptFailedEvent event = + new ModelCallAttemptFailedEvent( + REPLY, + 1, + ModelCallFailureClassifier.classify(ex), + false, + ModelCallFailureClassifier.sanitizeErrorCode(ex), + ModelCallFailureClassifier.sanitizeMessage(ex), + ModelCallAttemptRole.PRIMARY); + + String json = MAPPER.writeValueAsString(event); + // Response body must not leak into serialized event + assertFalse( + json.contains("secret-body"), + "Serialized event must not contain response body: " + json); + assertFalse( + json.contains("bearer-token-abc123"), + "Serialized event must not contain sensitive body content: " + json); + } + + // ======================================================================== + // REQ: Works for both direct provider retries and fallbackModel transitions + // ======================================================================== + + @Test + void req_retryInsideModelUtilsProducesAttemptEvents() { + // Proof: direct provider retry (inside ModelUtils) emits attempt events + List emitted = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + if (calls.incrementAndGet() == 1) { + return Flux.error(new HttpTransportException("server", 503, "")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + // Verify provider is correctly reported in each attempt event + for (AgentEvent e : emitted) { + if (e instanceof ModelCallAttemptStartEvent s) { + assertEquals(PROVIDER, s.getProvider()); + assertEquals(MODEL, s.getModelName()); + assertEquals(ModelCallAttemptRole.PRIMARY, s.getRole()); + } + } + } + + @Test + void req_fallbackTransitionEmitsModelFallbackActivatedEvent() { + // Proof: when a non-retryable error bypasses retry and reaches switchOnFirst, + // the modelForCall wrapper emits MODEL_FALLBACK_ACTIVATED. + List emitted = new ArrayList<>(); + + Model primaryModel = + new FailingModel( + "primary-provider", + "primary-model", + new HttpTransportException("forbidden", 403, "")); + Model fallbackModel = new StubModel("fallback-provider", "fallback-model"); + + Model wrapped = wrapWithFallback(primaryModel, fallbackModel, emitted::add); + + // The fallback model should be used + Flux result = wrapped.stream(List.of(), null, null); + assertNotNull(result); + // The first element proves fallback was selected + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + boolean hasFallbackEvent = + emitted.stream() + .anyMatch(e -> e.getType() == AgentEventType.MODEL_FALLBACK_ACTIVATED); + assertTrue( + hasFallbackEvent, + "MODEL_FALLBACK_ACTIVATED must be emitted when primary fails and fallback is used"); + + ModelFallbackActivatedEvent fae = + emitted.stream() + .filter(e -> e instanceof ModelFallbackActivatedEvent) + .map(e -> (ModelFallbackActivatedEvent) e) + .findFirst() + .orElseThrow(); + assertEquals("primary-model", fae.getFromModel()); + assertEquals("fallback-model", fae.getToModel()); + } + + // ======================================================================== + // REQ: 429 and 5xx retry, followed by success + // ======================================================================== + + @Test + void req_429RetryThenSuccess() { + List emitted = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + if (calls.incrementAndGet() == 1) { + return Flux.error( + new HttpTransportException("rate limited", 429, "")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + assertEquals(4, emitted.size()); + // Attempt 1 failed with RATE_LIMIT, retryable=true + ModelCallAttemptFailedEvent f1 = (ModelCallAttemptFailedEvent) emitted.get(1); + assertEquals(ModelCallAttemptFailureCategory.RATE_LIMIT, f1.getFailureCategory()); + assertTrue(f1.isRetryable()); + // Attempt 2 succeeded + ModelCallAttemptEndEvent e2 = (ModelCallAttemptEndEvent) emitted.get(3); + assertTrue(e2.isSuccess()); + } + + @Test + void req_5xxRetryThenSuccess() { + List emitted = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + if (calls.incrementAndGet() == 1) { + return Flux.error( + new HttpTransportException("server error", 503, "")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + ModelCallAttemptFailedEvent f1 = (ModelCallAttemptFailedEvent) emitted.get(1); + assertEquals(ModelCallAttemptFailureCategory.PROVIDER_5XX, f1.getFailureCategory()); + assertTrue(f1.isRetryable()); + } + + // ======================================================================== + // REQ: 401/403 non-retryable classification + // ======================================================================== + + @Test + void req_401NonRetryable() { + List emitted = new ArrayList<>(); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(ExecutionConfig.RETRYABLE_ERRORS) + .build()) + .build(); + + Flux raw = Flux.error(new HttpTransportException("unauthorized", 401, "")); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).verifyError(); + + long startCount = + emitted.stream() + .filter(e -> e.getType() == AgentEventType.MODEL_ATTEMPT_START) + .count(); + assertEquals(1, startCount, "Non-retryable 401 must produce exactly 1 START event"); + + ModelCallAttemptFailedEvent f1 = (ModelCallAttemptFailedEvent) emitted.get(1); + assertEquals(ModelCallAttemptFailureCategory.AUTHENTICATION, f1.getFailureCategory()); + assertFalse(f1.isRetryable()); + } + + @Test + void req_403NonRetryable() { + List emitted = new ArrayList<>(); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(ExecutionConfig.RETRYABLE_ERRORS) + .build()) + .build(); + + Flux raw = Flux.error(new HttpTransportException("forbidden", 403, "")); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).verifyError(); + + long startCount = + emitted.stream() + .filter(e -> e.getType() == AgentEventType.MODEL_ATTEMPT_START) + .count(); + assertEquals(1, startCount, "Non-retryable 403 must produce exactly 1 START event"); + + ModelCallAttemptFailedEvent f1 = (ModelCallAttemptFailedEvent) emitted.get(1); + assertEquals(ModelCallAttemptFailureCategory.AUTHORIZATION, f1.getFailureCategory()); + assertFalse(f1.isRetryable()); + } + + // ======================================================================== + // REQ: timeout/network retry + // ======================================================================== + + @Test + void req_timeoutRetry() { + List emitted = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + if (calls.incrementAndGet() == 1) { + return Flux.error( + new java.util.concurrent.TimeoutException("timed out")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + ModelCallAttemptFailedEvent f1 = (ModelCallAttemptFailedEvent) emitted.get(1); + assertEquals(ModelCallAttemptFailureCategory.TIMEOUT, f1.getFailureCategory()); + assertTrue(f1.isRetryable()); + } + + @Test + void req_networkRetry() { + List emitted = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + if (calls.incrementAndGet() == 1) { + return Flux.error(new java.io.IOException("connection reset")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 3, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + ModelCallAttemptFailedEvent f1 = (ModelCallAttemptFailedEvent) emitted.get(1); + assertEquals(ModelCallAttemptFailureCategory.NETWORK, f1.getFailureCategory()); + assertTrue(f1.isRetryable()); + } + + // ======================================================================== + // REQ: fallback failure (fallback also fails) + // ======================================================================== + + @Test + void req_fallbackFailureEmitsAttemptEvents() { + List emitted = new ArrayList<>(); + + Model primaryModel = + new FailingModel("p", "primary", new HttpTransportException("forbidden", 403, "")); + Model fallbackModel = + new FailingModel( + "f", "fallback", new HttpTransportException("server error", 500, "")); + + Model wrapped = wrapWithFallback(primaryModel, fallbackModel, emitted::add); + + Flux result = wrapped.stream(List.of(), null, null); + StepVerifier.create(result).verifyError(); + + // Must have MODEL_FALLBACK_ACTIVATED + boolean hasFallback = + emitted.stream() + .anyMatch(e -> e.getType() == AgentEventType.MODEL_FALLBACK_ACTIVATED); + assertTrue( + hasFallback, "Fallback activation must be emitted even when fallback also fails"); + } + + // ======================================================================== + // REQ: per-attempt latency when available + // ======================================================================== + + @Test + void req_perAttemptLatencyIsPositive() { + List emitted = new ArrayList<>(); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .build()) + .build(); + + Flux raw = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 1, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + ModelCallAttemptEndEvent end = (ModelCallAttemptEndEvent) emitted.get(1); + assertTrue( + end.getLatencyMs() >= 0, + "Latency must be non-negative, got: " + end.getLatencyMs()); + } + + // ======================================================================== + // REQ: attempt index is sequential and maxAttempts is reported + // ======================================================================== + + @Test + void req_attemptIndexSequentialAndMaxAttemptsReported() { + List emitted = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(5) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + int n = calls.incrementAndGet(); + if (n <= 3) { + return Flux.error(new HttpTransportException("error", 503, "")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 5, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + // Verify sequential indices and maxAttempts + List starts = + emitted.stream() + .filter(e -> e instanceof ModelCallAttemptStartEvent) + .map(e -> (ModelCallAttemptStartEvent) e) + .toList(); + + assertEquals(4, starts.size()); + for (int i = 0; i < starts.size(); i++) { + assertEquals( + i + 1, + starts.get(i).getAttemptIndex(), + "Attempt index must be sequential starting from 1"); + assertEquals( + 5, starts.get(i).getMaxAttempts(), "maxAttempts must be reported correctly"); + } + } + + // ======================================================================== + // REQ: JSON round-trip serialization for all new event types + // ======================================================================== + + @Test + void req_jsonRoundTripModelCallAttemptStartEvent() throws Exception { + ModelCallAttemptStartEvent original = + new ModelCallAttemptStartEvent( + REPLY, 2, 3, PROVIDER, MODEL, ModelCallAttemptRole.FALLBACK); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + assertSame(ModelCallAttemptStartEvent.class, deserialized.getClass()); + ModelCallAttemptStartEvent roundTripped = (ModelCallAttemptStartEvent) deserialized; + assertEquals(REPLY, roundTripped.getReplyId()); + assertEquals(2, roundTripped.getAttemptIndex()); + assertEquals(3, roundTripped.getMaxAttempts()); + assertEquals(PROVIDER, roundTripped.getProvider()); + assertEquals(MODEL, roundTripped.getModelName()); + assertEquals(ModelCallAttemptRole.FALLBACK, roundTripped.getRole()); + } + + @Test + void req_jsonRoundTripModelCallAttemptFailedEvent() throws Exception { + ModelCallAttemptFailedEvent original = + new ModelCallAttemptFailedEvent( + REPLY, + 1, + ModelCallAttemptFailureCategory.RATE_LIMIT, + true, + "HTTP_429", + "rate limited", + ModelCallAttemptRole.PRIMARY); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + assertSame(ModelCallAttemptFailedEvent.class, deserialized.getClass()); + ModelCallAttemptFailedEvent rt = (ModelCallAttemptFailedEvent) deserialized; + assertEquals(ModelCallAttemptFailureCategory.RATE_LIMIT, rt.getFailureCategory()); + assertTrue(rt.isRetryable()); + assertEquals("HTTP_429", rt.getErrorCode()); + assertEquals(ModelCallAttemptRole.PRIMARY, rt.getRole()); + } + + @Test + void req_jsonRoundTripModelFallbackActivatedEvent() throws Exception { + ModelFallbackActivatedEvent original = + new ModelFallbackActivatedEvent(REPLY, 3, "gpt-4o", "claude-sonnet"); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + assertSame(ModelFallbackActivatedEvent.class, deserialized.getClass()); + ModelFallbackActivatedEvent rt = (ModelFallbackActivatedEvent) deserialized; + assertEquals(3, rt.getFailedAttemptCount()); + assertEquals("gpt-4o", rt.getFromModel()); + assertEquals("claude-sonnet", rt.getToModel()); + } + + @Test + void req_jsonRoundTripModelCallAttemptEndEvent() throws Exception { + ModelCallAttemptEndEvent original = + new ModelCallAttemptEndEvent( + REPLY, 1, true, null, 123L, ModelCallAttemptRole.PRIMARY); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + assertSame(ModelCallAttemptEndEvent.class, deserialized.getClass()); + ModelCallAttemptEndEvent rt = (ModelCallAttemptEndEvent) deserialized; + assertTrue(rt.isSuccess()); + assertEquals(123L, rt.getLatencyMs()); + assertEquals(ModelCallAttemptRole.PRIMARY, rt.getRole()); + assertNull(rt.getUsage()); + } + + // ======================================================================== + // REQ: Same behavior through call() and streamEvents() lifecycle + // (verified by modelForCall wrapper which is used in both paths) + // ======================================================================== + + @Test + void req_modelForCallWithoutFallbackReturnsPlainModel() { + List emitted = new ArrayList<>(); + Model plain = new StubModel(PROVIDER, MODEL); + + // modelForCall with no fallback configured should return the model directly + // We simulate this by calling wrapWithFallback with no fallback + // When fallbackModel is null, modelForCall returns the original model + // which means no MODEL_FALLBACK_ACTIVATED events should appear + Model wrapped = wrapWithFallback(plain, null, emitted::add); + + Flux result = wrapped.stream(List.of(), null, null); + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + boolean hasFallback = + emitted.stream() + .anyMatch(e -> e.getType() == AgentEventType.MODEL_FALLBACK_ACTIVATED); + assertFalse(hasFallback, "No fallback event should be emitted when fallbackModel is null"); + } + + @Test + void req_attemptEventFieldsMatchSpecSuggestedShape() { + // Verify all suggested fields from the spec are present in events + ModelCallAttemptStartEvent start = + new ModelCallAttemptStartEvent( + "reply-42", + 2, + 5, + "anthropic", + "claude-opus-4", + ModelCallAttemptRole.PRIMARY); + assertNotNull(start.getReplyId()); + assertTrue(start.getAttemptIndex() > 0); + assertTrue(start.getMaxAttempts() > 0); + assertNotNull(start.getProvider()); + assertNotNull(start.getModelName()); + assertNotNull(start.getRole()); + + ModelCallAttemptFailedEvent failed = + new ModelCallAttemptFailedEvent( + "reply-42", + 2, + ModelCallAttemptFailureCategory.TIMEOUT, + true, + "TIMEOUT_EXCEEDED", + "request timed out", + ModelCallAttemptRole.PRIMARY); + assertNotNull(failed.getReplyId()); + assertNotNull(failed.getFailureCategory()); + assertNotNull(failed.getErrorCode()); + assertNotNull(failed.getErrorMessage()); + assertNotNull(failed.getRole()); + + ModelCallAttemptEndEvent end = + new ModelCallAttemptEndEvent( + "reply-42", 5, true, null, 500L, ModelCallAttemptRole.FALLBACK); + assertNotNull(end.getReplyId()); + assertTrue(end.isSuccess()); + assertNotNull(end.getRole()); + assertTrue(end.getLatencyMs() >= 0); + } + + // ======================================================================== + // REQ: Failed attempts do not create assistant messages + // (verified architecturally: tracker only emits events, no Msg objects) + // ======================================================================== + + @Test + void req_failedAttemptsOnlyEmitEventsNoMessages() { + List emitted = new ArrayList<>(); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .build()) + .build(); + + Flux raw = Flux.error(new HttpTransportException("error", 400, "")); + + Flux result = + ModelUtils.applyTimeoutAndRetry( + raw, + opts, + null, + MODEL, + PROVIDER, + emitted::add, + REPLY, + 1, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(result).verifyError(); + + // All emitted items must be AgentEvent instances (no Msg objects leak) + for (AgentEvent e : emitted) { + assertNotNull(e.getType(), "Every emitted item must have a valid event type"); + } + assertEquals(2, emitted.size()); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, emitted.get(0).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_FAILED, emitted.get(1).getType()); + } + + // ======================================================================== + // REQ: Existing aggregate MODEL_CALL_START/END remain backward compatible + // ======================================================================== + + @Test + void req_backwardCompat_originalApplyTimeoutAndRetryStillWorks() { + // The original 5-arg method must still work without any tracker + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .build()) + .build(); + + Flux raw = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + // Original signature - no emitter params + Flux result = + ModelUtils.applyTimeoutAndRetry(raw, opts, null, MODEL, PROVIDER); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /** + * Simulates ReActAgent.modelForCall() fallback wrapper with event emission. + */ + private static Model wrapWithFallback( + Model primary, Model fallback, java.util.function.Consumer emitter) { + if (fallback == null) { + return primary; + } + return new Model() { + @Override + public Flux stream( + List messages, + List tools, + GenerateOptions options) { + return primary.stream(messages, tools, options) + .switchOnFirst( + (signal, flux) -> { + if (signal.isOnError()) { + Throwable error = signal.getThrowable(); + emitter.accept( + new ModelFallbackActivatedEvent( + null, + 0, + primary.getModelName(), + fallback.getModelName())); + return fallback.stream(messages, tools, options); + } + return flux; + }); + } + + @Override + public String getModelName() { + return primary.getModelName(); + } + }; + } + + private static int countOccurrences(String str, String sub) { + int count = 0; + int idx = 0; + while ((idx = str.indexOf(sub, idx)) != -1) { + count++; + idx += sub.length(); + } + return count; + } + + private static class StubModel implements Model { + private final String provider; + private final String name; + + StubModel(String provider, String name) { + this.provider = provider; + this.name = name; + } + + @Override + public Flux stream( + List messages, + List tools, + GenerateOptions options) { + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + } + + @Override + public String getModelName() { + return name; + } + + @Override + public String toString() { + return provider + "/" + name; + } + } + + private static class FailingModel implements Model { + private final String provider; + private final String name; + private final RuntimeException error; + + FailingModel(String provider, String name, RuntimeException error) { + this.provider = provider; + this.name = name; + this.error = error; + } + + @Override + public Flux stream( + List messages, + List tools, + GenerateOptions options) { + return Flux.error(error); + } + + @Override + public String getModelName() { + return name; + } + + @Override + public String toString() { + return provider + "/" + name; + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallFailureClassifierTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallFailureClassifierTest.java new file mode 100644 index 0000000000..d905a25ddb --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallFailureClassifierTest.java @@ -0,0 +1,188 @@ +/* + * 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.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.event.ModelCallAttemptFailureCategory; +import io.agentscope.core.model.transport.HttpTransportException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; + +class ModelCallFailureClassifierTest { + + @Test + void classifyNullReturnsUnknown() { + assertEquals( + ModelCallAttemptFailureCategory.UNKNOWN, ModelCallFailureClassifier.classify(null)); + } + + @Test + void classifyRateLimit429() { + HttpTransportException ex = new HttpTransportException("rate limited", 429, ""); + assertEquals( + ModelCallAttemptFailureCategory.RATE_LIMIT, + ModelCallFailureClassifier.classify(ex)); + } + + @Test + void classifyServer5xx() { + HttpTransportException ex = new HttpTransportException("server error", 503, ""); + assertEquals( + ModelCallAttemptFailureCategory.PROVIDER_5XX, + ModelCallFailureClassifier.classify(ex)); + } + + @Test + void classifyAuthentication401() { + HttpTransportException ex = new HttpTransportException("unauthorized", 401, ""); + assertEquals( + ModelCallAttemptFailureCategory.AUTHENTICATION, + ModelCallFailureClassifier.classify(ex)); + } + + @Test + void classifyAuthorization403() { + HttpTransportException ex = new HttpTransportException("forbidden", 403, ""); + assertEquals( + ModelCallAttemptFailureCategory.AUTHORIZATION, + ModelCallFailureClassifier.classify(ex)); + } + + @Test + void classifyBadRequest400() { + HttpTransportException ex = new HttpTransportException("bad request", 400, ""); + assertEquals( + ModelCallAttemptFailureCategory.INVALID_REQUEST, + ModelCallFailureClassifier.classify(ex)); + } + + @Test + void classifyNetworkErrorNoStatusCode() { + HttpTransportException ex = new HttpTransportException("connection refused"); + assertEquals( + ModelCallAttemptFailureCategory.NETWORK, ModelCallFailureClassifier.classify(ex)); + } + + @Test + void classifyTimeoutException() { + assertEquals( + ModelCallAttemptFailureCategory.TIMEOUT, + ModelCallFailureClassifier.classify(new TimeoutException())); + } + + @Test + void classifySocketTimeoutException() { + assertEquals( + ModelCallAttemptFailureCategory.TIMEOUT, + ModelCallFailureClassifier.classify(new SocketTimeoutException())); + } + + @Test + void classifyConnectException() { + assertEquals( + ModelCallAttemptFailureCategory.NETWORK, + ModelCallFailureClassifier.classify(new ConnectException())); + } + + @Test + void classifyIoException() { + assertEquals( + ModelCallAttemptFailureCategory.NETWORK, + ModelCallFailureClassifier.classify(new IOException("reset"))); + } + + @Test + void classifyWrappedException() { + Exception inner = new HttpTransportException("rate limited", 429, ""); + RuntimeException wrapped = new RuntimeException("model call failed", inner); + assertEquals( + ModelCallAttemptFailureCategory.RATE_LIMIT, + ModelCallFailureClassifier.classify(wrapped)); + } + + @Test + void classifyUnknownException() { + assertEquals( + ModelCallAttemptFailureCategory.UNKNOWN, + ModelCallFailureClassifier.classify(new IllegalStateException("oops"))); + } + + @Test + void isRetryableDelegatesToRetryableErrors() { + HttpTransportException retryable = new HttpTransportException("rate limited", 429, ""); + assertTrue(ModelCallFailureClassifier.isRetryable(retryable)); + + HttpTransportException nonRetryable = new HttpTransportException("bad request", 400, ""); + assertFalse(ModelCallFailureClassifier.isRetryable(nonRetryable)); + } + + @Test + void sanitizeErrorCodeFromHttpException() { + HttpTransportException ex = new HttpTransportException("error", 429, "secret-key-abc"); + assertEquals("HTTP_429", ModelCallFailureClassifier.sanitizeErrorCode(ex)); + } + + @Test + void sanitizeErrorCodeFromNonHttpException() { + assertEquals( + "IllegalStateException", + ModelCallFailureClassifier.sanitizeErrorCode(new IllegalStateException("oops"))); + } + + @Test + void sanitizeErrorCodeNull() { + assertEquals("unknown", ModelCallFailureClassifier.sanitizeErrorCode(null)); + } + + @Test + void sanitizeMessageTruncatesLongMessage() { + String longMsg = "x".repeat(300); + HttpTransportException ex = new HttpTransportException(longMsg, 500, ""); + String sanitized = ModelCallFailureClassifier.sanitizeMessage(ex); + assertTrue(sanitized.length() <= 200); + } + + @Test + void sanitizeMessageTakesFirstLine() { + HttpTransportException ex = + new HttpTransportException("line1\nline2\nline3", 500, "some-body"); + assertEquals("line1", ModelCallFailureClassifier.sanitizeMessage(ex)); + } + + @Test + void sanitizeMessageNull() { + assertEquals("unknown error", ModelCallFailureClassifier.sanitizeMessage(null)); + } + + @Test + void sanitizeMessageStripsResponseBody() { + HttpTransportException ex = + new HttpTransportException("error", 500, "secret-response-body"); + assertEquals("error", ModelCallFailureClassifier.sanitizeMessage(ex)); + } + + @Test + void sanitizeMessageEmpty() { + HttpTransportException ex = new HttpTransportException("", 500, ""); + assertEquals("HttpTransportException", ModelCallFailureClassifier.sanitizeMessage(ex)); + } +} From 309469298843ff593204db1b10ae517f0ee7eb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E5=85=89c?= <2533995180@qq.com> Date: Tue, 14 Jul 2026 21:59:44 +0800 Subject: [PATCH 2/7] feat: add missing new files for attempt event tracking --- .../event/ModelCallAttemptNextAction.java | 38 + .../core/model/AttemptEventContext.java | 102 ++ .../agent/ReActAgentAttemptEventTest.java | 657 +++++++++++++ .../model/ModelCallAttemptFeatureTest.java | 919 ++++++++++++++++++ 4 files changed, 1716 insertions(+) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptNextAction.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentAttemptEventTest.java create mode 100644 agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptNextAction.java b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptNextAction.java new file mode 100644 index 0000000000..cdead3aa40 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ModelCallAttemptNextAction.java @@ -0,0 +1,38 @@ +/* + * 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; + +/** + * Describes the next action the system will take after a model call attempt failure. + */ +public enum ModelCallAttemptNextAction { + /** The system will retry the same model. */ + RETRY("retry"), + /** The system will switch to the fallback model. */ + FALLBACK("fallback"), + /** The system will propagate the failure (no more retries or fallback). */ + FAIL("fail"); + + private final String value; + + ModelCallAttemptNextAction(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java b/agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java new file mode 100644 index 0000000000..b811c3d8e9 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java @@ -0,0 +1,102 @@ +/* + * 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.model; + +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallAttemptRole; +import java.util.function.Consumer; + +/** + * Runtime-only context for emitting attempt lifecycle events from within the + * model transport pipeline. ,。/‘ + * + *

This object is not serialized and does not participate in JSON + * round-trips. It is set by {@code ReActAgent} (or any caller that owns the + * event sink) and read by {@link ModelUtils#applyTimeoutAndRetry} to wire the + * tracker into the retry pipeline without changing Provider signatures. + * + *

Usage: + *

{@code
+ * ExecutionConfig cfg = ExecutionConfig.builder()
+ *     .maxAttempts(3)
+ *     .attemptEventContext(new AttemptEventContext(emitter, replyId, role))
+ *     .build();
+ * }
+ */ +public final class AttemptEventContext { + + private final Consumer emitter; + private final String replyId; + private final ModelCallAttemptRole role; + private final boolean hasFallback; + + /** + * Creates a new attempt event context. + * + * @param emitter the event consumer to receive attempt lifecycle events + * @param replyId the logical call/reply identifier + * @param role whether this context is for a primary or fallback model call + */ + public AttemptEventContext( + Consumer emitter, String replyId, ModelCallAttemptRole role) { + this(emitter, replyId, role, false); + } + + /** + * Creates a new attempt event context with explicit fallback availability. + * + * @param emitter the event consumer to receive attempt lifecycle events + * @param replyId the logical call/reply identifier + * @param role whether this context is for a primary or fallback model call + * @param hasFallback whether a fallback model is configured (only meaningful for PRIMARY role) + */ + public AttemptEventContext( + Consumer emitter, + String replyId, + ModelCallAttemptRole role, + boolean hasFallback) { + this.emitter = emitter; + this.replyId = replyId; + this.role = role; + this.hasFallback = hasFallback; + } + + /** Returns the event emitter, or null if not set. */ + public Consumer getEmitter() { + return emitter; + } + + /** Returns the reply identifier, or null if not set. */ + public String getReplyId() { + return replyId; + } + + /** Returns the attempt role (primary or fallback). */ + public ModelCallAttemptRole getRole() { + return role; + } + + /** Returns true if a fallback model is configured for this call. */ + public boolean hasFallback() { + return hasFallback; + } + + /** Returns true if the context has all required fields populated. */ + public boolean isComplete() { + return emitter != null && replyId != null && role != null; + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentAttemptEventTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentAttemptEventTest.java new file mode 100644 index 0000000000..7630f1322c --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentAttemptEventTest.java @@ -0,0 +1,657 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.ModelCallAttemptEndEvent; +import io.agentscope.core.event.ModelCallAttemptFailedEvent; +import io.agentscope.core.event.ModelCallAttemptNextAction; +import io.agentscope.core.event.ModelCallAttemptRole; +import io.agentscope.core.event.ModelCallAttemptStartEvent; +import io.agentscope.core.event.ModelFallbackActivatedEvent; +import io.agentscope.core.event.RequireUserConfirmEvent; +import io.agentscope.core.event.ToolCallStartEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.AttemptEventContext; +import io.agentscope.core.model.ChatModelBase; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ExecutionConfig; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ModelUtils; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.model.transport.HttpTransportException; +import io.agentscope.core.permission.PermissionContextState; +import io.agentscope.core.permission.PermissionDecision; +import io.agentscope.core.tool.ToolBase; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.Toolkit; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * End-to-end tests verifying that {@link ReActAgent} emits model call attempt lifecycle events + * during real agent execution through the {@code streamEvents()} / {@code call()} paths. + * + *

These tests use a {@link RetryingScriptedModel} that internally calls + * {@link ModelUtils#applyTimeoutAndRetry} to simulate Provider-layer retry/timeout behavior, + * matching the production call chain: {@code ReActAgent.modelCallStream()} → + * {@code injectAttemptTracking()} → {@code model.stream()} → Provider's + * {@code applyTimeoutAndRetry()} → {@code ModelCallAttemptTracker.wrap()}. + * + *

Test scenarios: + *

    + *
  1. Retry then success — verifies MODEL_ATTEMPT_START/FAILED/END events + *
  2. Fallback activation — verifies MODEL_FALLBACK_ACTIVATED event + *
  3. Fallback role — verifies FALLBACK role in attempt events + *
  4. call() path — verifies events emitted through shared buildAgentStream core + *
  5. No tool replay after fallback — verifies no tool events from failed primary + *
  6. HITL + fallback coexist — verifies both mechanisms work together + *
  7. All attempts fail — verifies no assistant message on total failure + *
  8. AttemptEventContext propagation — verifies context reaches model options + *
+ */ +class ReActAgentAttemptEventTest { + + private static final String PRIMARY_NAME = "test-primary"; + private static final String FALLBACK_NAME = "test-fallback"; + private static final String PROVIDER = "test-provider"; + + private static final ExecutionConfig TEST_EXEC_CONFIG = + ExecutionConfig.builder() + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(ExecutionConfig.RETRYABLE_ERRORS) + .timeout(Duration.ofSeconds(5)) + .build(); + + // ==================== Test Models ==================== + + /** + * A scripted model that wraps its flux with {@link ModelUtils#applyTimeoutAndRetry} to + * simulate Provider-layer retry/timeout/tracking behavior. Each call to {@code doStream} + * advances the script index, and the returned flux is wrapped with the full retry pipeline + * so that {@link AttemptEventContext} injected by {@link ReActAgent#modelCallStream} is + * consumed and attempt lifecycle events are emitted. + */ + private static final class RetryingScriptedModel extends ChatModelBase { + private final List>> scripts; + private final AtomicInteger idx = new AtomicInteger(0); + private final String modelName; + private final String provider; + private final AtomicReference lastOptions = new AtomicReference<>(); + + RetryingScriptedModel( + String modelName, String provider, List>> scripts) { + this.modelName = modelName; + this.provider = provider; + this.scripts = scripts; + } + + @Override + public String getModelName() { + return modelName; + } + + @Override + protected Flux doStream( + List messages, List tools, GenerateOptions options) { + lastOptions.set(options); + int i = idx.getAndIncrement(); + Flux raw; + if (i >= scripts.size()) { + raw = Flux.just(textResponse("")); + } else { + raw = scripts.get(i).get(); + } + return ModelUtils.applyTimeoutAndRetry(raw, options, null, modelName, provider); + } + + GenerateOptions getLastOptions() { + return lastOptions.get(); + } + } + + /** A tool that requires user confirmation before execution. */ + private static final class AskingTool extends ToolBase { + AskingTool(String name) { + super(name, "asks for permission", schemaFor(), false, true, false, null, false, false); + } + + private static Map schemaFor() { + Map schema = new HashMap<>(); + schema.put("type", "object"); + Map props = new HashMap<>(); + Map q = new HashMap<>(); + q.put("type", "string"); + props.put("query", q); + schema.put("properties", props); + return schema; + } + + @Override + public Mono checkPermissions( + Map toolInput, PermissionContextState context) { + return Mono.just(PermissionDecision.ask("ask: " + getName())); + } + + @Override + public Mono callAsync(ToolCallParam param) { + Object q = param.getInput() == null ? "" : param.getInput().get("query"); + return Mono.just(io.agentscope.core.message.ToolResultBlock.text("executed:" + q)); + } + } + + // ==================== Helper methods ==================== + + private static ChatResponse textResponse(String text) { + return ChatResponse.builder() + .content(List.of(TextBlock.builder().text(text).build())) + .build(); + } + + private static ChatResponse toolUseResponse(String toolId, String toolName, String query) { + Map input = new HashMap<>(); + input.put("query", query); + return ChatResponse.builder() + .content( + List.of( + ToolUseBlock.builder() + .id(toolId) + .name(toolName) + .input(input) + .build())) + .build(); + } + + private static Msg userMsg(String text) { + return Msg.builder().name("user").role(MsgRole.USER).textContent(text).build(); + } + + private static Toolkit toolkitWith(ToolBase... tools) { + Toolkit tk = new Toolkit(); + for (ToolBase t : tools) { + tk.registerAgentTool(t); + } + return tk; + } + + private static ReActAgent buildAgent(RetryingScriptedModel model) { + return ReActAgent.builder() + .name("asst") + .model(model) + .modelExecutionConfig(TEST_EXEC_CONFIG) + .build(); + } + + private static ReActAgent buildAgentWithFallback( + RetryingScriptedModel primary, RetryingScriptedModel fallback) { + return ReActAgent.builder() + .name("asst") + .model(primary) + .fallbackModel(fallback) + .modelExecutionConfig(TEST_EXEC_CONFIG) + .build(); + } + + private static int indexOf(List events, Class type) { + for (int i = 0; i < events.size(); i++) { + if (type.isInstance(events.get(i))) { + return i; + } + } + return -1; + } + + private static int countOf(List events, Class type) { + int c = 0; + for (AgentEvent e : events) { + if (type.isInstance(e)) { + c++; + } + } + return c; + } + + @SuppressWarnings("unchecked") + private static List eventsOf(List events, Class type) { + List result = new ArrayList<>(); + for (AgentEvent e : events) { + if (type.isInstance(e)) { + result.add((T) e); + } + } + return result; + } + + // ======================================================================== + // 1. Retry then success — verifies MODEL_ATTEMPT_START/FAILED/END events + // ======================================================================== + + @Test + void retryThenSuccess_emitsAttemptLifecycleEvents() { + AtomicInteger callCount = new AtomicInteger(0); + RetryingScriptedModel model = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.defer( + () -> { + int n = callCount.getAndIncrement(); + if (n == 0) { + return Flux.error( + new HttpTransportException( + "rate limited", 429, "")); + } + return Flux.just(textResponse("recovered")); + }))); + ReActAgent agent = buildAgent(model); + + List events = agent.streamEvents(List.of(userMsg("hi"))).collectList().block(); + assertNotNull(events); + + List starts = + eventsOf(events, ModelCallAttemptStartEvent.class); + List failures = + eventsOf(events, ModelCallAttemptFailedEvent.class); + List ends = eventsOf(events, ModelCallAttemptEndEvent.class); + + // Two starts: attempt 1 (fails) + attempt 2 (succeeds) + assertEquals(2, starts.size(), "Should have 2 attempt starts (fail then succeed)"); + assertEquals(1, starts.get(0).getAttemptIndex()); + assertEquals(2, starts.get(1).getAttemptIndex()); + assertEquals(PRIMARY_NAME, starts.get(0).getModelName()); + assertEquals(PROVIDER, starts.get(0).getProvider()); + assertEquals(ModelCallAttemptRole.PRIMARY, starts.get(0).getRole()); + + // One failure: attempt 1 with RETRY nextAction + assertEquals(1, failures.size(), "Should have 1 attempt failure"); + ModelCallAttemptFailedEvent failed = failures.get(0); + assertEquals(1, failed.getAttemptIndex()); + assertTrue(failed.isRetryable(), "429 should be retryable"); + assertEquals( + ModelCallAttemptNextAction.RETRY, + failed.getNextAction(), + "First failure with attempts remaining should trigger RETRY"); + + // One successful end: attempt 2 + assertEquals(1, ends.size(), "Should have 1 attempt end (success)"); + ModelCallAttemptEndEvent end = ends.get(0); + assertEquals(2, end.getAttemptIndex()); + assertTrue(end.isSuccess(), "Second attempt should succeed"); + assertEquals(ModelCallAttemptRole.PRIMARY, end.getRole()); + } + + // ======================================================================== + // 2. Fallback activation — verifies MODEL_FALLBACK_ACTIVATED event + // ======================================================================== + + @Test + void fallbackActivation_emitsModelFallbackActivatedEvent() { + RetryingScriptedModel primary = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.error( + new HttpTransportException( + "rate limited", 429, "")))); + RetryingScriptedModel fallback = + new RetryingScriptedModel( + FALLBACK_NAME, + PROVIDER, + List.of(() -> Flux.just(textResponse("fallback success")))); + ReActAgent agent = buildAgentWithFallback(primary, fallback); + + List events = agent.streamEvents(List.of(userMsg("hi"))).collectList().block(); + assertNotNull(events); + + List fallbackEvents = + eventsOf(events, ModelFallbackActivatedEvent.class); + assertEquals(1, fallbackEvents.size(), "Should emit exactly one fallback activated event"); + + ModelFallbackActivatedEvent fba = fallbackEvents.get(0); + assertEquals(PRIMARY_NAME, fba.getFromModel()); + assertEquals(FALLBACK_NAME, fba.getToModel()); + + // Fallback should occur after primary attempt failures + int lastPrimaryFailedIdx = -1; + int fallbackIdx = indexOf(events, ModelFallbackActivatedEvent.class); + for (int i = 0; i < fallbackIdx; i++) { + if (events.get(i) instanceof ModelCallAttemptFailedEvent mfe + && mfe.getRole() == ModelCallAttemptRole.PRIMARY) { + lastPrimaryFailedIdx = i; + } + } + assertTrue( + lastPrimaryFailedIdx >= 0 && lastPrimaryFailedIdx < fallbackIdx, + "Fallback activation must follow primary attempt failures"); + } + + // ======================================================================== + // 3. Fallback role — verifies FALLBACK role in attempt events after switch + // ======================================================================== + + @Test + void fallbackModel_emitsAttemptEventsWithFallbackRole() { + RetryingScriptedModel primary = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.error( + new HttpTransportException( + "rate limited", 429, "")))); + RetryingScriptedModel fallback = + new RetryingScriptedModel( + FALLBACK_NAME, + PROVIDER, + List.of(() -> Flux.just(textResponse("fallback ok")))); + ReActAgent agent = buildAgentWithFallback(primary, fallback); + + List events = agent.streamEvents(List.of(userMsg("hi"))).collectList().block(); + assertNotNull(events); + + List starts = + eventsOf(events, ModelCallAttemptStartEvent.class); + List ends = eventsOf(events, ModelCallAttemptEndEvent.class); + + // Verify at least one FALLBACK role start event exists + boolean hasFallbackStart = + starts.stream().anyMatch(s -> s.getRole() == ModelCallAttemptRole.FALLBACK); + assertTrue(hasFallbackStart, "Should have at least one FALLBACK role attempt start"); + + // Verify a successful FALLBACK role end event exists + boolean hasFallbackEnd = + ends.stream() + .anyMatch( + e -> e.getRole() == ModelCallAttemptRole.FALLBACK && e.isSuccess()); + assertTrue(hasFallbackEnd, "Should have a successful FALLBACK role attempt end"); + + // Verify the fallback model name appears in start events + boolean hasFallbackModelName = + starts.stream().anyMatch(s -> FALLBACK_NAME.equals(s.getModelName())); + assertTrue( + hasFallbackModelName, "Fallback start events should carry the fallback model name"); + + // Verify primary events also exist (before fallback) + boolean hasPrimaryStart = + starts.stream().anyMatch(s -> s.getRole() == ModelCallAttemptRole.PRIMARY); + assertTrue(hasPrimaryStart, "Should have PRIMARY role attempt starts before fallback"); + } + + // ======================================================================== + // 4. call() path — verifies events emitted through shared buildAgentStream core + // ======================================================================== + + @Test + void callPath_emitsAttemptEvents() { + AtomicInteger callCount = new AtomicInteger(0); + RetryingScriptedModel model = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.defer( + () -> { + int n = callCount.getAndIncrement(); + if (n == 0) { + return Flux.error( + new HttpTransportException( + "rate limited", 429, "")); + } + return Flux.just(textResponse("ok")); + }))); + + // streamEvents() shares the same buildAgentStream core as call() + ReActAgent agent = buildAgent(model); + List events = agent.streamEvents(List.of(userMsg("hi"))).collectList().block(); + assertNotNull(events); + assertTrue( + countOf(events, ModelCallAttemptStartEvent.class) > 0, + "Attempt events must be emitted through the shared call/streamEvents core"); + + // Also verify call() path produces a result on a fresh agent + AtomicInteger callCount2 = new AtomicInteger(0); + RetryingScriptedModel model2 = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.defer( + () -> { + int n = callCount2.getAndIncrement(); + if (n == 0) { + return Flux.error( + new HttpTransportException( + "rate limited", 429, "")); + } + return Flux.just(textResponse("via call")); + }))); + ReActAgent agent2 = buildAgent(model2); + Msg result = agent2.call(List.of(userMsg("hi"))).block(); + assertNotNull(result, "call() should return a result after retry recovery"); + + // Verify result contains the expected text + boolean hasText = + result.getContent().stream() + .anyMatch( + b -> + b instanceof TextBlock tb + && tb.getText().contains("via call")); + assertTrue(hasText, "Result should contain text from the recovered model call"); + } + + // ======================================================================== + // 5. No tool replay after fallback — verifies no tool events from failed primary + // ======================================================================== + + @Test + void fallbackDoesNotReplayToolsFromFailedPrimary() { + RetryingScriptedModel primary = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.error( + new HttpTransportException( + "rate limited", 429, "")))); + RetryingScriptedModel fallback = + new RetryingScriptedModel( + FALLBACK_NAME, + PROVIDER, + List.of(() -> Flux.just(textResponse("text only")))); + ReActAgent agent = buildAgentWithFallback(primary, fallback); + + List events = agent.streamEvents(List.of(userMsg("hi"))).collectList().block(); + assertNotNull(events); + + // Primary failed without emitting any content; fallback returned text only. + // No tool call events should appear in the stream. + assertEquals( + 0, + countOf(events, ToolCallStartEvent.class), + "No tool calls should be emitted from the failed primary attempt"); + } + + // ======================================================================== + // 6. HITL + fallback coexist — verifies both mechanisms work together + // ======================================================================== + + @Test + void hitlAndFallbackCoexist() { + RetryingScriptedModel primary = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.error( + new HttpTransportException( + "rate limited", 429, "")))); + RetryingScriptedModel fallback = + new RetryingScriptedModel( + FALLBACK_NAME, + PROVIDER, + List.of(() -> Flux.just(toolUseResponse("tc1", "ask", "x")))); + ReActAgent agent = + ReActAgent.builder() + .name("asst") + .model(primary) + .fallbackModel(fallback) + .modelExecutionConfig(TEST_EXEC_CONFIG) + .toolkit(toolkitWith(new AskingTool("ask"))) + .build(); + + List events = agent.streamEvents(List.of(userMsg("hi"))).collectList().block(); + assertNotNull(events); + + // Both fallback activation and HITL confirmation events should be present + assertTrue( + countOf(events, ModelFallbackActivatedEvent.class) > 0, + "Fallback activation event should be emitted when primary fails"); + assertTrue( + countOf(events, RequireUserConfirmEvent.class) > 0, + "HITL confirmation event should be emitted after fallback returns asking tool"); + + // Fallback must occur before HITL + int fallbackIdx = indexOf(events, ModelFallbackActivatedEvent.class); + int hitlIdx = indexOf(events, RequireUserConfirmEvent.class); + assertTrue( + fallbackIdx >= 0 && hitlIdx > fallbackIdx, + "Fallback activation must precede the HITL confirmation event"); + } + + // ======================================================================== + // 7. All attempts fail — verifies no assistant message on total failure + // ======================================================================== + + @Test + void allAttemptsFail_noAssistantMessageAddedToContext() { + RetryingScriptedModel model = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.error( + new HttpTransportException( + "rate limited", 429, "")))); + ReActAgent agent = buildAgent(model); + + List events = new ArrayList<>(); + try { + agent.streamEvents(List.of(userMsg("hi"))).doOnNext(events::add).blockLast(); + } catch (Throwable expected) { + // All attempts failed — error propagation is expected + } + + // Attempt events should have been emitted before the error propagated + assertTrue( + countOf(events, ModelCallAttemptStartEvent.class) > 0, + "Attempt start events should be emitted before total failure"); + assertTrue( + countOf(events, ModelCallAttemptFailedEvent.class) > 0, + "Attempt failed events should be emitted before total failure"); + + // The last FAILED event should have nextAction=FAIL (no fallback configured) + List failures = + eventsOf(events, ModelCallAttemptFailedEvent.class); + assertFalse(failures.isEmpty(), "Should have at least one failure event"); + ModelCallAttemptFailedEvent lastFailure = failures.get(failures.size() - 1); + assertEquals( + ModelCallAttemptNextAction.FAIL, + lastFailure.getNextAction(), + "Last failure with no fallback should have nextAction=FAIL"); + + // No assistant message should be in the agent's context + boolean hasAssistant = + agent.getAgentState().getContext().stream() + .anyMatch(m -> m.getRole() == MsgRole.ASSISTANT); + assertFalse( + hasAssistant, + "No assistant message should be added to context when all attempts fail"); + } + + // ======================================================================== + // 8. AttemptEventContext propagation — verifies context reaches model options + // ======================================================================== + + @Test + void attemptEventContextPropagatedToModelOptions() { + AtomicInteger callCount = new AtomicInteger(0); + RetryingScriptedModel model = + new RetryingScriptedModel( + PRIMARY_NAME, + PROVIDER, + List.of( + () -> + Flux.defer( + () -> { + int n = callCount.getAndIncrement(); + if (n == 0) { + return Flux.error( + new HttpTransportException( + "rate limited", 429, "")); + } + return Flux.just(textResponse("ok")); + }))); + ReActAgent agent = buildAgent(model); + + agent.streamEvents(List.of(userMsg("hi"))).collectList().block(); + + GenerateOptions lastOptions = model.getLastOptions(); + assertNotNull(lastOptions, "Model should have received GenerateOptions"); + assertNotNull(lastOptions.getExecutionConfig(), "Options should contain ExecutionConfig"); + + AttemptEventContext ctx = lastOptions.getExecutionConfig().getAttemptEventContext(); + assertNotNull( + ctx, "ExecutionConfig should contain AttemptEventContext injected by ReActAgent"); + assertTrue(ctx.isComplete(), "AttemptEventContext should have all fields populated"); + assertNotNull(ctx.getReplyId(), "ReplyId should be set"); + assertEquals( + ModelCallAttemptRole.PRIMARY, + ctx.getRole(), + "Primary model call should have PRIMARY role"); + assertNotNull(ctx.getEmitter(), "Emitter consumer should be set"); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java new file mode 100644 index 0000000000..29304c82a3 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java @@ -0,0 +1,919 @@ +/* + * 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.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.core.event.AgentEvent; +import io.agentscope.core.event.AgentEventType; +import io.agentscope.core.event.ModelCallAttemptEndEvent; +import io.agentscope.core.event.ModelCallAttemptFailedEvent; +import io.agentscope.core.event.ModelCallAttemptFailureCategory; +import io.agentscope.core.event.ModelCallAttemptNextAction; +import io.agentscope.core.event.ModelCallAttemptRole; +import io.agentscope.core.event.ModelCallAttemptStartEvent; +import io.agentscope.core.event.ModelFallbackActivatedEvent; +import io.agentscope.core.model.transport.HttpTransportException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +/** + * Comprehensive tests for the model call attempt feature, covering nextAction logic, + * AttemptEventContext integration, usage extraction, backward compatibility, JSON round-trips, + * and 5-arg applyTimeoutAndRetry behavior. + */ +class ModelCallAttemptFeatureTest { + + private static final String REPLY = "r1"; + private static final String PROVIDER = "openai"; + private static final String MODEL = "gpt-4o"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // ======================================================================== + // 1. nextAction=RETRY when retryable and attempts remaining + // ======================================================================== + + @Test + void nextActionRetry_whenRetryableAndAttemptsRemaining() { + List events = new ArrayList<>(); + + Flux source = Flux.error(new HttpTransportException("rate limited", 429, "")); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY, + false); + + StepVerifier.create(tracked).verifyError(); + + assertEquals(2, events.size()); + ModelCallAttemptFailedEvent failed = (ModelCallAttemptFailedEvent) events.get(1); + assertEquals(ModelCallAttemptNextAction.RETRY, failed.getNextAction()); + assertEquals(1, failed.getAttemptIndex()); + assertEquals(3, failed.getMaxAttempts()); + assertTrue(failed.isRetryable()); + } + + // ======================================================================== + // 2. nextAction=FALLBACK when retries exhausted but fallback available + // ======================================================================== + + @Test + void nextActionFallback_whenRetriesExhaustedAndFallbackAvailable() { + List events = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + Flux source = + Flux.defer( + () -> { + calls.incrementAndGet(); + return Flux.error(new HttpTransportException("rate limited", 429, "")); + }); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY, + true); + + // Apply retry to reach maxAttempts + Flux withRetry = + tracked.retryWhen( + reactor.util.retry.Retry.backoff(2, Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .filter(e -> true)); + + StepVerifier.create(withRetry).verifyError(); + + // Find the last failed event (attempt 3 = maxAttempts) + List failedEvents = + events.stream() + .filter(e -> e instanceof ModelCallAttemptFailedEvent) + .map(e -> (ModelCallAttemptFailedEvent) e) + .toList(); + + ModelCallAttemptFailedEvent lastFailed = failedEvents.get(failedEvents.size() - 1); + assertEquals(3, lastFailed.getAttemptIndex()); + assertEquals(ModelCallAttemptNextAction.FALLBACK, lastFailed.getNextAction()); + } + + // ======================================================================== + // 3. nextAction=FAIL when retries exhausted and no fallback + // ======================================================================== + + @Test + void nextActionFail_whenRetriesExhaustedAndNoFallback() { + List events = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + Flux source = + Flux.defer( + () -> { + calls.incrementAndGet(); + return Flux.error(new HttpTransportException("rate limited", 429, "")); + }); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY, + false); + + Flux withRetry = + tracked.retryWhen( + reactor.util.retry.Retry.backoff(2, Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .filter(e -> true)); + + StepVerifier.create(withRetry).verifyError(); + + List failedEvents = + events.stream() + .filter(e -> e instanceof ModelCallAttemptFailedEvent) + .map(e -> (ModelCallAttemptFailedEvent) e) + .toList(); + + ModelCallAttemptFailedEvent lastFailed = failedEvents.get(failedEvents.size() - 1); + assertEquals(3, lastFailed.getAttemptIndex()); + assertEquals(ModelCallAttemptNextAction.FAIL, lastFailed.getNextAction()); + } + + // ======================================================================== + // 4. nextAction=FAIL for non-retryable errors + // ======================================================================== + + @Test + void nextActionFail_forNonRetryableErrors() { + List events = new ArrayList<>(); + + Flux source = Flux.error(new HttpTransportException("unauthorized", 401, "")); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY, + false); + + StepVerifier.create(tracked).verifyError(); + + ModelCallAttemptFailedEvent failed = (ModelCallAttemptFailedEvent) events.get(1); + assertEquals(1, failed.getAttemptIndex()); + assertFalse(failed.isRetryable()); + assertEquals(ModelCallAttemptNextAction.FAIL, failed.getNextAction()); + } + + // ======================================================================== + // 5. AttemptEventContext integration via ExecutionConfig + // ======================================================================== + + @Test + void attemptEventContextIntegrationViaExecutionConfig() { + List events = new ArrayList<>(); + + AttemptEventContext ctx = + new AttemptEventContext(events::add, REPLY, ModelCallAttemptRole.PRIMARY); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .attemptEventContext(ctx) + .build()) + .build(); + + Flux raw = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux result = + ModelUtils.applyTimeoutAndRetry(raw, opts, null, MODEL, PROVIDER); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + assertFalse(events.isEmpty(), "Events must be emitted via AttemptEventContext"); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, events.get(0).getType()); + } + + // ======================================================================== + // 6. AttemptEventContext.isComplete() + // ======================================================================== + + @Test + void attemptEventContext_isComplete_withNullFields() { + AttemptEventContext nullEmitter = + new AttemptEventContext(null, REPLY, ModelCallAttemptRole.PRIMARY); + assertFalse(nullEmitter.isComplete(), "Should not be complete with null emitter"); + + AttemptEventContext nullReplyId = + new AttemptEventContext(events -> {}, null, ModelCallAttemptRole.PRIMARY); + assertFalse(nullReplyId.isComplete(), "Should not be complete with null replyId"); + + AttemptEventContext nullRole = new AttemptEventContext(events -> {}, REPLY, null); + assertFalse(nullRole.isComplete(), "Should not be complete with null role"); + } + + @Test + void attemptEventContext_isComplete_withAllFields() { + AttemptEventContext complete = + new AttemptEventContext(events -> {}, REPLY, ModelCallAttemptRole.PRIMARY); + assertTrue(complete.isComplete(), "Should be complete with all fields set"); + } + + // ======================================================================== + // 7. Usage extraction in ModelCallAttemptEndEvent + // ======================================================================== + + @Test + void usageExtractionInModelCallAttemptEndEvent() { + List events = new ArrayList<>(); + ChatUsage usage = new ChatUsage(100, 50, 1.5); + + Flux source = + Flux.just(new ChatResponse("id-1", List.of(), usage, null, "stop")); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 1, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(tracked).expectNextCount(1).verifyComplete(); + + assertEquals(2, events.size()); + ModelCallAttemptEndEvent end = (ModelCallAttemptEndEvent) events.get(1); + assertNotNull(end.getUsage(), "Usage must be extracted from ChatResponse"); + assertEquals(100, end.getUsage().getInputTokens()); + assertEquals(50, end.getUsage().getOutputTokens()); + assertEquals(1.5, end.getUsage().getTime(), 0.001); + } + + // ======================================================================== + // 8. Backward compat: 7-arg wrap without hasFallback + // ======================================================================== + + @Test + void backwardCompat_sevenArgWrap_withoutHasFallback() { + List events = new ArrayList<>(); + + Flux source = Flux.error(new HttpTransportException("rate limited", 429, "")); + + // Use the 7-arg wrap (no hasFallback parameter) + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(tracked).verifyError(); + + ModelCallAttemptFailedEvent failed = (ModelCallAttemptFailedEvent) events.get(1); + // With hasFallback defaulting to false, a retryable error with attempts remaining + // still produces RETRY nextAction (because attemptIndex=1 < maxAttempts=3) + assertEquals(ModelCallAttemptNextAction.RETRY, failed.getNextAction()); + + // When retried to exhaustion, should produce FAIL (not FALLBACK) + List events2 = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + Flux source2 = + Flux.defer( + () -> { + calls.incrementAndGet(); + return Flux.error(new HttpTransportException("rate limited", 429, "")); + }); + + Flux tracked2 = + ModelCallAttemptTracker.wrap( + source2, + events2::add, + REPLY, + 2, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY); + + Flux withRetry = + tracked2.retryWhen( + reactor.util.retry.Retry.backoff(1, Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .filter(e -> true)); + + StepVerifier.create(withRetry).verifyError(); + + List failedEvents = + events2.stream() + .filter(e -> e instanceof ModelCallAttemptFailedEvent) + .map(e -> (ModelCallAttemptFailedEvent) e) + .toList(); + + // Last attempt exhausted → FAIL because hasFallback defaults to false + ModelCallAttemptFailedEvent lastFailed = failedEvents.get(failedEvents.size() - 1); + assertEquals(ModelCallAttemptNextAction.FAIL, lastFailed.getNextAction()); + } + + // ======================================================================== + // 9. Failed event JSON round-trip with nextAction and maxAttempts + // ======================================================================== + + @Test + void failedEventJsonRoundTrip_withNextActionAndMaxAttempts() throws Exception { + ModelCallAttemptFailedEvent original = + new ModelCallAttemptFailedEvent( + REPLY, + 2, + 3, + ModelCallAttemptFailureCategory.RATE_LIMIT, + true, + ModelCallAttemptNextAction.RETRY, + "HTTP_429", + "rate limited", + ModelCallAttemptRole.PRIMARY); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + assertSame(ModelCallAttemptFailedEvent.class, deserialized.getClass()); + ModelCallAttemptFailedEvent rt = (ModelCallAttemptFailedEvent) deserialized; + assertEquals(REPLY, rt.getReplyId()); + assertEquals(2, rt.getAttemptIndex()); + assertEquals(3, rt.getMaxAttempts()); + assertEquals(ModelCallAttemptFailureCategory.RATE_LIMIT, rt.getFailureCategory()); + assertTrue(rt.isRetryable()); + assertEquals(ModelCallAttemptNextAction.RETRY, rt.getNextAction()); + assertEquals("HTTP_429", rt.getErrorCode()); + assertEquals("rate limited", rt.getErrorMessage()); + assertEquals(ModelCallAttemptRole.PRIMARY, rt.getRole()); + } + + // ======================================================================== + // 10. ModelFallbackActivatedEvent with correct replyId and failedAttemptCount + // ======================================================================== + + @Test + void modelFallbackActivatedEvent_jsonRoundTrip() throws Exception { + ModelFallbackActivatedEvent original = + new ModelFallbackActivatedEvent(REPLY, 3, "gpt-4o", "claude-sonnet"); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + assertSame(ModelFallbackActivatedEvent.class, deserialized.getClass()); + ModelFallbackActivatedEvent rt = (ModelFallbackActivatedEvent) deserialized; + assertEquals(REPLY, rt.getReplyId()); + assertEquals(3, rt.getFailedAttemptCount()); + assertEquals("gpt-4o", rt.getFromModel()); + assertEquals("claude-sonnet", rt.getToModel()); + } + + // ======================================================================== + // 11. Fallback model attempt events have FALLBACK role + // ======================================================================== + + @Test + void fallbackModelAttemptEvents_haveFallbackRole() { + List events = new ArrayList<>(); + + Flux source = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.FALLBACK, + false); + + StepVerifier.create(tracked).expectNextCount(1).verifyComplete(); + + ModelCallAttemptStartEvent start = (ModelCallAttemptStartEvent) events.get(0); + assertEquals(ModelCallAttemptRole.FALLBACK, start.getRole()); + + ModelCallAttemptEndEvent end = (ModelCallAttemptEndEvent) events.get(1); + assertEquals(ModelCallAttemptRole.FALLBACK, end.getRole()); + } + + // ======================================================================== + // 12. computeNextAction utility method tests + // ======================================================================== + + @Test + void computeNextAction_retryableWithAttemptsRemaining_returnsRetry() { + assertEquals( + ModelCallAttemptNextAction.RETRY, + ModelCallAttemptTracker.computeNextAction( + 1, 3, true, ModelCallAttemptRole.PRIMARY, false)); + } + + @Test + void computeNextAction_retryableExhaustedWithFallback_returnsFallback() { + assertEquals( + ModelCallAttemptNextAction.FALLBACK, + ModelCallAttemptTracker.computeNextAction( + 3, 3, true, ModelCallAttemptRole.PRIMARY, true)); + } + + @Test + void computeNextAction_nonRetryablePrimaryWithFallback_returnsFallback() { + assertEquals( + ModelCallAttemptNextAction.FALLBACK, + ModelCallAttemptTracker.computeNextAction( + 1, 3, false, ModelCallAttemptRole.PRIMARY, true)); + } + + @Test + void computeNextAction_nonRetryableFallbackRoleWithFallback_returnsFail() { + assertEquals( + ModelCallAttemptNextAction.FAIL, + ModelCallAttemptTracker.computeNextAction( + 1, 3, false, ModelCallAttemptRole.FALLBACK, true)); + } + + @Test + void computeNextAction_retryableMidAttempt_returnsRetry() { + assertEquals( + ModelCallAttemptNextAction.RETRY, + ModelCallAttemptTracker.computeNextAction( + 2, 3, true, ModelCallAttemptRole.PRIMARY, false)); + } + + @Test + void computeNextAction_retryableExhaustedNoFallback_returnsFail() { + assertEquals( + ModelCallAttemptNextAction.FAIL, + ModelCallAttemptTracker.computeNextAction( + 3, 3, true, ModelCallAttemptRole.PRIMARY, false)); + } + + // ======================================================================== + // 13. 5-arg applyTimeoutAndRetry reads AttemptEventContext from config + // ======================================================================== + + @Test + void fiveArgApplyTimeoutAndRetry_readsAttemptEventContextFromConfig() { + List events = new ArrayList<>(); + + AttemptEventContext ctx = + new AttemptEventContext(events::add, REPLY, ModelCallAttemptRole.PRIMARY); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .attemptEventContext(ctx) + .build()) + .build(); + + Flux raw = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux result = + ModelUtils.applyTimeoutAndRetry(raw, opts, null, MODEL, PROVIDER); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + assertFalse(events.isEmpty(), "Attempt events must be emitted"); + assertTrue( + events.stream().anyMatch(e -> e.getType() == AgentEventType.MODEL_ATTEMPT_START), + "Must have MODEL_ATTEMPT_START event"); + assertTrue( + events.stream().anyMatch(e -> e.getType() == AgentEventType.MODEL_ATTEMPT_END), + "Must have MODEL_ATTEMPT_END event"); + + ModelCallAttemptStartEvent start = + events.stream() + .filter(e -> e instanceof ModelCallAttemptStartEvent) + .map(e -> (ModelCallAttemptStartEvent) e) + .findFirst() + .orElseThrow(); + assertEquals(REPLY, start.getReplyId()); + assertEquals(ModelCallAttemptRole.PRIMARY, start.getRole()); + } + + // ======================================================================== + // 14. 5-arg applyTimeoutAndRetry without AttemptEventContext produces no attempt events + // ======================================================================== + + @Test + void fiveArgApplyTimeoutAndRetry_withoutAttemptEventContext_noAttemptEvents() { + List events = new ArrayList<>(); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .build()) + .build(); + + Flux raw = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux result = + ModelUtils.applyTimeoutAndRetry(raw, opts, null, MODEL, PROVIDER); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + assertTrue( + events.isEmpty(), + "No attempt events should be emitted without AttemptEventContext"); + } + + // ======================================================================== + // Additional: 5-arg applyTimeoutAndRetry with retry and AttemptEventContext + // ======================================================================== + + @Test + void fiveArgApplyTimeoutAndRetry_withRetryAndContext_emitsEventsPerAttempt() { + List events = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + AttemptEventContext ctx = + new AttemptEventContext(events::add, REPLY, ModelCallAttemptRole.PRIMARY); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(3) + .initialBackoff(Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .retryOn(e -> true) + .attemptEventContext(ctx) + .build()) + .build(); + + Flux raw = + Flux.defer( + () -> { + if (calls.incrementAndGet() <= 2) { + return Flux.error( + new HttpTransportException("rate limited", 429, "")); + } + return Flux.just(new ChatResponse(null, List.of(), null, null, null)); + }); + + Flux result = + ModelUtils.applyTimeoutAndRetry(raw, opts, null, MODEL, PROVIDER); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + // Attempt 1: START + FAILED, Attempt 2: START + FAILED, Attempt 3: START + END + assertEquals(6, events.size()); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, events.get(0).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_FAILED, events.get(1).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, events.get(2).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_FAILED, events.get(3).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_START, events.get(4).getType()); + assertEquals(AgentEventType.MODEL_ATTEMPT_END, events.get(5).getType()); + } + + // ======================================================================== + // Additional: 5-arg applyTimeoutAndRetry with FALLBACK role in context + // ======================================================================== + + @Test + void fiveArgApplyTimeoutAndRetry_withFallbackRole_emitsEventsWithFallbackRole() { + List events = new ArrayList<>(); + + AttemptEventContext ctx = + new AttemptEventContext(events::add, REPLY, ModelCallAttemptRole.FALLBACK); + + GenerateOptions opts = + GenerateOptions.builder() + .executionConfig( + ExecutionConfig.builder() + .timeout(Duration.ofSeconds(5)) + .maxAttempts(1) + .attemptEventContext(ctx) + .build()) + .build(); + + Flux raw = Flux.just(new ChatResponse(null, List.of(), null, null, null)); + + Flux result = + ModelUtils.applyTimeoutAndRetry(raw, opts, null, MODEL, PROVIDER); + + StepVerifier.create(result).expectNextCount(1).verifyComplete(); + + ModelCallAttemptStartEvent start = + events.stream() + .filter(e -> e instanceof ModelCallAttemptStartEvent) + .map(e -> (ModelCallAttemptStartEvent) e) + .findFirst() + .orElseThrow(); + assertEquals(ModelCallAttemptRole.FALLBACK, start.getRole()); + + ModelCallAttemptEndEvent end = + events.stream() + .filter(e -> e instanceof ModelCallAttemptEndEvent) + .map(e -> (ModelCallAttemptEndEvent) e) + .findFirst() + .orElseThrow(); + assertEquals(ModelCallAttemptRole.FALLBACK, end.getRole()); + } + + // ======================================================================== + // Additional: nextAction=FALLBACK for non-retryable PRIMARY with hasFallback + // (end-to-end via wrap) + // ======================================================================== + + @Test + void nextActionFallback_nonRetryablePrimaryWithFallbackAvailable() { + List events = new ArrayList<>(); + + Flux source = Flux.error(new HttpTransportException("unauthorized", 401, "")); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY, + true); + + StepVerifier.create(tracked).verifyError(); + + ModelCallAttemptFailedEvent failed = (ModelCallAttemptFailedEvent) events.get(1); + assertFalse(failed.isRetryable()); + // Non-retryable error with PRIMARY role and fallback available → FALLBACK + assertEquals(ModelCallAttemptNextAction.FALLBACK, failed.getNextAction()); + } + + // ======================================================================== + // Additional: nextAction=FAIL for non-retryable FALLBACK role even with hasFallback + // (end-to-end via wrap) + // ======================================================================== + + @Test + void nextActionFail_nonRetryableFallbackRoleEvenWithHasFallback() { + List events = new ArrayList<>(); + + Flux source = Flux.error(new HttpTransportException("unauthorized", 401, "")); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.FALLBACK, + true); + + StepVerifier.create(tracked).verifyError(); + + ModelCallAttemptFailedEvent failed = (ModelCallAttemptFailedEvent) events.get(1); + assertFalse(failed.isRetryable()); + // Non-retryable error with FALLBACK role → FAIL regardless of hasFallback + assertEquals(ModelCallAttemptNextAction.FAIL, failed.getNextAction()); + } + + // ======================================================================== + // Additional: Failed event JSON round-trip with FALLBACK nextAction + // ======================================================================== + + @Test + void failedEventJsonRoundTrip_withFallbackNextAction() throws Exception { + ModelCallAttemptFailedEvent original = + new ModelCallAttemptFailedEvent( + REPLY, + 3, + 3, + ModelCallAttemptFailureCategory.RATE_LIMIT, + true, + ModelCallAttemptNextAction.FALLBACK, + "HTTP_429", + "rate limited", + ModelCallAttemptRole.PRIMARY); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + ModelCallAttemptFailedEvent rt = (ModelCallAttemptFailedEvent) deserialized; + assertEquals(ModelCallAttemptNextAction.FALLBACK, rt.getNextAction()); + assertEquals(3, rt.getMaxAttempts()); + } + + // ======================================================================== + // Additional: Failed event JSON round-trip with FAIL nextAction + // ======================================================================== + + @Test + void failedEventJsonRoundTrip_withFailNextAction() throws Exception { + ModelCallAttemptFailedEvent original = + new ModelCallAttemptFailedEvent( + REPLY, + 1, + 3, + ModelCallAttemptFailureCategory.AUTHENTICATION, + false, + ModelCallAttemptNextAction.FAIL, + "HTTP_401", + "unauthorized", + ModelCallAttemptRole.PRIMARY); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + ModelCallAttemptFailedEvent rt = (ModelCallAttemptFailedEvent) deserialized; + assertEquals(ModelCallAttemptNextAction.FAIL, rt.getNextAction()); + assertEquals(1, rt.getAttemptIndex()); + assertEquals(3, rt.getMaxAttempts()); + assertEquals(ModelCallAttemptFailureCategory.AUTHENTICATION, rt.getFailureCategory()); + } + + // ======================================================================== + // Additional: ModelFallbackActivatedEvent with null replyId round-trip + // ======================================================================== + + @Test + void modelFallbackActivatedEvent_nullReplyId_roundTrip() throws Exception { + ModelFallbackActivatedEvent original = + new ModelFallbackActivatedEvent(null, 2, "gpt-4o", "claude-sonnet"); + + String json = MAPPER.writeValueAsString(original); + AgentEvent deserialized = MAPPER.readValue(json, AgentEvent.class); + + ModelFallbackActivatedEvent rt = (ModelFallbackActivatedEvent) deserialized; + assertNull(rt.getReplyId()); + assertEquals(2, rt.getFailedAttemptCount()); + assertEquals("gpt-4o", rt.getFromModel()); + assertEquals("claude-sonnet", rt.getToModel()); + } + + // ======================================================================== + // Additional: Usage extraction with ChatUsage(int, int, int, double) + // ======================================================================== + + @Test + void usageExtraction_withFourArgChatUsage() { + List events = new ArrayList<>(); + ChatUsage usage = new ChatUsage(200, 100, 50, 2.5); + + Flux source = + Flux.just(new ChatResponse("id-2", List.of(), usage, null, "stop")); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 1, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY); + + StepVerifier.create(tracked).expectNextCount(1).verifyComplete(); + + ModelCallAttemptEndEvent end = (ModelCallAttemptEndEvent) events.get(1); + assertNotNull(end.getUsage()); + assertEquals(200, end.getUsage().getInputTokens()); + assertEquals(100, end.getUsage().getOutputTokens()); + assertEquals(50, end.getUsage().getCachedTokens()); + assertEquals(2.5, end.getUsage().getTime(), 0.001); + } + + // ======================================================================== + // Additional: End-to-end nextAction flow with all 3 outcomes + // ======================================================================== + + @Test + void nextAction_allThreeOutcomes_inRetrySequence() { + // Simulate: attempt 1 (RETRY), attempt 2 (RETRY), attempt 3 (FAIL) + List events = new ArrayList<>(); + AtomicInteger calls = new AtomicInteger(0); + + Flux source = + Flux.defer( + () -> { + calls.incrementAndGet(); + return Flux.error(new HttpTransportException("rate limited", 429, "")); + }); + + Flux tracked = + ModelCallAttemptTracker.wrap( + source, + events::add, + REPLY, + 3, + PROVIDER, + MODEL, + ModelCallAttemptRole.PRIMARY, + false); + + Flux withRetry = + tracked.retryWhen( + reactor.util.retry.Retry.backoff(2, Duration.ofMillis(10)) + .maxBackoff(Duration.ofMillis(50)) + .filter(e -> true)); + + StepVerifier.create(withRetry).verifyError(); + + List failedEvents = + events.stream() + .filter(e -> e instanceof ModelCallAttemptFailedEvent) + .map(e -> (ModelCallAttemptFailedEvent) e) + .toList(); + + assertEquals(3, failedEvents.size()); + assertEquals(ModelCallAttemptNextAction.RETRY, failedEvents.get(0).getNextAction()); + assertEquals(ModelCallAttemptNextAction.RETRY, failedEvents.get(1).getNextAction()); + assertEquals(ModelCallAttemptNextAction.FAIL, failedEvents.get(2).getNextAction()); + } + + // ======================================================================== + // Additional: Deprecated backward-compat constructor for FailedEvent + // ======================================================================== + + @Test + @SuppressWarnings("deprecation") + void deprecatedFailedEventConstructor_setsNextActionFromRetryable() { + // retryable=true → RETRY + ModelCallAttemptFailedEvent retryableEvent = + new ModelCallAttemptFailedEvent( + REPLY, + 1, + ModelCallAttemptFailureCategory.RATE_LIMIT, + true, + "HTTP_429", + "rate limited", + ModelCallAttemptRole.PRIMARY); + assertEquals(ModelCallAttemptNextAction.RETRY, retryableEvent.getNextAction()); + assertEquals(0, retryableEvent.getMaxAttempts()); + + // retryable=false → FAIL + ModelCallAttemptFailedEvent nonRetryableEvent = + new ModelCallAttemptFailedEvent( + REPLY, + 1, + ModelCallAttemptFailureCategory.AUTHENTICATION, + false, + "HTTP_401", + "unauthorized", + ModelCallAttemptRole.PRIMARY); + assertEquals(ModelCallAttemptNextAction.FAIL, nonRetryableEvent.getNextAction()); + assertEquals(0, nonRetryableEvent.getMaxAttempts()); + } +} From da807085bd52bbe64e20fa26fee4755d1db829db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E5=85=89c?= <2533995180@qq.com> Date: Wed, 15 Jul 2026 12:28:04 +0800 Subject: [PATCH 3/7] Fix Docs document link redirection --- .../java/io/agentscope/core/ReActAgent.java | 5 -- .../core/model/AttemptEventContext.java | 2 +- .../model/ModelCallFailureClassifier.java | 50 +++++++----- .../model/ModelCallAttemptFeatureTest.java | 79 +++++++++++++++++++ 4 files changed, 112 insertions(+), 24 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 459b8c7beb..3db10ad1ed 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -3507,11 +3507,6 @@ private Model modelForCall(Consumer eventEmitter) { public Flux stream( List messages, List tools, GenerateOptions options) { Flux primaryFlux = model.stream(messages, tools, options); - 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); diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java b/agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java index b811c3d8e9..8767cfe235 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/AttemptEventContext.java @@ -22,7 +22,7 @@ /** * Runtime-only context for emitting attempt lifecycle events from within the - * model transport pipeline. ,。/‘ + * model transport pipeline. * *

This object is not serialized and does not participate in JSON * round-trips. It is set by {@code ReActAgent} (or any caller that owns the diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java index 5e5dd5738e..46b54ae59f 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelCallFailureClassifier.java @@ -21,6 +21,7 @@ import java.net.ConnectException; import java.net.SocketTimeoutException; import java.util.concurrent.TimeoutException; +import java.util.regex.Pattern; /** * Classifies model call exceptions into structured failure categories for attempt events. @@ -32,6 +33,24 @@ public final class ModelCallFailureClassifier { private ModelCallFailureClassifier() {} + /** + * Pattern matching "Bearer <token>" — the most common credential leak format. + * Matches "Bearer" followed by one or more spaces and the token value. + */ + private static final Pattern BEARER_TOKEN_PATTERN = Pattern.compile("(?i)Bearer\\s+\\S+"); + + /** + * Pattern matching key-value credential patterns like "Api-Key: xxx", + * "Authorization: xxx", "Token=xxx", "api_key xxx", "Secret: xxx", etc. + */ + private static final Pattern KEY_VALUE_CREDENTIAL_PATTERN = + Pattern.compile( + "(?i)(Api-?Key|Authorization|Token|Secret|api_key|apikey)" + "[\\s:=]+\\S+"); + + /** Pattern matching response body sections appended by HttpTransportException. */ + private static final Pattern RESPONSE_BODY_PATTERN = + Pattern.compile("\\s*[|\\n]\\s*Response body:.*", Pattern.DOTALL); + /** * Classifies the given throwable into a failure category. * @@ -157,9 +176,10 @@ public static String sanitizeErrorCode(Throwable error) { * Extracts a sanitized, short error message suitable for event payloads. * *

The message contains only the exception's top-level message with no response - * bodies, headers, or credential data. For {@link HttpTransportException}, the - * response body is stripped even if {@code getMessage()} includes it. Truncated - * to 200 characters. + * bodies, headers, or credential data. For {@link HttpTransportException} and any + * exception whose message includes a "Response body:" section, the response body is + * stripped. Credential patterns (Bearer tokens, API keys, etc.) are replaced with + * {@code [REDACTED]}. Truncated to 200 characters. * * @param error the exception * @return a safe, truncated error message @@ -168,24 +188,18 @@ public static String sanitizeMessage(Throwable error) { if (error == null) { return "unknown error"; } - // For HttpTransportException, use only the base message without response body - String message; - if (error instanceof HttpTransportException) { - message = error.getMessage(); - // HttpTransportException.getMessage() appends " | Response body: ..." — strip it - if (message != null) { - int sep = message.indexOf(" | Response body: "); - if (sep > 0) { - message = message.substring(0, sep); - } - } - } else { - message = error.getMessage(); - } + String message = error.getMessage(); if (message == null || message.isEmpty()) { return error.getClass().getSimpleName(); } - // Take only the first line and truncate + // Strip any "Response body:" section (handles both "| Response body:" and + // "\nResponse body:" formats from HttpTransportException and similar) + message = RESPONSE_BODY_PATTERN.matcher(message).replaceAll(""); + // Redact "Bearer " patterns first (most common leak format) + message = BEARER_TOKEN_PATTERN.matcher(message).replaceAll("Bearer [REDACTED]"); + // Redact key-value credential patterns like "Authorization: xxx", "Api-Key=xxx" + message = KEY_VALUE_CREDENTIAL_PATTERN.matcher(message).replaceAll("$1 [REDACTED]"); + // Take only the first line int newline = message.indexOf('\n'); if (newline > 0) { message = message.substring(0, newline); diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java index 29304c82a3..2dd862f048 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelCallAttemptFeatureTest.java @@ -916,4 +916,83 @@ void deprecatedFailedEventConstructor_setsNextActionFromRetryable() { assertEquals(ModelCallAttemptNextAction.FAIL, nonRetryableEvent.getNextAction()); assertEquals(0, nonRetryableEvent.getMaxAttempts()); } + + // ======================================================================== + // Bug-fix: sanitizeMessage redacts Bearer tokens + // ======================================================================== + + @Test + void sanitizeMessage_redactsBearerToken() { + HttpTransportException ex = new HttpTransportException("Bearer sk-abc123secret", 401, ""); + String sanitized = ModelCallFailureClassifier.sanitizeMessage(ex); + assertFalse(sanitized.contains("sk-abc123secret"), "Bearer token must be redacted"); + assertTrue(sanitized.contains("[REDACTED]"), "Must contain redaction placeholder"); + } + + // ======================================================================== + // Bug-fix: sanitizeMessage redacts Api-Key patterns + // ======================================================================== + + @Test + void sanitizeMessage_redactsApiKey() { + HttpTransportException ex = + new HttpTransportException("Api-Key: my-secret-key-12345", 403, ""); + String sanitized = ModelCallFailureClassifier.sanitizeMessage(ex); + assertFalse(sanitized.contains("my-secret-key-12345"), "API key must be redacted"); + assertTrue(sanitized.contains("[REDACTED]"), "Must contain redaction placeholder"); + } + + // ======================================================================== + // Bug-fix: sanitizeMessage strips \nResponse body format + // ======================================================================== + + @Test + void sanitizeMessage_stripsNewlineResponseBodyFormat() { + // Construct a message that uses the \n format instead of " | " + String rawMessage = "Request failed\nResponse body: {\"error\":\"internal\"}"; + RuntimeException ex = new RuntimeException(rawMessage); + String sanitized = ModelCallFailureClassifier.sanitizeMessage(ex); + assertFalse( + sanitized.contains("{\"error\":\"internal\"}"), "Response body must be stripped"); + assertTrue(sanitized.contains("Request failed"), "Base message must be preserved"); + } + + // ======================================================================== + // Bug-fix: sanitizeMessage handles combined Bearer + Response body + // ======================================================================== + + @Test + void sanitizeMessage_handlesCombinedBearerAndResponseBody() { + // HttpTransportException with response body containing sensitive data + HttpTransportException ex = + new HttpTransportException( + "Bearer sk-leaked-token", 429, "{\"error\":\"rate limited\"}"); + String sanitized = ModelCallFailureClassifier.sanitizeMessage(ex); + assertFalse(sanitized.contains("sk-leaked-token"), "Bearer token must be redacted"); + assertFalse( + sanitized.contains("{\"error\":\"rate limited\"}"), + "Response body must be stripped"); + } + + // ======================================================================== + // Bug-fix: sanitizeMessage redacts various credential key formats + // ======================================================================== + + @Test + void sanitizeMessage_redactsVariousCredentialFormats() { + // Test "Authorization: xxx" + RuntimeException ex1 = new RuntimeException("Authorization: Bearer abc123"); + String s1 = ModelCallFailureClassifier.sanitizeMessage(ex1); + assertFalse(s1.contains("abc123")); + + // Test "Token: xxx" + RuntimeException ex2 = new RuntimeException("Token: tok-secret"); + String s2 = ModelCallFailureClassifier.sanitizeMessage(ex2); + assertFalse(s2.contains("tok-secret")); + + // Test "api_key=xxx" + RuntimeException ex3 = new RuntimeException("api_key=key-12345"); + String s3 = ModelCallFailureClassifier.sanitizeMessage(ex3); + assertFalse(s3.contains("key-12345")); + } } From 9e1cf71b750d3c9a5bc8ad4fe9aa1f639191ad25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E5=85=89c?= <2533995180@qq.com> Date: Wed, 15 Jul 2026 12:40:10 +0800 Subject: [PATCH 4/7] Fix Docs document link redirection --- .../src/main/java/io/agentscope/core/ReActAgent.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 3db10ad1ed..f4e31f263c 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -3506,7 +3506,17 @@ private Model modelForCall(Consumer eventEmitter) { @Override public Flux stream( List messages, List tools, GenerateOptions options) { - Flux 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 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); From d9526b0399ac331ebdb71a088e20705b40d656ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E5=85=89c?= <2533995180@qq.com> Date: Wed, 15 Jul 2026 19:06:00 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20merge=20=E5=86=B2=E7=AA=81=E6=AE=8B?= =?UTF-8?q?=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java | 1 - 1 file changed, 1 deletion(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 1d0bba0337..acfe49ef82 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -2130,7 +2130,6 @@ Flux reasoningStream( new ModelCallInput( messages, tools, options, modelForCall(this::publishEvent))) .doOnNext(this::publishEvent); - .apply(new ModelCallInput(messages, tools, options, modelForCall())); } private Flux modelCallStream( From 823f0bbb585f85cdbfd3ce5e6979a74688aa38db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E5=85=89c?= <2533995180@qq.com> Date: Wed, 15 Jul 2026 21:22:53 +0800 Subject: [PATCH 6/7] [feature] expose typed events --- .../java/io/agentscope/core/ReActAgent.java | 3 +-- .../core/tool/DangerousPathBypassTest.java | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index acfe49ef82..f651595608 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -2128,8 +2128,7 @@ Flux reasoningStream( modelCallCore) .apply( new ModelCallInput( - messages, tools, options, modelForCall(this::publishEvent))) - .doOnNext(this::publishEvent); + messages, tools, options, modelForCall(this::publishEvent))); } private Flux modelCallStream( diff --git a/agentscope-core/src/test/java/io/agentscope/core/tool/DangerousPathBypassTest.java b/agentscope-core/src/test/java/io/agentscope/core/tool/DangerousPathBypassTest.java index 0cbfbf3d37..d29909f41e 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tool/DangerousPathBypassTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tool/DangerousPathBypassTest.java @@ -16,6 +16,7 @@ package io.agentscope.core.tool; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import io.agentscope.core.permission.PermissionContextState; import io.agentscope.core.permission.PermissionDecision; @@ -99,6 +100,8 @@ void caseInsensitiveDotEnvIsDetected() { @Test void symlinkToSshIsDetected(@TempDir Path tempDir) throws IOException { + assumeTrue(supportsSymlinks(), "Symlinks not supported on this platform"); + Path sshDir = tempDir.resolve(".ssh"); Files.createDirectory(sshDir); Path sshConfig = sshDir.resolve("config"); @@ -112,6 +115,8 @@ void symlinkToSshIsDetected(@TempDir Path tempDir) throws IOException { @Test void symlinkToDotEnvIsDetected(@TempDir Path tempDir) throws IOException { + assumeTrue(supportsSymlinks(), "Symlinks not supported on this platform"); + Path envFile = tempDir.resolve(".env"); Files.writeString(envFile, "SECRET=value\n"); @@ -120,4 +125,18 @@ void symlinkToDotEnvIsDetected(@TempDir Path tempDir) throws IOException { assertTrue(PROBE.check(link.toString())); } + + private boolean supportsSymlinks() { + try { + Path temp = Files.createTempFile("symlink-probe", ".tmp"); + Path link = Files.createTempFile("symlink-probe-link", ".tmp"); + Files.deleteIfExists(link); + Files.createSymbolicLink(link, temp); + Files.deleteIfExists(link); + Files.deleteIfExists(temp); + return true; + } catch (Exception e) { + return false; + } + } } From 7d3398f082eb7eb2238f59063981b923d0503071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=97=E5=85=89c?= <2533995180@qq.com> Date: Wed, 15 Jul 2026 21:55:07 +0800 Subject: [PATCH 7/7] [feature] expose typed events --- .../io/agentscope/harness/agent/tool/SessionSearchTool.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/SessionSearchTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/SessionSearchTool.java index b807a69dc3..7592531ac8 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/SessionSearchTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/SessionSearchTool.java @@ -171,7 +171,8 @@ public String sessionList( public String sessionHistory( RuntimeContext runtimeContext, @ToolParam(name = "agentId", description = "Agent ID") String agentId, - @ToolParam(name = "sessionId", description = "AgentStateStore Session ID") String sessionId, + @ToolParam(name = "sessionId", description = "AgentStateStore Session ID") + String sessionId, @ToolParam( name = "lastN", description = "Number of recent messages to return (default: 20)",