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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,28 @@ private Mono<PostActingEvent> notifyPostActingHook(
protected Mono<Msg> summarizing() {
log.debug("Maximum iterations reached. Generating summary...");

// Handle pending tool calls that were not completed before max iterations
if (hasPendingToolUse()) {
List<ToolUseBlock> pendingTools = extractPendingToolCalls();
log.warn(
"Max iterations reached with {} pending tool calls. Adding error results.",
pendingTools.size());

for (ToolUseBlock toolUse : pendingTools) {
ToolResultBlock errorResult =
buildErrorToolResult(
toolUse.getId(),
"Tool execution cancelled because maximum iterations limit ("
+ maxIters
+ ") was reached");

Msg errorResultMsg =
ToolResultMessageBuilder.buildToolResultMsg(
errorResult, toolUse, getName());
memory.addMessage(errorResultMsg);
}
}

List<Msg> messageList = prepareSummaryMessages();
GenerateOptions generateOptions = buildGenerateOptions();
ReasoningContext context = new ReasoningContext(getName());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
* </ul>
*
* <p>All agents in the AgentScope framework should implement this interface.
*
* <p><b>Reply contract:</b> a single {@code call(...)} invocation produces exactly one
* terminal {@link Msg}. Streaming variants (see {@link StreamableAgent}) may emit
* many events but resolve to a single terminal Msg. This is enforced by the
* {@code Mono<Msg>} return type on the call methods.
*/
public interface Agent extends CallableAgent, StreamableAgent, ObservableAgent {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.
*/

/**
* Model-provider credential abstraction for AgentScope Java.
*
* <p>The package will host {@code CredentialBase} exposing {@code getChatModelClass}
* and {@code listModels}, unifying authentication metadata and available-model
* discovery across providers.
*/
package io.agentscope.core.credential;
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,37 @@
*/
package io.agentscope.core.event;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;

/**
* Fine-grained event types emitted during agent execution.
*
* <p>Aligned with AgentScope Python 2.0 EventType. Each type corresponds to
* a specific phase or delta in the agent's reasoning/acting lifecycle.
* <p>Each value carries a canonical name plus optional {@link JsonAlias} entries
* for legacy names so older JSON payloads continue to deserialize.
*
* <p>Legacy aliases recognised on deserialization:
* <ul>
* <li>{@code RUN_STARTED} → {@link #REPLY_START}</li>
* <li>{@code RUN_FINISHED} → {@link #REPLY_END}</li>
* <li>{@code MODEL_CALL_STARTED} → {@link #MODEL_CALL_START}</li>
* <li>{@code MODEL_CALL_ENDED} → {@link #MODEL_CALL_END}</li>
* <li>{@code BINARY_BLOCK_*} → {@code DATA_BLOCK_*}</li>
* <li>{@code TOOL_RESULT_BINARY_DELTA} → {@link #TOOL_RESULT_DATA_DELTA}</li>
* </ul>
*
* <p>Serialization always emits the canonical form.
*/
public enum AgentEventType {
@JsonAlias({"RUN_STARTED"})
REPLY_START("REPLY_START"),
@JsonAlias({"RUN_FINISHED"})
REPLY_END("REPLY_END"),

@JsonAlias({"MODEL_CALL_STARTED"})
MODEL_CALL_START("MODEL_CALL_START"),
@JsonAlias({"MODEL_CALL_ENDED"})
MODEL_CALL_END("MODEL_CALL_END"),

TEXT_BLOCK_START("TEXT_BLOCK_START"),
Expand All @@ -38,8 +56,11 @@ public enum AgentEventType {
THINKING_BLOCK_DELTA("THINKING_BLOCK_DELTA"),
THINKING_BLOCK_END("THINKING_BLOCK_END"),

@JsonAlias({"BINARY_BLOCK_START"})
DATA_BLOCK_START("DATA_BLOCK_START"),
@JsonAlias({"BINARY_BLOCK_DELTA"})
DATA_BLOCK_DELTA("DATA_BLOCK_DELTA"),
@JsonAlias({"BINARY_BLOCK_END"})
DATA_BLOCK_END("DATA_BLOCK_END"),

TOOL_CALL_START("TOOL_CALL_START"),
Expand All @@ -48,6 +69,7 @@ public enum AgentEventType {

TOOL_RESULT_START("TOOL_RESULT_START"),
TOOL_RESULT_TEXT_DELTA("TOOL_RESULT_TEXT_DELTA"),
@JsonAlias({"TOOL_RESULT_BINARY_DELTA"})
TOOL_RESULT_DATA_DELTA("TOOL_RESULT_DATA_DELTA"),
TOOL_RESULT_END("TOOL_RESULT_END"),

Expand All @@ -68,4 +90,38 @@ public enum AgentEventType {
public String getValue() {
return value;
}

/**
* Resolve an enum value from its canonical string or any legacy alias.
*
* <p>Falls back to a case-sensitive match against {@link #getValue()} and the
* declared aliases when Jackson's default enum lookup misses.
*
* @param raw the incoming string value
* @return the corresponding enum constant
* @throws IllegalArgumentException when {@code raw} matches no value or alias
*/
@JsonCreator
public static AgentEventType fromValue(String raw) {
if (raw == null) {
throw new IllegalArgumentException("AgentEventType value must not be null");
}
for (AgentEventType type : values()) {
if (type.value.equals(raw)) {
return type;
}
}
// Legacy aliases — keep the mapping co-located with the enum for grep-ability.
return switch (raw) {
case "RUN_STARTED" -> REPLY_START;
case "RUN_FINISHED" -> REPLY_END;
case "MODEL_CALL_STARTED" -> MODEL_CALL_START;
case "MODEL_CALL_ENDED" -> MODEL_CALL_END;
case "BINARY_BLOCK_START" -> DATA_BLOCK_START;
case "BINARY_BLOCK_DELTA" -> DATA_BLOCK_DELTA;
case "BINARY_BLOCK_END" -> DATA_BLOCK_END;
case "TOOL_RESULT_BINARY_DELTA" -> TOOL_RESULT_DATA_DELTA;
default -> throw new IllegalArgumentException("Unknown AgentEventType value: " + raw);
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,6 @@ public DashScopeMessage mergeToMessage(

/**
* Merge conversation messages into a single DashScopeMessage (multimodal mode).
* Follows Python's _format_agent_message logic exactly.
*
* <p>This method combines all agent messages into a single user message with conversation
* history wrapped in {@code <history>} tags. Images and videos are preserved as separate
Expand Down Expand Up @@ -196,7 +195,7 @@ public DashScopeMessage mergeToMultiModalMessage(

for (ContentBlock block : msg.getContent()) {
if (block instanceof TextBlock tb) {
// Accumulate text with agent name (Python format: "name: text")
// Accumulate text with agent name (format: "name: text")
accumulatedText.add(name + ": " + tb.getText());

} else if (block instanceof ImageBlock imageBlock) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public void applyToolChoice(DashScopeRequest request, ToolChoice toolChoice) {
* Format AgentScope Msg objects to DashScope MultiModal message format.
* This method is used for vision models that require the MultiModalConversation API.
*
* <p>This method follows Python's logic:
* <p>Processing steps:
* 1. Process system message (if any)
* 2. Group remaining messages into "agent_message" and "tool_sequence"
* 3. Process each group in order, with first agent_message having history prompt
Expand Down Expand Up @@ -230,7 +230,6 @@ public DashScopeRequest buildRequest(

/**
* Group messages sequentially into agent_message and tool_sequence groups.
* This follows Python's _group_messages logic.
*
* @param msgs Messages to group (excluding system message)
* @return List of MessageGroup objects in order
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,7 @@ private String convertRole(MsgRole role) {

/**
* Convert tool result output to string representation.
* Follows Python implementation: single item returns directly,
* multiple items use "- " prefix per line.
* Single item returns directly; multiple items use "- " prefix per line.
*
* @param output List of content blocks from tool result
* @return String representation of the output
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
* <li>No name field in messages (returns HTTP 400 if present)</li>
* <li>System messages should be converted to user messages</li>
* <li>Does NOT support strict parameter in tool definitions</li>
* <li>reasoning_content must be kept within current turn but removed for previous turns</li>
* <li>In thinking mode, reasoning_content is preserved for segments with tool calls</li>
* </ul>
*
* <p>Usage:
Expand Down Expand Up @@ -85,8 +85,8 @@ protected boolean supportsStrict() {
* <ul>
* <li>No name field in messages</li>
* <li>System messages converted to user</li>
* <li>reasoning_content kept within current turn (after last user message)</li>
* <li>reasoning_content removed for previous turns (before last user message)</li>
* <li>In thinking mode, reasoning_content preserved for segments with tool calls</li>
* <li>reasoning_content removed for segments without tool calls in thinking mode</li>
* </ul>
*
* <p>This method is static to allow sharing with {@link DeepSeekMultiAgentFormatter}.
Expand All @@ -95,16 +95,51 @@ protected boolean supportsStrict() {
* @return the fixed messages for DeepSeek API
*/
static List<OpenAIMessage> applyDeepSeekFixes(List<OpenAIMessage> messages) {
// Find the last user message index to determine current turn boundary
int lastUserIndex = findLastUserIndex(messages);
boolean thinkingMode = messages.stream().anyMatch(m -> m.getReasoningContent() != null);
boolean[] segHasTool = thinkingMode ? computeSegmentToolFlags(messages) : null;

List<OpenAIMessage> result = new ArrayList<>(messages.size());
for (int i = 0; i < messages.size(); i++) {
result.add(fixMessage(messages.get(i), i >= lastUserIndex));
boolean isCurrentTurn = i >= lastUserIndex;
boolean needReasoning =
thinkingMode
? (isCurrentTurn || (segHasTool != null && segHasTool[i]))
: isCurrentTurn;
result.add(fixMessage(messages.get(i), needReasoning));
}
return result;
}

/**
* Scans messages in a single pass to identify segments (between consecutive
* user messages) that contain tool calls. Messages within such segments
* are flagged to preserve their reasoning_content.
*/
private static boolean[] computeSegmentToolFlags(List<OpenAIMessage> messages) {
boolean[] flags = new boolean[messages.size()];
int prevUser = -1;
for (int i = 0; i <= messages.size(); i++) {
if (i == messages.size() || "user".equals(messages.get(i).getRole())) {
if (prevUser >= 0) {
// Check if segment (prevUser, i) has any tool call
boolean hasTool = false;
for (int j = prevUser + 1; j < i && !hasTool; j++) {
OpenAIMessage m = messages.get(j);
hasTool = m.getToolCalls() != null && !m.getToolCalls().isEmpty();
}
if (hasTool) {
for (int j = prevUser + 1; j < i; j++) {
flags[j] = true;
}
}
}
prevUser = i;
}
}
return flags;
}

/**
* Append an empty user message if the conversation ends with an assistant message.
*
Expand Down Expand Up @@ -133,12 +168,13 @@ private static int findLastUserIndex(List<OpenAIMessage> messages) {
}

@SuppressWarnings("unchecked")
private static OpenAIMessage fixMessage(OpenAIMessage msg, boolean isCurrentTurn) {
private static OpenAIMessage fixMessage(OpenAIMessage msg, boolean needReasoning) {
boolean isSystem = "system".equals(msg.getRole());
boolean hasName = msg.getName() != null;
boolean hasReasoning = msg.getReasoningContent() != null;
// Remove reasoning_content for previous turns, keep for current turn
boolean shouldRemoveReasoning = hasReasoning && !isCurrentTurn;
// needReasoning is determined by applyDeepSeekFixes:
// true = current turn, or segment had tool calls in thinking mode
boolean shouldRemoveReasoning = hasReasoning && !needReasoning;

if (!isSystem && !hasName && !shouldRemoveReasoning) {
return msg;
Expand All @@ -162,8 +198,7 @@ private static OpenAIMessage fixMessage(OpenAIMessage msg, boolean isCurrentTurn
builder.toolCallId(msg.getToolCallId());
}

// Keep reasoning_content only for current turn
if (hasReasoning && isCurrentTurn) {
if (needReasoning && hasReasoning) {
builder.reasoningContent(msg.getReasoningContent());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
* <li>{@link ToolUseBlock} - Tool execution requests
* <li>{@link ToolResultBlock} - Tool execution results
* <li>{@link HintBlock} - Hints for LLM reasoning (e.g., from RAG)
* <li>{@link DataBlock} - Generic binary data block unifying image/audio/video
* </ul>
*
* <p>Uses Jackson annotations for polymorphic JSON serialization with the "type" discriminator
Expand All @@ -51,7 +52,8 @@
@JsonSubTypes.Type(value = VideoBlock.class, name = "video"),
@JsonSubTypes.Type(value = ToolUseBlock.class, name = "tool_use"),
@JsonSubTypes.Type(value = ToolResultBlock.class, name = "tool_result"),
@JsonSubTypes.Type(value = HintBlock.class, name = "hint")
@JsonSubTypes.Type(value = HintBlock.class, name = "hint"),
@JsonSubTypes.Type(value = DataBlock.class, name = "data")
})
public sealed class ContentBlock implements State
permits TextBlock,
Expand All @@ -61,4 +63,5 @@ public sealed class ContentBlock implements State
ThinkingBlock,
ToolUseBlock,
ToolResultBlock,
HintBlock {}
HintBlock,
DataBlock {}
Loading
Loading