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
12 changes: 10 additions & 2 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -2550,7 +2550,11 @@ private Flux<AgentEvent> modelCallStream(
() -> {
List<AgentEvent> events = new ArrayList<>();
blockLifecycle.flushAll(events);
events.add(new ModelCallEndEvent(replyId, context.getChatUsage()));
events.add(
new ModelCallEndEvent(
replyId,
context.getChatUsage(),
context.getFinishReason()));
return Flux.fromIterable(events);
});

Expand Down Expand Up @@ -3612,7 +3616,11 @@ private Flux<AgentEvent> summaryModelCallStream(
() -> {
List<AgentEvent> events = new ArrayList<>();
blockLifecycle.flushAll(events);
events.add(new ModelCallEndEvent(replyId, context.getChatUsage()));
events.add(
new ModelCallEndEvent(
replyId,
context.getChatUsage(),
context.getFinishReason()));
return Flux.fromIterable(events);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
*/
public class ReasoningContext {

private static final String FINISH_REASON_LENGTH = "length";

private final String agentName;
private String messageId;

Expand All @@ -57,6 +59,7 @@ public class ReasoningContext {
private int outputTokens = 0;
private int cachedTokens = 0;
private double time = 0;
private String finishReason;

public ReasoningContext(String agentName) {
this.agentName = agentName;
Expand All @@ -78,6 +81,9 @@ public ReasoningContext(String agentName) {
*/
public List<Msg> processChunk(ChatResponse chunk) {
this.messageId = chunk.getId();
if (chunk.getFinishReason() != null && !chunk.getFinishReason().isBlank()) {
finishReason = chunk.getFinishReason();
}

// Accumulate ChatUsage
ChatUsage usage = chunk.getUsage();
Expand Down Expand Up @@ -159,6 +165,9 @@ public Msg buildFinalMessage() {

// Add all tool calls
List<ToolUseBlock> toolCalls = toolCallsAcc.buildAllToolCalls();
if (FINISH_REASON_LENGTH.equals(finishReason)) {
toolCalls = markLengthLimitedIncompleteToolCalls(toolCalls);
}
blocks.addAll(toolCalls);

// If no content at all, return null
Expand All @@ -179,6 +188,9 @@ public Msg buildFinalMessage() {
.build();
metadata.put(MessageMetadataKeys.CHAT_USAGE, chatUsage);
}
if (finishReason != null) {
metadata.put(MessageMetadataKeys.MODEL_FINISH_REASON, finishReason);
}

return AssistantMessage.builder()
.id(messageId)
Expand All @@ -189,6 +201,42 @@ public Msg buildFinalMessage() {
.build();
}

/**
* Marks tool calls whose argument stream was truncated when the model reached its output
* length limit.
*
* <p>The accumulator already preserves the legacy fallback of replacing invalid raw argument
* JSON with {@code {}}. That fallback must remain available for providers that do not report a
* finish reason, because invalid JSON alone does not prove that the response was truncated.
* A {@code length} finish reason is the additional evidence that lets the executor distinguish
* an incomplete response from a legacy malformed payload and safely avoid invoking the tool.
*
* <p>Only the affected calls are copied with the internal marker. Complete calls in the same
* response keep their original instance and remain executable.
*/
private List<ToolUseBlock> markLengthLimitedIncompleteToolCalls(List<ToolUseBlock> toolCalls) {
return toolCalls.stream()
.map(
toolCall -> {
if (!Boolean.TRUE.equals(
toolCall.getMetadata()
.get(ToolUseBlock.METADATA_RAW_CONTENT_INCOMPLETE))) {
return toolCall;
}
Map<String, Object> metadata = new HashMap<>(toolCall.getMetadata());
metadata.put(ToolUseBlock.METADATA_OUTPUT_LENGTH_LIMIT, true);
return ToolUseBlock.builder()
.id(toolCall.getId())
.name(toolCall.getName())
.input(toolCall.getInput())
.content(toolCall.getContent())
.metadata(metadata)
.state(toolCall.getState())
.build();
})
.toList();
}

/**
* Build a chunk message from a content block.
* @hidden
Expand Down Expand Up @@ -297,4 +345,11 @@ public ChatUsage getChatUsage() {
}
return null;
}

/**
* Returns the last non-blank finish reason reported during the current model response.
*/
public String getFinishReason() {
return finishReason;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ void merge(ToolUseBlock block) {
ToolUseBlock build() {
Map<String, Object> finalArgs = new HashMap<>(args);
String rawContentStr = this.rawContent.toString();
boolean rawContentIncomplete =
!rawContentStr.isEmpty() && !JsonUtils.isValidJsonObject(rawContentStr);

// Always attempt to parse the fully accumulated raw JSON. Early stream chunks may
// look like complete objects ({...}) but still contain null/incomplete values;
Expand Down Expand Up @@ -124,18 +126,25 @@ ToolUseBlock build() {
String contentStr;
if (rawContentStr.isEmpty()) {
contentStr = "{}";
} else if (JsonUtils.isValidJsonObject(rawContentStr)) {
} else if (!rawContentIncomplete) {
contentStr = rawContentStr;
} else {
contentStr = "{}";
}

Map<String, Object> finalMetadata = new HashMap<>(metadata);
if (rawContentIncomplete) {
// Keep the legacy content fallback while preserving enough provenance for the
// response-level finish reason to make a safe execution decision later.
finalMetadata.put(ToolUseBlock.METADATA_RAW_CONTENT_INCOMPLETE, true);
}

return ToolUseBlock.builder()
.id(toolId != null ? toolId : generateId())
.name(name)
.input(finalArgs)
.content(contentStr)
.metadata(metadata.isEmpty() ? null : metadata)
.metadata(finalMetadata.isEmpty() ? null : finalMetadata)
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,29 @@ public class ModelCallEndEvent extends AgentEvent {

private final String replyId;
private final ChatUsage usage;
private final String finishReason;

@JsonCreator
public ModelCallEndEvent(
@JsonProperty("id") String id,
@JsonProperty("createdAt") String createdAt,
@JsonProperty("replyId") String replyId,
@JsonProperty("usage") ChatUsage usage) {
@JsonProperty("usage") ChatUsage usage,
@JsonProperty("finishReason") String finishReason) {
super(id, createdAt);
this.replyId = replyId;
this.usage = usage;
this.finishReason = finishReason;
}

public ModelCallEndEvent(String replyId, ChatUsage usage) {
this(replyId, usage, null);
}

public ModelCallEndEvent(String replyId, ChatUsage usage, String finishReason) {
this.replyId = replyId;
this.usage = usage;
this.finishReason = finishReason;
}

@Override
Expand All @@ -55,4 +63,11 @@ public String getReplyId() {
public ChatUsage getUsage() {
return usage;
}

/**
* Returns the finish reason reported by the model provider, when available.
*/
public String getFinishReason() {
return finishReason;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ private MessageMetadataKeys() {
*/
public static final String CHAT_USAGE = "_chat_usage";

/**
* Metadata key for the final finish reason reported by the model provider.
*
* <p>Type: {@code String}. This value is optional because some providers do not expose a
* finish reason for streaming responses.
*/
public static final String MODEL_FINISH_REASON = "_model_finish_reason";

/**
* Metadata key for structured output data.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ public final class ToolUseBlock extends ContentBlock {
/** Metadata key for Gemini thought signature (byte[] value). */
public static final String METADATA_THOUGHT_SIGNATURE = "thoughtSignature";

/** Metadata key indicating a non-empty streaming argument payload was not valid JSON. */
public static final String METADATA_RAW_CONTENT_INCOMPLETE = "_raw_content_incomplete";

/** Metadata key indicating a length-limited model response left tool arguments incomplete. */
public static final String METADATA_OUTPUT_LENGTH_LIMIT = "_output_length_limit";

private final String id;
private final String name;
private final Map<String, Object> input;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,28 @@ Mono<ToolResultBlock> execute(ToolCallParam param) {
*/
private Mono<ToolResultBlock> executeCore(ToolCallParam param) {
ToolUseBlock toolCall = param.getToolUseBlock();

// A model may emit a syntactically partial tool call before reporting "length". The
// accumulator intentionally keeps the historical "{}" fallback for compatibility, so
// schema validation alone cannot distinguish this case from a genuine empty argument
// object. The paired metadata markers provide that evidence and ensure that no tool with
// potentially destructive side effects is invoked from a truncated argument stream. The
// error result is returned through the normal execution path, which preserves the tool
// call ID and prevents the ReAct state from retaining an unmatched pending tool call.
if (Boolean.TRUE.equals(
toolCall.getMetadata().get(ToolUseBlock.METADATA_OUTPUT_LENGTH_LIMIT))) {
String errorMsg =
"Tool arguments were incomplete because the model output reached its length"
+ " limit. The tool was not executed. Do not retry this call unchanged."
+ " Reduce the argument payload and complete the work with multiple"
+ " smaller calls or an available incremental update tool.";
logger.warn(
"Blocked incomplete length-limited tool call: name={}, id={}",
toolCall.getName(),
toolCall.getId());
return Mono.just(ToolResultBlock.error(errorMsg));
}

AgentTool tool = toolRegistry.getTool(toolCall.getName());

if (tool == null) {
Expand Down
Loading
Loading